mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
A plan that still carried a `room_drafts` key while already on the current wall model could not be edited at all. The card mirrors the same migration, so a structural edit was refused before the request ever left the browser; the toast sent the user to "Optimize plans", which reports that everything is already optimal because it looks at something else entirely; and the export path calls the same migration, so the one way out — take a backup, fix the file by hand — was shut too. An empty `room_drafts: []`, carrying no data at all, was enough to do it. The carrier is now removed the way the first migration removes it: an empty key silently, drafts converted one for one into partitions. The #478 protection against a stale client re-adding the carrier moves to the layer that can actually tell the two apart — `validate_wall_model_transition` sees both the submission and the stored plan, and refuses when the drafts appear over a plan that does not have them. It no longer keys on the submitted model number: a stale card echoes back the number it was given, which is exactly how the outdated client slipped past this guard and met "conflicting wall identifiers" instead of "update the card and reload the page". The schema invariant keeps refusing a non-empty carrier as the last line. Both mirrors change together and stay identical; the parity fixture is untouched. Issue: #529 User-Visible: yes
943 lines
41 KiB
TypeScript
943 lines
41 KiB
TypeScript
/**
|
|
* 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<T = any> {
|
|
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<string, string>;
|
|
lineageSpaceId?: string;
|
|
}
|
|
|
|
type Point = [number, number];
|
|
type Atom = {
|
|
key: string;
|
|
a: Point;
|
|
b: Point;
|
|
owners: Set<string>;
|
|
preferredIds: Set<string>;
|
|
positionalIds: Set<string>;
|
|
preferredCarriers: Map<string, { a: Point; b: Point }>;
|
|
parentKeys: Set<string>;
|
|
zeroWall: boolean;
|
|
id?: string;
|
|
};
|
|
|
|
const clone = <T>(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<string>();
|
|
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>): 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<string> => {
|
|
const result = new Set<string>();
|
|
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<string, WallSegmentEntry> => {
|
|
const result = new Map<string, WallSegmentEntry>();
|
|
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<string, number> => {
|
|
const result = new Map<string, number>();
|
|
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<string, WallSegmentEntry>,
|
|
): { 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<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 || []), ...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<string, string> => {
|
|
const hints = new Map<string, string>();
|
|
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<string, WallSegmentEntry>, initialMigration: boolean,
|
|
lineageHints?: ReadonlyMap<string, string>,
|
|
): void => {
|
|
const oldByKey = new Map([...old.values()].map((segment) => [atomKey(segment.a, segment.b), segment]));
|
|
const hostCounts = openingHostCounts(space.openings || []);
|
|
const proposed = new Map<Atom, WallSegmentEntry>();
|
|
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<string, Atom[]>();
|
|
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<string, string>();
|
|
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<string>();
|
|
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<string, string>,
|
|
): { 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<T>(
|
|
input: T, options: WallSegmentCommitOptions = {},
|
|
): WallSegmentCommitResult<T> {
|
|
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<string, any> => (
|
|
!!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<T>(target: T, candidate: T): T {
|
|
const adopt = (current: any, next: any): any => {
|
|
if (Array.isArray(current) && Array.isArray(next)) {
|
|
const currentById = new Map<string, any>();
|
|
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<T>(input: T): WallSegmentCommitResult<T> {
|
|
const result = commitWallSegmentModel(input);
|
|
adoptWallSegmentModelCandidateInPlace(input, result.config);
|
|
return { ...result, config: input };
|
|
}
|