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:
Codex
2026-08-26 22:06:43 +03:00
parent 33a5b46990
commit ffb232398a
18 changed files with 878 additions and 135 deletions
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -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")
+101 -10
View File
@@ -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)
+12
View File
@@ -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))
+6 -1
View File
@@ -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);
+44 -44
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -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
+6
View File
@@ -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
+10 -3
View File
@@ -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
+11 -11
View File
@@ -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"
}
}
+2 -2
View File
@@ -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',
},
{
+29
View File
@@ -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
View File
@@ -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
View File
@@ -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": {}
}
}
+1 -1
View File
@@ -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');
+108 -6
View File
@@ -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);
});
+56 -5
View File
@@ -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