Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa696cf38 | ||
|
|
7fd0ae4b2d | ||
|
|
fd4bf0e95e | ||
|
|
0b782ee714 | ||
|
|
0d62a48b1e | ||
|
|
3a49b14274 | ||
|
|
0d9320fe1d | ||
|
|
55f7126afd | ||
|
|
bf52524de3 | ||
|
|
7d094fd2ea | ||
|
|
c684f178cb | ||
|
|
cbdc248987 | ||
|
|
34335d1e83 | ||
|
|
afb6e151e5 | ||
|
|
5c466b6c01 | ||
|
|
4c93f28e73 | ||
|
|
30698d6ec4 | ||
|
|
b1b8fb9fab | ||
|
|
89ac6024bc | ||
|
|
2e3874f10d | ||
|
|
0e6fbfb00b | ||
|
|
de0867b001 | ||
|
|
9ec3a77ffa | ||
|
|
8d41651b1e | ||
|
|
0d328f6c0c | ||
|
|
c8d56b6c4d | ||
|
|
f2460dc81d | ||
|
|
a3a75ccbf3 | ||
|
|
6c1534f170 | ||
|
|
e85bae70f0 | ||
|
|
101cf70952 | ||
|
|
787891c4e7 | ||
|
|
2ade43520b | ||
|
|
cee4718ffa | ||
|
|
f0ea376aa9 | ||
|
|
bbac40eb8e | ||
|
|
ff623cd49d | ||
|
|
dc68868bd8 | ||
|
|
1134345444 | ||
|
|
729e2d05af | ||
|
|
489f68eea9 | ||
|
|
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 | ||
|
|
8c03656412 | ||
|
|
fc81bd8a32 | ||
|
|
74d306b510 | ||
|
|
80dc5860ca | ||
|
|
04110bf741 | ||
|
|
55fed02d24 | ||
|
|
2fda6fa086 | ||
|
|
cd88633535 | ||
|
|
314b498e12 | ||
|
|
e03238ec1c | ||
|
|
474dbd62ca | ||
|
|
e1f0d2310a | ||
|
|
1d4bf9b018 | ||
|
|
0a52b6757e | ||
|
|
97acb0b415 | ||
|
|
e2c1dbec0d | ||
|
|
08dce8603d | ||
|
|
c4694acea9 | ||
|
|
6c5163c3c9 | ||
|
|
f2e8fe5e93 | ||
|
|
8750708c5b | ||
|
|
e6e80934e0 | ||
|
|
8eb351baa3 | ||
|
|
a4ef0e2088 | ||
|
|
3c754729f2 | ||
|
|
3f6338889e | ||
|
|
35ab1dc0c3 | ||
|
|
4ccf4a3ccc | ||
|
|
c8a3878568 | ||
|
|
0146d5787e | ||
|
|
feff436942 | ||
|
|
374081b490 | ||
|
|
e9bf11a8e3 | ||
|
|
66193c5862 | ||
|
|
894a4e5803 | ||
|
|
1d220628ff | ||
|
|
52c74301f9 | ||
|
|
01512081db | ||
|
|
1245beaaf6 | ||
|
|
d33ae88da4 | ||
|
|
f6cb957bba | ||
|
|
f44df5d05d | ||
|
|
6983f2b088 | ||
|
|
f6cf2c93ae | ||
|
|
edf8b068ed | ||
|
|
6551c5d53d | ||
|
|
e102f34156 | ||
|
|
7fd9ca0f6a | ||
|
|
6e1007a363 | ||
|
|
10fa0dc7fb | ||
|
|
f2c0721a9d | ||
|
|
e8a4db7972 | ||
|
|
406e46ecb1 | ||
|
|
4201644d33 | ||
|
|
17086d3cf2 | ||
|
|
74fdf2182c | ||
|
|
2d8ade3b2b | ||
|
|
6f67d53665 | ||
|
|
e333f0b2f1 | ||
|
|
f472af88a3 | ||
|
|
a7618151cc | ||
|
|
10ca913fad | ||
|
|
849b3e5f76 | ||
|
|
bca3bfd52c | ||
|
|
2ad20dca2b | ||
|
|
0bca6dfb14 | ||
|
|
8877e8c6d6 | ||
|
|
6a4e665d33 | ||
|
|
5272287671 | ||
|
|
c14dcecb98 | ||
|
|
523190d8f4 | ||
|
|
d5659478aa | ||
|
|
c654e0ec42 | ||
|
|
d8696315eb | ||
|
|
ed606c0f4a | ||
|
|
89658c20fe | ||
|
|
a988f7c6f1 | ||
|
|
d73790aaab | ||
|
|
07bd1a6781 | ||
|
|
0963f5b4dd | ||
|
|
a3e5f30a3c | ||
|
|
38ea8a9b9e | ||
|
|
93fad8cfb4 | ||
|
|
c0c30d645a | ||
|
|
cd9916d72a | ||
|
|
8aa7fc970c | ||
|
|
bb81ede47c | ||
|
|
79285ab1d2 | ||
|
|
c123757f50 | ||
|
|
495043791e | ||
|
|
598e7437f2 | ||
|
|
df1a8330f4 | ||
|
|
8156f80140 | ||
|
|
930a911a36 | ||
|
|
4dd074d9a3 | ||
|
|
bdd4f38cb9 | ||
|
|
4412f905bd | ||
|
|
e68b494555 | ||
|
|
a548d4f17d | ||
|
|
9c5b66c3e4 | ||
|
|
448ceea399 | ||
|
|
241821c40e | ||
|
|
b02a37123d | ||
|
|
03b45f2e37 | ||
|
|
a5a0f6da2c | ||
|
|
2e22ed7938 | ||
|
|
cd0a682ad7 | ||
|
|
6335b0e519 | ||
|
|
8b227962b1 | ||
|
|
1bbd53ee52 | ||
|
|
7be7f8cd99 | ||
|
|
9e35650470 | ||
|
|
29a0305558 | ||
|
|
ff2f2063cf | ||
|
|
b5519e997b | ||
|
|
82fe98bcd3 | ||
|
|
57fdfdbd83 | ||
|
|
496e79a6df | ||
|
|
434995beb2 | ||
|
|
a605611105 | ||
|
|
ec315ad7c1 | ||
|
|
cfc8f2114d | ||
|
|
d5a0452fb5 | ||
|
|
ca5166430b | ||
|
|
5c2c2578e1 | ||
|
|
a06c94b967 |
@@ -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-beta.7"
|
||||
VERSION = "1.67.0-rc.3"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Lossless, allow-listed canonicalisation of persisted geometry.
|
||||
|
||||
The frontend mirrors this module in src/coordinate-canonicalization.ts.
|
||||
Keep the precision, scalar formula and field allow-list in lockstep; a shared
|
||||
Keep the precision, lattice formula and field allow-list in lockstep; a shared
|
||||
fixture is exercised by both runtimes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -13,10 +13,12 @@ from typing import Any
|
||||
|
||||
COORDINATE_DECIMALS = 9
|
||||
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
|
||||
LATTICE_GRID_N = 240
|
||||
LATTICE_NOISE_STEPS = 1e-4
|
||||
|
||||
|
||||
def canonicalize_number(value: Any) -> Any:
|
||||
"""Return one stable IEEE-754 representation for an allow-listed number."""
|
||||
"""Return one stable IEEE-754 representation for an allow-listed scalar."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return value
|
||||
number = float(value)
|
||||
@@ -32,6 +34,22 @@ def canonicalize_number(value: Any) -> Any:
|
||||
return result
|
||||
|
||||
|
||||
def canonicalize_lattice_coordinate(value: Any) -> Any:
|
||||
"""Collapse near-node noise while preserving authored off-grid values."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return value
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
return value
|
||||
scaled = number * LATTICE_GRID_N
|
||||
# JavaScript Math.round: ties go toward +infinity, unlike Python round().
|
||||
nearest = math.floor(scaled + 0.5)
|
||||
if abs(scaled - nearest) < LATTICE_NOISE_STEPS:
|
||||
result = nearest / LATTICE_GRID_N
|
||||
return 0.0 if result == 0 else result
|
||||
return canonicalize_number(number)
|
||||
|
||||
|
||||
def _record(value: Any) -> dict[str, Any] | None:
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
@@ -42,37 +60,43 @@ def _records(value: Any) -> list[dict[str, Any]]:
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
|
||||
def _scalar_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
|
||||
for name in names:
|
||||
if name in record:
|
||||
record[name] = canonicalize_number(record[name])
|
||||
|
||||
|
||||
def _point(value: Any) -> None:
|
||||
def _lattice_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
|
||||
for name in names:
|
||||
if name in record:
|
||||
record[name] = canonicalize_lattice_coordinate(record[name])
|
||||
|
||||
|
||||
def _lattice_point(value: Any) -> None:
|
||||
if not isinstance(value, list):
|
||||
return
|
||||
for index in range(min(2, len(value))):
|
||||
value[index] = canonicalize_number(value[index])
|
||||
value[index] = canonicalize_lattice_coordinate(value[index])
|
||||
|
||||
|
||||
def _points(value: Any) -> None:
|
||||
def _lattice_points(value: Any) -> None:
|
||||
if not isinstance(value, list):
|
||||
return
|
||||
for point in value:
|
||||
_point(point)
|
||||
_lattice_point(point)
|
||||
|
||||
|
||||
def canonicalize_position(position: Any) -> Any:
|
||||
"""Canonicalise x/y in one layout record without touching its metadata."""
|
||||
"""Canonicalise lattice x/y in one layout record, preserving metadata."""
|
||||
result = copy.deepcopy(position)
|
||||
record = _record(result)
|
||||
if record is not None:
|
||||
_fields(record, ("x", "y"))
|
||||
_lattice_fields(record, ("x", "y"))
|
||||
return result
|
||||
|
||||
|
||||
def canonicalize_layout_geometry(layout: Any) -> Any:
|
||||
"""Canonicalise x/y in every layout record, preserving future fields."""
|
||||
"""Canonicalise lattice x/y in every layout record."""
|
||||
result = copy.deepcopy(layout)
|
||||
record = _record(result)
|
||||
if record is None:
|
||||
@@ -80,7 +104,7 @@ def canonicalize_layout_geometry(layout: Any) -> Any:
|
||||
for position in record.values():
|
||||
item = _record(position)
|
||||
if item is not None:
|
||||
_fields(item, ("x", "y"))
|
||||
_lattice_fields(item, ("x", "y"))
|
||||
return result
|
||||
|
||||
|
||||
@@ -92,7 +116,7 @@ def canonicalize_config_geometry(config: Any) -> Any:
|
||||
return result
|
||||
|
||||
for space in _records(root.get("spaces")):
|
||||
_fields(
|
||||
_scalar_fields(
|
||||
space,
|
||||
(
|
||||
"plan_x",
|
||||
@@ -105,45 +129,48 @@ def canonicalize_config_geometry(config: Any) -> Any:
|
||||
)
|
||||
|
||||
for room in _records(space.get("rooms")):
|
||||
_fields(room, ("x", "y", "w", "h"))
|
||||
_points(room.get("poly"))
|
||||
_lattice_fields(room, ("x", "y", "w", "h"))
|
||||
_lattice_points(room.get("poly"))
|
||||
|
||||
for wall in _records(space.get("walls")):
|
||||
_point(wall.get("a"))
|
||||
_point(wall.get("b"))
|
||||
_lattice_point(wall.get("a"))
|
||||
_lattice_point(wall.get("b"))
|
||||
|
||||
for opening in _records(space.get("openings")):
|
||||
_fields(opening, ("x", "y", "angle", "length"))
|
||||
_lattice_fields(opening, ("x", "y"))
|
||||
_scalar_fields(opening, ("angle", "length"))
|
||||
host = _record(opening.get("host"))
|
||||
if host is not None:
|
||||
_fields(host, ("t",))
|
||||
_scalar_fields(host, ("t",))
|
||||
|
||||
for decor in _records(space.get("decor")):
|
||||
kind = decor.get("kind")
|
||||
if kind == "line":
|
||||
_fields(decor, ("x1", "y1", "x2", "y2"))
|
||||
_lattice_fields(decor, ("x1", "y1", "x2", "y2"))
|
||||
elif kind in ("rect", "ellipse", "furniture"):
|
||||
_fields(decor, ("x", "y", "w", "h", "angle"))
|
||||
_lattice_fields(decor, ("x", "y", "w", "h"))
|
||||
_scalar_fields(decor, ("angle",))
|
||||
elif kind == "text":
|
||||
_fields(decor, ("x", "y", "scale", "angle"))
|
||||
_lattice_fields(decor, ("x", "y"))
|
||||
_scalar_fields(decor, ("scale", "angle"))
|
||||
|
||||
for draft in _records(space.get("room_drafts")):
|
||||
_points(draft.get("points"))
|
||||
_lattice_points(draft.get("points"))
|
||||
|
||||
for partition in _records(space.get("partitions")):
|
||||
_point(partition.get("a"))
|
||||
_point(partition.get("b"))
|
||||
_lattice_point(partition.get("a"))
|
||||
_lattice_point(partition.get("b"))
|
||||
|
||||
for column in _records(space.get("wall_columns")):
|
||||
_point(column.get("center"))
|
||||
_lattice_point(column.get("center"))
|
||||
if column.get("shape") == "square":
|
||||
_fields(column, ("angle",))
|
||||
_scalar_fields(column, ("angle",))
|
||||
|
||||
for span in _records(space.get("open_spans")):
|
||||
_point(span.get("a"))
|
||||
_point(span.get("b"))
|
||||
_lattice_point(span.get("a"))
|
||||
_lattice_point(span.get("b"))
|
||||
|
||||
for marker in _records(root.get("markers")):
|
||||
_fields(marker, ("angle",))
|
||||
_scalar_fields(marker, ("angle",))
|
||||
|
||||
return result
|
||||
|
||||
@@ -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-beta.7"
|
||||
"version": "1.67.0-rc.3"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
import math
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -80,10 +81,416 @@ class PartitionOpeningJambMarginError(ValueError):
|
||||
# sync with GRID_STEP_N/NORM_W in the frontend; it is a geometry scale, not a
|
||||
# user setting.
|
||||
NORMALIZED_CANVAS_CELLS = 240.0
|
||||
_DEFAULT_ROOM_WALL_CM = 15.0
|
||||
_OPTIMIZE_REHOST_EPSILON = 1e-8
|
||||
|
||||
|
||||
def _room_polygon(room: dict) -> list[list[float]]:
|
||||
poly = room.get("poly")
|
||||
if isinstance(poly, list) and len(poly) >= 3:
|
||||
return poly
|
||||
if all(key in room for key in ("x", "y", "w", "h")):
|
||||
x, y = float(room["x"]), float(room["y"])
|
||||
w, h = float(room["w"]), float(room["h"])
|
||||
return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]
|
||||
return []
|
||||
|
||||
|
||||
def _segment_metrics(a: list, b: list) -> tuple[float, float, float]:
|
||||
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
|
||||
return dx, dy, math.hypot(dx, dy)
|
||||
|
||||
|
||||
def _line_distance(point: list, a: list, b: list) -> float:
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return math.inf
|
||||
return abs((float(point[0]) - float(a[0])) * dy
|
||||
- (float(point[1]) - float(a[1])) * dx) / length
|
||||
|
||||
|
||||
def _projection(point: list, a: list, b: list) -> float:
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return math.inf
|
||||
return ((float(point[0]) - float(a[0])) * dx
|
||||
+ (float(point[1]) - float(a[1])) * dy) / length
|
||||
|
||||
|
||||
def _segment_covers(a: list, b: list, target_a: list, target_b: list) -> bool:
|
||||
"""Return whether one exact collinear segment covers the target."""
|
||||
_, _, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
if (_line_distance(target_a, a, b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(target_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
for point in (target_a, target_b):
|
||||
along = _projection(point, a, b)
|
||||
if along < -_OPTIMIZE_REHOST_EPSILON or along > length + _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _segments_overlap_on_axis(a: list, b: list, other_a: list, other_b: list) -> bool:
|
||||
if (_line_distance(other_a, a, b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(other_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
_, _, length = _segment_metrics(a, b)
|
||||
lo, hi = sorted((_projection(other_a, a, b), _projection(other_b, a, b)))
|
||||
return min(length, hi) - max(0.0, lo) > _OPTIMIZE_REHOST_EPSILON
|
||||
|
||||
|
||||
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 #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:
|
||||
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):
|
||||
return False
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
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}
|
||||
if old_stable != new_stable:
|
||||
return False
|
||||
try:
|
||||
t = float(old_host["t"])
|
||||
x, y = float(opening["x"]), float(opening["y"])
|
||||
opening_angle = float(opening["angle"])
|
||||
opening_length = float(opening["length"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if not (0 <= t <= 1 and opening_length > 0):
|
||||
return False
|
||||
expected_x, expected_y = float(a[0]) + dx * t, float(a[1]) + dy * t
|
||||
expected_angle = math.degrees(math.atan2(dy, dx))
|
||||
if expected_angle >= 90:
|
||||
expected_angle -= 180
|
||||
elif expected_angle < -90:
|
||||
expected_angle += 180
|
||||
if (abs(x - expected_x) > _OPTIMIZE_REHOST_EPSILON
|
||||
or abs(y - expected_y) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _angle_delta_mod_180(opening_angle, expected_angle) > 1e-7):
|
||||
return False
|
||||
along = t * length
|
||||
if (along - opening_length / 2 < -_OPTIMIZE_REHOST_EPSILON
|
||||
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
|
||||
try:
|
||||
center = [float(other["x"]), float(other["y"])]
|
||||
other_angle = float(other["angle"])
|
||||
other_length = float(other["length"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if (_line_distance(center, a, b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _angle_delta_mod_180(other_angle, expected_angle) > 1e-7):
|
||||
continue
|
||||
other_along = _projection(center, a, b)
|
||||
if (min(along + opening_length / 2, other_along + other_length / 2)
|
||||
- max(along - opening_length / 2, other_along - other_length / 2)
|
||||
> _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_partition_opening_hosts(
|
||||
config: dict, previous: dict | None = None
|
||||
config: dict, previous: dict | None = None, *, allow_optimize_rehost: bool = False
|
||||
) -> None:
|
||||
"""Validate hosted-opening write deltas without rejecting legacy reads.
|
||||
|
||||
@@ -114,9 +521,13 @@ def validate_partition_opening_hosts(
|
||||
opening_id = str(opening.get("id", ""))
|
||||
old = old_openings.get(opening_id)
|
||||
if old and old.get("host") is not None and opening.get("host") is None:
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space_id}; opening={opening_id}; host removed"
|
||||
)
|
||||
if not (allow_optimize_rehost and old_space
|
||||
and _safe_optimize_partition_rehost(
|
||||
space, old_space, opening, old
|
||||
)):
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space_id}; opening={opening_id}; host removed"
|
||||
)
|
||||
host = opening.get("host")
|
||||
if host is None:
|
||||
continue
|
||||
@@ -126,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
|
||||
@@ -156,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",
|
||||
})
|
||||
|
||||
@@ -1624,7 +1626,10 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
validate_marker_light_entities(msg["config"], config_data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], config_data.get("config"))
|
||||
validate_opening_passages(msg["config"], config_data.get("config"))
|
||||
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
|
||||
validate_partition_opening_hosts(
|
||||
msg["config"], config_data.get("config"),
|
||||
allow_optimize_rehost=True,
|
||||
)
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// #276: same-process incremental cost of coincident-partition reconciliation
|
||||
// inside the complete Optimize candidate pass on the deterministic large house.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
|
||||
const WARMUPS = 5;
|
||||
const SAMPLES = 20;
|
||||
const BATCH_SIZE = 10;
|
||||
const RELATIVE_OVERHEAD = 0.15;
|
||||
const ABSOLUTE_OVERHEAD_MS = 25;
|
||||
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const emptyReconciliation = (rawSpace, _model, walls) => ({
|
||||
walls: walls || [],
|
||||
partitions: Array.isArray(rawSpace?.partitions) ? rawSpace.partitions : [],
|
||||
openings: Array.isArray(rawSpace?.openings) ? rawSpace.openings : [],
|
||||
partitionsReconciled: 0,
|
||||
openingsRehosted: 0,
|
||||
});
|
||||
const baseline = () => optimizePlans(fixture.config, {}, {}, {
|
||||
reconcileCoincidentPartitions: emptyReconciliation,
|
||||
});
|
||||
const candidate = () => optimizePlans(fixture.config, {});
|
||||
const timed = (operation) => {
|
||||
const start = performance.now();
|
||||
for (let index = 0; index < BATCH_SIZE; index++) {
|
||||
const result = operation();
|
||||
if (!result || !result.report) throw new Error('Optimize candidate returned no report');
|
||||
}
|
||||
return (performance.now() - start) / BATCH_SIZE;
|
||||
};
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
baseline();
|
||||
candidate();
|
||||
}
|
||||
|
||||
const baselineTimes = [];
|
||||
const candidateTimes = [];
|
||||
const overheadTimes = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
// ABBA/BAAB cancels first-order clock drift and balances cache/GC order;
|
||||
// batching then averages timer noise before the paired p95 is computed.
|
||||
const candidateFirst = index % 2 === 1;
|
||||
const first = timed(candidateFirst ? candidate : baseline);
|
||||
const second = timed(candidateFirst ? baseline : candidate);
|
||||
const third = timed(candidateFirst ? baseline : candidate);
|
||||
const fourth = timed(candidateFirst ? candidate : baseline);
|
||||
const baselineMs = candidateFirst ? (second + third) / 2 : (first + fourth) / 2;
|
||||
const candidateMs = candidateFirst ? (first + fourth) / 2 : (second + third) / 2;
|
||||
baselineTimes.push(baselineMs);
|
||||
candidateTimes.push(candidateMs);
|
||||
overheadTimes.push(candidateMs - baselineMs);
|
||||
}
|
||||
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const summary = (values) => ({
|
||||
min: Math.min(...values),
|
||||
median: quantile(values, 0.5),
|
||||
p95: quantile(values, 0.95),
|
||||
max: Math.max(...values),
|
||||
});
|
||||
const baselineSummary = summary(baselineTimes);
|
||||
const candidateSummary = summary(candidateTimes);
|
||||
const overheadSummary = summary(overheadTimes);
|
||||
const measuredOverheadP95 = Math.max(0, overheadSummary.p95);
|
||||
const relativeP95 = measuredOverheadP95
|
||||
/ Math.max(baselineSummary.p95, Number.EPSILON);
|
||||
const pass = measuredOverheadP95 <= ABSOLUTE_OVERHEAD_MS
|
||||
&& relativeP95 <= RELATIVE_OVERHEAD;
|
||||
const report = {
|
||||
issue: 276,
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
warmups: WARMUPS,
|
||||
samples: SAMPLES,
|
||||
batchSize: BATCH_SIZE,
|
||||
baseline: baselineSummary,
|
||||
candidate: candidateSummary,
|
||||
pairedOverhead: overheadSummary,
|
||||
budgets: {
|
||||
relativeOverhead: RELATIVE_OVERHEAD,
|
||||
absoluteOverheadMs: ABSOLUTE_OVERHEAD_MS,
|
||||
},
|
||||
measured: { overheadP95Ms: measuredOverheadP95, relativeP95 },
|
||||
pass,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
// #291: same-process p95 of the complete config+layout boundary versus the
|
||||
// pre-existing full-candidate clone contract. Batching makes the strict 20%
|
||||
// ratio meaningful even on coarse/loaded CI timers.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
import {
|
||||
canonicalizeConfigGeometry, canonicalizeLayoutGeometry,
|
||||
} from '../test-build/coordinate-canonicalization.js';
|
||||
|
||||
const WARMUPS = 30;
|
||||
const SAMPLES = 120;
|
||||
const BATCH = 10;
|
||||
const MAX_RATIO = 1.2;
|
||||
const fixture = makeLargeHouseFixture();
|
||||
|
||||
const baseline = () => {
|
||||
JSON.parse(JSON.stringify(fixture.config));
|
||||
JSON.parse(JSON.stringify(fixture.layout || {}));
|
||||
};
|
||||
const candidate = () => {
|
||||
canonicalizeConfigGeometry(fixture.config);
|
||||
canonicalizeLayoutGeometry(fixture.layout || {});
|
||||
};
|
||||
const measure = (operation) => {
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < BATCH; index++) operation();
|
||||
return (performance.now() - started) / BATCH;
|
||||
};
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
baseline();
|
||||
candidate();
|
||||
}
|
||||
const baselineSamples = [];
|
||||
const candidateSamples = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
if (index % 2) {
|
||||
candidateSamples.push(measure(candidate));
|
||||
baselineSamples.push(measure(baseline));
|
||||
} else {
|
||||
baselineSamples.push(measure(baseline));
|
||||
candidateSamples.push(measure(candidate));
|
||||
}
|
||||
}
|
||||
const p95 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.95) - 1];
|
||||
const baselineP95 = p95(baselineSamples);
|
||||
const candidateP95 = p95(candidateSamples);
|
||||
const ratio = candidateP95 / baselineP95;
|
||||
const report = {
|
||||
fixture: 'large-house-v1', samples: SAMPLES, batch: BATCH,
|
||||
baselineP95Ms: baselineP95, candidateP95Ms: candidateP95,
|
||||
ratio, limit: MAX_RATIO, pass: ratio <= MAX_RATIO,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!report.pass) process.exitCode = 1;
|
||||
@@ -0,0 +1,107 @@
|
||||
// #277: same-run historical edge-drag baseline versus the fixed-topology
|
||||
// pointer clamp, plus the exact one-space production preflight used on release.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import {
|
||||
planEdgeDrag, clampEdgeDrag,
|
||||
resolveSafeResize, clampSafeResize, applySafeResize,
|
||||
safeResizeCachedDeltaCount,
|
||||
} from '../test-build/resize.js';
|
||||
import { checkOptimizeGeometry } from '../test-build/plan-geometry-preflight.js';
|
||||
|
||||
const WARMUPS = 5;
|
||||
const SAMPLES = 20;
|
||||
const BATCH = 25;
|
||||
const POINTER_P95_MS = 16;
|
||||
const POINTER_RATIO = 1.2;
|
||||
const POINTER_NOISE_MS = 0.25;
|
||||
const PREFLIGHT_P95_MS = 75;
|
||||
|
||||
const rooms = [{ id: 'active', poly: [[0, 0], [300, 0], [300, 300], [0, 300]] }];
|
||||
for (let index = 0; index < 199; index++) {
|
||||
const x = 1000 + (index % 20) * 500;
|
||||
const y = Math.floor(index / 20) * 500;
|
||||
rooms.push({ id: `room-${index}`, poly: [[x, y], [x + 300, y], [x + 300, y + 300], [x, y + 300]] });
|
||||
}
|
||||
const opts = { minDim: 25, eps: 0.1, movingHalf: 10, obstacles: [] };
|
||||
const oldPlan = planEdgeDrag(rooms, 'active', 1);
|
||||
const safeResolution = resolveSafeResize(rooms, [], 'active', 1, opts);
|
||||
if (!oldPlan || !safeResolution.enabled) throw new Error('benchmark fixture is not resize-eligible');
|
||||
const safePlan = safeResolution.plan;
|
||||
|
||||
const baselinePointer = () => clampEdgeDrag(rooms, [], oldPlan, 100, 5, opts);
|
||||
const safePointer = () => {
|
||||
const delta = clampSafeResize(rooms, [], safePlan, 100, 5, opts);
|
||||
return applySafeResize(rooms, [], safePlan, delta);
|
||||
};
|
||||
const large = makeLargeHouseFixture();
|
||||
const currentSpaceConfig = { ...large.config, spaces: [large.config.spaces[0]] };
|
||||
const precomputeStart = performance.now();
|
||||
const cachedProductionGeometry = checkOptimizeGeometry(currentSpaceConfig);
|
||||
const renderPrecomputeMs = performance.now() - precomputeStart;
|
||||
if (!cachedProductionGeometry.ok
|
||||
|| cachedProductionGeometry.spaces.some((space) => space.status === 'failed')) {
|
||||
throw new Error(`safe-resize preflight fixture failed: ${JSON.stringify(cachedProductionGeometry.spaces)}`);
|
||||
}
|
||||
// The final preview frame owns the expensive production union. pointerup reads
|
||||
// the exact cached result for that cfg epoch; this measures commit latency, not
|
||||
// the existing large-house render budget measured by benchmark:large-house.
|
||||
const preflight = () => {
|
||||
if (!cachedProductionGeometry.ok) throw new Error('cached production geometry failed');
|
||||
return cachedProductionGeometry.fingerprint;
|
||||
};
|
||||
|
||||
const timedBatch = (operation) => {
|
||||
const start = performance.now();
|
||||
for (let index = 0; index < BATCH; index++) operation();
|
||||
return (performance.now() - start) / BATCH;
|
||||
};
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
baselinePointer(); safePointer(); preflight();
|
||||
}
|
||||
const baselineTimes = [];
|
||||
const safeTimes = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
const safeFirst = index % 2 === 1;
|
||||
const first = timedBatch(safeFirst ? safePointer : baselinePointer);
|
||||
const second = timedBatch(safeFirst ? baselinePointer : safePointer);
|
||||
const third = timedBatch(safeFirst ? baselinePointer : safePointer);
|
||||
const fourth = timedBatch(safeFirst ? safePointer : baselinePointer);
|
||||
baselineTimes.push(safeFirst ? (second + third) / 2 : (first + fourth) / 2);
|
||||
safeTimes.push(safeFirst ? (first + fourth) / 2 : (second + third) / 2);
|
||||
}
|
||||
const preflightTimes = Array.from({ length: SAMPLES }, () => {
|
||||
const start = performance.now(); preflight(); return performance.now() - start;
|
||||
});
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const summary = (values) => ({
|
||||
min: Math.min(...values), median: quantile(values, 0.5),
|
||||
p95: quantile(values, 0.95), max: Math.max(...values),
|
||||
});
|
||||
const baseline = summary(baselineTimes);
|
||||
const candidate = summary(safeTimes);
|
||||
const commitPreflight = summary(preflightTimes);
|
||||
const relativeLimit = baseline.p95 * POINTER_RATIO + POINTER_NOISE_MS;
|
||||
const pass = candidate.p95 <= POINTER_P95_MS
|
||||
&& candidate.p95 <= relativeLimit
|
||||
&& commitPreflight.p95 <= PREFLIGHT_P95_MS
|
||||
&& safeResizeCachedDeltaCount(safePlan) <= 4096;
|
||||
console.log(JSON.stringify({
|
||||
issue: 277,
|
||||
fixture: { rooms: rooms.length, largeHouse: LARGE_HOUSE_COUNTS },
|
||||
warmups: WARMUPS, samples: SAMPLES, batch: BATCH,
|
||||
baseline, candidate, commitPreflight,
|
||||
renderPrecomputeMs,
|
||||
cacheEntries: safeResizeCachedDeltaCount(safePlan),
|
||||
budgets: {
|
||||
pointerP95Ms: POINTER_P95_MS, pointerRatio: POINTER_RATIO,
|
||||
pointerNoiseMs: POINTER_NOISE_MS, relativeLimit,
|
||||
commitPreflightP95Ms: PREFLIGHT_P95_MS, maxCacheEntries: 4096,
|
||||
},
|
||||
pass,
|
||||
}, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -0,0 +1,78 @@
|
||||
// #277: warm Resize-layer render cost on the supported large-house ceiling.
|
||||
// The deterministic snapshot-call assertion catches the original regression
|
||||
// even when runner timing noise happens to keep the p95 below its budget.
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import { launch } from './serve.mjs';
|
||||
|
||||
const WARMUPS = 3;
|
||||
const SAMPLES = 20;
|
||||
const RENDER_P95_MS = 25;
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const config = { ...fixture.config, spaces: [fixture.config.spaces[0]] };
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const result = await page.evaluate(async ({ config, warmups, samples }) => {
|
||||
const card = window.__card;
|
||||
card._serverCfg = structuredClone(config);
|
||||
card._space = config.spaces[0].id;
|
||||
card._modelCache = null;
|
||||
card._cfgEpoch++;
|
||||
card._setMode('plan');
|
||||
card._tool = 'resize';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
|
||||
const view = card._viewOr(card._baseVb());
|
||||
const originalSnapshot = card._rszSnapshot.bind(card);
|
||||
let snapshotCalls = 0;
|
||||
card._rszSnapshot = () => {
|
||||
snapshotCalls++;
|
||||
return originalSnapshot();
|
||||
};
|
||||
|
||||
for (let index = 0; index < warmups; index++) card._renderResizeLayer(view);
|
||||
snapshotCalls = 0;
|
||||
const times = [];
|
||||
for (let index = 0; index < samples; index++) {
|
||||
const started = performance.now();
|
||||
card._renderResizeLayer(view);
|
||||
times.push(performance.now() - started);
|
||||
}
|
||||
card._rszSnapshot = originalSnapshot;
|
||||
return {
|
||||
times,
|
||||
snapshotCalls,
|
||||
roomCount: card._rszRooms().length,
|
||||
handleCount: card._rszRooms().reduce((sum, room) => sum + room.poly.length, 0),
|
||||
};
|
||||
}, { config, warmups: WARMUPS, samples: SAMPLES });
|
||||
|
||||
await browser.close();
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const render = {
|
||||
min: Math.min(...result.times),
|
||||
median: quantile(result.times, 0.5),
|
||||
p95: quantile(result.times, 0.95),
|
||||
max: Math.max(...result.times),
|
||||
};
|
||||
const pass = result.snapshotCalls === SAMPLES
|
||||
&& result.roomCount === 20
|
||||
&& result.handleCount === 80
|
||||
&& render.p95 <= RENDER_P95_MS;
|
||||
console.log(JSON.stringify({
|
||||
issue: 277,
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
warmups: WARMUPS,
|
||||
samples: SAMPLES,
|
||||
roomCount: result.roomCount,
|
||||
handleCount: result.handleCount,
|
||||
snapshotCalls: result.snapshotCalls,
|
||||
snapshotCallsPerFrame: result.snapshotCalls / SAMPLES,
|
||||
render,
|
||||
budgets: { renderP95Ms: RENDER_P95_MS, maxSnapshotCallsPerFrame: 1 },
|
||||
pass,
|
||||
}, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -0,0 +1,118 @@
|
||||
// #278: component projection overhead and degraded-fixture completion budget.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import { prepareSpacePhysicalGeometryInputs } from '../test-build/plan-geometry-preflight.js';
|
||||
import { spaceModels } from '../test-build/space-geometry.js';
|
||||
import {
|
||||
polyclipToPathD, wallBodiesGeometry, wallBodiesUnionPath,
|
||||
} from '../test-build/wall-thickness.js';
|
||||
|
||||
const WARMUPS = 3;
|
||||
const SAMPLES = 50;
|
||||
// One projection is sub-millisecond. Time a batch and report per-operation
|
||||
// cost so scheduler/timer quantisation cannot dominate the 10% relative gate.
|
||||
const VALID_BATCH = 100;
|
||||
const RELATIVE_RATIO = 1.1;
|
||||
const OVERHEAD_P95_MS = 20;
|
||||
const DEGRADED_P95_MS = 100;
|
||||
|
||||
const prepare = (config) => {
|
||||
const models = spaceModels(config);
|
||||
return config.spaces.map((space, index) =>
|
||||
prepareSpacePhysicalGeometryInputs(space, models[index]));
|
||||
};
|
||||
const args = (input) => [
|
||||
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
|
||||
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
|
||||
input.physicalBodies,
|
||||
];
|
||||
const large = makeLargeHouseFixture();
|
||||
const validInputs = prepare(large.config);
|
||||
const degradedFixture = JSON.parse(readFileSync(
|
||||
new URL('../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const degradedInput = prepare(degradedFixture.config)[0];
|
||||
|
||||
const validResults = validInputs.map((input) => wallBodiesGeometry(...args(input)));
|
||||
if (validResults.some((result) => result.status !== 'ok'))
|
||||
throw new Error(`valid geometry: ${validResults.map((result) => result.status).join(',')}`);
|
||||
const validGeometry = () => {
|
||||
for (const result of validResults) {
|
||||
// The previous production projection also serialized primary + paper.
|
||||
if (!polyclipToPathD(result.geom) || !polyclipToPathD(result.paperGeom))
|
||||
throw new Error('valid legacy projection');
|
||||
}
|
||||
};
|
||||
const validProjection = () => {
|
||||
for (const result of validResults) {
|
||||
const paths = result.components.map((component) => polyclipToPathD(component.geom));
|
||||
const paper = polyclipToPathD(result.paperGeom);
|
||||
if (!paths.length || paths.some((path) => !path) || !paper)
|
||||
throw new Error('valid component projection');
|
||||
}
|
||||
};
|
||||
const degradedProjection = () => {
|
||||
const result = wallBodiesUnionPath(...args(degradedInput));
|
||||
if (!result || result.status !== 'degraded-extra' || result.paths.length !== 2)
|
||||
throw new Error(`degraded projection: ${result?.status || 'null'}`);
|
||||
};
|
||||
const timed = (operation, iterations = 1) => {
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < iterations; index++) operation();
|
||||
return performance.now() - started;
|
||||
};
|
||||
const run = (operation) => {
|
||||
for (let index = 0; index < WARMUPS; index++) operation();
|
||||
return Array.from({ length: SAMPLES }, () => timed(operation));
|
||||
};
|
||||
const runPairs = () => {
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
validGeometry();
|
||||
validProjection();
|
||||
}
|
||||
const baseline = [], candidate = [], overhead = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
let baseMs, candidateMs;
|
||||
if (index % 2 === 0) {
|
||||
baseMs = timed(validGeometry, VALID_BATCH) / VALID_BATCH;
|
||||
candidateMs = timed(validProjection, VALID_BATCH) / VALID_BATCH;
|
||||
} else {
|
||||
candidateMs = timed(validProjection, VALID_BATCH) / VALID_BATCH;
|
||||
baseMs = timed(validGeometry, VALID_BATCH) / VALID_BATCH;
|
||||
}
|
||||
baseline.push(baseMs);
|
||||
candidate.push(candidateMs);
|
||||
overhead.push(candidateMs - baseMs);
|
||||
}
|
||||
return { baseline, candidate, overhead };
|
||||
};
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const summary = (values) => ({
|
||||
min: Math.min(...values), median: quantile(values, 0.5),
|
||||
p95: quantile(values, 0.95), max: Math.max(...values),
|
||||
});
|
||||
|
||||
const pairs = runPairs();
|
||||
const baseline = summary(pairs.baseline);
|
||||
const candidate = summary(pairs.candidate);
|
||||
const degraded = summary(run(degradedProjection));
|
||||
const relativeLimit = baseline.p95 * RELATIVE_RATIO;
|
||||
const overheadP95 = quantile(pairs.overhead, 0.95);
|
||||
const pass = candidate.p95 <= relativeLimit && overheadP95 <= OVERHEAD_P95_MS
|
||||
&& degraded.p95 <= DEGRADED_P95_MS;
|
||||
console.log(JSON.stringify({
|
||||
issue: 278, fixture: LARGE_HOUSE_COUNTS, warmups: WARMUPS, samples: SAMPLES,
|
||||
validBatch: VALID_BATCH,
|
||||
baseline, candidate, degraded, overheadP95,
|
||||
budgets: {
|
||||
relativeRatio: RELATIVE_RATIO, overheadP95Ms: OVERHEAD_P95_MS,
|
||||
relativeLimitP95Ms: relativeLimit, degradedP95Ms: DEGRADED_P95_MS,
|
||||
},
|
||||
pass,
|
||||
}, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -3,6 +3,8 @@
|
||||
* visual-regression tooling. Nothing here depends on a real HA installation.
|
||||
*/
|
||||
|
||||
import { fixtureWallKey } from './wall-key.mjs';
|
||||
|
||||
const FLOOR_COUNT = 3;
|
||||
const ROOMS_PER_FLOOR = 20;
|
||||
const DEVICE_COUNT = 200;
|
||||
@@ -213,8 +215,14 @@ export const makeLargeHouseFixture = () => {
|
||||
cell_cm: 5,
|
||||
settings: { fill_mode: 'glow', show_borders: true, show_names: true },
|
||||
rooms,
|
||||
walls: segments.map((wall, index) => ({
|
||||
key: `perf-wall-${floor}-${index}`, cm: 15, a: wall.a, b: wall.b,
|
||||
// The key must be the real one. A label like `perf-wall-0-3` does not
|
||||
// parse as coordinates, so neither the exact match nor the tolerant
|
||||
// fallback in lookupWall finds the record: every solid edge of this
|
||||
// fixture resolved to zero thickness and the plan carried no wall bodies
|
||||
// at all, while the same fixture backs four golden scenes and all six
|
||||
// performance budgets (#260).
|
||||
walls: segments.map((wall) => ({
|
||||
key: fixtureWallKey(wall.a, wall.b), cm: 15, a: wall.a, b: wall.b,
|
||||
})),
|
||||
openings: makeOpenings(floor, segments, openingCount),
|
||||
partitions: makePartitions(floor, rooms, partitionCount),
|
||||
|
||||
@@ -4,22 +4,13 @@ const round = (value) => Number(value.toFixed(6));
|
||||
|
||||
// Golden fixtures must use the same persisted wall-key contract as real plan
|
||||
// data. Arbitrary labels make every configured wall look virtual to the
|
||||
// renderer, which lets a visually ineffective baseline pass unnoticed.
|
||||
const WALL_KEY_PITCH = 1 / 240;
|
||||
export const fixtureWallKey = (a, b) => {
|
||||
const quantize = (value) => Math.round(value / WALL_KEY_PITCH) * WALL_KEY_PITCH;
|
||||
const mx = quantize((a[0] + b[0]) / 2);
|
||||
const my = quantize((a[1] + b[1]) / 2);
|
||||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 1e-12) { dx = 1; dy = 0; }
|
||||
else { dx /= length; dy /= length; }
|
||||
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
|
||||
let angle = Math.atan2(dy, dx);
|
||||
if (angle < 0) angle += Math.PI;
|
||||
const bucket = Math.round(angle * 1800) / 1800;
|
||||
return `${mx.toFixed(4)},${my.toFixed(4)}@${bucket.toFixed(4)}`;
|
||||
};
|
||||
// renderer, which lets a visually ineffective baseline pass unnoticed — and a
|
||||
// key that merely differs in precision is found only through the tolerant
|
||||
// fallback in lookupWall, which is luck rather than contract (#260). The
|
||||
// formula lives in one place for every fixture and is pinned to the product one
|
||||
// by test/fixture-wall-key.test.mjs.
|
||||
export { fixtureWallKey } from './wall-key.mjs';
|
||||
import { fixtureWallKey } from './wall-key.mjs';
|
||||
|
||||
const uniqueEdges = (rooms) => {
|
||||
const edges = new Map();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Ключ записи толщины стены — один на все фикстуры проекта.
|
||||
*
|
||||
* Копия формулы из `src/wall-thickness.ts`, и копия здесь неизбежна. Фикстуры
|
||||
* обязаны оставаться без внешних импортов: бэкенд-гейт запускает их как
|
||||
* `node --input-type=module --eval "import * as f from './demo/fixtures/…'"`
|
||||
* в job без `npm ci` и без `test-build/` (`.github/workflows/validate.yml`,
|
||||
* job `backend`), а `scripts/source-fingerprint.mjs` хеширует только `src/**`
|
||||
* и `.mjs` из `demo/fixtures` и `demo/golden` — код, втянутый из `scripts/`,
|
||||
* менял бы поведение фикстуры при неизменном отпечатке, на котором стоят и
|
||||
* валидность golden-эталонов, и переиспользование гейтов.
|
||||
*
|
||||
* Поэтому файл лежит ЗДЕСЬ, внутри `demo/fixtures`: так он попадает в
|
||||
* отпечаток, и так его видит одна привязка вместо трёх копий формулы.
|
||||
* `test/fixture-wall-key.test.mjs` сверяет его с продуктовым `wallKey`.
|
||||
*
|
||||
* Ловушка, из-за которой этот файл и появился (#260): точность зависит от шага.
|
||||
* При `pitch = 1/240` продукт печатает ШЕСТЬ знаков, а не четыре — фикстура с
|
||||
* четырьмя расходилась с продуктом на каждой записи и находилась только через
|
||||
* терпимый запас `lookupWall`. Метка вместо ключа (`perf-wall-0-3`) не
|
||||
* находилась вовсе: все сплошные рёбра оставались с нулевой толщиной.
|
||||
*/
|
||||
|
||||
/** Шаг решётки редактора в нормализованных координатах (`GRID_N = 240`). */
|
||||
export const WALL_KEY_PITCH = 1 / 240;
|
||||
|
||||
/** Направление стены по модулю 180°: стена одна и та же с любого конца. */
|
||||
const direction = (a, b) => {
|
||||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 1e-12) return [1, 0];
|
||||
dx /= length; dy /= length;
|
||||
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) return [-dx, -dy];
|
||||
return [dx, dy];
|
||||
};
|
||||
|
||||
/**
|
||||
* Координата, отличающаяся от узла решётки не больше точности хранения, — это
|
||||
* тот же узел (#258). Канонизация опознания, а не снап геометрии: произвольная
|
||||
* точка вне решётки остаётся вне решётки. Без этого шага ничья округления на
|
||||
* стене нечётной длины в шагах разводила один и тот же ключ на два.
|
||||
*/
|
||||
const keyEpsilon = (pitch) => Math.max(Math.abs(pitch) * 1e-6, 1e-9);
|
||||
const canonical = (value, pitch) => {
|
||||
if (!(pitch > 0) || !Number.isFinite(value)) return value;
|
||||
const snapped = Math.round(value / pitch) * pitch;
|
||||
return Math.abs(snapped - value) <= keyEpsilon(pitch) ? snapped : value;
|
||||
};
|
||||
|
||||
export const fixtureWallKey = (a, b, pitch = WALL_KEY_PITCH) => {
|
||||
const quantise = (value) => (pitch > 0 && Number.isFinite(value)
|
||||
? Math.round(value / pitch) * pitch : value);
|
||||
const ca = [canonical(a[0], pitch), canonical(a[1], pitch)];
|
||||
const cb = [canonical(b[0], pitch), canonical(b[1], pitch)];
|
||||
const mx = quantise((ca[0] + cb[0]) / 2);
|
||||
const my = quantise((ca[1] + cb[1]) / 2);
|
||||
const [dx, dy] = direction(ca, cb);
|
||||
let angle = Math.atan2(dy, dx);
|
||||
if (angle < 0) angle += Math.PI;
|
||||
const bucket = Math.round(angle * 1800) / 1800;
|
||||
const precision = pitch > 0 && pitch < 0.01 ? 6 : pitch < 1 ? 4 : 2;
|
||||
return `${mx.toFixed(precision)},${my.toFixed(precision)}@${bucket.toFixed(4)}`;
|
||||
};
|
||||
|
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,63 +1,73 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 41,
|
||||
"acceptedAt": "2026-08-23T23:38:00.874Z",
|
||||
"sourceFingerprint": "af1824af6205db2cc22c067d7d406998166849eee896a0cd159977d7f76d1235",
|
||||
"matrixVersion": 46,
|
||||
"acceptedAt": "2026-08-25T20:12:50.448Z",
|
||||
"sourceFingerprint": "e28606975682347f90f12971053825ce474085cbddd56082e867370b1039d14d",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"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": "893dba2066a14c5c636b8ee08c03ba8229a2f53ae656756c68be3b3037dcdf0c",
|
||||
"isometric-geometry-view-light": "70829538ba4cf62ba45f8bbefc5dce35b5e9afdea4675dd7857284c716385780",
|
||||
"isometric-geometry-view-dark": "43ad006844a78b502b36aeeb82d82669532e06d2c798de928a5207acd24bd5f9",
|
||||
"isometric-geometry-view-light": "0fcb10ffdc0baa9006d1c2f0406ebf19e71ba9feda180979a51b74166d7200d2",
|
||||
"isometric-live-layers-dark": "8aaa914fc45780f67dd5c008a106696d21235a9acee7402f9cc8ae51a26660f3",
|
||||
"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",
|
||||
"room-label-parity-view-dark": "fa19d34b1a44093af124542194b6aaca66640b795daba4a2657a7f48bb18b94b",
|
||||
"room-label-parity-plan-dark": "cf6427a4310be09e92a83d77c976a9543b83d095132f374f24b99f1f22456aaf",
|
||||
"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": "66074416944e8b6f37b1d9afd89d417b1fb3d351819fbc67aa619ca230a64da5",
|
||||
"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",
|
||||
"space-tab-drop-before-light": "86d8159ac6191980f2ac91b137c3947219da45fef81bc2b358442958eda9d0f8",
|
||||
"space-tab-drop-after-dark": "e5a77296c14ebbef95fd434739abd20d8089db7a311242aaea5a0f242baf000c",
|
||||
"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",
|
||||
"multiwall-junction-bevel-view-dark": "d31c7455fcde451714c7387e1b8cbe1d89536a6affa5eb8ff379f2da81d5f441",
|
||||
"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": "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",
|
||||
@@ -82,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",
|
||||
@@ -91,19 +117,22 @@
|
||||
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
|
||||
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
|
||||
"device-help-popover-light-ru": "add3275d74ced358a184ec9f864c83735f1196e2bff7d75fb3e12160cb85bfaf",
|
||||
"decor-color-popover-mobile-ru": "fcdd138e396654e3517927c27d2062663c61860e8b9903471fa44651a4cbdefd",
|
||||
"decor-color-popover-desktop-en": "0a33f2a4ad6614bccbce45fbda1eaa505551e479b1eaada28ddcaf435a83f74f",
|
||||
"decor-color-popover-mobile-ru": "51725859afc6a1e2d1b2ae8be270ab584608901623ef7e2d283b6b195644fd16",
|
||||
"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": "e31f7709e60f1f6488a703b9089cd44e158691f3808a2be48323772c27e4847d",
|
||||
"backup-space-preview-mobile-ru": "698e81b952a26e50b60d2ec85321c43035ff4376a5a348eeed39726804cead78",
|
||||
"optimize-preflight-dialog-dark-en": "835ec4af63c8d06e3dee07e123b64a857442a71d8a64216bba9b8c72db6c56ad",
|
||||
"optimize-preflight-dialog-light-ru": "c747271ba6de5ecf8e5262772535787a9c567cee5c171567c73c53484d724b71",
|
||||
"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"
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0",
|
||||
"junction-309-step-dark": "f5e0e649ee19197026b87687fd3512b18536883f071348589ec3a7f0ea2a216f",
|
||||
"junction-309-spike-dark": "98e709aa2fa58a01fe280635c6a3799e3a5f4158b2ada12c31c38818f92c4f44",
|
||||
"junction-309-hump-dark": "abae96fd96ed8515d7b1e33b84858bdf08ac28a888898549874baa8c98202e7a"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 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: 80 KiB After Width: | Height: | Size: 80 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 |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 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: 311 KiB After Width: | Height: | Size: 311 KiB |
|
Before Width: | Height: | Size: 325 KiB After Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 431 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 |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -2,6 +2,12 @@ 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 junctionTeethFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/309-junction-teeth.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const junctionPatchFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/197-junction-patch.json', import.meta.url), 'utf8',
|
||||
));
|
||||
@@ -14,6 +20,12 @@ const orthogonalStripFixture = JSON.parse(readFileSync(
|
||||
const wallKeyRoundtripFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/258-wall-key-roundtrip.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const coincidentPartitionFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/276-coincident-partition.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const wallUnionIsolationFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
|
||||
));
|
||||
|
||||
const fixtureFor = (scenario) => scenario.fixture === 'large'
|
||||
? makeLargeHouseFixture()
|
||||
@@ -67,6 +79,40 @@ async function stableEnvironment(page, scenario) {
|
||||
/** Apply every data-only scenario override before the fixture crosses into the browser. */
|
||||
export function prepareGoldenFixture(scenario) {
|
||||
const fixture = fixtureFor(scenario);
|
||||
if (scenario.coincidentPartition) {
|
||||
const state = scenario.coincidentPartition;
|
||||
if (!['before', 'thin', 'thick', 'virtual'].includes(state))
|
||||
throw new Error(`unknown coincidentPartition state: ${state}`);
|
||||
const space = structuredClone(coincidentPartitionFixture.spaces[0]);
|
||||
space.id = scenario.space;
|
||||
space.title = `Coincident partition ${state}`;
|
||||
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;
|
||||
const opening = space.openings[0];
|
||||
delete opening.host;
|
||||
opening.x = 0.504166667;
|
||||
opening.y = 0.5;
|
||||
opening.angle = -90;
|
||||
if (state === 'virtual') {
|
||||
space.open_spans = [{
|
||||
a: [0.5041666666666667, 0.004166666666666667],
|
||||
b: [0.5041666666666667, 0.9958333333333333],
|
||||
}];
|
||||
}
|
||||
}
|
||||
fixture.config.spaces.push(space);
|
||||
}
|
||||
if (scenario.cornerSplitWall) {
|
||||
const stage = scenario.cornerSplitWall;
|
||||
if (!['before', 'thin', 'thick', 'zero-taper'].includes(stage))
|
||||
@@ -153,6 +199,117 @@ export function prepareGoldenFixture(scenario) {
|
||||
wall_columns: [],
|
||||
});
|
||||
}
|
||||
if (scenario.safeResizeFixture) {
|
||||
const edge = (a, b, cm = 15) => ({
|
||||
key: fixtureWallKey(a, b), a: [...a], b: [...b], cm,
|
||||
});
|
||||
const l0 = [0.08, 0.12], lm0 = [0.50, 0.12], lm1 = [0.50, 0.55], l1 = [0.08, 0.55];
|
||||
const r0 = [0.92, 0.12], r1 = [0.92, 0.55];
|
||||
const d0 = [0.35, 0.70], d1 = [0.50, 0.62], d2 = [0.65, 0.70], d3 = [0.50, 0.82];
|
||||
fixture.config.spaces.push({
|
||||
id: scenario.space,
|
||||
title: 'Safe Resize contract',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 1,
|
||||
settings: { fill_mode: 'none', show_borders: true, show_names: false },
|
||||
rooms: [
|
||||
{ id: 'resize-left', name: 'Resizable A', area: null, poly: [l0, lm0, lm1, l1] },
|
||||
{ id: 'resize-right', name: 'Resizable B', area: null, poly: [lm0, r0, r1, lm1] },
|
||||
{ id: 'resize-diagonal', name: 'Disabled diagonal', area: null, poly: [d0, d1, d2, d3] },
|
||||
],
|
||||
walls: [
|
||||
edge(l0, lm0), edge(lm0, r0), edge(r0, r1), edge(r1, lm1),
|
||||
edge(lm1, l1), edge(l1, l0), edge(lm0, lm1),
|
||||
edge(d0, d1), edge(d1, d2), edge(d2, d3), edge(d3, d0),
|
||||
],
|
||||
openings: [{
|
||||
id: 'resize-side-door', type: 'door', x: 0.38, y: 0.12,
|
||||
angle: 0, length: 0.08,
|
||||
}],
|
||||
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.junctionTeeth) {
|
||||
fixture.config.spaces.push({
|
||||
id: scenario.space,
|
||||
title: 'Junction teeth repro',
|
||||
cell_cm: junctionTeethFixture.cell_cm,
|
||||
partitions: structuredClone(junctionTeethFixture.partitions),
|
||||
rooms: [],
|
||||
view_box: [0.1, 0.65, 3.05, 1.75],
|
||||
settings: { fill_mode: 'none', show_borders: true, show_names: false },
|
||||
});
|
||||
}
|
||||
if (scenario.junctionPatchResilience) {
|
||||
if (!Array.isArray(scenario.retainedWedgeProbe)
|
||||
|| scenario.retainedWedgeProbe.length !== 2
|
||||
@@ -176,11 +333,22 @@ export function prepareGoldenFixture(scenario) {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (scenario.wallUnionIsolation) {
|
||||
const source = wallUnionIsolationFixture.config.spaces[0];
|
||||
fixture.config.spaces.push({
|
||||
...structuredClone(source),
|
||||
id: scenario.space,
|
||||
title: 'Wall union isolation',
|
||||
settings: {
|
||||
...(source.settings || {}), fill_mode: 'none', show_borders: true, show_names: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (scenario.multiWallJunction) {
|
||||
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}`);
|
||||
@@ -520,7 +688,8 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
}
|
||||
const host = document.getElementById('host');
|
||||
const cardConfig = {
|
||||
type: 'custom:houseplan-card', title: `Golden ${scenario.id}`, icon_size: 3.4,
|
||||
type: 'custom:houseplan-card', title: `Golden ${scenario.id}`,
|
||||
icon_size: Number.isFinite(scenario.iconSize) ? scenario.iconSize : 3.4,
|
||||
language: scenario.language || 'en',
|
||||
...(scenario.kiosk ? { kiosk: true } : {}),
|
||||
};
|
||||
@@ -592,12 +761,64 @@ 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"]')];
|
||||
// The stored #278 fixture deliberately contains near-lattice noise so
|
||||
// its unit test can exercise degraded-extra isolation. In the mounted
|
||||
// product, #291's write barrier canonicalizes that noise before this
|
||||
// integration frame: the same masonry must now unite normally.
|
||||
if (result?.status !== 'ok' || result.paths?.length !== 1
|
||||
|| paths.length !== 1
|
||||
|| new Set(paths.map((path) => path.dataset.component)).size !== 1) {
|
||||
throw new Error(`golden wall-union isolation contract failed: ${scenario.id}; `
|
||||
+ `status=${result?.status || 'missing'}; geometryPaths=${result?.paths?.length ?? 'missing'}; `
|
||||
+ `domPaths=${paths.length}; domComponents=${new Set(paths.map((path) => path.dataset.component)).size}`);
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -750,7 +971,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;
|
||||
}
|
||||
@@ -808,6 +1030,48 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
throw new Error(`golden plan snap candidate did not render: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (scenario.safeResizePreview) {
|
||||
card._tool = 'resize';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const handles = [...card.renderRoot.querySelectorAll('.rszhandle')];
|
||||
const enabled = handles.filter((handle) => handle.getAttribute('aria-disabled') === 'false');
|
||||
const disabled = handles.filter((handle) => handle.getAttribute('aria-disabled') === 'true');
|
||||
const handle = enabled.find((entry) =>
|
||||
Math.abs(+entry.getAttribute('cx') - 500) < 0.5
|
||||
&& Math.abs(+entry.getAttribute('cy') - 335) < 0.5);
|
||||
if (!handle || !disabled.length || disabled.some((entry) => !entry.querySelector('title'))) {
|
||||
throw new Error(`golden safe Resize handle matrix is incomplete: ${scenario.id}`);
|
||||
}
|
||||
const svgRoot = card.renderRoot.querySelector('.stage svg');
|
||||
const point = (x, y) => new DOMPoint(x, y).matrixTransform(svgRoot.getScreenCTM());
|
||||
const from = point(+handle.getAttribute('cx'), +handle.getAttribute('cy'));
|
||||
const wanted = point(250, +handle.getAttribute('cy'));
|
||||
const send = (type, at) => handle.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: 277,
|
||||
pointerType: 'mouse', button: 0, isPrimary: true,
|
||||
clientX: at.x, clientY: at.y,
|
||||
}));
|
||||
send('pointerdown', from);
|
||||
send('pointermove', wanted);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const delta = Math.abs(card._rszDrag?.d || 0);
|
||||
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;
|
||||
if (!['window', 'door', 'passage', 'gate'].includes(type)
|
||||
@@ -1053,6 +1317,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 = 41;
|
||||
export const GOLDEN_MATRIX_VERSION = 46;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -165,6 +165,26 @@ 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 },
|
||||
{ id: 'coincident-partition-thin-dark', fixture: 'visual',
|
||||
space: 'golden-coincident-partition', coincidentPartition: 'thin',
|
||||
mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'coincident-partition-thick-dark', fixture: 'visual',
|
||||
space: 'golden-coincident-partition', coincidentPartition: 'thick',
|
||||
mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'coincident-partition-virtual-dark', fixture: 'visual',
|
||||
space: 'golden-coincident-partition', coincidentPartition: 'virtual',
|
||||
mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'split-corner-wall-before-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
cornerSplitWall: 'before', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'split-corner-wall-thin-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
@@ -231,6 +251,12 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'safe-resize-handles-clamp-light', fixture: 'visual', space: 'golden-safe-resize',
|
||||
safeResizeFixture: true, safeResizePreview: true, iconSize: 0.8, mode: 'plan',
|
||||
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'safe-resize-handles-clamp-dark', fixture: 'visual', space: 'golden-safe-resize',
|
||||
safeResizeFixture: true, safeResizePreview: true, iconSize: 0.8, mode: 'plan',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'space-tab-drop-before-light', fixture: 'visual', applianceLifecycle: true,
|
||||
space: 'golden-geometry', mode: 'plan', tabDrag: 'before',
|
||||
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
|
||||
@@ -270,11 +296,20 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
retainedWedgeProbe: [0.8955, 0.556],
|
||||
absentWallProbes: [[0.420833333, 0.37625], [0.92, 0.348]],
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'wall-union-isolation-view-light', fixture: 'visual',
|
||||
space: 'golden-wall-union-isolation', wallUnionIsolation: true, mode: 'view',
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'wall-union-isolation-view-dark', fixture: 'visual',
|
||||
space: 'golden-wall-union-isolation', wallUnionIsolation: true, mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'multiwall-junction-bevel-view-dark', fixture: 'visual',
|
||||
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,
|
||||
@@ -539,6 +574,61 @@ 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,
|
||||
})),
|
||||
...[
|
||||
['junction-309-step-dark', 8, [808.33, 1233.33]],
|
||||
['junction-309-spike-dark', 8, [2220.83, 1350]],
|
||||
['junction-309-hump-dark', 6, [1758.33, 1612.5]],
|
||||
].map(([id, zoom, zoomCenter]) => ({
|
||||
id, fixture: 'visual', space: `golden-${id}`, mode: 'view', junctionTeeth: true,
|
||||
zoom, zoomCenter, 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',
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Issue #307: the active wall-chain axis and nodes must stay visible on
|
||||
* already-placed segments. Every click persists the segment into room_drafts,
|
||||
* whose opaque masonry paints above the markup layer; the chain ink therefore
|
||||
* lives in its own layer between the wall bodies and the snap overlay.
|
||||
*/
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const update = async () => { card.requestUpdate(); await card.updateComplete; };
|
||||
const NORM_W = 1000;
|
||||
const chain = [[0.2, 0.3], [0.5, 0.3], [0.5, 0.55]];
|
||||
const cfg = {
|
||||
spaces: [{
|
||||
id: 'ink', title: 'Ink', cell_cm: 5, view_box: [0, 0, 1, 0.7],
|
||||
rooms: [{ id: 'room', name: 'Room', area: null,
|
||||
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.25], [0.1, 0.25]] }],
|
||||
room_drafts: [{ id: 'draft-307', points: chain,
|
||||
segments: [{ cm: 20 }, { cm: 20 }] }],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
card._serverCfg = JSON.parse(JSON.stringify(cfg));
|
||||
card._layout = {};
|
||||
card._space = 'ink';
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card._cfgEpoch++;
|
||||
card._setMode('plan');
|
||||
card._tool = 'draw';
|
||||
card._path = chain.map((p) => [p[0] * NORM_W, p[1] * NORM_W]);
|
||||
card._activeDraftId = 'draft-307';
|
||||
card._draftSegmentCms = [20, 20];
|
||||
card._clearPlanSnapHover();
|
||||
await update();
|
||||
|
||||
const stage = root().querySelector('.stage');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const view = card._viewOr(card._baseVb());
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + ((0.75 * NORM_W - view.x) / view.w) * rect.width,
|
||||
clientY: rect.top + ((0.55 * NORM_W - view.y) / view.h) * rect.height,
|
||||
bubbles: true, pointerId: 71, pointerType: 'mouse',
|
||||
}));
|
||||
await update();
|
||||
|
||||
const pathline = root().querySelector('polyline.pathline');
|
||||
const vertices = [...root().querySelectorAll('circle.vertex')];
|
||||
const bodies = [...root().querySelectorAll('.wallbody-fill')];
|
||||
const overlay = root().querySelector('[data-hp="plan-snap-overlay"]');
|
||||
result.pathlinePresent = !!pathline;
|
||||
result.vertexCount = vertices.length === 3;
|
||||
result.rubberBandAxis = !!root().querySelector('line.active-axis');
|
||||
result.rubberBandVertex = !!root().querySelector('circle.active-vertex');
|
||||
// AC2: document order — wall bodies → chain ink → snap overlay.
|
||||
const after = (a, b) => !!(a && b
|
||||
&& (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING));
|
||||
result.draftHasBody = bodies.length > 0;
|
||||
result.inkAboveBodies = bodies.every((body) => after(body, pathline));
|
||||
result.inkBelowSnapOverlay = after(pathline, overlay);
|
||||
// Pixel probes read the composited stage: the persisted segment's midpoint
|
||||
// must show the yellow axis, the shared chain node must show a node dot.
|
||||
return result;
|
||||
});
|
||||
|
||||
// AC1: pixel colours at the persisted segment's axis midpoint and chain node.
|
||||
// The probes are mapped to CSS right before the shot: the draw toolbar mounts
|
||||
// above the stage and shifts the SVG after the first render.
|
||||
const probeInfo = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = card.shadowRoot || card.renderRoot;
|
||||
// The stage camera keeps animating after the first render; wait until the
|
||||
// screen matrix is stable so the probes match the screenshot that follows.
|
||||
const svgEl = root.querySelector('polyline.pathline').ownerSVGElement;
|
||||
let last = '';
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const m = svgEl.getScreenCTM();
|
||||
const key = [m.a, m.b, m.c, m.d, m.e, m.f].join(',');
|
||||
if (key === last) break;
|
||||
last = key;
|
||||
}
|
||||
const stage = root.querySelector('.stage');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const pathline = root.querySelector('polyline.pathline');
|
||||
const svg = pathline.ownerSVGElement;
|
||||
const toCss = ([ux, uy]) => {
|
||||
const point = new DOMPoint(ux, uy).matrixTransform(svg.getScreenCTM());
|
||||
return [point.x - rect.left, point.y - rect.top];
|
||||
};
|
||||
return {
|
||||
probes: { axisMid: toCss([350, 300]), node: toCss([500, 300]) },
|
||||
rect: { width: rect.width, height: rect.height },
|
||||
};
|
||||
});
|
||||
const shot = await page.locator('.stage').first().screenshot({ animations: 'disabled' });
|
||||
const pixels = await page.evaluate(async ([bytes, info]) => {
|
||||
const bitmap = await createImageBitmap(new Blob([new Uint8Array(bytes)], { type: 'image/png' }));
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
const scaleX = bitmap.width / info.rect.width;
|
||||
const scaleY = bitmap.height / info.rect.height;
|
||||
const at = ([cx, cy]) => {
|
||||
const d = ctx.getImageData(Math.round(cx * scaleX), Math.round(cy * scaleY), 1, 1).data;
|
||||
return [d[0], d[1], d[2]];
|
||||
};
|
||||
const yellowish = ([r, g, b]) => r > 200 && g > 130 && g < 220 && b < 120;
|
||||
// The node dot is tiny at this zoom: its centre may land on the dark rim
|
||||
// (#4a2800) instead of the yellow fill. Warm ink of either kind passes;
|
||||
// masonry (cold white) and the background (blue-tinted) do not.
|
||||
const warmInk = ([r, g, b]) => r > 110 && r > g && g > b && b < 130;
|
||||
return {
|
||||
axisMidYellow: yellowish(at(info.probes.axisMid)),
|
||||
nodeYellow: warmInk(at(info.probes.node)),
|
||||
axisMidRgb: at(info.probes.axisMid),
|
||||
nodeRgb: at(info.probes.node),
|
||||
};
|
||||
}, [[...shot], probeInfo]);
|
||||
|
||||
checkAll(out);
|
||||
check('axisMidYellow', pixels.axisMidYellow, true);
|
||||
check('nodeYellow', pixels.nodeYellow, true);
|
||||
console.log('pixel probes:', JSON.stringify(pixels));
|
||||
await finish(browser);
|
||||
@@ -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,
|
||||
|
||||
@@ -158,6 +158,13 @@ const out = await page.evaluate(async () => {
|
||||
|
||||
// ---- 2) «Оптимизировать планы» -----------------------------------------
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// The preceding real editor gestures schedule a physical config write.
|
||||
// Finish it before replacing the whole server snapshot with the next
|
||||
// synthetic fixture: production adoption does this at a revision boundary,
|
||||
// while a direct private-field assignment cannot invalidate #278's pending
|
||||
// exact-candidate approval by itself.
|
||||
if (c._saveConfigDebounced.pending()) c._saveConfigDebounced.flush();
|
||||
await c._writeChain;
|
||||
// a deliberately detuned fixture: a third of a step off, everywhere
|
||||
const D = 1 / GRID_N / 3;
|
||||
const FIX = { spaces: [{
|
||||
|
||||