Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31d7a70e34 | ||
|
|
96318fb94f | ||
|
|
d3927f2e56 | ||
|
|
b2b2640751 | ||
|
|
d8c7652d0e | ||
|
|
8a82189368 | ||
|
|
8eef4e9c5a | ||
|
|
cc937de0b1 | ||
|
|
d3ed299d2a | ||
|
|
9c396935dc | ||
|
|
ec8dc6fde1 | ||
|
|
58c096cef1 | ||
|
|
e88abcb138 | ||
|
|
9dbdbec03d | ||
|
|
c179a89876 | ||
|
|
df38c5c93b | ||
|
|
b6f80c56f0 | ||
|
|
aac501516e | ||
|
|
8af5ebefd2 | ||
|
|
ef22c2c6fd | ||
|
|
a6ef664bef | ||
|
|
76d60b3e1b | ||
|
|
a1361974a3 | ||
|
|
f1c76aa415 | ||
|
|
3df1dcf5a3 | ||
|
|
eef4e0d6d4 | ||
|
|
1575b1e890 | ||
|
|
e603c5496f | ||
|
|
33dcd1ce62 | ||
|
|
7310ce0134 | ||
|
|
a2b4d32b2d | ||
|
|
9c5e1e2749 | ||
|
|
2143e88824 | ||
|
|
1f83f35a66 | ||
|
|
e7f2f2c174 | ||
|
|
9f54cbab75 | ||
|
|
51810164c2 | ||
|
|
9c0b276f43 | ||
|
|
26af611af1 | ||
|
|
19f536f361 | ||
|
|
67bcce8efb | ||
|
|
ab2f37aa4d | ||
|
|
2cf47fc133 | ||
|
|
ca58f0e3eb | ||
|
|
93cd45569d | ||
|
|
36992d68bd | ||
|
|
0f71d869e3 | ||
|
|
56dbb48868 | ||
|
|
13219307d5 | ||
|
|
8b4730df05 | ||
|
|
19332a915d | ||
|
|
fd4fc801ef | ||
|
|
6d094ca01b | ||
|
|
65e55872e8 | ||
|
|
68498e526e | ||
|
|
4b8f17bae5 | ||
|
|
136e0cb773 | ||
|
|
641c4e199e | ||
|
|
8a3a115f42 | ||
|
|
5526447070 | ||
|
|
5696e274fe | ||
|
|
ba63fccbb7 | ||
|
|
6558728519 | ||
|
|
7b66b1e9b3 | ||
|
|
2805c2c8a0 | ||
|
|
1b7c150dda | ||
|
|
2523c38a4e | ||
|
|
5ecfebcf67 | ||
|
|
e13e8ff9e9 | ||
|
|
01992a9a87 | ||
|
|
2d19ce04ad | ||
|
|
390f77bd65 | ||
|
|
d5670ad141 | ||
|
|
3cf8ed74db | ||
|
|
b9db5d4750 | ||
|
|
2cad797d2c | ||
|
|
e4dfef19e1 | ||
|
|
8d165fd1c2 | ||
|
|
23d208f30c | ||
|
|
e4139da3e6 | ||
|
|
3fb2edc904 | ||
|
|
4969f46e0e | ||
|
|
ed28951b9d | ||
|
|
2b7c98e6bc | ||
|
|
8187dd9bda | ||
|
|
89ac96a8af | ||
|
|
033ad11bf7 | ||
|
|
f7a19a35f1 | ||
|
|
451932c8e9 | ||
|
|
ab9a83e755 | ||
|
|
55db16df6e | ||
|
|
8221ad9b84 | ||
|
|
45066631ae | ||
|
|
4feeebc355 | ||
|
|
158601d67d | ||
|
|
671d1af4c3 | ||
|
|
67777e47c0 | ||
|
|
953f6756bc | ||
|
|
578eb5ebd3 | ||
|
|
874838c954 | ||
|
|
0ac92cb551 | ||
|
|
142ec8d1c7 | ||
|
|
1816042a1a |
@@ -46,7 +46,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.67.0-rc.1"
|
||||
VERSION = "1.67.0-rc.2"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
|
||||
@@ -63,6 +63,11 @@ _LIVE_TEXT_TOKEN = re.compile(r"\{([^{}\r\n]+)\}")
|
||||
_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",
|
||||
}
|
||||
_MAX_IMPORT_LINEAGE_DEPTH = 16
|
||||
_REPORT_EXAMPLE_LIMIT = 24
|
||||
|
||||
_SPACE_PLAN_FIELDS = (
|
||||
"id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y",
|
||||
@@ -801,8 +806,31 @@ def _unique_title(base: str, existing: list[dict[str, Any]]) -> str:
|
||||
return f"{base} ({n})"
|
||||
|
||||
|
||||
def canonical_import_root(prefix: str, value: str) -> tuple[str, int, bool]:
|
||||
"""Return the bounded root of ids generated by a previous space import.
|
||||
|
||||
Only the exact ``<same namespace>_<stem>_<8 lowercase hex>`` envelope is
|
||||
reversible. Similar user ids, other namespaces and uppercase/short hashes
|
||||
remain literal. The layer count and bounded flag are shared with the
|
||||
TypeScript helper through one conformance fixture. The flag reports that
|
||||
another valid layer remained after the safety bound.
|
||||
"""
|
||||
root = str(value)
|
||||
pattern = re.compile(rf"^{re.escape(prefix)}_(.+)_([0-9a-f]{{8}})$")
|
||||
layers = 0
|
||||
for _depth in range(_MAX_IMPORT_LINEAGE_DEPTH):
|
||||
match = pattern.fullmatch(root)
|
||||
if match is None:
|
||||
return root, layers, False
|
||||
root = match.group(1)
|
||||
layers += 1
|
||||
return root, layers, pattern.fullmatch(root) is not None
|
||||
|
||||
|
||||
def _fresh(prefix: str, old: str, used: set[str]) -> str:
|
||||
stem = re.sub(r"[^a-zA-Z0-9_-]+", "_", old).strip("_")[:35] or prefix
|
||||
root, _layers, _bounded = canonical_import_root(prefix, old) \
|
||||
if prefix in _IMPORT_ID_NAMESPACES else (old, 0, False)
|
||||
stem = re.sub(r"[^a-zA-Z0-9_-]+", "_", root).strip("_")[:35] or prefix
|
||||
while True:
|
||||
value = f"{prefix}_{stem}_{secrets.token_hex(4)}"
|
||||
if value not in used:
|
||||
@@ -822,53 +850,281 @@ def _orphan_marker(key: str, manifest: dict[str, Any] | None, space_id: str, use
|
||||
}
|
||||
|
||||
|
||||
def _empty_reference_report() -> dict[str, Any]:
|
||||
return {
|
||||
"remapped": {"incoming": {}, "target": {}},
|
||||
"collisions": {},
|
||||
"preservedUnresolved": {},
|
||||
"droppedIncomingLinks": {},
|
||||
"boundedLineages": 0,
|
||||
"examples": [],
|
||||
}
|
||||
|
||||
|
||||
def _report_reference(
|
||||
report: dict[str, Any], bucket: str, category: str, owner: str, reference: str,
|
||||
) -> None:
|
||||
section = report.setdefault(bucket, {})
|
||||
section[category] = int(section.get(category, 0)) + 1
|
||||
examples = report.setdefault("examples", [])
|
||||
if len(examples) < _REPORT_EXAMPLE_LIMIT:
|
||||
examples.append({
|
||||
"bucket": bucket, "category": category,
|
||||
"owner": str(owner)[:160], "reference": str(reference)[:160],
|
||||
})
|
||||
|
||||
|
||||
def _report_remap(
|
||||
report: dict[str, Any], side: str, category: str, owner: str, reference: str,
|
||||
) -> None:
|
||||
section = report.setdefault("remapped", {}).setdefault(side, {})
|
||||
section[category] = int(section.get(category, 0)) + 1
|
||||
examples = report.setdefault("examples", [])
|
||||
if len(examples) < _REPORT_EXAMPLE_LIMIT:
|
||||
examples.append({
|
||||
"bucket": f"remapped.{side}", "category": category,
|
||||
"owner": str(owner)[:160], "reference": str(reference)[:160],
|
||||
})
|
||||
|
||||
|
||||
def _record_bounded_lineage(
|
||||
report: dict[str, Any], seen: set[tuple[str, str]], prefix: str, value: str,
|
||||
) -> None:
|
||||
_root, _layers, bounded = canonical_import_root(prefix, value)
|
||||
if bounded:
|
||||
seen.add((prefix, value))
|
||||
report["boundedLineages"] = len(seen)
|
||||
|
||||
|
||||
def _lineage_resolver(
|
||||
prefix: str,
|
||||
exact_map: dict[str, str],
|
||||
live_ids: set[str],
|
||||
report: dict[str, Any],
|
||||
bounded_seen: set[tuple[str, str]],
|
||||
) -> Callable[[str], tuple[str | None, str]]:
|
||||
"""Resolve only dead exact or uniquely provable cross-generation refs."""
|
||||
imported: dict[str, set[str]] = {}
|
||||
live: dict[str, set[str]] = {}
|
||||
for old, new in exact_map.items():
|
||||
root, _layers, bounded = canonical_import_root(prefix, old)
|
||||
imported.setdefault(root, set()).add(new)
|
||||
if bounded:
|
||||
_record_bounded_lineage(report, bounded_seen, prefix, old)
|
||||
for value in live_ids:
|
||||
root, _layers, bounded = canonical_import_root(prefix, value)
|
||||
live.setdefault(root, set()).add(value)
|
||||
if bounded:
|
||||
_record_bounded_lineage(report, bounded_seen, prefix, value)
|
||||
|
||||
def resolve(reference: str) -> tuple[str | None, str]:
|
||||
if reference in live_ids:
|
||||
return None, "live"
|
||||
if reference in exact_map:
|
||||
return exact_map[reference], "exact"
|
||||
root, _layers, bounded = canonical_import_root(prefix, reference)
|
||||
if bounded:
|
||||
_record_bounded_lineage(report, bounded_seen, prefix, reference)
|
||||
candidates = imported.get(root, set())
|
||||
if not candidates:
|
||||
return None, "unrelated"
|
||||
if len(candidates) == 1 and not live.get(root):
|
||||
return next(iter(candidates)), "lineage"
|
||||
return None, "ambiguous"
|
||||
|
||||
return resolve
|
||||
|
||||
|
||||
def _repair_target_space_refs(
|
||||
current_config: dict[str, Any],
|
||||
current_layout: dict[str, Any],
|
||||
old_space_id: str,
|
||||
new_space_id: str,
|
||||
old_room_ids: dict[str, str],
|
||||
id_maps: dict[str, dict[str, str]],
|
||||
marker_link_map: dict[str, str],
|
||||
report: dict[str, Any],
|
||||
bounded_seen: set[tuple[str, str]],
|
||||
) -> tuple[dict[str, Any], dict[str, Any], int]:
|
||||
"""Apply the exact map of this import to pre-existing orphan target refs."""
|
||||
"""Repair target refs by exact map, then by one safe lineage candidate."""
|
||||
config = _json_copy(current_config)
|
||||
layout = _json_copy(current_layout)
|
||||
if any(str(space.get("id")) == old_space_id for space in config.get("spaces") or []):
|
||||
return config, layout, 0
|
||||
|
||||
spaces = config.get("spaces") or []
|
||||
markers = config.get("markers") or []
|
||||
live_spaces = {str(item.get("id")) for item in spaces if item.get("id") is not None}
|
||||
live_rooms = {
|
||||
str(room.get("id")) for space in spaces for room in space.get("rooms") or []
|
||||
if room.get("id") is not None
|
||||
}
|
||||
live_partitions = {
|
||||
str(item.get("id")) for space in spaces for item in space.get("partitions") or []
|
||||
if item.get("id") is not None
|
||||
}
|
||||
live_markers = {
|
||||
str(marker.get("id")) for marker in markers if marker.get("id") is not None
|
||||
}
|
||||
resolve_space = _lineage_resolver(
|
||||
"space", id_maps.get("space", {}), live_spaces, report, bounded_seen,
|
||||
)
|
||||
resolve_room = _lineage_resolver(
|
||||
"room", id_maps.get("room", {}), live_rooms, report, bounded_seen,
|
||||
)
|
||||
resolve_partition = _lineage_resolver(
|
||||
"partition", id_maps.get("partition", {}), live_partitions, report, bounded_seen,
|
||||
)
|
||||
resolve_marker = _lineage_resolver(
|
||||
"marker", id_maps.get("marker", {}), live_markers, report, bounded_seen,
|
||||
)
|
||||
resolve_marker_link = _lineage_resolver(
|
||||
"marker", marker_link_map, live_markers, report, bounded_seen,
|
||||
)
|
||||
imported_space_ids = set(id_maps.get("space", {}).values())
|
||||
repaired = 0
|
||||
for marker in config.get("markers") or []:
|
||||
if marker.get("space") == old_space_id:
|
||||
marker["space"] = new_space_id
|
||||
repaired += 1
|
||||
room_id = marker.get("room_id")
|
||||
if room_id is not None and str(room_id) in old_room_ids:
|
||||
marker["room_id"] = old_room_ids[str(room_id)]
|
||||
unresolved: set[tuple[str, str, str]] = set()
|
||||
|
||||
def replace(
|
||||
owner: str, category: str, reference: Any,
|
||||
resolver: Callable[[str], tuple[str | None, str]],
|
||||
) -> str | None:
|
||||
nonlocal repaired
|
||||
if not isinstance(reference, str) or not reference:
|
||||
return None
|
||||
mapped, reason = resolver(reference)
|
||||
if mapped is not None:
|
||||
repaired += 1
|
||||
_report_remap(report, "target", category, owner, reference)
|
||||
return mapped
|
||||
if reason == "ambiguous":
|
||||
preserve_once(owner, category, reference)
|
||||
return None
|
||||
|
||||
def preserve_once(owner: str, category: str, reference: str) -> None:
|
||||
key = (owner, category, reference)
|
||||
if key in unresolved:
|
||||
return
|
||||
unresolved.add(key)
|
||||
_report_reference(
|
||||
report, "preservedUnresolved", category, owner, reference,
|
||||
)
|
||||
|
||||
def preserve_related(
|
||||
owner: str, category: str, reference: Any,
|
||||
resolver: Callable[[str], tuple[str | None, str]],
|
||||
) -> None:
|
||||
if not isinstance(reference, str) or not reference:
|
||||
return
|
||||
mapped, reason = resolver(reference)
|
||||
if mapped is not None or reason == "ambiguous":
|
||||
preserve_once(owner, category, reference)
|
||||
|
||||
for space in spaces:
|
||||
space_id = str(space.get("id", "?"))
|
||||
for room in space.get("rooms") or []:
|
||||
room_id = str(room.get("id", "?"))
|
||||
values = room.get("open_to")
|
||||
if isinstance(values, list):
|
||||
for value in values:
|
||||
preserve_related(
|
||||
f"{space_id}:{room_id}", "room.open_to", value, resolve_room,
|
||||
)
|
||||
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":
|
||||
# A partition host is space-local. An imported partition lives
|
||||
# in the new independent copy, so a pre-existing opening in a
|
||||
# different target space cannot be rebound to it safely.
|
||||
preserve_related(
|
||||
f"{space_id}:{opening.get('id', '?')}", "opening.host",
|
||||
host.get("id"), resolve_partition,
|
||||
)
|
||||
|
||||
for marker in markers:
|
||||
marker_id = str(marker.get("id", "?"))
|
||||
mapped = replace(marker_id, "marker.space", marker.get("space"), resolve_space)
|
||||
if mapped is not None:
|
||||
marker["space"] = mapped
|
||||
may_rebind_room = marker.get("space") in imported_space_ids
|
||||
if may_rebind_room:
|
||||
mapped = replace(marker_id, "marker.room_id", marker.get("room_id"), resolve_room)
|
||||
if mapped is not None:
|
||||
marker["room_id"] = mapped
|
||||
else:
|
||||
preserve_related(
|
||||
marker_id, "marker.room_id", marker.get("room_id"), resolve_room,
|
||||
)
|
||||
vacuum = marker.get("vacuum")
|
||||
segment_map = vacuum.get("segment_map") if isinstance(vacuum, dict) else None
|
||||
if isinstance(segment_map, dict):
|
||||
for key, room_id in list(segment_map.items()):
|
||||
mapped = old_room_ids.get(str(room_id))
|
||||
if may_rebind_room:
|
||||
mapped = replace(
|
||||
marker_id, "marker.vacuum.segment_map", room_id, resolve_room,
|
||||
)
|
||||
if mapped is not None:
|
||||
segment_map[key] = mapped
|
||||
else:
|
||||
preserve_related(
|
||||
marker_id, "marker.vacuum.segment_map", room_id, resolve_room,
|
||||
)
|
||||
controls = marker.get("controls")
|
||||
if isinstance(controls, list):
|
||||
for index, ref in enumerate(controls):
|
||||
if not isinstance(ref, str) or not ref.startswith("marker:"):
|
||||
continue
|
||||
old_target = ref[len("marker:"):]
|
||||
mapped = replace(
|
||||
marker_id, "marker.controls", old_target, resolve_marker_link,
|
||||
)
|
||||
if mapped is not None:
|
||||
segment_map[key] = mapped
|
||||
repaired += 1
|
||||
controls[index] = "marker:" + mapped
|
||||
else:
|
||||
# A duplicate may have been virtualised or otherwise lost
|
||||
# light-source semantics. Such a marker remains a valid
|
||||
# layout owner, but it is not a valid controls target.
|
||||
preserve_related(
|
||||
marker_id, "marker.controls", old_target, resolve_marker,
|
||||
)
|
||||
badge = marker.get("value_badge")
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
|
||||
ref = source.get("ref")
|
||||
old_target = ref[len("marker:"):] \
|
||||
if isinstance(ref, str) and ref.startswith("marker:") else None
|
||||
mapped = replace(
|
||||
marker_id, "marker.value_badge", old_target, resolve_marker_link,
|
||||
)
|
||||
if mapped is not None:
|
||||
source["ref"] = "marker:" + mapped
|
||||
else:
|
||||
preserve_related(
|
||||
marker_id, "marker.value_badge", old_target, resolve_marker,
|
||||
)
|
||||
|
||||
# Rewrite ownership before rekeying labels. A valid destination wins a
|
||||
# collision inside the target; the imported document itself is overlaid
|
||||
# later and has final priority for its own label position.
|
||||
for position in layout.values():
|
||||
if isinstance(position, dict) and position.get("s") == old_space_id:
|
||||
position["s"] = new_space_id
|
||||
repaired += 1
|
||||
for old_room_id, new_room_id in old_room_ids.items():
|
||||
old_key = "rl_" + old_room_id
|
||||
if old_key not in layout:
|
||||
# Destination wins collisions. Rekey only proven dead plan-owned keys;
|
||||
# opaque HA owners remain literal.
|
||||
for key in list(layout):
|
||||
position = layout[key]
|
||||
if isinstance(position, dict):
|
||||
mapped_space = replace(key, "layout.space", position.get("s"), resolve_space)
|
||||
if mapped_space is not None:
|
||||
position["s"] = mapped_space
|
||||
new_key: str | None = None
|
||||
category = ""
|
||||
if key.startswith("rl_"):
|
||||
if isinstance(position, dict) and position.get("s") in imported_space_ids:
|
||||
mapped_room = replace(key, "layout.room_label", key[3:], resolve_room)
|
||||
if mapped_room is not None:
|
||||
new_key, category = "rl_" + mapped_room, "layout.room_label"
|
||||
else:
|
||||
preserve_related(key, "layout.room_label", key[3:], resolve_room)
|
||||
elif key not in live_markers:
|
||||
mapped_marker = replace(key, "layout.marker", key, resolve_marker)
|
||||
if mapped_marker is not None:
|
||||
new_key, category = mapped_marker, "layout.marker"
|
||||
if new_key is None or new_key == key:
|
||||
continue
|
||||
new_key = "rl_" + new_room_id
|
||||
if new_key not in layout:
|
||||
layout[new_key] = layout[old_key]
|
||||
del layout[old_key]
|
||||
repaired += 1
|
||||
if new_key in layout:
|
||||
_report_reference(report, "collisions", category, key, new_key)
|
||||
else:
|
||||
layout[new_key] = position
|
||||
del layout[key]
|
||||
return config, layout, repaired
|
||||
|
||||
|
||||
@@ -889,14 +1145,53 @@ def build_space_merge(
|
||||
raise ImportFailure("invalid_config", "A space export must contain exactly one space")
|
||||
space = spaces[0]
|
||||
_materialize_space_background(space, "static")
|
||||
reference_report = _empty_reference_report()
|
||||
bounded_seen: set[tuple[str, str]] = set()
|
||||
target_config = _json_copy(current_config)
|
||||
target_layout = _json_copy(current_layout)
|
||||
used = {
|
||||
str(value)
|
||||
for sp in current_config.get("spaces") or []
|
||||
for value in [sp.get("id")]
|
||||
} | {str(m.get("id")) for m in current_config.get("markers") 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("openings") or [], sp.get("decor") or [],
|
||||
)
|
||||
for item in collection
|
||||
if isinstance(item, dict)
|
||||
for value in [item.get("id")]
|
||||
if value is not None
|
||||
} | {
|
||||
str(m.get("id")) for m in current_config.get("markers") or []
|
||||
if m.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("wall_columns") or [],
|
||||
incoming_space.get("openings") or [], incoming_space.get("decor") or [],
|
||||
)
|
||||
for item in collection
|
||||
if isinstance(item, dict)
|
||||
for value in [item.get("id")]
|
||||
if value is not None
|
||||
} | {
|
||||
str(m.get("id")) for m in incoming.get("markers") or []
|
||||
if m.get("id") is not None
|
||||
}
|
||||
old_space_id = str(space.get("id"))
|
||||
_record_bounded_lineage(
|
||||
reference_report, bounded_seen, "space", old_space_id,
|
||||
)
|
||||
new_space_id = _fresh("space", old_space_id, used)
|
||||
id_map: dict[str, str] = {old_space_id: new_space_id}
|
||||
id_maps: dict[str, dict[str, str]] = {
|
||||
prefix: {} for prefix in _IMPORT_ID_NAMESPACES
|
||||
}
|
||||
id_maps["space"][old_space_id] = new_space_id
|
||||
for collection, prefix in (
|
||||
("rooms", "room"), ("room_drafts", "draft"), ("partitions", "partition"),
|
||||
("wall_columns", "column"), ("openings", "opening"), ("decor", "decor"),
|
||||
@@ -904,17 +1199,26 @@ def build_space_merge(
|
||||
for item in space.get(collection) or []:
|
||||
if isinstance(item, dict) and item.get("id") is not None:
|
||||
old = str(item["id"])
|
||||
_record_bounded_lineage(
|
||||
reference_report, bounded_seen, prefix, old,
|
||||
)
|
||||
id_map[old] = _fresh(prefix, old, used)
|
||||
id_maps[prefix][old] = id_map[old]
|
||||
item["id"] = id_map[old]
|
||||
old_room_ids = {
|
||||
old: new for old, new in id_map.items() if old != old_space_id and new.startswith("room_")
|
||||
}
|
||||
target_config, target_layout, repaired_target_refs = _repair_target_space_refs(
|
||||
current_config, current_layout, old_space_id, new_space_id, old_room_ids,
|
||||
)
|
||||
old_room_ids = id_maps["room"]
|
||||
for room in space.get("rooms") or []:
|
||||
if room.get("open_to"):
|
||||
room["open_to"] = [old_room_ids.get(str(value), str(value)) for value in room["open_to"]]
|
||||
remapped_open_to = []
|
||||
for value in room["open_to"]:
|
||||
old_value = str(value)
|
||||
mapped_value = old_room_ids.get(old_value, old_value)
|
||||
remapped_open_to.append(mapped_value)
|
||||
if mapped_value != old_value:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "room.open_to",
|
||||
str(room.get("id", "?")), old_value,
|
||||
)
|
||||
room["open_to"] = remapped_open_to
|
||||
# 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
|
||||
@@ -925,6 +1229,10 @@ def build_space_merge(
|
||||
old_host_id = str(host.get("id"))
|
||||
if old_host_id in id_map:
|
||||
host["id"] = id_map[old_host_id]
|
||||
_report_remap(
|
||||
reference_report, "incoming", "opening.host",
|
||||
str(opening.get("id", "?")), old_host_id,
|
||||
)
|
||||
space["id"] = new_space_id
|
||||
space["title"] = _unique_title(
|
||||
str(space.get("title") or old_space_id), current_config.get("spaces") or []
|
||||
@@ -947,22 +1255,40 @@ def build_space_merge(
|
||||
dropped_marker_links = _transfer_dropped_marker_links(document)
|
||||
for marker in incoming.get("markers") or []:
|
||||
old_id = str(marker.get("id"))
|
||||
_record_bounded_lineage(
|
||||
reference_report, bounded_seen, "marker", old_id,
|
||||
)
|
||||
binding = _binding_key(marker)
|
||||
if binding in duplicate and duplicate_policy == "skip":
|
||||
skipped.add(old_id)
|
||||
continue
|
||||
new_id = _fresh("marker", old_id, used)
|
||||
marker_map[old_id] = new_id
|
||||
id_maps["marker"][old_id] = new_id
|
||||
marker["id"] = new_id
|
||||
marker["space"] = new_space_id
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.space", new_id, old_space_id,
|
||||
)
|
||||
if marker.get("room_id") is not None:
|
||||
marker["room_id"] = old_room_ids.get(str(marker["room_id"]), marker["room_id"])
|
||||
old_room_id = str(marker["room_id"])
|
||||
marker["room_id"] = old_room_ids.get(old_room_id, marker["room_id"])
|
||||
if marker["room_id"] != old_room_id:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.room_id", new_id, old_room_id,
|
||||
)
|
||||
vacuum = marker.get("vacuum")
|
||||
if isinstance(vacuum, dict) and isinstance(vacuum.get("segment_map"), dict):
|
||||
vacuum["segment_map"] = {
|
||||
key: old_room_ids.get(str(value), value)
|
||||
for key, value in vacuum["segment_map"].items()
|
||||
}
|
||||
remapped_segments = {}
|
||||
for key, value in vacuum["segment_map"].items():
|
||||
old_room_id = str(value)
|
||||
remapped_segments[key] = old_room_ids.get(old_room_id, value)
|
||||
if remapped_segments[key] != old_room_id:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.vacuum.segment_map",
|
||||
new_id, old_room_id,
|
||||
)
|
||||
vacuum["segment_map"] = remapped_segments
|
||||
if binding in duplicate and duplicate_policy == "virtual":
|
||||
virtualized += 1
|
||||
virtualized_targets.add(old_id)
|
||||
@@ -1003,6 +1329,10 @@ def build_space_merge(
|
||||
dropped_marker_links += 1
|
||||
continue
|
||||
remapped.append("marker:" + target)
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.controls",
|
||||
str(marker.get("id", "?")), old_target,
|
||||
)
|
||||
marker["controls"] = remapped or None
|
||||
badge = marker.get("value_badge")
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
@@ -1012,11 +1342,30 @@ def build_space_merge(
|
||||
target = None if old_target in virtualized_targets else marker_map.get(old_target)
|
||||
if target:
|
||||
source["ref"] = "marker:" + target
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.value_badge",
|
||||
str(marker.get("id", "?")), old_target,
|
||||
)
|
||||
else:
|
||||
badge["enabled"] = False
|
||||
badge["source"] = None
|
||||
dropped_marker_links += 1
|
||||
|
||||
output_markers_by_id = {
|
||||
str(marker.get("id")): marker for marker in output_markers
|
||||
if marker.get("id") is not None
|
||||
}
|
||||
marker_link_map = {
|
||||
old_id: new_id for old_id, new_id in marker_map.items()
|
||||
if (target := output_markers_by_id.get(new_id)) is not None
|
||||
and target.get("removed") is not True
|
||||
and target.get("is_light") is True
|
||||
}
|
||||
target_config, target_layout, repaired_target_refs = _repair_target_space_refs(
|
||||
current_config, current_layout, id_maps, marker_link_map,
|
||||
reference_report, bounded_seen,
|
||||
)
|
||||
|
||||
output_layout: dict[str, Any] = {}
|
||||
for key, pos in (document["payload"].get("layout") or {}).items():
|
||||
if key in skipped:
|
||||
@@ -1042,6 +1391,16 @@ def build_space_merge(
|
||||
new_key = orphan["id"]
|
||||
if layout_binding in duplicate and duplicate_policy == "virtual":
|
||||
virtualized += 1
|
||||
if new_key != key:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "layout.owner", new_key, key,
|
||||
)
|
||||
old_layout_space = str(pos.get("s", "")) if isinstance(pos, dict) else ""
|
||||
if old_layout_space != new_space_id:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "layout.space", new_key,
|
||||
old_layout_space,
|
||||
)
|
||||
output_layout[new_key] = {**pos, "s": new_space_id}
|
||||
|
||||
merged_config = target_config
|
||||
@@ -1079,6 +1438,8 @@ def build_space_merge(
|
||||
merged_config, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
||||
).encode("utf-8")) > MAX_CONFIG_BYTES:
|
||||
raise ImportFailure("capacity_exceeded", "Merged configuration exceeds the store limit")
|
||||
if dropped_marker_links:
|
||||
reference_report["droppedIncomingLinks"]["marker.links"] = dropped_marker_links
|
||||
return merged_config, merged_layout, {
|
||||
"space_id": new_space_id,
|
||||
"space_title": space["title"],
|
||||
@@ -1088,6 +1449,10 @@ def build_space_merge(
|
||||
"orphan_markers": len(output_markers) - len(marker_map),
|
||||
"dropped_marker_links": dropped_marker_links,
|
||||
"repaired_target_refs": repaired_target_refs,
|
||||
"preserved_unresolved_refs": sum(
|
||||
int(value) for value in reference_report["preservedUnresolved"].values()
|
||||
),
|
||||
"reference_report": reference_report,
|
||||
}
|
||||
|
||||
|
||||
@@ -1211,6 +1576,70 @@ def _missing_areas(
|
||||
return sorted(incoming - available)
|
||||
|
||||
|
||||
def _materialize_import_candidate(
|
||||
document: dict[str, Any],
|
||||
current_config: dict[str, Any],
|
||||
current_layout: dict[str, Any],
|
||||
*,
|
||||
duplicate_policy: str,
|
||||
same_source: bool,
|
||||
content: list[dict[str, Any]],
|
||||
confirmation_required: bool,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
"""Build once the normalized config/layout that the preview may apply."""
|
||||
prepared = _json_copy(document)
|
||||
imported_config = prepared["payload"]["config"]
|
||||
if confirmation_required:
|
||||
_detach_missing(imported_config, content)
|
||||
if prepared["kind"] == "space":
|
||||
config, layout, details = build_space_merge(
|
||||
prepared, current_config, current_layout, duplicate_policy,
|
||||
same_source=same_source,
|
||||
)
|
||||
else:
|
||||
config = imported_config
|
||||
_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 {}
|
||||
settings.pop("known_devices", None)
|
||||
settings.pop("new_device_ids", None)
|
||||
details = {
|
||||
"dropped_marker_links": _transfer_dropped_marker_links(prepared),
|
||||
}
|
||||
try:
|
||||
config = CONFIG_SCHEMA(config)
|
||||
except vol.Invalid as err:
|
||||
raise ImportFailure("invalid_config", str(err)) from err
|
||||
try:
|
||||
layout = LAYOUT_SCHEMA(layout)
|
||||
except vol.Invalid as err:
|
||||
raise ImportFailure("invalid_layout", str(err)) from err
|
||||
details = _json_copy(details)
|
||||
details["counts"] = _counts(config, layout)
|
||||
return config, layout, details
|
||||
|
||||
|
||||
def _candidate_digest(candidate: dict[str, Any]) -> str:
|
||||
"""Bind a token to both its source and the exact normalized target pair."""
|
||||
return _document_digest({
|
||||
"document": candidate.get("document"),
|
||||
"duplicate_policy": candidate.get("duplicate_policy"),
|
||||
"config_rev": candidate.get("config_rev"),
|
||||
"layout_rev": candidate.get("layout_rev"),
|
||||
"content": candidate.get("content"),
|
||||
"confirmation_required": candidate.get("confirmation_required"),
|
||||
"same_source": candidate.get("same_source"),
|
||||
"target_config": candidate.get("target_config"),
|
||||
"target_layout": candidate.get("target_layout"),
|
||||
"details": candidate.get("details"),
|
||||
})
|
||||
|
||||
|
||||
def create_preview(
|
||||
runtime: HouseplanData,
|
||||
raw: bytes,
|
||||
@@ -1236,16 +1665,10 @@ def create_preview(
|
||||
validate_opening_passages(incoming_config, validate_all=True)
|
||||
except OpeningPassageError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
if document["kind"] == "space":
|
||||
_merged_config, _merged_layout, details = build_space_merge(
|
||||
document, current_config, current_layout, duplicate_policy,
|
||||
same_source=same_source,
|
||||
)
|
||||
else:
|
||||
if document["kind"] == "full":
|
||||
dropped_marker_links = _transfer_dropped_marker_links(document)
|
||||
dropped_marker_links += _drop_invalid_import_marker_links(incoming_config)
|
||||
document["transfer"]["dropped_marker_links"] = dropped_marker_links
|
||||
details["dropped_marker_links"] = dropped_marker_links
|
||||
try:
|
||||
validate_marker_controls(incoming_config, validate_all=True)
|
||||
validate_marker_light_entities(incoming_config, validate_all=True)
|
||||
@@ -1253,13 +1676,19 @@ def create_preview(
|
||||
except MarkerControlError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
content, confirmation = _content_state(document, same_source, config_root)
|
||||
target_config, target_layout, details = _materialize_import_candidate(
|
||||
document, current_config, current_layout,
|
||||
duplicate_policy=duplicate_policy,
|
||||
same_source=same_source,
|
||||
content=content,
|
||||
confirmation_required=confirmation,
|
||||
)
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
candidate = {
|
||||
"owner_id": owner_id,
|
||||
"created": now,
|
||||
"expires": now + IMPORT_PREVIEW_TTL_S,
|
||||
"digest": _document_digest(document),
|
||||
"document": document,
|
||||
"duplicate_policy": duplicate_policy,
|
||||
"config_rev": int(current_config_data.get("rev", 0)),
|
||||
@@ -1267,7 +1696,11 @@ def create_preview(
|
||||
"content": content,
|
||||
"confirmation_required": confirmation,
|
||||
"same_source": same_source,
|
||||
"target_config": target_config,
|
||||
"target_layout": target_layout,
|
||||
"details": details,
|
||||
}
|
||||
candidate["digest"] = _candidate_digest(candidate)
|
||||
# Opportunistic expiry and per-user oldest-first eviction.
|
||||
runtime.import_previews = {
|
||||
key: value for key, value in runtime.import_previews.items()
|
||||
@@ -1319,7 +1752,7 @@ def get_candidate(runtime: HouseplanData, token: str, owner_id: str, *, consume:
|
||||
raise ImportFailure("preview_expired", "Import preview expired")
|
||||
if candidate.get("owner_id") != owner_id:
|
||||
raise ImportFailure("preview_owner_mismatch", "Preview belongs to another user")
|
||||
if candidate.get("digest") != _document_digest(candidate.get("document") or {}):
|
||||
if candidate.get("digest") != _candidate_digest(candidate):
|
||||
runtime.import_previews.pop(token, None)
|
||||
raise ImportFailure("invalid_format", "Import preview candidate changed")
|
||||
if consume:
|
||||
@@ -1342,21 +1775,26 @@ def revalidate_candidate(
|
||||
"spaces": [], "markers": [], "settings": {},
|
||||
}
|
||||
current_layout = current_layout_data.get("layout") or {}
|
||||
details: dict[str, Any] = {}
|
||||
if document["kind"] == "space":
|
||||
_config, _layout, details = build_space_merge(
|
||||
document, current_config, current_layout, duplicate_policy,
|
||||
same_source=bool(candidate.get("same_source")),
|
||||
)
|
||||
if config_root is not None:
|
||||
content, confirmation = _content_state(
|
||||
document, bool(candidate.get("same_source")), config_root
|
||||
)
|
||||
candidate["content"] = content
|
||||
candidate["confirmation_required"] = confirmation
|
||||
target_config, target_layout, details = _materialize_import_candidate(
|
||||
document, current_config, current_layout,
|
||||
duplicate_policy=duplicate_policy,
|
||||
same_source=bool(candidate.get("same_source")),
|
||||
content=candidate.get("content") or [],
|
||||
confirmation_required=bool(candidate.get("confirmation_required")),
|
||||
)
|
||||
candidate["duplicate_policy"] = duplicate_policy
|
||||
candidate["config_rev"] = int(current_config_data.get("rev", 0))
|
||||
candidate["layout_rev"] = int(current_layout_data.get("rev", 0))
|
||||
candidate["target_config"] = target_config
|
||||
candidate["target_layout"] = target_layout
|
||||
candidate["details"] = details
|
||||
candidate["digest"] = _candidate_digest(candidate)
|
||||
incoming = document["payload"]
|
||||
return {
|
||||
"preview": {
|
||||
@@ -1388,35 +1826,17 @@ def prepare_apply(
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
if candidate.get("confirmation_required") and not confirm_missing_content:
|
||||
raise ImportFailure("content_confirmation_required", "Missing content must be confirmed")
|
||||
document = candidate["document"]
|
||||
# Detachment is defined in the exported identity space. Do it before a
|
||||
# space import remaps owners to fresh ids.
|
||||
imported_config = _json_copy(document["payload"]["config"])
|
||||
if candidate.get("confirmation_required"):
|
||||
_detach_missing(imported_config, candidate.get("content") or [])
|
||||
if document["kind"] == "full":
|
||||
config = imported_config
|
||||
_materialize_global_background(config)
|
||||
# Full exports keep the data-model version in the portable envelope so
|
||||
# the payload can be validated independently. Restore it before the
|
||||
# configuration is persisted; otherwise every full round-trip silently
|
||||
# downgrades the stored plan to an unversioned document.
|
||||
model_version = document.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(document["payload"]["layout"])
|
||||
if not candidate.get("same_source"):
|
||||
settings = config.get("settings") or {}
|
||||
settings.pop("known_devices", None)
|
||||
settings.pop("new_device_ids", None)
|
||||
details = {"counts": _counts(config, layout)}
|
||||
else:
|
||||
local_document = {**document, "payload": {**document["payload"], "config": imported_config}}
|
||||
config, layout, details = build_space_merge(
|
||||
local_document, current_config, current_layout,
|
||||
duplicate_policy or candidate.get("duplicate_policy", "skip"),
|
||||
same_source=bool(candidate.get("same_source")),
|
||||
)
|
||||
details["counts"] = _counts(config, layout)
|
||||
return CONFIG_SCHEMA(config), LAYOUT_SCHEMA(layout), details
|
||||
if duplicate_policy is not None and duplicate_policy != candidate.get("duplicate_policy"):
|
||||
raise ImportFailure("conflict", "Duplicate policy changed after the preview")
|
||||
if candidate.get("digest") != _candidate_digest(candidate):
|
||||
raise ImportFailure("invalid_format", "Import preview candidate changed")
|
||||
config = candidate.get("target_config")
|
||||
layout = candidate.get("target_layout")
|
||||
details = candidate.get("details")
|
||||
if not isinstance(config, dict) or not isinstance(layout, dict) \
|
||||
or not isinstance(details, dict):
|
||||
raise ImportFailure("invalid_format", "Import preview has no materialized candidate")
|
||||
# The current stores are intentionally unused: revisions are checked by
|
||||
# the websocket handler under the write lock before this function runs.
|
||||
_ = current_config, current_layout
|
||||
return _json_copy(config), _json_copy(layout), _json_copy(details)
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.67.0-rc.1"
|
||||
"version": "1.67.0-rc.2"
|
||||
}
|
||||
|
||||
@@ -145,20 +145,257 @@ def _angle_delta_mod_180(first: float, second: float) -> float:
|
||||
return abs((first - second + 90.0) % 180.0 - 90.0)
|
||||
|
||||
|
||||
def _segments_cover_target(segments: list[tuple[list, list]], target_a: list, target_b: list) -> bool:
|
||||
"""Whether a collinear union covers one complete positive target span."""
|
||||
dx, dy, length = _segment_metrics(target_a, target_b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
ranges = []
|
||||
for a, b in segments:
|
||||
if (_line_distance(a, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(b, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
continue
|
||||
lo, hi = sorted((_projection(a, target_a, target_b),
|
||||
_projection(b, target_a, target_b)))
|
||||
lo, hi = max(0.0, lo), min(length, hi)
|
||||
if hi - lo > _OPTIMIZE_REHOST_EPSILON:
|
||||
ranges.append((lo, hi))
|
||||
reached = 0.0
|
||||
for lo, hi in sorted(ranges):
|
||||
if lo > reached + _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
reached = max(reached, hi)
|
||||
if reached >= length - _OPTIMIZE_REHOST_EPSILON:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_OPTIMIZE_PARTITION_KEYS = {"id", "a", "b", "cm"}
|
||||
|
||||
|
||||
def _known_optimize_partition(partition: dict) -> bool:
|
||||
return isinstance(partition, dict) and set(partition).issubset(_OPTIMIZE_PARTITION_KEYS)
|
||||
|
||||
|
||||
def _axis_range(a: list, b: list, axis_a: list, axis_b: list) -> tuple[float, float] | None:
|
||||
"""Project one positive collinear overlap onto ``axis_a -> axis_b``."""
|
||||
if not (isinstance(a, list) and len(a) == 2
|
||||
and isinstance(b, list) and len(b) == 2):
|
||||
return None
|
||||
if (_line_distance(a, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(b, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
return None
|
||||
_, _, length = _segment_metrics(axis_a, axis_b)
|
||||
lo, hi = sorted((_projection(a, axis_a, axis_b),
|
||||
_projection(b, axis_a, axis_b)))
|
||||
lo, hi = max(0.0, lo), min(length, hi)
|
||||
return (lo, hi) if hi - lo > _OPTIMIZE_REHOST_EPSILON else None
|
||||
|
||||
|
||||
def _axis_point(a: list, b: list, along: float) -> list[float]:
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
return [float(a[0]) + dx / length * along,
|
||||
float(a[1]) + dy / length * along]
|
||||
|
||||
|
||||
def _optimize_partition_needs_proof(old_partition: dict, space: dict) -> bool:
|
||||
"""Select #296 removal/splitting, not ordinary Optimize grid alignment."""
|
||||
partition_id = str(old_partition.get("id", ""))
|
||||
current = next((item for item in space.get("partitions") or []
|
||||
if str(item.get("id", "")) == partition_id), None)
|
||||
if current == old_partition:
|
||||
return False
|
||||
if current is None:
|
||||
return True
|
||||
if not (_known_optimize_partition(old_partition)
|
||||
and _known_optimize_partition(current)):
|
||||
return False
|
||||
try:
|
||||
if float(current.get("cm")) != float(old_partition.get("cm")):
|
||||
return False
|
||||
old_a, old_b = old_partition["a"], old_partition["b"]
|
||||
current_range = _axis_range(current["a"], current["b"], old_a, old_b)
|
||||
_, _, old_length = _segment_metrics(old_a, old_b)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
return current_range is not None and (
|
||||
current_range[0] > _OPTIMIZE_REHOST_EPSILON
|
||||
or current_range[1] < old_length - _OPTIMIZE_REHOST_EPSILON
|
||||
)
|
||||
|
||||
|
||||
def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:
|
||||
"""Independently prove every removed atom of one old partition axis."""
|
||||
if not _known_optimize_partition(old_partition):
|
||||
return False
|
||||
try:
|
||||
old_a, old_b = old_partition["a"], old_partition["b"]
|
||||
old_cm = float(old_partition["cm"])
|
||||
_, _, length = _segment_metrics(old_a, old_b)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON or old_cm <= 0:
|
||||
return False
|
||||
|
||||
rooms: list[tuple[str, list[tuple[list, list]]]] = []
|
||||
for room in space.get("rooms") or []:
|
||||
poly = _room_polygon(room)
|
||||
if len(poly) < 3:
|
||||
return False
|
||||
rooms.append((str(room.get("id", "")), [
|
||||
(poly[index], poly[(index + 1) % len(poly)])
|
||||
for index in range(len(poly))
|
||||
]))
|
||||
|
||||
candidate_partitions = space.get("partitions") or []
|
||||
retained: list[tuple[float, float]] = []
|
||||
breakpoints = [0.0, length]
|
||||
|
||||
def note_segment(a: list, b: list) -> tuple[float, float] | None:
|
||||
projected = _axis_range(a, b, old_a, old_b)
|
||||
if projected:
|
||||
breakpoints.extend(projected)
|
||||
return projected
|
||||
|
||||
for partition in candidate_partitions:
|
||||
try:
|
||||
projected = note_segment(partition["a"], partition["b"])
|
||||
candidate_cm = float(partition["cm"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if projected and _known_optimize_partition(partition) and candidate_cm == old_cm:
|
||||
retained.append(projected)
|
||||
for _, edges in rooms:
|
||||
for edge_a, edge_b in edges:
|
||||
note_segment(edge_a, edge_b)
|
||||
for collection in (space.get("walls") or [], space.get("open_spans") or []):
|
||||
for segment in collection:
|
||||
try:
|
||||
note_segment(segment["a"], segment["b"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
points = sorted(set(round(point, 12) for point in breakpoints
|
||||
if -_OPTIMIZE_REHOST_EPSILON <= point
|
||||
<= length + _OPTIMIZE_REHOST_EPSILON))
|
||||
for lo, hi in zip(points, points[1:]):
|
||||
if hi - lo <= _OPTIMIZE_REHOST_EPSILON:
|
||||
continue
|
||||
if any(start <= lo + _OPTIMIZE_REHOST_EPSILON
|
||||
and end >= hi - _OPTIMIZE_REHOST_EPSILON
|
||||
for start, end in retained):
|
||||
continue
|
||||
target_a, target_b = _axis_point(old_a, old_b, lo), _axis_point(old_a, old_b, hi)
|
||||
owners = {room_id for room_id, edges in rooms
|
||||
if _segments_cover_target(edges, target_a, target_b)}
|
||||
collinear = {room_id for room_id, edges in rooms
|
||||
if any(_segments_overlap_on_axis(
|
||||
target_a, target_b, edge_a, edge_b
|
||||
) for edge_a, edge_b in edges)}
|
||||
if len(owners) not in (1, 2) or collinear != owners:
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(
|
||||
target_a, target_b, span["a"], span["b"]
|
||||
) for span in space.get("open_spans") or []):
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(
|
||||
target_a, target_b, partition["a"], partition["b"]
|
||||
) for partition in candidate_partitions):
|
||||
return False
|
||||
|
||||
covering_walls = []
|
||||
for wall in space.get("walls") or []:
|
||||
try:
|
||||
wall_a, wall_b = wall["a"], wall["b"]
|
||||
if _segment_covers(wall_a, wall_b, target_a, target_b):
|
||||
covering_walls.append((
|
||||
_segment_metrics(wall_a, wall_b)[2], float(wall["cm"]),
|
||||
))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
effective_cm = (min(covering_walls, key=lambda item: item[0])[1]
|
||||
if covering_walls else _DEFAULT_ROOM_WALL_CM)
|
||||
if effective_cm + _OPTIMIZE_REHOST_EPSILON < old_cm:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _hosted_opening_geometry(opening: dict, partition: dict) -> tuple[float, float, float, float] | None:
|
||||
host = opening.get("host")
|
||||
try:
|
||||
if not isinstance(host, dict) or host.get("kind") != "partition":
|
||||
return None
|
||||
t = float(host["t"])
|
||||
a, b = partition["a"], partition["b"]
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
opening_length = float(opening["length"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if not 0 <= t <= 1 or length <= _OPTIMIZE_REHOST_EPSILON or opening_length <= 0:
|
||||
return None
|
||||
angle = math.degrees(math.atan2(dy, dx))
|
||||
if angle >= 90:
|
||||
angle -= 180
|
||||
elif angle < -90:
|
||||
angle += 180
|
||||
return float(a[0]) + dx * t, float(a[1]) + dy * t, angle, opening_length
|
||||
|
||||
|
||||
def _safe_optimize_residual_rehost(
|
||||
space: dict, old_space: dict, opening: dict, old_opening: dict,
|
||||
) -> bool:
|
||||
"""Prove a hosted opening rebound to a newly-created residual id."""
|
||||
old_host, new_host = old_opening.get("host"), opening.get("host")
|
||||
if not (isinstance(old_host, dict) and isinstance(new_host, dict)
|
||||
and old_host.get("kind") == new_host.get("kind") == "partition"):
|
||||
return False
|
||||
old_partition = next((item for item in old_space.get("partitions") or []
|
||||
if str(item.get("id", "")) == str(old_host.get("id", ""))), None)
|
||||
new_partition = next((item for item in space.get("partitions") or []
|
||||
if str(item.get("id", "")) == str(new_host.get("id", ""))), None)
|
||||
if old_partition is None or new_partition is None:
|
||||
return False
|
||||
if not _safe_optimize_partition_delta(space, old_partition):
|
||||
return False
|
||||
ignored = {"host", "x", "y", "angle"}
|
||||
if ({key: value for key, value in old_opening.items() if key not in ignored}
|
||||
!= {key: value for key, value in opening.items() if key not in ignored}):
|
||||
return False
|
||||
old_geometry = _hosted_opening_geometry(old_opening, old_partition)
|
||||
new_geometry = _hosted_opening_geometry(opening, new_partition)
|
||||
if old_geometry is None or new_geometry is None:
|
||||
return False
|
||||
old_x, old_y, old_angle, old_length = old_geometry
|
||||
new_x, new_y, new_angle, new_length = new_geometry
|
||||
try:
|
||||
explicit_x, explicit_y = float(opening["x"]), float(opening["y"])
|
||||
explicit_angle = float(opening["angle"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
return (
|
||||
abs(old_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and abs(old_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and abs(old_length - new_length) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and _angle_delta_mod_180(old_angle, new_angle) <= 1e-7
|
||||
and abs(explicit_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and abs(explicit_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and _angle_delta_mod_180(explicit_angle, new_angle) <= 1e-7
|
||||
)
|
||||
|
||||
|
||||
def _safe_optimize_partition_rehost(
|
||||
space: dict, old_space: dict, opening: dict, old_opening: dict,
|
||||
) -> bool:
|
||||
"""Independently prove the exact #276 partition-to-room-wall transition."""
|
||||
"""Independently prove the #276/#296 partition-to-room-wall transition."""
|
||||
old_host = old_opening.get("host")
|
||||
if not isinstance(old_host, dict) or old_host.get("kind") != "partition":
|
||||
return False
|
||||
partition_id = str(old_host.get("id", ""))
|
||||
old_partition = next((item for item in old_space.get("partitions") or []
|
||||
if str(item.get("id", "")) == partition_id), None)
|
||||
if old_partition is None or any(
|
||||
str(item.get("id", "")) == partition_id
|
||||
for item in space.get("partitions") or []
|
||||
):
|
||||
if old_partition is None:
|
||||
return False
|
||||
if not _safe_optimize_partition_delta(space, old_partition):
|
||||
return False
|
||||
a, b = old_partition.get("a"), old_partition.get("b")
|
||||
if not (isinstance(a, list) and len(a) == 2 and isinstance(b, list) and len(b) == 2):
|
||||
@@ -167,36 +404,6 @@ def _safe_optimize_partition_rehost(
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
|
||||
owners: set[str] = set()
|
||||
collinear_rooms: set[str] = set()
|
||||
for room in space.get("rooms") or []:
|
||||
poly = _room_polygon(room)
|
||||
room_id = str(room.get("id", ""))
|
||||
edges = [(poly[index], poly[(index + 1) % len(poly)])
|
||||
for index in range(len(poly))]
|
||||
if any(_segment_covers(edge_a, edge_b, a, b) for edge_a, edge_b in edges):
|
||||
owners.add(room_id)
|
||||
if any(_segments_overlap_on_axis(a, b, edge_a, edge_b)
|
||||
for edge_a, edge_b in edges):
|
||||
collinear_rooms.add(room_id)
|
||||
if len(owners) not in (1, 2) or collinear_rooms != owners:
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(a, b, span["a"], span["b"])
|
||||
for span in space.get("open_spans") or []):
|
||||
return False
|
||||
|
||||
covering_walls = []
|
||||
for wall in space.get("walls") or []:
|
||||
wall_a, wall_b = wall.get("a"), wall.get("b")
|
||||
if not (isinstance(wall_a, list) and isinstance(wall_b, list)):
|
||||
continue
|
||||
if _segment_covers(wall_a, wall_b, a, b):
|
||||
covering_walls.append((_segment_metrics(wall_a, wall_b)[2], float(wall["cm"])))
|
||||
effective_cm = min(covering_walls, key=lambda item: item[0])[1] \
|
||||
if covering_walls else _DEFAULT_ROOM_WALL_CM
|
||||
if effective_cm + _OPTIMIZE_REHOST_EPSILON < float(old_partition.get("cm", 0)):
|
||||
return False
|
||||
|
||||
ignored = {"host", "x", "y", "angle"}
|
||||
old_stable = {key: value for key, value in old_opening.items() if key not in ignored}
|
||||
new_stable = {key: value for key, value in opening.items() if key not in ignored}
|
||||
@@ -226,6 +433,42 @@ def _safe_optimize_partition_rehost(
|
||||
or along + opening_length / 2 > length + _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
|
||||
half_dx, half_dy = dx / length * opening_length / 2, dy / length * opening_length / 2
|
||||
target_a = [expected_x - half_dx, expected_y - half_dy]
|
||||
target_b = [expected_x + half_dx, expected_y + half_dy]
|
||||
owners: set[str] = set()
|
||||
collinear_rooms: set[str] = set()
|
||||
for room in space.get("rooms") or []:
|
||||
poly = _room_polygon(room)
|
||||
room_id = str(room.get("id", ""))
|
||||
edges = [(poly[index], poly[(index + 1) % len(poly)])
|
||||
for index in range(len(poly))]
|
||||
if _segments_cover_target(edges, target_a, target_b):
|
||||
owners.add(room_id)
|
||||
if any(_segments_overlap_on_axis(target_a, target_b, edge_a, edge_b)
|
||||
for edge_a, edge_b in edges):
|
||||
collinear_rooms.add(room_id)
|
||||
if len(owners) not in (1, 2) or collinear_rooms != owners:
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(target_a, target_b, span["a"], span["b"])
|
||||
for span in space.get("open_spans") or []):
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(target_a, target_b, item["a"], item["b"])
|
||||
for item in space.get("partitions") or []):
|
||||
return False
|
||||
|
||||
covering_walls = []
|
||||
for wall in space.get("walls") or []:
|
||||
wall_a, wall_b = wall.get("a"), wall.get("b")
|
||||
if not (isinstance(wall_a, list) and isinstance(wall_b, list)):
|
||||
continue
|
||||
if _segment_covers(wall_a, wall_b, target_a, target_b):
|
||||
covering_walls.append((_segment_metrics(wall_a, wall_b)[2], float(wall["cm"])))
|
||||
effective_cm = min(covering_walls, key=lambda item: item[0])[1] \
|
||||
if covering_walls else _DEFAULT_ROOM_WALL_CM
|
||||
if effective_cm + _OPTIMIZE_REHOST_EPSILON < float(old_partition.get("cm", 0)):
|
||||
return False
|
||||
|
||||
for other in space.get("openings") or []:
|
||||
if other is opening or str(other.get("id", "")) == str(opening.get("id", "")):
|
||||
continue
|
||||
@@ -294,6 +537,17 @@ def validate_partition_opening_hosts(
|
||||
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
|
||||
@@ -324,6 +578,14 @@ def validate_partition_opening_hosts(
|
||||
raise PartitionOpeningJambMarginError(
|
||||
space_id, opening_id, margin, margin_cm
|
||||
)
|
||||
if allow_optimize_rehost and old_space:
|
||||
for old_partition in old_space.get("partitions") or []:
|
||||
if (_optimize_partition_needs_proof(old_partition, space)
|
||||
and not _safe_optimize_partition_delta(space, old_partition)):
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space_id}; partition={old_partition.get('id', '')}; "
|
||||
"optimize delta unproved"
|
||||
)
|
||||
|
||||
|
||||
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
|
||||
|
||||
@@ -511,6 +511,8 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
"counts": details.get("counts", {}),
|
||||
"space_id": details.get("space_id"),
|
||||
"repaired_target_refs": details.get("repaired_target_refs", 0),
|
||||
"preserved_unresolved_refs": details.get("preserved_unresolved_refs", 0),
|
||||
"reference_report": details.get("reference_report", {}),
|
||||
"can_undo": kind == "full",
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 72 KiB |
@@ -1,17 +1,19 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 44,
|
||||
"acceptedAt": "2026-08-24T16:12:22.415Z",
|
||||
"sourceFingerprint": "7a687db9aadb86ee4af8374678c06f3697fb98935ea9bfa3df7b2cd0b652543a",
|
||||
"matrixVersion": 45,
|
||||
"acceptedAt": "2026-08-25T14:41:01.859Z",
|
||||
"sourceFingerprint": "8bca2e9a29df5fd509825fbfe2a790d7aa71c0f775867abf3328e3d609fa900d",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"coincident-partition-before-dark": "c0e38cfff096725cf1eb5dc91e005b2af983172d04e6573075db635275cabca3",
|
||||
"coincident-partition-thin-dark": "3a33ebf94e6c19b0079cf66975180817146d68ae97d1f9ff4a26711f4ca54903",
|
||||
"coincident-partition-thick-dark": "33f9296e765f4ea9922cb3b13d11b50306709ba4fcf3114d351eda1833399870",
|
||||
"coincident-partition-virtual-dark": "84969a05c442549dac0bcbb7e507ee77967e42f75d0fdf3c8e8fbe2170c65834",
|
||||
"hidden-wall-diagnostics-plan-light": "6470ed8b09b21144ec1399389e9a57774e977a6473b8eaa7de7e9b5df3273f21",
|
||||
"hidden-wall-diagnostics-plan-dark": "da6e725ddfad7c807408a710b78090dc222b56dcee484a29baa68b5452fc44e5",
|
||||
"coincident-partition-before-dark": "19626db704fccb7a42b519ffb20b7bad82c726e8845c752ab451f731d264561c",
|
||||
"coincident-partition-thin-dark": "3afede1f99b86454bf4e50d9628196a2240243e034644592c5c3efa1b4561930",
|
||||
"coincident-partition-thick-dark": "57960bd09e6cbbeb073561692690ca68a72d6dbf8242198ef73b8d4ae4d8fbcc",
|
||||
"coincident-partition-virtual-dark": "da7adbf931ad95f4e620d9d088177f1da20d88a21b9e85b6ff2675925d5557b4",
|
||||
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
|
||||
"split-corner-wall-thin-dark": "17e49394c300887bb5349bf477292571e96e209c9515db6853348883d95a1422",
|
||||
"split-corner-wall-thick-dark": "309b8fe8a206f4f3fc50f6a27fc762a933f245b778e7b1f9a6348b66f39b2c39",
|
||||
"split-corner-wall-thick-dark": "f7864a6b2ab58586540f836183c9e59f09f9f57165ec0e0b9cd8444ada71153c",
|
||||
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
|
||||
"isometric-geometry-view-dark": "43ad006844a78b502b36aeeb82d82669532e06d2c798de928a5207acd24bd5f9",
|
||||
"isometric-geometry-view-light": "0fcb10ffdc0baa9006d1c2f0406ebf19e71ba9feda180979a51b74166d7200d2",
|
||||
@@ -19,53 +21,53 @@
|
||||
"isometric-no-borders-dark": "c105382d883bbdae8562b756ccbf47ac1fcf46675c6aa7654289856223a3ebf0",
|
||||
"isometric-touch-kiosk-dark": "be122ceec8114374ba384f51d5d9287e498a97354865c5a9f91d505ca18d8640",
|
||||
"isometric-large-warm-remount-dark": "d76e3334bf80bff265b1e8aa6770c5eff5df6fbfebeb4703b04d048c4252eb68",
|
||||
"geometry-view-dark-fit": "30985326ad907fac4b93f848860e5b6c7737f24e8281706c3f69a189477f932f",
|
||||
"geometry-view-light-fit": "310cca616bf1fc5fda4f1e8d58e80ddf2fe2662d088829e22eef7b9e6b5ddd93",
|
||||
"geometry-view-dark-fit": "1b80d2d0fe84cfe15f1fa29ccc2a2dcc66b4c5e62d1722c06c00442c78979be0",
|
||||
"geometry-view-light-fit": "91caf6621af00eb0cd16589e2fc79976cdca1e8467d0e86151ecbc46948612d9",
|
||||
"room-label-parity-view-dark": "7f1da43466a13f82a7595dddabe6938ed974debfb14c8f40e729e273a53ac4ff",
|
||||
"room-label-parity-plan-dark": "5f976d5743d68a27b594e1a5ddf45e5ad3708f5c703409e5cefb7c96d3048096",
|
||||
"room-label-parity-view-light": "ee3d303fb2b329c356a4354d355c29694130a38d4812ffbfefbe861f5d3f9860",
|
||||
"room-label-parity-plan-light": "d32b538499a031d4310b243cc9ef43610f6cd67aabfde7cd01b59c88b0363ec5",
|
||||
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
|
||||
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
|
||||
"day-cycle-dawn-dark": "b01d9add378afb47912da10f6327d43177d4bbb0b42cb4323395fd01bf2fe8f5",
|
||||
"day-cycle-day-dark": "97db91bd4daf41d903cc968d70a5a4ab7af7e332b60b7316e339f490e08fa953",
|
||||
"day-cycle-dusk-dark": "a856187898fc425a48dd165f5ec01f0f50b78043b382f708dbffd810bfdbf043",
|
||||
"day-cycle-night-dark": "b863260250d739fd369140356e195a64d3ac58478ab2c1e43415abd8f96a7fbd",
|
||||
"geometry-plan-editor-dark": "a1ca4219a1e7b2c4749b72edad87cd0bdee793390cd6efe972aca1d41d0d2478",
|
||||
"safe-resize-handles-clamp-light": "298670cc94642968e58c45560597e8277c405cf61c2a8b5e08f1544612a6ed4b",
|
||||
"safe-resize-handles-clamp-dark": "64f32f2038238f423ba3cc1ed618bf9d32704c8161042751d447c3f884e37e4a",
|
||||
"space-tab-drop-before-light": "86d8159ac6191980f2ac91b137c3947219da45fef81bc2b358442958eda9d0f8",
|
||||
"space-tab-drop-after-dark": "2e2bde063f9fa34284beb27457f1542e8b6085033be67fd0dc1923b81c6af1bf",
|
||||
"plan-snap-endpoint-light": "78225caf0522f69c294b299212a717260c31e9620bdefdc970dc84730ae06e17",
|
||||
"plan-snap-line-gaps-dark": "61bac139592fd292a8e9ded541dc4b98912779f415f371108c2d99a18c7d9d57",
|
||||
"wall-junctions-plan-preview-light": "1482b01209a796f93ebc23e231b33fe869ee96b6f46d1fb2d32ccf677a94e602",
|
||||
"wall-junctions-plan-t-dark": "40f0b57e2b713df28c2769ba44931b165aaa669d4c0b3dd97d562e904292d449",
|
||||
"day-cycle-dawn-dark": "3f32bc1ed042475516f7e239aedd7fee11717b00fa2103ae9f1231e006d64015",
|
||||
"day-cycle-day-dark": "1a0f53c948ae669c7ab4e1dcb931c11e7c878974862637dd1f596011876a7ed4",
|
||||
"day-cycle-dusk-dark": "ec95e99ac3c7e4d6affa2f7944759727251dc4f1b62e2855e421a7e48f7280eb",
|
||||
"day-cycle-night-dark": "cc49dc53dabb63b7dd800eaa65c8748972fcf3c1abd948b34d1e0f973e63f0d2",
|
||||
"geometry-plan-editor-dark": "fd8d067daa3b2a4e572dea9644486e6324318e8ea4e5f43e3df2069a05389a88",
|
||||
"safe-resize-handles-clamp-light": "ed705040357cad043095baf12d02cc69abdc3db27ff40066a6979be85d035f77",
|
||||
"safe-resize-handles-clamp-dark": "9d81a45f3acac787cde1b9eb74a858020fbad09670b8ff5b081869b5eca73b82",
|
||||
"space-tab-drop-before-light": "401dded3d7fdff3e823d46eac1b3cef2d6e0eb05f48b5ea289cdd8ecd941dee0",
|
||||
"space-tab-drop-after-dark": "a851621040e14f911b1080488f2c75e3d9bc0d5fef74fb5d0081f80b023ff0a2",
|
||||
"plan-snap-endpoint-light": "db967076cd9fd50c2a2a1337e4ecbf4ef67cb7f800401f6227497c75415951d5",
|
||||
"plan-snap-line-gaps-dark": "ff1b6bbf2e1b4272bb2f0e96d92136aa6960ee24acef1b63b4478e54441a4b58",
|
||||
"wall-junctions-plan-preview-light": "fa81fa6c6dba92578a4beee5ed29f407443a3bc61affa25c44fcfefab0c7070c",
|
||||
"wall-junctions-plan-t-dark": "f8a517d316a99ca9ee5b4ff56f515be15d440adfc551bbdaaa5e9d9a411a79bc",
|
||||
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
|
||||
"junction-patch-resilience-plan-dark": "5dac2f2f81c801b0d9b1c0ee786f1f531906c80ddeec36f9c91b524468c13608",
|
||||
"junction-patch-resilience-view-dark": "efe6e3ebd0e08e936bfa79886d5f55185198cf770a5d2473160cf2335be46340",
|
||||
"junction-patch-resilience-plan-dark": "57ea7522c438e52292f226f975bb112930976b6e072b72322180d52ec0277f4f",
|
||||
"junction-patch-resilience-view-dark": "1f0df26ed72bcc5bef06a8366d7043ad40b4a2bb744fa5a64248c29eae968424",
|
||||
"wall-union-isolation-view-light": "291c045758be2814be88d12ab46bdc3d12cb99a03dbbcc9a1539c2c9a5931edf",
|
||||
"wall-union-isolation-view-dark": "616fc6cc7144c9596ae52421027914e222f30f26de45215fa7e7b6175ca78543",
|
||||
"multiwall-junction-bevel-view-dark": "70f7f0e7c60beb33853fc26445b6901d48299c2c92ddc41f6e1e2cfb4cac2320",
|
||||
"multiwall-junction-bevel-view-dark": "e226d023fbab0502eb747395d43a46bb56bcf2be037ba4f9b7ad74ba406d9671",
|
||||
"orthogonal-strip-cell-5-view-dark": "9b8e4bd22cbf1a82ff48f052822967c61179290a3234ae48ef547642329b5a90",
|
||||
"orthogonal-strip-cell-1-view-dark": "44e245db41ab9a4e575cefbf7656055dba7e27fc62c2b1215445517a9ea81386",
|
||||
"wall-key-roundtrip-view-dark": "28895aa02eb995f976b26a2d2b3675954611641b60910f40427b15a95ef53e3e",
|
||||
"orthogonal-strip-cell-1-view-dark": "e59e51ad3645144161459c438a07420730c4dcfd2c33bd2b18e8c3e57ea84515",
|
||||
"wall-key-roundtrip-view-dark": "df46b4e209f98b97958748097ddc646ddc64d8285c2651762de71ec2456ac690",
|
||||
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
|
||||
"opening-placement-door-thick-wall-dark": "59469954c1ea5a2e79df7eea1c287228998c0bfe1b3dc43869d553f25be78222",
|
||||
"opening-placement-passage-thick-wall-dark": "f3efc2bafbc472f18042f26a896870a4256aa2ea843d887f8b33301001a947f0",
|
||||
"opening-placement-passage-thick-wall-light": "e5f6024151eebb328f211793bd2d346703d627524bdff931c61ce13b5a5fa7f4",
|
||||
"opening-placement-door-thick-wall-dark": "1157139aac7325b40e3869b8c3b5444509b4bc24a415ddf6f8d5402624f3a04a",
|
||||
"opening-placement-passage-thick-wall-dark": "6d4a9a953b70401ca11cd6ce96c2e0e0781bbfff71e758dc57958a4163e2712d",
|
||||
"opening-placement-passage-thick-wall-light": "b004b1395652c8a8eef5f5002b471ecf8f2f0347f158a14a056ff2b7382e4ea3",
|
||||
"opening-symbol-room-wall-light": "562fed297210ef542de608e384c2d344dddd193a5b43615930c9f76faaf53b42",
|
||||
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
|
||||
"opening-symbol-flip-pairs-light": "33364f51fb65e49bfdd6c41d6e0c53542c89fadc45a3c6e5adc590144c4676ea",
|
||||
"isometric-opening-symbol-parity-dark": "302daf5d90d4af5ef90036cc10e957e6fc909ea56f9f2af938ab850d4f608e34",
|
||||
"geometry-devices-editor-dark": "9053fd940e5f2a27f0062b159e4b4debd3d4f47d5cd5bd81c0b154ecc0f4639b",
|
||||
"geometry-decor-editor-dark": "736e289cf16adb5fa5e58e7db7d75475e16065b58039bbdc0cec402f4256f390",
|
||||
"tray-wide-selection-en": "dab331bc075c9742b4aecb9feb7de01a30601416d7dcaeb78f50252af0d58a5d",
|
||||
"tray-wide-tool-ru": "0623ff17c4c22d338d9d317f348b2e181658cf0fe3275a6dc6b80d4077ec3b68",
|
||||
"tray-medium-group-en": "03410227827c034035245bf84b4a283ac72f0da3925d3a4b6873f1bb1992f057",
|
||||
"tray-medium-selection-ru": "ec9c28eb81cf2b5106254422d17e0adc58ba22a720db4f4832c669ccd0c84723",
|
||||
"tray-narrow-palette-en": "10f4786a9e6020a8cb1c92b4b1eab299a50ae46eea7a9360b6af8cf675b4c943",
|
||||
"tray-narrow-tool-ru": "2c2a886b70cda7b02bbd663ee915fd1e68f87587b85c653496784084883bcc7b",
|
||||
"geometry-diagonal-45-opening-dark": "1bc04b80e39234e98b98dbcc8eba554ee77d3c5dd3de0adfa140fa24410711f8",
|
||||
"geometry-devices-editor-dark": "4b91bdc00082e33d2529ea3cf7ce7af36383a84838f4a2c909463df24483b47b",
|
||||
"geometry-decor-editor-dark": "8b03bc87d2353cb6ef8fb4e2dec3f0d3be85de903ac9346171ea4aaa31170a66",
|
||||
"tray-wide-selection-en": "d140cf6a51c634a38d2e3661c4b61835d63673174fd934b85f9da0db4fa20d1a",
|
||||
"tray-wide-tool-ru": "806ff7c5ae3d2e708a4682833af7dc28a88001ef09e4ca564c91a5e6023b297d",
|
||||
"tray-medium-group-en": "5a1adbaa64d2b73f294003e2752a79008f7ec313ab41dd0514b6b38918b8c00e",
|
||||
"tray-medium-selection-ru": "53c7ea5099772fd14c2e27ad414c4d57d87c020866992b7a0b0dd62a22fbeb7e",
|
||||
"tray-narrow-palette-en": "5bc029bb7a4b077a45abc0c18f54360f781caa3684befb63cfa714efcfaf7edd",
|
||||
"tray-narrow-tool-ru": "32e27435ec9f0ce6db2e2800ffd71b6a5fcb1ab7dde17454a3ba7dda680b8a45",
|
||||
"geometry-diagonal-45-opening-dark": "a0167f3028cd8ef8283c92ae7e803940cf0eb0db45f1b137108a4e009741a5df",
|
||||
"openings-thick-wall-dark": "c45bae26ec0b4f719f72e5b92e3b8e03260d7bda4b3182d4c563b50f656e68a1",
|
||||
"openings-filled-tunnel-dark": "b916d3ce48bba7487ed4b548bf659b610243ebcde07206c21fff65867792d0d1",
|
||||
"decor-over-opaque-hover-light": "553d279db44aa0a4a6855496036b0f6ddda9570cef133e09a30f29f41b7d0c31",
|
||||
@@ -90,7 +92,23 @@
|
||||
"lighting-temp-glow-room-override-dark": "bebfc27f460cb75e32ab14076a01e3b9729763d7cecd3f457c1b696ed3e57da3",
|
||||
"lighting-manual-auto-spill-overlap-dark": "471e39397361832a171f0309302be7d8ba4310d628e337478e23da44dc455ef8",
|
||||
"hover-over-glow-dark": "6581433024060f773e00211d60eac7b6daffe442031ff076f861918419c71930",
|
||||
"hover-nested-room-dark": "675170204c1d98b891a3e06d921e4264538c4949e5d1b274efa3ef708afef169",
|
||||
"hover-nested-room-dark": "f05ba332a127b56c00a6a94eac727b168ed34cc3f0355f8b783827f7bf55714c",
|
||||
"junction-t-90-equal15-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
|
||||
"junction-t-90-bar50-leg15-dark": "e981f0597a55e9e9e623dd49417f33a0ebac18d18e0e66675a9af1f2224bd152",
|
||||
"junction-t-90-bar70-leg15-dark": "4428fbe75679fab6e57ef61c5d5097d498960e2539bb9e759e1bae9a160ef174",
|
||||
"junction-x-90-equal15-dark": "8fe0e262e5873714f94b9a6a76ed920881bc6b267e69153039f52a7102ec55d5",
|
||||
"junction-x-90-mixed-dark": "deabce4c2b5fa7866a4f56d25404c6a515d47437298bde114442bc80a6339560",
|
||||
"junction-x-45-alternating-dark": "56a85069baafd6e7af921389b55b916b74cc5d76df908782013b94c2b5b87752",
|
||||
"junction-star5-equal15-dark": "1b9714dffa3f37b9f1e8d848244c4585d38539b74fe582e8d974e2b9e8fdd01a",
|
||||
"junction-y-60-equal50-dark": "f90d46cfe79a95bb3ec73253d0b7f77948164e90c49561d79b1e0db655cc0847",
|
||||
"junction-acute30-mixed-dark": "9ea64ae5864295a1d93a6819efb6e210de2599030fe650913862d99f40b11a69",
|
||||
"junction-acute15-equal50-dark": "3327caf8bc1ccddb317aa7c4c330e09c098122a4f6d2e4df64502cdc033cb2e4",
|
||||
"junction-splay10-170-dark": "ae27454bb4213237313f1fe9e2591ada48facf20baca5517968f92aebbfb330e",
|
||||
"junction-t-virtual-arm-dark": "d325dc112e2f56c31d4511fd586adaa92b6785fc676c2a60ed5aba69c164fd6e",
|
||||
"junction-x-virtual-through-dark": "cce24ac9d7678959844803c6306e65ab48f723ec0375ee6d3f2dbf1c7def7be1",
|
||||
"junction-column-node-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
|
||||
"junction-draft-end-node-dark": "6453bb8463ffdc9a42dbd7acb171c6249fe6d5beec0eae0c789e40f48e65a1f9",
|
||||
"junction-owner-repro-dark": "23ae216c2637b8ee9ea8f852002693f74d64021a66d4494e2086a53db8ca6520",
|
||||
"large-house-zoom-040-dark": "df1a5515089b5e470ae550ce6da31a301be5079d3f7bb9130baad99d3d7c641e",
|
||||
"large-house-zoom-250-dark": "88c0d22686fd9ef314866d0275a5ae785781c3d671e257b2c5f790f816cd65ee",
|
||||
"large-house-warm-remount-dark": "9a61d668c5fbc61bb370c043771cee91707b3d40378cf89f81199882820bf95c",
|
||||
@@ -100,17 +118,17 @@
|
||||
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
|
||||
"device-help-popover-light-ru": "add3275d74ced358a184ec9f864c83735f1196e2bff7d75fb3e12160cb85bfaf",
|
||||
"decor-color-popover-mobile-ru": "51725859afc6a1e2d1b2ae8be270ab584608901623ef7e2d283b6b195644fd16",
|
||||
"decor-color-popover-desktop-en": "0a33f2a4ad6614bccbce45fbda1eaa505551e479b1eaada28ddcaf435a83f74f",
|
||||
"decor-color-popover-desktop-en": "8ad229fd3d433f969dc6d09c7677e9949d4f62503196bffbf17030386f7c7511",
|
||||
"general-color-popover-desktop-en": "91c3b7ebae5146c62d172d1ce10ae9e375ed955f23e9dc860976373a334d8be5",
|
||||
"device-ripple-color-popover-mobile-ru": "3ab12c339106004c76fe3c7741d242269e157c40ddfa29afd8f13b445b0a7023",
|
||||
"space-room-color-popover-desktop-ru": "a7b2c78d8b178eb1947937f9aebe2b02d67a0f6fed3b2a69a7bf0d7e24df50ac",
|
||||
"backup-full-preview-desktop-en": "252f4f687f188645f0b22a49f03c63725aefc6bda5ff2e930a33b8ac729544a8",
|
||||
"backup-plan-only-export-desktop-en": "d76da7b84f46499b8323c920e464e1ff52893dee17db08001badbdb1a09829c1",
|
||||
"backup-space-preview-mobile-ru": "698e81b952a26e50b60d2ec85321c43035ff4376a5a348eeed39726804cead78",
|
||||
"optimize-preflight-dialog-dark-en": "93461a3d95208aa798dd93d5500003127dcad8e7993f7bdc6b7f8f1a95389d73",
|
||||
"optimize-preflight-dialog-light-ru": "de43f205fdf16d290603543a02ced9492d1b5a35e089fa6023c98efdf32ed7c3",
|
||||
"optimize-orphan-references-dark-en": "ce2116ffefd64e6b38921c3afd2a5d9bd9329e8a2aec102eb42a032d23a7d000",
|
||||
"optimize-orphan-references-light-ru": "5a4152df98a34e724b25c0d331fd691fa63eaba753bd4a9c5b173a454c148ecd",
|
||||
"space-room-color-popover-desktop-ru": "d66d068a1b403f88a5bd3c57f4a9e55c336767c533ddfe3ae251cc4f9da40a74",
|
||||
"backup-full-preview-desktop-en": "c9fe8ca1cf63b9ed98e2d517a2c06639d044d460011d958573e70c6b22bd7a2f",
|
||||
"backup-plan-only-export-desktop-en": "c48b3ccdd7d85d36f132c2d7b4c5dba94a1027e3e3f2ef495b4eb89a23f8ca33",
|
||||
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
|
||||
"optimize-preflight-dialog-dark-en": "6e0706b951db73c7d699173d9cd0cd3cd8857238e3d7e95bc38eeb3691f907b6",
|
||||
"optimize-preflight-dialog-light-ru": "a96af4b4428297d2c672f0d0e8f376e81c27765ecc941781a2d3873a054e2ec3",
|
||||
"optimize-orphan-references-dark-en": "cdf4eb8c79a9961a1e961a4eed4107d4d849ebc5b922ad88ce07befee6b65a9a",
|
||||
"optimize-orphan-references-light-ru": "c07151ac896e43bef6150128cbef8a5798ee113083961ed8eaf97113a0b56e1e",
|
||||
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 340 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 339 KiB |
|
Before Width: | Height: | Size: 323 KiB After Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 341 KiB After Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 354 KiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 431 KiB |
|
Before Width: | Height: | Size: 438 KiB After Width: | Height: | Size: 455 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 356 KiB After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 356 KiB After Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@@ -2,6 +2,9 @@ import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
|
||||
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const junctionArtifactsFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const junctionPatchFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/197-junction-patch.json', import.meta.url), 'utf8',
|
||||
));
|
||||
@@ -83,6 +86,13 @@ export function prepareGoldenFixture(scenario) {
|
||||
space.settings = {
|
||||
fill_mode: 'none', show_borders: true, show_names: true,
|
||||
};
|
||||
if (scenario.hiddenWallDiagnostics) {
|
||||
space.room_drafts = [{
|
||||
id: 'hidden-saved-chain',
|
||||
points: [[0, 0], [0, 1]],
|
||||
segments: [{ cm: 15 }],
|
||||
}];
|
||||
}
|
||||
if (state !== 'before') {
|
||||
delete space.partitions;
|
||||
space.walls[0].cm = state === 'thin' ? 10 : state === 'thick' ? 30 : 20;
|
||||
@@ -217,6 +227,75 @@ export function prepareGoldenFixture(scenario) {
|
||||
partitions: [], room_drafts: [], wall_columns: [], decor: [],
|
||||
});
|
||||
}
|
||||
if (scenario.junctionNode) {
|
||||
// #302: a close-up star node. Pie-slice rooms around the centre; every
|
||||
// arm is a shared edge carrying its own thickness; a virtual arm is the
|
||||
// same edge released by an open span.
|
||||
const spec = scenario.junctionNode;
|
||||
const C = [0.5, 0.5];
|
||||
const R = 0.3;
|
||||
const arms = spec.arms
|
||||
.map((arm) => ({ ...arm, rad: (arm.deg * Math.PI) / 180 }))
|
||||
.sort((a, b) => a.deg - b.deg);
|
||||
const endOf = (arm) => [
|
||||
C[0] + Math.cos(arm.rad) * R, C[1] + Math.sin(arm.rad) * R,
|
||||
];
|
||||
const rooms = [];
|
||||
for (let index = 0; index < arms.length; index++) {
|
||||
const a = arms[index];
|
||||
const b = arms[(index + 1) % arms.length];
|
||||
const sweep = ((b.deg - a.deg + 360) % 360) || 360;
|
||||
const arc = [];
|
||||
const steps = Math.max(1, Math.ceil(sweep / 60));
|
||||
for (let step = 0; step <= steps; step++) {
|
||||
const rad = ((a.deg + (sweep * step) / steps) * Math.PI) / 180;
|
||||
arc.push([C[0] + Math.cos(rad) * R, C[1] + Math.sin(rad) * R]);
|
||||
}
|
||||
rooms.push({
|
||||
id: `junction-slice-${index}`, name: `S${index}`, area: null,
|
||||
poly: [C.map((v) => v), ...arc],
|
||||
});
|
||||
}
|
||||
const walls = arms.map((arm) => {
|
||||
const end = endOf(arm);
|
||||
return { key: fixtureWallKey(C, end), a: [...C], b: [...end], cm: arm.cm };
|
||||
});
|
||||
const space = {
|
||||
id: scenario.space, title: 'Junction node', view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
rooms, walls,
|
||||
settings: { fill_mode: 'none', show_borders: true, show_names: false },
|
||||
};
|
||||
const virtual = arms.filter((arm) => arm.virtual);
|
||||
if (virtual.length) {
|
||||
space.open_spans = virtual.map((arm) => {
|
||||
const end = endOf(arm);
|
||||
return { a: [...C], b: [...end] };
|
||||
});
|
||||
}
|
||||
if (spec.column) {
|
||||
space.wall_columns = [{
|
||||
id: 'junction-column', center: [...C], shape: 'circle', size_cm: 40,
|
||||
}];
|
||||
}
|
||||
if (spec.draft) {
|
||||
space.room_drafts = [{
|
||||
id: 'junction-draft',
|
||||
points: [[0.5, 0.5], [0.75, 0.62]],
|
||||
segments: [{ cm: 15 }],
|
||||
}];
|
||||
}
|
||||
fixture.config.spaces.push(space);
|
||||
}
|
||||
if (scenario.junctionArtifacts) {
|
||||
fixture.config.spaces.push({
|
||||
...structuredClone(junctionArtifactsFixture),
|
||||
id: scenario.space,
|
||||
title: 'Junction artifacts repro',
|
||||
view_box: [0, 0, 1, 1],
|
||||
settings: { fill_mode: 'none', show_borders: true, show_names: false },
|
||||
});
|
||||
}
|
||||
if (scenario.junctionPatchResilience) {
|
||||
if (!Array.isArray(scenario.retainedWedgeProbe)
|
||||
|| scenario.retainedWedgeProbe.length !== 2
|
||||
@@ -255,7 +334,7 @@ export function prepareGoldenFixture(scenario) {
|
||||
const contract = scenario.multiWallJunction;
|
||||
const validPoint = (point) => Array.isArray(point) && point.length === 2
|
||||
&& point.every(Number.isFinite);
|
||||
if (!validPoint(contract.node) || !validPoint(contract.discardedWedgeProbe)
|
||||
if (!validPoint(contract.node) || !validPoint(contract.retainedOverlapProbe)
|
||||
|| !Number.isInteger(contract.rays) || contract.rays < 3
|
||||
|| !Number.isInteger(contract.enclosedHoles) || contract.enclosedHoles < 0) {
|
||||
throw new Error(`invalid golden multiWallJunction: ${scenario.id}`);
|
||||
@@ -668,6 +747,43 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await card.updateComplete;
|
||||
await settleMode(card);
|
||||
}
|
||||
// #304: the two existing hidden-wall golden scenes also guard the static
|
||||
// architectural overlay in every Plan tool. Finish in draw so the reviewed
|
||||
// pixels stay stable; a missing/changed tool projection fails before capture.
|
||||
if (scenario.hiddenWallDiagnostics) {
|
||||
const tools = [
|
||||
'select', 'draw', 'column', 'merge', 'split', 'resize',
|
||||
'opening', 'boundary', 'wallthick', 'delroom',
|
||||
];
|
||||
const snapshot = () => {
|
||||
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
|
||||
if (!overlay || overlay.getAttribute('pointer-events') !== 'none') return null;
|
||||
return JSON.stringify({
|
||||
lines: [...overlay.querySelectorAll('.plan-snap-line')].map((line) => [
|
||||
line.getAttribute('data-key'), line.getAttribute('x1'), line.getAttribute('y1'),
|
||||
line.getAttribute('x2'), line.getAttribute('y2'),
|
||||
]),
|
||||
endpoints: [...overlay.querySelectorAll('.plan-snap-node[data-kind="endpoint"]')]
|
||||
.map((node) => [node.getAttribute('data-key'), node.getAttribute('cx'), node.getAttribute('cy')]),
|
||||
});
|
||||
};
|
||||
let expected = null;
|
||||
for (const tool of tools) {
|
||||
card._tool = tool;
|
||||
card._clearPlanSnapHover();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
const current = snapshot();
|
||||
if (expected == null) expected = current;
|
||||
if (!current || current !== expected) {
|
||||
throw new Error(`golden plan-axis parity failed for ${tool}: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
card._tool = 'draw';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
if (scenario.wallUnionIsolation) {
|
||||
const result = card._wallUnionGeometry?.();
|
||||
const paths = [...card.renderRoot.querySelectorAll('[data-hp="wall"]')];
|
||||
@@ -684,11 +800,11 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
}
|
||||
}
|
||||
if (scenario.multiWallJunction) {
|
||||
const { node, discardedWedgeProbe, enclosedHoles } = scenario.multiWallJunction;
|
||||
const { node, retainedOverlapProbe, enclosedHoles } = scenario.multiWallJunction;
|
||||
const wall = card.renderRoot.querySelector('[data-hp="wall"]');
|
||||
const at = (point) => new DOMPoint(point[0] * 1000, point[1] * card._spaceH);
|
||||
if (!wall?.isPointInFill?.(at(node))
|
||||
|| wall.isPointInFill(at(discardedWedgeProbe))) {
|
||||
|| !wall.isPointInFill(at(retainedOverlapProbe))) {
|
||||
throw new Error(`golden multi-wall bevel contract failed: ${scenario.id}`);
|
||||
}
|
||||
// A pixel threshold missed the reported triangles because they occupy a
|
||||
@@ -841,7 +957,8 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await until(() => card._renderProjection === 'iso');
|
||||
}
|
||||
if (Number.isFinite(scenario.zoom)) {
|
||||
card._applyView(scenario.zoom, 500, 500);
|
||||
const [zx, zy] = Array.isArray(scenario.zoomCenter) ? scenario.zoomCenter : [500, 500];
|
||||
card._applyView(scenario.zoom, zx, zy);
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
}
|
||||
@@ -930,6 +1047,16 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
if (!card._rszPreview || !card._rszLive?.length || !(delta > 0 && delta < 250)) {
|
||||
throw new Error(`golden safe Resize opening clamp did not render: ${scenario.id}`);
|
||||
}
|
||||
const resizeLengths = card._rszLive.filter((label) => label.kind === 'length');
|
||||
const resizeAreas = card._rszLive.filter((label) => label.kind === 'area');
|
||||
const measuredEdges = card.renderRoot.querySelectorAll('[data-hp="resize-measured-edge"]');
|
||||
const areaLabels = card.renderRoot.querySelectorAll('[data-hp="resize-area-label"]');
|
||||
const leaders = card.renderRoot.querySelectorAll('[data-hp="resize-area-leader"]');
|
||||
if (resizeLengths.length !== 2 || measuredEdges.length !== 2
|
||||
|| resizeAreas.length !== card._rszDrag.plan.roomIds.length
|
||||
|| areaLabels.length !== resizeAreas.length || leaders.length !== resizeAreas.length) {
|
||||
throw new Error(`golden Resize measurement contract is incomplete: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (scenario.openingPreview) {
|
||||
const { type, pointer } = scenario.openingPreview;
|
||||
@@ -1176,6 +1303,22 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
created_at: '2026-08-11T10:00:00Z', space_title: 'Ground (2)',
|
||||
counts: { spaces: 1, rooms: 4, markers: full ? 12 : 0, layout: full ? 15 : 4 },
|
||||
duplicates: 0,
|
||||
repaired_target_refs: full ? 0 : 3,
|
||||
preserved_unresolved_refs: full ? 0 : 1,
|
||||
reference_report: full ? {} : {
|
||||
remapped: {
|
||||
incoming: { 'layout.space': 4, 'room.open_to': 1 },
|
||||
target: { 'marker.space': 3 },
|
||||
},
|
||||
collisions: {},
|
||||
preservedUnresolved: { 'marker.room_id': 1 },
|
||||
droppedIncomingLinks: {},
|
||||
boundedLineages: 0,
|
||||
examples: [{
|
||||
bucket: 'preservedUnresolved', category: 'marker.room_id',
|
||||
owner: 'legacy-thermostat', reference: 'room_old-generation_deadbeef',
|
||||
}],
|
||||
},
|
||||
confirmation_required: full,
|
||||
content: full
|
||||
? [{ url: '/api/houseplan/content/plans/_/ground.svg', state: 'detach_required' }]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 44;
|
||||
export const GOLDEN_MATRIX_VERSION = 45;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -165,6 +165,14 @@ const decorLayerProbes = {
|
||||
};
|
||||
|
||||
export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'hidden-wall-diagnostics-plan-light', fixture: 'visual',
|
||||
space: 'golden-coincident-partition', coincidentPartition: 'before',
|
||||
hiddenWallDiagnostics: true, mode: 'plan', theme: 'light',
|
||||
viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'hidden-wall-diagnostics-plan-dark', fixture: 'visual',
|
||||
space: 'golden-coincident-partition', coincidentPartition: 'before',
|
||||
hiddenWallDiagnostics: true, mode: 'plan', theme: 'dark',
|
||||
viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'coincident-partition-before-dark', fixture: 'visual',
|
||||
space: 'golden-coincident-partition', coincidentPartition: 'before',
|
||||
mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
@@ -298,7 +306,10 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
space: 'golden-multiwall-junction', mode: 'view',
|
||||
multiWallJunction: {
|
||||
node: [0.329166667, 0.141666667], rays: 3,
|
||||
discardedWedgeProbe: [0.3303808442725, 0.1488560107825],
|
||||
// #302: the chamfer is strip-safe — this point sits in the overlap of
|
||||
// two incident strips and must stay FILLED (it used to be discarded by
|
||||
// the old subtractive bevel, which is exactly the hole class fixed).
|
||||
retainedOverlapProbe: [0.3303808442725, 0.1488560107825],
|
||||
// Product contract (#272): a bounded exterior bevel is allowed, but no
|
||||
// empty component may remain trapped inside the continuous masonry.
|
||||
enclosedHoles: 0,
|
||||
@@ -563,6 +574,53 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
hoverRoom: 'light-right', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'hover-nested-room-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
hoverRoom: 'geo-nested', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
// --- #302: junction close-ups. One node per scene, the node fills the
|
||||
// frame; the same scenes carry the objective no-holes detector in tests.
|
||||
...[
|
||||
['junction-t-90-equal15-dark', { arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 }] }],
|
||||
['junction-t-90-bar50-leg15-dark', { arms: [
|
||||
{ deg: 0, cm: 50 }, { deg: 90, cm: 15 }, { deg: 180, cm: 50 }] }],
|
||||
['junction-t-90-bar70-leg15-dark', { arms: [
|
||||
{ deg: 0, cm: 70 }, { deg: 90, cm: 15 }, { deg: 180, cm: 70 }] }],
|
||||
['junction-x-90-equal15-dark', { arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 },
|
||||
{ deg: 270, cm: 15 }] }],
|
||||
['junction-x-90-mixed-dark', { arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 90, cm: 50 }, { deg: 180, cm: 15 },
|
||||
{ deg: 270, cm: 70 }] }],
|
||||
['junction-x-45-alternating-dark', { arms: [
|
||||
{ deg: 45, cm: 15 }, { deg: 135, cm: 50 }, { deg: 225, cm: 15 },
|
||||
{ deg: 315, cm: 50 }] }],
|
||||
['junction-star5-equal15-dark', { arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 72, cm: 15 }, { deg: 144, cm: 15 },
|
||||
{ deg: 216, cm: 15 }, { deg: 288, cm: 15 }] }],
|
||||
['junction-y-60-equal50-dark', { arms: [
|
||||
{ deg: 0, cm: 50 }, { deg: 60, cm: 50 }, { deg: 120, cm: 50 }] }],
|
||||
['junction-acute30-mixed-dark', { arms: [
|
||||
{ deg: 0, cm: 50 }, { deg: 30, cm: 70 }, { deg: 180, cm: 15 }] }],
|
||||
['junction-acute15-equal50-dark', { arms: [
|
||||
{ deg: 0, cm: 50 }, { deg: 15, cm: 50 }, { deg: 180, cm: 50 }] }],
|
||||
['junction-splay10-170-dark', { arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 10, cm: 15 }, { deg: 180, cm: 50 }] }],
|
||||
['junction-t-virtual-arm-dark', { arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 90, cm: 15, virtual: true },
|
||||
{ deg: 180, cm: 15 }] }],
|
||||
['junction-x-virtual-through-dark', { arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 90, cm: 15, virtual: true },
|
||||
{ deg: 180, cm: 15 }, { deg: 270, cm: 15, virtual: true }] }],
|
||||
['junction-column-node-dark', { column: true, arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 }] }],
|
||||
['junction-draft-end-node-dark', { draft: true, arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 }] }],
|
||||
].map(([id, junctionNode]) => ({
|
||||
id, fixture: 'visual', space: `golden-${id}`, mode: 'view', junctionNode,
|
||||
zoom: 4, theme: 'dark', viewport: { width: 900, height: 900 }, ...stage,
|
||||
})),
|
||||
{ id: 'junction-owner-repro-dark', fixture: 'visual',
|
||||
space: 'golden-junction-owner-repro', mode: 'view', junctionArtifacts: true,
|
||||
zoom: 6, zoomCenter: [329.17, 141.67], theme: 'dark',
|
||||
viewport: { width: 900, height: 900 }, ...stage },
|
||||
{ id: 'large-house-zoom-040-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
|
||||
zoom: 0.4, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'large-house-zoom-250-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
|
||||
|
||||
@@ -82,6 +82,18 @@ const result = await page.evaluate(async () => {
|
||||
counts: { spaces: 1, rooms: 2, markers: 0, layout: 2 },
|
||||
bindings: { device: 0, entity: 0, virtual: 0, active: 0, disabled: 0, missing: 0 },
|
||||
duplicates: 0, confirmation_required: false, content: [],
|
||||
repaired_target_refs: 2, preserved_unresolved_refs: 1,
|
||||
reference_report: {
|
||||
remapped: {
|
||||
incoming: { 'layout.space': 2 }, target: { 'marker.space': 2 },
|
||||
},
|
||||
collisions: {}, preservedUnresolved: { 'marker.room_id': 1 },
|
||||
droppedIncomingLinks: {}, boundedLineages: 0,
|
||||
examples: [{
|
||||
bucket: 'preservedUnresolved', category: 'marker.room_id',
|
||||
owner: 'legacy-marker', reference: 'room_old_deadbeef',
|
||||
}],
|
||||
},
|
||||
},
|
||||
expectedConfigRev: 1, expectedLayoutRev: 2, duplicatePolicy: 'skip',
|
||||
confirmMissing: false, busy: false, error: '',
|
||||
@@ -93,6 +105,11 @@ const result = await page.evaluate(async () => {
|
||||
noDuplicatePolicy: !root().querySelector('.backupchoices'),
|
||||
noHorizontalOverflow: root().querySelector('hp-dialog').scrollWidth
|
||||
<= root().querySelector('hp-dialog').clientWidth,
|
||||
hasReferenceReport: !!root().querySelector('.backupdetails')
|
||||
&& root().querySelector('.backupdetails')?.textContent
|
||||
.includes(card._t('backup.import_details')),
|
||||
warnsWithoutDiscarding: root().querySelector('.backupwarn')?.textContent
|
||||
.includes(card._t('backup.preserved_unresolved_hint')) === true,
|
||||
};
|
||||
return {
|
||||
group, actions, keyboardImport, exportRadios, exportWarning,
|
||||
@@ -112,6 +129,9 @@ checkAll(result, {
|
||||
planOnlyResetForFull: true,
|
||||
planOnlyRequest: { kind: 'space', planOnly: true, space: 'f1' },
|
||||
importSafe: { danger: true, disabledUntilConfirmed: true, noHorizontalOverflow: true },
|
||||
planOnlyPreview: { visible: true, noDuplicatePolicy: true, noHorizontalOverflow: true },
|
||||
planOnlyPreview: {
|
||||
visible: true, noDuplicatePolicy: true, noHorizontalOverflow: true,
|
||||
hasReferenceReport: true, warnsWithoutDiscarding: true,
|
||||
},
|
||||
});
|
||||
await finish(browser, result);
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
// Обход последовательностей правок: инварианты после каждого жеста (#297).
|
||||
//
|
||||
// Зачем это отдельно от всех прежних гейтов. Решётка и шум (#283), непрерывность
|
||||
// кладки (#285/#286), смешанные роли (#287), ключи стен (#259), аудит ручек
|
||||
// (#292) — каждый берёт ГОТОВЫЙ план и что-то в нём измеряет. А дефекты
|
||||
// геометрии рождаются не в хранении, а в РЕДАКТИРОВАНИИ: #289 (ресайз наружного
|
||||
// ребра создал запись со смешанной ролью), #290, #296 (после удаления комнаты
|
||||
// остались перегородка поверх стены и черновик из двух точек, и обе ручки
|
||||
// ресайза выключились). Ни один снимок этого не показывает: такая геометрия
|
||||
// ничего не портит в модели, она портит следующий жест.
|
||||
//
|
||||
// Поэтому здесь план не проверяется, а РАСШАТЫВАЕТСЯ: продуктовыми жестами, в
|
||||
// продуктовом карте, по детерминированному семени, с проверкой инвариантов
|
||||
// после каждого шага. Второе представление редактора при этом не появляется —
|
||||
// жесты идут через `_rszEdgeDown/_rszMove/_rszUp` и `_confirmRoomDelete`, то
|
||||
// есть через тот самый код, который выполняется у пользователя.
|
||||
//
|
||||
// Найденное нарушение печатается вместе с семенем и полной цепочкой жестов,
|
||||
// поэтому воспроизводится одной командой:
|
||||
// node demo/smoke_edit_walk.mjs --seed 7 --plan real-plan-second-floor.json
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
// `model-invariants.mjs` и оптимизатор — скомпилированный продуктовый код, а не
|
||||
// вторая модель. Значит смок обязан собрать тот же `test-build`, который делает
|
||||
// `npm test`: на чистом Linux CI этого каталога нет, и опора на оставшийся от
|
||||
// разработчика падает до запуска браузера. Ровно эту ошибку уже проходил
|
||||
// `smoke_lattice_write_barrier.mjs` — здесь она повторена по той же причине.
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
execFileSync(process.execPath, [
|
||||
resolve(root, 'node_modules/typescript/bin/tsc'), '-p', 'tsconfig.test.json',
|
||||
], { cwd: root, stdio: 'inherit' });
|
||||
execFileSync(process.execPath, [resolve(root, 'scripts/fix-test-build.mjs')], {
|
||||
cwd: root, stdio: 'inherit',
|
||||
});
|
||||
const { optimizePlans } = await import('../test-build/plan-optimizer.js');
|
||||
const {
|
||||
checkHiddenObstacles, checkMixedRoleRecords, checkWallKeys, checkReferences,
|
||||
checkPhysicalGeometry, latticeProfile, readModel,
|
||||
} = await import('../scripts/model-invariants.mjs');
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Известный долг фикстур, а не «должно быть ноль».
|
||||
*
|
||||
* `real-plan-second-floor.json` содержит перегородку `partition-mt2on9ou-0`,
|
||||
* лежащую на наружной стене трёх комнат. #296 научил явный Optimize безопасно
|
||||
* удалять этот объект, но исходная raw-фикстура намеренно не переписана: обход
|
||||
* обязан отделять её исходный долг от НОВЫХ нарушений каждого жеста.
|
||||
*/
|
||||
const PLANS = [
|
||||
{ file: 'real-plan-second-floor.json', debt: 1 },
|
||||
{ file: 'real-plan-first-floor.json', debt: 0 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Что обход находит СЕГОДНЯ. Не «допустимо», а «заведено и ждёт починки».
|
||||
*
|
||||
* Таблица работает в обе стороны: обход падает и когда находок стало больше, и
|
||||
* когда стало меньше. Второе — не придирчивость. Молча позеленевший гейт не
|
||||
* сообщает о починке, и долг перестаёт быть виден; ровно так исчез из вида
|
||||
* `partition-mt2on9ou-0`, проживший в плане владельца от беты 9 до rc.1.
|
||||
* Починили — обновить строку в этой таблице тем же коммитом.
|
||||
*/
|
||||
// #298 убрал off-grid/carrier producer Resize, а #299 — mixed-role producer
|
||||
// Optimize/Delete-room. Поэтому все шесть 24-шаговых обходов теперь обязаны
|
||||
// завершаться без нового нарушения; исходный fixture debt учтён отдельно выше.
|
||||
const KNOWN = {};
|
||||
const STEPS = Number(arg('--steps', 24));
|
||||
const SEEDS = arg('--seed') ? [Number(arg('--seed'))] : [1, 2, 3];
|
||||
|
||||
/** mulberry32: короткий, воспроизводимый, без зависимостей. */
|
||||
const rng = (seed) => () => {
|
||||
seed = (seed + 0x6D2B79F5) | 0;
|
||||
let t = seed;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
|
||||
const configOf = (file) => readModel(readFileSync(
|
||||
new URL(`../test/fixtures/${file}`, import.meta.url), 'utf8',
|
||||
));
|
||||
|
||||
/**
|
||||
* Полный набор инвариантов по одному конфигу. Судит node, не страница.
|
||||
*
|
||||
* Про решётку судится ТОЛЬКО «вне сетки», а подшаговый шум остаётся
|
||||
* наблюдением. Причина измерена, а не выбрана: координата пишется девятью
|
||||
* знаками после запятой, и `304/240 = 1.2666666666…` в этой записи становится
|
||||
* `1.266666667` — отклонение 8e-8 шага. Такой шум неустраним никаким исправлением
|
||||
* жеста, он свойство формата хранения и уйдёт на этапе 1 ADR #282. Гейт,
|
||||
* падающий на неустранимом, отключат в первую неделю.
|
||||
*/
|
||||
const inspect = (config, layout = {}) => {
|
||||
const notes = [];
|
||||
const lattice = latticeProfile({ config, layout });
|
||||
return {
|
||||
violations: [
|
||||
...checkReferences({ config, layout }, { notes }),
|
||||
...checkHiddenObstacles(config),
|
||||
...checkMixedRoleRecords(config),
|
||||
...checkWallKeys(config, { notes }),
|
||||
...checkPhysicalGeometry(config),
|
||||
],
|
||||
noise: lattice.noise,
|
||||
offGrid: lattice.offGrid,
|
||||
};
|
||||
};
|
||||
|
||||
/** Виды нарушений, которых стало больше, чем было в исходной фикстуре. */
|
||||
const countKinds = (violations) => {
|
||||
const counts = new Map();
|
||||
for (const violation of violations) {
|
||||
counts.set(violation.kind, (counts.get(violation.kind) || 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
|
||||
const beyondBaseline = (violations, baseline) => {
|
||||
const now = countKinds(violations);
|
||||
return [...now]
|
||||
.filter(([kind, count]) => count > (baseline.get(kind) || 0))
|
||||
.map(([kind]) => kind)
|
||||
.sort();
|
||||
};
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
const failures = [];
|
||||
|
||||
/** Установить конфиг в карту и включить разметку с инструментом ресайза. */
|
||||
const install = (config) => page.evaluate(async (config) => {
|
||||
const card = window.__card;
|
||||
card._serverCfg = JSON.parse(JSON.stringify(config));
|
||||
card._cfgEpoch = (card._cfgEpoch || 0) + 1;
|
||||
card._modelCache = null;
|
||||
card._space = config.spaces[0].id;
|
||||
card._setMode?.('plan');
|
||||
card._markup = true;
|
||||
card._tool = 'resize';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
card._fitAll?.();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
return true;
|
||||
}, config);
|
||||
|
||||
/** Перечислить ручки ресайза так, как их видит рендер слоя. */
|
||||
const handles = () => page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const snap = card._rszSnapshot();
|
||||
const rooms = card._rszRooms();
|
||||
const list = [];
|
||||
for (const room of rooms) {
|
||||
for (let edge = 0; edge < room.poly.length; edge++) {
|
||||
const a = room.poly[edge], b = room.poly[(edge + 1) % room.poly.length];
|
||||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < card._gridPitch) continue;
|
||||
const resolution = card._rszResolution(room.id, edge, snap);
|
||||
list.push({
|
||||
roomId: room.id, edge, enabled: !!resolution.enabled,
|
||||
reason: resolution.enabled ? null : resolution.reason,
|
||||
mid: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
|
||||
});
|
||||
}
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
/**
|
||||
* Перетащить ручку на `steps` шагов решётки.
|
||||
*
|
||||
* Смещение задаётся в клиентских координатах, поэтому масштаб «клиент → SVG»
|
||||
* измеряется у самой карты: подставлять свои цифры значило бы завести второе
|
||||
* представление её геометрии.
|
||||
*/
|
||||
const dragHandle = (roomId, edge, mid, steps) => page.evaluate(
|
||||
async ({ roomId, edge, mid, steps }) => {
|
||||
const card = window.__card;
|
||||
const at = (x, y) => card._svgPoint({ clientX: x, clientY: y });
|
||||
const origin = at(0, 0), unitX = at(100, 0), unitY = at(0, 100);
|
||||
const perClientX = (unitX[0] - origin[0]) / 100;
|
||||
const perClientY = (unitY[1] - origin[1]) / 100;
|
||||
if (!(Math.abs(perClientX) > 1e-9) || !(Math.abs(perClientY) > 1e-9)) return 'нет масштаба';
|
||||
// Клиентская точка, попадающая в середину ребра.
|
||||
const clientX = (mid[0] - origin[0]) / perClientX;
|
||||
const clientY = (mid[1] - origin[1]) / perClientY;
|
||||
const ev = (x, y) => ({
|
||||
pointerId: 77, clientX: x, clientY: y,
|
||||
target: card._stageEl, preventDefault() {}, stopPropagation() {},
|
||||
});
|
||||
card._rszEdgeDown(ev(clientX, clientY), roomId, edge);
|
||||
if (!card._rszDrag) return 'жест не начался';
|
||||
const plan = card._rszDrag.plan;
|
||||
const shift = card._gridPitch * steps;
|
||||
const dx = plan.n[0] * shift / perClientX;
|
||||
const dy = plan.n[1] * shift / perClientY;
|
||||
card._rszMove(ev(clientX + dx, clientY + dy));
|
||||
const moved = !!card._rszDrag?.moved;
|
||||
card._rszUp(ev(clientX + dx, clientY + dy));
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
return moved ? null : 'предпросмотр не сдвинулся';
|
||||
}, { roomId, edge, mid, steps },
|
||||
);
|
||||
|
||||
/** Удалить комнату с сохранением стен — путь, который и родил #296. */
|
||||
const deleteRoom = (roomId) => page.evaluate(async (roomId) => {
|
||||
const card = window.__card;
|
||||
card._roomDeleteDialog = { roomId, name: roomId };
|
||||
card._confirmRoomDelete(true);
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
return null;
|
||||
}, roomId);
|
||||
|
||||
const currentConfig = () => page.evaluate(() => JSON.parse(JSON.stringify(window.__card._serverCfg)));
|
||||
|
||||
for (const plan of PLANS) {
|
||||
const source = configOf(plan.file);
|
||||
for (const seed of SEEDS) {
|
||||
const random = rng(seed);
|
||||
const trace = [];
|
||||
let config = JSON.parse(JSON.stringify(source.config));
|
||||
let broke = null;
|
||||
const baseline = inspect(config);
|
||||
baseline.counts = countKinds(baseline.violations);
|
||||
if (baseline.violations.length !== plan.debt) {
|
||||
failures.push({
|
||||
plan: plan.file, seed, step: -1,
|
||||
action: `объявленный долг фикстуры ${plan.debt}`
|
||||
+ `, фактический ${baseline.violations.length}`,
|
||||
violations: baseline.violations, trace: [],
|
||||
});
|
||||
out[`debt_${plan.file.replace(/[^a-z0-9]+/gi, '_')}`] = false;
|
||||
continue;
|
||||
}
|
||||
await install(config);
|
||||
for (let step = 0; step < STEPS && !broke; step++) {
|
||||
const list = await handles();
|
||||
const enabled = list.filter((handle) => handle.enabled);
|
||||
const roll = random();
|
||||
let action = null;
|
||||
if (roll < 0.75 && enabled.length) {
|
||||
const handle = enabled[Math.floor(random() * enabled.length)];
|
||||
const steps = (random() < 0.5 ? -1 : 1) * (1 + Math.floor(random() * 4));
|
||||
const note = await dragHandle(handle.roomId, handle.edge, handle.mid, steps);
|
||||
action = `ресайз ${handle.roomId}#${handle.edge} на ${steps}${note ? ` (${note})` : ''}`;
|
||||
} else if (roll < 0.9) {
|
||||
const rooms = [...new Set(list.map((handle) => handle.roomId))];
|
||||
if (!rooms.length) continue;
|
||||
const roomId = rooms[Math.floor(random() * rooms.length)];
|
||||
await deleteRoom(roomId);
|
||||
action = `удаление комнаты ${roomId} с сохранением стен`;
|
||||
} else {
|
||||
const before = await currentConfig();
|
||||
const result = optimizePlans(before, {}, {}, {});
|
||||
await install(result.config);
|
||||
action = `оптимизация (изменений: ${result.report.total})`;
|
||||
}
|
||||
trace.push(action);
|
||||
config = await currentConfig();
|
||||
const state = inspect(config);
|
||||
const found = [...state.violations];
|
||||
if (state.offGrid > baseline.offGrid) {
|
||||
found.push({
|
||||
kind: 'off_lattice_coordinate', owner: 'config',
|
||||
reference: `вне сетки ${baseline.offGrid} → ${state.offGrid}`
|
||||
+ ` (подшаговый шум ${baseline.noise} → ${state.noise})`,
|
||||
detail: 'жест записал координату мимо решётки',
|
||||
});
|
||||
}
|
||||
const fresh = beyondBaseline(found, baseline.counts);
|
||||
if (fresh.length) broke = { step, action, violations: found, kinds: fresh };
|
||||
}
|
||||
const key = `${plan.file}:${seed}`;
|
||||
const known = KNOWN[key] || null;
|
||||
const label = `${plan.file.replace('real-plan-', '').replace('.json', '')}_seed${seed}`;
|
||||
const asExpected = known
|
||||
? !!broke && broke.step === known.step
|
||||
&& JSON.stringify(broke.kinds) === JSON.stringify([...known.kinds].sort())
|
||||
: !broke;
|
||||
out[`walk_${label.replace(/[^a-z0-9]+/gi, '_')}`] = asExpected;
|
||||
if (!asExpected) {
|
||||
failures.push({
|
||||
plan: plan.file, seed, trace,
|
||||
step: broke ? broke.step : -1,
|
||||
action: broke
|
||||
? broke.action
|
||||
: `находок нет, а объявлено: шаг ${known.step}, ${known.kinds.join(', ')}`
|
||||
+ ' — если это починка, обновите KNOWN тем же коммитом',
|
||||
violations: broke ? broke.violations : [],
|
||||
});
|
||||
mkdirSync(new URL('../artifacts/', import.meta.url), { recursive: true });
|
||||
writeFileSync(
|
||||
new URL(`../artifacts/edit-walk-${label}.json`, import.meta.url),
|
||||
JSON.stringify(config, null, 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error('\nОбход нашёл нарушения:');
|
||||
for (const failure of failures) {
|
||||
console.error(`\n ${failure.plan}, семя ${failure.seed}, шаг ${failure.step}: ${failure.action}`);
|
||||
for (const violation of failure.violations.slice(0, 8)) {
|
||||
console.error(` ${violation.kind} · ${violation.owner} · ${violation.reference}`);
|
||||
}
|
||||
console.error(' цепочка жестов:');
|
||||
for (const [index, action] of failure.trace.entries()) {
|
||||
console.error(` ${index}. ${action}`);
|
||||
}
|
||||
console.error(` конфиг сохранён: artifacts/edit-walk-${failure.plan}-seed${failure.seed}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -76,9 +76,11 @@ const res = await page.evaluate(async () => {
|
||||
c._openSettingsDialog(); c._infoCard = c._devices[0]; await c.updateComplete;
|
||||
await esc(); out.stacked = !c._infoCard && !!c._settingsDialog;
|
||||
await esc(); out.stacked2 = !c._settingsDialog;
|
||||
// Esc в разметке по-прежнему откатывает точку, а не только диалоги
|
||||
// #294: once no dialog remains, Escape is still routed to the Plan tool.
|
||||
// It finishes and detaches the accepted chain while keeping Walls armed;
|
||||
// Ctrl/Cmd+Z, not Escape, owns one-point undo.
|
||||
c._setMode('plan'); c._tool = 'draw'; c._path = [[1, 2], [3, 4]]; await c.updateComplete;
|
||||
await esc(); out.undoPointStillWorks = c._path.length === 1;
|
||||
await esc(); out.wallChainDetaches = c._path.length === 0 && c._tool === 'draw';
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
@@ -391,6 +391,13 @@ const isoDarkDiff = await pixelDiff(isoDark.reference.pixels, isoDark.detailed.p
|
||||
const pixelEquivalent = (diff) => diff.sameSize
|
||||
&& diff.changed <= 150 && diff.maxDelta <= 40 && diff.meanDelta <= 0.05;
|
||||
|
||||
// Diagnostic only: the raw diff numbers behind every pixel verdict, so a CI
|
||||
// failure reports HOW FAR a pair drifted instead of a bare boolean (#302).
|
||||
console.error('pixel-diffs', JSON.stringify({
|
||||
flat: flatDiff, static: staticDiff, plan: planDiff, darkView: darkViewDiff,
|
||||
devices: devicesDiff, background: backgroundDiff,
|
||||
isoLight: isoLightDiff, isoDark: isoDarkDiff,
|
||||
}));
|
||||
const out = {
|
||||
referenceFactorIsOne: referenceView.metrics.factor === 1,
|
||||
detailedFactorIsFive: detailedView.metrics.factor === 5,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Issue #302: the rendered wall body of the owner's repro covers the junction
|
||||
// contract — every support strip and sector fan, clipped to the facade bound.
|
||||
//
|
||||
// The units own the geometry rules; this smoke owns the wiring: the same
|
||||
// probes that the pure pipeline must cover are checked against the actual
|
||||
// `d` attribute the card renders, so a regression anywhere between the
|
||||
// geometry pass and the DOM turns it red.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
import {
|
||||
wallBodiesGeometry, wallIntervals, buildMultiWallNodeMap,
|
||||
junctionNodeGeometry, junctionNodeBound, MITRE_LIMIT,
|
||||
} from '../test-build/wall-thickness.js';
|
||||
import { GRID_PITCH, GRID_STEP_N, NORM_W } from '../test-build/space-geometry.js';
|
||||
|
||||
const fixture = JSON.parse(readFileSync(
|
||||
new URL('../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const rooms = fixture.rooms.map((room) => ({
|
||||
...room, poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
|
||||
}));
|
||||
const map = buildMultiWallNodeMap(
|
||||
wallIntervals(rooms, fixture.walls, [], GRID_STEP_N, fixture.cell_cm, GRID_PITCH, NORM_W)
|
||||
.filter((iv) => !iv.open && iv.half > 0),
|
||||
GRID_STEP_N * NORM_W * 0.04 * 4, NORM_W,
|
||||
);
|
||||
const bound = junctionNodeBound(
|
||||
rooms, fixture.walls, [], GRID_STEP_N, fixture.cell_cm, GRID_PITCH, NORM_W, map,
|
||||
);
|
||||
const corners = junctionNodeGeometry(map);
|
||||
const inPolygon = (points, x, y) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const [xi, yi] = points[i], [xj, yj] = points[j];
|
||||
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
const inGeometry = (geom, x, y) => {
|
||||
let inside = false;
|
||||
for (const poly of geom || []) for (const ring of poly || []) {
|
||||
if (inPolygon(ring, x, y)) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
// Contract probes around every node: strip or fan, inside the facade bound.
|
||||
const probes = [];
|
||||
for (const node of map.nodes) {
|
||||
const radius = MITRE_LIMIT * node.halfDepth + node.halfDepth;
|
||||
const step = GRID_PITCH * 0.25;
|
||||
for (let dx = -radius; dx <= radius; dx += step) {
|
||||
for (let dy = -radius; dy <= radius; dy += step) {
|
||||
const x = node.point[0] + dx, y = node.point[1] + dy;
|
||||
const inStrip = node.rays.some((ray) => ray.supports.some((support) => {
|
||||
const rx = x - node.point[0], ry = y - node.point[1];
|
||||
const along = rx * ray.u[0] + ry * ray.u[1];
|
||||
if (along < 0 || along > support.length) return false;
|
||||
return Math.abs(rx * ray.u[1] - ry * ray.u[0]) <= support.halfDepth - step * 0.25;
|
||||
}));
|
||||
const inFan = !inStrip && corners.fans.some((fan) => inPolygon(fan, x, y));
|
||||
if (!inStrip && !inFan) continue;
|
||||
if (bound && !inGeometry(bound, x, y)) continue;
|
||||
probes.push([x, y]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (probes.length < 500) {
|
||||
console.error(`FAIL: contract probe set degenerated to ${probes.length} points`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { page, browser } = await launch({ width: 1200, height: 1000 }, 1);
|
||||
const res = await page.evaluate(async ({ sp, probes: probeList }) => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 4; i++) await new Promise((r) => requestAnimationFrame(r));
|
||||
await c.updateComplete;
|
||||
};
|
||||
sp.id = c._space;
|
||||
c._serverCfg.spaces[c._serverCfg.spaces.findIndex((s) => s.id === c._space)] = sp;
|
||||
c._cfgEpoch++; c.requestUpdate(); await settle();
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
const united = c._wallUnionGeometry();
|
||||
out.geometryProduced = !!united?.d;
|
||||
const path = new Path2D(united.d);
|
||||
const g = document.createElement('canvas').getContext('2d');
|
||||
const holes = probeList.filter(([x, y]) =>
|
||||
!g.isPointInPath(path, x, y, united.fillRule || 'evenodd'));
|
||||
out.probes = probeList.length;
|
||||
out.holes = holes.length;
|
||||
out.holeSample = holes.slice(0, 5).map((p) => p.map((v) => +v.toFixed(1)));
|
||||
out.rendered = !!c.shadowRoot.querySelector('.wallbody');
|
||||
return out;
|
||||
}, { sp: { ...JSON.parse(readFileSync(new URL('../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8')), rooms: fixture.rooms, title: 'Repro', view_box: [0, 0, 1, 1], settings: { show_borders: true } }, probes });
|
||||
const verdict = {
|
||||
geometryProduced: res.geometryProduced,
|
||||
rendered: res.rendered,
|
||||
enoughProbes: res.probes >= 500,
|
||||
noContractHoles: res.holes === 0,
|
||||
};
|
||||
if (res.holes) console.error('дыры:', res.holeSample, 'из', res.holes);
|
||||
checkAll(verdict);
|
||||
await finish(browser, verdict);
|
||||
@@ -107,12 +107,15 @@ const result = await page.evaluate(async (source) => {
|
||||
const canonical = card._wallUnionGeometry();
|
||||
const node = new DOMPoint(source.node[0] * 1000, source.node[1] * 1000);
|
||||
// Midpoint between the R-bounded straight bevel and the old 1.80×H mitre.
|
||||
const discardedWedge = new DOMPoint(330.3808442725, 148.8560107825);
|
||||
// #302: this point lies in the OVERLAP of two incident strips, and the
|
||||
// chamfer is strip-safe now — it must stay filled (it used to be discarded,
|
||||
// which is exactly the hole class #302 removed).
|
||||
const retainedOverlap = new DOMPoint(330.3808442725, 148.8560107825);
|
||||
out.fixtureLoaded = card._spaceModel()?.rooms.length === 2
|
||||
&& card._spaceWalls.length === 7;
|
||||
out.planUsesCanonicalPath = !!planD && canonical?.d === planD;
|
||||
out.nodeRemainsFilled = !!path?.isPointInFill(node);
|
||||
out.excessWedgeIsEmpty = path && !path.isPointInFill(discardedWedge);
|
||||
out.stripOverlapRetained = !!path?.isPointInFill(retainedOverlap);
|
||||
out.planHasNoEnclosedHoles = enclosedSvgHoles(path, node) === 0;
|
||||
out.paperRemainsSolid = !!canonical?.paperD
|
||||
&& !!root().querySelector('.hp-paper');
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// #301: contacts and locks in the real opening dialog are searchable by both
|
||||
// friendly name and entity_id without losing the resolver's priority order.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const settle = async () => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
};
|
||||
const search = async (kind, query) => {
|
||||
const input = sr().querySelector(`[data-opening-panel="${kind}"] .opening-entity-search`);
|
||||
input.value = query;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
await settle();
|
||||
};
|
||||
const rows = (kind) => [...sr().querySelectorAll(
|
||||
`[data-opening-panel="${kind}"] .opening-entity-candidate[data-opening-entity]:not([data-opening-entity=""])`,
|
||||
)];
|
||||
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
states: {
|
||||
...c.hass.states,
|
||||
'binary_sensor.hp301_z_door': {
|
||||
entity_id: 'binary_sensor.hp301_z_door', state: 'off',
|
||||
attributes: { friendly_name: 'Prioritycheck Z door', device_class: 'door' },
|
||||
},
|
||||
'binary_sensor.hp301_a_motion': {
|
||||
entity_id: 'binary_sensor.hp301_a_motion', state: 'off',
|
||||
attributes: { friendly_name: 'Prioritycheck A motion', device_class: 'motion' },
|
||||
},
|
||||
'binary_sensor.hp301_window': {
|
||||
entity_id: 'binary_sensor.hp301_window', state: 'off',
|
||||
attributes: { friendly_name: 'Salon Contact', device_class: 'window' },
|
||||
},
|
||||
'lock.hp301_front': {
|
||||
entity_id: 'lock.hp301_front', state: 'locked',
|
||||
attributes: { friendly_name: 'HP301 Front Lock' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const space = c._serverCfg.spaces.find((item) => item.id === 'f1');
|
||||
space.openings = [{
|
||||
id: 'hp301-door', type: 'door', x: 0.22, y: 0.14, angle: 0, length: 0.09,
|
||||
}];
|
||||
c._cfgEpoch++;
|
||||
c._setMode('plan');
|
||||
await settle();
|
||||
const opening = c._openingsR.find((item) => item.type === 'door');
|
||||
if (!opening) return { realDoorFixtureExists: false };
|
||||
c._editOpening(opening);
|
||||
await settle();
|
||||
|
||||
const result = {
|
||||
realDoorFixtureExists: true,
|
||||
bothPickersShown: sr().querySelectorAll('.opening-entity-drop').length === 2,
|
||||
panelsInitiallyClosed: !sr().querySelector('.opening-entity-panel'),
|
||||
};
|
||||
|
||||
sr().querySelector('[data-opening-picker="contact"]').click();
|
||||
await settle();
|
||||
result.openDoesNotForceFocus = !sr().querySelector('.opening-entity-search').matches(':focus');
|
||||
await search('contact', 'prioritycheck');
|
||||
const ordered = rows('contact');
|
||||
result.contactOrderPreserved = ordered.length === 2
|
||||
&& ordered[0].dataset.openingEntity === 'binary_sensor.hp301_z_door'
|
||||
&& ordered[1].dataset.openingEntity === 'binary_sensor.hp301_a_motion';
|
||||
result.rowsExplainEntityId = ordered.every((row) =>
|
||||
row.querySelector('.cs')?.textContent === row.dataset.openingEntity);
|
||||
|
||||
await search('contact', 'binary_sensor.hp301_window');
|
||||
const byId = rows('contact');
|
||||
result.searchByEntityId = byId.length === 1
|
||||
&& byId[0].querySelector('.cl')?.textContent === 'Salon Contact';
|
||||
byId[0].click();
|
||||
await settle();
|
||||
result.contactSelectedAndClosed = c._openingDialog.contact === 'binary_sensor.hp301_window'
|
||||
&& !sr().querySelector('[data-opening-panel="contact"]');
|
||||
result.closedButtonExplainsEntityId = sr().querySelector('[data-opening-picker="contact"] .ref')
|
||||
?.textContent === 'binary_sensor.hp301_window';
|
||||
|
||||
sr().querySelector('[data-opening-picker="contact"]').click();
|
||||
await settle();
|
||||
await search('contact', 'no such hp301 entity');
|
||||
const contactPanel = sr().querySelector('[data-opening-panel="contact"]');
|
||||
const firstChoice = contactPanel.querySelector('.candlist > .opening-entity-candidate');
|
||||
result.emptyStateKeepsNoneFirst = firstChoice?.dataset.openingEntity === ''
|
||||
&& !!contactPanel.querySelector('.opening-entity-empty');
|
||||
firstChoice.click();
|
||||
await settle();
|
||||
result.noneClearsContact = c._openingDialog.contact === ''
|
||||
&& !sr().querySelector('[data-opening-panel="contact"]');
|
||||
|
||||
sr().querySelector('[data-opening-picker="contact"]').click();
|
||||
await settle();
|
||||
await search('contact', 'hp301_window');
|
||||
rows('contact')[0].click();
|
||||
await settle();
|
||||
sr().querySelector('[data-opening-picker="lock"]').click();
|
||||
await settle();
|
||||
await search('lock', 'front lock');
|
||||
const lockRows = rows('lock');
|
||||
result.lockSearchWorks = lockRows.length === 1
|
||||
&& lockRows[0].dataset.openingEntity === 'lock.hp301_front';
|
||||
lockRows[0].click();
|
||||
await settle();
|
||||
result.lockSelectedAndClosed = c._openingDialog.lock === 'lock.hp301_front'
|
||||
&& !sr().querySelector('[data-opening-panel="lock"]');
|
||||
|
||||
sr().querySelector('.dialog-action-commit .btn.on').click();
|
||||
await settle();
|
||||
const saved = c._curSpaceCfg.openings.find((item) => item.id === opening.id);
|
||||
result.savedSameFields = saved?.contact === 'binary_sensor.hp301_window'
|
||||
&& saved?.lock === 'lock.hp301_front';
|
||||
result.dialogClosedAfterSave = !c._openingDialog;
|
||||
|
||||
c._editOpening(c._openingsR.find((item) => item.id === opening.id));
|
||||
c._openingDialog = { ...c._openingDialog, type: 'passage' };
|
||||
await settle();
|
||||
result.passageStillHasNoBindingPickers = !sr().querySelector('.opening-entity-drop');
|
||||
c._openingDialog = null;
|
||||
return result;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -1,5 +1,6 @@
|
||||
// #198: explicit Optimize removes only a proven isolated wall-thickness
|
||||
// micro-interval. Exercise the production bundle's Preview → Apply → Undo flow.
|
||||
// #198/#299: explicit Optimize removes only a proven isolated wall-thickness
|
||||
// micro-interval without compacting across a physical wall-owner role boundary.
|
||||
// Exercise the production bundle's Preview → Apply → Reload → Undo flow.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 });
|
||||
@@ -40,6 +41,19 @@ const out = await page.evaluate(async () => {
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const expectedRuns = [
|
||||
[0.8, 203 / 240],
|
||||
[203 / 240, 213 / 240],
|
||||
[213 / 240, 0.95],
|
||||
];
|
||||
const hasRoleAwareCanonicalRuns = (walls) => {
|
||||
if (walls.length !== expectedRuns.length || walls.some((wall) => wall.cm !== 22)) return false;
|
||||
const runs = walls.map((wall) => {
|
||||
if (!wall.a || !wall.b || wall.a[1] !== canonicalY || wall.b[1] !== canonicalY) return null;
|
||||
return [Math.min(wall.a[0], wall.b[0]), Math.max(wall.a[0], wall.b[0])];
|
||||
}).filter(Boolean).sort((left, right) => left[0] - right[0]);
|
||||
return JSON.stringify(runs) === JSON.stringify(expectedRuns);
|
||||
};
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
let serverConfig = clone(original), serverLayout = {}, backup = null;
|
||||
const sent = [];
|
||||
@@ -71,10 +85,9 @@ const out = await page.evaluate(async () => {
|
||||
|
||||
card._openAlignDialog(); await card.updateComplete;
|
||||
const preview = card._alignDialog;
|
||||
result.previewOffersChange = !!preview?.changed
|
||||
&& preview.report.canonicalized === 1 && preview.report.wallsMerged === 2
|
||||
&& preview.config.spaces[0].walls.length === 1
|
||||
&& preview.config.spaces[0].walls[0].cm === 22;
|
||||
result.previewOffersRoleAwareChange = !!preview?.changed
|
||||
&& preview.report.canonicalized === 1 && preview.report.wallsMerged === 0
|
||||
&& hasRoleAwareCanonicalRuns(preview.config.spaces[0].walls);
|
||||
result.previewDoesNotWrite = sent.length === 0
|
||||
&& JSON.stringify(card._serverCfg) === JSON.stringify(original);
|
||||
result.previewIsVisible = !!card.renderRoot.querySelector('hp-dialog .alignmsg');
|
||||
@@ -87,16 +100,12 @@ const out = await page.evaluate(async () => {
|
||||
await card._runAlignToGrid(); await card.updateComplete;
|
||||
const appliedWalls = card._serverCfg.spaces[0].walls;
|
||||
result.applyUsesOneAtomicWrite = sent.filter((type) => type === 'houseplan/plan/optimize').length === 1;
|
||||
result.applyStoresOneUniformRun = appliedWalls.length === 1 && appliedWalls[0].cm === 22
|
||||
&& JSON.stringify(appliedWalls[0].a) === JSON.stringify([0.8, canonicalY])
|
||||
&& JSON.stringify(appliedWalls[0].b) === JSON.stringify([0.95, canonicalY]);
|
||||
result.applyStoresRoleAwareRuns = hasRoleAwareCanonicalRuns(appliedWalls);
|
||||
result.applyEnablesUndo = card._canOptimizeUndo === true;
|
||||
|
||||
await card._loadFromServer(); await card.updateComplete;
|
||||
const reloadedWalls = card._serverCfg.spaces[0].walls;
|
||||
result.reloadKeepsCanonicalRun = reloadedWalls.length === 1 && reloadedWalls[0].cm === 22
|
||||
&& JSON.stringify(reloadedWalls[0].a) === JSON.stringify([0.8, canonicalY])
|
||||
&& JSON.stringify(reloadedWalls[0].b) === JSON.stringify([0.95, canonicalY]);
|
||||
result.reloadKeepsRoleAwareRuns = hasRoleAwareCanonicalRuns(reloadedWalls);
|
||||
|
||||
await card._undoPlanOptimization(); await card.updateComplete;
|
||||
result.undoUsesServerSnapshot = sent.filter((type) => type === 'houseplan/plan/optimize_undo').length === 1;
|
||||
|
||||
@@ -15,7 +15,8 @@ const out = await page.evaluate(async () => {
|
||||
}, {
|
||||
// Keep `home` non-final so this fixture proves the normal blocker; the
|
||||
// final-space safe-detach exception is covered by #113's lifecycle smoke.
|
||||
id: 'other', title: 'Other', view_box: [0, 0, 1, 1], cell_cm: 5, rooms: [],
|
||||
id: 'space_space_other_deadbeef_cafebabe', title: 'Other',
|
||||
view_box: [0, 0, 1, 1], cell_cm: 5, rooms: [],
|
||||
}],
|
||||
markers: [
|
||||
{
|
||||
@@ -28,6 +29,10 @@ const out = await page.evaluate(async () => {
|
||||
id: 'home-blocker', binding: 'virtual', space: 'home', name: 'Home blocker',
|
||||
},
|
||||
{ id: 'removed-marker', binding: 'virtual', removed: true, space: 'gone', name: 'Old marker' },
|
||||
{
|
||||
id: 'lineage-marker', binding: 'virtual',
|
||||
space: 'space_other_11111111', name: 'Nested lineage marker',
|
||||
},
|
||||
],
|
||||
settings: { known_devices: ['removed-auto-device'] },
|
||||
};
|
||||
@@ -38,6 +43,7 @@ const out = await page.evaluate(async () => {
|
||||
'removed-marker': { s: 'gone', x: 0.3, y: 0.4 },
|
||||
'removed-auto-device': { s: 'gone', x: 0.4, y: 0.5 },
|
||||
'lg_light.removed_group': { s: 'gone', x: 0.5, y: 0.6 },
|
||||
'lineage-marker': { s: 'space_other_11111111', x: 0.7, y: 0.8 },
|
||||
};
|
||||
let serverConfig = clone(original);
|
||||
let serverLayout = clone(originalLayout);
|
||||
@@ -114,6 +120,10 @@ const out = await page.evaluate(async () => {
|
||||
&& preview.layout['removed-marker'] === undefined
|
||||
&& preview.layout['removed-auto-device'] === undefined
|
||||
&& preview.layout['lg_light.removed_group'] === undefined;
|
||||
const lineageMarker = preview?.config.markers.find((marker) => marker.id === 'lineage-marker');
|
||||
result.nestedImportLineageRepairsToOneCanonicalOwner = lineageMarker?.space
|
||||
=== 'space_space_other_deadbeef_cafebabe'
|
||||
&& preview.layout['lineage-marker']?.s === 'space_space_other_deadbeef_cafebabe';
|
||||
const details = previewBody?.querySelector('details.optimize-details');
|
||||
result.idsExistOnlyInClosedDetails = details && details.open === false
|
||||
&& details.textContent.includes('opaque_owner') && details.textContent.includes('gone');
|
||||
|
||||
@@ -228,12 +228,125 @@ const out = await page.evaluate(async () => {
|
||||
partitions: card._curSpaceCfg.partitions,
|
||||
}) === gestureGeometry;
|
||||
|
||||
card._curSpaceCfg.partitions.push({
|
||||
id: 'hidden-partition', a: [0.1, 0.1], b: [0.5, 0.1], cm: 15,
|
||||
});
|
||||
card._modelCache = null;
|
||||
card._cfgEpoch++;
|
||||
card._activateMarkupTool('select');
|
||||
await update();
|
||||
result.otherPlanToolsHaveNoOverlay = !overlay();
|
||||
const diagnostic = root().querySelector('[data-hp="hidden-wall-diagnostic"]');
|
||||
result.otherPlanToolsKeepFullOverlayAndHiddenDiagnostic = !!overlay() && !!diagnostic
|
||||
&& diagnostic.querySelectorAll('.hidden-wall-line').length === 1
|
||||
&& diagnostic.querySelectorAll('.hidden-wall-node').length === 2;
|
||||
const editorVirtualWalls = root().querySelector('.openwalls');
|
||||
result.hiddenDiagnosticAboveAllWallBodies = !!diagnostic && !!wallBodies
|
||||
&& !!(wallBodies.compareDocumentPosition(diagnostic) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
&& (!editorVirtualWalls
|
||||
|| !!(editorVirtualWalls.compareDocumentPosition(diagnostic) & Node.DOCUMENT_POSITION_FOLLOWING));
|
||||
result.hiddenDiagnosticIsPointerTransparent = diagnostic?.getAttribute('pointer-events') === 'none'
|
||||
&& getComputedStyle(diagnostic).pointerEvents === 'none';
|
||||
card._activateMarkupTool('draw');
|
||||
await update();
|
||||
const drawDiagnostic = root().querySelector('[data-hp="hidden-wall-diagnostic"]');
|
||||
const drawSnap = overlay();
|
||||
result.drawToolKeepsHiddenDiagnosticAndSnapOverlay = !!drawDiagnostic && !!drawSnap
|
||||
&& drawDiagnostic.querySelectorAll('.hidden-wall-line').length === 1
|
||||
&& drawDiagnostic.querySelectorAll('.hidden-wall-node').length === 2;
|
||||
result.hiddenDiagnosticPaintsBeforeTransientSnap = !!(
|
||||
drawDiagnostic.compareDocumentPosition(drawSnap) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
|
||||
// #304 regression fixture: one room plus two independent walls which overlap
|
||||
// its left/bottom axes and continue beyond them. The six source endpoints are
|
||||
// the exact topology from the field report, reduced to deterministic data.
|
||||
const parityCfg = {
|
||||
model_version: 7,
|
||||
spaces: [{
|
||||
id: 'axis-parity', title: 'Axis parity', cell_cm: 5, view_box: [0, 0, 1, 0.75],
|
||||
rooms: [{
|
||||
id: 'room', name: 'Room', area: null,
|
||||
poly: [[0.2, 0.2], [0.6, 0.2], [0.6, 0.6], [0.2, 0.6]],
|
||||
}],
|
||||
room_drafts: [], openings: [], open_spans: [],
|
||||
partitions: [
|
||||
{ id: 'vertical', a: [0.2, 0.05], b: [0.2, 0.6], cm: 20 },
|
||||
{ id: 'horizontal', a: [0.2, 0.6], b: [0.9, 0.6], cm: 20 },
|
||||
],
|
||||
settings: { fill_mode: 'none', show_borders: true, show_names: false },
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
card._serverCfg = JSON.parse(JSON.stringify(parityCfg));
|
||||
card._layout = {};
|
||||
card._space = 'axis-parity';
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card._activeDraftId = null;
|
||||
card._path = [];
|
||||
card._cfgEpoch++;
|
||||
card._setMode('plan');
|
||||
await update();
|
||||
|
||||
const staticSnapshot = () => {
|
||||
const current = overlay();
|
||||
return JSON.stringify({
|
||||
lines: [...current.querySelectorAll('.plan-snap-line')].map((line) => [
|
||||
line.getAttribute('data-key'), line.getAttribute('data-source-kind'),
|
||||
line.getAttribute('x1'), line.getAttribute('y1'),
|
||||
line.getAttribute('x2'), line.getAttribute('y2'),
|
||||
]),
|
||||
endpoints: [...current.querySelectorAll('.plan-snap-node[data-kind="endpoint"]')]
|
||||
.map((node) => [
|
||||
node.getAttribute('data-key'), node.getAttribute('cx'), node.getAttribute('cy'),
|
||||
]),
|
||||
});
|
||||
};
|
||||
const tools = [
|
||||
'select', 'draw', 'column', 'merge', 'split', 'resize',
|
||||
'opening', 'boundary', 'wallthick', 'delroom',
|
||||
];
|
||||
let expectedSnapshot = null;
|
||||
let everyToolMatches = true;
|
||||
let everyToolIsPassive = true;
|
||||
let everyToolPaintsAboveWalls = true;
|
||||
let thicknessHasSixNodes = false;
|
||||
for (const tool of tools) {
|
||||
card._tool = tool;
|
||||
card._clearPlanSnapHover();
|
||||
await update();
|
||||
const current = overlay();
|
||||
const bodies = root().querySelector('.wallbodies');
|
||||
const snapshot = current ? staticSnapshot() : null;
|
||||
if (expectedSnapshot == null) expectedSnapshot = snapshot;
|
||||
everyToolMatches &&= !!current && snapshot === expectedSnapshot;
|
||||
everyToolIsPassive &&= current?.getAttribute('pointer-events') === 'none'
|
||||
&& getComputedStyle(current).pointerEvents === 'none'
|
||||
&& !current.querySelector('.plan-snap-node[data-active="true"]');
|
||||
everyToolPaintsAboveWalls &&= !!bodies && !!current
|
||||
&& !!(bodies.compareDocumentPosition(current) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
if (tool === 'wallthick') {
|
||||
thicknessHasSixNodes = current.querySelectorAll(
|
||||
'.plan-snap-node[data-kind="endpoint"]',
|
||||
).length === 6 && current.querySelectorAll('.plan-snap-line').length === 6;
|
||||
}
|
||||
}
|
||||
result.allPlanToolsShareStaticAxesAndNodes = everyToolMatches;
|
||||
result.allPlanToolOverlaysStayPointerTransparent = everyToolIsPassive;
|
||||
result.allPlanToolOverlaysPaintAboveWallBodies = everyToolPaintsAboveWalls;
|
||||
result.thicknessShowsCompleteSixNodeFixture = thicknessHasSixNodes;
|
||||
|
||||
card._setMode('devices');
|
||||
await update();
|
||||
const devicesHaveNoPlanOverlay = !overlay();
|
||||
card._setMode('decor');
|
||||
await update();
|
||||
const decorHasNoPlanOverlay = !overlay();
|
||||
card._setMode('view');
|
||||
await update();
|
||||
result.viewHasNoOverlay = !overlay();
|
||||
result.nonPlanModesHaveNoOverlay = devicesHaveNoPlanOverlay && decorHasNoPlanOverlay
|
||||
&& !overlay()
|
||||
&& !root().querySelector('[data-hp="hidden-wall-diagnostic"]');
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -70,16 +70,19 @@ const res = await page.evaluate(async () => {
|
||||
out.noCentrelineSpan = !!spans
|
||||
&& !spans.some((s) => Math.abs(s - 300) < 0.5 || Math.abs(s - 400) < 0.5);
|
||||
|
||||
// The dragged-edge bubble, called the way `_rszLive` calls it.
|
||||
// The two useful side-wall bubbles, called the way `_rszLive` calls them.
|
||||
c._rszSel = 'r233';
|
||||
c._rszDrag = { rooms: c._spaceModel().rooms.map((r) => ({ id: r.id, poly: r.poly })) };
|
||||
await upd();
|
||||
const drag = c._rszEdgeLabels({ polys: { r233: render } }, { roomId: 'r233', edge: 0 });
|
||||
const dragLens = drag.filter((l) => !l.area).map((l) => num(l.text));
|
||||
out.dragLabelsThreeEdges = dragLens.length === 3;
|
||||
const drag = c._rszEdgeLabels(
|
||||
{ polys: { r233: render } },
|
||||
{ roomId: 'r233', edge: 0, roomIds: ['r233'], edgeByRoom: { r233: 0 } },
|
||||
);
|
||||
const dragLens = drag.filter((l) => l.kind === 'length').map((l) => num(l.text));
|
||||
out.dragLabelsTwoSideEdges = dragLens.length === 2;
|
||||
out.dragLabelsInner = dragLens.every((v) => Math.abs(v - 2.85) < 0.02 || Math.abs(v - 3.85) < 0.02);
|
||||
out.dragLabelsNotCentreline = !dragLens.some((v) => Math.abs(v - 3) < 0.005 || Math.abs(v - 4) < 0.005);
|
||||
const dragArea = drag.find((l) => l.area);
|
||||
const dragArea = drag.find((l) => l.kind === 'area');
|
||||
out.dragAreaStillInner = !!dragArea && Math.abs(num(dragArea.text) - 11) < 0.06;
|
||||
|
||||
out.legacyScaleLabelsAbsent = typeof c._rszScaleLabels === 'undefined';
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// #300: the production Resize gesture renders two side-wall measurements,
|
||||
// matching highlights and one area badge per owner beside the moving wall.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const out = {};
|
||||
const update = async () => {
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const rectRoom = (id, x0, y0, x1, y1) => ({
|
||||
id, name: id, area: null,
|
||||
poly: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
|
||||
.map(([x, y]) => [x / 1000, y / 1000]),
|
||||
});
|
||||
|
||||
card._setMode('plan');
|
||||
card._tool = 'resize';
|
||||
const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
|
||||
space.rooms = [
|
||||
rectRoom('label-left', 200, 100, 300, 500),
|
||||
rectRoom('label-right', 300, 100, 400, 500),
|
||||
];
|
||||
space.openings = [];
|
||||
delete space.walls;
|
||||
delete space.open_spans;
|
||||
delete space.partitions;
|
||||
delete space.room_drafts;
|
||||
delete space.wall_columns;
|
||||
card._rszEligibilityCache = null;
|
||||
await update();
|
||||
|
||||
const handle = [...card.renderRoot.querySelectorAll('.rszhandle')]
|
||||
.find((node) => Math.abs(Number(node.getAttribute('cx')) - 300) < 1
|
||||
&& Math.abs(Number(node.getAttribute('cy')) - 300) < 1
|
||||
&& node.getAttribute('aria-disabled') === 'false');
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
const svg = stage.querySelector('svg');
|
||||
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
|
||||
const screen = (x, y) => [
|
||||
stageRect.left + ((x - vx) / vw) * stageRect.width,
|
||||
stageRect.top + ((y - vy) / vh) * stageRect.height,
|
||||
];
|
||||
const [sx, sy] = screen(300, 300);
|
||||
const [tx] = screen(325, 300);
|
||||
const dispatch = (type, x, y) => handle?.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, cancelable: true, pointerId: 300, pointerType: 'mouse',
|
||||
clientX: x, clientY: y, buttons: type === 'pointerup' ? 0 : 1,
|
||||
}));
|
||||
dispatch('pointerdown', sx, sy);
|
||||
dispatch('pointermove', tx, sy);
|
||||
await update();
|
||||
|
||||
const live = card._rszLive || [];
|
||||
const lengths = live.filter((label) => label.kind === 'length');
|
||||
const areas = live.filter((label) => label.kind === 'area');
|
||||
out.dragStarted = !!card._rszDrag;
|
||||
out.twoLengths = lengths.length === 2;
|
||||
out.movingWallLengthAbsent = lengths.every((label) =>
|
||||
Math.abs(label.edge.a[0] - label.edge.b[0]) > 1);
|
||||
out.twoMeasuredEdges = card.renderRoot.querySelectorAll(
|
||||
'[data-hp="resize-measured-edge"]',
|
||||
).length === 2;
|
||||
out.twoAreas = card.renderRoot.querySelectorAll('[data-hp="resize-area-label"]').length === 2;
|
||||
out.twoLeaders = card.renderRoot.querySelectorAll('[data-hp="resize-area-leader"]').length === 2;
|
||||
out.oppositeSides = new Set(areas.map((label) => label.placement.side)).size === 2;
|
||||
out.gearVisible = card.renderRoot.querySelectorAll('[data-hp="room-settings"]').length === 2;
|
||||
const areaRects = [...card.renderRoot.querySelectorAll('[data-hp="resize-area-label"]')]
|
||||
.map((label) => label.getBoundingClientRect());
|
||||
out.areasDoNotOverlap = areaRects.length === 2 && (
|
||||
areaRects[0].right <= areaRects[1].left || areaRects[0].left >= areaRects[1].right
|
||||
|| areaRects[0].bottom <= areaRects[1].top || areaRects[0].top >= areaRects[1].bottom
|
||||
);
|
||||
out.gearAvoided = areas.every((area) => {
|
||||
const label = card.renderRoot.querySelector(
|
||||
`[data-hp="resize-area-label"][data-room="${area.roomId}"]`,
|
||||
);
|
||||
const gear = card.renderRoot.querySelector(
|
||||
`[data-hp="room-settings"][data-room="${area.roomId}"]`,
|
||||
);
|
||||
if (!label || !gear) return false;
|
||||
const a = label.getBoundingClientRect();
|
||||
const b = gear.getBoundingClientRect();
|
||||
return a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom;
|
||||
});
|
||||
out.tangentAvoidanceUsed = areas.some((label) => Math.abs(label.placement.tangentOffsetPx) > 0);
|
||||
|
||||
dispatch('pointerup', tx, sy);
|
||||
await update();
|
||||
out.cleanedAfterCommit = card._rszLive === null
|
||||
&& card.renderRoot.querySelectorAll('[data-hp^="resize-"]').length === 0;
|
||||
|
||||
// Repeat the actual browser gesture at a non-default zoom. The footprint is
|
||||
// recomputed from current view.w/iconCqw rather than reusing the first frame.
|
||||
space.rooms = [
|
||||
rectRoom('label-left', 200, 100, 300, 500),
|
||||
rectRoom('label-right', 300, 100, 400, 500),
|
||||
];
|
||||
card._rszEligibilityCache = null;
|
||||
card._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 2);
|
||||
await update();
|
||||
const zoomHandle = [...card.renderRoot.querySelectorAll('.rszhandle')]
|
||||
.find((node) => Math.abs(Number(node.getAttribute('cx')) - 300) < 1
|
||||
&& Math.abs(Number(node.getAttribute('cy')) - 300) < 1
|
||||
&& node.getAttribute('aria-disabled') === 'false');
|
||||
const zoomStageRect = stage.getBoundingClientRect();
|
||||
const zoomSvg = stage.querySelector('svg');
|
||||
const [zvx, zvy, zvw, zvh] = zoomSvg.getAttribute('viewBox').split(' ').map(Number);
|
||||
const zoomScreen = (x, y) => [
|
||||
zoomStageRect.left + ((x - zvx) / zvw) * zoomStageRect.width,
|
||||
zoomStageRect.top + ((y - zvy) / zvh) * zoomStageRect.height,
|
||||
];
|
||||
const [zsx, zsy] = zoomScreen(300, 300);
|
||||
const [ztx] = zoomScreen(325, 300);
|
||||
const zoomDispatch = (type, x, y) => zoomHandle?.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, cancelable: true, pointerId: 301, pointerType: 'mouse',
|
||||
clientX: x, clientY: y, buttons: type === 'pointerup' ? 0 : 1,
|
||||
}));
|
||||
zoomDispatch('pointerdown', zsx, zsy);
|
||||
zoomDispatch('pointermove', ztx, zsy);
|
||||
await update();
|
||||
const zoomAreas = [...card.renderRoot.querySelectorAll('[data-hp="resize-area-label"]')];
|
||||
out.nonDefaultZoomActive = card._zoom > 1 && zoomAreas.length === 2;
|
||||
out.gearAvoidedAtNonDefaultZoom = zoomAreas.every((label) => {
|
||||
const gear = card.renderRoot.querySelector(
|
||||
`[data-hp="room-settings"][data-room="${label.getAttribute('data-room')}"]`,
|
||||
);
|
||||
if (!gear) return false;
|
||||
const a = label.getBoundingClientRect();
|
||||
const b = gear.getBoundingClientRect();
|
||||
return a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom;
|
||||
});
|
||||
zoomDispatch('pointercancel', ztx, zsy);
|
||||
await update();
|
||||
out.cleanedAfterZoomCancel = card._rszLive === null;
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser);
|
||||
@@ -96,6 +96,53 @@ const res = await page.evaluate(async () => {
|
||||
rooms: sp().rooms, walls: sp().walls, openings: sp().openings,
|
||||
}) === before;
|
||||
}
|
||||
|
||||
// #298: an affected key-only record has no endpoints with which to prove a
|
||||
// partial mapping. The handle remains eligible, but the runtime candidate
|
||||
// must fail closed before preview, history and config/set.
|
||||
sp().rooms = [
|
||||
{
|
||||
id: 'legacy-left', name: 'Legacy left',
|
||||
poly: [[0.08, 0.12], [0.50, 0.12], [0.50, 0.55], [0.08, 0.55]],
|
||||
},
|
||||
{
|
||||
id: 'legacy-right', name: 'Legacy right',
|
||||
poly: [[0.50, 0.12], [0.92, 0.12], [0.92, 0.55], [0.50, 0.55]],
|
||||
},
|
||||
];
|
||||
sp().walls = [{ key: '0.250000,0.120833@0.0000', cm: 22 }];
|
||||
sp().openings = [];
|
||||
delete sp().open_spans;
|
||||
await upd();
|
||||
|
||||
const legacyBefore = JSON.stringify({ rooms: sp().rooms, walls: sp().walls });
|
||||
const legacyHistoryBefore = c._geometryHistory?.size || 0;
|
||||
const originalCallWS = c.hass.callWS.bind(c.hass);
|
||||
let legacyWrites = 0;
|
||||
c.hass.callWS = async (message) => {
|
||||
if (message.type === 'houseplan/config/set') legacyWrites++;
|
||||
return originalCallWS(message);
|
||||
};
|
||||
const legacyHandle = [...sr().querySelectorAll('.rszhandle')].find((entry) =>
|
||||
entry.getAttribute('aria-disabled') === 'false'
|
||||
&& Math.abs(+entry.getAttribute('cx') - 500) < 0.5
|
||||
&& Math.abs(+entry.getAttribute('cy') - 335) < 0.5);
|
||||
out.legacyHandleEnabled = !!legacyHandle;
|
||||
if (legacyHandle) {
|
||||
const x = +legacyHandle.getAttribute('cx');
|
||||
const y = +legacyHandle.getAttribute('cy');
|
||||
pointer('pointerdown', legacyHandle, x, y);
|
||||
pointer('pointermove', legacyHandle, x + c._gridPitch, y);
|
||||
await c.updateComplete;
|
||||
out.legacyPreviewRejected = !!c._rszDrag && !c._rszPreview;
|
||||
pointer('pointerup', legacyHandle, x + c._gridPitch, y);
|
||||
await c.updateComplete;
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
out.legacyNoHistory = (c._geometryHistory?.size || 0) === legacyHistoryBefore;
|
||||
out.legacyNoConfigWrite = legacyWrites === 0;
|
||||
out.legacySourceUnchanged = JSON.stringify({ rooms: sp().rooms, walls: sp().walls })
|
||||
=== legacyBefore;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,24 @@ const result = await page.evaluate(async () => {
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const update = async () => { card.requestUpdate(); await card.updateComplete; };
|
||||
const space = () => card._serverCfg.spaces[0];
|
||||
const near = (actual, expected) => Math.abs(actual - expected) <= 1e-9;
|
||||
const keyDown = async (key, init = {}) => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key, bubbles: true, cancelable: true, ...init,
|
||||
}));
|
||||
await update();
|
||||
};
|
||||
const clickStage = async (x, y) => {
|
||||
const stage = root().querySelector('.stage');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const view = card._viewOr(card._baseVb());
|
||||
stage.dispatchEvent(new MouseEvent('click', {
|
||||
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
|
||||
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
|
||||
bubbles: true, cancelable: true,
|
||||
}));
|
||||
await update();
|
||||
};
|
||||
const resetGeometry = async (rooms = []) => {
|
||||
const current = space();
|
||||
current.rooms = JSON.parse(JSON.stringify(rooms));
|
||||
@@ -24,9 +42,14 @@ const result = await page.evaluate(async () => {
|
||||
card._wallFaceBatch = null;
|
||||
card._roomDialog = false;
|
||||
card._tool = 'draw';
|
||||
card._cursorPt = null;
|
||||
card._suppressClick = false;
|
||||
card._geometryHistory.clear();
|
||||
delete card._resumeDraftBySpace[card._space];
|
||||
card._clearPlanSnapHover();
|
||||
await update();
|
||||
};
|
||||
const draw = async (points) => {
|
||||
const draw = async (points, cms = []) => {
|
||||
card._tool = 'draw';
|
||||
card._path = [[...points[0]]];
|
||||
card._activeDraftId = null;
|
||||
@@ -34,7 +57,7 @@ const result = await page.evaluate(async () => {
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const before = card._path.map((point) => [...point]);
|
||||
card._path = [...card._path, [...points[i]]];
|
||||
card._draftSegmentCms = [...card._draftSegmentCms, card._drawWallCm];
|
||||
card._draftSegmentCms = [...card._draftSegmentCms, cms[i - 1] ?? card._drawWallCm];
|
||||
card._persistActiveDraftSegment();
|
||||
card._offerWallFaces(before);
|
||||
await update();
|
||||
@@ -74,6 +97,64 @@ const result = await page.evaluate(async () => {
|
||||
&& space().partitions.every((partition, index) => partition.cm === cms[index]);
|
||||
out.finishedChainDoesNotResume = !card._activeDraftId && card._path.length === 0;
|
||||
|
||||
// #294: Escape owns the registered window keyboard path, finishes exactly
|
||||
// like a tool change and leaves Walls armed for an independent next chain.
|
||||
await resetGeometry();
|
||||
await draw([[100, 100], [300, 100], [300, 250]], [12, 23]);
|
||||
await keyDown('Escape');
|
||||
const escapedPartitions = space().partitions || [];
|
||||
out.escapeFinishesWithoutDeletingSegments = card._tool === 'draw'
|
||||
&& card._path.length === 0 && !card._activeDraftId && !space().room_drafts
|
||||
&& !card._resumeDraftBySpace[card._space]
|
||||
&& escapedPartitions.length === 2
|
||||
&& escapedPartitions.some((part) => part.cm === 12)
|
||||
&& escapedPartitions.some((part) => part.cm === 23)
|
||||
&& card._geometryHistory.undoName === card._t('history.wall_chain_finish')
|
||||
&& !root().querySelector('.active-axis') && !root().querySelector('.active-vertex');
|
||||
const escapedGeometry = JSON.stringify(space());
|
||||
const escapedHistory = card._geometryHistory.undoName;
|
||||
await keyDown('Escape');
|
||||
out.repeatedEscapeAfterFinishIsNoop = JSON.stringify(space()) === escapedGeometry
|
||||
&& card._geometryHistory.undoName === escapedHistory
|
||||
&& card._tool === 'draw' && card._path.length === 0;
|
||||
await clickStage(500, 500);
|
||||
out.nextClickStartsIndependentChain = card._path.length === 1
|
||||
&& near(card._path[0][0], 500) && near(card._path[0][1], 500)
|
||||
&& !space().room_drafts && (space().partitions || []).length === 2;
|
||||
await clickStage(700, 500);
|
||||
const independentDraft = space().room_drafts?.[0];
|
||||
out.secondClickCreatesOnlyIndependentSegment = card._path.length === 2
|
||||
&& independentDraft?.points?.length === 2
|
||||
&& near(independentDraft.points[0][0], 0.5) && near(independentDraft.points[0][1], 0.5)
|
||||
&& near(independentDraft.points[1][0], 0.7) && near(independentDraft.points[1][1], 0.5)
|
||||
&& (space().partitions || []).length === 2;
|
||||
|
||||
await resetGeometry();
|
||||
const singlePointGeometry = JSON.stringify(space());
|
||||
const singlePointHistory = card._geometryHistory.undoName;
|
||||
await clickStage(200, 200);
|
||||
const onePointWasArmed = card._path.length === 1 && !space().room_drafts;
|
||||
await keyDown('Escape');
|
||||
out.escapeClearsOnlyTransientFirstPoint = onePointWasArmed
|
||||
&& card._tool === 'draw' && card._path.length === 0 && !card._activeDraftId
|
||||
&& JSON.stringify(space()) === singlePointGeometry
|
||||
&& card._geometryHistory.undoName === singlePointHistory;
|
||||
|
||||
await resetGeometry();
|
||||
await draw([[100, 100], [300, 100], [300, 250]], [12, 23]);
|
||||
const rejectedPath = JSON.stringify(card._path);
|
||||
const rejectedDraft = JSON.stringify(space().room_drafts);
|
||||
const rejectedHistory = card._geometryHistory.undoName;
|
||||
const finishWallChain = card._finishWallChain;
|
||||
card._finishWallChain = () => false;
|
||||
await keyDown('Escape');
|
||||
card._finishWallChain = finishWallChain;
|
||||
out.rejectedFinishKeepsActiveDraft = JSON.stringify(card._path) === rejectedPath
|
||||
&& JSON.stringify(space().room_drafts) === rejectedDraft
|
||||
&& card._geometryHistory.undoName === rejectedHistory
|
||||
&& card._activeDraftId === space().room_drafts?.[0]?.id
|
||||
&& !(space().partitions?.length);
|
||||
|
||||
await resetGeometry();
|
||||
await draw([[100, 100], [300, 100], [300, 300], [100, 300], [100, 100]]);
|
||||
out.closedFaceOpensDialog = card._roomDialog && card._wallFaceBatch?.candidates.length === 1;
|
||||
@@ -93,19 +174,25 @@ const result = await page.evaluate(async () => {
|
||||
out.cancelKeepsWholeTerminalDraft = !card._roomDialog && !card._wallFaceBatch
|
||||
&& JSON.stringify(space().room_drafts) === draftBeforeCancel
|
||||
&& card._path.length === 5;
|
||||
card._onKey(new KeyboardEvent('keydown', {
|
||||
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true,
|
||||
}));
|
||||
await update();
|
||||
out.escapeUndoContractStillRemovesLastSegment = space().room_drafts?.[0]?.segments.length === 3
|
||||
await keyDown('z', { code: 'KeyZ', ctrlKey: true });
|
||||
out.ctrlZContractStillRemovesLastSegment = space().room_drafts?.[0]?.segments.length === 3
|
||||
&& card._path.length === 4;
|
||||
|
||||
await resetGeometry();
|
||||
await draw([[500, 100], [700, 100], [700, 300], [500, 300], [500, 100]]);
|
||||
const dialogDraft = JSON.stringify(space().room_drafts);
|
||||
await keyDown('Escape');
|
||||
out.firstEscapeOnlyCancelsRoomDialog = !card._roomDialog && !card._wallFaceBatch
|
||||
&& JSON.stringify(space().room_drafts) === dialogDraft
|
||||
&& card._path.length === 5 && !(space().partitions?.length);
|
||||
await keyDown('Escape');
|
||||
out.secondEscapeFinishesRestoredDraft = !space().room_drafts
|
||||
&& card._path.length === 0 && !card._activeDraftId
|
||||
&& space().partitions?.length === 4 && card._tool === 'draw';
|
||||
|
||||
await resetGeometry();
|
||||
await draw([[100, 450], [300, 450], [300, 650], [100, 650], [100, 450]]);
|
||||
card._onKey(new KeyboardEvent('keydown', {
|
||||
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true,
|
||||
}));
|
||||
await update();
|
||||
await keyDown('z', { code: 'KeyZ', ctrlKey: true });
|
||||
out.ctrlZInQueueRemovesTerminalPoint = !card._roomDialog && !card._wallFaceBatch
|
||||
&& card._path.length === 4 && space().room_drafts?.[0]?.segments.length === 3;
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// #303: the Thickness-tool hover fill follows physical wall centimetres while
|
||||
// its deliberately generous pointer hit radius remains unchanged.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 860 }, 1);
|
||||
const result = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const settle = async (structural = false) => {
|
||||
if (structural) {
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card._wallUnionCache = null;
|
||||
}
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
while (card._modeTransitionBusy) await frame();
|
||||
await frame();
|
||||
};
|
||||
const currentSpace = () => card._serverCfg.spaces.find((space) => space.id === card._space);
|
||||
const scanWidth = (path, y, fromX, toX, step = 0.01) => {
|
||||
if (!path?.isPointInFill) return NaN;
|
||||
let first = NaN;
|
||||
let last = NaN;
|
||||
for (let x = fromX; x <= toX; x += step) {
|
||||
if (!path.isPointInFill(new DOMPoint(x, y))) continue;
|
||||
if (!Number.isFinite(first)) first = x;
|
||||
last = x;
|
||||
}
|
||||
return Number.isFinite(first) && Number.isFinite(last) ? last - first + step : NaN;
|
||||
};
|
||||
const closeRelative = (actual, expected, tolerance = 0.02) =>
|
||||
Number.isFinite(actual) && Number.isFinite(expected) && expected > 0
|
||||
&& Math.abs(actual - expected) / expected <= tolerance;
|
||||
|
||||
const space = currentSpace();
|
||||
space.cell_cm = 30;
|
||||
space.settings = { ...(space.settings || {}), show_borders: true };
|
||||
delete space.walls;
|
||||
delete space.openings;
|
||||
delete space.open_spans;
|
||||
for (const room of space.rooms || []) delete room.open_to;
|
||||
|
||||
card._setMode('plan');
|
||||
card._tool = 'wallthick';
|
||||
await settle(true);
|
||||
|
||||
const wallAxis = [50, 250];
|
||||
card._wallThickClick(wallAxis);
|
||||
await settle();
|
||||
const dialogOpened = !!card._wallDialog;
|
||||
card._wallDialog = { ...card._wallDialog, value: '50' };
|
||||
card._wallThickApply(false);
|
||||
await settle(true);
|
||||
|
||||
card._tool = 'wallthick';
|
||||
card._cursorPt = wallAxis;
|
||||
await settle();
|
||||
const body = root().querySelector('[data-hp="wall"]');
|
||||
const hover = root().querySelector('.wallthick-hover');
|
||||
const bodyWidth = scanWidth(body, wallAxis[1], 25, 75);
|
||||
const hoverWidth = scanWidth(hover, wallAxis[1], 25, 75);
|
||||
const expectedWidth = (50 / 30) * card._gridPitch;
|
||||
const axisHit = card._wallThickHit(wallAxis);
|
||||
const dx = axisHit ? axisHit.b[0] - axisHit.a[0] : 0;
|
||||
const dy = axisHit ? axisHit.b[1] - axisHit.a[1] : 0;
|
||||
const length = Math.hypot(dx, dy);
|
||||
const midpoint = axisHit
|
||||
? [(axisHit.a[0] + axisHit.b[0]) / 2, (axisHit.a[1] + axisHit.b[1]) / 2]
|
||||
: wallAxis;
|
||||
const farHit = length > 0 && card._wallThickHit([
|
||||
midpoint[0] - (dy / length) * card._gridPitch * 5,
|
||||
midpoint[1] + (dx / length) * card._gridPitch * 5,
|
||||
]);
|
||||
|
||||
return {
|
||||
dialogOpened,
|
||||
bodyAndHoverRendered: !!body && !!hover,
|
||||
bodyIsPhysicalWidth: closeRelative(bodyWidth, expectedWidth),
|
||||
hoverMatchesWallWithinTwoPercent: closeRelative(hoverWidth, bodyWidth),
|
||||
generousHitAreaUnchanged: !!farHit,
|
||||
};
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(result));
|
||||
@@ -540,6 +540,12 @@ The controller rebuilds every live candidate from one immutable
|
||||
`SpaceGeometryState`. `rekeyWallsAfterMove()` and
|
||||
`rekeyOpenSpansAfterMove()` map exact wall-owned records into that overlay;
|
||||
partitions, drafts, columns, decor and plan transform stay byte-equivalent.
|
||||
Wall rekey has a production-only fixed-topology mode: rigid moving edges
|
||||
translate all breakpoints, while length-changing side edges move only proven
|
||||
old-vertex → new-vertex endpoints. Before the overlay is accepted, the union of
|
||||
collinear room/partition carriers must cover every new exact wall record and no
|
||||
new lattice/carrier violation may appear. Historical invalid records are
|
||||
compared as an exact multiset rather than repaired during an unrelated Resize.
|
||||
The renderer's canonical wall/floor result for the final preview cfg epoch is
|
||||
the pointerup preflight result. Success copies that exact overlay once and
|
||||
records one Undo/save; there is no commit-time simplify/degrade/reconstruction.
|
||||
@@ -548,6 +554,13 @@ scale helpers remain pure-test history only and are tree-shaken from the
|
||||
production interaction path. Exact `a/b` wall endpoints remain identity and
|
||||
the quantised midpoint/direction `key` remains only a compatibility index.
|
||||
|
||||
Live measurement layout is isolated in pure `src/resize-labels.ts` (#300).
|
||||
The controller supplies the accepted candidate, current view, cached stage
|
||||
size and the room gear's `iconCqw()`-derived footprint. It produces exactly two
|
||||
side-wall highlights/lengths plus one area/leader per affected room. The SVG
|
||||
ink sits above wall bodies and below openings/handles; HTML labels are
|
||||
pointer-inert. No `getBoundingClientRect()` enters the pointer path.
|
||||
|
||||
Near-axis geometry has one shared classifier in `src/near-axis.ts` (#290).
|
||||
Walls applies it after architectural/grid resolution and before hover/commit,
|
||||
moving only the free endpoint. Resize validates that its fixed-topology output
|
||||
@@ -557,6 +570,14 @@ reuses ordinary opening projection and wall/open-span rekeying. Unique physical
|
||||
count, maximum centimetres and skipped candidates stay separate from ordinary
|
||||
grid movement; no load/save migration invokes this repair.
|
||||
|
||||
`normalizeWallIntervals()` compacts atomic real-wall intervals only when both
|
||||
their centimetre thickness and ownership signature match (#299). The signature
|
||||
is `outer(A)` or the stable sorted pair `shared(A,B)`; an outer/shared transition,
|
||||
a change of shared pair, or ambiguous multi-owner geometry is a hard breakpoint.
|
||||
Explicit Optimize and the room-deletion transaction call this same normalizer,
|
||||
so neither path can create one saved record whose physical role changes halfway
|
||||
through its exact span. Ambiguous ownership fails closed per atom.
|
||||
|
||||
All physical-geometry writers share the same transaction boundary (#278).
|
||||
`checkSpacePhysicalGeometry()` validates the exact candidate through canonical
|
||||
wall and floor builders before history or save. A failed or degraded candidate
|
||||
@@ -567,14 +588,16 @@ colour and other presentation edits bypass this structural check, allowing an
|
||||
old degraded plan to be exported or corrected without a background migration.
|
||||
|
||||
`reconcileCoincidentPartitions()` is an explicit-Optimize-only structural
|
||||
canonicalizer (#276). It consumes canonical shared wall intervals and the
|
||||
canonicalizer (#276/#296). It consumes canonical room-wall intervals and the
|
||||
partition-opening compatibility resolver; it does not implement a second
|
||||
nearest-wall model. An independent wall is removed only after an exact
|
||||
endpoint-to-endpoint, two-room, uniform-solid proof. Its hosted openings are
|
||||
materialised onto the ordinary room wall, and `max(roomCm, partitionCm)` keeps
|
||||
the original centred physical union envelope. Unknown partition semantics,
|
||||
partial/composite matches, overlapping openings and adjacent independent
|
||||
bodies fail closed. The candidate then crosses the existing whole-plan
|
||||
nearest-wall model. A source axis is atomized at solid interval and opening
|
||||
boundaries. Exact one-owner outer or two-owner shared spans may be absorbed;
|
||||
ambiguous spans are recombined into deterministic residual partitions and keep
|
||||
their hosted openings. Converted openings are materialised onto ordinary room
|
||||
walls, and `max(roomCm, partitionCm)` keeps the original centred physical union
|
||||
envelope. Saved drafts use a separate all-or-nothing full-coverage proof.
|
||||
Unknown partition semantics, gaps, overlapping openings and adjacent
|
||||
independent bodies fail closed. The candidate then crosses the existing whole-plan
|
||||
geometry preflight and one atomic Optimize write/Undo boundary. No render or
|
||||
ordinary save path invokes this pass, so `PLAN_MODEL_VERSION` remains unchanged.
|
||||
`OptimizeDependencies` is a narrow test/benchmark seam: production uses the
|
||||
|
||||
@@ -497,7 +497,9 @@ orchestrator. It converts only legacy fields with an exact lossless
|
||||
mapping, materialises legacy `open_to`, calls the grid projection,
|
||||
rekeys exact wall/open-span endpoints onto the moved rooms, merges
|
||||
touching virtual spans per room pair, compacts consecutive real-wall
|
||||
intervals of equal thickness and stamps `model_version`. Unknown fields
|
||||
intervals only when their thickness and physical ownership both match, and
|
||||
stamps `model_version`. Outer/shared transitions and changes of shared-room
|
||||
pair remain exact breakpoints even at equal thickness. Unknown fields
|
||||
are preserved and every pass is idempotent.
|
||||
|
||||
The explicit pass also repairs pre-existing near-axis room walls, saved wall
|
||||
@@ -683,6 +685,17 @@ current anchor is excluded to prevent zero-length segments. The static geometry
|
||||
is cached by structural editor state; pointer movement changes at most the
|
||||
single active candidate and never writes config, layout or storage.
|
||||
|
||||
A separate diagnostic projection is present throughout the Plan editor (#296),
|
||||
including tools other than **Walls**. For every saved draft or independent wall
|
||||
segment with a positive exact collinear overlap against another wall, it keeps
|
||||
that source segment's complete axis and original endpoints visible. It is
|
||||
painted after every real and virtual wall body and before openings, selection
|
||||
chrome and transient previews. The layer is `pointer-events:none`,
|
||||
`aria-hidden`, absent from View and cached by structural revision; it neither
|
||||
deduplicates source identities nor participates in the architectural snap
|
||||
resolver above. The 1 CSS px non-scaling axis and physical 5 cm nodes therefore
|
||||
diagnose an otherwise invisible Resize blocker without changing any hit target.
|
||||
|
||||
## Planar wall faces
|
||||
|
||||
Every completed Walls segment is first persisted in the active `room_drafts`
|
||||
|
||||
@@ -2,6 +2,65 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Wall junctions no longer leave white wedges and notches. A node where three
|
||||
or more walls meet is now completed with sector fans and the exact strips of
|
||||
its rays: a node closes with a full mitre, like an ordinary wall
|
||||
intersection on a drawing — the old junction chamfers are gone. The
|
||||
no-holes invariant is checked automatically on a set of 16
|
||||
close-up junction scenes and on the plan from the original report
|
||||
([#302](https://github.com/Matysh/houseplan-card/issues/302)).
|
||||
- Every Plan editor tool now shows the same complete set of wall axes and
|
||||
structural endpoints. Switching from Walls to Thickness no longer hides room
|
||||
axes, while each tool keeps its own hints and live preview
|
||||
([#304](https://github.com/Matysh/houseplan-card/issues/304)).
|
||||
- Re-importing an already imported space no longer grows nested internal-ID
|
||||
prefixes. Preview and Apply now share one immutable candidate, safely repair
|
||||
only uniquely proven references to earlier copies, and preserve ambiguous
|
||||
references without guessing or deletion; the import dialog exposes the
|
||||
reference details ([#265](https://github.com/Matysh/houseplan-card/issues/265)).
|
||||
- The wall-thickness tool now highlights a real wall at its actual physical
|
||||
width instead of inflating thin walls according to the plan's grid size.
|
||||
Zero-thickness walls keep a visible, scale-independent highlight and the
|
||||
generous pointer hit area is unchanged
|
||||
([#303](https://github.com/Matysh/houseplan-card/issues/303)).
|
||||
- Contact sensors and locks in the opening properties dialog can now be
|
||||
searched by friendly name or full entity ID. The selector keeps door/window
|
||||
candidates prioritised and shows the entity ID beside every result
|
||||
([#301](https://github.com/Matysh/houseplan-card/issues/301)).
|
||||
|
||||
## v1.67.0-rc.2 — 2026-08-25
|
||||
|
||||
- Hidden independent wall sections and saved wall chains now keep their axes
|
||||
and original endpoints visible above all masonry in every Plan tool. “Optimize
|
||||
plans” can absorb every exactly covered section into one or more consecutive
|
||||
room walls, retain any unproven residual and its openings, and remove a saved
|
||||
chain only when all of it is redundant. This clears real invisible Resize
|
||||
blockers without shrinking walls or deleting unfinished work. The server
|
||||
independently proves the complete removed axis, including walls without
|
||||
openings, and the Optimize report names redundant saved chains separately
|
||||
([#296](https://github.com/Matysh/houseplan-card/issues/296)).
|
||||
- Resize no longer proportionally shifts thickness-record endpoints on
|
||||
neighbouring walls. Fixed-topology moves now preserve unrelated wall records,
|
||||
keep new endpoints on the plan grid and their real carriers, and reject an
|
||||
ambiguous candidate before it can damage a different wall
|
||||
([#298](https://github.com/Matysh/houseplan-card/issues/298)).
|
||||
- Resize now labels and highlights only the two side walls being measured,
|
||||
removes the redundant length from the moving wall, and shows each affected
|
||||
room's live area beside its own side of that wall. Shared and narrow rooms
|
||||
keep both areas visible with leader lines, without covering each other or the
|
||||
room-settings button
|
||||
([#300](https://github.com/Matysh/houseplan-card/issues/300)).
|
||||
- “Optimize plans” and room deletion no longer combine an equal wall thickness
|
||||
across a shared-to-outer boundary or between different pairs of rooms. The
|
||||
saved wall profile keeps each physical role at its exact breakpoint, avoiding
|
||||
a record that changes meaning halfway through its span
|
||||
([#299](https://github.com/Matysh/houseplan-card/issues/299)).
|
||||
- While drawing Walls, `Esc` now finishes all accepted segments as independent
|
||||
walls and releases the last point without deleting geometry or leaving the
|
||||
tool. The next click starts a new chain; `Ctrl/Cmd+Z` remains the shortcut
|
||||
that removes the last point and segment
|
||||
([#294](https://github.com/Matysh/houseplan-card/issues/294)).
|
||||
|
||||
## v1.67.0-rc.1 — 2026-08-24
|
||||
|
||||
- Republished the reviewed beta.10 candidate under an RC identifier so HACS
|
||||
|
||||
@@ -8,6 +8,65 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Стыки стен больше не оставляют белых клиньев и зазубрин. Узел, где сходятся
|
||||
три и больше стен, теперь достраивается угловыми веерами и точными полосами
|
||||
своих лучей: узел смыкается полным mitre, как обычное пересечение стен на
|
||||
чертеже, — прежние фаски на стыках ушли. Инвариант «дыр нет»
|
||||
проверяется автоматически на сете из 16 крупноплановых сцен стыков и на
|
||||
плане из исходного сообщения
|
||||
([#302](https://github.com/Matysh/houseplan-card/issues/302)).
|
||||
- Во всех инструментах редактора Плана теперь отображается один и тот же полный
|
||||
набор осевых линий и узлов стен. При переходе из «Стены» в «Толщина» оси
|
||||
комнат больше не исчезают, а подсказки и предпросмотр активного инструмента
|
||||
остаются независимыми ([#304](https://github.com/Matysh/houseplan-card/issues/304)).
|
||||
- Повторный импорт уже импортированного пространства больше не наращивает
|
||||
служебные префиксы внутренних ID. Предпросмотр и применение теперь используют
|
||||
один и тот же неизменяемый кандидат, безопасно восстанавливают единственные
|
||||
доказанные ссылки на прошлые копии и сохраняют неоднозначные ссылки без
|
||||
догадок или удаления; подробности доступны прямо в диалоге импорта
|
||||
([#265](https://github.com/Matysh/houseplan-card/issues/265)).
|
||||
- Инструмент толщины стен теперь подсвечивает реальную стену по её фактической
|
||||
физической ширине, не раздувая тонкие стены из-за размера клетки плана.
|
||||
Стены без толщины сохраняют видимую, независимую от масштаба подсветку, а
|
||||
широкая зона попадания указателя не изменилась
|
||||
([#303](https://github.com/Matysh/houseplan-card/issues/303)).
|
||||
- Контактные датчики и замки в свойствах проёма теперь можно искать по имени
|
||||
или полному `entity_id`. Селектор сохраняет приоритет дверных и оконных
|
||||
сущностей и показывает идентификатор рядом с каждым результатом
|
||||
([#301](https://github.com/Matysh/houseplan-card/issues/301)).
|
||||
|
||||
## v1.67.0-rc.2 — 2026-08-25
|
||||
|
||||
- Оси и исходные узлы скрытых независимых стен и сохранённых цепочек теперь
|
||||
видны поверх всей кладки во всех инструментах Плана. «Оптимизировать планы» умеет
|
||||
поглощать каждый точно покрытый участок одной или несколькими соседними
|
||||
стенами комнат, сохраняет недоказанный остаток вместе с его проёмами и удаляет
|
||||
цепочку только при полной избыточности. Это устраняет реальные невидимые
|
||||
блокеры Resize, не уменьшая толщину стен и не удаляя незавершённую работу.
|
||||
Сервер независимо доказывает всю удаляемую ось, включая стены без проёмов,
|
||||
а отчёт Optimize отдельно называет избыточные сохранённые цепочки
|
||||
([#296](https://github.com/Matysh/houseplan-card/issues/296)).
|
||||
- Resize больше не сдвигает пропорционально концы записей толщины соседних
|
||||
стен. Fixed-topology перенос сохраняет нетронутые записи, оставляет новые
|
||||
концы на сетке и реальных границах стены, а неоднозначный результат отклоняет
|
||||
до того, как он повредит другую стену
|
||||
([#298](https://github.com/Matysh/houseplan-card/issues/298)).
|
||||
- Resize теперь подписывает и подсвечивает только две измеряемые боковые стены,
|
||||
не показывает лишнюю длину на перемещаемой стене и ставит живую площадь каждой
|
||||
затронутой комнаты с её стороны. Для общих и узких комнат обе площади остаются
|
||||
видимыми с выносными линиями и не перекрывают друг друга или кнопку настроек
|
||||
комнаты ([#300](https://github.com/Matysh/houseplan-card/issues/300)).
|
||||
- «Оптимизировать планы» и удаление комнаты больше не склеивают одинаковую
|
||||
толщину через границу общей и наружной стены или между разными парами комнат.
|
||||
Профиль стены сохраняет каждую физическую роль до её точной границы, поэтому
|
||||
одна запись больше не меняет смысл посередине своего участка
|
||||
([#299](https://github.com/Matysh/houseplan-card/issues/299)).
|
||||
- При рисовании стен `Esc` теперь завершает все принятые отрезки как
|
||||
независимые стены и отцепляется от последней точки, не удаляя геометрию и не
|
||||
покидая инструмент. Следующий клик начинает новую цепочку, а `Ctrl/Cmd+Z`
|
||||
по-прежнему удаляет последнюю точку и отрезок
|
||||
([#294](https://github.com/Matysh/houseplan-card/issues/294)).
|
||||
|
||||
## v1.67.0-rc.1 — 2026-08-24
|
||||
|
||||
- Проверенный кандидат beta.10 переопубликован с RC-идентификатором, чтобы HACS
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<!-- release: v1.67.0-rc.1 -->
|
||||
<!-- release: v1.67.0-rc.2 -->
|
||||
|
||||
## Основное
|
||||
|
||||
- Многостенные стыки больше не вырезают длинные участки соседних стен: кладка остаётся непрерывной даже рядом с коротким лучом узла.
|
||||
- Resize сохраняет роли и толщины общих стен, действительно двигает активную ручку, а для недоступных стен показывает конкретную понятную причину.
|
||||
- Почти осевые стены и невидимый координатный шум теперь исправляются предсказуемо: Walls сохраняет точную ось, а «Оптимизировать планы» и общий барьер записи приводят старые значения к канонической сетке.
|
||||
- «Оптимизировать планы» удаляет доказанно избыточную скрытую геометрию, не затрагивая незавершённую работу, и сохраняет границы роли общей и наружной стены.
|
||||
- Resize сохраняет записи толщины соседних стен и отклоняет неоднозначное изменение до повреждения геометрии.
|
||||
- Режим Resize показывает только относящиеся к жесту размеры, подсветку боковых стен и площадь каждой затронутой комнаты.
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Multi-wall junctions no longer cut long sections out of neighbouring walls: masonry stays continuous even beside a short node ray.
|
||||
- Resize preserves shared-wall roles and thicknesses, enabled handles actually move, and unavailable walls now expose a specific human-readable reason.
|
||||
- Nearly axial walls and invisible coordinate noise are repaired predictably: Walls stores an exact axis, while Optimize plans and the common write barrier converge older values onto the canonical grid.
|
||||
- “Optimize plans” removes proven-redundant hidden geometry without touching unfinished work and preserves shared-to-outer wall role boundaries.
|
||||
- Resize preserves neighbouring wall-thickness records and rejects ambiguous changes before they can damage geometry.
|
||||
- Resize mode shows only gesture-relevant dimensions, side-wall highlights and the area of every affected room.
|
||||
- Small fixes and improvements.
|
||||
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.67.0-rc.1/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.67.0-rc.1/docs/CHANGELOG.md)
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.67.0-rc.2/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.67.0-rc.2/docs/CHANGELOG.md)
|
||||
|
||||
@@ -136,12 +136,45 @@ wall degradation or second geometry reconstruction. Failure, Esc,
|
||||
`pointercancel`, `lostpointercapture`, pinch and tool exit discard the overlay
|
||||
with zero Undo entries and zero writes.
|
||||
|
||||
## Live measurements
|
||||
|
||||
During an accepted drag, Resize labels only the two side walls whose clear
|
||||
lengths change. Each length has a matching accent highlight on that exact wall;
|
||||
the moving wall no longer repeats its own length under the pointer.
|
||||
|
||||
The clean-floor area of every affected room is anchored beside its side of the
|
||||
moving wall. An outer wall therefore shows one area and an exactly shared wall
|
||||
shows two, on opposite sides. A short leader keeps ownership explicit. A label
|
||||
is never hidden or clipped merely because the room is narrow: it may leave the
|
||||
room outline, but two labels may not overlap.
|
||||
|
||||
`src/resize-labels.ts` performs placement from the accepted preview, current
|
||||
view and cached stage size. It accounts for the zoom-dependent room-settings
|
||||
button and shifts a conflicting area label along the wall. The production
|
||||
pointer path performs no DOM measurement; browser smoke compares the actual
|
||||
post-render rectangles at default and non-default zoom.
|
||||
|
||||
## Thickness, virtual spans and openings
|
||||
|
||||
`rekeyWallsAfterMove()` and `rekeyOpenSpansAfterMove()` map the immutable
|
||||
`rekeyWallsAfterMoveChecked()` and `rekeyOpenSpansAfterMove()` map the immutable
|
||||
snapshot to the fixed-topology candidate. Physical centimetre values and open
|
||||
span count must survive; the production geometry check is fail-closed.
|
||||
|
||||
Safe Resize uses endpoint correspondence, not the historical affine mapping.
|
||||
Every breakpoint follows a moving wall by one rigid translation. On a side wall
|
||||
whose length changes, only the old topology endpoint moves to its paired new
|
||||
vertex; an interior thickness breakpoint stays on its physical boundary rather
|
||||
than keeping a proportional fraction of the new edge. Unrelated exact records
|
||||
remain byte-equivalent. The candidate then proves that every new exact record
|
||||
is lattice-safe and continuously covered by room-wall carriers. An
|
||||
unchanged historical endpoint may remain readable even when its record changes
|
||||
around it, but Resize cannot add or replace it with a different violation.
|
||||
Key-only legacy records move only when their key identifies one whole changed
|
||||
edge with one destination. An affected partial/ambiguous midpoint returns an
|
||||
explicit rejected result; preview, history and persistence remain untouched.
|
||||
The generic array-only helper retains its old affine fallback outside Safe
|
||||
Resize for compatibility with historical pure transforms.
|
||||
|
||||
When the two owners of a shared moving seam split one physically continuous
|
||||
side-wall record at their meeting point, the mapped atoms are joined back only
|
||||
if their endpoints still meet exactly and their directions remain collinear.
|
||||
@@ -189,10 +222,15 @@ building the preview frame itself.
|
||||
blocks a zero-range handle, Optimize safely rehosts its windows and removes
|
||||
the blocker, then the same production Resize gesture changes exactly two
|
||||
rooms;
|
||||
- `test/optimize-hidden-obstacles.test.mjs`: a real plan's composite hidden
|
||||
independent walls and wholly redundant saved chain are removed by explicit
|
||||
Optimize, while partial/free residuals remain and the post-Optimize Resize
|
||||
audit loses only the proven `duplicate-physical-wall` blockers (#296);
|
||||
- `demo/benchmark_safe_resize.mjs`: same-run pointer and cached pointerup budgets;
|
||||
- `demo/benchmark_safe_resize_render.mjs`: warm 20-room/80-handle layer p95
|
||||
and exactly one geometry snapshot per rendered frame;
|
||||
- mutation gate: eligibility, third-room, topology, side ownership, jamb,
|
||||
fixed-topology wall endpoint mapping,
|
||||
pointer displacement/capture, shared-seam coalescing, preview rejection and
|
||||
commit-preflight bypass mutants.
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@ change must pass through a published beta/RC before stable. Stable release
|
||||
commits are promotion-only (versions, generated bundles and release/changelog
|
||||
metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
|
||||
## Snapshot (2026-08-24)
|
||||
## Snapshot (2026-08-25)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.67.0-rc.1 candidate** everywhere (manifest, const.py, package.json, CARD_VERSION) — HACS-discoverable continuation of the reviewed beta.10 candidate after beta.9 |
|
||||
| Current local cycle | v1.67.0-beta.9 is published. Current `dev` preserves neighbouring masonry beyond short multi-wall rays (#288), prevents Resize from changing shared/outer wall roles (#289), straightens only confirmed near-axis defects (#290), canonicalizes invisible coordinate noise at every write boundary (#291), audits every real-plan Resize handle with human-readable blockers (#292), and restores the active real pointer gesture plus Undo (#293). v1.67.0-rc.1 packages these reviewed S8 changes under the HACS-discoverable identifier required after beta.9 and remains gated by the full exact-SHA Validate before publication. |
|
||||
| Version | **v1.67.0-rc.2 candidate** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| Current local cycle | v1.67.0-rc.1 is published. Current `dev` makes `Esc` detach an accepted Walls chain without deleting it (#294), removes only proven-redundant hidden wall geometry during Optimize (#296), preserves neighbouring thickness records through Resize (#298), keeps wall-thickness records split at shared/outer role boundaries (#299), and limits Resize labels, highlights and room-area readouts to the geometry affected by the gesture (#300). v1.67.0-rc.2 packages these S8 changes. The next candidate also keeps the complete wall-axis/endpoint overlay identical across every Plan tool (#304) after normal issue review. |
|
||||
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
|
||||
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
|
||||
@@ -55,6 +55,17 @@
|
||||
`resize-preview-reject-silent` и
|
||||
`safe-resize-commit-preflight-bypassed` обязаны красить соответствующие
|
||||
unit/production smoke guards.
|
||||
- [ ] Fixed-topology wall records (#298): moving-wall breakpoints translate
|
||||
rigidly, side-wall interior endpoints never scale proportionally,
|
||||
the exact first-floor 49→52 gesture ends on 17/52/57/101, unrelated
|
||||
records remain byte-equivalent, and a full-span carrier/lattice proof
|
||||
rejects gaps before preview. Key-only legacy records move only by one
|
||||
whole-edge identity; partial midpoint ambiguity produces no preview,
|
||||
history or config write [unit: `wall-thickness.test.mjs`; auto:
|
||||
`smoke_resize_pointer_real_plan`, `smoke_resize_wall_thickness`, six
|
||||
`smoke_edit_walk` runs; mutation:
|
||||
`safe-resize-wall-endpoints-affine-scaled`,
|
||||
`safe-resize-legacy-midpoint-fail-open`].
|
||||
|
||||
## Decor composition order (#231)
|
||||
|
||||
@@ -512,6 +523,10 @@ directory's README.
|
||||
- [ ] Opening binding/registry-less/lock-action smokes preserve the secure
|
||||
no-toggle-on-plan invariant while checking compact Light/Dark
|
||||
locked/unlocked/unknown shell/core presentation.
|
||||
- [ ] `node demo/smoke_opening_entity_search.mjs` checks the real opening
|
||||
dialog: contact and lock search by friendly name/entity ID, preserved
|
||||
contact priority, visible IDs, persistent **none** option and unchanged
|
||||
`opening.contact`/`opening.lock` storage.
|
||||
- [ ] Unit presentation coverage compares marker LQI colour with the shared
|
||||
continuous `lqiColor()` across former 40/41 and 179/180 boundaries; bands
|
||||
remain semantic metadata only.
|
||||
@@ -2043,6 +2058,14 @@ require hands on real hardware — they remain for the human pass.
|
||||
become partly shared and partly outer
|
||||
[unit: resize.test + fixture 289-mixed-role-resize; auto:
|
||||
smoke_room_resize; mutation: safe-resize-side-ownership-bypassed]
|
||||
- [ ] Wall compaction preserves physical ownership (#299): equal thickness on
|
||||
`shared(A,B) -> outer(A)` and `shared(A,B) -> shared(A,C)` remains split
|
||||
at the exact role breakpoint, while equal neighbouring atoms inside one
|
||||
role still compact. Optimize on `real-plan-first-floor.json` is immutable,
|
||||
invariant-clean and idempotent; real-plan edit-walk seeds 1 and 3 exercise
|
||||
Optimize and Delete-room/Keep-walls without producing a mixed-role record
|
||||
[unit: wall-thickness + plan-optimizer; auto: smoke_edit_walk seeds 1/3;
|
||||
mutation: wall-compaction-owner-role-bypassed]
|
||||
- [ ] Live badges while dragging: lengths of the dragged wall + both
|
||||
adjacent walls, and the m² area at the room centre; dragging a shared
|
||||
wall shows BOTH areas; all numbers update continuously
|
||||
@@ -2767,6 +2790,13 @@ require hands on real hardware — they remain for the human pass.
|
||||
the whole wall; click opens the cm/in field; empty/0 clears; Esc closes
|
||||
without applying; «Apply to all walls of this room» fills every allowed
|
||||
edge [auto: smoke_wall_thickness]
|
||||
- [ ] **Hover width follows wall centimetres (#303)**: on a 30 cm cell a
|
||||
50 cm wall's hover fill matches the masonry within 2%; a zero-thickness
|
||||
wall keeps the same physical visual minimum across cell sizes, while the
|
||||
pointer still hits at five grid pitches from the axis
|
||||
[unit: grid-scale.test.mjs; auto: smoke_wallthick_hover_width;
|
||||
mutations: wallthick-hover-floor-back,
|
||||
wallthick-zero-strip-not-visual, wallthick-hit-narrowed]
|
||||
- [ ] **Hatched body, clean-floor area**: after setting thickness a `.wallbody`
|
||||
path appears; room-card and tooltip m² both decrease to the same inner-
|
||||
contour area
|
||||
|
||||
@@ -204,7 +204,7 @@ objects; each space keeps its local View viewport.
|
||||
| Zoom and pan | Wheel; drag empty space; `−`/`+` | Pinch; drag; double-tap resets kiosk | Available but precision is not guaranteed | — |
|
||||
| Change space | Click a tab | Tap; kiosk swipe at 1:1 | Tap a tab | — |
|
||||
| Device | Click/double-click per mode | Tap; safe actions equal desktop | Drag/properties are best effort | `Esc` closes the top surface |
|
||||
| Draw or precise drag | Full contract | Not applicable | Best effort; use desktop for Resize and exact nodes | `Shift` changes magnet/angle; `Esc` cancels the operation |
|
||||
| Walls drawing or precise drag | Full contract | Not applicable | Best effort; use desktop for Resize and exact nodes | `Shift` changes magnet/angle; `Esc` finishes a Walls chain or cancels the current precise drag |
|
||||
| Editor history | Undo/Redo controls | Not applicable | Controls may work; no gesture guarantee | `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` |
|
||||
| Kiosk sizing | — | Hold empty space for 3 seconds | — | — |
|
||||
|
||||
@@ -213,8 +213,10 @@ Any manual kiosk operation pauses auto-cycle for 60 seconds.
|
||||
|
||||
### Cancel and undo
|
||||
|
||||
- `Esc` cancels an unfinished path, current drag/resize/rotation, or the top
|
||||
dialog without undoing an already committed action.
|
||||
- `Esc` finishes an active Walls chain without deleting its accepted segments.
|
||||
In Split and other tools it cancels the unfinished path, current
|
||||
drag/resize/rotation, or the top dialog without undoing an already committed
|
||||
action.
|
||||
- `Ctrl/Cmd+Z` undoes an editor command; redo is `Ctrl/Cmd+Shift+Z` or `Ctrl+Y`.
|
||||
- Undo/Redo stores up to 50 named commands for Plan and Background.
|
||||
|
||||
@@ -298,6 +300,11 @@ House Plan offers them from smallest to largest. Save creates that room, Keep
|
||||
as walls rejects only that candidate, and Cancel restores the whole draft with
|
||||
no partial rooms.
|
||||
|
||||
While drawing an open chain, `Esc` finishes all accepted segments as ordinary
|
||||
independent walls and keeps **Walls** selected; the next click starts a new
|
||||
chain. `Ctrl/Cmd+Z` instead removes the last accepted point and segment. Pan,
|
||||
pinch and `pointercancel` neither finish the chain nor add geometry.
|
||||
|
||||
Existing segment endpoints and lines appear above walls while drawing. An
|
||||
endpoint grows when the next click will join it. A point on a line shows where
|
||||
the click will create a valid junction.
|
||||
@@ -323,9 +330,9 @@ Other operations edit existing geometry:
|
||||
|---|---|
|
||||
| Merge | Joins adjacent rooms; a dialog chooses the surviving identity, name and area |
|
||||
| Split | Cuts a room from one wall to another; the larger part keeps the original room |
|
||||
| Resize | Moves one eligible horizontal/vertical wall without changing room topology. Live labels report **inner** dimensions — the clear distance between wall faces, the number a tape measure gives — and the area of each affected room |
|
||||
| Resize | Moves one eligible horizontal/vertical wall without changing room topology. Live labels report the two changing **inner** side-wall dimensions, highlight those walls, and place each affected room's area beside its side of the moving wall |
|
||||
| Thickness | Changes one physical span or every wall of a room |
|
||||
| Delete room | Deletes only the selected room after confirmation |
|
||||
| Delete room | Deletes the room after choosing whether its exclusive physical walls remain; shared walls always remain |
|
||||
|
||||

|
||||
|
||||
@@ -352,6 +359,9 @@ It also stops where extending or shortening an adjacent wall would turn shared
|
||||
material into outer material (or the reverse), so one saved thickness never
|
||||
silently serves both roles. If neither direction has even one safe grid step,
|
||||
the handle explains that only part of a shared wall cannot be moved.
|
||||
Resize also preserves every unrelated wall exactly: changing the length of a
|
||||
neighbouring wall cannot shift a thickness boundary to an invented off-grid
|
||||
point. An ambiguous candidate is rejected instead of damaging another wall.
|
||||
Partial shared walls, diagonal walls and walls overlapped by an independent
|
||||
partition/draft/column keep a dimmed handle with an explanatory tooltip and
|
||||
cannot start a drag. The former corner scale frame was removed. An ordinary
|
||||
@@ -359,6 +369,11 @@ opening on the moving wall follows it once; a side-wall opening stops the
|
||||
moving masonry at its physical jamb. Release creates one Undo step, while Esc
|
||||
or an interrupted pointer writes nothing.
|
||||
|
||||
During the drag, the moving wall itself has no redundant length badge. An outer
|
||||
wall shows one area badge; a shared wall shows two on opposite sides, each with
|
||||
a short leader. In a narrow room the area stays visible and may extend outside
|
||||
the room rather than overlap another area or the room-settings button.
|
||||
|
||||
### HA area binding
|
||||
|
||||
One HA area may be bound to one room. The binding drives automatic device
|
||||
@@ -672,7 +687,10 @@ pull intentional off-grid or diagonal geometry to a node. Current ordinary
|
||||
edits apply the same invisible boundary automatically, so the noise cannot
|
||||
return after a later room, opening, decor or marker-position save.
|
||||
|
||||
Equal neighbouring wall-thickness fragments are compacted. Optimize may also
|
||||
Equal neighbouring wall-thickness fragments are compacted only while they have
|
||||
the same physical role: one outer room or the same pair of shared rooms. A
|
||||
shared-to-outer transition or a change of shared-room pair stays as an exact
|
||||
breakpoint even when the thickness is equal. Optimize may also
|
||||
remove a different-thickness fragment shorter than half a grid step when equal
|
||||
pieces of the same straight wall prove the replacement. This includes a
|
||||
fragment touching exactly one room T-junction: the junction and perpendicular
|
||||
@@ -687,14 +705,24 @@ change is canceled before Undo history or server storage is touched and the
|
||||
card reports that the wall geometry could not be built safely. Titles, colours,
|
||||
markers and other non-geometry settings remain editable.
|
||||
|
||||
When an old plan contains an independent wall exactly on top of one complete
|
||||
solid boundary shared by two rooms, Optimize can replace the duplicate with
|
||||
the single room wall. Doors, windows and gates hosted by that independent wall
|
||||
stay in place and keep their contact/lock settings. The resulting thickness is
|
||||
the wider original thickness, so the visible masonry does not shrink during
|
||||
maintenance; use **Thickness** afterwards to choose a new value or **Boundary**
|
||||
to make the shared wall virtual. Partial, composite and ambiguous overlaps are
|
||||
left unchanged for manual correction.
|
||||
When an old plan contains an independent wall exactly on top of solid room
|
||||
masonry, Optimize can absorb each proven covered section even when consecutive
|
||||
room-wall intervals form the cover. Free or ambiguous residual sections remain
|
||||
independent walls with stable identities. Doors, windows and gates stay in
|
||||
place: each is reattached to the room wall or to the retained residual that
|
||||
still hosts it. The resulting thickness is the wider original thickness, so
|
||||
visible masonry does not shrink. A saved unfinished wall chain is removed only
|
||||
when every one of its segments is fully redundant; a free, partly covered or
|
||||
thicker chain remains byte-for-byte unchanged. The report counts absorbed
|
||||
independent-wall sections and removed whole chains, not source records.
|
||||
|
||||
Every Plan editor tool draws room, independent-wall and saved-chain centre axes
|
||||
and endpoint nodes through the same layer above wall bodies. An independent
|
||||
wall or saved chain hidden under other masonry additionally retains its source
|
||||
diagnostic axis and nodes. These pointer-transparent layers do not change
|
||||
snapping or selection and are absent outside the Plan editor. The diagnostic
|
||||
disappears after Apply only when the corresponding independent geometry was
|
||||
safely absorbed or removed.
|
||||
|
||||
Old positions are classified before Apply. A position whose room label, device
|
||||
or light-group owner is proven absent is removed automatically and counted by a
|
||||
@@ -728,6 +756,12 @@ assigns new internal IDs and adds the copy without replacing global settings.
|
||||
When that exact import map matches orphan references already present in the
|
||||
target, the preview reports them and Apply restores them together with the
|
||||
space.
|
||||
Re-importing a copy still creates an independent space, but no longer grows
|
||||
nested service prefixes in internal IDs. Preview and **Add space** use the same
|
||||
prepared candidate. **Import reference details** reports links updated inside
|
||||
the copy and in the existing plan. If more than one target is possible, House
|
||||
Plan preserves the reference instead of guessing and recommends running
|
||||
**Optimize plans** after the import.
|
||||
Internal uploaded files are not embedded in JSON; an import to another HA
|
||||
instance must explicitly detach those links.
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ desktop: для точного рисования, Resize, модификато
|
||||
| Масштаб и панорама | Колесо; drag пустого места; кнопки `−`/`+` | Щипок; drag; двойной тап сбрасывает киоск | Работает, но точность не гарантируется | — |
|
||||
| Переключение пространства | Клик по вкладке | Клик; в киоске свайп при масштабе 1:1 | Клик по вкладке | — |
|
||||
| Устройство | Клик/двойной клик по правилам режима | Tap; безопасные действия как на desktop | Drag и свойства — best effort | `Esc` закрывает верхнюю поверхность |
|
||||
| Рисование и точный drag | Полный контракт | Не применяется | Best effort; для Resize и точных точек используйте мышь | `Shift` меняет магнит/угол; `Esc` отменяет операцию |
|
||||
| Рисование стен и точный drag | Полный контракт | Не применяется | Best effort; для Resize и точных точек используйте мышь | `Shift` меняет магнит/угол; `Esc` завершает цепочку стен или отменяет текущий точный drag |
|
||||
| История редактора | Кнопки Undo/Redo | Не применяется | Кнопки могут работать, жест не гарантирован | `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` |
|
||||
| Киоск-размеры | — | Удерживать пустое место 3 секунды | — | — |
|
||||
|
||||
@@ -238,8 +238,9 @@ desktop: для точного рисования, Resize, модификато
|
||||
|
||||
| Контекст | `Esc` | `Ctrl+Z` / `Cmd+Z` | `Ctrl+Shift+Z` / `Ctrl+Y` |
|
||||
|---|---|---|---|
|
||||
| Незавершённый Draw/Split | Убирает последнюю точку или выходит из инструмента | Сначала убирает незавершённую точку | — |
|
||||
| Любой инструмент редактора плана | Отменяет текущий жест/выбор | Отменяет последнюю именованную геометрическую операцию | Повторяет отменённую операцию |
|
||||
| Активная цепочка «Стены» | Завершает принятые отрезки как независимые стены, оставляя инструмент активным | Убирает последнюю принятую точку и отрезок | — |
|
||||
| Незавершённый Split | Убирает последнюю точку или выходит из инструмента | Сначала убирает незавершённую точку | — |
|
||||
| Любой другой инструмент редактора плана | Отменяет текущий жест/выбор | Отменяет последнюю именованную геометрическую операцию | Повторяет отменённую операцию |
|
||||
| Подложка | Отменяет незавершённое рисование или возвращает состояние до начала активного move/resize/rotate | Отменяет последнюю именованную операцию декора или картинки | Повторяет отменённую операцию |
|
||||
| Текстовое поле | Закрывает верхний диалог | Обычная отмена текста браузером | Обычный повтор текста браузером |
|
||||
|
||||
@@ -409,9 +410,12 @@ T-соединение входит в проходящую стену без в
|
||||
Отмена ничего не записывает, подтверждённое исправление можно отменить обычным
|
||||
Undo оптимизации.
|
||||
|
||||
Каждый законченный отрезок цепочки сохраняется сразу. Esc/Ctrl+Z удаляет
|
||||
последнюю точку. Pan, pinch и `pointercancel` ничего не завершают и не добавляют.
|
||||
После перезагрузки сохранённый draft можно продолжить кликом по его концу.
|
||||
Каждый законченный отрезок цепочки сохраняется сразу. `Esc` завершает принятые
|
||||
отрезки как независимые стены, оставляет инструмент **Стены** активным и
|
||||
отцепляется от последней точки; следующий клик начинает новую цепочку.
|
||||
`Ctrl/Cmd+Z` вместо этого удаляет последнюю точку и отрезок. Pan, pinch и
|
||||
`pointercancel` ничего не завершают и не добавляют. После перезагрузки
|
||||
сохранённый draft можно продолжить кликом по его концу.
|
||||
|
||||
### Связь с HA-зоной
|
||||
|
||||
@@ -434,7 +438,7 @@ Undo оптимизации.
|
||||
|---|---|
|
||||
| Объединить | Склеивает соседние комнаты; в диалоге выбирается сохраняемая идентичность, имя и HA-зона |
|
||||
| Split | Делит комнату путём от одной стены до другой; большая часть сохраняет исходную комнату |
|
||||
| Resize | Двигает одну допустимую горизонтальную/вертикальную стену без изменения топологии комнат. Подписи показывают **внутренние** размеры — расстояние между гранями стен, которое даёт рулетка, — и площадь каждой затронутой комнаты |
|
||||
| Resize | Двигает одну допустимую горизонтальную/вертикальную стену без изменения топологии комнат. Подписи показывают два меняющихся **внутренних** размера боковых стен, подсвечивают эти стены и ставят площадь каждой затронутой комнаты с её стороны перемещаемой стены |
|
||||
| Толщина | Задаёт толщину выбранному участку или всем стенам комнаты |
|
||||
| Удалить комнату | Открывает выбор: удалить комнату и оставить её физические стены либо удалить комнату вместе с ними; общие стены остаются всегда |
|
||||
|
||||
@@ -443,7 +447,9 @@ Undo оптимизации.
|
||||
толщиной, а их двери/окна перепривязываются к ним. Виртуальные и нулевые участки
|
||||
не превращаются в кладку. При варианте **«Удалить комнату и стены»** удаляются
|
||||
только проёмы, принадлежавшие эксклюзивной стене этой комнаты; общая стена,
|
||||
явная совпадающая перегородка и их проёмы сохраняются.
|
||||
явная совпадающая перегородка и их проёмы сохраняются. Оставшиеся записи
|
||||
толщины нормализуются уже по новому составу комнат: одинаковая толщина не
|
||||
склеивается через переход «общая ↔ наружная» или между разными парами комнат.
|
||||
|
||||

|
||||
|
||||
@@ -482,6 +488,14 @@ Resize показывает живые длины и чистую площадь
|
||||
стены, не вставляет вершины на частичной общей границе и не меняет больше двух
|
||||
комнат. Завершённый drag становится одним именованным шагом общего
|
||||
50-командного Undo/Redo; Esc, pointercancel и потеря захвата ничего не записывают.
|
||||
Границы толщины соседних стен при этом остаются на своих реальных точках сетки:
|
||||
Resize не пересчитывает их пропорционально новой длине. Неоднозначный результат
|
||||
отклоняется целиком, а не повреждает стену в другом месте плана.
|
||||
|
||||
У самой перемещаемой стены больше нет дублирующей подписи длины. У внешней
|
||||
стены показывается одна площадь, у общей — две на противоположных сторонах, с
|
||||
короткими выносными линиями. В узкой комнате площадь остаётся видимой и может
|
||||
выйти за контур, но не перекрывает вторую площадь или кнопку настроек комнаты.
|
||||
|
||||
Прямая стена, нарисованная в несколько кликов, сохраняется одной перегородкой:
|
||||
соседние отрезки одинаковой толщины и направления срастаются сразу после
|
||||
@@ -614,6 +628,11 @@ YAML-сущность без `unique_id` и строки в Entity Registry: п
|
||||
передаёт её точное состояние, она так же управляет проёмом и значком замка в
|
||||
Просмотре. Явно отключённая, потерянная или отсутствующая сущность не работает.
|
||||
|
||||
В списках контактных датчиков и замков нажмите поле выбора и введите часть
|
||||
отображаемого имени либо `entity_id`. Под каждым результатом показан полный
|
||||
идентификатор. Пункт **«— нет —»** всегда остаётся первым и очищает привязку;
|
||||
поиск не меняет исходный приоритет дверных и оконных датчиков.
|
||||
|
||||
### Поведение в разных режимах
|
||||
|
||||
| Режим | Клик по проёму | Перетаскивание |
|
||||
@@ -1415,8 +1434,8 @@ show_signal: true
|
||||
| Декор и мебель | Положение и размеры округляются к сетке |
|
||||
| Устройства и подписи комнат | Позиции округляются к сетке |
|
||||
| Проёмы | Возвращаются на ближайшую стену, смещение вдоль стены округляется, угол исправляется |
|
||||
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, если с обеих сторон находятся участки одной толщины той же прямой стены. Допустим один T-узел комнаты: он и перпендикулярная стена не двигаются. Два топологических узла или граница проёма защищают участок |
|
||||
| Перегородки | Коллинеарные соседние отрезки одинаковой толщины сращиваются в одну запись; узел сохраняется, если в нём сходится третья стена, стена комнаты, колонна или конец черновика. Если отдельная перегородка точно endpoint-to-endpoint совпадает с одной сплошной общей стеной двух комнат, она преобразуется в эту стену, а размещённые на ней проёмы перепривязываются без сдвига и потери датчиков. Сохраняется большая исходная толщина. Частичные и неоднозначные совпадения не меняются |
|
||||
| Стены | Одинаковые соседние участки толщины объединяются только в одной физической роли: наружная стена одной комнаты либо общая стена той же пары комнат. Переход «общая ↔ наружная» и смена пары комнат остаются точными границами даже при одинаковой толщине. Изолированный участок другой толщины короче половины шага сетки также схлопывается, если с обеих сторон находятся участки одной толщины той же прямой стены. Допустим один T-узел комнаты: он и перпендикулярная стена не двигаются. Два топологических узла или граница проёма защищают участок |
|
||||
| Перегородки и сохранённые цепочки | Коллинеарные соседние отрезки одинаковой толщины сращиваются; топологический узел сохраняется. Каждый участок отдельной стены, точно покрытый одной или несколькими соседними сплошными стенами комнат, поглощается ими, а недоказанные остатки и их проёмы сохраняются. Проёмы на поглощённой части перепривязываются без сдвига и потери датчиков; сохраняется большая исходная толщина. Незавершённая цепочка удаляется только целиком и только если каждый её сегмент полностью избыточен; свободная, частично покрытая или более толстая цепочка не меняется |
|
||||
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
|
||||
| Ссылки устройств | Точная подпись независимого импорта восстанавливает пространство, комнату и позицию. Иначе реальное устройство следует однозначной Area HA либо теряет только мёртвую привязку; настройки маркера сохраняются |
|
||||
| Забытые позиции | Позиции доказанно отсутствующих подписей комнат, устройств и групповых меток удаляются и считаются по понятным категориям |
|
||||
@@ -1479,6 +1498,15 @@ show_signal: true
|
||||
обычным способом, а общую стену — сделать виртуальной. До явного подтверждения
|
||||
предпросмотра и для неполного/неоднозначного совпадения ничего не меняется.
|
||||
|
||||
Отчёт считает поглощённые максимальные участки независимых стен, а не число
|
||||
исходных записей, и отдельно — только целиком удалённые сохранённые цепочки.
|
||||
Во всех инструментах редактора Плана оси и конечные узлы комнат, независимых
|
||||
стен и сохранённых цепочек рисуются одним и тем же слоем поверх тел стен. Для
|
||||
независимой стены или цепочки, скрытой под другой кладкой, дополнительно
|
||||
сохраняется её исходная диагностическая ось и узлы. Эти слои не перехватывают
|
||||
указатель, не меняют привязку и отсутствуют вне редактора Плана; после Apply
|
||||
диагностика исчезает только вместе с доказанно избыточной геометрией.
|
||||
|
||||
<!-- docs-section: multiple-cards -->
|
||||
|
||||
## 20. Хранение, совместная работа и резервные копии
|
||||
@@ -1498,6 +1526,14 @@ show_signal: true
|
||||
предпросмотр показывает их количество, а применение восстанавливает их
|
||||
вместе с пространством.
|
||||
|
||||
Повторный импорт копии остаётся независимым, но не наращивает служебные
|
||||
префиксы внутренних ID. Предпросмотр и кнопка «Добавить» работают с одним и тем
|
||||
же подготовленным вариантом. В блоке **Подробности восстановления ссылок**
|
||||
видно, сколько ссылок обновлено внутри копии и в существующем плане. Если
|
||||
подходят несколько возможных целей, House Plan ничего не угадывает: ссылка
|
||||
сохраняется без изменений, диалог сообщает об этом и рекомендует после импорта
|
||||
запустить **Оптимизировать планы**.
|
||||
|
||||
Для текущего пространства можно включить **Только планировка**. Такой файл
|
||||
сохраняет комнаты, стены, проёмы, декор, трансформации подложки, позиции и
|
||||
выбранный масштаб подписей комнат, но удаляет реальные и виртуальные маркеры,
|
||||
|
||||