mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: the initial wall-model migration resolves every opening conflict itself (#316)
Implements spec revision 4 (green r4). §3.1 — a legacy open_spans/open_to cut never zeroes the atom that carries an existing contour opening: the opening's edges become atom boundaries, the door keeps its real wall and the zero run continues on both sides. §3.2 — an ambiguous carrier resolves deterministically: current host, then distance, thicker cm, smaller id. §3.3 — an opening with no usable carrier persists unhosted: a valid degraded v9 state, inert in the physics, rendered by its own x/y, kept by later writes and re-placeable in the editor (backend schema accepts it). §3.4 — the initial migration never throws over an opening; a post-v9 write that LOST its carrier keeps the fail-closed opening-host refusal. The Python migration mirror implements the same rules with byte-identical output (verified on the span+door fixture including the segment id). The new smoke replays #316 end to end: a conflicted space no longer blocks drawing on an empty plan. The golden scene span-over-door-migrated-dark renders the migrated fixture pinned byte-for-byte to the real writer; two gate mutants revert §3.1 and §3.4 and are red by execution. Issue: #316 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1831,7 +1831,10 @@ def _config_wall_segment_invariants(value: dict) -> dict:
|
||||
for opening in space.get("openings", []):
|
||||
host = opening.get("host")
|
||||
if host is None:
|
||||
raise vol.Invalid("v8+ opening requires an explicit host")
|
||||
# #316 §3.3: a degraded migration may leave a contour opening
|
||||
# unhosted. It is inert (no body/tunnel/cut) and excluded from
|
||||
# the interval checks; a later edit can re-place it.
|
||||
continue
|
||||
if host["kind"] == "partition":
|
||||
if host["id"] not in partitions:
|
||||
raise vol.Invalid("partition opening host must exist in the same space")
|
||||
|
||||
@@ -225,6 +225,53 @@ def _atomize(
|
||||
global_breaks.extend((segment[:2], segment[2:]))
|
||||
zero_segments = canonical_zero_segments + legacy_segments
|
||||
|
||||
# #316 §3.1: a legacy open_spans/open_to cut never zeroes the atom that
|
||||
# carries an existing contour opening; its edges become atom boundaries so
|
||||
# the zero run continues on both sides of the opening.
|
||||
legacy_era_openings: list[dict[str, Any]] = []
|
||||
if legacy_segments:
|
||||
for opening in space.get("openings") or []:
|
||||
if not isinstance(opening, dict):
|
||||
continue
|
||||
host = opening.get("host")
|
||||
if isinstance(host, dict) and host.get("kind") == "partition":
|
||||
continue
|
||||
try:
|
||||
centre = [float(opening["x"]), float(opening["y"])]
|
||||
angle = float(opening["angle"])
|
||||
half = float(opening["length"]) / 2
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if half <= 0:
|
||||
continue
|
||||
# Only an opening that actually stands on a legacy cut changes the
|
||||
# atomization; unrelated openings must not churn the catalogue.
|
||||
if not any(
|
||||
_distance_to_segment(centre, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
|
||||
for cut in legacy_segments
|
||||
):
|
||||
continue
|
||||
legacy_era_openings.append(
|
||||
{"centre": centre, "angle": angle, "half": half}
|
||||
)
|
||||
direction = (math.cos(math.radians(angle)), math.sin(math.radians(angle)))
|
||||
global_breaks.extend((
|
||||
[centre[0] - direction[0] * half, centre[1] - direction[1] * half],
|
||||
[centre[0] + direction[0] * half, centre[1] + direction[1] * half],
|
||||
))
|
||||
|
||||
def _atom_carries_opening(a: list[float], b: list[float]) -> bool:
|
||||
for entry in legacy_era_openings:
|
||||
if not _angle_matches(a, b, entry["angle"]):
|
||||
continue
|
||||
if _distance_to_segment(entry["centre"], a, b) > GRID_STEP_N * 0.02:
|
||||
continue
|
||||
span = _length(a, b)
|
||||
tc = _project_t(entry["centre"], a, b) * span
|
||||
if tc + entry["half"] >= -EPS and tc - entry["half"] <= span + EPS:
|
||||
return True
|
||||
return False
|
||||
|
||||
for wall in space.get("walls") or []:
|
||||
if isinstance(wall, dict) and isinstance(wall.get("a"), list) and isinstance(wall.get("b"), list):
|
||||
global_breaks.extend((wall["a"], wall["b"]))
|
||||
@@ -301,9 +348,14 @@ def _atomize(
|
||||
parent_index = parents[index]
|
||||
atom["parent_keys"].add(_wall_key(original[parent_index], original[(parent_index + 1) % len(original)]))
|
||||
midpoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
|
||||
atom["zero_wall"] = atom["zero_wall"] or any(
|
||||
covered_by = lambda cuts: any(
|
||||
_distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
|
||||
for cut in zero_segments
|
||||
for cut in cuts
|
||||
)
|
||||
# Canonical cm:0 atoms stay zero; a LEGACY cut spares the atom
|
||||
# that carries an opening (#316 §3.1).
|
||||
atom["zero_wall"] = atom["zero_wall"] or covered_by(canonical_zero_segments) or (
|
||||
covered_by(legacy_segments) and not _atom_carries_opening(a, b)
|
||||
)
|
||||
if indexed_lineage and isinstance(old_ids[parent_index], str) and old_ids[parent_index]:
|
||||
previous = old.get(old_ids[parent_index])
|
||||
@@ -478,7 +530,9 @@ def _angle_matches(a: list[float], b: list[float], angle: float) -> bool:
|
||||
return difference <= 8
|
||||
|
||||
|
||||
def _host_openings(space: dict[str, Any], segments: list[dict[str, Any]]) -> None:
|
||||
def _host_openings(
|
||||
space: dict[str, Any], segments: list[dict[str, Any]], initial_migration: bool,
|
||||
) -> None:
|
||||
for opening in space.get("openings") or []:
|
||||
host = opening.get("host")
|
||||
if isinstance(host, dict) and host.get("kind") == "partition":
|
||||
@@ -487,6 +541,10 @@ def _host_openings(space: dict[str, Any], segments: list[dict[str, Any]]) -> Non
|
||||
centre = [float(opening["x"]), float(opening["y"])]
|
||||
angle, half = float(opening["angle"]), float(opening["length"]) / 2
|
||||
except (KeyError, TypeError, ValueError):
|
||||
if initial_migration:
|
||||
# #316 §3.4: the initial migration never throws over an opening.
|
||||
opening.pop("host", None)
|
||||
continue
|
||||
raise WallSegmentMigrationError("opening-host", str(opening.get("id", ""))) from None
|
||||
|
||||
def eligible(segment: dict[str, Any]) -> bool:
|
||||
@@ -501,19 +559,52 @@ def _host_openings(space: dict[str, Any], segments: list[dict[str, Any]]) -> Non
|
||||
and half >= 0 and t * span - half >= -EPS
|
||||
and t * span + half <= span + EPS)
|
||||
|
||||
def materialize(carrier: dict[str, Any]) -> None:
|
||||
opening["host"] = {
|
||||
"kind": "wall", "id": carrier["id"],
|
||||
"t": max(0.0, min(1.0, _project_t(centre, carrier["a"], carrier["b"]))),
|
||||
}
|
||||
|
||||
current = None
|
||||
if isinstance(host, dict) and host.get("kind") == "wall":
|
||||
current = next((segment for segment in segments if segment["id"] == host.get("id")), None)
|
||||
candidates = [current] if current is not None and eligible(current) else [
|
||||
segment for segment in segments if eligible(segment)
|
||||
]
|
||||
if len(candidates) != 1:
|
||||
if len(candidates) == 1:
|
||||
materialize(candidates[0])
|
||||
continue
|
||||
# #316 §3.3: an unhosted opening is a valid degraded v9 state. A later
|
||||
# write keeps it, may self-heal it, and never fails over it.
|
||||
if host is None and not initial_migration:
|
||||
continue
|
||||
if not initial_migration:
|
||||
raise WallSegmentMigrationError("opening-host", str(opening.get("id", "")))
|
||||
carrier = candidates[0]
|
||||
opening["host"] = {
|
||||
"kind": "wall", "id": carrier["id"],
|
||||
"t": max(0.0, min(1.0, _project_t(centre, carrier["a"], carrier["b"]))),
|
||||
}
|
||||
|
||||
def pick(pool: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
if not pool:
|
||||
return None
|
||||
if current is not None and any(item is current for item in pool):
|
||||
return current
|
||||
return sorted(pool, key=lambda segment: (
|
||||
_distance_to_segment(centre, segment["a"], segment["b"]),
|
||||
-float(segment.get("cm", 0)),
|
||||
str(segment.get("id", "")),
|
||||
))[0]
|
||||
|
||||
# #316 §3.2 tie-break, then the §3.3 degraded pool (angle- and
|
||||
# capacity-compatible positive walls at any distance, nearest first).
|
||||
degraded = [segment for segment in segments if (
|
||||
float(segment.get("cm", 0)) > 0
|
||||
and _angle_matches(segment["a"], segment["b"], angle)
|
||||
and half >= 0
|
||||
and _length(segment["a"], segment["b"]) + EPS >= half * 2
|
||||
)]
|
||||
carrier = pick([segment for segment in segments if eligible(segment)]) or pick(degraded)
|
||||
if carrier is not None:
|
||||
materialize(carrier)
|
||||
else:
|
||||
opening.pop("host", None)
|
||||
|
||||
|
||||
def _migrate_space(space: dict[str, Any], initial_migration: bool) -> int:
|
||||
@@ -579,7 +670,7 @@ def _migrate_space(space: dict[str, Any], initial_migration: bool) -> int:
|
||||
else:
|
||||
segment["id"] = _fresh_wall_segment_id(draft_used)
|
||||
draft_used.add(segment["id"])
|
||||
_host_openings(space, segments)
|
||||
_host_openings(space, segments, initial_migration)
|
||||
return sum(1 for segment in segments if segment["id"] not in old)
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ const wallKeyRoundtripFixture = JSON.parse(readFileSync(
|
||||
const coincidentPartitionFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/276-coincident-partition.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const spanOverDoorFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/316-span-over-door-migrated.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const wallUnionIsolationFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
|
||||
));
|
||||
@@ -79,6 +82,15 @@ 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.spanOverDoor) {
|
||||
// #316 §3.1: the scene renders the MIGRATED document — the door keeps its
|
||||
// carrying atom while the former span is zero (dashed) on both sides. The
|
||||
// fixture is produced by the real writer and pinned byte-for-byte by the
|
||||
// wall-segment-model unit test, so it cannot drift from the migration.
|
||||
const migrated = structuredClone(spanOverDoorFixture.migrated);
|
||||
fixture.config.model_version = migrated.model_version;
|
||||
fixture.config.spaces.push(...migrated.spaces);
|
||||
}
|
||||
if (scenario.coincidentPartition) {
|
||||
const state = scenario.coincidentPartition;
|
||||
if (!['before', 'thin', 'thick', 'virtual'].includes(state))
|
||||
|
||||
@@ -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 = 46;
|
||||
export const GOLDEN_MATRIX_VERSION = 47;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -185,6 +185,11 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'coincident-partition-virtual-dark', fixture: 'visual',
|
||||
space: 'golden-coincident-partition', coincidentPartition: 'virtual',
|
||||
mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
// #316 §3.1: a legacy span over a wall with a door — after the migration the
|
||||
// door keeps its carrying atom while the border is zero on both sides.
|
||||
{ id: 'span-over-door-migrated-dark', fixture: 'visual',
|
||||
space: 'golden-span-over-door', spanOverDoor: true,
|
||||
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',
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/** Issue #316: a legacy opening/zero-wall conflict in ANY space must not block
|
||||
* drawing in another (empty) space — the migration auto-resolves it (§3). */
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
const update = async () => { card.requestUpdate(); await card.updateComplete; };
|
||||
const toasts = [];
|
||||
const origToast = card._showToast.bind(card);
|
||||
card._showToast = (text) => { toasts.push(String(text)); };
|
||||
|
||||
// Space B replays the #316 report: a room, a legacy open_span along its top
|
||||
// wall and a door standing inside that former "border".
|
||||
const spaceB = {
|
||||
id: 'B', title: 'B', cell_cm: 5, view_box: [0, 0, 1, 0.7],
|
||||
rooms: [{ id: 'rB', poly: [[0.2, 0.2], [0.6, 0.2], [0.6, 0.5], [0.2, 0.5]] }],
|
||||
walls: [{ key: '', a: [0.2, 0.2], b: [0.6, 0.2], cm: 15 }],
|
||||
open_spans: [{ a: [0.2, 0.2], b: [0.6, 0.2] }],
|
||||
openings: [{ id: 'op1', type: 'door', x: 0.4, y: 0.2, angle: 0, length: 0.09, cm: 90 }],
|
||||
};
|
||||
const spaceA = { id: 'A', title: 'A', cell_cm: 5, view_box: [0, 0, 1, 0.7], rooms: [] };
|
||||
card._serverCfg = { spaces: [spaceA, spaceB], markers: [], settings: {} };
|
||||
const sent = [];
|
||||
const baseCall = card.hass.callWS.bind(card.hass);
|
||||
card.hass = { ...card.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/set') {
|
||||
sent.push(structuredClone(message));
|
||||
return { ok: true, rev: (message.expected_rev ?? 0) + 1 };
|
||||
}
|
||||
return baseCall(message);
|
||||
} };
|
||||
card._space = 'A'; card._layout = {};
|
||||
card._cfgEpoch++; card._modelCache = null; card._frame = null;
|
||||
card._setMode('plan'); card._tool = 'draw'; card._drawWallField = '20';
|
||||
card._path = []; await update();
|
||||
|
||||
// The two clicks of the Walls tool: a first draft segment is committed.
|
||||
const before = card._geometrySnapshotFromConfig(card._serverCfg, 'A');
|
||||
const fresh = card._serverCfg.spaces.find((space) => space.id === 'A');
|
||||
fresh.room_drafts = [{ id: 'd1', points: [[0.24, 0.24], [0.48, 0.24]],
|
||||
segments: [{ cm: 20 }] }];
|
||||
result.commitSucceeds = card._commitPhysicalGeometry('draw', before) === true;
|
||||
result.noMigrationToast = !toasts.some((text) => /не преобразовано|not converted/i.test(text));
|
||||
|
||||
// The auto-resolution kept the door on a real wall and zeroed the border on
|
||||
// both sides of it (§3.1) inside the migrated candidate.
|
||||
const migratedB = card._serverCfg.spaces.find((space) => space.id === 'B');
|
||||
const top = (migratedB.wall_segments || [])
|
||||
.filter((s) => Math.abs(s.a[1] - 0.2) < 1e-9 && Math.abs(s.b[1] - 0.2) < 1e-9)
|
||||
.sort((x, y) => x.a[0] - y.a[0]);
|
||||
result.doorKeepsItsWall = top.length === 3
|
||||
&& top[0].cm === 0 && top[1].cm > 0 && top[2].cm === 0
|
||||
&& migratedB.openings[0]?.host?.id === top[1].id;
|
||||
result.legacyFieldsGone = !('open_spans' in migratedB)
|
||||
&& (migratedB.rooms || []).every((room) => !('open_to' in room));
|
||||
result.draftSurvives = (card._serverCfg.spaces.find((s) => s.id === 'A')
|
||||
.room_drafts || []).length === 1;
|
||||
|
||||
card._showToast = origToast;
|
||||
return result;
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser);
|
||||
Vendored
+44
-44
File diff suppressed because one or more lines are too long
@@ -7,6 +7,12 @@
|
||||
example one that still carried an orphan legacy border) instead of demanding
|
||||
a card update that was already installed
|
||||
([#319](https://github.com/Matysh/houseplan-card/issues/319)).
|
||||
- Drawing works again everywhere: converting a plan to the new wall model no
|
||||
longer stops on a legacy border/opening conflict. A door standing inside a
|
||||
former «border» keeps its real wall (the gap continues on both sides), an
|
||||
ambiguous carrier is resolved deterministically, and an opening with no
|
||||
usable wall at all is kept as data — inert until you re-place it
|
||||
([#316](https://github.com/Matysh/houseplan-card/issues/316)).
|
||||
|
||||
## v1.68.0-beta.3 — 2026-08-26
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
документа (например, с осиротевшей легаси-границей) вместо требования
|
||||
обновить уже обновлённую карточку
|
||||
([#319](https://github.com/Matysh/houseplan-card/issues/319)).
|
||||
- Рисование снова работает везде: преобразование плана к новой модели стен
|
||||
больше не останавливается на конфликте легаси-границы с проёмом. Дверь
|
||||
внутри бывшей «границы» сохраняет настоящую стену (разрыв продолжается по
|
||||
обе стороны), неоднозначный носитель выбирается детерминированно, а проём
|
||||
вовсе без пригодной стены сохраняется в данных — бездействует, пока его не
|
||||
переставят ([#316](https://github.com/Matysh/houseplan-card/issues/316)).
|
||||
|
||||
## v1.68.0-beta.3 — 2026-08-26
|
||||
|
||||
|
||||
@@ -90,9 +90,16 @@ transmit Glow and sunlight, while solid zero walls are exact line barriers.
|
||||
documents only. Explicit valid spans win; `open_to` expands to the full proven
|
||||
shared boundary only when spans are absent. The first structural write,
|
||||
confirmed **Optimize plans**, or import into a current target atomizes that
|
||||
geometry into stable `wall_segments[].cm=0`, preserves surviving IDs, verifies
|
||||
that no opening would acquire a zero host, and then removes both legacy fields
|
||||
in one transaction. A conflict rejects the complete candidate. Presentation,
|
||||
geometry into stable `wall_segments[].cm=0`, preserves surviving IDs, and then
|
||||
removes both legacy fields in one transaction. An opening never blocks that
|
||||
migration (#316): the atom carrying an existing opening keeps its positive
|
||||
thickness (the zero run continues on both sides), an ambiguous carrier is
|
||||
resolved deterministically (current host, then distance, thicker cm, smaller
|
||||
id), and an opening with no usable carrier at all persists **unhosted** — a
|
||||
valid degraded v9 state that is inert in the physics (no body, tunnel or cut),
|
||||
renders by its own `x/y`, survives later writes untouched and may be re-placed
|
||||
in the editor. Post-migration structural writes keep the fail-closed refusal
|
||||
for an opening that LOST its carrier. Presentation,
|
||||
marker and ordinary space-settings writes do not trigger the migration.
|
||||
|
||||
There is deliberately no provenance flag. Existing v8 `cm:0` and atoms derived
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"version": 1,
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"sourceFingerprint": "4e7b4a7dc71cb9f1bb89caddbac0e70fee49088369165368845fcf1988867577",
|
||||
"sourceFingerprint": "1f20c9d64ca59363be3909accd069595b4fe18404ee7a28a5db479dac605661a",
|
||||
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4e7b4a7dc71cb9f1bb89caddbac0e70fee49088369165368845fcf1988867577",
|
||||
"sourceSha256": "1f20c9d64ca59363be3909accd069595b4fe18404ee7a28a5db479dac605661a",
|
||||
"imageSha256": "9190bd86a07b0cb0502019d76d3286c0542181e1b4a4f862077d9cefa120613c"
|
||||
},
|
||||
"view-touch": {
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4e7b4a7dc71cb9f1bb89caddbac0e70fee49088369165368845fcf1988867577",
|
||||
"sourceSha256": "1f20c9d64ca59363be3909accd069595b4fe18404ee7a28a5db479dac605661a",
|
||||
"imageSha256": "cfa2f77eb68df69ae9fc9b51ffe2f14df01f7b74444fd2b038cadc1e9dfee22b"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4e7b4a7dc71cb9f1bb89caddbac0e70fee49088369165368845fcf1988867577",
|
||||
"sourceSha256": "1f20c9d64ca59363be3909accd069595b4fe18404ee7a28a5db479dac605661a",
|
||||
"imageSha256": "a86d4211af24923d5048e9094d0d35f9b28129394d00cd72582a8f8c2dd7ff75"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4e7b4a7dc71cb9f1bb89caddbac0e70fee49088369165368845fcf1988867577",
|
||||
"sourceSha256": "1f20c9d64ca59363be3909accd069595b4fe18404ee7a28a5db479dac605661a",
|
||||
"imageSha256": "c7effd4ccd19bcaff9138458583368aaab488dcb6fc7ea5ab766383cb5377ac4"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
@@ -58,7 +58,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4e7b4a7dc71cb9f1bb89caddbac0e70fee49088369165368845fcf1988867577",
|
||||
"sourceSha256": "1f20c9d64ca59363be3909accd069595b4fe18404ee7a28a5db479dac605661a",
|
||||
"imageSha256": "af1194f073f00b03af02b949a072cffb21bfd29995aa65a067eaaaec74ef8490"
|
||||
},
|
||||
"device-editor": {
|
||||
@@ -69,7 +69,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4e7b4a7dc71cb9f1bb89caddbac0e70fee49088369165368845fcf1988867577",
|
||||
"sourceSha256": "1f20c9d64ca59363be3909accd069595b4fe18404ee7a28a5db479dac605661a",
|
||||
"imageSha256": "36ed21b66920ff2de9e3346cf6f27c67e64a9f81692599b9c70d8373f5eb7241"
|
||||
},
|
||||
"device-display-preview": {
|
||||
@@ -80,7 +80,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4e7b4a7dc71cb9f1bb89caddbac0e70fee49088369165368845fcf1988867577",
|
||||
"sourceSha256": "1f20c9d64ca59363be3909accd069595b4fe18404ee7a28a5db479dac605661a",
|
||||
"imageSha256": "7151c96dc048381f7a7070ecb85a82a890b2f4c703628f14fa472eb8dacbf3dc"
|
||||
},
|
||||
"background-editor": {
|
||||
@@ -91,7 +91,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4e7b4a7dc71cb9f1bb89caddbac0e70fee49088369165368845fcf1988867577",
|
||||
"sourceSha256": "1f20c9d64ca59363be3909accd069595b4fe18404ee7a28a5db479dac605661a",
|
||||
"imageSha256": "9f08f3f3711b100d68fb1a05af269e43e47d8f8d12c0cbe4f9ab557138722989"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -102,7 +102,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4e7b4a7dc71cb9f1bb89caddbac0e70fee49088369165368845fcf1988867577",
|
||||
"sourceSha256": "1f20c9d64ca59363be3909accd069595b4fe18404ee7a28a5db479dac605661a",
|
||||
"imageSha256": "66d8b0a509909822ff6b891a483bad9d2c72b20f08e287355883aa5205c6cee0"
|
||||
},
|
||||
"device-info": {
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "4e7b4a7dc71cb9f1bb89caddbac0e70fee49088369165368845fcf1988867577",
|
||||
"sourceSha256": "1f20c9d64ca59363be3909accd069595b4fe18404ee7a28a5db479dac605661a",
|
||||
"imageSha256": "83620033bb66edf7c804619261e4a62327049c178267f0837bd997627ffdc003"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,13 +292,13 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([
|
||||
selector: { path: ['spaces', '*', 'openings', '*', 'host', 'kind'], equals: 'wall' },
|
||||
storage: 'House Plan server config',
|
||||
type: "{ kind: 'wall'; id: string; t: number }",
|
||||
default: 'required for contour openings in model v8',
|
||||
default: 'materialized by migration (#316 §3); may persist unhosted after degraded migration',
|
||||
level: 'opening',
|
||||
ui: 'internal opening ownership',
|
||||
runtime: 'binds an opening to a stable contour-wall segment',
|
||||
introduced: 'model v8 / issue #282',
|
||||
status: 'current',
|
||||
migration: 'materialize only when exactly one carrier is proven',
|
||||
migration: 'auto-resolved per #316 §3.1-3.3; unhosted is the degraded terminal state',
|
||||
compatibility: 'legacy unhosted openings remain readable before migration',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2257,6 +2257,35 @@ export const MUTANTS = [
|
||||
replace: " const spacesById = new Map(((this._serverCfg as any)?.spaces || [])",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'span-cut-erases-the-door-wall',
|
||||
// #316 §3.1: легаси-кат обязан щадить атом, несущий проём — иначе дверь
|
||||
// остаётся на нулевой стене и миграция деградирует без нужды.
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="keeps its wall through a legacy span cut" '
|
||||
+ 'test/wall-segment-model.test.mjs',
|
||||
because: 'дверь внутри бывшей «границы» стояла в настоящей стене — занулять её атом значит переписать план (#316)',
|
||||
patches: [{
|
||||
file: 'src/wall-segment-model.ts',
|
||||
find: " atom.zeroWall ||= coveredBy(canonicalZeroCuts)\n || (coveredBy(legacyCuts) && !atomCarriesOpening(a, b));",
|
||||
replace: " atom.zeroWall ||= coveredBy(canonicalZeroCuts) || coveredBy(legacyCuts);",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'migration-throws-over-an-opening-again',
|
||||
// #316 §3.4: initial migration никогда не кидает opening-host — реверт
|
||||
// возвращает глобальный блокер рисования beta.3.
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="migrates unhosted instead of blocking" '
|
||||
+ 'test/wall-segment-model.test.mjs '
|
||||
+ '&& node demo/smoke_zero_wall_migration_unblocked.mjs',
|
||||
because: 'один конфликтный проём снова заблокировал бы структурные записи во всех пространствах (#316)',
|
||||
patches: [{
|
||||
file: 'src/wall-segment-model.ts',
|
||||
find: " if (initialMigration) { delete opening.host; continue; }",
|
||||
replace: " if (initialMigration) throw new WallSegmentModelError('opening-host', opening.id);",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'preflight-fallback-survives-dialog-close',
|
||||
// CODE-REVIEW-295-r1 M2: инлайн-фолбэк живёт одно показание диалога;
|
||||
|
||||
+113
-7
@@ -332,13 +332,54 @@ const buildAtoms = (
|
||||
}
|
||||
}
|
||||
const cuts = [...canonicalZeroCuts, ...legacyCuts];
|
||||
// #316 §3.1: a legacy open_spans/open_to cut never zeroes the atom that
|
||||
// carries an existing contour opening — if a door stands inside a former
|
||||
// "border", the wall under it was real and stays real. The zero run
|
||||
// continues on both sides, so the opening's edges become atom boundaries.
|
||||
const legacyEraOpenings: OpeningCfg[] = legacyCuts.length
|
||||
? ((Array.isArray(space.openings) ? space.openings : []) as OpeningCfg[])
|
||||
.filter((opening) => opening.host?.kind !== 'partition'
|
||||
&& [Number(opening.x), Number(opening.y)].every(Number.isFinite)
|
||||
&& Number.isFinite(Number(opening.angle))
|
||||
&& Number(opening.length) > 0
|
||||
// Only an opening that actually stands on a legacy cut changes the
|
||||
// atomization; unrelated openings must not churn the catalogue.
|
||||
&& legacyCuts.some((cut) => distanceToSegment(
|
||||
[Number(opening.x), Number(opening.y)],
|
||||
[cut[0], cut[1]], [cut[2], cut[3]],
|
||||
) <= GRID_STEP_N * 0.04))
|
||||
: [];
|
||||
const openingEdgeBreaks: WallEntry[] = legacyEraOpenings.map((opening) => {
|
||||
const rad = (Number(opening.angle) * Math.PI) / 180;
|
||||
const dir = [Math.cos(rad), Math.sin(rad)];
|
||||
const half = Number(opening.length) / 2;
|
||||
const centre = [Number(opening.x), Number(opening.y)];
|
||||
return {
|
||||
a: [centre[0] - dir[0] * half, centre[1] - dir[1] * half],
|
||||
b: [centre[0] + dir[0] * half, centre[1] + dir[1] * half],
|
||||
} as WallEntry;
|
||||
});
|
||||
const atomCarriesOpening = (a: number[], b: number[]): boolean => (
|
||||
legacyEraOpenings.some((opening) => {
|
||||
const centre = [Number(opening.x), Number(opening.y)];
|
||||
if (!wallAngleMatches(a, b, Number(opening.angle))) return false;
|
||||
if (distanceToSegment(centre, a, b) > GRID_STEP_N * 0.02) return false;
|
||||
const span = lengthOf(a, b);
|
||||
const tc = projectT(centre, a, b) * span;
|
||||
const half = Number(opening.length) / 2;
|
||||
return tc + half >= -EPS && tc - half <= span + EPS;
|
||||
})
|
||||
);
|
||||
const byKey = new Map<string, Atom>();
|
||||
const nextRooms: any[] = [];
|
||||
for (const rawRoom of rooms) {
|
||||
const id = String(rawRoom?.id || '');
|
||||
const original = roomPoly(rawRoom);
|
||||
if (!id || !original || original.length < 3) throw new WallSegmentModelError('invalid-room', id);
|
||||
const atomic = atomicPolyForRoom(rooms, id, cuts, GRID_STEP_N, 1, space.walls || []);
|
||||
const atomic = atomicPolyForRoom(
|
||||
rooms, id, cuts, GRID_STEP_N, 1,
|
||||
[...(space.walls || []), ...openingEdgeBreaks],
|
||||
);
|
||||
if (!atomic || atomic.poly.length < 3) throw new WallSegmentModelError('invalid-room', id);
|
||||
const oldIds = Array.isArray(rawRoom.wall_ids) ? rawRoom.wall_ids : [];
|
||||
const indexedLineageIsValid = oldIds.length === original.length;
|
||||
@@ -399,9 +440,13 @@ const buildAtoms = (
|
||||
original[parent], original[(parent + 1) % original.length], GRID_STEP_N,
|
||||
));
|
||||
const midpoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
atom.zeroWall ||= cuts.some((cut) => distanceToSegment(
|
||||
const coveredBy = (list: number[][]): boolean => list.some((cut) => distanceToSegment(
|
||||
midpoint, [cut[0], cut[1]], [cut[2], cut[3]],
|
||||
) <= GRID_STEP_N * 0.04);
|
||||
// Canonical cm:0 atoms stay zero; a LEGACY cut spares the atom that
|
||||
// carries an opening (#316 §3.1).
|
||||
atom.zeroWall ||= coveredBy(canonicalZeroCuts)
|
||||
|| (coveredBy(legacyCuts) && !atomCarriesOpening(a, b));
|
||||
wallIds.push(key); // replaced with stable ids after lineage resolution
|
||||
}
|
||||
// Re-add the owned field last on every pass. Several legacy maintenance
|
||||
@@ -614,13 +659,74 @@ export const resolveRoomOpeningHost = (
|
||||
};
|
||||
};
|
||||
|
||||
const hostRoomOpenings = (space: any, segments: readonly WallSegmentEntry[]): void => {
|
||||
/** #316 §3.2/§3.3: deterministic migration-time host resolution. Returns null
|
||||
* only when the space has no usable carrier at all — the opening then persists
|
||||
* unhosted (§3.3) instead of blocking the migration. */
|
||||
const migrateRoomOpeningHost = (
|
||||
opening: OpeningCfg, segments: readonly WallSegmentEntry[],
|
||||
): WallOpeningHost | null => {
|
||||
const centre = [Number(opening.x), Number(opening.y)];
|
||||
if (!centre.every(Number.isFinite)) return null;
|
||||
const half = Number(opening.length) / 2;
|
||||
const eligible = segments.filter((segment) => {
|
||||
if (!(Number(segment.cm) > 0)) return false;
|
||||
const t = projectT(centre, segment.a, segment.b);
|
||||
const span = lengthOf(segment.a, segment.b);
|
||||
return t >= -EPS && t <= 1 + EPS
|
||||
&& distanceToSegment(centre, segment.a, segment.b) <= GRID_STEP_N * 0.02
|
||||
&& wallAngleMatches(segment.a, segment.b, Number(opening.angle))
|
||||
&& Number.isFinite(half) && half >= 0
|
||||
&& t * span - half >= -EPS && t * span + half <= span + EPS;
|
||||
});
|
||||
// §3.2 tie-break: current host → distance → thicker cm → smaller id.
|
||||
const pick = (candidates: readonly WallSegmentEntry[]): WallSegmentEntry | null => {
|
||||
if (!candidates.length) return null;
|
||||
const current = opening.host?.kind === 'wall'
|
||||
? candidates.find((segment) => segment.id === opening.host!.id) : null;
|
||||
if (current) return current;
|
||||
return [...candidates].sort((x, y) => (
|
||||
distanceToSegment(centre, x.a, x.b) - distanceToSegment(centre, y.a, y.b)
|
||||
|| Number(y.cm) - Number(x.cm)
|
||||
|| (x.id < y.id ? -1 : x.id > y.id ? 1 : 0)
|
||||
))[0];
|
||||
};
|
||||
// §3.3 degraded pool: angle- and capacity-compatible positive walls at any
|
||||
// distance, nearest first.
|
||||
const degraded = segments.filter((segment) => (
|
||||
Number(segment.cm) > 0
|
||||
&& wallAngleMatches(segment.a, segment.b, Number(opening.angle))
|
||||
&& Number.isFinite(half) && half >= 0
|
||||
&& lengthOf(segment.a, segment.b) + EPS >= Number(opening.length)
|
||||
));
|
||||
const host = pick(eligible) ?? pick(degraded);
|
||||
if (!host) return null;
|
||||
return {
|
||||
kind: 'wall', id: host.id,
|
||||
t: Math.max(0, Math.min(1, projectT(centre, host.a, host.b))),
|
||||
};
|
||||
};
|
||||
|
||||
const hostRoomOpenings = (
|
||||
space: any, segments: readonly WallSegmentEntry[], initialMigration: boolean,
|
||||
): void => {
|
||||
const openings: OpeningCfg[] = Array.isArray(space.openings) ? space.openings : [];
|
||||
for (const opening of openings) {
|
||||
if (opening.host?.kind === 'partition') continue;
|
||||
const host = resolveRoomOpeningHost(opening, segments);
|
||||
if (!host) throw new WallSegmentModelError('opening-host', opening.id);
|
||||
opening.host = host;
|
||||
// #316 §3.3: an unhosted opening is a valid degraded v9 state — inert in
|
||||
// the physics, rendered by its own x/y. A later write keeps it and may
|
||||
// self-heal it when a unique carrier appears; it never blocks the write.
|
||||
if (!opening.host && !initialMigration) {
|
||||
const healed = resolveRoomOpeningHost(opening, segments);
|
||||
if (healed) opening.host = healed;
|
||||
continue;
|
||||
}
|
||||
const host = resolveRoomOpeningHost(opening, segments)
|
||||
?? (initialMigration ? migrateRoomOpeningHost(opening, segments) : null);
|
||||
if (host) { opening.host = host; continue; }
|
||||
// #316 §3.4: the initial migration never throws over an opening; a
|
||||
// post-v9 write that LOST a carrier keeps the fail-closed refusal.
|
||||
if (initialMigration) { delete opening.host; continue; }
|
||||
throw new WallSegmentModelError('opening-host', opening.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -703,7 +809,7 @@ const migrateSpace = (
|
||||
delete space.open_spans;
|
||||
for (const room of space.rooms) delete room.open_to;
|
||||
migrateDraftSegments(space, initialMigration);
|
||||
hostRoomOpenings(space, segments);
|
||||
hostRoomOpenings(space, segments, initialMigration);
|
||||
return segments.reduce((count, segment) => count + (old.has(segment.id) ? 0 : 1), 0);
|
||||
};
|
||||
|
||||
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
{
|
||||
"comment": "stored: a pre-migration v8 document — a room whose top wall carries a door and is fully covered by a legacy open_span; migrated: the same document after the current initial migration (#316 §3.1) — the door keeps its carrying atom, the border is zero on both sides. The golden scene renders `migrated`; the unit test pins it to the real writer byte for byte.",
|
||||
"stored": {
|
||||
"model_version": 8,
|
||||
"spaces": [
|
||||
{
|
||||
"id": "golden-span-over-door",
|
||||
"title": "Span over door",
|
||||
"cell_cm": 5,
|
||||
"view_box": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0.7
|
||||
],
|
||||
"settings": {
|
||||
"fill_mode": "none",
|
||||
"show_borders": true,
|
||||
"show_names": true
|
||||
},
|
||||
"rooms": [
|
||||
{
|
||||
"id": "sod-room",
|
||||
"name": "Room",
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0.2,
|
||||
0.3
|
||||
],
|
||||
[
|
||||
0.6,
|
||||
0.3
|
||||
],
|
||||
[
|
||||
0.6,
|
||||
0.6
|
||||
],
|
||||
[
|
||||
0.2,
|
||||
0.6
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.400000,0.300000@0.0000",
|
||||
"a": [
|
||||
0.2,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.6,
|
||||
0.3
|
||||
],
|
||||
"cm": 15
|
||||
}
|
||||
],
|
||||
"open_spans": [
|
||||
{
|
||||
"a": [
|
||||
0.2,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.6,
|
||||
0.3
|
||||
]
|
||||
}
|
||||
],
|
||||
"openings": [
|
||||
{
|
||||
"id": "sod-door",
|
||||
"type": "door",
|
||||
"x": 0.4,
|
||||
"y": 0.3,
|
||||
"angle": 0,
|
||||
"length": 0.09,
|
||||
"cm": 90,
|
||||
"flip_v": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"settings": {}
|
||||
},
|
||||
"migrated": {
|
||||
"model_version": 9,
|
||||
"spaces": [
|
||||
{
|
||||
"id": "golden-span-over-door",
|
||||
"title": "Span over door",
|
||||
"cell_cm": 5,
|
||||
"view_box": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0.7
|
||||
],
|
||||
"settings": {
|
||||
"fill_mode": "none",
|
||||
"show_borders": true,
|
||||
"show_names": true
|
||||
},
|
||||
"rooms": [
|
||||
{
|
||||
"id": "sod-room",
|
||||
"name": "Room",
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0.2,
|
||||
0.3
|
||||
],
|
||||
[
|
||||
0.355,
|
||||
0.3
|
||||
],
|
||||
[
|
||||
0.445,
|
||||
0.3
|
||||
],
|
||||
[
|
||||
0.6,
|
||||
0.3
|
||||
],
|
||||
[
|
||||
0.6,
|
||||
0.6
|
||||
],
|
||||
[
|
||||
0.2,
|
||||
0.6
|
||||
]
|
||||
],
|
||||
"wall_ids": [
|
||||
"wall-s2g2iw3nbbxkym6sodv4",
|
||||
"wall-qmhko777ctlb3hey6c3v",
|
||||
"wall-odwfy5lyxkwsum2jewma",
|
||||
"wall-l3r6mytgo2l4so24dljp",
|
||||
"wall-mzbb6mwcqxj67rhw7mc5",
|
||||
"wall-uyhvjlqcorfj3orzvhhj"
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.400000,0.300000@0.0000",
|
||||
"cm": 15,
|
||||
"a": [
|
||||
0.355,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.445,
|
||||
0.3
|
||||
]
|
||||
}
|
||||
],
|
||||
"openings": [
|
||||
{
|
||||
"id": "sod-door",
|
||||
"type": "door",
|
||||
"x": 0.4,
|
||||
"y": 0.3,
|
||||
"angle": 0,
|
||||
"length": 0.09,
|
||||
"cm": 90,
|
||||
"flip_v": true,
|
||||
"host": {
|
||||
"kind": "wall",
|
||||
"id": "wall-qmhko777ctlb3hey6c3v",
|
||||
"t": 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_segments": [
|
||||
{
|
||||
"id": "wall-uyhvjlqcorfj3orzvhhj",
|
||||
"a": [
|
||||
0.2,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.2,
|
||||
0.6
|
||||
],
|
||||
"cm": 0
|
||||
},
|
||||
{
|
||||
"id": "wall-s2g2iw3nbbxkym6sodv4",
|
||||
"a": [
|
||||
0.2,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.355,
|
||||
0.3
|
||||
],
|
||||
"cm": 0
|
||||
},
|
||||
{
|
||||
"id": "wall-mzbb6mwcqxj67rhw7mc5",
|
||||
"a": [
|
||||
0.2,
|
||||
0.6
|
||||
],
|
||||
"b": [
|
||||
0.6,
|
||||
0.6
|
||||
],
|
||||
"cm": 0
|
||||
},
|
||||
{
|
||||
"id": "wall-qmhko777ctlb3hey6c3v",
|
||||
"a": [
|
||||
0.355,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.445,
|
||||
0.3
|
||||
],
|
||||
"cm": 15
|
||||
},
|
||||
{
|
||||
"id": "wall-odwfy5lyxkwsum2jewma",
|
||||
"a": [
|
||||
0.445,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.6,
|
||||
0.3
|
||||
],
|
||||
"cm": 0
|
||||
},
|
||||
{
|
||||
"id": "wall-l3r6mytgo2l4so24dljp",
|
||||
"a": [
|
||||
0.6,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.6,
|
||||
0.6
|
||||
],
|
||||
"cm": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
@@ -367,7 +367,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
|
||||
assert.ok(scenario);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 46);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 47);
|
||||
assert.equal(space.settings.sun_rays, true);
|
||||
assert.equal(scenario.northDeg, 90,
|
||||
'the sign-sensitive golden must keep a non-zero north direction');
|
||||
|
||||
@@ -262,18 +262,23 @@ test('room opening host resolver updates t and rejects ambiguous or zero walls',
|
||||
]), null);
|
||||
});
|
||||
|
||||
test('an ambiguous room opening blocks the complete candidate without mutating input', () => {
|
||||
test('a corner opening on bodyless walls migrates unhosted instead of blocking (#316 §3.3/§3.4)', () => {
|
||||
// Until #316 the initial migration refused this candidate outright; the
|
||||
// reviewed contract degrades it instead and never blocks the write.
|
||||
const input = configOf({
|
||||
id: 'floor', title: 'Floor',
|
||||
rooms: [rectangle('room')],
|
||||
openings: [{ id: 'door', type: 'door', x: 0, y: 0, angle: 0, length: 0.2 }],
|
||||
});
|
||||
const before = structuredClone(input);
|
||||
assert.throws(
|
||||
() => commitWallSegmentModel(input),
|
||||
(error) => error instanceof WallSegmentModelError && error.reason === 'opening-host',
|
||||
);
|
||||
assert.deepEqual(input, before);
|
||||
const out = commitWallSegmentModel(input).config;
|
||||
assert.deepEqual(input, before, 'the input candidate is never mutated');
|
||||
// Since #306 an unconfigured contour is explicitly bodyless (cm: 0), and a
|
||||
// zero wall is never an opening carrier — the corner door therefore
|
||||
// migrates UNHOSTED (§3.3) instead of blocking the whole candidate.
|
||||
assert.equal('host' in out.spaces[0].openings[0], false);
|
||||
const again = commitWallSegmentModel(structuredClone(out)).config;
|
||||
assert.equal(JSON.stringify(again), JSON.stringify(out), 'the outcome is stable');
|
||||
});
|
||||
|
||||
test('draft segment ids materialise once and remain stable', () => {
|
||||
@@ -391,3 +396,100 @@ test('every frontend structural transaction crosses the wall identity barrier',
|
||||
assert.doesNotMatch(commitMethod, /this\._writeConfig\(/,
|
||||
'the common barrier must finish before the existing persistence path runs');
|
||||
});
|
||||
|
||||
// --- #316: the initial migration auto-resolves opening-host conflicts ---
|
||||
|
||||
const spanDoorConfig = () => ({
|
||||
model_version: 8,
|
||||
spaces: [{
|
||||
id: 'B', title: 'B', cell_cm: 5, view_box: [0, 0, 1, 0.7],
|
||||
rooms: [{ id: 'rB', poly: [[0.2, 0.2], [0.6, 0.2], [0.6, 0.5], [0.2, 0.5]] }],
|
||||
walls: [{ key: wallKey([0.2, 0.2], [0.6, 0.2], GRID_STEP_N),
|
||||
a: [0.2, 0.2], b: [0.6, 0.2], cm: 15 }],
|
||||
open_spans: [{ a: [0.2, 0.2], b: [0.6, 0.2] }],
|
||||
openings: [{ id: 'op1', type: 'door', x: 0.4, y: 0.2, angle: 0, length: 0.09, cm: 90 }],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
});
|
||||
|
||||
test('an opening keeps its wall through a legacy span cut (#316 §3.1) and the run stays idempotent', () => {
|
||||
const out = commitWallSegmentModel(spanDoorConfig()).config;
|
||||
const space = out.spaces[0];
|
||||
const top = space.wall_segments
|
||||
.filter((s) => Math.abs(s.a[1] - 0.2) < 1e-9 && Math.abs(s.b[1] - 0.2) < 1e-9)
|
||||
.sort((x, y) => x.a[0] - y.a[0]);
|
||||
assert.equal(top.length, 3, 'the opening edges split the span run into three atoms');
|
||||
assert.equal(top[0].cm, 0, 'left of the door the border stays zero');
|
||||
assert.ok(top[1].cm > 0, 'the atom under the door keeps its real thickness');
|
||||
assert.equal(top[2].cm, 0, 'right of the door the border stays zero');
|
||||
assert.deepEqual(space.openings[0].host, { kind: 'wall', id: top[1].id, t: 0.5 });
|
||||
assert.equal('open_spans' in space, false);
|
||||
const again = commitWallSegmentModel(structuredClone(out)).config;
|
||||
assert.equal(JSON.stringify(again), JSON.stringify(out), 'commit(commit(x)) == commit(x)');
|
||||
});
|
||||
|
||||
test('an ambiguous carrier is resolved deterministically at a thickness boundary (#316 §3.2)', () => {
|
||||
// Two contour atoms of one wall meet at x=0.4 (thickness change); a short
|
||||
// opening centred exactly on the junction fits inside either atom.
|
||||
const config = {
|
||||
model_version: 8,
|
||||
spaces: [{
|
||||
id: 'S', title: 'S', cell_cm: 5, view_box: [0, 0, 1, 0.7],
|
||||
rooms: [{ id: 'r', poly: [[0.2, 0.2], [0.6, 0.2], [0.6, 0.5], [0.2, 0.5]] }],
|
||||
walls: [
|
||||
{ key: wallKey([0.2, 0.2], [0.4, 0.2], GRID_STEP_N),
|
||||
a: [0.2, 0.2], b: [0.4, 0.2], cm: 10 },
|
||||
{ key: wallKey([0.4, 0.2], [0.6, 0.2], GRID_STEP_N),
|
||||
a: [0.4, 0.2], b: [0.6, 0.2], cm: 20 },
|
||||
],
|
||||
openings: [{ id: 'op', type: 'door', x: 0.4, y: 0.2, angle: 0, length: 0, cm: 0 }],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const out = commitWallSegmentModel(structuredClone(config)).config;
|
||||
const space = out.spaces[0];
|
||||
const host = space.openings[0].host;
|
||||
assert.equal(host?.kind, 'wall');
|
||||
const carrier = space.wall_segments.find((s) => s.id === host.id);
|
||||
assert.ok(carrier, 'the picked carrier exists in the catalogue');
|
||||
// §3.2: equal distance → the thicker atom wins.
|
||||
assert.equal(carrier.cm, 20);
|
||||
const again = commitWallSegmentModel(structuredClone(out)).config;
|
||||
assert.equal(JSON.stringify(again), JSON.stringify(out), 'the pick is stable');
|
||||
});
|
||||
|
||||
test('an opening with no usable carrier migrates unhosted and survives later writes (#316 §3.3/§3.4)', () => {
|
||||
const config = spanDoorConfig();
|
||||
// The opening is far from every wall and across the grain — no carrier.
|
||||
config.spaces[0].openings[0] = {
|
||||
id: 'orphan', type: 'door', x: 0.9, y: 0.65, angle: 90, length: 0.05, cm: 90,
|
||||
};
|
||||
const out = commitWallSegmentModel(structuredClone(config)).config;
|
||||
const opening = out.spaces[0].openings[0];
|
||||
assert.equal('host' in opening, false, 'the orphan persists unhosted');
|
||||
// A later structural write keeps it and does not throw (§3.3).
|
||||
const again = commitWallSegmentModel(structuredClone(out)).config;
|
||||
assert.equal('host' in again.spaces[0].openings[0], false);
|
||||
assert.equal(JSON.stringify(again), JSON.stringify(out));
|
||||
});
|
||||
|
||||
test('a post-v9 write that LOST its carrier keeps the fail-closed refusal (#316 AC5)', () => {
|
||||
const migrated = commitWallSegmentModel(spanDoorConfig()).config;
|
||||
const broken = structuredClone(migrated);
|
||||
// Point the hosted opening at a segment id that no longer exists.
|
||||
broken.spaces[0].openings[0].host = { kind: 'wall', id: 'wall-gone', t: 0.5 };
|
||||
broken.spaces[0].openings[0].x = 0.9; broken.spaces[0].openings[0].y = 0.65;
|
||||
broken.spaces[0].openings[0].angle = 90;
|
||||
assert.throws(
|
||||
() => commitWallSegmentModel(broken),
|
||||
(error) => error instanceof WallSegmentModelError && error.reason === 'opening-host',
|
||||
);
|
||||
});
|
||||
|
||||
test('the #316 golden fixture matches the current initial migration byte for byte', () => {
|
||||
const fixture = JSON.parse(readFileSync(
|
||||
new URL('./fixtures/316-span-over-door-migrated.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const migrated = commitWallSegmentModel(structuredClone(fixture.stored)).config;
|
||||
assert.deepEqual(migrated, fixture.migrated);
|
||||
});
|
||||
|
||||
@@ -103,7 +103,13 @@ def test_partial_shared_boundary_has_exactly_one_two_owner_atom() -> None:
|
||||
assert list(owners.values()).count(2) == 1
|
||||
|
||||
|
||||
def test_ambiguous_opening_blocks_without_mutating_source() -> None:
|
||||
def test_corner_opening_on_bodyless_walls_migrates_unhosted() -> None:
|
||||
"""#316 §3.3/§3.4: the initial migration degrades instead of blocking.
|
||||
|
||||
Since #306 an unconfigured contour is explicitly bodyless (cm 0) and a
|
||||
zero wall is never an opening carrier, so the corner door persists
|
||||
unhosted; the migration never throws over an opening.
|
||||
"""
|
||||
source = _config({
|
||||
"id": "floor", "rooms": [_room("room")],
|
||||
"openings": [{
|
||||
@@ -112,9 +118,10 @@ def test_ambiguous_opening_blocks_without_mutating_source() -> None:
|
||||
}],
|
||||
})
|
||||
original = copy.deepcopy(source)
|
||||
with pytest.raises(WallSegmentMigrationError, match="opening-host"):
|
||||
commit_wall_segment_model(source)
|
||||
migrated, _ = commit_wall_segment_model(source)
|
||||
assert source == original
|
||||
assert "host" not in migrated["spaces"][0]["openings"][0]
|
||||
assert commit_wall_segment_model(migrated)[0] == migrated
|
||||
|
||||
|
||||
def test_post_v8_new_atoms_are_random_and_promoted_draft_id_survives() -> None:
|
||||
@@ -369,7 +376,7 @@ def test_v8_open_span_migrates_to_zero_atoms_and_removes_legacy_fields() -> None
|
||||
assert commit_wall_segment_model(migrated)[0] == migrated
|
||||
|
||||
|
||||
def test_v8_open_span_over_an_opening_blocks_atomically() -> None:
|
||||
def test_v8_open_span_over_an_opening_spares_the_carrying_atom() -> None:
|
||||
base, _ = commit_wall_segment_model(_config({
|
||||
"id": "floor", "rooms": [_room("room")],
|
||||
}))
|
||||
@@ -390,6 +397,50 @@ def test_v8_open_span_over_an_opening_blocks_atomically() -> None:
|
||||
base["model_version"] = 8
|
||||
before = copy.deepcopy(base)
|
||||
|
||||
# #316 §3.1: the atom carrying the hosted door keeps its thickness while
|
||||
# the rest of the span run turns to zero on both sides of the opening.
|
||||
migrated, _ = commit_wall_segment_model(base)
|
||||
assert base == before
|
||||
space = migrated["spaces"][0]
|
||||
bottom = sorted(
|
||||
(segment for segment in space["wall_segments"]
|
||||
if abs(segment["a"][1]) < 1e-9 and abs(segment["b"][1]) < 1e-9),
|
||||
key=lambda segment: segment["a"][0],
|
||||
)
|
||||
assert [segment["cm"] for segment in bottom] == [0.0, 15.0, 0.0]
|
||||
host = space["openings"][0]["host"]
|
||||
assert host["kind"] == "wall" and host["id"] == bottom[1]["id"]
|
||||
assert "open_spans" not in space
|
||||
assert commit_wall_segment_model(migrated)[0] == migrated
|
||||
|
||||
|
||||
def test_unhosted_contour_opening_is_a_valid_degraded_v9_state() -> None:
|
||||
"""#316 AC4: schema and repeat writes keep the unhosted opening."""
|
||||
source = _config({
|
||||
"id": "floor", "rooms": [_room("room")],
|
||||
"openings": [{
|
||||
"id": "orphan", "type": "door", "x": 0.9, "y": 0.65,
|
||||
"angle": 90, "length": 0.05,
|
||||
}],
|
||||
})
|
||||
migrated, _ = commit_wall_segment_model(source)
|
||||
opening = migrated["spaces"][0]["openings"][0]
|
||||
assert "host" not in opening
|
||||
validated = CONFIG_SCHEMA(copy.deepcopy(migrated))
|
||||
assert "host" not in validated["spaces"][0]["openings"][0]
|
||||
assert commit_wall_segment_model(migrated)[0] == migrated
|
||||
|
||||
|
||||
def test_post_v9_write_that_lost_its_carrier_keeps_the_refusal() -> None:
|
||||
"""#316 AC5: only the INITIAL migration degrades; a v9 write fails closed."""
|
||||
base, _ = commit_wall_segment_model(_config({
|
||||
"id": "floor", "rooms": [_room("room")],
|
||||
}))
|
||||
space = base["spaces"][0]
|
||||
space["openings"] = [{
|
||||
"id": "door", "type": "door", "x": 0.9, "y": 0.65,
|
||||
"angle": 90, "length": 0.05,
|
||||
"host": {"kind": "wall", "id": "wall-gone", "t": 0.5},
|
||||
}]
|
||||
with pytest.raises(WallSegmentMigrationError, match="opening-host"):
|
||||
commit_wall_segment_model(base)
|
||||
assert base == before
|
||||
|
||||
Reference in New Issue
Block a user