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
+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: инлайн-фолбэк живёт одно показание диалога;