/** * Persisted identity for contour-wall atoms (ADR 282, Stage 1). * * The editor still renders through the legacy `walls[]` projection. This * module is the single structural-write barrier which owns the canonical v8 * catalogue and regenerates that projection. It is deliberately DOM-free so * the frontend and migration contracts can be exercised as pure tests. */ import { canonicalizeConfigGeometryInPlace, LATTICE_NOISE_STEPS, } from './coordinate-canonicalization'; import { roomPoly, samePoint } from './logic'; import { resolveOpenCuts, sanitizeOpenSpans } from './open-spans'; import { GRID_STEP_N } from './space-geometry'; import { atomicPolyForRoom, thicknessCmAt, wallAngleMatches, wallKey, type WallEntry, } from './wall-thickness'; import type { OpeningCfg, PartitionCfg, WallOpeningHost, WallSegmentEntry } from './types'; export const WALL_SEGMENT_MODEL_VERSION = 10; const EPS = 1e-9; export type WallSegmentModelBlocker = | 'invalid-room' | 'zero-length' | 'third-owner' | 'duplicate-id' | 'thickness-conflict' | 'opening-host'; export class WallSegmentModelError extends Error { constructor(public readonly reason: WallSegmentModelBlocker, detail = '') { super(detail ? `${reason}: ${detail}` : reason); this.name = 'WallSegmentModelError'; } } export interface WallSegmentCommitResult { config: T; changed: boolean; migratedSegments: number; /** Legacy persisted contour records converted to ordinary partitions. */ migratedDrafts: number; /** Legacy draft edges converted one-for-one to ordinary partitions. */ migratedDraftSegments: number; } export interface WallSegmentCommitOptions { /** Atom-key → existing ID, supplied only by a proven local topology edit. */ lineageHints?: ReadonlyMap; lineageSpaceId?: string; } type Point = [number, number]; type Atom = { key: string; a: Point; b: Point; owners: Set; preferredIds: Set; positionalIds: Set; preferredCarriers: Map; parentKeys: Set; zeroWall: boolean; id?: string; }; const clone = (value: T): T => JSON.parse(JSON.stringify(value)); const finitePoint = (value: unknown): value is number[] => ( Array.isArray(value) && value.length >= 2 && Number.isFinite(Number(value[0])) && Number.isFinite(Number(value[1])) ); const point = (value: number[]): Point => [Number(value[0]), Number(value[1])]; const pointKey = (value: number[]): string => ( `${Number(value[0]).toFixed(12)},${Number(value[1]).toFixed(12)}` ); const atomKey = (a: number[], b: number[]): string => { const ka = pointKey(a), kb = pointKey(b); return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; }; const canonicalSpan = (a: number[], b: number[]): [Point, Point] => ( pointKey(a) <= pointKey(b) ? [point(a), point(b)] : [point(b), point(a)] ); const lengthOf = (a: number[], b: number[]): number => Math.hypot(b[0] - a[0], b[1] - a[1]); /** Unique authored/derived contour coordinates that are materially off-grid. */ export const wallModelOffGridValueCount = ( space: any, additionalPoints: readonly (readonly number[])[] = [], ): number => { const values = new Set(); const inspect = (value: unknown): void => { const number = Number(value); if (!Number.isFinite(number)) return; const steps = number / GRID_STEP_N; if (Math.abs(steps - Math.round(steps)) >= LATTICE_NOISE_STEPS) values.add(number.toFixed(12)); }; for (const room of Array.isArray(space?.rooms) ? space.rooms : []) { for (const point of roomPoly(room) || []) { inspect(point[0]); inspect(point[1]); } } for (const collection of [space?.wall_segments, space?.walls]) { for (const segment of Array.isArray(collection) ? collection : []) { inspect(segment?.a?.[0]); inspect(segment?.a?.[1]); inspect(segment?.b?.[0]); inspect(segment?.b?.[1]); } } for (const additionalPoint of additionalPoints) { inspect(additionalPoint?.[0]); inspect(additionalPoint?.[1]); } return values.size; }; const projectT = (p: number[], a: number[], b: number[]): number => { const dx = b[0] - a[0], dy = b[1] - a[1]; const den = dx * dx + dy * dy; return den > EPS * EPS ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / den : 0; }; const distanceToSegment = (p: number[], a: number[], b: number[]): number => { const t = Math.max(0, Math.min(1, projectT(p, a, b))); return Math.hypot(p[0] - (a[0] + (b[0] - a[0]) * t), p[1] - (a[1] + (b[1] - a[1]) * t)); }; const collinearOverlap = ( a: number[], b: number[], c: number[], d: number[], epsilon = EPS, ): number => { const dx = b[0] - a[0], dy = b[1] - a[1], len = Math.hypot(dx, dy); if (len <= epsilon) return 0; const cross = (p: number[]) => Math.abs((p[0] - a[0]) * dy - (p[1] - a[1]) * dx) / len; if (cross(c) > epsilon || cross(d) > epsilon) return 0; const tc = projectT(c, a, b), td = projectT(d, a, b); return Math.max(0, Math.min(1, Math.max(tc, td)) - Math.max(0, Math.min(tc, td))) * len; }; /* Small synchronous SHA-256: structural commits are synchronous gestures, so * WebCrypto's Promise API cannot be the source of migration identity. */ const sha256 = (text: string): Uint8Array => { const bytes = new TextEncoder().encode(text); const bitLength = bytes.length * 8; const size = Math.ceil((bytes.length + 9) / 64) * 64; const data = new Uint8Array(size); data.set(bytes); data[bytes.length] = 0x80; const view = new DataView(data.buffer); view.setUint32(size - 8, Math.floor(bitLength / 0x100000000), false); view.setUint32(size - 4, bitLength >>> 0, false); const k = new Uint32Array([ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, ]); const h = new Uint32Array([ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, ]); const w = new Uint32Array(64); const rotr = (value: number, bits: number) => (value >>> bits) | (value << (32 - bits)); for (let offset = 0; offset < size; offset += 64) { for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4, false); for (let i = 16; i < 64; i++) { const x = w[i - 15], y = w[i - 2]; const s0 = rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3); const s1 = rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10); w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; } let [a, b, c, d, e, f, g, hh] = h; for (let i = 0; i < 64; i++) { const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); const ch = (e & f) ^ (~e & g); const t1 = (hh + s1 + ch + k[i] + w[i]) >>> 0; const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); const maj = (a & b) ^ (a & c) ^ (b & c); const t2 = (s0 + maj) >>> 0; hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0; } h[0] = (h[0] + a) >>> 0; h[1] = (h[1] + b) >>> 0; h[2] = (h[2] + c) >>> 0; h[3] = (h[3] + d) >>> 0; h[4] = (h[4] + e) >>> 0; h[5] = (h[5] + f) >>> 0; h[6] = (h[6] + g) >>> 0; h[7] = (h[7] + hh) >>> 0; } const out = new Uint8Array(32); const outView = new DataView(out.buffer); for (let i = 0; i < h.length; i++) outView.setUint32(i * 4, h[i], false); return out; }; const base32 = (bytes: Uint8Array): string => { const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; let bits = 0, value = 0, result = ''; for (const byte of bytes) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { result += alphabet[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits) result += alphabet[(value << (5 - bits)) & 31]; return result; }; export const deterministicWallSegmentId = ( spaceId: string, a: number[], b: number[], ownerIds: readonly string[], salt = '', ): string => { const [ca, cb] = canonicalSpan(a, b); const seed = `${spaceId}|${pointKey(ca)}|${pointKey(cb)}|${[...ownerIds].sort().join(',')}${salt}`; return `wall-${base32(sha256(seed)).slice(0, 20)}`; }; let fallbackIdSequence = 0; /** IDs created after v8 materialisation must never be derived from geometry. */ const freshWallSegmentId = (used: Set): string => { for (let attempt = 0; attempt < 1000; attempt++) { const uuid = globalThis.crypto?.randomUUID?.(); const entropy = uuid || base32(sha256( `${Date.now()}|${fallbackIdSequence++}|${Math.random()}`, )).slice(0, 26); const id = `wall-${entropy}`; if (!used.has(id)) return id; } throw new WallSegmentModelError('duplicate-id', 'id factory exhausted'); }; const nonCatalogIds = (space: any): Set => { const result = new Set(); for (const collection of [ space.rooms, space.openings, space.decor, space.room_drafts, space.partitions, space.wall_columns, ]) { for (const item of Array.isArray(collection) ? collection : []) { if (typeof item?.id === 'string' && item.id) result.add(item.id); } } for (const draft of Array.isArray(space.room_drafts) ? space.room_drafts : []) { for (const segment of Array.isArray(draft?.segments) ? draft.segments : []) { if (typeof segment?.id === 'string' && segment.id) result.add(segment.id); } } return result; }; const oldSegmentMap = (space: any): Map => { const result = new Map(); for (const segment of Array.isArray(space?.wall_segments) ? space.wall_segments : []) { if (!segment || typeof segment.id !== 'string' || !finitePoint(segment.a) || !finitePoint(segment.b)) continue; if (result.has(segment.id)) throw new WallSegmentModelError('duplicate-id', segment.id); result.set(segment.id, segment as WallSegmentEntry); } return result; }; const translatedSegmentDelta = ( a: number[], b: number[], previous: WallSegmentEntry, ): [number, number] | null => { const matches = (pa: number[], pb: number[]): [number, number] | null => { const dx = a[0] - pa[0], dy = a[1] - pa[1]; return Math.abs((b[0] - pb[0]) - dx) <= EPS && Math.abs((b[1] - pb[1]) - dy) <= EPS ? [dx, dy] : null; }; return matches(previous.a, previous.b) || matches(previous.b, previous.a); }; const openingHostCounts = (openings: readonly OpeningCfg[]): Map => { const result = new Map(); for (const opening of openings) { if (opening.host?.kind !== 'wall') continue; result.set(opening.host.id, (result.get(opening.host.id) || 0) + 1); } return result; }; const buildAtoms = ( space: any, old: ReadonlyMap, ): { atoms: Atom[]; rooms: any[] } => { const rooms = Array.isArray(space.rooms) ? space.rooms : []; const explicitLegacyCuts = sanitizeOpenSpans(space.open_spans).map((entry) => [ entry.a[0], entry.a[1], entry.b[0], entry.b[1], ]); const legacyCuts = explicitLegacyCuts.length ? explicitLegacyCuts : resolveOpenCuts( rooms, null, 1, GRID_STEP_N * 0.04, true, ); // Since v9 every bodyless contour atom is authoritative in wall_segments. // Feed its CURRENT carrier back into atomisation on every structural write: // a partial zero run must survive after the deprecated open_spans/open_to // projection has been removed, while Resize must not leave its old endpoint // behind as a phantom breakpoint. const canonicalZeroCuts: number[][] = []; for (const room of rooms) { const poly = roomPoly(room); const ids = Array.isArray(room?.wall_ids) ? room.wall_ids : []; if (!poly || ids.length !== poly.length) continue; for (let index = 0; index < ids.length; index++) { const previous = typeof ids[index] === 'string' ? old.get(ids[index]) : null; if (Number(previous?.cm) !== 0) continue; canonicalZeroCuts.push([ poly[index][0], poly[index][1], poly[(index + 1) % poly.length][0], poly[(index + 1) % poly.length][1], ]); } } 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(); 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 || []), ...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; let rigidDelta: [number, number] | null = null; let rigidIndexedLineage = indexedLineageIsValid; if (rigidIndexedLineage) { for (let index = 0; index < original.length; index++) { const previous = old.get(oldIds[index]); const delta = previous ? translatedSegmentDelta(original[index], original[(index + 1) % original.length], previous) : null; if (!delta || (rigidDelta && ( Math.abs(delta[0] - rigidDelta[0]) > EPS || Math.abs(delta[1] - rigidDelta[1]) > EPS ))) { rigidIndexedLineage = false; break; } rigidDelta = delta; } } const wallIds: string[] = []; for (let index = 0; index < atomic.poly.length; index++) { const a = atomic.poly[index], b = atomic.poly[(index + 1) % atomic.poly.length]; if (lengthOf(a, b) <= EPS) throw new WallSegmentModelError('zero-length', id); const key = atomKey(a, b); let atom = byKey.get(key); if (!atom) { const [ca, cb] = canonicalSpan(a, b); atom = { key, a: ca, b: cb, owners: new Set(), preferredIds: new Set(), positionalIds: new Set(), preferredCarriers: new Map(), parentKeys: new Set(), zeroWall: false, }; byKey.set(key, atom); } atom.owners.add(id); if (atom.owners.size > 2) throw new WallSegmentModelError('third-owner', key); const preferred = indexedLineageIsValid ? oldIds[atomic.parent[index]] : undefined; if (typeof preferred === 'string' && preferred) { const previous = old.get(preferred); // An index is a strong hint only while it still names the same // physical carrier. During a room split an edge can keep its ordinal // but become the new divider; a promoted draft ID on that divider must // outrank the stale ordinal. Pure rigid moves retain the ordinal as a // fallback because none of their old carriers overlap new geometry. if (rigidIndexedLineage || !previous || collinearOverlap(a, b, previous.a, previous.b) > EPS) atom.preferredIds.add(preferred); else atom.positionalIds.add(preferred); atom.preferredCarriers.set(preferred, { a: point(original[atomic.parent[index]]), b: point(original[(atomic.parent[index] + 1) % original.length]), }); } const parent = atomic.parent[index]; atom.parentKeys.add(wallKey( original[parent], original[(parent + 1) % original.length], GRID_STEP_N, )); const midpoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; 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 // helpers legitimately rebuild `open_to`; without this normalization the // semantic object is identical but JSON key order oscillates forever and // Optimize can never become byte-idempotent. const nextRoom = { ...rawRoom }; delete nextRoom.wall_ids; nextRooms.push({ ...nextRoom, poly: atomic.poly.map((p) => [p[0], p[1]]), wall_ids: wallIds, }); } return { atoms: [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key)), rooms: nextRooms }; }; /** * Preserve ordinal carrier identity for an operation that kept every edited * room's coarse topology. Atom counts must agree edge-by-edge; otherwise the * ordinary split/merge lineage rules remain authoritative. */ export const fixedTopologyWallLineageHints = ( baselineSpace: any, beforeRooms: readonly any[], editedSpace: any, ): Map => { const hints = new Map(); const baselineRooms = new Map((baselineSpace?.rooms || []).map((room: any) => [room.id, room])); const sourceRooms = new Map((beforeRooms || []).map((room: any) => [room.id, room])); const { rooms: editedRooms } = buildAtoms(editedSpace, oldSegmentMap(baselineSpace)); for (const editedRoom of editedRooms) { const source: any = sourceRooms.get(editedRoom.id); const baseline: any = baselineRooms.get(editedRoom.id); const sourcePoly = roomPoly(source), editedRaw = (editedSpace.rooms || []) .find((room: any) => room.id === editedRoom.id); const editedRawPoly = roomPoly(editedRaw), baselinePoly = roomPoly(baseline); if (!sourcePoly || !editedRawPoly || !baselinePoly || sourcePoly.length !== editedRawPoly.length || baselinePoly.length !== baseline?.wall_ids?.length || editedRoom.poly.length !== editedRoom.wall_ids.length) continue; for (let edge = 0; edge < sourcePoly.length; edge++) { const orderedPieces = (poly: number[][], ids: string[], a: number[], b: number[]) => { const pieces: Array<{ start: number; id: string }> = []; for (let index = 0; index < poly.length; index++) { const pa = poly[index], pb = poly[(index + 1) % poly.length]; if (distanceToSegment(pa, a, b) > EPS || distanceToSegment(pb, a, b) > EPS) continue; const start = projectT(pa, a, b), end = projectT(pb, a, b); if (start < -EPS || end > 1 + EPS || end <= start + EPS) continue; pieces.push({ start, id: ids[index] }); } return pieces.sort((left, right) => left.start - right.start); }; const oldPieces = orderedPieces( baselinePoly, baseline.wall_ids, sourcePoly[edge], sourcePoly[(edge + 1) % sourcePoly.length], ); const newPieces = orderedPieces( editedRoom.poly, editedRoom.wall_ids, editedRawPoly[edge], editedRawPoly[(edge + 1) % editedRawPoly.length], ); if (!oldPieces.length || oldPieces.length !== newPieces.length) continue; for (let index = 0; index < oldPieces.length; index++) { const existing = hints.get(newPieces[index].id); if (existing && existing !== oldPieces[index].id) throw new WallSegmentModelError('duplicate-id', newPieces[index].id); hints.set(newPieces[index].id, oldPieces[index].id); } } } return hints; }; const assignLineage = ( space: any, atoms: Atom[], old: Map, initialMigration: boolean, lineageHints?: ReadonlyMap, ): void => { const oldByKey = new Map([...old.values()].map((segment) => [atomKey(segment.a, segment.b), segment])); const hostCounts = openingHostCounts(space.openings || []); const proposed = new Map(); for (const atom of atoms) { const hintedId = lineageHints?.get(atom.key); if (hintedId) { const hinted = old.get(hintedId); if (!hinted) throw new WallSegmentModelError('duplicate-id', hintedId); proposed.set(atom, hinted); continue; } if (atom.preferredIds.size > 1) { throw new WallSegmentModelError('duplicate-id', [...atom.preferredIds].sort().join(',')); } const preferredId = [...atom.preferredIds][0]; const preferred = preferredId ? old.get(preferredId) : undefined; if (preferred) { proposed.set(atom, preferred); continue; } // Promotion from a persisted draft/partition supplies an ID which is not // in the old contour catalogue yet. Treat its old carrier exactly like a // contour split so only one child inherits it. const carrier = preferredId ? atom.preferredCarriers.get(preferredId) : undefined; if (preferredId && carrier) { proposed.set(atom, { id: preferredId, a: carrier.a, b: carrier.b, cm: 0 }); continue; } const exact = oldByKey.get(atom.key); if (exact) { proposed.set(atom, exact); continue; } const overlaps = [...old.values()].filter((segment) => ( collinearOverlap(atom.a, atom.b, segment.a, segment.b) > EPS )); // Geometry proves split/merge lineage more strongly than a stale edge // index. Positional preference is the rigid-move fallback only when the // edited segment no longer overlaps its previous coordinates at all. const candidates = [...new Map(overlaps .map((item) => [item.id, item])).values()]; candidates.sort((left, right) => ( (hostCounts.get(right.id) || 0) - (hostCounts.get(left.id) || 0) || lengthOf(right.a, right.b) - lengthOf(left.a, left.b) || left.id.localeCompare(right.id) )); if (candidates[0]) proposed.set(atom, candidates[0]); else if (atom.positionalIds.size === 1) { const positional = old.get([...atom.positionalIds][0]); if (positional) proposed.set(atom, positional); } } const byId = new Map(); for (const [atom, segment] of proposed) { const list = byId.get(segment.id) || []; list.push(atom); byId.set(segment.id, list); } for (const [id, candidates] of byId) { const oldSegment = old.get(id) || proposed.get(candidates[0]); if (!oldSegment) throw new WallSegmentModelError('duplicate-id', id); const midpoint = [ (oldSegment.a[0] + oldSegment.b[0]) / 2, (oldSegment.a[1] + oldSegment.b[1]) / 2, ]; candidates.sort((left, right) => { const midpointLeft = distanceToSegment(midpoint, left.a, left.b) <= EPS ? 0 : 1; const midpointRight = distanceToSegment(midpoint, right.a, right.b) <= EPS ? 0 : 1; if (midpointLeft !== midpointRight) return midpointLeft - midpointRight; const oldALeft = distanceToSegment(oldSegment.a, left.a, left.b) <= EPS ? 0 : 1; const oldARight = distanceToSegment(oldSegment.a, right.a, right.b) <= EPS ? 0 : 1; return oldALeft - oldARight || left.key.localeCompare(right.key); }); candidates[0].id = id; } const used = nonCatalogIds(space); for (const atom of atoms) { if (!atom.id) continue; if (used.has(atom.id)) throw new WallSegmentModelError('duplicate-id', atom.id); used.add(atom.id); } const unassigned = atoms.filter((atom) => !atom.id); if (initialMigration) { // A truncated-digest collision is resolved by full-digest order, then the // documented -2/-3 suffix. This is deterministic even if room order is not. const seeds = unassigned.map((atom) => { const [ca, cb] = canonicalSpan(atom.a, atom.b); const seed = `${String(space.id || '')}|${pointKey(ca)}|${pointKey(cb)}|${[...atom.owners].sort().join(',')}`; const digest = base32(sha256(seed)); return { atom, seed, digest, base: `wall-${digest.slice(0, 20)}` }; }).sort((left, right) => left.digest.localeCompare(right.digest) || left.atom.key.localeCompare(right.atom.key)); const fullDigests = new Map(); for (const entry of seeds) { const previousSeed = fullDigests.get(entry.digest); if (previousSeed && previousSeed !== entry.seed) throw new WallSegmentModelError('duplicate-id', entry.digest); fullDigests.set(entry.digest, entry.seed); let suffix = 1; let id = entry.base; while (used.has(id)) id = `${entry.base}-${++suffix}`; entry.atom.id = id; used.add(id); } } else { for (const atom of unassigned) { atom.id = freshWallSegmentId(used); used.add(atom.id); } } }; /** Resolve one contour-opening owner using the same fail-closed rule as the * structural writer. UI gestures use this before entering the v9 barrier, so * the live candidate never contains a stale/missing host during a valid move. */ export const resolveRoomOpeningHost = ( opening: OpeningCfg, segments: readonly WallSegmentEntry[], ): WallOpeningHost | null => { if (opening.host?.kind === 'partition') return null; const centre = [Number(opening.x), Number(opening.y)]; if (!centre.every(Number.isFinite)) return null; const eligible = (segment: WallSegmentEntry): boolean => { if (!(Number(segment.cm) > 0)) return false; const t = projectT(centre, segment.a, segment.b); const span = lengthOf(segment.a, segment.b); const half = Number(opening.length) / 2; 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; }; const current = opening.host?.kind === 'wall' ? segments.find((segment) => segment.id === opening.host!.id) : null; const candidates = current && eligible(current) ? [current] : segments.filter(eligible); if (candidates.length !== 1) return null; const host = candidates[0]; return { kind: 'wall', id: host.id, t: Math.max(0, Math.min(1, projectT(centre, host.a, host.b))), }; }; /** #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 (CODE-REVIEW-316-r1 H1): no distant fallback pool. A host away from // the opening's own x/y would violate the backend geometry-match invariant // («wall opening geometry must match its host») and wedge the write on the // schema layer — the very failure mode #316 removes. An opening without an // in-place carrier goes straight to the unhosted degraded state. const host = pick(eligible); 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; // #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); } }; type LegacyRoomDraftSpace = { id?: unknown; room_drafts?: Array<{ id?: unknown; points?: unknown; segments?: unknown; }>; rooms?: Array<{ id?: unknown }>; openings?: OpeningCfg[]; decor?: Array<{ id?: unknown }>; partitions?: PartitionCfg[]; wall_columns?: Array<{ id?: unknown }>; wall_segments?: WallSegmentEntry[]; }; const LEGACY_DRAFT_POINT_EPSILON = 0.001; const LEGACY_NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/; const legacyFiniteNumber = (value: unknown): number | null => { if (typeof value !== 'number' && typeof value !== 'string') return null; if (typeof value === 'string' && !LEGACY_NUMBER.test(value.trim())) return null; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; }; const legacyDraftPoint = (value: unknown): Point | null => { if (!Array.isArray(value) || value.length < 2) return null; const x = legacyFiniteNumber(value[0]), y = legacyFiniteNumber(value[1]); return x == null || y == null ? null : [x, y]; }; const legacyIdentity = (value: unknown): string => typeof value === 'string' ? value : ''; const migrateRoomDraftsToPartitions = ( space: LegacyRoomDraftSpace, ): { drafts: number; segments: number } => { if (!Object.prototype.hasOwnProperty.call(space, 'room_drafts')) return { drafts: 0, segments: 0 }; const drafts = Array.isArray(space.room_drafts) ? space.room_drafts : []; // #529: наследие снимается, а не запирает план — зеркало питоновской // миграции. Отказ здесь отбивал структурную правку ещё до отправки на // сервер, и план становился нередактируемым и невыгружаемым. if (!drafts.length) { delete space.room_drafts; return { drafts: 0, segments: 0 }; } const used = new Set(); for (const collection of [ space.rooms, space.openings, space.decor, space.partitions, space.wall_columns, space.wall_segments, ]) { for (const item of Array.isArray(collection) ? collection : []) { if (typeof item?.id === 'string' && item.id) used.add(item.id); } } // A draft id was globally reserved in v9 too. Keep it out of the generated // partition namespace even though the carrier itself disappears in v10. for (const draft of drafts) { if (typeof draft?.id === 'string' && draft.id) used.add(draft.id); } const partitions = Array.isArray(space.partitions) ? [...space.partitions] : []; let converted = 0; for (const draft of drafts) { const draftIdentity = legacyIdentity(draft?.id); const points = Array.isArray(draft?.points) ? draft.points : []; const segments = Array.isArray(draft?.segments) ? draft.segments : []; if (points.length < 2 || segments.length !== points.length - 1) throw new WallSegmentModelError('zero-length', draftIdentity); for (let index = 0; index + 1 < points.length; index++) { const a = legacyDraftPoint(points[index]), b = legacyDraftPoint(points[index + 1]); const segment = segments[index]; if (!a || !b || samePoint(a, b, LEGACY_DRAFT_POINT_EPSILON)) throw new WallSegmentModelError('zero-length', draftIdentity); const cm = legacyFiniteNumber(segment?.cm); if (cm == null || cm < 0 || cm > 100) throw new WallSegmentModelError('thickness-conflict', draftIdentity); let id = typeof segment?.id === 'string' && segment.id ? segment.id : ''; if (!id || used.has(id)) { const base = deterministicWallSegmentId( legacyIdentity(space.id), a, b, [`draft:${draftIdentity}:${index}`], ); id = base; for (let suffix = 2; used.has(id); suffix++) id = `${base}-${suffix}`; } used.add(id); partitions.push({ id, a: point(a), b: point(b), cm }); converted++; } } if (partitions.length) space.partitions = partitions; else delete space.partitions; delete space.room_drafts; return { drafts: drafts.length, segments: converted }; }; /** * Narrow legacy adapter used before Optimize geometry repair. Unlike the full * v10 identity barrier it does not require room contours to be valid yet. */ export const migrateLegacyRoomDraftsToPartitionsInPlace = ( space: LegacyRoomDraftSpace, ): { drafts: number; segments: number } => migrateRoomDraftsToPartitions(space); const resolvedThicknessCm = ( space: any, atom: Atom, previous: WallSegmentEntry | undefined, ): number => { if (atom.zeroWall) return 0; const walls: WallEntry[] = Array.isArray(space.walls) ? space.walls : []; const ownKey = wallKey(atom.a, atom.b, GRID_STEP_N); const candidates = walls.filter((wall) => { if (!(Number(wall.cm) > 0)) return false; if (wall.key === ownKey || atom.parentKeys.has(wall.key)) return true; if (!finitePoint(wall.a) || !finitePoint(wall.b)) return false; return collinearOverlap(atom.a, atom.b, wall.a, wall.b) >= lengthOf(atom.a, atom.b) - EPS; }).map((wall) => Math.max(1, Math.min(100, Number(wall.cm)))); const unique = new Set(candidates.map((cm) => cm.toFixed(9))); if (unique.size > 1) throw new WallSegmentModelError('thickness-conflict', atom.key); if (candidates.length) return candidates[0]; const resolved = thicknessCmAt(walls, atom.a, atom.b, GRID_STEP_N, 1); if (resolved > 0) return resolved; return Number(previous?.cm) > 0 ? Number(previous!.cm) : 0; }; const migrateSpace = ( space: any, initialMigration: boolean, lineageHints?: ReadonlyMap, ): { wallSegments: number; drafts: number; draftSegments: number } => { const draftMigration = migrateRoomDraftsToPartitions(space); const old = oldSegmentMap(space); const { atoms, rooms } = buildAtoms(space, old); assignLineage(space, atoms, old, initialMigration, lineageHints); const segments = atoms.map((atom): WallSegmentEntry => { const previous = atom.id ? old.get(atom.id) : undefined; const cm = resolvedThicknessCm(space, atom, previous); return { ...(previous || {}), id: atom.id!, a: [...atom.a], b: [...atom.b], cm, }; }); const idByKey = new Map(atoms.map((atom) => [atom.key, atom.id!])); for (const room of rooms) room.wall_ids = room.wall_ids.map((key: string) => idByKey.get(key)!); space.rooms = rooms; space.wall_segments = segments; space.walls = segments.filter((segment) => segment.cm > 0).map((segment) => ({ key: wallKey(segment.a, segment.b, GRID_STEP_N), cm: segment.cm, a: [...segment.a], b: [...segment.b], })); if (!space.walls.length) delete space.walls; delete space.open_spans; for (const room of space.rooms) delete room.open_to; hostRoomOpenings(space, segments, initialMigration); return { wallSegments: segments.reduce((count, segment) => count + (old.has(segment.id) ? 0 : 1), 0), drafts: draftMigration.drafts, draftSegments: draftMigration.segments, }; }; /** Pure, atomic structural candidate. Read paths must never call this helper. */ export function commitWallSegmentModel( input: T, options: WallSegmentCommitOptions = {}, ): WallSegmentCommitResult { const before = JSON.stringify(input); const config: any = clone(input); canonicalizeConfigGeometryInPlace(config); let migratedSegments = 0; let migratedDrafts = 0; let migratedDraftSegments = 0; const initialMigration = Number(config?.model_version || 0) < WALL_SEGMENT_MODEL_VERSION; for (const space of Array.isArray(config?.spaces) ? config.spaces : []) { const hints = options.lineageSpaceId === String(space?.id || '') ? options.lineageHints : undefined; const migrated = migrateSpace(space, initialMigration, hints); migratedSegments += migrated.wallSegments; migratedDrafts += migrated.drafts; migratedDraftSegments += migrated.draftSegments; } config.model_version = WALL_SEGMENT_MODEL_VERSION; canonicalizeConfigGeometryInPlace(config); return { config, changed: before !== JSON.stringify(config), migratedSegments, migratedDrafts, migratedDraftSegments, }; } const isRecord = (value: unknown): value is Record => ( !!value && typeof value === 'object' && !Array.isArray(value) ); /** * Adopt an already validated candidate without invalidating references held by * an active editor gesture. The editor historically mutates the current * space and its id-bearing children in place; replacing the complete config * after the identity barrier would leave those references attached to a * detached document until the next interaction. */ export function adoptWallSegmentModelCandidateInPlace(target: T, candidate: T): T { const adopt = (current: any, next: any): any => { if (Array.isArray(current) && Array.isArray(next)) { const currentById = new Map(); for (const item of current) { if (isRecord(item) && typeof item.id === 'string' && item.id) currentById.set(item.id, item); } const adopted = next.map((item, index) => { if (isRecord(item) && typeof item.id === 'string' && item.id) { const existing = currentById.get(item.id); return existing ? adopt(existing, item) : clone(item); } return index < current.length ? adopt(current[index], item) : clone(item); }); current.splice(0, current.length, ...adopted); return current; } if (isRecord(current) && isRecord(next)) { const entries = Object.entries(next).map(([key, value]) => [ key, key in current ? adopt(current[key], value) : clone(value), ] as const); // Reinsert in candidate order as well as adopting candidate values. // JSON byte idempotence is part of Optimize and history snapshots; an // in-place adapter must not retain stale property order from its target. for (const key of Object.keys(current)) delete current[key]; for (const [key, value] of entries) current[key] = value; return current; } return clone(next); }; return adopt(target, candidate); } /** In-place adapter for the already-pure Optimize candidate. */ export function commitWallSegmentModelInPlace(input: T): WallSegmentCommitResult { const result = commitWallSegmentModel(input); adoptWallSegmentModelCandidateInPlace(input, result.config); return { ...result, config: input }; }