mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
A plan that still carried a `room_drafts` key while already on the current wall model could not be edited at all. The card mirrors the same migration, so a structural edit was refused before the request ever left the browser; the toast sent the user to "Optimize plans", which reports that everything is already optimal because it looks at something else entirely; and the export path calls the same migration, so the one way out — take a backup, fix the file by hand — was shut too. An empty `room_drafts: []`, carrying no data at all, was enough to do it. The carrier is now removed the way the first migration removes it: an empty key silently, drafts converted one for one into partitions. The #478 protection against a stale client re-adding the carrier moves to the layer that can actually tell the two apart — `validate_wall_model_transition` sees both the submission and the stored plan, and refuses when the drafts appear over a plan that does not have them. It no longer keys on the submitted model number: a stale card echoes back the number it was given, which is exactly how the outdated client slipped past this guard and met "conflicting wall identifiers" instead of "update the card and reload the page". The schema invariant keeps refusing a non-empty carrier as the last line. Both mirrors change together and stay identical; the parity fixture is untouched. Issue: #529 User-Visible: yes
785 lines
34 KiB
Python
785 lines
34 KiB
Python
"""Deterministic persisted contour-wall identity (model v10, issues #282/#478).
|
|
|
|
This is the backend twin of ``src/wall-segment-model.ts``. Import preview is
|
|
a server-side structural writer, so it cannot depend on a browser being open
|
|
to upgrade v7 backups before validating/remapping them.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import copy
|
|
import hashlib
|
|
import math
|
|
import re
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from .coordinate_canonicalization import canonicalize_config_geometry
|
|
|
|
WALL_SEGMENT_MODEL_VERSION = 10
|
|
GRID_STEP_N = 1 / 240
|
|
EPS = 1e-9
|
|
LEGACY_DRAFT_POINT_EPSILON = 0.001
|
|
_LEGACY_NUMBER = re.compile(r"^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$")
|
|
|
|
|
|
class WallSegmentMigrationError(ValueError):
|
|
"""The candidate cannot be upgraded without guessing ownership."""
|
|
|
|
code = "wall_model_migration_blocked"
|
|
|
|
def __init__(self, reason: str, detail: str = "") -> None:
|
|
self.reason = reason
|
|
super().__init__(f"{reason}: {detail}" if detail else reason)
|
|
|
|
|
|
def _legacy_finite_number(value: Any) -> float | None:
|
|
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
|
return None
|
|
if isinstance(value, str) and not _LEGACY_NUMBER.fullmatch(value.strip()):
|
|
return None
|
|
try:
|
|
parsed = float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return parsed if math.isfinite(parsed) else None
|
|
|
|
|
|
def _legacy_draft_point(value: Any) -> list[float] | None:
|
|
if not isinstance(value, list) or len(value) < 2:
|
|
return None
|
|
x, y = _legacy_finite_number(value[0]), _legacy_finite_number(value[1])
|
|
return None if x is None or y is None else [x, y]
|
|
|
|
|
|
def _legacy_identity(value: Any) -> str:
|
|
return value if isinstance(value, str) else ""
|
|
|
|
|
|
def _point_key(point: list[float]) -> str:
|
|
return f"{float(point[0]):.12f},{float(point[1]):.12f}"
|
|
|
|
|
|
def _span_key(a: list[float], b: list[float]) -> str:
|
|
ka, kb = _point_key(a), _point_key(b)
|
|
return f"{ka}|{kb}" if ka < kb else f"{kb}|{ka}"
|
|
|
|
|
|
def _canonical_span(a: list[float], b: list[float]) -> tuple[list[float], list[float]]:
|
|
left, right = ([float(a[0]), float(a[1])], [float(b[0]), float(b[1])])
|
|
return (left, right) if _point_key(left) <= _point_key(right) else (right, left)
|
|
|
|
|
|
def _length(a: list[float], b: list[float]) -> float:
|
|
return math.hypot(float(b[0]) - float(a[0]), float(b[1]) - float(a[1]))
|
|
|
|
|
|
def _project_t(point: list[float], a: list[float], b: list[float]) -> float:
|
|
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
|
|
denominator = dx * dx + dy * dy
|
|
if denominator <= EPS * EPS:
|
|
return 0
|
|
return ((float(point[0]) - float(a[0])) * dx
|
|
+ (float(point[1]) - float(a[1])) * dy) / denominator
|
|
|
|
|
|
def _distance_to_segment(point: list[float], a: list[float], b: list[float]) -> float:
|
|
t = max(0.0, min(1.0, _project_t(point, a, b)))
|
|
return math.hypot(
|
|
float(point[0]) - (float(a[0]) + (float(b[0]) - float(a[0])) * t),
|
|
float(point[1]) - (float(a[1]) + (float(b[1]) - float(a[1])) * t),
|
|
)
|
|
|
|
|
|
def _collinear_overlap(
|
|
a: list[float], b: list[float], c: list[float], d: list[float], epsilon: float = EPS,
|
|
) -> float:
|
|
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
|
|
length = math.hypot(dx, dy)
|
|
if length <= epsilon:
|
|
return 0
|
|
cross_c = abs((float(c[0]) - float(a[0])) * dy
|
|
- (float(c[1]) - float(a[1])) * dx) / length
|
|
cross_d = abs((float(d[0]) - float(a[0])) * dy
|
|
- (float(d[1]) - float(a[1])) * dx) / length
|
|
if cross_c > epsilon or cross_d > epsilon:
|
|
return 0
|
|
tc, td = _project_t(c, a, b), _project_t(d, a, b)
|
|
return max(0.0, min(1.0, max(tc, td)) - max(0.0, min(tc, td))) * length
|
|
|
|
|
|
def _room_poly(room: dict[str, Any]) -> list[list[float]]:
|
|
poly = room.get("poly")
|
|
if isinstance(poly, list) and len(poly) >= 3:
|
|
return [[float(point[0]), float(point[1])] for point in poly]
|
|
if all(isinstance(room.get(key), (int, float)) and not isinstance(room.get(key), bool)
|
|
for key in ("x", "y", "w", "h")):
|
|
x, y, width, height = (float(room[key]) for key in ("x", "y", "w", "h"))
|
|
return [[x, y], [x + width, y], [x + width, y + height], [x, y + height]]
|
|
return []
|
|
|
|
|
|
def _shared_boundaries(first: list[list[float]], second: list[list[float]]) -> list[list[float]]:
|
|
result: list[list[float]] = []
|
|
epsilon = GRID_STEP_N * 0.04
|
|
for index, start in enumerate(first):
|
|
end = first[(index + 1) % len(first)]
|
|
dx, dy = end[0] - start[0], end[1] - start[1]
|
|
length = math.hypot(dx, dy)
|
|
if length < epsilon:
|
|
continue
|
|
ux, uy = dx / length, dy / length
|
|
for other_index, other_start in enumerate(second):
|
|
other_end = second[(other_index + 1) % len(second)]
|
|
tolerance = max(epsilon, length * 1e-6)
|
|
distances = [
|
|
abs((point[0] - start[0]) * uy - (point[1] - start[1]) * ux)
|
|
for point in (other_start, other_end)
|
|
]
|
|
if max(distances) > tolerance:
|
|
continue
|
|
t1 = (other_start[0] - start[0]) * ux + (other_start[1] - start[1]) * uy
|
|
t2 = (other_end[0] - start[0]) * ux + (other_end[1] - start[1]) * uy
|
|
lo, hi = max(0.0, min(t1, t2)), min(length, max(t1, t2))
|
|
if hi - lo > epsilon:
|
|
result.append([
|
|
start[0] + ux * lo, start[1] + uy * lo,
|
|
start[0] + ux * hi, start[1] + uy * hi,
|
|
])
|
|
return result
|
|
|
|
|
|
def _wall_direction(a: list[float], b: list[float]) -> tuple[float, float]:
|
|
dx, dy = b[0] - a[0], b[1] - a[1]
|
|
length = math.hypot(dx, dy)
|
|
if length < 1e-12:
|
|
return 1.0, 0.0
|
|
dx, dy = dx / length, dy / length
|
|
if dx < -1e-12 or (abs(dx) <= 1e-12 and dy < 0):
|
|
dx, dy = -dx, -dy
|
|
return dx, dy
|
|
|
|
|
|
def _js_round(value: float) -> int:
|
|
return math.floor(value + 0.5)
|
|
|
|
|
|
def _quantize(value: float, pitch: float = GRID_STEP_N) -> float:
|
|
return _js_round(value / pitch) * pitch
|
|
|
|
|
|
def _wall_key(a: list[float], b: list[float]) -> str:
|
|
midpoint_x = _quantize((float(a[0]) + float(b[0])) / 2)
|
|
midpoint_y = _quantize((float(a[1]) + float(b[1])) / 2)
|
|
dx, dy = _wall_direction(a, b)
|
|
angle = math.atan2(dy, dx)
|
|
if angle < 0:
|
|
angle += math.pi
|
|
angle = _js_round(angle * 1800) / 1800
|
|
return f"{midpoint_x:.6f},{midpoint_y:.6f}@{angle:.4f}"
|
|
|
|
|
|
def deterministic_wall_segment_id(
|
|
space_id: str, a: list[float], b: list[float], owners: list[str], salt: str = "",
|
|
) -> str:
|
|
ca, cb = _canonical_span(a, b)
|
|
seed = (f"{space_id}|{_point_key(ca)}|{_point_key(cb)}|"
|
|
f"{','.join(sorted(owners))}{salt}")
|
|
encoded = base64.b32encode(hashlib.sha256(seed.encode()).digest()).decode().lower()
|
|
return f"wall-{encoded[:20]}"
|
|
|
|
|
|
def _translated_segment_delta(
|
|
a: list[float], b: list[float], previous: dict[str, Any],
|
|
) -> tuple[float, float] | None:
|
|
def matches(pa: list[float], pb: list[float]) -> tuple[float, float] | None:
|
|
dx, dy = a[0] - pa[0], a[1] - pa[1]
|
|
if abs((b[0] - pb[0]) - dx) <= EPS and abs((b[1] - pb[1]) - dy) <= EPS:
|
|
return dx, dy
|
|
return None
|
|
|
|
return matches(previous["a"], previous["b"]) or matches(previous["b"], previous["a"])
|
|
|
|
|
|
def _atomize(
|
|
space: dict[str, Any], old: dict[str, dict[str, Any]],
|
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
rooms = space.get("rooms") or []
|
|
room_polys = {str(room.get("id", "")): _room_poly(room) for room in rooms}
|
|
global_breaks: list[list[float]] = []
|
|
explicit_spans = space.get("open_spans") or []
|
|
legacy_segments: list[list[float]] = []
|
|
for span in explicit_spans:
|
|
if isinstance(span, dict) and isinstance(span.get("a"), list) and isinstance(span.get("b"), list):
|
|
global_breaks.extend((span["a"], span["b"]))
|
|
legacy_segments.append([
|
|
float(span["a"][0]), float(span["a"][1]),
|
|
float(span["b"][0]), float(span["b"][1]),
|
|
])
|
|
if not legacy_segments:
|
|
def linked(first: dict, second: dict) -> bool: # #42 E731
|
|
return (
|
|
str(second.get("id", "")) in (first.get("open_to") or [])
|
|
or str(first.get("id", "")) in (second.get("open_to") or [])
|
|
)
|
|
for first_index, first in enumerate(rooms):
|
|
for second in rooms[first_index + 1:]:
|
|
if not linked(first, second):
|
|
continue
|
|
for shared in _shared_boundaries(
|
|
room_polys.get(str(first.get("id", "")), []),
|
|
room_polys.get(str(second.get("id", "")), []),
|
|
):
|
|
legacy_segments.append(shared)
|
|
global_breaks.extend(([shared[0], shared[1]], [shared[2], shared[3]]))
|
|
canonical_zero_segments: list[list[float]] = []
|
|
for room in rooms:
|
|
poly = room_polys.get(str(room.get("id", ""))) or []
|
|
wall_ids = room.get("wall_ids") or []
|
|
if len(wall_ids) != len(poly):
|
|
continue
|
|
for index, segment_id in enumerate(wall_ids):
|
|
previous = old.get(str(segment_id)) if isinstance(segment_id, str) else None
|
|
if previous is None or float(previous.get("cm", 0)) != 0:
|
|
continue
|
|
following = (index + 1) % len(poly)
|
|
canonical_zero_segments.append([
|
|
float(poly[index][0]), float(poly[index][1]),
|
|
float(poly[following][0]), float(poly[following][1]),
|
|
])
|
|
for segment in canonical_zero_segments:
|
|
global_breaks.extend((segment[:2], segment[2:]))
|
|
|
|
# #316 §3.1: a legacy open_spans/open_to cut never zeroes the atom that
|
|
# carries an existing contour opening; its edges become atom boundaries so
|
|
# the zero run continues on both sides of the opening.
|
|
legacy_era_openings: list[dict[str, Any]] = []
|
|
if legacy_segments:
|
|
for opening in space.get("openings") or []:
|
|
if not isinstance(opening, dict):
|
|
continue
|
|
host = opening.get("host")
|
|
if isinstance(host, dict) and host.get("kind") == "partition":
|
|
continue
|
|
try:
|
|
centre = [float(opening["x"]), float(opening["y"])]
|
|
angle = float(opening["angle"])
|
|
half = float(opening["length"]) / 2
|
|
except (KeyError, TypeError, ValueError):
|
|
continue
|
|
if half <= 0:
|
|
continue
|
|
# Only an opening that actually stands on a legacy cut changes the
|
|
# atomization; unrelated openings must not churn the catalogue.
|
|
if not any(
|
|
_distance_to_segment(centre, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
|
|
for cut in legacy_segments
|
|
):
|
|
continue
|
|
legacy_era_openings.append(
|
|
{"centre": centre, "angle": angle, "half": half}
|
|
)
|
|
direction = (math.cos(math.radians(angle)), math.sin(math.radians(angle)))
|
|
global_breaks.extend((
|
|
[centre[0] - direction[0] * half, centre[1] - direction[1] * half],
|
|
[centre[0] + direction[0] * half, centre[1] + direction[1] * half],
|
|
))
|
|
|
|
def _atom_carries_opening(a: list[float], b: list[float]) -> bool:
|
|
for entry in legacy_era_openings:
|
|
if not _angle_matches(a, b, entry["angle"]):
|
|
continue
|
|
if _distance_to_segment(entry["centre"], a, b) > GRID_STEP_N * 0.02:
|
|
continue
|
|
span = _length(a, b)
|
|
tc = _project_t(entry["centre"], a, b) * span
|
|
if tc + entry["half"] >= -EPS and tc - entry["half"] <= span + EPS:
|
|
return True
|
|
return False
|
|
|
|
for wall in space.get("walls") or []:
|
|
if isinstance(wall, dict) and isinstance(wall.get("a"), list) and isinstance(wall.get("b"), list):
|
|
global_breaks.extend((wall["a"], wall["b"]))
|
|
|
|
atoms_by_key: dict[str, dict[str, Any]] = {}
|
|
next_rooms: list[dict[str, Any]] = []
|
|
epsilon = GRID_STEP_N * 0.04
|
|
for raw_room in rooms:
|
|
room_id = str(raw_room.get("id", ""))
|
|
original = room_polys.get(room_id) or []
|
|
if not room_id or len(original) < 3:
|
|
raise WallSegmentMigrationError("invalid-room", room_id)
|
|
breaks = list(global_breaks)
|
|
for other_id, other_poly in room_polys.items():
|
|
if other_id == room_id:
|
|
continue
|
|
for shared in _shared_boundaries(original, other_poly):
|
|
breaks.extend(([shared[0], shared[1]], [shared[2], shared[3]]))
|
|
|
|
poly: list[list[float]] = []
|
|
parents: list[int] = []
|
|
for index, a in enumerate(original):
|
|
b = original[(index + 1) % len(original)]
|
|
poly.append(list(a))
|
|
parents.append(index)
|
|
length = _length(a, b)
|
|
if length < epsilon * 2:
|
|
raise WallSegmentMigrationError("zero-length", room_id)
|
|
gap = min(0.499, epsilon * 2 / length)
|
|
positions: list[float] = []
|
|
for breakpoint in breaks:
|
|
if _distance_to_segment(breakpoint, a, b) > epsilon:
|
|
continue
|
|
t = _project_t(breakpoint, a, b)
|
|
if t <= gap or t >= 1 - gap:
|
|
continue
|
|
if any(abs(existing - t) * length <= epsilon * 2 for existing in positions):
|
|
continue
|
|
positions.append(t)
|
|
for t in sorted(positions):
|
|
poly.append([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t])
|
|
parents.append(index)
|
|
|
|
old_ids = raw_room.get("wall_ids") if isinstance(raw_room.get("wall_ids"), list) else []
|
|
indexed_lineage = len(old_ids) == len(original)
|
|
rigid_delta: tuple[float, float] | None = None
|
|
rigid_indexed_lineage = indexed_lineage
|
|
if rigid_indexed_lineage:
|
|
for index, a in enumerate(original):
|
|
previous = old.get(old_ids[index])
|
|
delta = _translated_segment_delta(
|
|
a, original[(index + 1) % len(original)], previous,
|
|
) if previous else None
|
|
if delta is None or (rigid_delta is not None and (
|
|
abs(delta[0] - rigid_delta[0]) > EPS
|
|
or abs(delta[1] - rigid_delta[1]) > EPS
|
|
)):
|
|
rigid_indexed_lineage = False
|
|
break
|
|
rigid_delta = delta
|
|
wall_keys: list[str] = []
|
|
for index, a in enumerate(poly):
|
|
b = poly[(index + 1) % len(poly)]
|
|
key = _span_key(a, b)
|
|
atom = atoms_by_key.setdefault(key, {
|
|
"key": key, "a": _canonical_span(a, b)[0], "b": _canonical_span(a, b)[1],
|
|
"owners": set(), "preferred": set(), "positional": set(),
|
|
"preferred_carriers": {},
|
|
"parent_keys": set(), "zero_wall": False,
|
|
})
|
|
atom["owners"].add(room_id)
|
|
if len(atom["owners"]) > 2:
|
|
raise WallSegmentMigrationError("third-owner", key)
|
|
parent_index = parents[index]
|
|
atom["parent_keys"].add(_wall_key(original[parent_index], original[(parent_index + 1) % len(original)]))
|
|
midpoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
|
|
|
|
# #42 B023/E731: the loop variable is bound as a default — the
|
|
# closure is only called inside this iteration, but the binding
|
|
# makes that safety structural instead of incidental.
|
|
def covered_by(cuts: list[list[float]], *, midpoint: list[float] = midpoint) -> bool:
|
|
return any(
|
|
_distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
|
|
for cut in cuts
|
|
)
|
|
# Canonical cm:0 atoms stay zero; a LEGACY cut spares the atom
|
|
# that carries an opening (#316 §3.1).
|
|
atom["zero_wall"] = atom["zero_wall"] or covered_by(canonical_zero_segments) or (
|
|
covered_by(legacy_segments) and not _atom_carries_opening(a, b)
|
|
)
|
|
if indexed_lineage and isinstance(old_ids[parent_index], str) and old_ids[parent_index]:
|
|
previous = old.get(old_ids[parent_index])
|
|
if rigid_indexed_lineage or previous is None or _collinear_overlap(
|
|
a, b, previous["a"], previous["b"]
|
|
) > EPS:
|
|
atom["preferred"].add(old_ids[parent_index])
|
|
else:
|
|
atom["positional"].add(old_ids[parent_index])
|
|
atom["preferred_carriers"][old_ids[parent_index]] = {
|
|
"a": list(original[parent_index]),
|
|
"b": list(original[(parent_index + 1) % len(original)]),
|
|
}
|
|
wall_keys.append(key)
|
|
next_room = {key: copy.deepcopy(value) for key, value in raw_room.items() if key != "wall_ids"}
|
|
next_room["poly"] = poly
|
|
next_room["wall_ids"] = wall_keys
|
|
next_rooms.append(next_room)
|
|
return sorted(atoms_by_key.values(), key=lambda atom: atom["key"]), next_rooms
|
|
|
|
|
|
def _thickness(space: dict[str, Any], atom: dict[str, Any], previous: dict[str, Any] | None) -> float:
|
|
if atom.get("zero_wall"):
|
|
return 0.0
|
|
candidates: list[float] = []
|
|
query_key = _wall_key(atom["a"], atom["b"])
|
|
query_length = _length(atom["a"], atom["b"])
|
|
for wall in space.get("walls") or []:
|
|
try:
|
|
cm = float(wall.get("cm", 0))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if cm <= 0:
|
|
continue
|
|
exact_key = wall.get("key") == query_key or wall.get("key") in atom["parent_keys"]
|
|
covers = False
|
|
if isinstance(wall.get("a"), list) and isinstance(wall.get("b"), list):
|
|
overlap = _collinear_overlap(atom["a"], atom["b"], wall["a"], wall["b"])
|
|
covers = overlap >= query_length - EPS
|
|
if exact_key or covers:
|
|
candidates.append(max(1.0, min(100.0, cm)))
|
|
unique = {round(value, 9) for value in candidates}
|
|
if len(unique) > 1:
|
|
raise WallSegmentMigrationError("thickness-conflict", atom["key"])
|
|
if candidates:
|
|
return candidates[0]
|
|
if previous is not None and float(previous.get("cm", 0)) > 0:
|
|
return float(previous["cm"])
|
|
return 0.0
|
|
|
|
|
|
def _non_catalog_ids(space: dict[str, Any]) -> set[str]:
|
|
result: set[str] = set()
|
|
for name in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
|
|
for item in space.get(name) or []:
|
|
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]:
|
|
result.add(item["id"])
|
|
for draft in space.get("room_drafts") or []:
|
|
for segment in draft.get("segments") or []:
|
|
if isinstance(segment, dict) and isinstance(segment.get("id"), str) and segment["id"]:
|
|
result.add(segment["id"])
|
|
return result
|
|
|
|
|
|
def _fresh_wall_segment_id(used: set[str]) -> str:
|
|
for _attempt in range(1000):
|
|
segment_id = f"wall-{uuid.uuid4()}"
|
|
if segment_id not in used:
|
|
return segment_id
|
|
raise WallSegmentMigrationError("duplicate-id", "id factory exhausted")
|
|
|
|
|
|
def _assign_lineage(
|
|
space: dict[str, Any], atoms: list[dict[str, Any]], old: dict[str, dict],
|
|
initial_migration: bool,
|
|
) -> None:
|
|
old_by_key = {_span_key(segment["a"], segment["b"]): segment for segment in old.values()}
|
|
host_counts: dict[str, int] = {}
|
|
for opening in space.get("openings") or []:
|
|
host = opening.get("host") if isinstance(opening, dict) else None
|
|
if isinstance(host, dict) and host.get("kind") == "wall":
|
|
host_counts[str(host.get("id"))] = host_counts.get(str(host.get("id")), 0) + 1
|
|
proposals: dict[str, dict[str, Any]] = {}
|
|
for atom in atoms:
|
|
if len(atom["preferred"]) > 1:
|
|
raise WallSegmentMigrationError("duplicate-id", ",".join(sorted(atom["preferred"])))
|
|
preferred_id = next(iter(atom["preferred"]), None)
|
|
preferred = old.get(preferred_id) if preferred_id else None
|
|
if preferred:
|
|
proposals[atom["key"]] = preferred
|
|
continue
|
|
carrier = atom["preferred_carriers"].get(preferred_id) if preferred_id else None
|
|
if preferred_id and carrier:
|
|
proposals[atom["key"]] = {
|
|
"id": preferred_id, "a": carrier["a"], "b": carrier["b"], "cm": 0,
|
|
}
|
|
continue
|
|
if atom["key"] in old_by_key:
|
|
proposals[atom["key"]] = old_by_key[atom["key"]]
|
|
continue
|
|
overlaps = [segment for segment in old.values()
|
|
if _collinear_overlap(atom["a"], atom["b"], segment["a"], segment["b"]) > EPS]
|
|
overlaps.sort(key=lambda segment: (
|
|
-host_counts.get(str(segment["id"]), 0),
|
|
-_length(segment["a"], segment["b"]), str(segment["id"]),
|
|
))
|
|
if overlaps:
|
|
proposals[atom["key"]] = overlaps[0]
|
|
elif len(atom["positional"]) == 1:
|
|
positional = old.get(next(iter(atom["positional"])))
|
|
if positional:
|
|
proposals[atom["key"]] = positional
|
|
|
|
by_id: dict[str, list[dict[str, Any]]] = {}
|
|
for atom in atoms:
|
|
proposal = proposals.get(atom["key"])
|
|
if proposal:
|
|
by_id.setdefault(str(proposal["id"]), []).append(atom)
|
|
for segment_id, candidates in by_id.items():
|
|
old_segment = old.get(segment_id) or proposals.get(candidates[0]["key"])
|
|
if old_segment is None:
|
|
raise WallSegmentMigrationError("duplicate-id", segment_id)
|
|
midpoint = [
|
|
(old_segment["a"][0] + old_segment["b"][0]) / 2,
|
|
(old_segment["a"][1] + old_segment["b"][1]) / 2,
|
|
]
|
|
candidates.sort(key=lambda atom: (
|
|
0 if _distance_to_segment(midpoint, atom["a"], atom["b"]) <= EPS else 1,
|
|
0 if _distance_to_segment(old_segment["a"], atom["a"], atom["b"]) <= EPS else 1,
|
|
atom["key"],
|
|
))
|
|
candidates[0]["id"] = segment_id
|
|
|
|
used = _non_catalog_ids(space)
|
|
for atom in atoms:
|
|
if not atom.get("id"):
|
|
continue
|
|
if atom["id"] in used:
|
|
raise WallSegmentMigrationError("duplicate-id", atom["id"])
|
|
used.add(atom["id"])
|
|
unassigned = [atom for atom in atoms if not atom.get("id")]
|
|
if initial_migration:
|
|
seeds: list[tuple[str, str, str, dict[str, Any]]] = []
|
|
for atom in unassigned:
|
|
ca, cb = _canonical_span(atom["a"], atom["b"])
|
|
seed = (f"{space.get('id', '')}|{_point_key(ca)}|{_point_key(cb)}|"
|
|
f"{','.join(sorted(atom['owners']))}")
|
|
digest = base64.b32encode(hashlib.sha256(seed.encode()).digest()).decode().lower()
|
|
seeds.append((digest, atom["key"], seed, atom))
|
|
full_digests: dict[str, str] = {}
|
|
for digest, _key, seed, atom in sorted(seeds):
|
|
if digest in full_digests and full_digests[digest] != seed:
|
|
raise WallSegmentMigrationError("duplicate-id", digest)
|
|
full_digests[digest] = seed
|
|
base = f"wall-{digest[:20]}"
|
|
suffix, segment_id = 1, base
|
|
while segment_id in used:
|
|
suffix += 1
|
|
segment_id = f"{base}-{suffix}"
|
|
atom["id"] = segment_id
|
|
used.add(segment_id)
|
|
else:
|
|
for atom in unassigned:
|
|
atom["id"] = _fresh_wall_segment_id(used)
|
|
used.add(atom["id"])
|
|
|
|
|
|
def _angle_matches(a: list[float], b: list[float], angle: float) -> bool:
|
|
dx, dy = _wall_direction(a, b)
|
|
wall_angle = math.degrees(math.atan2(dy, dx))
|
|
difference = abs((wall_angle - angle + 90) % 180 - 90)
|
|
return difference <= 8
|
|
|
|
|
|
def _host_openings(
|
|
space: dict[str, Any], segments: list[dict[str, Any]], initial_migration: bool,
|
|
) -> None:
|
|
for opening in space.get("openings") or []:
|
|
host = opening.get("host")
|
|
if isinstance(host, dict) and host.get("kind") == "partition":
|
|
continue
|
|
try:
|
|
centre = [float(opening["x"]), float(opening["y"])]
|
|
angle, half = float(opening["angle"]), float(opening["length"]) / 2
|
|
except (KeyError, TypeError, ValueError):
|
|
if initial_migration:
|
|
# #316 §3.4: the initial migration never throws over an opening.
|
|
opening.pop("host", None)
|
|
continue
|
|
raise WallSegmentMigrationError("opening-host", str(opening.get("id", ""))) from None
|
|
|
|
# #42 B023: loop variables are bound as defaults (see covered_by).
|
|
def eligible(
|
|
segment: dict[str, Any], *,
|
|
centre: list[float] = centre, angle: float = angle, half: float = half,
|
|
) -> bool:
|
|
if float(segment.get("cm", 0)) <= 0:
|
|
return False
|
|
t = _project_t(centre, segment["a"], segment["b"])
|
|
span = _length(segment["a"], segment["b"])
|
|
return (-EPS <= t <= 1 + EPS
|
|
and _distance_to_segment(centre, segment["a"], segment["b"])
|
|
<= GRID_STEP_N * 0.02
|
|
and _angle_matches(segment["a"], segment["b"], angle)
|
|
and half >= 0 and t * span - half >= -EPS
|
|
and t * span + half <= span + EPS)
|
|
|
|
def materialize(
|
|
carrier: dict[str, Any], *,
|
|
opening: dict[str, Any] = opening, centre: list[float] = centre,
|
|
) -> None:
|
|
opening["host"] = {
|
|
"kind": "wall", "id": carrier["id"],
|
|
"t": max(0.0, min(1.0, _project_t(centre, carrier["a"], carrier["b"]))),
|
|
}
|
|
|
|
current = None
|
|
if isinstance(host, dict) and host.get("kind") == "wall":
|
|
current = next((segment for segment in segments if segment["id"] == host.get("id")), None)
|
|
candidates = [current] if current is not None and eligible(current) else [
|
|
segment for segment in segments if eligible(segment)
|
|
]
|
|
if len(candidates) == 1:
|
|
materialize(candidates[0])
|
|
continue
|
|
# #316 §3.3: an unhosted opening is a valid degraded v9 state. A later
|
|
# write keeps it, may self-heal it, and never fails over it.
|
|
if host is None and not initial_migration:
|
|
continue
|
|
if not initial_migration:
|
|
raise WallSegmentMigrationError("opening-host", str(opening.get("id", "")))
|
|
|
|
def pick(
|
|
pool: list[dict[str, Any]], *,
|
|
current: dict[str, Any] | None = current, centre: list[float] = centre,
|
|
) -> dict[str, Any] | None:
|
|
if not pool:
|
|
return None
|
|
if current is not None and any(item is current for item in pool):
|
|
return current
|
|
return sorted(pool, key=lambda candidate: (
|
|
_distance_to_segment(centre, candidate["a"], candidate["b"]),
|
|
-float(candidate.get("cm", 0)),
|
|
str(candidate.get("id", "")),
|
|
))[0]
|
|
|
|
# #316 §3.2 tie-break. No distant fallback pool (CODE-REVIEW-316-r1
|
|
# H1): a host away from the opening's own x/y would violate the
|
|
# geometry-match invariant of CONFIG_SCHEMA and wedge the write on the
|
|
# schema layer; without an in-place carrier the opening goes straight
|
|
# to the unhosted degraded state.
|
|
carrier = pick([segment for segment in segments if eligible(segment)])
|
|
if carrier is not None:
|
|
materialize(carrier)
|
|
else:
|
|
opening.pop("host", None)
|
|
|
|
|
|
def _migrate_room_drafts_to_partitions(space: dict[str, Any]) -> tuple[int, int]:
|
|
if "room_drafts" not in space:
|
|
return 0, 0
|
|
drafts = space.get("room_drafts") or []
|
|
# #529: наследие снимается, а не запирает план. Отказ здесь стоил
|
|
# пользователю всего: карточка считает ту же миграцию своим зеркалом, так
|
|
# что структурная правка отбивалась ещё до отправки, «Optimize plans» на
|
|
# эту ветку не влияет, а экспорт зовёт эту же функцию — забрать бэкап и
|
|
# починить файл руками тоже было нельзя. Защита от устаревшего клиента
|
|
# (#478) переехала на свой слой: `validate_wall_model_transition` ловит
|
|
# заявку с черновиками поверх чистого сохранённого конфига и отвечает
|
|
# человеку «обновите карточку», а не загадкой про идентификаторы.
|
|
if not drafts:
|
|
space.pop("room_drafts", None)
|
|
return 0, 0
|
|
used = {
|
|
str(item["id"])
|
|
for name in ("rooms", "openings", "decor", "partitions",
|
|
"wall_columns", "wall_segments")
|
|
for item in space.get(name) or []
|
|
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
|
|
}
|
|
used.update(
|
|
str(draft["id"]) for draft in drafts
|
|
if isinstance(draft, dict) and isinstance(draft.get("id"), str) and draft["id"]
|
|
)
|
|
partitions = copy.deepcopy(space.get("partitions") or [])
|
|
converted = 0
|
|
for draft in drafts:
|
|
draft_identity = _legacy_identity(draft.get("id")) if isinstance(draft, dict) else ""
|
|
if not isinstance(draft, dict):
|
|
raise WallSegmentMigrationError("zero-length", draft_identity)
|
|
points = draft.get("points") if isinstance(draft.get("points"), list) else []
|
|
segments = draft.get("segments") if isinstance(draft.get("segments"), list) else []
|
|
if len(points) < 2 or len(segments) != len(points) - 1:
|
|
raise WallSegmentMigrationError("zero-length", draft_identity)
|
|
for index, segment in enumerate(segments):
|
|
a = _legacy_draft_point(points[index])
|
|
b = _legacy_draft_point(points[index + 1])
|
|
if a is None or b is None or (
|
|
abs(a[0] - b[0]) < LEGACY_DRAFT_POINT_EPSILON
|
|
and abs(a[1] - b[1]) < LEGACY_DRAFT_POINT_EPSILON
|
|
):
|
|
raise WallSegmentMigrationError("zero-length", draft_identity)
|
|
cm = _legacy_finite_number(segment.get("cm") if isinstance(segment, dict) else None)
|
|
if cm is None or cm < 0 or cm > 100:
|
|
raise WallSegmentMigrationError("thickness-conflict", draft_identity)
|
|
segment_id = segment.get("id") if isinstance(segment, dict) and isinstance(segment.get("id"), str) else ""
|
|
if not segment_id or segment_id in used:
|
|
base = deterministic_wall_segment_id(
|
|
_legacy_identity(space.get("id")), a, b,
|
|
[f"draft:{draft_identity}:{index}"],
|
|
)
|
|
segment_id = base
|
|
suffix = 2
|
|
while segment_id in used:
|
|
segment_id = f"{base}-{suffix}"
|
|
suffix += 1
|
|
used.add(segment_id)
|
|
partitions.append({"id": segment_id, "a": a, "b": b, "cm": cm})
|
|
converted += 1
|
|
if partitions:
|
|
space["partitions"] = partitions
|
|
else:
|
|
space.pop("partitions", None)
|
|
space.pop("room_drafts", None)
|
|
return len(drafts), converted
|
|
|
|
|
|
def _migrate_space(
|
|
space: dict[str, Any], initial_migration: bool,
|
|
) -> tuple[int, int, int]:
|
|
migrated_drafts, migrated_draft_segments = _migrate_room_drafts_to_partitions(space)
|
|
old: dict[str, dict[str, Any]] = {}
|
|
for segment in space.get("wall_segments") or []:
|
|
segment_id = str(segment.get("id", ""))
|
|
if not segment_id or segment_id in old:
|
|
raise WallSegmentMigrationError("duplicate-id", segment_id)
|
|
old[segment_id] = segment
|
|
atoms, rooms = _atomize(space, old)
|
|
_assign_lineage(space, atoms, old, initial_migration)
|
|
segments = []
|
|
for atom in atoms:
|
|
previous = old.get(atom["id"])
|
|
segment = copy.deepcopy(previous) if previous else {}
|
|
segment.update({
|
|
"id": atom["id"], "a": list(atom["a"]), "b": list(atom["b"]),
|
|
"cm": _thickness(space, atom, previous),
|
|
})
|
|
segments.append(segment)
|
|
id_by_key = {atom["key"]: atom["id"] for atom in atoms}
|
|
for room in rooms:
|
|
room["wall_ids"] = [id_by_key[key] for key in room["wall_ids"]]
|
|
space["rooms"] = rooms
|
|
space["wall_segments"] = segments
|
|
walls = [{
|
|
"key": _wall_key(segment["a"], segment["b"]),
|
|
"cm": segment["cm"], "a": list(segment["a"]), "b": list(segment["b"]),
|
|
} for segment in segments if float(segment["cm"]) > 0]
|
|
if walls:
|
|
space["walls"] = walls
|
|
else:
|
|
space.pop("walls", None)
|
|
space.pop("open_spans", None)
|
|
for room in space.get("rooms") or []:
|
|
room.pop("open_to", None)
|
|
_host_openings(space, segments, initial_migration)
|
|
return (
|
|
sum(1 for segment in segments if segment["id"] not in old),
|
|
migrated_drafts,
|
|
migrated_draft_segments,
|
|
)
|
|
|
|
|
|
def commit_wall_segment_model(
|
|
config: Any, *, migration_report: dict[str, int] | None = None,
|
|
) -> tuple[Any, int]:
|
|
"""Return one migrated deep copy and the count of newly assigned wall ids."""
|
|
candidate = canonicalize_config_geometry(copy.deepcopy(config))
|
|
if not isinstance(candidate, dict):
|
|
raise WallSegmentMigrationError("invalid-room")
|
|
migrated = 0
|
|
migrated_drafts = 0
|
|
migrated_draft_segments = 0
|
|
initial_migration = int(candidate.get("model_version", 0) or 0) < WALL_SEGMENT_MODEL_VERSION
|
|
for space in candidate.get("spaces") or []:
|
|
wall_segments, drafts, draft_segments = _migrate_space(space, initial_migration)
|
|
migrated += wall_segments
|
|
migrated_drafts += drafts
|
|
migrated_draft_segments += draft_segments
|
|
candidate["model_version"] = WALL_SEGMENT_MODEL_VERSION
|
|
if migration_report is not None:
|
|
migration_report.clear()
|
|
migration_report.update({
|
|
"room_drafts": migrated_drafts,
|
|
"room_draft_segments": migrated_draft_segments,
|
|
})
|
|
return canonicalize_config_geometry(candidate), migrated
|