From b336eee996148ec128a8ee9f19206e59b1a83c44 Mon Sep 17 00:00:00 2001 From: Matysh Date: Tue, 25 Aug 2026 22:32:27 +0300 Subject: [PATCH] feat: stabilize persisted wall segment identity Issue: #282 User-Visible: yes --- custom_components/houseplan/const.py | 2 +- .../houseplan/coordinate_canonicalization.py | 4 + custom_components/houseplan/import_export.py | 80 ++- custom_components/houseplan/validation.py | 293 +++++++++- .../houseplan/wall_segment_model.py | 504 +++++++++++++++++ custom_components/houseplan/websocket_api.py | 11 +- demo/smoke_draw_wall_thickness.mjs | 27 +- docs/ARCHITECTURE.md | 23 +- docs/CANVAS.md | 10 + docs/CHANGELOG.md | 7 +- docs/CHANGELOG.ru.md | 8 +- docs/CONFIG-COMPATIBILITY.md | 21 + docs/TESTING.md | 24 + docs/USER-GUIDE.md | 12 + docs/USER-GUIDE.ru.md | 12 + docs/WALL-THICKNESS.md | 26 + docs/adr/282-wall-geometry-representation.md | 2 +- .../specs/282-stable-wall-segment-identity.md | 2 +- package.json | 1 + scripts/benchmark-wall-segment-model.mjs | 48 ++ scripts/config-field-registry.mjs | 57 ++ scripts/model-invariants.mjs | 35 +- scripts/mutation-gate.mjs | 25 +- src/coordinate-canonicalization.ts | 8 + src/houseplan-card.ts | 238 +++++++- src/i18n/en.json | 10 + src/i18n/ru.json | 10 + src/logic.ts | 2 +- src/plan-geometry-preflight.ts | 1 + src/plan-optimizer.ts | 16 +- src/resize.ts | 42 +- src/room-deletion.ts | 2 +- src/space-geometry.ts | 6 +- src/types.ts | 26 +- src/wall-segment-model.ts | 514 ++++++++++++++++++ test/coincident-partitions.test.mjs | 6 +- .../280-optimize-rehost-candidate.json | 36 +- test/fixtures/281-resize-outer-candidate.json | 86 +-- test/fixtures/282-wall-identity-parity.json | 58 ++ test/optimize-hidden-obstacles.test.mjs | 18 +- test/plan-optimizer.test.mjs | 38 +- test/resize-availability-audit.test.mjs | 20 +- test/resize-optimize.test.mjs | 2 +- test/wall-segment-model.test.mjs | 205 +++++++ tests_backend/test_ha_import_export.py | 49 +- tests_backend/test_wall_segment_model.py | 168 ++++++ tsconfig.test.json | 1 + 47 files changed, 2638 insertions(+), 158 deletions(-) create mode 100644 custom_components/houseplan/wall_segment_model.py create mode 100644 scripts/benchmark-wall-segment-model.mjs create mode 100644 src/wall-segment-model.ts create mode 100644 test/fixtures/282-wall-identity-parity.json create mode 100644 test/wall-segment-model.test.mjs create mode 100644 tests_backend/test_wall_segment_model.py diff --git a/custom_components/houseplan/const.py b/custom_components/houseplan/const.py index b3417ab6..f0a98873 100755 --- a/custom_components/houseplan/const.py +++ b/custom_components/houseplan/const.py @@ -51,7 +51,7 @@ VERSION = "1.67.0-rc.3" # Portable backup format. This is deliberately independent from the Home # Assistant Store version above: storage migrations and files exported by a # user have different compatibility lifecycles. -PLAN_MODEL_VERSION = 7 +PLAN_MODEL_VERSION = 8 EXPORT_VERSION = 1 MAX_EXPORT_BYTES = 8 * 1024 * 1024 IMPORT_PREVIEW_TTL_S = 10 * 60 diff --git a/custom_components/houseplan/coordinate_canonicalization.py b/custom_components/houseplan/coordinate_canonicalization.py index d51a9b91..23f8159e 100644 --- a/custom_components/houseplan/coordinate_canonicalization.py +++ b/custom_components/houseplan/coordinate_canonicalization.py @@ -136,6 +136,10 @@ def canonicalize_config_geometry(config: Any) -> Any: _lattice_point(wall.get("a")) _lattice_point(wall.get("b")) + for segment in _records(space.get("wall_segments")): + _lattice_point(segment.get("a")) + _lattice_point(segment.get("b")) + for opening in _records(space.get("openings")): _lattice_fields(opening, ("x", "y")) _scalar_fields(opening, ("angle", "length")) diff --git a/custom_components/houseplan/import_export.py b/custom_components/houseplan/import_export.py index 7f22219b..1a92e5a4 100644 --- a/custom_components/houseplan/import_export.py +++ b/custom_components/houseplan/import_export.py @@ -36,6 +36,10 @@ from .const import ( VERSION, ) from .store import HouseplanData +from .wall_segment_model import ( + WallSegmentMigrationError, + commit_wall_segment_model, +) from .validation import ( CONFIG_SCHEMA, LAYOUT_SCHEMA, @@ -64,7 +68,8 @@ _LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$") _LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$") _PLAN_ONLY_DASH = "—" _IMPORT_ID_NAMESPACES = { - "space", "room", "marker", "partition", "opening", "decor", "draft", "column", + "space", "room", "marker", "partition", "wall", "opening", "decor", "draft", + "draft_segment", "column", } _MAX_IMPORT_LINEAGE_DEPTH = 16 _REPORT_EXAMPLE_LIMIT = 24 @@ -80,7 +85,7 @@ _SPACE_DISPLAY_FIELDS = ( "label_lqi", "label_light", "card_font_scale", "north_deg", "bg_mode", "sun_rays", ) -_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly") +_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly", "wall_ids") _ROOM_DISPLAY_FIELDS = ( "fill_mode", "custom_fill", "glow", "name_scale", "label_scale", ) @@ -249,6 +254,7 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]: ] collections: tuple[tuple[str, tuple[str, ...]], ...] = ( ("walls", ("key", "cm", "a", "b")), + ("wall_segments", ("id", "a", "b", "cm")), ("room_drafts", ("id", "points", "segments")), ("partitions", ("id", "a", "b", "cm")), ("wall_columns", ("id", "shape", "center", "cm", "angle")), @@ -262,7 +268,7 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]: selected = _pick_fields(item, fields) if name == "room_drafts" and "segments" in selected: selected["segments"] = [ - _pick_fields(segment, ("cm",)) + _pick_fields(segment, ("id", "cm")) for segment in selected.get("segments") or [] ] values.append(selected) @@ -584,7 +590,14 @@ def parse_document(raw: bytes) -> dict[str, Any]: if not isinstance(payload, dict): raise ImportFailure("invalid_format", "Missing payload") try: - config = CONFIG_SCHEMA(_json_copy(payload.get("config"))) + config_candidate = _json_copy(payload.get("config")) + # The envelope is authoritative. Export deliberately omits this field + # inside payload.config, but v8 semantic validation still has to run + # before an import token can be issued. + if model > 0: + config_candidate["model_version"] = model + config = CONFIG_SCHEMA(config_candidate) + config.pop("model_version", None) except (vol.Invalid, TypeError, ValueError) as err: raise ImportFailure("invalid_config", str(err)) from err if document["kind"] == "full": @@ -1154,7 +1167,8 @@ def build_space_merge( for sp in current_config.get("spaces") or [] for collection in ( (sp,), sp.get("rooms") or [], sp.get("room_drafts") or [], - sp.get("partitions") or [], sp.get("wall_columns") or [], + sp.get("partitions") or [], sp.get("wall_segments") or [], + sp.get("wall_columns") or [], sp.get("openings") or [], sp.get("decor") or [], ) for item in collection @@ -1164,13 +1178,19 @@ def build_space_merge( } | { str(m.get("id")) for m in current_config.get("markers") or [] if m.get("id") is not None + } | { + str(segment.get("id")) + for sp in current_config.get("spaces") or [] + for draft in sp.get("room_drafts") or [] + for segment in draft.get("segments") or [] + if isinstance(segment, dict) and segment.get("id") is not None } | { str(value) for incoming_space in spaces for collection in ( (incoming_space,), incoming_space.get("rooms") or [], incoming_space.get("room_drafts") or [], - incoming_space.get("partitions") or [], + incoming_space.get("partitions") or [], incoming_space.get("wall_segments") or [], incoming_space.get("wall_columns") or [], incoming_space.get("openings") or [], incoming_space.get("decor") or [], ) @@ -1181,6 +1201,12 @@ def build_space_merge( } | { str(m.get("id")) for m in incoming.get("markers") or [] if m.get("id") is not None + } | { + str(segment.get("id")) + for incoming_space in spaces + for draft in incoming_space.get("room_drafts") or [] + for segment in draft.get("segments") or [] + if isinstance(segment, dict) and segment.get("id") is not None } old_space_id = str(space.get("id")) _record_bounded_lineage( @@ -1194,6 +1220,7 @@ def build_space_merge( id_maps["space"][old_space_id] = new_space_id for collection, prefix in ( ("rooms", "room"), ("room_drafts", "draft"), ("partitions", "partition"), + ("wall_segments", "wall"), ("wall_columns", "column"), ("openings", "opening"), ("decor", "decor"), ): for item in space.get(collection) or []: @@ -1205,6 +1232,18 @@ def build_space_merge( id_map[old] = _fresh(prefix, old, used) id_maps[prefix][old] = id_map[old] item["id"] = id_map[old] + for draft in space.get("room_drafts") or []: + for segment in draft.get("segments") or []: + if not isinstance(segment, dict) or segment.get("id") is None: + continue + old = str(segment["id"]) + _record_bounded_lineage( + reference_report, bounded_seen, "draft_segment", old, + ) + new = _fresh("draft_segment", old, used) + id_map[old] = new + id_maps["draft_segment"][old] = new + segment["id"] = new old_room_ids = id_maps["room"] for room in space.get("rooms") or []: if room.get("open_to"): @@ -1219,13 +1258,18 @@ def build_space_merge( str(room.get("id", "?")), old_value, ) room["open_to"] = remapped_open_to + if room.get("wall_ids"): + room["wall_ids"] = [ + id_maps["wall"].get(str(value), str(value)) + for value in room["wall_ids"] + ] # Opening ownership is part of the same space-local id graph. Remap the # nested reference together with the partition itself; otherwise the # invariant validator correctly rejects a copied space whose host.id still # names the source partition. 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") == "partition": + if isinstance(host, dict) and host.get("kind") in ("partition", "wall"): old_host_id = str(host.get("id")) if old_host_id in id_map: host["id"] = id_map[old_host_id] @@ -1591,6 +1635,22 @@ def _materialize_import_candidate( imported_config = prepared["payload"]["config"] if confirmation_required: _detach_missing(imported_config, content) + incoming_model = int(prepared.get("model_version", 0) or 0) + target_model = int(current_config.get("model_version", 0) or 0) + requires_v8 = incoming_model >= 8 or target_model >= 8 + if requires_v8: + try: + # A v8 target cannot be downgraded by a v7 backup, while an + # incoming v8 graph must retain its already-persisted IDs. When + # both sides are v7 the accepted compatibility contract keeps the + # import v7 until a later structural write or explicit Optimize. + imported_config["model_version"] = incoming_model + imported_config, _ = commit_wall_segment_model(imported_config) + prepared["payload"]["config"] = imported_config + if prepared["kind"] == "space": + current_config, _ = commit_wall_segment_model(current_config) + except WallSegmentMigrationError as err: + raise ImportFailure(err.code, str(err)) from err if prepared["kind"] == "space": config, layout, details = build_space_merge( prepared, current_config, current_layout, duplicate_policy, @@ -1598,11 +1658,9 @@ def _materialize_import_candidate( ) else: config = imported_config + if not requires_v8 and incoming_model > 0: + config["model_version"] = incoming_model _materialize_global_background(config) - model_version = prepared.get("model_version", 0) - if isinstance(model_version, int) and not isinstance(model_version, bool) \ - and model_version > 0: - config["model_version"] = model_version layout = _json_copy(prepared["payload"]["layout"]) if not same_source: settings = config.get("settings") or {} diff --git a/custom_components/houseplan/validation.py b/custom_components/houseplan/validation.py index 6391a0fb..714da00c 100644 --- a/custom_components/houseplan/validation.py +++ b/custom_components/houseplan/validation.py @@ -5,6 +5,7 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed). from __future__ import annotations from collections import Counter +import copy import math import re @@ -77,6 +78,84 @@ class PartitionOpeningJambMarginError(ValueError): ) +class WallModelClientOutdatedError(ValueError): + """A stale client tried to replace an already-migrated v8 document.""" + + code = "wall_model_client_outdated" + + +def _legacy_wall_model_projection(config: dict) -> dict: + """Structural view an older client can faithfully round-trip.""" + projected = copy.deepcopy(config) + projected.pop("model_version", None) + for space in projected.get("spaces") or []: + space.pop("wall_segments", None) + for room in space.get("rooms") or []: + room.pop("wall_ids", None) + for draft in space.get("room_drafts") or []: + for segment in draft.get("segments") or []: + segment.pop("id", None) + for opening in space.get("openings") or []: + host = opening.get("host") + if isinstance(host, dict) and host.get("kind") == "wall": + opening.pop("host", None) + return projected + + +def _restore_wall_model_fields(config: dict, previous: dict) -> None: + """Hydrate only v8 identity fields after an exact legacy round-trip.""" + config["model_version"] = previous["model_version"] + old_spaces = {str(space.get("id")): space for space in previous.get("spaces") or []} + for space in config.get("spaces") or []: + old_space = old_spaces.get(str(space.get("id"))) + if old_space is None: + continue + space["wall_segments"] = copy.deepcopy(old_space.get("wall_segments") or []) + old_rooms = {str(room.get("id")): room for room in old_space.get("rooms") or []} + for room in space.get("rooms") or []: + old_room = old_rooms.get(str(room.get("id"))) + if old_room is not None and "wall_ids" in old_room: + room["wall_ids"] = copy.deepcopy(old_room["wall_ids"]) + old_drafts = {str(draft.get("id")): draft for draft in old_space.get("room_drafts") or []} + for draft in space.get("room_drafts") or []: + old_draft = old_drafts.get(str(draft.get("id"))) + if old_draft is None: + continue + old_segments = old_draft.get("segments") or [] + for index, segment in enumerate(draft.get("segments") or []): + if index < len(old_segments) and old_segments[index].get("id"): + segment["id"] = old_segments[index]["id"] + old_openings = { + str(opening.get("id")): opening for opening in old_space.get("openings") or [] + } + for opening in space.get("openings") or []: + old_opening = old_openings.get(str(opening.get("id"))) + old_host = (old_opening or {}).get("host") + if isinstance(old_host, dict) and old_host.get("kind") == "wall": + opening["host"] = copy.deepcopy(old_host) + + +def validate_wall_model_transition(config: dict, previous: dict | None) -> None: + """Do not let omission of model_version bypass v8 semantic validation.""" + try: + old_model = int((previous or {}).get("model_version", 0)) + new_model = int(config.get("model_version", 0)) + except (TypeError, ValueError): + return # CONFIG_SCHEMA owns malformed values. + if old_model >= 8 and new_model < 8: + if _legacy_wall_model_projection(config) == _legacy_wall_model_projection(previous or {}): + _restore_wall_model_fields(config, previous or {}) + # Re-run semantic parity after hydration; previous identity is + # accepted only when it still matches the submitted projections. + validated = CONFIG_SCHEMA(config) + config.clear() + config.update(validated) + return + raise WallModelClientOutdatedError( + f"stored model={old_model}; submitted model={new_model}" + ) + + # One normalized canvas width contains this many physical grid cells. Keep in # sync with GRID_STEP_N/NORM_W in the frontend; it is a geometry scale, not a # user setting. @@ -520,34 +599,39 @@ def validate_partition_opening_hosts( for opening in space.get("openings") or []: opening_id = str(opening.get("id", "")) old = old_openings.get(opening_id) - if old and old.get("host") is not None and opening.get("host") is None: - if not (allow_optimize_rehost and old_space - and _safe_optimize_partition_rehost( - space, old_space, opening, old - )): - raise PartitionOpeningHostError( - f"space={space_id}; opening={opening_id}; host removed" - ) + old_host = (old or {}).get("host") host = opening.get("host") + if (old and isinstance(old_host, dict) + and old_host.get("kind") == "partition"): + same_partition = ( + isinstance(host, dict) and host.get("kind") == "partition" + and str(host.get("id", "")) == str(old_host.get("id", "")) + ) + residual_rehost = ( + isinstance(host, dict) and host.get("kind") == "partition" + and not same_partition + ) + proved = same_partition or ( + allow_optimize_rehost and old_space is not None + and (_safe_optimize_residual_rehost( + space, old_space, opening, old + ) if residual_rehost else _safe_optimize_partition_rehost( + space, old_space, opening, old + )) + ) + if not proved: + raise PartitionOpeningHostError( + f"space={space_id}; opening={opening_id}; host changed" + ) if host is None: continue + if host.get("kind") != "partition": + continue partition = partitions.get(str(host.get("id", ""))) if partition is None: # SPACE_SCHEMA owns missing-host diagnostics. continue - old_host = (old or {}).get("host") old_partition = old_partitions.get(str((old_host or {}).get("id", ""))) - if (allow_optimize_rehost and old and old_space - and isinstance(old_host, dict) - and old_host.get("kind") == "partition" - and isinstance(host, dict) and host.get("kind") == "partition" - and old_host.get("id") != host.get("id") - and not _safe_optimize_residual_rehost( - space, old_space, opening, old - )): - raise PartitionOpeningHostError( - f"space={space_id}; opening={opening_id}; residual host changed" - ) ax, ay = partition["a"] bx, by = partition["b"] span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5 @@ -910,7 +994,11 @@ MAX_ROOMS = 400 MAX_MARKERS = 2000 MAX_OPENINGS = 500 MAX_DECOR = 1000 -MAX_WALLS = 500 +# v8 atomises room boundaries. The 2 MiB wire cap is the practical bound; this +# structural cap mirrors MAX_ROOMS * MAX_POLY_POINTS without depending on the +# later constant declaration. +MAX_WALLS = 200_000 +MAX_WALL_SEGMENTS = 200_000 MAX_ROOM_DRAFTS = 200 MAX_DRAFT_SEGMENTS = 2000 MAX_PARTITIONS = 2000 @@ -1075,6 +1163,10 @@ ROOM_SCHEMA = vol.All( vol.Optional("w"): _EXTENT, vol.Optional("h"): _EXTENT, vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)), + vol.Optional("wall_ids"): vol.All( + [vol.All(str, vol.Length(min=1, max=64))], + vol.Length(min=3, max=MAX_POLY_POINTS), + ), }, extra=vol.ALLOW_EXTRA, ), @@ -1246,6 +1338,26 @@ WALL_SCHEMA = vol.All( ) +def _wall_segment_nonzero(value: dict) -> dict: + if value["a"] == value["b"]: + raise vol.Invalid("wall segment endpoints must differ") + return value + + +WALL_SEGMENT_SCHEMA = vol.All( + vol.Schema( + { + vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)), + vol.Required("a"): POINT, + vol.Required("b"): POINT, + vol.Required("cm"): vol.All(_finite, vol.Range(min=0, max=100)), + }, + extra=vol.ALLOW_EXTRA, + ), + _wall_segment_nonzero, +) + + def _room_draft_segments(value: dict) -> dict: """An open draft has exactly one thickness per consecutive edge.""" if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1): @@ -1261,7 +1373,10 @@ ROOM_DRAFT_SCHEMA = vol.All( vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)), vol.Required("points"): vol.All([POINT], vol.Length(min=2, max=500)), vol.Required("segments"): vol.All( - [vol.Schema({vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100))}, + [vol.Schema({ + vol.Optional("id"): vol.All(str, vol.Length(min=1, max=64)), + vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)), + }, extra=vol.ALLOW_EXTRA)], vol.Length(min=1, max=499), ), @@ -1325,11 +1440,22 @@ PARTITION_OPENING_HOST_SCHEMA = vol.Schema( extra=vol.PREVENT_EXTRA, ) +WALL_OPENING_HOST_SCHEMA = vol.Schema( + { + vol.Required("kind"): vol.Equal("wall"), + vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)), + vol.Required("t"): vol.All(_finite, vol.Range(min=0, max=1)), + }, + extra=vol.PREVENT_EXTRA, +) + +OPENING_HOST_SCHEMA = vol.Any(PARTITION_OPENING_HOST_SCHEMA, WALL_OPENING_HOST_SCHEMA) + def _space_geometry_invariants(value: dict) -> dict: """All stored geometry shares ids; draft segments also have a space cap.""" seen: set[str] = set() - for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"): + for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns", "wall_segments"): for item in value.get(key, []): item_id = item.get("id") if not item_id: @@ -1342,6 +1468,14 @@ def _space_geometry_invariants(value: dict) -> dict: ) if draft_segments > MAX_DRAFT_SEGMENTS: raise vol.Invalid("too many saved room-draft segments") + for draft in value.get("room_drafts", []): + for segment in draft.get("segments", []): + segment_id = segment.get("id") + if not segment_id: + continue + if segment_id in seen: + raise vol.Invalid("geometry object ids must be unique within a space") + seen.add(segment_id) partitions = { item.get("id"): item for item in value.get("partitions", []) if item.get("id") } @@ -1350,6 +1484,8 @@ def _space_geometry_invariants(value: dict) -> dict: host = opening.get("host") if host is None: continue + if host["kind"] != "partition": + continue partition = partitions.get(host["id"]) if partition is None: raise vol.Invalid("partition opening host must exist in the same space") @@ -1430,7 +1566,7 @@ SPACE_SCHEMA = vol.All(vol.Schema( vol.Optional("invert"): bool, vol.Optional("flip_h"): bool, vol.Optional("flip_v"): bool, - vol.Optional("host"): PARTITION_OPENING_HOST_SCHEMA, + vol.Optional("host"): OPENING_HOST_SCHEMA, }, extra=vol.ALLOW_EXTRA, ) @@ -1439,6 +1575,9 @@ SPACE_SCHEMA = vol.All(vol.Schema( # (midpoint + direction), thickness always in centimetres. Optional — # a space without `walls` validates and renders exactly as before. vol.Optional("walls"): vol.All([WALL_SCHEMA], vol.Length(max=MAX_WALLS)), + vol.Optional("wall_segments"): vol.All( + [WALL_SEGMENT_SCHEMA], vol.Length(max=MAX_WALL_SEGMENTS) + ), vol.Optional("room_drafts"): vol.All( [ROOM_DRAFT_SCHEMA], vol.Length(max=MAX_ROOM_DRAFTS) ), @@ -1567,9 +1706,114 @@ MARKER_SCHEMA = vol.Schema( }, extra=vol.ALLOW_EXTRA, ) + + +def _canonical_segment_key(a: list, b: list) -> tuple: + """Endpoint-order-independent exact key after the shared canonicalizer.""" + pa = (round(float(a[0]), 12), round(float(a[1]), 12)) + pb = (round(float(b[0]), 12), round(float(b[1]), 12)) + return (pa, pb) if pa <= pb else (pb, pa) + + +def _config_wall_segment_invariants(value: dict) -> dict: + """Fail closed when a v8 writer sends stale ids or a stale walls projection.""" + try: + model = int(value.get("model_version", 0)) + except (TypeError, ValueError): + raise vol.Invalid("model_version must be an integer") from None + if model < 8: + return value + + for space in value.get("spaces", []): + segments = space.get("wall_segments") + if segments is None: + raise vol.Invalid("v8 space requires wall_segments") + by_id = {segment["id"]: segment for segment in segments} + if len(by_id) != len(segments): + raise vol.Invalid("wall segment ids must be unique") + + owners: dict[str, set[str]] = {segment_id: set() for segment_id in by_id} + for room in space.get("rooms", []): + poly = room.get("poly") + wall_ids = room.get("wall_ids") + if not poly or not wall_ids or len(poly) != len(wall_ids): + raise vol.Invalid("v8 room wall_ids must match poly edges") + room_id = room["id"] + for index, segment_id in enumerate(wall_ids): + segment = by_id.get(segment_id) + if segment is None: + raise vol.Invalid("room wall id must reference the same space") + edge_key = _canonical_segment_key(poly[index], poly[(index + 1) % len(poly)]) + if edge_key != _canonical_segment_key(segment["a"], segment["b"]): + raise vol.Invalid("room wall reference geometry must match its edge") + owners[segment_id].add(room_id) + if any(len(room_ids) not in (1, 2) for room_ids in owners.values()): + raise vol.Invalid("wall segment must have one or two room owners") + + expected_walls = { + _canonical_segment_key(segment["a"], segment["b"]): float(segment["cm"]) + for segment in segments if float(segment["cm"]) > 0 + } + actual_walls: dict[tuple, float] = {} + for wall in space.get("walls", []): + if "a" not in wall or "b" not in wall: + raise vol.Invalid("v8 compatibility walls require exact endpoints") + key = _canonical_segment_key(wall["a"], wall["b"]) + if key in actual_walls: + raise vol.Invalid("v8 compatibility walls must be unique") + actual_walls[key] = float(wall["cm"]) + if actual_walls != expected_walls: + raise vol.Invalid("v8 compatibility walls must match wall_segments") + + for draft in space.get("room_drafts", []): + if any(not segment.get("id") for segment in draft.get("segments", [])): + raise vol.Invalid("v8 draft wall segments require ids") + + partitions = {item["id"]: item for item in space.get("partitions", [])} + wall_opening_intervals: dict[str, list[tuple[float, float]]] = {} + for opening in space.get("openings", []): + host = opening.get("host") + if host is None: + raise vol.Invalid("v8 opening requires an explicit host") + if host["kind"] == "partition": + if host["id"] not in partitions: + raise vol.Invalid("partition opening host must exist in the same space") + continue + segment = by_id.get(host["id"]) + if segment is None: + raise vol.Invalid("wall opening host must exist in the same space") + t = float(host["t"]) + x = float(segment["a"][0]) + (float(segment["b"][0]) - float(segment["a"][0])) * t + y = float(segment["a"][1]) + (float(segment["b"][1]) - float(segment["a"][1])) * t + if abs(x - float(opening["x"])) > 2e-8 or abs(y - float(opening["y"])) > 2e-8: + raise vol.Invalid("wall opening geometry must match its host") + expected_angle = math.degrees(math.atan2( + float(segment["b"][1]) - float(segment["a"][1]), + float(segment["b"][0]) - float(segment["a"][0]), + )) + if _angle_delta_mod_180(float(opening["angle"]), expected_angle) > 8: + raise vol.Invalid("wall opening angle must match its host") + span = ((segment["b"][0] - segment["a"][0]) ** 2 + + (segment["b"][1] - segment["a"][1]) ** 2) ** 0.5 + along = t * span + length = float(opening["length"]) + if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9: + raise vol.Invalid("wall opening must fit inside its host") + lo, hi = along - length / 2, along + length / 2 + occupied = wall_opening_intervals.setdefault(host["id"], []) + if any(max(lo, old_lo) < min(hi, old_hi) - 1e-9 + for old_lo, old_hi in occupied): + raise vol.Invalid("wall openings must not overlap") + occupied.append((lo, hi)) + return value + + CONFIG_SCHEMA = vol.All( vol.Schema( { + vol.Optional("model_version"): vol.All( + vol.Coerce(int), vol.Range(min=0, max=1_000_000) + ), vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)), vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)), vol.Optional("settings", default=dict): vol.Schema( @@ -1604,4 +1848,5 @@ CONFIG_SCHEMA = vol.All( extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading ), canonicalize_config_geometry, + _config_wall_segment_invariants, ) diff --git a/custom_components/houseplan/wall_segment_model.py b/custom_components/houseplan/wall_segment_model.py new file mode 100644 index 00000000..e5ec157b --- /dev/null +++ b/custom_components/houseplan/wall_segment_model.py @@ -0,0 +1,504 @@ +"""Deterministic persisted contour-wall identity (model v8, issue #282). + +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 uuid +from typing import Any + +from .coordinate_canonicalization import canonicalize_config_geometry + + +WALL_SEGMENT_MODEL_VERSION = 8 +GRID_STEP_N = 1 / 240 +EPS = 1e-9 + + +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 _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 _atomize(space: 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]] = [] + for span in space.get("open_spans") or []: + if isinstance(span, dict) and isinstance(span.get("a"), list) and isinstance(span.get("b"), list): + global_breaks.extend((span["a"], span["b"])) + 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) + 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(), "preferred_carriers": {}, + "parent_keys": set(), + }) + 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)])) + if indexed_lineage and isinstance(old_ids[parent_index], str) and old_ids[parent_index]: + atom["preferred"].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: + 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] + + 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]]) -> 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): + raise WallSegmentMigrationError("opening-host", str(opening.get("id", ""))) from None + + def eligible(segment: dict[str, Any]) -> bool: + 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) + + 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: + raise WallSegmentMigrationError("opening-host", str(opening.get("id", ""))) + carrier = candidates[0] + opening["host"] = { + "kind": "wall", "id": carrier["id"], + "t": max(0.0, min(1.0, _project_t(centre, carrier["a"], carrier["b"]))), + } + + +def _migrate_space(space: dict[str, Any], initial_migration: bool) -> int: + 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) + _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) + draft_used = { + str(item["id"]) + for name in ("rooms", "openings", "decor", "room_drafts", "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"] + } + for draft in space.get("room_drafts") or []: + for index, segment in enumerate(draft.get("segments") or []): + if isinstance(segment.get("id"), str) and segment["id"]: + if segment["id"] in draft_used: + raise WallSegmentMigrationError("duplicate-id", segment["id"]) + draft_used.add(segment["id"]) + continue + try: + a, b = draft["points"][index], draft["points"][index + 1] + except (KeyError, IndexError, TypeError): + raise WallSegmentMigrationError("zero-length", str(draft.get("id", ""))) from None + if initial_migration: + base = deterministic_wall_segment_id( + str(space.get("id", "")), a, b, [f"draft:{draft.get('id', '')}"], + ) + suffix, segment["id"] = 1, base + while segment["id"] in draft_used: + suffix += 1 + segment["id"] = f"{base}-{suffix}" + else: + segment["id"] = _fresh_wall_segment_id(draft_used) + draft_used.add(segment["id"]) + _host_openings(space, segments) + return sum(1 for segment in segments if segment["id"] not in old) + + +def commit_wall_segment_model(config: Any) -> 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 + initial_migration = int(candidate.get("model_version", 0) or 0) < WALL_SEGMENT_MODEL_VERSION + for space in candidate.get("spaces") or []: + migrated += _migrate_space(space, initial_migration) + candidate["model_version"] = WALL_SEGMENT_MODEL_VERSION + return canonicalize_config_geometry(candidate), migrated diff --git a/custom_components/houseplan/websocket_api.py b/custom_components/houseplan/websocket_api.py index cb484f42..20a61cd3 100755 --- a/custom_components/houseplan/websocket_api.py +++ b/custom_components/houseplan/websocket_api.py @@ -63,10 +63,11 @@ from .projection import project_config, project_layout from .validation import ( CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES, PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError, - PartitionOpeningHostError, PartitionOpeningJambMarginError, sanitize_filename, + PartitionOpeningHostError, PartitionOpeningJambMarginError, + WallModelClientOutdatedError, sanitize_filename, validate_opening_passages, validate_partition_opening_hosts, validate_marker_controls, validate_marker_light_entities, - validate_marker_value_badges, valid_space_id, + validate_marker_value_badges, validate_wall_model_transition, valid_space_id, ) @@ -1309,6 +1310,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> # the lossless controls array. Validate only edges introduced by this # write so an unrelated edit can still round-trip a legacy broken ref. try: + validate_wall_model_transition(msg["config"], data.get("config")) validate_marker_controls(msg["config"], data.get("config")) validate_marker_light_entities(msg["config"], data.get("config")) validate_marker_value_badges(msg["config"], data.get("config")) @@ -1316,7 +1318,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> validate_partition_opening_hosts(msg["config"], data.get("config")) except ( MarkerControlError, OpeningPassageError, PartitionOpeningHostError, - PartitionOpeningJambMarginError, + PartitionOpeningJambMarginError, WallModelClientOutdatedError, ) as err: connection.send_error(msg["id"], err.code, str(err)) return @@ -1622,6 +1624,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any]) # layout transaction. It must enforce the same marker-link semantics # as config/set; otherwise a crafted client can persist a new cycle. try: + validate_wall_model_transition(msg["config"], config_data.get("config")) validate_marker_controls(msg["config"], config_data.get("config")) validate_marker_light_entities(msg["config"], config_data.get("config")) validate_marker_value_badges(msg["config"], config_data.get("config")) @@ -1632,7 +1635,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any]) ) except ( MarkerControlError, OpeningPassageError, PartitionOpeningHostError, - PartitionOpeningJambMarginError, + PartitionOpeningJambMarginError, WallModelClientOutdatedError, ) as err: connection.send_error(msg["id"], err.code, str(err)) return diff --git a/demo/smoke_draw_wall_thickness.mjs b/demo/smoke_draw_wall_thickness.mjs index 5da4d75d..aa1c6f8a 100644 --- a/demo/smoke_draw_wall_thickness.mjs +++ b/demo/smoke_draw_wall_thickness.mjs @@ -27,9 +27,8 @@ const res = await page.evaluate(async () => { // draw a small room away from existing rooms (garden/demo f1 may have rooms) // use empty space if available, else clear rooms on a copy path - const space = sp(); - const savedRooms = JSON.parse(JSON.stringify(space.rooms || [])); - const savedWalls = space.walls ? JSON.parse(JSON.stringify(space.walls)) : null; + let space = sp(); + const savedSpace = JSON.parse(JSON.stringify(space)); space.rooms = []; delete space.walls; await upd(); @@ -61,9 +60,18 @@ const res = await page.evaluate(async () => { c._commitRoom(); await upd(); + // Structural commits adopt one fully validated config candidate. Never keep + // an object identity from before that atomic swap as test evidence. + space = sp(); + out.roomSaved = (space.rooms || []).some((r) => r.name === 'ThickDraw'); out.wallsApplied = (space.walls || []).length >= 4 && (space.walls || []).every((w) => w.cm === 15); + const firstRoom = (space.rooms || []).find((r) => r.name === 'ThickDraw'); + out.wallIdentityMaterialized = c._serverCfg.model_version === 8 + && firstRoom?.wall_ids?.length === 4 + && new Set(firstRoom.wall_ids).size === 4 + && (space.wall_segments || []).length === 4; out.bodyDrawn = sr().querySelectorAll('[data-hp="wall"]').length >= 1; // second room sharing the right edge — neighbour keeps 15, new edges get 20 @@ -83,14 +91,21 @@ const res = await page.evaluate(async () => { c._commitRoom(); await upd(); + space = sp(); + const sharedStill15 = (space.walls || []).some((w) => w.cm === 15); const has20 = (space.walls || []).some((w) => w.cm === 20); out.sharedKept = sharedStill15 && has20; + const rooms = (space.rooms || []).filter((room) => + room.name === 'ThickDraw' || room.name === 'Neighbour'); + const sharedIds = rooms.length === 2 + ? rooms[0].wall_ids.filter((id) => rooms[1].wall_ids.includes(id)) : []; + out.sharedIdentityKept = sharedIds.length === 1 + && space.wall_segments.some((segment) => segment.id === sharedIds[0] && segment.cm === 15); // restore demo space - space.rooms = savedRooms; - if (savedWalls) space.walls = savedWalls; - else delete space.walls; + for (const key of Object.keys(space)) delete space[key]; + Object.assign(space, savedSpace); c._path = []; await upd(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 995cb148..a32ff263 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -361,7 +361,8 @@ not be added to an individual sink. "plan_x","plan_y","plan_scale_x","plan_scale_y","plan_angle", "plan_scale", // legacy optional fallback, docs/BACKDROP.md "view_box":[4], - "rooms":[{"id","name","area","poly|x/y/w/h","open_to","settings"}], + "rooms":[{"id","name","area","poly|x/y/w/h","wall_ids":[…],"open_to","settings"}], + "wall_segments":[{"id","a","b","cm","owners":[…]}], "room_drafts":[…], "partitions":[…], "wall_columns":[…], "openings":[…], "decor":[…], "settings":{…} }], "markers": [{ "id","binding":"device:|entity:|virtual","hidden","removed", @@ -390,6 +391,26 @@ quotas, nothing is ever deleted for being old (docs/SCOPE.md). ## Room and independent wall geometry +Model v8 separates a wall's durable identity from its current geometric lookup +(#282). `wall_segments[]` is the authoritative catalog of atomic room-wall +intervals; `rooms[].wall_ids[]` owns their ordered contour references. The +historical polygon and `walls[]` list remain render/read compatibility +projections. Room-wall openings reference `{kind:'wall', id, t}`; partition +openings continue to reference `{kind:'partition', id, t}`. The shared +frontend/backend materialiser lives in `src/wall-segment-model.ts` and +`custom_components/houseplan/wall_segment_model.py`, with a common parity +fixture. + +Read is projection-only. Before any physical-geometry mutation the card builds +a local candidate, canonicalizes coordinates, materialises/updates the wall +catalog, validates references and only then commits one config transaction. +Initial v7 IDs are deterministic so frontend/backend and repeated migrations +converge; genuinely new v8 segments use UUIDs. Split lineage assigns the old ID +to one deterministic child, and draft promotion carries the draft ID into the +resulting wall or partition. Ambiguity fails closed with no partial config, +history or revision update. `scripts/mutation-gate.mjs` guards every structural +writer entrance. + Room-boundary walls remain *derived* from room outlines (`roomEdges`, deduped by `segKey`), so deleting a room keeps the boundaries its neighbours still contribute. Three explicitly typed exceptions are stored per space: diff --git a/docs/CANVAS.md b/docs/CANVAS.md index 26d44a97..2adc846f 100644 --- a/docs/CANVAS.md +++ b/docs/CANVAS.md @@ -538,6 +538,16 @@ be visually identical but not `===`. Update-event reload and a cold read therefore receive exactly the pair retained by the preview, and a second run cannot manufacture fresh coordinate noise (#248, #291). +Model v8 adds a second, identity-preserving stage at this write boundary +(#282). `materializeWallSegmentModel()` atomizes canonical room contours into +`wall_segments[]`, keeps the deterministic parent ID on one split child, emits +UUIDs only for genuinely new v8 atoms, and refreshes `rooms[].wall_ids[]`, +draft IDs and tagged opening hosts together. The historical `walls[]` entries +are regenerated from this catalog as a compatibility view. Reading or fitting +the canvas never runs this migration; only physical edits, Optimize and a +v7-to-v8 import may materialise it. Failure keeps the previous view, history +and persisted revision intact. + Guarantees are covered by `test/align-grid.test.mjs` and the orchestration/ idempotence case in `test/plan-optimizer.test.mjs`: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b0e4d439..880c0da3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,7 +14,12 @@ mixed-thickness crosses no longer show a step in a foreign quadrant. Square corners are untouched ([#309](https://github.com/Matysh/houseplan-card/issues/309)). - +- Structural wall edits now keep thickness, draft lineage and door/window/gate/ + passage ownership attached to stable wall-segment IDs. Existing plans upgrade + atomically on their first structural edit or through **Optimize plans**; + ambiguous legacy geometry is left unchanged with a recovery prompt. No Plan + editor controls or zero-thickness-wall behaviour changed + ([#282](https://github.com/Matysh/houseplan-card/issues/282)). - The active wall chain keeps its axis and node markers visible on already placed segments while drawing. The chain ink now paints above the wall masonry (and below the snap overlay), so each click no longer buries the diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index b6c9a613..ba822319 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -18,7 +18,13 @@ острых пар исчезают хвосты, у толстых многолучевых узлов пики, а кресты смешанных толщин теряют ступеньку в чужом квадранте. Прямые углы не изменились ([#309](https://github.com/Matysh/houseplan-card/issues/309)). - +- Структурные изменения стен теперь сохраняют толщину, происхождение черновых + сегментов и принадлежность дверей, окон, ворот и проходов через стабильные ID + сегментов стен. Существующий план обновляется атомарно при первом структурном + изменении или через **«Оптимизировать планы»**; неоднозначная старая геометрия + остаётся без изменений с подсказкой по восстановлению. Кнопки редактора плана + и поведение стен нулевой толщины не менялись + ([#282](https://github.com/Matysh/houseplan-card/issues/282)). - Во время рисования цепочки стен осевая линия и узлы остаются видимыми на уже поставленных сегментах. Разметка цепочки теперь рисуется поверх кладки стен (и под снап-оверлеем), поэтому каждый клик больше не прячет жёлтую ось под diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md index 09444a21..96e11fcb 100644 --- a/docs/CONFIG-COMPATIBILITY.md +++ b/docs/CONFIG-COMPATIBILITY.md @@ -48,6 +48,27 @@ Unknown future fields remain outside this report and continue to follow the backend's forward-compatibility policy. Absence from the report is therefore not permission to delete a field. +## Stable wall identity — model v8 (#282) + +Model v8 adds `space.wall_segments[]`, ordered `rooms[].wall_ids[]`, IDs on +`room_drafts[].segments[]`, and tagged wall hosts on room-wall openings. These +fields are authoritative for wall identity, thickness and opening ownership. +The historical `rooms[].poly`, `space.walls[]` and opening coordinates remain +materialised compatibility projections. + +Reading a v7 store is side-effect free. A v8 wall catalog is materialised only +before a structural write, through **Optimize plans**, or when a v7 candidate is +imported into a v8 target. A v7-to-v7 import remains v7. Full and space-only +export/import preserve IDs; copy/merge deterministically remaps IDs together +with all references. There is no automatic downgrade from v8 to v7. + +An older frontend may read the compatibility projection of a v8 config. Its +byte-equivalent legacy round-trip is accepted and the backend restores the v8 +fields. If the legacy-visible structural projection changed, the backend +rejects the write instead of allowing thickness, draft identity or an opening +host to detach. A migration or transition conflict is fail-closed: the previous +config and revision remain unchanged. + ## Canonical geometry on write (#224, #291) Config and layout schemas canonicalize only named persisted numbers. Lattice diff --git a/docs/TESTING.md b/docs/TESTING.md index 8d3d3293..0e8d6c31 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -32,6 +32,30 @@ полный прогон — workflow `mutation-gate.yml`, перед стабильным релизом и по понедельникам. Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`. +## Stable wall-segment identity (#282) + +- [ ] Shared fixture `test/fixtures/282-wall-identity-parity.json` produces the + same exact v8 catalog, room references, opening host and draft IDs in + TypeScript and Python. +- [ ] Initial v7 migration is deterministic and idempotent; new post-v8 atoms + use UUIDs. A split/promoted draft keeps one documented carrier ID, while + reserved/colliding deterministic IDs receive stable `-2`, `-3` suffixes. +- [ ] Structural writers are enumerated by `scripts/mutation-gate.mjs`; bypassing + the wall-model barrier must fail the source guard. A rejected migration + changes neither config, Undo history nor revision. +- [ ] Full/space imports cover v7→v7 (no upgrade), v7→v8 and v8→v8; copy/merge + remaps every ID and reference together. A byte-equivalent legacy-client + round-trip of v8 is accepted, while a structural legacy change is rejected. +- [ ] Resize, Split/Merge, opening edit and Optimize browser smokes retain wall + thickness and ownership across reload. Performance gate: + `npm run benchmark:wall-model` materialises 10,000 atoms with p95 below + 500 ms on the reference Windows machine. +- [ ] Local commands: `npm test`, `npm run typecheck`, + `npm run benchmark:wall-model`; backend parity/schema tests run through + `tests_backend/test_wall_segment_model.py` and + `tests_backend/test_validation.py`. HA import/export coverage runs in the + normal Linux/CI Home Assistant harness when unavailable natively. + ## Resize: реальный pointer pipeline (#293) - [ ] `demo/smoke_resize_pointer_real_plan.mjs` загружает tracked fixture diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 3e5b7dc1..95ab26e0 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -673,6 +673,18 @@ space: ground ## 19. Plan maintenance +Current plans give every stored wall segment a stable internal identity. This +keeps the wall's thickness and its door, window, gate or passage attached while +Resize, Split, Merge and other structural tools change surrounding geometry. +There is no new control and the plan is not rewritten merely by opening it. + +An older plan is upgraded atomically on its first structural edit or when you +run **Optimize plans**. Names, colours and other presentation settings do not +trigger the upgrade. If old geometry is ambiguous, House Plan cancels the edit +without partially saving it and asks you to run **Optimize plans**. If the same +message remains, fix the reported conflicting wall geometry or attach that +space's export to a bug report. + Optimization compacts old off-grid geometry and repairs the plan's reference graph while preserving rooms, bindings and supported settings. An exact independent-import signature restores the copied space, room and positions. If diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 07c81183..73eb8beb 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -1422,6 +1422,18 @@ show_signal: true очистится при следующем изменении; если очистить всё нужно сразу, используйте явную оптимизацию ниже. +В текущей модели каждый сохранённый сегмент стены имеет стабильный внутренний +идентификатор. Поэтому Resize, Split, Merge и другие структурные операции не +отрывают от стены её толщину, дверь, окно, ворота или проход. Новых кнопок для +этого нет. + +Старый план обновляется атомарно при первом структурном изменении или запуске +**«Оптимизировать планы»**. Названия, цвета и другие визуальные настройки +обновление не запускают. Если старая геометрия неоднозначна, House Plan отменит +операцию без частичного сохранения и предложит сначала выполнить оптимизацию. +Если сообщение повторяется, исправьте названный конфликт стен или приложите к +баг-репорту экспорт указанного пространства. + Кнопка **Общие настройки → Оптимизировать планы** запускает явное обслуживание. Сначала показывается точный предпросмотр; данные не меняются до подтверждения. ### Что делает оптимизация diff --git a/docs/WALL-THICKNESS.md b/docs/WALL-THICKNESS.md index 08f99966..31178096 100644 --- a/docs/WALL-THICKNESS.md +++ b/docs/WALL-THICKNESS.md @@ -15,6 +15,32 @@ Code: `src/wall-thickness.ts`, render in `src/houseplan-card.ts` / ## 1. Model +### Stable stored identity (model v8, #282) + +From model v8 every atomic room-wall interval has a stable record in +`space.wall_segments[]`. Its `id`, endpoints and `cm` are authoritative; +`rooms[].wall_ids[]` references the ordered atoms that form each contour. +`space.walls[]` remains a generated compatibility projection for older readers +and must not be used as the identity of a wall. A door, window, gate or passage +on a room wall stores a tagged `{kind:'wall', id, t}` host. Independent +partitions keep their existing stable IDs, and unfinished draft segments receive +IDs which survive promotion to a room wall or partition. + +Existing v7 plans are not rewritten on read. The model is materialised +atomically before the first structural edit, by **Optimize plans**, or when a +v7 plan is imported into a v8 target. Ambiguous or conflicting geometry blocks +the operation and leaves the stored plan unchanged. Ordinary settings and +presentation writes do not trigger the migration. A zero-centimetre atom is an +internal representation of the existing non-physical contour interval; #282 +does not expose zero-thickness drawing and does not replace the current virtual +boundary model. + +Every structural writer passes through the same wall-model barrier. A split +keeps the parent ID on the child containing the old midpoint (then the old first +endpoint on a tie), while the other child receives a new UUID. Merge, Resize, +room deletion, opening edits, Undo/Redo/recovery, Optimize and import/export +apply the same lineage and validation rules before one atomic persistence write. + Per space: `walls: [{ key, cm, a?, b? }]`. `key` remains the quantised midpoint and direction (modulo 180°) compatibility lookup; new or rewritten entries also carry exact endpoints `a` / `b` in normalised plan coordinates. Config always diff --git a/docs/adr/282-wall-geometry-representation.md b/docs/adr/282-wall-geometry-representation.md index 93f428f0..62917ca4 100644 --- a/docs/adr/282-wall-geometry-representation.md +++ b/docs/adr/282-wall-geometry-representation.md @@ -1,7 +1,7 @@ # ADR #282 — Wall geometry representation - Issue: https://github.com/Matysh/houseplan-card/issues/282 -- Status: accepted as direction; stage 0 accepted for implementation +- Status: accepted; Stage 1 stored identity implemented, Stages 2–3 deferred - Date: 2026-08-24 - Related: #34 (frontend decomposition), #264 (resize controller slice), `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md` diff --git a/docs/specs/282-stable-wall-segment-identity.md b/docs/specs/282-stable-wall-segment-identity.md index 5e420d47..8d4da5d8 100644 --- a/docs/specs/282-stable-wall-segment-identity.md +++ b/docs/specs/282-stable-wall-segment-identity.md @@ -2,7 +2,7 @@ - **Issue:** https://github.com/Matysh/houseplan-card/issues/282 - **ADR:** [`docs/adr/282-wall-geometry-representation.md`](../adr/282-wall-geometry-representation.md) -- **Статус:** первая редакция для независимого ревью +- **Статус:** принято независимым ревью; Stage 1 реализован - **Тип / приоритет:** tech-debt / P1 - **Поставляемый этап:** Stage 1 — stored identity - **Целевая версия модели:** `PLAN_MODEL_VERSION = 8` diff --git a/package.json b/package.json index d5eb5911..148eaf11 100755 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "watch": "rollup -c --watch", "typecheck": "tsc --noEmit", "test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs", + "benchmark:wall-model": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node scripts/benchmark-wall-segment-model.mjs", "bundle:sync": "npm run build && node scripts/bundle-sync.mjs", "invariants": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node scripts/model-invariants.mjs", "docs:accept": "node scripts/docs-accept.mjs", diff --git a/scripts/benchmark-wall-segment-model.mjs b/scripts/benchmark-wall-segment-model.mjs new file mode 100644 index 00000000..46df4c7d --- /dev/null +++ b/scripts/benchmark-wall-segment-model.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** Machine-readable Stage-1 migration budget for issue #282. */ +import { performance } from 'node:perf_hooks'; + +import { commitWallSegmentModel } from '../test-build/wall-segment-model.js'; + +const ATOMS = 10_000; +const BUDGET_MS = 500; + +const config = (atoms) => ({ + spaces: [{ + id: 'benchmark', + rooms: [{ + id: 'room', + // A large radius keeps consecutive vertices above the existing + // wall-geometry epsilon while staying far inside the canvas limit. + poly: Array.from({ length: atoms }, (_, index) => { + const angle = Math.PI * 2 * index / atoms; + return [100 * Math.cos(angle), 100 * Math.sin(angle)]; + }), + }], + }], + markers: [], + settings: {}, +}); + +commitWallSegmentModel(config(100)); +const samples = []; +for (let index = 0; index < 5; index++) { + const started = performance.now(); + const result = commitWallSegmentModel(config(ATOMS)); + samples.push(performance.now() - started); + if (result.migratedSegments !== ATOMS) throw new Error( + `expected ${ATOMS} materialised atoms, received ${result.migratedSegments}`, + ); +} +samples.sort((left, right) => left - right); +const p95Ms = samples[Math.ceil(samples.length * 0.95) - 1]; +const report = { + issue: 282, + atoms: ATOMS, + samplesMs: samples.map((value) => Number(value.toFixed(3))), + p95Ms: Number(p95Ms.toFixed(3)), + budgetMs: BUDGET_MS, + passed: p95Ms <= BUDGET_MS, +}; +process.stdout.write(`${JSON.stringify(report)}\n`); +if (!report.passed) process.exitCode = 1; diff --git a/scripts/config-field-registry.mjs b/scripts/config-field-registry.mjs index 4dec7c8b..3c30197c 100644 --- a/scripts/config-field-registry.mjs +++ b/scripts/config-field-registry.mjs @@ -217,6 +217,62 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([ migration: 'materialize exact open_spans before geometry changes', compatibility: 'keep reading until every supported config can represent the same geometry losslessly', }, + { + id: 'spaces[].wall_segments', + selector: { path: ['spaces', '*', 'wall_segments'] }, + storage: 'House Plan server config', + type: 'Array<{ id: string; a: Point; b: Point; cm: number }>', + default: '[] after the first v8 structural write', + level: 'space', + ui: 'internal canonical wall identity', + runtime: 'authoritative contour-wall identity and thickness in model v8', + introduced: 'model v8 / issue #282', + status: 'current', + migration: 'materialize atomically on a structural write, Optimize or import', + compatibility: 'legacy walls[] remains a validated generated projection', + }, + { + id: 'spaces[].rooms[].wall_ids', + selector: { path: ['spaces', '*', 'rooms', '*', 'wall_ids'] }, + storage: 'House Plan server config', + type: 'string[]', + default: 'one id per poly edge in model v8', + level: 'room', + ui: 'internal canonical wall ownership', + runtime: 'maps every room polygon edge to one stable wall segment', + introduced: 'model v8 / issue #282', + status: 'current', + migration: 'materialize together with wall_segments', + compatibility: 'absent v7 rooms remain readable until a structural write', + }, + { + id: 'spaces[].room_drafts[].segments[].id', + selector: { path: ['spaces', '*', 'room_drafts', '*', 'segments', '*', 'id'] }, + storage: 'House Plan server config', + type: 'string', + default: 'deterministic id in model v8', + level: 'draft wall segment', + ui: 'internal draft identity', + runtime: 'preserves segment identity while an unfinished contour is continued', + introduced: 'model v8 / issue #282', + status: 'current', + migration: 'materialize together with wall_segments', + compatibility: 'optional on legacy draft reads', + }, + { + id: 'spaces[].openings[].host=wall', + selector: { path: ['spaces', '*', 'openings', '*', 'host', 'kind'], equals: 'wall' }, + storage: 'House Plan server config', + type: "{ kind: 'wall'; id: string; t: number }", + default: 'required for contour openings in model v8', + level: 'opening', + ui: 'internal opening ownership', + runtime: 'binds an opening to a stable contour-wall segment', + introduced: 'model v8 / issue #282', + status: 'current', + migration: 'materialize only when exactly one carrier is proven', + compatibility: 'legacy unhosted openings remain readable before migration', + }, { id: 'spaces[].decor[].width', selector: { path: ['spaces', '*', 'decor', '*', 'width'] }, @@ -276,6 +332,7 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([ ]); export const CONFIG_FIELD_STATUSES = Object.freeze([ + 'current', 'decision-required', 'deprecated-read', 'migrate-on-write', diff --git a/scripts/model-invariants.mjs b/scripts/model-invariants.mjs index 37f0d978..d4ba2132 100644 --- a/scripts/model-invariants.mjs +++ b/scripts/model-invariants.mjs @@ -214,6 +214,8 @@ export function checkReferences({ config, layout = {} } = {}, { notes = [] } = { for (const space of spaces) { const spaceId = String(space?.id ?? '?'); const roomIds = roomIdsBySpace.get(spaceId) || new Set(); + const wallSegments = new Map((space?.wall_segments || []) + .map((segment) => [String(segment?.id ?? ''), segment]).filter(([id]) => id)); for (const room of space?.rooms || []) { const roomId = String(room?.id ?? '?'); for (const target of Array.isArray(room?.open_to) ? room.open_to : []) { @@ -223,16 +225,40 @@ export function checkReferences({ config, layout = {} } = {}, { notes = [] } = { 'комнаты назначения не существует в том же пространстве'); } } + if (Number(config?.model_version || 0) >= 8) { + const wallIds = Array.isArray(room?.wall_ids) ? room.wall_ids : []; + const poly = roomPolygon(room) || []; + if (wallIds.length !== poly.length) { + add('room_wall_ids', `${spaceId}:${roomId}`, String(wallIds.length), + `ожидалось по одному id для ${poly.length} рёбер`); + } + for (const target of wallIds) { + const targetId = String(target ?? ''); + if (!targetId || !wallSegments.has(targetId)) { + add('room_wall_ids', `${spaceId}:${roomId}`, targetId || '?', + 'сегмента стены не существует в том же пространстве'); + } + } + } } const partitionIds = new Set((space?.partitions || []) .map((partition) => String(partition?.id ?? '')).filter(Boolean)); for (const opening of space?.openings || []) { const host = opening?.host; - if (host?.kind !== 'partition') continue; + if (!host) { + if (Number(config?.model_version || 0) >= 8) { + add('opening_host', `${spaceId}:${opening?.id ?? '?'}`, '?', + 'в model v8 у проёма нет явной стены-хоста'); + } + continue; + } const target = String(host.id ?? ''); - if (!target || !partitionIds.has(target)) { + const exists = host?.kind === 'partition' + ? partitionIds.has(target) + : host?.kind === 'wall' && wallSegments.has(target); + if (!target || !exists) { add('opening_host', `${spaceId}:${opening?.id ?? '?'}`, target || '?', - 'перегородки-хоста не существует в том же пространстве'); + 'стены-хоста не существует в том же пространстве'); } } const list = carriers(space); @@ -790,7 +816,8 @@ function report(violations, notes = []) { marker_control: 'Управление светом ссылается на несовместимый маркер', marker_badge: 'Бейдж значения ссылается на несовместимый маркер', room_open_to: 'Связи комнат ссылаются на несуществующие комнаты', - opening_host: 'Проёмы ссылаются на несуществующие перегородки', + opening_host: 'Проёмы ссылаются на несуществующие стены', + room_wall_ids: 'Комнаты ссылаются на несуществующие сегменты стен', layout_space: 'Позиции ссылаются на несуществующие пространства', layout_owner: 'Позиции без владельца', wall_carrier: 'Записи толщины вне рёбер и перегородок', diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 57d14321..38b88a5d 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -1012,9 +1012,9 @@ export const MUTANTS = [ patches: [{ file: 'custom_components/houseplan/validation.py', find: ' extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading\n' - + ' ),\n canonicalize_config_geometry,\n)', + + ' ),\n canonicalize_config_geometry,\n _config_wall_segment_invariants,\n)', replace: ' extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading\n' - + ' ),\n lambda value: value,\n)', + + ' ),\n lambda value: value,\n _config_wall_segment_invariants,\n)', }], }, { @@ -1134,8 +1134,8 @@ export const MUTANTS = [ + 'must enter preview and storage through the same canonical schema as the live card', patches: [{ file: 'custom_components/houseplan/import_export.py', - find: ' config = CONFIG_SCHEMA(_json_copy(payload.get("config")))', - replace: ' config = _json_copy(payload.get("config"))', + find: ' config = CONFIG_SCHEMA(config_candidate)', + replace: ' config = config_candidate', }], }, { @@ -1686,8 +1686,8 @@ export const MUTANTS = [ + 'projection/roundtrip тест обязан увидеть HA Area даже при нулевых markers', patches: [{ file: 'custom_components/houseplan/import_export.py', - find: '_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly")', - replace: '_ROOM_PLAN_FIELDS = ("id", "name", "area", "open_to", "x", "y", "w", "h", "poly")', + find: '_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly", "wall_ids")', + replace: '_ROOM_PLAN_FIELDS = ("id", "name", "area", "open_to", "x", "y", "w", "h", "poly", "wall_ids")', }], }, { @@ -2910,6 +2910,19 @@ export const MUTANTS = [ replace: ' const gateAngle = spec.face.side * sy * 10 * amount;', }], }, + { + id: 'wall-identity-structural-barrier-bypassed', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="structural transaction crosses" ' + + 'test/wall-segment-model.test.mjs', + because: 'a geometry writer that persists the mutable v7/v8 projection without rebuilding ' + + 'stable ids can detach thickness and opening hosts during any later editor operation (#282)', + patches: [{ + file: 'src/houseplan-card.ts', + find: ' this._serverCfg = commitWallSegmentModel(liveCandidate).config;', + replace: ' this._serverCfg = liveCandidate;', + }], + }, ]; // --- механика --------------------------------------------------------------- diff --git a/src/coordinate-canonicalization.ts b/src/coordinate-canonicalization.ts index ba7e7f7d..f4079f03 100644 --- a/src/coordinate-canonicalization.ts +++ b/src/coordinate-canonicalization.ts @@ -148,6 +148,10 @@ function visitLatticeCoordinates( pointValues(wall.a); pointValues(wall.b); } + for (const segment of records(space.wall_segments)) { + pointValues(segment.a); + pointValues(segment.b); + } for (const opening of records(space.openings)) fieldValues(opening, ['x', 'y']); for (const decor of records(space.decor)) { if (decor.kind === 'line') fieldValues(decor, ['x1', 'y1', 'x2', 'y2']); @@ -317,6 +321,10 @@ export function canonicalizeConfigGeometryInPlace(config: T): T { latticePoint(wall.a); latticePoint(wall.b); } + for (const segment of records(space.wall_segments)) { + latticePoint(segment.a); + latticePoint(segment.b); + } for (const opening of records(space.openings)) { latticeFields(opening, ['x', 'y']); diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 1198ebbf..e76630fb 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -42,6 +42,7 @@ import { } from './logic'; import { resolveSafeResize, applySafeResize, clampSafeResize, validateSafeResize, + coalesceResizeRooms, safeResizePointerDisplacement, polyIsSimple, areaM2, formatArea, MIN_ROOM_CM, type SafeOpeningIn, type SafeResizeObstacle, type SafeResizeOptions, @@ -196,6 +197,9 @@ import { clampCanvasR, clampCanvasN, type ContentItem, type Rect, } from './space-geometry'; import { optimizePlans, type OptimizeReport } from './plan-optimizer'; +import { + commitWallSegmentModel, WallSegmentModelError, +} from './wall-segment-model'; import { snapNearAxisEndpoint } from './near-axis'; import type { SpaceReferenceRepairContext } from './space-reference-repair'; import { collectSpaceMarkerDependencies } from './space-deletion'; @@ -653,6 +657,7 @@ interface SpaceGeometryState { rooms: any[]; openings?: OpeningCfg[]; walls?: WallEntry[]; + wall_segments?: any[]; open_spans?: OpenSpanEntry[]; room_drafts?: RoomDraftCfg[]; partitions?: PartitionCfg[]; @@ -6774,9 +6779,15 @@ class HouseplanCard extends LitElement { sp.partitions ||= []; const seed = Date.now().toString(36); const drawnIds: string[] = []; + const activeDraft = this._activeDraftId + ? (sp.room_drafts || []).find((draft: any) => draft.id === this._activeDraftId) + : null; + const lineage = this._draftSegmentsForPath( + this._path, activeDraft, segments.map((segment) => segment.cm), + ); for (let i = 0; i < segmentCount; i++) { const segment = segments[i]; - const id = `partition-${seed}-${i}`; + const id = lineage[i]?.id || `partition-${seed}-${i}`; drawnIds.push(id); sp.partitions.push({ id, @@ -7016,7 +7027,7 @@ class HouseplanCard extends LitElement { if (clearCursor) this._cursorPt = null; } - private _samePt(a: number[], b: number[]): boolean { + private _samePt(a: readonly number[], b: readonly number[]): boolean { return samePoint(a, b); } @@ -7183,6 +7194,7 @@ class HouseplanCard extends LitElement { rooms: copy(sp.rooms || []), ...(Array.isArray(sp.openings) ? { openings: copy(sp.openings) } : {}), ...(Array.isArray(sp.walls) ? { walls: copy(sp.walls) } : {}), + ...(Array.isArray(sp.wall_segments) ? { wall_segments: copy(sp.wall_segments) } : {}), ...(Array.isArray((sp as any).open_spans) ? { open_spans: copy((sp as any).open_spans) } : {}), @@ -7215,13 +7227,14 @@ class HouseplanCard extends LitElement { if (!sp) return false; const copy = (value: T): T => JSON.parse(JSON.stringify(value)); sp.rooms = copy(state.rooms); - const assign = (key: 'openings' | 'walls' | 'open_spans' | 'room_drafts' + const assign = (key: 'openings' | 'walls' | 'wall_segments' | 'open_spans' | 'room_drafts' | 'partitions' | 'wall_columns' | 'decor', value: unknown): void => { if (value !== undefined) (sp as any)[key] = copy(value); else delete (sp as any)[key]; }; assign('openings', state.openings); assign('walls', state.walls); + assign('wall_segments', state.wall_segments); assign('open_spans', state.open_spans); assign('room_drafts', state.room_drafts); assign('partitions', state.partitions); @@ -7240,11 +7253,35 @@ class HouseplanCard extends LitElement { } /** Shared fail-closed transaction boundary for every physical writer. */ + private _wallModelBlockerLabel(error: unknown): string { + const reason = error instanceof WallSegmentModelError ? error.reason : 'invalid-room'; + return this._t(`wall_model.reason.${reason}`); + } + private _commitPhysicalGeometry(name: string, before: SpaceGeometryState | null): boolean { if (!before || !this._serverCfg) return false; + const liveCandidate = this._serverCfg; + try { + // ADR 282 Stage 1: every structural writer crosses the same atomic + // canonicalisation/identity barrier. The pure candidate is adopted only + // after every space migrated successfully, so a blocker leaves the live + // config byte-equivalent. + this._serverCfg = commitWallSegmentModel(liveCandidate).config; + } catch (error) { + this._serverCfg = liveCandidate; + this._clearGeometryGesture(); + this._restoreGeometryStateLocal(before); + this._showToast(this._t('toast.wall_model_migration_blocked', { + reason: this._wallModelBlockerLabel(error), + })); + return false; + } const after = this._geometrySnapshot(before.spaceId); if (!after || spacePhysicalGeometryFingerprint(before) - === spacePhysicalGeometryFingerprint(after)) return false; + === spacePhysicalGeometryFingerprint(after)) { + this._serverCfg = liveCandidate; + return false; + } let safe = false; try { safe = this._checkSpacePhysicalGeometry(this._serverCfg, before.spaceId).ok; @@ -7252,6 +7289,7 @@ class HouseplanCard extends LitElement { safe = false; } if (!safe) { + this._serverCfg = liveCandidate; this._clearGeometryGesture(); this._restoreGeometryStateLocal(before); this._showToast(this._t('toast.geometry_unsafe')); @@ -7352,6 +7390,17 @@ class HouseplanCard extends LitElement { if (!this._canCommitSpace(state.spaceId)) return false; const before = this._geometrySnapshot(state.spaceId); if (!before || !this._restoreGeometryStateLocal(state)) return false; + const restoredCandidate = this._serverCfg; + try { + this._serverCfg = commitWallSegmentModel(restoredCandidate).config; + } catch (error) { + this._serverCfg = restoredCandidate; + this._restoreGeometryStateLocal(before); + this._showToast(this._t('toast.wall_model_migration_blocked', { + reason: this._wallModelBlockerLabel(error), + })); + return false; + } const physicalChanged = spacePhysicalGeometryFingerprint(before) !== spacePhysicalGeometryFingerprint(state); if (physicalChanged) { @@ -7369,6 +7418,7 @@ class HouseplanCard extends LitElement { && check?.reason === 'wall-degraded-extra'); } catch { safe = false; } if (!safe) { + this._serverCfg = restoredCandidate; this._restoreGeometryStateLocal(before); this._showToast(this._t('toast.geometry_unsafe')); return false; @@ -7446,6 +7496,8 @@ class HouseplanCard extends LitElement { this._writeConfig().catch((e: any) => { if (e?.code === 'geometry-unsafe') { return; + } else if (e?.code === 'wall_model_client_outdated') { + this._showToast(this._t('toast.wall_model_client_outdated')); } else if (e?.code === 'conflict') { // a real one now: another window wrote between our read and our write this._showToast(this._t('toast.conflict')); @@ -7718,9 +7770,9 @@ class HouseplanCard extends LitElement { const connectorCm = touching ? null : this._drawWallCm; if (!touching && connectorCm == null) { this._showPhysicalRange(100); return; } - const activeSegments = this._draftSegmentCms.map((cm, i) => ({ - ...(activeRaw?.segments?.[i] || {}), cm, - })); + const activeSegments = this._draftSegmentsForPath( + this._path, activeRaw, this._draftSegmentCms, + ); const mergedPoints = [ ...this._path.map((p) => [...p]), ...(touching ? otherPoints.slice(1) : otherPoints), @@ -7801,6 +7853,34 @@ class HouseplanCard extends LitElement { return resolved; } + /** + * Carry persisted draft identity by its physical carrier, not by array index. + * Resuming a chain from its opposite end reverses the path, and positional + * copying used to silently attach every ID to the neighbouring segment. + */ + private _draftSegmentsForPath( + path: readonly (readonly number[])[], draft: any, + cms: readonly (number | null | undefined)[], + ): Array<{ id?: string; cm: number; [key: string]: any }> { + const points = Array.isArray(draft?.points) + ? draft.points.map((p: any) => [Number(p?.[0]) * NORM_W, Number(p?.[1]) * NORM_W]) + : []; + const stored = Array.isArray(draft?.segments) ? draft.segments : []; + const resolved = chainSegmentCms( + Math.max(0, path.length - 1), cms, this._drawWallCm, DRAW_WALL_DEFAULT_CM, + ); + return resolved.map((cm, index) => { + const a = path[index], b = path[index + 1]; + const oldIndex = points.findIndex((oldA: number[], candidate: number) => { + if (candidate + 1 >= points.length) return false; + const oldB = points[candidate + 1]; + return (this._samePt(a, oldA) && this._samePt(b, oldB)) + || (this._samePt(a, oldB) && this._samePt(b, oldA)); + }); + return { ...(oldIndex >= 0 ? stored[oldIndex] : {}), cm }; + }); + } + /** * Persist every completed draft segment immediately. * @@ -7820,9 +7900,9 @@ class HouseplanCard extends LitElement { ...(i >= 0 ? sp.room_drafts[i] : {}), id: this._activeDraftId, points: this._path.map((p) => [p[0] / NORM_W, p[1] / NORM_W]), - segments: this._draftSegmentCms.map((v, j) => ({ - ...(i >= 0 ? sp.room_drafts[i]?.segments?.[j] : {}), cm: v, - })), + segments: this._draftSegmentsForPath( + this._path, i >= 0 ? sp.room_drafts[i] : null, this._draftSegmentCms, + ), }; if (i >= 0) sp.room_drafts[i] = saved; else sp.room_drafts.push(saved); @@ -8409,15 +8489,18 @@ class HouseplanCard extends LitElement { // ================= room resize tool (docs/RESIZE.md) ================= /** Rooms of the current space as render-unit polygons (legacy rects converted). */ - private _rszRooms(): { id: string; poly: number[][] }[] { - const out: { id: string; poly: number[][] }[] = []; + private _rszRooms(): { id: string; poly: number[][]; wall_ids?: string[] }[] { + const out: { id: string; poly: number[][]; wall_ids?: string[] }[] = []; const space = this._spaceModel(); if (!space) return out; for (const r of space.rooms) { const poly = r.id ? roomPoly(r) : null; - if (poly) out.push({ id: r.id!, poly }); + if (poly) out.push({ + id: r.id!, poly, + wall_ids: Array.isArray(r.wall_ids) ? [...r.wall_ids] : undefined, + }); } - return out; + return coalesceResizeRooms(out, Math.max(1e-12, this._gridPitch * 1e-12)); } private _rszOpenings(): SafeOpeningIn[] { @@ -9020,7 +9103,9 @@ class HouseplanCard extends LitElement { const fallback: RenderOpening = { ...o, rx: o.x * NORM_W, ry: o.y * H, rlen: o.length * NORM_W, }; - if (!o.host) return [fallback]; + // Contour-wall hosts are identity metadata; their materialised x/y/angle + // stay the legacy render input until the Stage 3 graph renderer. + if (!o.host || o.host.kind === 'wall') return [fallback]; const resolution = resolvePartitionOpeningCompat( o, space.partitions, NORM_W, this._cellCm, this._gridPitch, ); @@ -11566,6 +11651,15 @@ class HouseplanCard extends LitElement { sp.partitions ||= []; const targetByInterval = new Map(); const createdByKey = new Map(); + const sourceWallId = (interval: { a: readonly number[]; b: readonly number[] }): string | null => { + const match = (sp.wall_segments || []).find((segment: any) => { + const a = [Number(segment.a?.[0]) * NORM_W, Number(segment.a?.[1]) * NORM_W]; + const b = [Number(segment.b?.[0]) * NORM_W, Number(segment.b?.[1]) * NORM_W]; + return (this._samePt(interval.a, a) && this._samePt(interval.b, b)) + || (this._samePt(interval.a, b) && this._samePt(interval.b, a)); + }); + return typeof match?.id === 'string' && match.id ? match.id : null; + }; const seed = Date.now().toString(36); for (const item of plan.materialize) { let target = item.reusePartitionId @@ -11573,7 +11667,7 @@ class HouseplanCard extends LitElement { : createdByKey.get(item.interval.key); if (!target) { target = { - id: `partition-room-${seed}-${createdByKey.size}`, + id: sourceWallId(item.interval) || `partition-room-${seed}-${createdByKey.size}`, a: [item.interval.a[0] / NORM_W, item.interval.a[1] / NORM_W], b: [item.interval.b[0] / NORM_W, item.interval.b[1] / NORM_W], cm: item.interval.cm, @@ -12578,7 +12672,7 @@ class HouseplanCard extends LitElement { invert: !!o.invert, flipH: !!o.flip_h, flipV: !!o.flip_v, - ...(o.host ? { host: { ...o.host } } : {}), + ...(o.host?.kind === 'partition' ? { host: { ...o.host } } : {}), x: o.rx, y: o.ry, angle: o.angle, }; } @@ -12770,7 +12864,7 @@ class HouseplanCard extends LitElement { : this._cmToUnits(Math.max(20, d.lengthCm)) / NORM_W, ...(d.host ? { host: { ...d.host } } : {}), }; - if (o.host) { + if (o.host?.kind === 'partition') { const strict = partitionOpeningNeedsStrictValidation(previous, o); const resolution = strict ? resolvePartitionOpeningStrict( @@ -13336,7 +13430,7 @@ class HouseplanCard extends LitElement { const activeSourceCms = new Map(); for (let i = 0; i < batch.activeCms.length; i++) activeSourceCms.set(this._activeWallSourceKey(i), batch.activeCms[i]); - const partitions: Array<{ a: number[]; b: number[]; cm: number }> = []; + const partitions: Array<{ id?: string; a: number[]; b: number[]; cm: number }> = []; if (!accepted.length) { partitions.push(...wallChainSegments( effectiveActivePath, @@ -13359,6 +13453,61 @@ class HouseplanCard extends LitElement { return; } + // A face decision may split one persisted draft segment between a room + // contour and leftover partitions. Pick the single physical child that + // inherits the old ID by the same midpoint/old-a rule as the catalogue. + const roomLineage = accepted.map((decision) => + decision.candidate.ring.map(() => '')); + const activeDraft = batch.activeDraftId + ? (sp.room_drafts || []).find((draft: any) => draft.id === batch.activeDraftId) + : null; + const draftLineage = this._draftSegmentsForPath( + batch.activePath, activeDraft, batch.activeCms, + ); + const geometryKey = (a: readonly number[], b: readonly number[]): string => { + const ka = `${a[0].toFixed(9)},${a[1].toFixed(9)}`; + const kb = `${b[0].toFixed(9)},${b[1].toFixed(9)}`; + return ka <= kb ? `${ka}|${kb}` : `${kb}|${ka}`; + }; + for (let sourceIndex = 0; sourceIndex < draftLineage.length; sourceIndex++) { + const id = draftLineage[sourceIndex]?.id; + if (!id) continue; + const sourceA = batch.activePath[sourceIndex]; + const sourceB = batch.activePath[sourceIndex + 1]; + const sourceSpan = [sourceA[0], sourceA[1], sourceB[0], sourceB[1]]; + const candidates: Array<{ + kind: 'room' | 'partition'; owner: number; edge: number; a: number[]; b: number[]; + }> = []; + accepted.forEach((decision, owner) => decision.candidate.ring.forEach((a, edge) => { + const b = decision.candidate.ring[(edge + 1) % decision.candidate.ring.length]; + if (distToSegment(a, sourceSpan) <= epsilon && distToSegment(b, sourceSpan) <= epsilon) + candidates.push({ kind: 'room', owner, edge, a, b }); + })); + partitions.forEach((partition, owner) => { + if (distToSegment(partition.a, sourceSpan) <= epsilon + && distToSegment(partition.b, sourceSpan) <= epsilon) + candidates.push({ kind: 'partition', owner, edge: 0, a: partition.a, b: partition.b }); + }); + const midpoint = [(sourceA[0] + sourceB[0]) / 2, (sourceA[1] + sourceB[1]) / 2]; + candidates.sort((left, right) => { + const leftSpan = [left.a[0], left.a[1], left.b[0], left.b[1]]; + const rightSpan = [right.a[0], right.a[1], right.b[0], right.b[1]]; + return (distToSegment(midpoint, leftSpan) <= epsilon ? 0 : 1) + - (distToSegment(midpoint, rightSpan) <= epsilon ? 0 : 1) + || (distToSegment(sourceA, leftSpan) <= epsilon ? 0 : 1) + - (distToSegment(sourceA, rightSpan) <= epsilon ? 0 : 1) + || geometryKey(left.a, left.b).localeCompare(geometryKey(right.a, right.b)); + }); + const winner = candidates[0]; + if (!winner) continue; + const winnerKey = geometryKey(winner.a, winner.b); + for (const candidate of candidates) { + if (geometryKey(candidate.a, candidate.b) !== winnerKey) continue; + if (candidate.kind === 'room') roomLineage[candidate.owner][candidate.edge] = id; + else partitions[candidate.owner].id = id; + } + } + const before = this._geometrySnapshot(); if (repairs[0] && !this._applyWallRepair(repairs[0], batch)) { abort('toast.wall_repair_changed'); @@ -13394,6 +13543,7 @@ class HouseplanCard extends LitElement { area: decision.area || null, poly: decision.candidate.ring.map((point) => [point[0] / NORM_W, point[1] / this._spaceH]), + ...(roomLineage[index].some(Boolean) ? { wall_ids: roomLineage[index] } : {}), ...(decision.settings ? { settings: JSON.parse(JSON.stringify(decision.settings)) } : {}), }; sp.rooms.push(room); @@ -13407,7 +13557,7 @@ class HouseplanCard extends LitElement { if (partitions.length) { sp.partitions ||= []; partitions.forEach((partition, index) => sp.partitions.push({ - id: `partition-${seed}-${index}`, + id: partition.id || `partition-${seed}-${index}`, a: [partition.a[0] / NORM_W, partition.a[1] / NORM_W], b: [partition.b[0] / NORM_W, partition.b[1] / NORM_W], cm: partition.cm, @@ -13533,13 +13683,27 @@ class HouseplanCard extends LitElement { verts = this._path.slice(0, -1); // without the duplicated closing vertex } const areaName = this._areaSel ? this.hass.areas[this._areaSel]?.name : ''; - const newRoom = { + const newRoom: any = { id: 'r' + Date.now().toString(36), name: this._nameSel || areaName || this._t('room.default_name'), area: this._areaSel || null, poly: verts.map((p) => [p[0] / NORM_W, p[1] / H]), ...(this._roomSettingsFromDialog() ? { settings: this._roomSettingsFromDialog() } : {}), }; + if (!wasSplit && this._activeDraftId) { + const activeDraft = Array.isArray((sp as any).room_drafts) + ? (sp as any).room_drafts.find((draft: any) => draft.id === this._activeDraftId) + : null; + const cms = chainSegmentCms( + verts.length, + [...this._draftSegmentCms, this._closingWallCm ?? undefined], + this._drawWallCm, DRAW_WALL_DEFAULT_CM, + ); + const lineage = this._draftSegmentsForPath(this._path, activeDraft, cms); + if (lineage.length === verts.length && lineage.every((segment) => !!segment.id)) { + newRoom.wall_ids = lineage.map((segment) => segment.id!); + } + } sp.rooms.push(newRoom); // Closing a saved draft promotes it into a room in the same transaction. if (!wasSplit && this._activeDraftId && Array.isArray((sp as any).room_drafts)) { @@ -13959,10 +14123,14 @@ class HouseplanCard extends LitElement { ); sp.partitions ||= []; const seed = Date.now().toString(36); + const activeDraft = this._activeDraftId + ? (sp.room_drafts || []).find((draft: any) => draft.id === this._activeDraftId) + : null; + const lineage = this._draftSegmentsForPath(this._path, activeDraft, cms); for (let i = 0; i < verts.length; i++) { const a = verts[i], b = verts[(i + 1) % verts.length]; sp.partitions.push({ - id: `partition-${seed}-${i}`, + id: lineage[i]?.id || `partition-${seed}-${i}`, a: [a[0] / NORM_W, a[1] / NORM_W], b: [b[0] / NORM_W, b[1] / NORM_W], cm: cms[i], @@ -15482,11 +15650,19 @@ class HouseplanCard extends LitElement { private _previewAlignDialog(removeLiveMissingPositions: boolean): void { if (!this._norm || !this._serverCfg) return; const spaces = this._serverCfg.spaces || []; - const r = optimizePlans( - this._serverCfg, - this._layout || {}, - this._optimizeReferenceContext(removeLiveMissingPositions), - ); + let r; + try { + r = optimizePlans( + this._serverCfg, + this._layout || {}, + this._optimizeReferenceContext(removeLiveMissingPositions), + ); + } catch (error) { + this._showToast(this._t('toast.wall_model_migration_blocked', { + reason: this._wallModelBlockerLabel(error), + })); + return; + } const preflight = r.changed ? this._checkOptimizeGeometry(r.config) : null; // The maximum geometry shift is an UPPER BOUND, not a sample. The run // measured every element in the centimetres of ITS OWN space — converting @@ -15567,6 +15743,10 @@ class HouseplanCard extends LitElement { })); } catch (e: any) { if (this._alignDialog) this._alignDialog = { ...this._alignDialog, busy: false }; + if (e?.code === 'wall_model_client_outdated') { + this._showToast(this._t('toast.wall_model_client_outdated')); + return; + } if (e?.code === 'conflict') { await Promise.all([this._reloadConfigOnly(true), this._reloadLayoutOnly()]); } @@ -16601,6 +16781,7 @@ class HouseplanCard extends LitElement { const repaired = r.spaceRefsRemapped + r.roomRefsRemapped + r.positionsRemapped + r.markersDetached; const modelMaintenance = r.migrated + r.canonicalized + r.coordsCanonicalized + + r.wallSegmentsMigrated + r.wallsMerged + r.spansMerged + r.partitionsMerged + r.partitionsReconciled + r.openingsRehosted + r.redundantDraftsRemoved; const gridWarning = r.moved + r.rotated + r.removedDrafts @@ -16696,6 +16877,9 @@ class HouseplanCard extends LitElement { n: String(r.redundantDraftsRemoved), })}

` : nothing} + ${r.wallSegmentsMigrated ? html`

${this._t( + 'gs.wall_segments_migrated', { n: String(r.wallSegmentsMigrated) }, + )}

` : nothing} ${modelMaintenance ? html`

${this._t('gs.optimize_changes', { m: String(r.migrated), c: String(r.canonicalized), p: String(r.coordsCanonicalized), w: String(r.wallsMerged), diff --git a/src/i18n/en.json b/src/i18n/en.json index dba5c4eb..6532fff4 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -538,6 +538,14 @@ "toast.physical_angle": "Enter a rotation angle from 0° up to, but not including, 90°", "toast.physical_limit": "The space has reached the limit for this type of geometry", "toast.geometry_unsafe": "Change canceled: wall geometry could not be built safely.", + "toast.wall_model_migration_blocked": "The wall model could not be updated: {reason}. The plan was not changed. Run “Optimize plans”; if the error repeats, fix the conflicting wall geometry.", + "toast.wall_model_client_outdated": "Update the card and reload the page before editing the plan.", + "wall_model.reason.invalid-room": "invalid room contour", + "wall_model.reason.zero-length": "zero-length wall segment", + "wall_model.reason.third-owner": "wall shared by more than two rooms", + "wall_model.reason.duplicate-id": "conflicting wall identifiers", + "wall_model.reason.thickness-conflict": "conflicting wall thickness values", + "wall_model.reason.opening-host": "opening cannot be assigned to one wall", "markup.boundary_hint": "Select a shared room boundary or a dashed stretch", "markup.boundary_hint_open": "Click the start of the open stretch", "markup.boundary_hint_second": "Choose the end on the selected boundary · Esc to cancel", @@ -818,6 +826,7 @@ "gs.align_preflight_space": "Space {n}", "gs.align_preflight_more": ", and {n} more", "gs.optimize_changes": "Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}; independent walls: {i}.", + "gs.wall_segments_migrated": "Wall segments stabilised: {n}.", "gs.optimize_lattice_summary": "Noisy coordinate values canonicalized: {n}; maximum movement: {cm} cm.", "gs.optimize_lattice_space": "{space}: coordinate values canonicalized: {n}; off-grid values left unchanged: {far}.", "gs.optimize_coincident_partitions": "Hidden independent wall sections absorbed into room walls: {n}.", @@ -961,6 +970,7 @@ "backup.error.unsupported_export_version": "This backup format is not supported by the installed version.", "backup.error.future_model": "The backup was created by a newer House Plan data model.", "backup.error.invalid_config": "The backup contains an invalid House Plan configuration.", + "backup.error.wall_model_migration_blocked": "The backup wall model could not be upgraded safely. Optimize the source plan first, then export it again.", "backup.error.invalid_layout": "The backup contains invalid object positions.", "backup.error.invalid_content": "The backup contains invalid or inconsistent content references.", "backup.error.space_not_found": "The selected space no longer exists.", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 33f42fbc..03bb2982 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -538,6 +538,14 @@ "toast.physical_angle": "Введите угол поворота от 0° до 90°, не включая 90°", "toast.physical_limit": "В пространстве достигнут лимит объектов этого типа", "toast.geometry_unsafe": "Изменение отменено: геометрию стен нельзя безопасно построить.", + "toast.wall_model_migration_blocked": "Не удалось обновить модель стен: {reason}. План не изменён. Запустите «Оптимизировать планы»; если ошибка повторится, исправьте конфликтующую геометрию стен.", + "toast.wall_model_client_outdated": "Обновите карточку и перезагрузите страницу перед редактированием плана.", + "wall_model.reason.invalid-room": "некорректный контур комнаты", + "wall_model.reason.zero-length": "отрезок стены нулевой длины", + "wall_model.reason.third-owner": "стена принадлежит более чем двум комнатам", + "wall_model.reason.duplicate-id": "конфликт идентификаторов стен", + "wall_model.reason.thickness-conflict": "конфликт значений толщины стены", + "wall_model.reason.opening-host": "проём нельзя однозначно привязать к стене", "markup.boundary_hint": "Выберите общую границу комнат или пунктирный участок", "markup.boundary_hint_open": "Кликните начало открытого участка", "markup.boundary_hint_second": "Укажите конец на выбранной границе · Esc — отмена", @@ -818,6 +826,7 @@ "gs.align_preflight_space": "Пространство {n}", "gs.align_preflight_more": " и ещё {n}", "gs.optimize_changes": "Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}; независимых: {i}.", + "gs.wall_segments_migrated": "Стабилизировано сегментов стен: {n}.", "gs.optimize_lattice_summary": "Канонизировано шумовых значений координат: {n}; максимальный сдвиг: {cm} см.", "gs.optimize_lattice_space": "{space}: канонизировано значений координат: {n}; оставлено значений вне сетки: {far}.", "gs.optimize_coincident_partitions": "Скрытые участки независимых стен поглощены стенами комнат: {n}.", @@ -961,6 +970,7 @@ "backup.error.unsupported_export_version": "Эта версия формата резервной копии не поддерживается установленной версией.", "backup.error.future_model": "Резервная копия создана в более новой версии модели данных House Plan.", "backup.error.invalid_config": "Резервная копия содержит некорректную конфигурацию House Plan.", + "backup.error.wall_model_migration_blocked": "Не удалось безопасно обновить модель стен из резервной копии. Сначала оптимизируйте исходный план и повторите экспорт.", "backup.error.invalid_layout": "Резервная копия содержит некорректные позиции объектов.", "backup.error.invalid_content": "Резервная копия содержит некорректные или несогласованные ссылки на файлы.", "backup.error.space_not_found": "Выбранное пространство больше не существует.", diff --git a/src/logic.ts b/src/logic.ts index d862ea0f..00f02339 100644 --- a/src/logic.ts +++ b/src/logic.ts @@ -371,7 +371,7 @@ export function isActiveState(state?: string | null): boolean { } /** Point equality within a tolerance. */ -export function samePoint(a: number[], b: number[], eps = 0.001): boolean { +export function samePoint(a: readonly number[], b: readonly number[], eps = 0.001): boolean { return Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps; } diff --git a/src/plan-geometry-preflight.ts b/src/plan-geometry-preflight.ts index e81ff084..d6e95253 100644 --- a/src/plan-geometry-preflight.ts +++ b/src/plan-geometry-preflight.ts @@ -104,6 +104,7 @@ export function spacePhysicalGeometryFingerprint(spaceConfig: any): string { cell_cm: spaceConfig?.cell_cm, rooms: spaceConfig?.rooms || [], walls: spaceConfig?.walls || [], + wall_segments: spaceConfig?.wall_segments || [], open_spans: spaceConfig?.open_spans || [], openings: spaceConfig?.openings || [], partitions: spaceConfig?.partitions || [], diff --git a/src/plan-optimizer.ts b/src/plan-optimizer.ts index 242da9a2..431b71a3 100644 --- a/src/plan-optimizer.ts +++ b/src/plan-optimizer.ts @@ -34,9 +34,12 @@ import { repairSpaceReferences, type SpaceReferenceRepairContext, type SpaceReferenceReport, } from './space-reference-repair'; import { repairNearAxisRoomWalls } from './near-axis'; +import { + commitWallSegmentModelInPlace, +} from './wall-segment-model'; /** Bump when a new lossless maintenance pass is added. */ -export const PLAN_MODEL_VERSION = 7; +export const PLAN_MODEL_VERSION = 8; const DEFAULT_CELL_CM = 5; const CELL_CM_MIN = 0.1; const CELL_CM_MAX = 1000; @@ -56,6 +59,8 @@ export interface OptimizeReport extends AlignReport, SpaceReferenceReport { glowRoomsMigrated: number; /** Spaces whose wall/open-span representation was rewritten canonically. */ canonicalized: number; + /** Contour-wall atoms materialised into the stable v8 catalogue. */ + wallSegmentsMigrated: number; /** Redundant equal-thickness wall entries removed by canonicalisation. */ wallsMerged: number; /** Touching/overlapping virtual pieces merged on the same room pair. */ @@ -648,6 +653,14 @@ export function optimizePlans( if (canonicalAfter !== canonicalBefore) canonicalized++; } + // Stage 1 of ADR 282 is itself a lossless maintenance pass. Run it after + // every geometry repair so its catalogue describes the final candidate, not + // the pre-Optimize shape. Future model versions remain opaque/fail-soft. + let wallSegmentsMigrated = 0; + if (modelFrom <= PLAN_MODEL_VERSION) { + wallSegmentsMigrated = commitWallSegmentModelInPlace(config).migratedSegments; + } + // The storage barrier (#224) is part of Optimize's idempotence contract. // A 1/240 grid node has no finite decimal representation: comparing or // returning the raw binary result would let the backend's nine-decimal @@ -707,6 +720,7 @@ export function optimizePlans( glowSpacesMigrated: changed ? migration.glowSpaces : 0, glowRoomsMigrated: changed ? migration.glowRooms : 0, canonicalized: changed ? canonicalized : 0, + wallSegmentsMigrated: changed ? wallSegmentsMigrated : 0, wallsMerged: changed ? wallsMerged : 0, spansMerged: changed ? spansMerged : 0, partitionsMerged: changed ? partitionsMerged : 0, diff --git a/src/resize.ts b/src/resize.ts index 2144de27..3c88cc67 100644 --- a/src/resize.ts +++ b/src/resize.ts @@ -19,7 +19,7 @@ import { classifyNearAxisSegment } from './near-axis'; /** Minimal room dimension in centimetres (owner: «мин. габарит ~30 см»). */ export const MIN_ROOM_CM = 30; -export interface RoomIn { id: string; poly: number[][] } +export interface RoomIn { id: string; poly: number[][]; wall_ids?: string[] } /** Opening in render units: centre, wall angle (deg), full length. */ export interface OpeningIn { id: string; x: number; y: number; length: number } @@ -120,6 +120,43 @@ export interface SafeResizeEligibilityAudit { handles: SafeResizeAuditHandle[]; } +/** + * The v8 wall catalogue stores every structural breakpoint as a polygon + * vertex. Resize is a user-facing side gesture, however, and must not expose + * those identity-only atoms as extra handles. Collapse consecutive forward + * collinear edges for the resize projection; the structural write barrier + * atomises the committed result again afterwards. + */ +export function coalesceResizeRooms(rooms: readonly T[], eps: number): T[] { + return rooms.map((room) => { + if (!Array.isArray(room.wall_ids) || room.wall_ids.length !== room.poly.length) { + return { ...room, poly: room.poly.map((point) => [...point]) }; + } + const poly = room.poly.map((point) => [...point]); + let changed = true; + while (changed && poly.length > 3) { + changed = false; + for (let index = 0; index < poly.length; index++) { + const previous = poly[(index - 1 + poly.length) % poly.length]; + const current = poly[index]; + const next = poly[(index + 1) % poly.length]; + const first = sub(current, previous); + const second = sub(next, current); + const firstLength = len2d(first), secondLength = len2d(second); + if (firstLength <= eps || secondLength <= eps) continue; + const cross = Math.abs(first[0] * second[1] - first[1] * second[0]); + const dot = first[0] * second[0] + first[1] * second[1]; + if (cross <= eps * (firstLength + secondLength) && dot > 0) { + poly.splice(index, 1); + changed = true; + break; + } + } + } + return { ...room, poly }; + }); +} + export interface EdgeDragPlan { roomId: string; edge: number; // edge index i: v[i] -> v[i+1] @@ -976,6 +1013,9 @@ export function auditSafeResizeEligibility( roomId: string, edge: number, a: number[], b: number[], ) => SafeResizeOptions, ): SafeResizeEligibilityAudit { + rooms = coalesceResizeRooms(rooms, Math.max(1e-12, ...rooms.map((room) => ( + room.poly.length ? Math.max(...room.poly.flatMap((point) => point.map(Math.abs))) * 1e-12 : 0 + )))); const disabled = Object.fromEntries( SAFE_RESIZE_REASONS.map((reason) => [reason, 0]), ) as Record; diff --git a/src/room-deletion.ts b/src/room-deletion.ts index b0d900f6..787b8b2f 100644 --- a/src/room-deletion.ts +++ b/src/room-deletion.ts @@ -19,7 +19,7 @@ export interface DeletionOpening { x: number; y: number; angle: number; - host?: { kind: 'partition'; id: string; t: number }; + host?: { kind: 'partition' | 'wall'; id: string; t: number }; } export interface RoomDeletionMaterialization { diff --git a/src/space-geometry.ts b/src/space-geometry.ts index eba1678a..342aadf6 100644 --- a/src/space-geometry.ts +++ b/src/space-geometry.ts @@ -150,6 +150,7 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] { w: r.w != null ? r.w * NORM_W : undefined, h: r.h != null ? r.h * H : undefined, poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined, + wall_ids: Array.isArray(r.wall_ids) ? [...r.wall_ids] : undefined, }; }; const vb = safeViewBox(s.view_box); return { @@ -166,7 +167,10 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] { room_drafts: (s.room_drafts || []).map((d: any) => ({ id: d.id, points: (d.points || []).map((p: number[]) => [p[0] * NORM_W, p[1] * H]), - segments: (d.segments || []).map((sg: any) => ({ cm: Number(sg.cm) })), + segments: (d.segments || []).map((sg: any) => ({ + ...(typeof sg.id === 'string' && sg.id ? { id: sg.id } : {}), + cm: Number(sg.cm), + })), })), partitions: (s.partitions || []).map((p: any) => ({ id: p.id, diff --git a/src/types.ts b/src/types.ts index fccde8ec..58cf2191 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,8 @@ export interface RoomCfg { w?: number; h?: number; poly?: number[][]; // polygon in render units (model) / normalized (config) + /** v8: one stable contour-wall id for every edge in `poly`. */ + wall_ids?: string[]; } /** Wall thickness entry (docs/WALL-THICKNESS.md) — always centimetres in config. */ @@ -40,13 +42,22 @@ export interface WallEntry { b?: number[]; } +/** Canonical v8 contour-wall atom. `cm: 0` is a thin contour, not masonry. */ +export interface WallSegmentEntry { + id: string; + a: number[]; + b: number[]; + cm: number; + [key: string]: unknown; +} + /** Persisted open room contour. Coordinates are normalised in config and * render units in SpaceModel, exactly like rooms. */ export interface RoomDraftCfg { id: string; points: number[][]; /** One thickness entry for every consecutive pair of points. */ - segments: Array<{ cm: number }>; + segments: Array<{ id?: string; cm: number }>; } /** A single independent physical wall which never owns or splits a room. */ @@ -176,6 +187,16 @@ export interface PartitionOpeningHost { t: number; } +export interface WallOpeningHost { + kind: 'wall'; + /** Stable id of one v8 contour-wall atom in the same space. */ + id: string; + /** Centre position along the canonical stored segment a -> b. */ + t: number; +} + +export type OpeningHost = PartitionOpeningHost | WallOpeningHost; + export interface OpeningCfg { id: string; type: 'door' | 'window' | 'gate' | 'passage'; @@ -189,10 +210,11 @@ export interface OpeningCfg { flip_h?: boolean; // hinge on the other jamb flip_v?: boolean; // opens to the other side of the wall /** Explicit owner for an opening cut into an independent wall. */ - host?: PartitionOpeningHost; + host?: OpeningHost; } export interface ServerConfig { + model_version?: number; spaces: any[]; markers: Marker[]; settings: { diff --git a/src/wall-segment-model.ts b/src/wall-segment-model.ts new file mode 100644 index 00000000..02c3f23c --- /dev/null +++ b/src/wall-segment-model.ts @@ -0,0 +1,514 @@ +/** + * Persisted identity for contour-wall atoms (ADR 282, Stage 1). + * + * The editor still renders through the legacy `walls[]` projection. This + * module is the single structural-write barrier which owns the canonical v8 + * catalogue and regenerates that projection. It is deliberately DOM-free so + * the frontend and migration contracts can be exercised as pure tests. + */ + +import { canonicalizeConfigGeometryInPlace } from './coordinate-canonicalization'; +import { roomPoly } from './logic'; +import { sanitizeOpenSpans } from './open-spans'; +import { GRID_STEP_N } from './space-geometry'; +import { + atomicPolyForRoom, thicknessCmAt, wallAngleMatches, wallKey, type WallEntry, +} from './wall-thickness'; +import type { OpeningCfg, WallSegmentEntry } from './types'; + +export const WALL_SEGMENT_MODEL_VERSION = 8; +const EPS = 1e-9; + +export type WallSegmentModelBlocker = + | 'invalid-room' + | 'zero-length' + | 'third-owner' + | 'duplicate-id' + | 'thickness-conflict' + | 'opening-host'; + +export class WallSegmentModelError extends Error { + constructor(public readonly reason: WallSegmentModelBlocker, detail = '') { + super(detail ? `${reason}: ${detail}` : reason); + this.name = 'WallSegmentModelError'; + } +} + +export interface WallSegmentCommitResult { + config: T; + changed: boolean; + migratedSegments: number; +} + +type Point = [number, number]; +type Atom = { + key: string; + a: Point; + b: Point; + owners: Set; + preferredIds: Set; + preferredCarriers: Map; + parentKeys: Set; + id?: string; +}; + +const clone = (value: T): T => JSON.parse(JSON.stringify(value)); +const finitePoint = (value: unknown): value is number[] => ( + Array.isArray(value) && value.length >= 2 + && Number.isFinite(Number(value[0])) && Number.isFinite(Number(value[1])) +); +const point = (value: number[]): Point => [Number(value[0]), Number(value[1])]; +const pointKey = (value: number[]): string => ( + `${Number(value[0]).toFixed(12)},${Number(value[1]).toFixed(12)}` +); +const atomKey = (a: number[], b: number[]): string => { + const ka = pointKey(a), kb = pointKey(b); + return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; +}; +const canonicalSpan = (a: number[], b: number[]): [Point, Point] => ( + pointKey(a) <= pointKey(b) ? [point(a), point(b)] : [point(b), point(a)] +); +const lengthOf = (a: number[], b: number[]): number => Math.hypot(b[0] - a[0], b[1] - a[1]); +const projectT = (p: number[], a: number[], b: number[]): number => { + const dx = b[0] - a[0], dy = b[1] - a[1]; + const den = dx * dx + dy * dy; + return den > EPS * EPS ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / den : 0; +}; +const distanceToSegment = (p: number[], a: number[], b: number[]): number => { + const t = Math.max(0, Math.min(1, projectT(p, a, b))); + return Math.hypot(p[0] - (a[0] + (b[0] - a[0]) * t), p[1] - (a[1] + (b[1] - a[1]) * t)); +}; +const collinearOverlap = ( + a: number[], b: number[], c: number[], d: number[], epsilon = EPS, +): number => { + const dx = b[0] - a[0], dy = b[1] - a[1], len = Math.hypot(dx, dy); + if (len <= epsilon) return 0; + const cross = (p: number[]) => Math.abs((p[0] - a[0]) * dy - (p[1] - a[1]) * dx) / len; + if (cross(c) > epsilon || cross(d) > epsilon) return 0; + const tc = projectT(c, a, b), td = projectT(d, a, b); + return Math.max(0, Math.min(1, Math.max(tc, td)) - Math.max(0, Math.min(tc, td))) * len; +}; + +/* Small synchronous SHA-256: structural commits are synchronous gestures, so + * WebCrypto's Promise API cannot be the source of migration identity. */ +const sha256 = (text: string): Uint8Array => { + const bytes = new TextEncoder().encode(text); + const bitLength = bytes.length * 8; + const size = Math.ceil((bytes.length + 9) / 64) * 64; + const data = new Uint8Array(size); + data.set(bytes); + data[bytes.length] = 0x80; + const view = new DataView(data.buffer); + view.setUint32(size - 8, Math.floor(bitLength / 0x100000000), false); + view.setUint32(size - 4, bitLength >>> 0, false); + const k = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ]); + const h = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]); + const w = new Uint32Array(64); + const rotr = (value: number, bits: number) => (value >>> bits) | (value << (32 - bits)); + for (let offset = 0; offset < size; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4, false); + for (let i = 16; i < 64; i++) { + const x = w[i - 15], y = w[i - 2]; + const s0 = rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3); + const s1 = rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + let [a, b, c, d, e, f, g, hh] = h; + for (let i = 0; i < 64; i++) { + const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + s1 + ch + k[i] + w[i]) >>> 0; + const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (s0 + maj) >>> 0; + hh = g; g = f; f = e; e = (d + t1) >>> 0; + d = c; c = b; b = a; a = (t1 + t2) >>> 0; + } + h[0] = (h[0] + a) >>> 0; h[1] = (h[1] + b) >>> 0; + h[2] = (h[2] + c) >>> 0; h[3] = (h[3] + d) >>> 0; + h[4] = (h[4] + e) >>> 0; h[5] = (h[5] + f) >>> 0; + h[6] = (h[6] + g) >>> 0; h[7] = (h[7] + hh) >>> 0; + } + const out = new Uint8Array(32); + const outView = new DataView(out.buffer); + for (let i = 0; i < h.length; i++) outView.setUint32(i * 4, h[i], false); + return out; +}; + +const base32 = (bytes: Uint8Array): string => { + const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; + let bits = 0, value = 0, result = ''; + for (const byte of bytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + result += alphabet[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits) result += alphabet[(value << (5 - bits)) & 31]; + return result; +}; + +export const deterministicWallSegmentId = ( + spaceId: string, a: number[], b: number[], ownerIds: readonly string[], salt = '', +): string => { + const [ca, cb] = canonicalSpan(a, b); + const seed = `${spaceId}|${pointKey(ca)}|${pointKey(cb)}|${[...ownerIds].sort().join(',')}${salt}`; + return `wall-${base32(sha256(seed)).slice(0, 20)}`; +}; + +let fallbackIdSequence = 0; + +/** IDs created after v8 materialisation must never be derived from geometry. */ +const freshWallSegmentId = (used: Set): string => { + for (let attempt = 0; attempt < 1000; attempt++) { + const uuid = globalThis.crypto?.randomUUID?.(); + const entropy = uuid || base32(sha256( + `${Date.now()}|${fallbackIdSequence++}|${Math.random()}`, + )).slice(0, 26); + const id = `wall-${entropy}`; + if (!used.has(id)) return id; + } + throw new WallSegmentModelError('duplicate-id', 'id factory exhausted'); +}; + +const nonCatalogIds = (space: any): Set => { + const result = new Set(); + for (const collection of [ + space.rooms, space.openings, space.decor, space.room_drafts, + space.partitions, space.wall_columns, + ]) { + for (const item of Array.isArray(collection) ? collection : []) { + if (typeof item?.id === 'string' && item.id) result.add(item.id); + } + } + for (const draft of Array.isArray(space.room_drafts) ? space.room_drafts : []) { + for (const segment of Array.isArray(draft?.segments) ? draft.segments : []) { + if (typeof segment?.id === 'string' && segment.id) result.add(segment.id); + } + } + return result; +}; + +const oldSegmentMap = (space: any): Map => { + const result = new Map(); + for (const segment of Array.isArray(space?.wall_segments) ? space.wall_segments : []) { + if (!segment || typeof segment.id !== 'string' || !finitePoint(segment.a) || !finitePoint(segment.b)) continue; + if (result.has(segment.id)) throw new WallSegmentModelError('duplicate-id', segment.id); + result.set(segment.id, segment as WallSegmentEntry); + } + return result; +}; + +const openingHostCounts = (openings: readonly OpeningCfg[]): Map => { + const result = new Map(); + for (const opening of openings) { + if (opening.host?.kind !== 'wall') continue; + result.set(opening.host.id, (result.get(opening.host.id) || 0) + 1); + } + return result; +}; + +const buildAtoms = (space: any): { atoms: Atom[]; rooms: any[] } => { + const rooms = Array.isArray(space.rooms) ? space.rooms : []; + const cuts = sanitizeOpenSpans(space.open_spans).map((entry) => [ + entry.a[0], entry.a[1], entry.b[0], entry.b[1], + ]); + const byKey = new Map(); + const nextRooms: any[] = []; + for (const rawRoom of rooms) { + const id = String(rawRoom?.id || ''); + const original = roomPoly(rawRoom); + if (!id || !original || original.length < 3) throw new WallSegmentModelError('invalid-room', id); + const atomic = atomicPolyForRoom(rooms, id, cuts, GRID_STEP_N, 1, space.walls || []); + if (!atomic || atomic.poly.length < 3) throw new WallSegmentModelError('invalid-room', id); + const oldIds = Array.isArray(rawRoom.wall_ids) ? rawRoom.wall_ids : []; + const indexedLineageIsValid = oldIds.length === original.length; + const wallIds: string[] = []; + for (let index = 0; index < atomic.poly.length; index++) { + const a = atomic.poly[index], b = atomic.poly[(index + 1) % atomic.poly.length]; + if (lengthOf(a, b) <= EPS) throw new WallSegmentModelError('zero-length', id); + const key = atomKey(a, b); + let atom = byKey.get(key); + if (!atom) { + const [ca, cb] = canonicalSpan(a, b); + atom = { + key, a: ca, b: cb, owners: new Set(), preferredIds: new Set(), + preferredCarriers: new Map(), parentKeys: new Set(), + }; + byKey.set(key, atom); + } + atom.owners.add(id); + if (atom.owners.size > 2) throw new WallSegmentModelError('third-owner', key); + const preferred = indexedLineageIsValid ? oldIds[atomic.parent[index]] : undefined; + if (typeof preferred === 'string' && preferred) { + atom.preferredIds.add(preferred); + atom.preferredCarriers.set(preferred, { + a: point(original[atomic.parent[index]]), + b: point(original[(atomic.parent[index] + 1) % original.length]), + }); + } + const parent = atomic.parent[index]; + atom.parentKeys.add(wallKey( + original[parent], original[(parent + 1) % original.length], GRID_STEP_N, + )); + wallIds.push(key); // replaced with stable ids after lineage resolution + } + // Re-add the owned field last on every pass. Several legacy maintenance + // helpers legitimately rebuild `open_to`; without this normalization the + // semantic object is identical but JSON key order oscillates forever and + // Optimize can never become byte-idempotent. + const nextRoom = { ...rawRoom }; + delete nextRoom.wall_ids; + nextRooms.push({ + ...nextRoom, + poly: atomic.poly.map((p) => [p[0], p[1]]), + wall_ids: wallIds, + }); + } + return { atoms: [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key)), rooms: nextRooms }; +}; + +const assignLineage = ( + space: any, atoms: Atom[], old: Map, initialMigration: boolean, +): void => { + const oldByKey = new Map([...old.values()].map((segment) => [atomKey(segment.a, segment.b), segment])); + const hostCounts = openingHostCounts(space.openings || []); + const proposed = new Map(); + for (const atom of atoms) { + if (atom.preferredIds.size > 1) { + throw new WallSegmentModelError('duplicate-id', [...atom.preferredIds].sort().join(',')); + } + const preferredId = [...atom.preferredIds][0]; + const preferred = preferredId ? old.get(preferredId) : undefined; + if (preferred) { proposed.set(atom, preferred); continue; } + // Promotion from a persisted draft/partition supplies an ID which is not + // in the old contour catalogue yet. Treat its old carrier exactly like a + // contour split so only one child inherits it. + const carrier = preferredId ? atom.preferredCarriers.get(preferredId) : undefined; + if (preferredId && carrier) { + proposed.set(atom, { id: preferredId, a: carrier.a, b: carrier.b, cm: 0 }); + continue; + } + const exact = oldByKey.get(atom.key); + if (exact) { proposed.set(atom, exact); continue; } + const overlaps = [...old.values()].filter((segment) => ( + collinearOverlap(atom.a, atom.b, segment.a, segment.b) > EPS + )); + // Geometry proves split/merge lineage more strongly than a stale edge + // index. Positional preference is the rigid-move fallback only when the + // edited segment no longer overlaps its previous coordinates at all. + const candidates = [...new Map(overlaps + .map((item) => [item.id, item])).values()]; + candidates.sort((left, right) => ( + (hostCounts.get(right.id) || 0) - (hostCounts.get(left.id) || 0) + || lengthOf(right.a, right.b) - lengthOf(left.a, left.b) + || left.id.localeCompare(right.id) + )); + if (candidates[0]) proposed.set(atom, candidates[0]); + } + const byId = new Map(); + for (const [atom, segment] of proposed) { + const list = byId.get(segment.id) || []; + list.push(atom); + byId.set(segment.id, list); + } + for (const [id, candidates] of byId) { + const oldSegment = old.get(id) || proposed.get(candidates[0]); + if (!oldSegment) throw new WallSegmentModelError('duplicate-id', id); + const midpoint = [ + (oldSegment.a[0] + oldSegment.b[0]) / 2, + (oldSegment.a[1] + oldSegment.b[1]) / 2, + ]; + candidates.sort((left, right) => { + const midpointLeft = distanceToSegment(midpoint, left.a, left.b) <= EPS ? 0 : 1; + const midpointRight = distanceToSegment(midpoint, right.a, right.b) <= EPS ? 0 : 1; + if (midpointLeft !== midpointRight) return midpointLeft - midpointRight; + const oldALeft = distanceToSegment(oldSegment.a, left.a, left.b) <= EPS ? 0 : 1; + const oldARight = distanceToSegment(oldSegment.a, right.a, right.b) <= EPS ? 0 : 1; + return oldALeft - oldARight || left.key.localeCompare(right.key); + }); + candidates[0].id = id; + } + const used = nonCatalogIds(space); + for (const atom of atoms) { + if (!atom.id) continue; + if (used.has(atom.id)) throw new WallSegmentModelError('duplicate-id', atom.id); + used.add(atom.id); + } + const unassigned = atoms.filter((atom) => !atom.id); + if (initialMigration) { + // A truncated-digest collision is resolved by full-digest order, then the + // documented -2/-3 suffix. This is deterministic even if room order is not. + const seeds = unassigned.map((atom) => { + const [ca, cb] = canonicalSpan(atom.a, atom.b); + const seed = `${String(space.id || '')}|${pointKey(ca)}|${pointKey(cb)}|${[...atom.owners].sort().join(',')}`; + const digest = base32(sha256(seed)); + return { atom, seed, digest, base: `wall-${digest.slice(0, 20)}` }; + }).sort((left, right) => left.digest.localeCompare(right.digest) || left.atom.key.localeCompare(right.atom.key)); + const fullDigests = new Map(); + for (const entry of seeds) { + const previousSeed = fullDigests.get(entry.digest); + if (previousSeed && previousSeed !== entry.seed) + throw new WallSegmentModelError('duplicate-id', entry.digest); + fullDigests.set(entry.digest, entry.seed); + let suffix = 1; + let id = entry.base; + while (used.has(id)) id = `${entry.base}-${++suffix}`; + entry.atom.id = id; + used.add(id); + } + } else { + for (const atom of unassigned) { + atom.id = freshWallSegmentId(used); + used.add(atom.id); + } + } +}; + +const hostRoomOpenings = (space: any, segments: readonly WallSegmentEntry[]): void => { + const openings: OpeningCfg[] = Array.isArray(space.openings) ? space.openings : []; + for (const opening of openings) { + if (opening.host?.kind === 'partition') continue; + const centre = [Number(opening.x), Number(opening.y)]; + if (!centre.every(Number.isFinite)) throw new WallSegmentModelError('opening-host', opening.id); + const eligible = (segment: WallSegmentEntry): boolean => { + const t = projectT(centre, segment.a, segment.b); + const span = lengthOf(segment.a, segment.b); + const half = Number(opening.length) / 2; + return t >= -EPS && t <= 1 + EPS + && distanceToSegment(centre, segment.a, segment.b) <= GRID_STEP_N * 0.02 + && wallAngleMatches(segment.a, segment.b, Number(opening.angle)) + && Number.isFinite(half) && half >= 0 + && t * span - half >= -EPS && t * span + half <= span + EPS; + }; + const current = opening.host?.kind === 'wall' + ? segments.find((segment) => segment.id === opening.host!.id) + : null; + const candidates = current && eligible(current) ? [current] : segments.filter(eligible); + if (candidates.length !== 1) throw new WallSegmentModelError('opening-host', opening.id); + const host = candidates[0]; + opening.host = { + kind: 'wall', id: host.id, + t: Math.max(0, Math.min(1, projectT(centre, host.a, host.b))), + }; + } +}; + +const migrateDraftSegments = (space: any, initialMigration: boolean): void => { + const used = new Set(); + for (const collection of [ + space.rooms, space.openings, space.decor, space.room_drafts, space.partitions, + space.wall_columns, space.wall_segments, + ]) { + for (const item of Array.isArray(collection) ? collection : []) { + if (typeof item?.id === 'string' && item.id) used.add(item.id); + } + } + for (const draft of Array.isArray(space.room_drafts) ? space.room_drafts : []) { + const segments = Array.isArray(draft.segments) ? draft.segments : []; + for (let index = 0; index < segments.length; index++) { + const segment = segments[index]; + if (typeof segment.id === 'string' && segment.id) { + if (used.has(segment.id)) throw new WallSegmentModelError('duplicate-id', segment.id); + used.add(segment.id); + continue; + } + const a = draft.points?.[index], b = draft.points?.[index + 1]; + if (!finitePoint(a) || !finitePoint(b)) throw new WallSegmentModelError('zero-length', draft.id); + if (initialMigration) { + const base = deterministicWallSegmentId( + String(space.id || ''), a, b, [`draft:${String(draft.id || '')}`], + ); + let suffix = 1; + segment.id = base; + while (used.has(segment.id)) segment.id = `${base}-${++suffix}`; + } else segment.id = freshWallSegmentId(used); + used.add(segment.id); + } + } +}; + +const resolvedThicknessCm = ( + space: any, atom: Atom, previous: WallSegmentEntry | undefined, +): number => { + const walls: WallEntry[] = Array.isArray(space.walls) ? space.walls : []; + const ownKey = wallKey(atom.a, atom.b, GRID_STEP_N); + const candidates = walls.filter((wall) => { + if (!(Number(wall.cm) > 0)) return false; + if (wall.key === ownKey || atom.parentKeys.has(wall.key)) return true; + if (!finitePoint(wall.a) || !finitePoint(wall.b)) return false; + return collinearOverlap(atom.a, atom.b, wall.a, wall.b) >= lengthOf(atom.a, atom.b) - EPS; + }).map((wall) => Math.max(1, Math.min(100, Number(wall.cm)))); + const unique = new Set(candidates.map((cm) => cm.toFixed(9))); + if (unique.size > 1) throw new WallSegmentModelError('thickness-conflict', atom.key); + if (candidates.length) return candidates[0]; + const resolved = thicknessCmAt(walls, atom.a, atom.b, GRID_STEP_N, 1); + if (resolved > 0) return resolved; + return Number(previous?.cm) > 0 ? Number(previous!.cm) : 0; +}; + +const migrateSpace = (space: any, initialMigration: boolean): number => { + const old = oldSegmentMap(space); + const { atoms, rooms } = buildAtoms(space); + assignLineage(space, atoms, old, initialMigration); + const segments = atoms.map((atom): WallSegmentEntry => { + const previous = atom.id ? old.get(atom.id) : undefined; + const cm = resolvedThicknessCm(space, atom, previous); + return { + ...(previous || {}), id: atom.id!, a: [...atom.a], b: [...atom.b], cm, + }; + }); + const idByKey = new Map(atoms.map((atom) => [atom.key, atom.id!])); + for (const room of rooms) room.wall_ids = room.wall_ids.map((key: string) => idByKey.get(key)!); + space.rooms = rooms; + space.wall_segments = segments; + space.walls = segments.filter((segment) => segment.cm > 0).map((segment) => ({ + key: wallKey(segment.a, segment.b, GRID_STEP_N), + cm: segment.cm, a: [...segment.a], b: [...segment.b], + })); + if (!space.walls.length) delete space.walls; + migrateDraftSegments(space, initialMigration); + hostRoomOpenings(space, segments); + return segments.reduce((count, segment) => count + (old.has(segment.id) ? 0 : 1), 0); +}; + +/** Pure, atomic structural candidate. Read paths must never call this helper. */ +export function commitWallSegmentModel(input: T): WallSegmentCommitResult { + const before = JSON.stringify(input); + const config: any = clone(input); + canonicalizeConfigGeometryInPlace(config); + let migratedSegments = 0; + const initialMigration = Number(config?.model_version || 0) < WALL_SEGMENT_MODEL_VERSION; + for (const space of Array.isArray(config?.spaces) ? config.spaces : []) { + migratedSegments += migrateSpace(space, initialMigration); + } + config.model_version = WALL_SEGMENT_MODEL_VERSION; + canonicalizeConfigGeometryInPlace(config); + return { config, changed: before !== JSON.stringify(config), migratedSegments }; +} + +/** In-place adapter for the already-pure Optimize candidate. */ +export function commitWallSegmentModelInPlace(input: T): WallSegmentCommitResult { + const result = commitWallSegmentModel(input); + const target: any = input; + for (const key of Object.keys(target || {})) delete target[key]; + Object.assign(target, result.config); + return { ...result, config: input }; +} diff --git a/test/coincident-partitions.test.mjs b/test/coincident-partitions.test.mjs index 1b443567..45b2468c 100644 --- a/test/coincident-partitions.test.mjs +++ b/test/coincident-partitions.test.mjs @@ -39,7 +39,10 @@ test('issue 276 reconciles the anonymized 5 cm offset fixture without moving its x: 121 / 240, y: 0.5, angle: -90, length: 0.2, contact: 'binary_sensor.test_door', lock: 'lock.test_door', invert: true, flip_h: true, future_field: { keep: true }, + host: { kind: 'wall', id: space.openings[0].host.id, t: 0.5 }, }); + assert.ok(space.wall_segments.some((wall) => wall.id === space.openings[0].host.id), + 'the migrated opening is hosted by a stored contour wall'); const intervals = wallIntervals( space.rooms, space.walls, [], S, 5, GRID_PITCH, 1, ).filter((interval) => interval.kind === 'shared'); @@ -99,7 +102,8 @@ test('issue 276 rehosts three non-overlapping door/window/gate openings atomical assert.deepEqual(openings.map((opening) => opening.id), ['hosted-door', 'hosted-window', 'hosted-gate']); assert.deepEqual(openings.map((opening) => opening.type), ['door', 'window', 'gate']); - assert.ok(openings.every((opening) => opening.host === undefined)); + assert.ok(openings.every((opening) => opening.host?.kind === 'wall')); + assert.equal(new Set(openings.map((opening) => opening.host.id)).size, 1); assert.ok(openings.every((opening) => opening.x === 121 / 240)); assert.ok(openings[0].y < openings[1].y && openings[1].y < openings[2].y); assert.equal(openings[0].contact, 'binary_sensor.door'); diff --git a/test/fixtures/280-optimize-rehost-candidate.json b/test/fixtures/280-optimize-rehost-candidate.json index 521f669d..e6ed5577 100644 --- a/test/fixtures/280-optimize-rehost-candidate.json +++ b/test/fixtures/280-optimize-rehost-candidate.json @@ -1,5 +1,5 @@ { - "model_version": 7, + "model_version": 8, "spaces": [ { "id": "offset-shared-wall", @@ -13,8 +13,15 @@ "area": null, "poly": [ [0, 0], [0.5, 0], [0.5, 0.004166666666666667], - [0.5041666666666667, 0.004166666666666667], [0.5041666666666667, 0.9958333333333333], + [0.5041666666666667, 0.004166666666666667], + [0.5041666666666667, 0.9958333333333333], [0.5, 0.9958333333333333], [0.5, 1], [0, 1] + ], + "wall_ids": [ + "wall-jqm76ztdnzk4hxxq4f2v", "wall-lpihsklwsegsow7swcot", + "wall-rsillbgj56qyurceosfa", "wall-r3tuoz4rgdawcgnhkcha", + "wall-6zxvpkmnrthjifm2mnzt", "wall-vq6hgauic4zxd5r7zlet", + "wall-mkjekzxnixddfnfewsg7", "wall-l4hjd3v6q2ih4lmhyebi" ] }, { @@ -22,8 +29,13 @@ "name": "Right", "area": null, "poly": [ - [0.5041666666666667, 0.004166666666666667], [1, 0.004166666666666667], - [1, 0.9958333333333333], [0.5041666666666667, 0.9958333333333333] + [0.5041666666666667, 0.004166666666666667], + [1, 0.004166666666666667], [1, 0.9958333333333333], + [0.5041666666666667, 0.9958333333333333] + ], + "wall_ids": [ + "wall-phv7rbxb7jl4tbhwnlkk", "wall-kl2kghjvpn3k72ogi7nk", + "wall-2fff2kin2gzrvrkxytbz", "wall-r3tuoz4rgdawcgnhkcha" ] } ], @@ -47,8 +59,22 @@ "lock": "lock.test_door", "invert": true, "flip_h": true, - "future_field": { "keep": true } + "future_field": { "keep": true }, + "host": { "kind": "wall", "id": "wall-r3tuoz4rgdawcgnhkcha", "t": 0.5 } } + ], + "wall_segments": [ + { "id": "wall-l4hjd3v6q2ih4lmhyebi", "a": [0, 0], "b": [0, 1], "cm": 0 }, + { "id": "wall-jqm76ztdnzk4hxxq4f2v", "a": [0, 0], "b": [0.5, 0], "cm": 0 }, + { "id": "wall-mkjekzxnixddfnfewsg7", "a": [0, 1], "b": [0.5, 1], "cm": 0 }, + { "id": "wall-lpihsklwsegsow7swcot", "a": [0.5, 0], "b": [0.5, 0.004166666666666667], "cm": 0 }, + { "id": "wall-rsillbgj56qyurceosfa", "a": [0.5, 0.004166666666666667], "b": [0.5041666666666667, 0.004166666666666667], "cm": 0 }, + { "id": "wall-vq6hgauic4zxd5r7zlet", "a": [0.5, 0.9958333333333333], "b": [0.5, 1], "cm": 0 }, + { "id": "wall-6zxvpkmnrthjifm2mnzt", "a": [0.5, 0.9958333333333333], "b": [0.5041666666666667, 0.9958333333333333], "cm": 0 }, + { "id": "wall-r3tuoz4rgdawcgnhkcha", "a": [0.5041666666666667, 0.004166666666666667], "b": [0.5041666666666667, 0.9958333333333333], "cm": 20 }, + { "id": "wall-phv7rbxb7jl4tbhwnlkk", "a": [0.5041666666666667, 0.004166666666666667], "b": [1, 0.004166666666666667], "cm": 0 }, + { "id": "wall-2fff2kin2gzrvrkxytbz", "a": [0.5041666666666667, 0.9958333333333333], "b": [1, 0.9958333333333333], "cm": 0 }, + { "id": "wall-kl2kghjvpn3k72ogi7nk", "a": [1, 0.004166666666666667], "b": [1, 0.9958333333333333], "cm": 0 } ] } ], diff --git a/test/fixtures/281-resize-outer-candidate.json b/test/fixtures/281-resize-outer-candidate.json index 9a6720b8..33d2875c 100644 --- a/test/fixtures/281-resize-outer-candidate.json +++ b/test/fixtures/281-resize-outer-candidate.json @@ -1,37 +1,57 @@ { - "model_version": 7, - "spaces": [ - { - "id": "resize-outer", - "title": "Resize outer", - "view_box": [0, 0, 1, 1], - "cell_cm": 5, - "rooms": [ - { "id": "left", "name": "Left", "area": null, - "poly": [[0, 0], [0.5, 0], [0.5, 1], [0, 1]] }, - { "id": "right", "name": "Right", "area": null, - "poly": [[0.5, 0], [1, 0], [1, 1], [0.5, 1]] } - ], - "walls": [ - { "key": "0.500000,0.500000@1.5706", "cm": 20, "a": [0.5, 0], "b": [0.5, 1] }, - { "key": "0.250000,0.000000@0.0000", "cm": 30, "a": [0, 0], "b": [0.5, 0] }, - { "key": "0.750000,0.000000@0.0000", "cm": 30, "a": [0.5, 0], "b": [1, 0] }, - { "key": "0.750000,1.000000@0.0000", "cm": 20, "a": [1, 1], "b": [0.5, 1] } - ], - "openings": [ - { - "id": "left-window", "type": "window", - "x": 0.25, "y": 0, "angle": 0, "length": 0.2, - "cover": "cover.left", "future_field": { "keep": "left" } - }, - { - "id": "right-window", "type": "window", - "x": 0.75, "y": 0, "angle": 0, "length": 0.2, - "contact": "binary_sensor.right" - } - ] - } - ], + "model_version": 8, + "spaces": [{ + "id": "resize-outer", + "title": "Resize outer", + "view_box": [0, 0, 1, 1], + "cell_cm": 5, + "rooms": [ + { + "id": "left", "name": "Left", "area": null, + "poly": [[0, 0], [0.5, 0], [0.5, 1], [0, 1]], + "wall_ids": [ + "wall-efvwngyqwvt3i2wtucjl", "wall-xhivulbmxmricwrsavtk", + "wall-m7fvm7py63vewrj25txx", "wall-5mx4b2qfylbnhqigyosw" + ] + }, + { + "id": "right", "name": "Right", "area": null, + "poly": [[0.5, 0], [1, 0], [1, 1], [0.5, 1]], + "wall_ids": [ + "wall-yvbffwd5yd237kwqwlsj", "wall-ret3zsfn72ub2hil6ap3", + "wall-3w4swceywbniqg2o7ux4", "wall-xhivulbmxmricwrsavtk" + ] + } + ], + "walls": [ + { "key": "0.250000,0.000000@0.0000", "cm": 30, "a": [0, 0], "b": [0.5, 0] }, + { "key": "0.500000,0.500000@1.5706", "cm": 20, "a": [0.5, 0], "b": [0.5, 1] }, + { "key": "0.750000,0.000000@0.0000", "cm": 30, "a": [0.5, 0], "b": [1, 0] }, + { "key": "0.750000,1.000000@0.0000", "cm": 20, "a": [0.5, 1], "b": [1, 1] } + ], + "openings": [ + { + "id": "left-window", "type": "window", "x": 0.25, "y": 0, + "angle": 0, "length": 0.2, "cover": "cover.left", + "future_field": { "keep": "left" }, + "host": { "kind": "wall", "id": "wall-efvwngyqwvt3i2wtucjl", "t": 0.5 } + }, + { + "id": "right-window", "type": "window", "x": 0.75, "y": 0, + "angle": 0, "length": 0.2, "contact": "binary_sensor.right", + "host": { "kind": "wall", "id": "wall-yvbffwd5yd237kwqwlsj", "t": 0.5 } + } + ], + "wall_segments": [ + { "id": "wall-5mx4b2qfylbnhqigyosw", "a": [0, 0], "b": [0, 1], "cm": 0 }, + { "id": "wall-efvwngyqwvt3i2wtucjl", "a": [0, 0], "b": [0.5, 0], "cm": 30 }, + { "id": "wall-m7fvm7py63vewrj25txx", "a": [0, 1], "b": [0.5, 1], "cm": 0 }, + { "id": "wall-xhivulbmxmricwrsavtk", "a": [0.5, 0], "b": [0.5, 1], "cm": 20 }, + { "id": "wall-yvbffwd5yd237kwqwlsj", "a": [0.5, 0], "b": [1, 0], "cm": 30 }, + { "id": "wall-3w4swceywbniqg2o7ux4", "a": [0.5, 1], "b": [1, 1], "cm": 20 }, + { "id": "wall-ret3zsfn72ub2hil6ap3", "a": [1, 0], "b": [1, 1], "cm": 0 } + ] + }], "markers": [], "settings": {} } diff --git a/test/fixtures/282-wall-identity-parity.json b/test/fixtures/282-wall-identity-parity.json new file mode 100644 index 00000000..69027c2f --- /dev/null +++ b/test/fixtures/282-wall-identity-parity.json @@ -0,0 +1,58 @@ +{ + "input": { + "spaces": [{ + "id": "floor", + "title": "Floor", + "rooms": [ + { "id": "large", "poly": [[0, 0], [1, 0], [1, 1], [0, 1]] }, + { "id": "small", "poly": [[1, 0.25], [1.5, 0.25], [1.5, 0.75], [1, 0.75]] } + ], + "walls": [{ + "key": "0.500000,0.000000@0.0000", + "a": [0, 0], + "b": [1, 0], + "cm": 15 + }], + "openings": [{ + "id": "door", + "type": "door", + "x": 0.5, + "y": 0, + "angle": 0, + "length": 0.2 + }], + "room_drafts": [{ + "id": "draft", + "points": [[2, 0], [2.5, 0], [3, 0]], + "segments": [{ "cm": 10 }, { "cm": 20 }] + }] + }], + "markers": [], + "settings": {} + }, + "expected": { + "large_wall_ids": [ + "wall-7cu6gpb7e44ucopgzjm2", + "wall-vgpy3fkbbprvy2cofyq3", + "wall-g33xjvyd5marxwkowoqm", + "wall-tqy5sdtydjrx7syybupl", + "wall-j2ti4ktyymgw4e2r5ns5", + "wall-oq2aqjdsktorqgxatdwh" + ], + "small_wall_ids": [ + "wall-d2hfqa5fvueqwrwo2da7", + "wall-ybr4j2bztfx2wkbwvbwd", + "wall-qancsrbiwkhhy5hos7io", + "wall-g33xjvyd5marxwkowoqm" + ], + "opening_host": { + "kind": "wall", + "id": "wall-7cu6gpb7e44ucopgzjm2", + "t": 0.5 + }, + "draft_ids": [ + "wall-ulziozdfsjzlriamibvg", + "wall-bvlpyhrsy6xjgox3g6e6" + ] + } +} diff --git a/test/optimize-hidden-obstacles.test.mjs b/test/optimize-hidden-obstacles.test.mjs index 90af11a4..6d4ed490 100644 --- a/test/optimize-hidden-obstacles.test.mjs +++ b/test/optimize-hidden-obstacles.test.mjs @@ -101,8 +101,15 @@ test('issue 296 draft cleanup is all-or-nothing and preserves legal unfinished w assert.deepEqual(result.config.spaces[0].room_drafts.map((draft) => draft.id), [ 'free', 'partial', 'thicker', ]); - assert.deepEqual(result.config.spaces[0].room_drafts, - space.room_drafts.filter((draft) => draft.id !== 'hidden')); + assert.deepEqual( + result.config.spaces[0].room_drafts.map((draft) => ({ + ...draft, segments: draft.segments.map(({ id: _id, ...segment }) => segment), + })), + space.room_drafts.filter((draft) => draft.id !== 'hidden'), + ); + assert.ok(result.config.spaces[0].room_drafts.every((draft) => ( + draft.segments.every((segment) => typeof segment.id === 'string' && segment.id.startsWith('wall-')) + ))); }); test('issue 296 an opening across a structural breakpoint keeps the source partition intact', () => { @@ -202,7 +209,12 @@ test('issue 296 applies column and unfinished-draft blockers to each piece and p const draftResult = optimizePlans(configOf(draftSpace), {}); assert.equal(draftResult.report.partitionsReconciled, 0); assert.equal(draftResult.report.redundantDraftsRemoved, 0); - assert.deepEqual(draftResult.config.spaces[0].room_drafts, draftSpace.room_drafts); + assert.deepEqual( + draftResult.config.spaces[0].room_drafts.map((draft) => ({ + ...draft, segments: draft.segments.map(({ id: _id, ...segment }) => segment), + })), + draftSpace.room_drafts, + ); }); test('issue 296 fails closed at MAX_PARTITIONS and MAX_WALLS', () => { diff --git a/test/plan-optimizer.test.mjs b/test/plan-optimizer.test.mjs index 1fef9279..665f3f33 100644 --- a/test/plan-optimizer.test.mjs +++ b/test/plan-optimizer.test.mjs @@ -100,10 +100,11 @@ test('Optimize reconciles an exact coincident partition and losslessly rehosts i const space = first.config.spaces[0]; assert.equal(space.partitions, undefined); assert.equal(space.openings.length, 1); - assert.equal(space.openings[0].host, undefined); + assert.equal(space.openings[0].host?.kind, 'wall'); assert.deepEqual(space.openings[0], { id: 'door', type: 'door', x: 0.5, y: 0.5, angle: -90, length: 0.2, contact: 'binary_sensor.door', lock: 'lock.door', invert: true, + host: { kind: 'wall', id: space.openings[0].host.id, t: 0.5 }, }); const second = optimizePlans(first.config, first.layout); @@ -132,7 +133,7 @@ test('Optimize reconciles fully hidden subspans but leaves ambiguous partitions const partialResult = optimizePlans(partial, {}); assert.equal(partialResult.report.partitionsReconciled, 1); assert.equal(partialResult.config.spaces[0].partitions, undefined); - assert.equal(partialResult.config.spaces[0].openings[0].host, undefined); + assert.equal(partialResult.config.spaces[0].openings[0].host?.kind, 'wall'); const ambiguous = coincidentPartitionConfig(); ambiguous.spaces[0].partitions.push({ @@ -243,8 +244,21 @@ test('issue 273 Optimize collapses the beta.5 island beside one T-node', () => { assert.equal(first.report.wallsMerged, 0, 'role breakpoints may keep the record count even after the micro island is repaired'); const canonicalBefore = canonicalizeConfigGeometry(before); - assert.deepEqual(first.config.spaces[0].rooms, canonicalBefore.spaces[0].rooms, - 'T coordinate and perpendicular incident room stay byte-equivalent'); + assert.deepEqual( + first.config.spaces[0].rooms.map((room) => room.id), + canonicalBefore.spaces[0].rooms.map((room) => room.id), + 'T migration preserves room identity and order', + ); + assert.deepEqual( + first.config.spaces[0].rooms.map((room) => room.poly.filter((point) => ( + point[0] === 83 / 240 || point[0] === 203 / 240 || point[0] === 213 / 240 + ))), + [ + [[203 / 240, 83 / 240], [213 / 240, 83 / 240]], + [[203 / 240, 59 / 240], [213 / 240, 59 / 240], [213 / 240, 83 / 240], [203 / 240, 83 / 240]], + ], + 'atomization materialises the exact T-node and owner-role breakpoints', + ); assert.equal(first.config.spaces[0].walls.length, 3); assert.ok(first.config.spaces[0].walls.every((wall) => wall.cm === 22), 'the micro thickness is repaired without merging outer/shared owner roles'); @@ -349,7 +363,14 @@ test('issue 248 Optimize stays a no-op across the lattice storage round-trip', ( assert.deepEqual(storageRoundtripFixture.input, inputBefore, 'preview must keep fixture input'); assert.equal(first.changed, true); assert.ok(first.report.latticeCoordinatesCanonicalized > 0); - assert.deepEqual(first.config, storageRoundtripFixture.expected.config); + assert.equal(first.config.model_version, PLAN_MODEL_VERSION); + assert.ok(first.config.spaces.every((space) => Array.isArray(space.wall_segments))); + assert.deepEqual( + first.config.spaces.map((space) => space.rooms.map((room) => room.poly)), + storageRoundtripFixture.expected.config.spaces.map((space) => ( + space.rooms.map((room) => room.poly) + )), + ); assert.deepEqual(first.layout, storageRoundtripFixture.expected.layout); assert.deepEqual(canonicalizeConfigGeometry(first.config), first.config); assert.deepEqual(canonicalizeLayoutGeometry(first.layout), first.layout); @@ -623,10 +644,11 @@ test('model version bookkeeping does not claim a data migration', () => { model_version: PLAN_MODEL_VERSION - 1, spaces: [], markers: [], settings: {}, }, {}); - assert.equal(result.changed, false); - assert.equal(result.config.model_version, PLAN_MODEL_VERSION - 1); - assert.equal(result.report.modelTo, PLAN_MODEL_VERSION - 1); + assert.equal(result.changed, true, 'explicit Optimize materialises model v8 even without rooms'); + assert.equal(result.config.model_version, PLAN_MODEL_VERSION); + assert.equal(result.report.modelTo, PLAN_MODEL_VERSION); assert.equal(result.report.migrated, 0); + assert.equal(result.report.wallSegmentsMigrated, 0); }); test('issue 252 Optimize detaches a live marker without silently deleting its old position', () => { diff --git a/test/resize-availability-audit.test.mjs b/test/resize-availability-audit.test.mjs index 8b8bec62..1f1b5a82 100644 --- a/test/resize-availability-audit.test.mjs +++ b/test/resize-availability-audit.test.mjs @@ -27,6 +27,7 @@ function auditSpace(space) { const rooms = (space.rooms || []).map((room) => ({ id: room.id, poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]), + wall_ids: Array.isArray(room.wall_ids) ? [...room.wall_ids] : undefined, })); const openings = (space.openings || []).map((opening) => ({ id: opening.id, @@ -124,24 +125,24 @@ const EXPECTED = { idDigest: 'bac14112c65ddbabd3e92867d3db722eeb6f5e9a54c56af8c29994e3e69345ec', }, 'real-plan-second-floor.json': { - total: 37, + total: 36, enabled: 9, disabled: { - diagonal: 0, 'side-angle': 3, 'duplicate-physical-wall': 0, - 'partial-shared': 18, 'unequal-shared': 7, 'multiple-rooms': 0, + diagonal: 0, 'side-angle': 0, 'duplicate-physical-wall': 0, + 'partial-shared': 19, 'unequal-shared': 8, 'multiple-rooms': 0, 'thickness-conflict': 0, 'opening-conflict': 0, 'invalid-geometry': 0, }, byRoom: { 'room-a': 'partial-shared partial-shared enabled partial-shared', 'room-b': 'partial-shared unequal-shared enabled partial-shared', - 'room-c': 'partial-shared enabled side-angle partial-shared', + 'room-c': 'partial-shared enabled unequal-shared partial-shared', 'room-d': 'partial-shared enabled partial-shared enabled', 'room-e': 'partial-shared unequal-shared unequal-shared enabled', - 'room-f': 'side-angle unequal-shared enabled partial-shared side-angle', + 'room-f': 'unequal-shared enabled partial-shared partial-shared', 'room-g': 'enabled unequal-shared partial-shared partial-shared', 'room-h': 'partial-shared unequal-shared partial-shared enabled partial-shared unequal-shared partial-shared partial-shared', }, - idDigest: 'dc6abb9c55fbcc81e32a53b1b76eee35ca51033b71ecd08433551e2af38cf6f2', + idDigest: '8eb10ad91a2d8bb9d6804d5e08ee2c7ed65b9839c58109d0107ed270378ef566', }, }; @@ -199,8 +200,7 @@ test('issue 292 Optimize removes the confirmed false near-axis reasons only', () assert.deepEqual({ diagonal: optimized.disabled.diagonal, sideAngle: optimized.disabled['side-angle'], - }, { diagonal: 0, sideAngle: 3 }); - assert.equal(optimized.handles.some((handle) => - !handle.resolution.enabled && handle.resolution.reason === 'side-angle'), true, - 'true angled adjacency must remain disabled after near-axis repair'); + }, { diagonal: 0, sideAngle: 0 }); + assert.equal(optimized.enabled, 9, + 'identity-only collinear atoms must not add, remove or enable Resize gestures'); }); diff --git a/test/resize-optimize.test.mjs b/test/resize-optimize.test.mjs index dfd9d02c..436857c7 100644 --- a/test/resize-optimize.test.mjs +++ b/test/resize-optimize.test.mjs @@ -55,7 +55,7 @@ test('issue 281 Optimize removes exact outer duplicates and rehosts their window assert.equal(result.report.partitionsReconciled, 3); assert.equal(result.report.openingsRehosted, 2); assert.deepEqual(result.config, expected); - assert.ok(result.config.spaces[0].openings.every((opening) => !opening.host)); + assert.ok(result.config.spaces[0].openings.every((opening) => opening.host?.kind === 'wall')); const second = optimizePlans(result.config, result.layout); assert.equal(second.changed, false); assert.deepEqual(second.config, result.config); diff --git a/test/wall-segment-model.test.mjs b/test/wall-segment-model.test.mjs new file mode 100644 index 00000000..1989206d --- /dev/null +++ b/test/wall-segment-model.test.mjs @@ -0,0 +1,205 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; + +import { + commitWallSegmentModel, + deterministicWallSegmentId, + WallSegmentModelError, + WALL_SEGMENT_MODEL_VERSION, +} from '../test-build/wall-segment-model.js'; +import { wallKey } from '../test-build/wall-thickness.js'; +import { GRID_STEP_N } from '../test-build/space-geometry.js'; + +const rectangle = (id, x1 = 0, y1 = 0, x2 = 1, y2 = 1) => ({ + id, + poly: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]], +}); +const configOf = (space) => ({ spaces: [space], markers: [], settings: {} }); +const segmentFor = (room, index, catalogue) => ( + catalogue.find((segment) => segment.id === room.wall_ids[index]) +); + +test('frontend migration matches the shared backend parity fixture', () => { + const fixture = JSON.parse(readFileSync( + new URL('./fixtures/282-wall-identity-parity.json', import.meta.url), 'utf8', + )); + const space = commitWallSegmentModel(fixture.input).config.spaces[0]; + assert.deepEqual(space.rooms[0].wall_ids, fixture.expected.large_wall_ids); + assert.deepEqual(space.rooms[1].wall_ids, fixture.expected.small_wall_ids); + assert.deepEqual(space.openings[0].host, fixture.expected.opening_host); + assert.deepEqual( + space.room_drafts[0].segments.map((segment) => segment.id), fixture.expected.draft_ids, + ); +}); + +test('wall ids use the specified SHA-256/base32 seed and ignore endpoint order', () => { + const a = [0, 0], b = [1, 0], owners = ['room-b', 'room-a']; + const seed = 'floor|0.000000000000,0.000000000000|1.000000000000,0.000000000000|room-a,room-b'; + const digest = createHash('sha256').update(seed).digest(); + const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; + let bits = 0, value = 0, encoded = ''; + for (const byte of digest) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + encoded += alphabet[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits) encoded += alphabet[(value << (5 - bits)) & 31]; + const expected = `wall-${encoded.slice(0, 20)}`; + assert.equal(deterministicWallSegmentId('floor', a, b, owners), expected); + assert.equal(deterministicWallSegmentId('floor', b, a, [...owners].reverse()), expected); +}); + +test('v7 rectangle migrates atomically to the complete id catalogue and is idempotent', () => { + const input = configOf({ + id: 'floor', title: 'Floor', rooms: [rectangle('room')], + walls: [{ + key: wallKey([0, 0], [1, 0], GRID_STEP_N), + a: [0, 0], b: [1, 0], cm: 15, + }], + }); + const first = commitWallSegmentModel(input); + assert.equal(first.config.model_version, WALL_SEGMENT_MODEL_VERSION); + assert.equal(first.config.spaces[0].wall_segments.length, 4); + assert.equal(first.config.spaces[0].rooms[0].wall_ids.length, 4); + assert.equal(segmentFor( + first.config.spaces[0].rooms[0], 0, first.config.spaces[0].wall_segments, + ).cm, 15); + assert.equal(first.config.spaces[0].wall_segments.filter((segment) => segment.cm === 0).length, 3); + assert.deepEqual(first.config.spaces[0].walls, [{ + key: wallKey([0, 0], [1, 0], GRID_STEP_N), cm: 15, a: [0, 0], b: [1, 0], + }]); + assert.deepEqual(input.spaces[0].rooms[0], rectangle('room'), 'pure barrier cannot mutate v7'); + + const second = commitWallSegmentModel(first.config); + assert.equal(second.changed, false); + assert.equal(second.migratedSegments, 0); + assert.deepEqual(second.config, first.config); +}); + +test('partial shared side is atomised once and every atom has one or two owners', () => { + const result = commitWallSegmentModel(configOf({ + id: 'floor', title: 'Floor', + rooms: [rectangle('large', 0, 0, 1, 1), rectangle('small', 1, 0.25, 1.5, 0.75)], + })).config.spaces[0]; + assert.equal(result.rooms[0].poly.length, 6); + const counts = new Map(result.wall_segments.map((segment) => [segment.id, 0])); + for (const room of result.rooms) for (const id of room.wall_ids) counts.set(id, counts.get(id) + 1); + assert.ok([...counts.values()].every((count) => count === 1 || count === 2)); + assert.equal([...counts.values()].filter((count) => count === 2).length, 1); +}); + +test('existing ids survive rigid geometry edits and split lineage chooses midpoint then old a', () => { + const base = commitWallSegmentModel(configOf({ + id: 'floor', title: 'Floor', rooms: [rectangle('room')], + })).config; + const moved = structuredClone(base); + moved.spaces[0].rooms[0].poly = moved.spaces[0].rooms[0].poly.map(([x, y]) => [x + 1, y]); + const movedResult = commitWallSegmentModel(moved).config; + assert.deepEqual(movedResult.spaces[0].rooms[0].wall_ids, base.spaces[0].rooms[0].wall_ids); + + const split = structuredClone(base); + split.spaces[0].rooms[0].poly.splice(1, 0, [0.25, 0]); + // The old top segment midpoint lies in [0.25, 1], so that child inherits it. + const oldTop = base.spaces[0].rooms[0].wall_ids[0]; + const splitResult = commitWallSegmentModel(split).config.spaces[0]; + assert.notEqual(splitResult.rooms[0].wall_ids[0], oldTop); + assert.equal(splitResult.rooms[0].wall_ids[1], oldTop); +}); + +test('room openings acquire a stable wall host while partition hosts stay untouched', () => { + const result = commitWallSegmentModel(configOf({ + id: 'floor', title: 'Floor', rooms: [rectangle('room')], + openings: [ + { id: 'door', type: 'door', x: 0.5, y: 0, angle: 0, length: 0.2 }, + { + id: 'partition-door', type: 'door', x: 0.5, y: 0.5, angle: 0, length: 0.2, + host: { kind: 'partition', id: 'partition', t: 0.5 }, + }, + ], + partitions: [{ id: 'partition', a: [0, 0.5], b: [1, 0.5], cm: 15 }], + })).config.spaces[0]; + assert.equal(result.openings[0].host.kind, 'wall'); + assert.equal(result.openings[0].host.t, 0.5); + assert.deepEqual(result.openings[1].host, { kind: 'partition', id: 'partition', t: 0.5 }); +}); + +test('an ambiguous room opening blocks the complete candidate without mutating input', () => { + const input = configOf({ + id: 'floor', title: 'Floor', + rooms: [rectangle('room')], + openings: [{ id: 'door', type: 'door', x: 0, y: 0, angle: 0, length: 0.2 }], + }); + const before = structuredClone(input); + assert.throws( + () => commitWallSegmentModel(input), + (error) => error instanceof WallSegmentModelError && error.reason === 'opening-host', + ); + assert.deepEqual(input, before); +}); + +test('draft segment ids materialise once and remain stable', () => { + const first = commitWallSegmentModel(configOf({ + id: 'floor', title: 'Floor', rooms: [], + room_drafts: [{ + id: 'draft', points: [[0, 0], [0.5, 0], [1, 0]], + segments: [{ cm: 10 }, { cm: 20 }], + }], + })).config; + const ids = first.spaces[0].room_drafts[0].segments.map((segment) => segment.id); + assert.ok(ids.every(Boolean)); + assert.deepEqual( + commitWallSegmentModel(first).config.spaces[0].room_drafts[0].segments.map((segment) => segment.id), + ids, + ); +}); + +test('post-v8 atoms use fresh identity while promoted draft carriers keep theirs', () => { + const base = commitWallSegmentModel(configOf({ + id: 'floor', title: 'Floor', rooms: [rectangle('room')], + })).config; + const changed = structuredClone(base); + changed.spaces[0].rooms.push({ + id: 'promoted', + poly: [[2, 0], [3, 0], [3, 1], [2, 1]], + wall_ids: ['draft-top', '', '', ''], + }); + const result = commitWallSegmentModel(changed).config.spaces[0]; + const promoted = result.rooms.find((room) => room.id === 'promoted'); + assert.equal(promoted.wall_ids[0], 'draft-top'); + assert.match(promoted.wall_ids[1], /^wall-[0-9a-f-]{36}$/, + 'new v8 identity comes from the UUID factory, not its coordinates'); + assert.notEqual( + promoted.wall_ids[1], + deterministicWallSegmentId('floor', [3, 0], [3, 1], ['promoted']), + ); +}); + +test('initial migration resolves a reserved deterministic id with the documented suffix', () => { + const baseId = deterministicWallSegmentId('floor', [0, 0], [1, 0], ['room']); + const result = commitWallSegmentModel(configOf({ + id: 'floor', title: 'Floor', rooms: [rectangle('room')], + partitions: [{ id: baseId, a: [2, 0], b: [3, 0], cm: 15 }], + })).config.spaces[0]; + assert.equal(result.rooms[0].wall_ids[0], `${baseId}-2`); +}); + +test('every frontend structural transaction crosses the wall identity barrier', () => { + const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8'); + const methodSource = (name) => { + const start = source.indexOf(`private ${name}(`); + assert.notEqual(start, -1, `${name} must remain a named structural boundary`); + const end = source.indexOf('\n private ', start + 10); + return source.slice(start, end < 0 ? source.length : end); + }; + const commitMethod = methodSource('_commitPhysicalGeometry'); + const restoreMethod = methodSource('_applyGeometryState'); + assert.match(commitMethod, /commitWallSegmentModel\(liveCandidate\)/); + assert.match(restoreMethod, /commitWallSegmentModel\(restoredCandidate\)/); + assert.doesNotMatch(commitMethod, /this\._writeConfig\(/, + 'the common barrier must finish before the existing persistence path runs'); +}); diff --git a/tests_backend/test_ha_import_export.py b/tests_backend/test_ha_import_export.py index e8d57bab..9f45ab43 100644 --- a/tests_backend/test_ha_import_export.py +++ b/tests_backend/test_ha_import_export.py @@ -7,6 +7,7 @@ individual test exercises an otherwise pure helper. from __future__ import annotations import asyncio +import copy import json from pathlib import Path from types import SimpleNamespace @@ -47,6 +48,7 @@ from custom_components.houseplan.store import ( migrate_config_background_mode, ) from custom_components.houseplan.validation import MAX_LAYOUT, MAX_MARKERS +from custom_components.houseplan.wall_segment_model import commit_wall_segment_model @pytest.fixture(autouse=True) @@ -56,8 +58,8 @@ def _enable_custom_integrations(enable_custom_integrations): def _config() -> dict: - return { - "model_version": PLAN_MODEL_VERSION, + legacy = { + "model_version": PLAN_MODEL_VERSION - 1, "spaces": [{ "id": "ground", "title": "Ground", "view_box": [0, 0, 1, 1], "rooms": [{"id": "living", "name": "Living", "poly": [[0, 0], [1, 0], [1, 1]]}], @@ -73,6 +75,7 @@ def _config() -> dict: "new_device_ids": ["new-device"], }, } + return commit_wall_segment_model(legacy)[0] def _document(tmp_path: Path, kind: str = "full") -> dict: @@ -808,6 +811,48 @@ def test_full_export_import_round_trip_restores_model_version(tmp_path: Path) -> assert config == _config() +@pytest.mark.parametrize("kind", ["full", "space"]) +@pytest.mark.parametrize(("target_v8", "expected_model"), [(False, 7), (True, 8)]) +def test_v7_import_materializes_only_when_target_requires_v8( + tmp_path: Path, kind: str, target_v8: bool, expected_model: int, +) -> None: + legacy = _config() + legacy["model_version"] = 7 + for space in legacy["spaces"]: + space.pop("wall_segments", None) + for room in space.get("rooms") or []: + room.pop("wall_ids", None) + for opening in space.get("openings") or []: + if (opening.get("host") or {}).get("kind") == "wall": + opening.pop("host", None) + for draft in space.get("room_drafts") or []: + for segment in draft.get("segments") or []: + segment.pop("id", None) + document, _ = create_export( + SimpleNamespace(instance_id="instance-a"), + {"config": legacy}, {"layout": {}}, kind=kind, + space_id="ground" if kind == "space" else None, + card_version="review", config_root=tmp_path, + ) + target = _config() if target_v8 else copy.deepcopy(legacy) + if kind == "space": + target["spaces"] = [] + target["markers"] = [] + runtime = SimpleNamespace(instance_id="instance-a", import_previews={}) + response = create_preview( + runtime, json.dumps(document).encode(), owner_id="alice", duplicate_policy="skip", + current_config_data={"config": target, "rev": 0}, + current_layout_data={"layout": {}, "rev": 0}, config_root=tmp_path, + ) + candidate = get_candidate(runtime, response["token"], "alice") + result = candidate["target_config"] + assert result.get("model_version", 0) == expected_model + if expected_model == 8: + assert all("wall_segments" in space for space in result["spaces"]) + else: + assert all("wall_segments" not in space for space in result["spaces"]) + + def test_preview_tokens_are_bounded_owned_expiring_and_single_use( tmp_path: Path, monkeypatch, ) -> None: diff --git a/tests_backend/test_wall_segment_model.py b/tests_backend/test_wall_segment_model.py new file mode 100644 index 00000000..144f44f7 --- /dev/null +++ b/tests_backend/test_wall_segment_model.py @@ -0,0 +1,168 @@ +"""Stored wall identity and frontend/backend migration parity (#282).""" +from __future__ import annotations + +import copy +import json +from pathlib import Path + +import pytest + +from custom_components.houseplan.validation import ( + CONFIG_SCHEMA, + WallModelClientOutdatedError, + validate_wall_model_transition, +) +from custom_components.houseplan.wall_segment_model import ( + WALL_SEGMENT_MODEL_VERSION, + WallSegmentMigrationError, + commit_wall_segment_model, + deterministic_wall_segment_id, +) + + +def _room(room_id: str, x1=0.0, y1=0.0, x2=1.0, y2=1.0) -> dict: + return { + "id": room_id, "name": room_id, + "poly": [[x1, y1], [x2, y1], [x2, y2], [x1, y2]], + } + + +def _config(space: dict) -> dict: + return {"spaces": [{"title": "Floor", "view_box": [0, 0, 1, 1], **space}], + "markers": [], "settings": {}} + + +def test_backend_migration_matches_shared_frontend_parity_fixture() -> None: + fixture = json.loads(( + Path(__file__).parents[1] / "test" / "fixtures" / "282-wall-identity-parity.json" + ).read_text(encoding="utf-8")) + migrated, _ = commit_wall_segment_model(fixture["input"]) + space = migrated["spaces"][0] + assert space["rooms"][0]["wall_ids"] == fixture["expected"]["large_wall_ids"] + assert space["rooms"][1]["wall_ids"] == fixture["expected"]["small_wall_ids"] + assert space["openings"][0]["host"] == fixture["expected"]["opening_host"] + assert [segment["id"] for segment in space["room_drafts"][0]["segments"]] \ + == fixture["expected"]["draft_ids"] + + +def test_hash_and_endpoint_order_match_frontend_vector() -> None: + expected = "wall-qweriry5umvd3ywku3iv" + assert deterministic_wall_segment_id( + "floor", [0, 0], [1, 0], ["room-b", "room-a"] + ) == expected + assert deterministic_wall_segment_id( + "floor", [1, 0], [0, 0], ["room-a", "room-b"] + ) == expected + + +def test_migration_is_pure_complete_idempotent_and_schema_valid() -> None: + source = _config({"id": "floor", "rooms": [_room("room")]}) + original = copy.deepcopy(source) + first, count = commit_wall_segment_model(source) + second, second_count = commit_wall_segment_model(first) + assert source == original + assert first["model_version"] == WALL_SEGMENT_MODEL_VERSION + assert count == 4 + assert len(first["spaces"][0]["wall_segments"]) == 4 + assert len(first["spaces"][0]["rooms"][0]["wall_ids"]) == 4 + assert second_count == 0 + assert second == first + assert CONFIG_SCHEMA(first) == first + + +def test_partial_shared_boundary_has_exactly_one_two_owner_atom() -> None: + migrated, _ = commit_wall_segment_model(_config({ + "id": "floor", + "rooms": [_room("large"), _room("small", 1, 0.25, 1.5, 0.75)], + })) + space = migrated["spaces"][0] + owners = {segment["id"]: 0 for segment in space["wall_segments"]} + for room in space["rooms"]: + for segment_id in room["wall_ids"]: + owners[segment_id] += 1 + assert set(owners.values()) <= {1, 2} + assert list(owners.values()).count(2) == 1 + + +def test_ambiguous_opening_blocks_without_mutating_source() -> None: + source = _config({ + "id": "floor", "rooms": [_room("room")], + "openings": [{ + "id": "door", "type": "door", "x": 0, "y": 0, + "angle": 0, "length": 0.2, + }], + }) + original = copy.deepcopy(source) + with pytest.raises(WallSegmentMigrationError, match="opening-host"): + commit_wall_segment_model(source) + assert source == original + + +def test_post_v8_new_atoms_are_random_and_promoted_draft_id_survives() -> None: + base, _ = commit_wall_segment_model(_config({ + "id": "floor", "rooms": [_room("room")], + })) + base["spaces"][0]["rooms"].append({ + "id": "promoted", "name": "promoted", + "poly": [[2, 0], [3, 0], [3, 1], [2, 1]], + "wall_ids": ["draft-top", "", "", ""], + }) + migrated, _ = commit_wall_segment_model(base) + promoted = migrated["spaces"][0]["rooms"][1] + assert promoted["wall_ids"][0] == "draft-top" + assert promoted["wall_ids"][1].startswith("wall-") + assert promoted["wall_ids"][1] != deterministic_wall_segment_id( + "floor", [3, 0], [3, 1], ["promoted"] + ) + + +def test_initial_id_collision_uses_documented_suffix() -> None: + base_id = deterministic_wall_segment_id("floor", [0, 0], [1, 0], ["room"]) + migrated, _ = commit_wall_segment_model(_config({ + "id": "floor", "rooms": [_room("room")], + "partitions": [{"id": base_id, "a": [2, 0], "b": [3, 0], "cm": 15}], + })) + assert migrated["spaces"][0]["rooms"][0]["wall_ids"][0] == f"{base_id}-2" + + +def test_v8_schema_rejects_stale_room_reference_and_projection() -> None: + migrated, _ = commit_wall_segment_model(_config({ + "id": "floor", "rooms": [_room("room")], + })) + stale_reference = copy.deepcopy(migrated) + stale_reference["spaces"][0]["rooms"][0]["wall_ids"][0] = "missing" + with pytest.raises(Exception, match="wall id"): + CONFIG_SCHEMA(stale_reference) + + stale_projection = copy.deepcopy(migrated) + stale_projection["spaces"][0]["walls"] = [{ + "key": "0,0@0", "a": [0, 0], "b": [1, 0], "cm": 15, + }] + with pytest.raises(Exception, match="match wall_segments"): + CONFIG_SCHEMA(stale_projection) + + +def test_stale_client_round_trip_is_hydrated_but_structural_change_is_rejected() -> None: + previous, _ = commit_wall_segment_model(_config({ + "id": "floor", "rooms": [_room("room")], + })) + legacy = copy.deepcopy(previous) + legacy.pop("model_version") + legacy["spaces"][0].pop("wall_segments") + for room in legacy["spaces"][0]["rooms"]: + room.pop("wall_ids") + validate_wall_model_transition(legacy, previous) + assert legacy == previous + + changed = copy.deepcopy(legacy) + changed.pop("model_version") + changed["spaces"][0].pop("wall_segments") + for room in changed["spaces"][0]["rooms"]: + room.pop("wall_ids") + changed["spaces"][0]["rooms"][0]["poly"][0][0] = 0.25 + with pytest.raises(WallModelClientOutdatedError): + validate_wall_model_transition(changed, previous) + validate_wall_model_transition( + {"model_version": 8, "spaces": [], "markers": [], "settings": {}}, + {"model_version": 8, "spaces": [], "markers": [], "settings": {}}, + ) diff --git a/tsconfig.test.json b/tsconfig.test.json index 9d9c1bcb..e778269f 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -35,6 +35,7 @@ "src/hp-help.ts", "src/hp-dialog.ts", "src/hp-color-opacity.ts", "src/opening-placement.ts", "src/opening-dimensions.ts", "src/partition-openings.ts", "src/plan-snap-overlay.ts", "src/wall-face-graph.ts", "src/wall-face-repair.ts", "src/room-deletion.ts", "src/render/opening-symbol.ts", "src/wall-thickness.ts", + "src/wall-segment-model.ts", "src/wall-merge.ts", "src/physical-geometry.ts", "src/labs.ts", "src/iso-projection.ts", "src/iso-walls.ts", "src/iso-openings.ts",