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
+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);
};