From f8287f80c569ed3d0a6f956b70597c0ef7f4fdb6 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 29 Sep 2026 20:34:04 -0400 Subject: [PATCH] fix(core): legacy scenes with raw nodes load without crashing The load migrations added by #976 read stored nodes as if schema-parsed. The oldest stored scenes omit container fields the schema defaults (walls without `children`, openings placed by a legacy `offset` with no `position`), so `plateLevelContext` and `isFloorAnchoredOpening` threw and both the hosted authority and `setScene` failed to open the project. Every exported load migration now runs through `loadMigration`: it reads a view of the stored nodes with container defaults (arrays and empty objects) filled, never scalars whose absence migrations read, and strips the fills it carried through untouched so no stored node is rewritten by them. A migration that still throws is reported and skipped, so the scene loads as main loaded it instead of failing. Also: deleting a room re-poses items kept from a curved wall with the curve tangent at the item, not the wall chord (Bugbot on #976). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_013LKpG6PpZe6Jb4dBBAKtBG --- .../commands/structure/delete-zone.test.ts | 47 ++++ .../src/commands/structure/delete-zone.ts | 2 +- .../core/src/utils/ensure-scene-openings.ts | 9 +- .../core/src/utils/floor-opening-migration.ts | 16 +- .../core/src/utils/floor-plate-migration.ts | 16 +- .../core/src/utils/legacy-material-slots.ts | 9 +- .../core/src/utils/load-migration.test.ts | 86 ++++++ packages/core/src/utils/load-migration.ts | 101 +++++++ packages/core/src/utils/node-defaults.ts | 27 ++ .../utils/owned-floor-opening-migration.ts | 16 +- .../core/src/utils/raw-legacy-load.test.ts | 257 ++++++++++++++++++ .../src/utils/reconcile-structure-on-load.ts | 22 +- .../core/src/utils/room-zone-migration.ts | 17 +- .../src/utils/vertical-scene-migration.ts | 9 +- .../core/src/utils/wall-face-migration.ts | 17 +- 15 files changed, 635 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/utils/load-migration.test.ts create mode 100644 packages/core/src/utils/load-migration.ts create mode 100644 packages/core/src/utils/raw-legacy-load.test.ts diff --git a/packages/core/src/commands/structure/delete-zone.test.ts b/packages/core/src/commands/structure/delete-zone.test.ts index 93124a5199..f6562e9cf7 100644 --- a/packages/core/src/commands/structure/delete-zone.test.ts +++ b/packages/core/src/commands/structure/delete-zone.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { reconcileLevelStructure } from '../../lib/structure-kernel' import { ItemNode, SeparatorNode, WallNode, type ZoneNode } from '../../schema' +import { getWallCurveFrameAt, getWallCurveLength } from '../../systems/wall/wall-curve' import { SHARED_WALLS_DELETE_MESSAGE } from './delete-zone' import { deleteZone, divideZone, setZoneIntent } from './index' import { applyToScratch, type StructureNodes, structureChangeBatch } from './shared' @@ -240,3 +241,49 @@ describe('deleting a room', () => { expect(plan.payload.wallIds.sort()).toEqual(['wall_m0', 'wall_m1', 'wall_m2']) }) }) + +test('an item kept from a curved wall faces along the curve where it hung', () => { + const nodes: Record = fixture() + const corners: [number, number][] = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ] + for (let i = 0; i < 4; i++) + nodes[`wall_c${i}`] = WallNode.parse({ + id: `wall_c${i}`, + parentId: 'level_test', + start: corners[i], + end: corners[(i + 1) % 4], + ...(i === 0 ? { curveOffset: -1 } : {}), + }) + const curved = nodes.wall_c0 + const along = getWallCurveLength(curved) * 0.2 + nodes.item_shelf = ItemNode.parse({ + ...chair('item_shelf', 0, 0), + parentId: curved.id, + position: [along, 1, 0.1], + rotation: [0, 0.3, 0], + }) + curved.children = ['item_shelf'] + let n = 0 + const graph = applyToScratch( + nodes, + structureChangeBatch( + reconcileLevelStructure({ + levelId: 'level_test', + nodes, + mintId: (kind) => `${kind}_c${++n}`, + }).patches, + ), + ) + const [zone] = zones(graph) + const plan = deleteZone(graph, { zoneId: zone!.id, contents: 'keep' }) + const kept = applyToScratch(graph, structureChangeBatch(plan.changes)).item_shelf as ItemNode + const frame = getWallCurveFrameAt(curved, 0.2) + expect(kept.parentId).toBe('level_test') + expect(kept.rotation[1]).toBeCloseTo(0.3 - Math.atan2(frame.tangent.y, frame.tangent.x), 6) + expect(kept.position[0]).toBeCloseTo(frame.point.x + frame.normal.x * 0.1, 6) + expect(kept.position[2]).toBeCloseTo(frame.point.y + frame.normal.y * 0.1, 6) +}) diff --git a/packages/core/src/commands/structure/delete-zone.ts b/packages/core/src/commands/structure/delete-zone.ts index dd75f21f81..2af7eb41fa 100644 --- a/packages/core/src/commands/structure/delete-zone.ts +++ b/packages/core/src/commands/structure/delete-zone.ts @@ -59,7 +59,7 @@ function levelPose(nodes: StructureNodes, item: PositionedIntent, levelId: strin position[1], frame.point.y + frame.normal.y * position[2], ] - yaw -= Math.atan2(parent.end[1] - parent.start[1], parent.end[0] - parent.start[0]) + yaw -= Math.atan2(frame.tangent.y, frame.tangent.x) } else if (parent.type === 'ceiling') position[1] += parent.height ?? 2.7 else if ('position' in parent && Array.isArray(parent.position)) { const rotation = diff --git a/packages/core/src/utils/ensure-scene-openings.ts b/packages/core/src/utils/ensure-scene-openings.ts index 608eaaae86..8980aec813 100644 --- a/packages/core/src/utils/ensure-scene-openings.ts +++ b/packages/core/src/utils/ensure-scene-openings.ts @@ -1,7 +1,8 @@ import type { AnyNode, AnyNodeId } from '../schema' import { syncStairRises } from '../systems/stair/stair-rise-query' +import { loadMigration } from './load-migration' -export function ensureSceneOpenings(sourceNodes: Record) { +function ensureSceneOpeningsOnView(sourceNodes: Record) { let nodes = sourceNodes as Record const patches = new Map>() const kinds = new Set(Object.values(nodes).map((node) => node.type)) @@ -27,3 +28,9 @@ export function ensureSceneOpenings(sourceNodes: Record) { updates: [...patches].map(([id, data]) => ({ id, data })), } } + +export const ensureSceneOpenings = loadMigration( + 'stair and elevator openings', + ensureSceneOpeningsOnView, + (nodes) => ({ nodes: nodes as Record, changed: false, updates: [] }), +) diff --git a/packages/core/src/utils/floor-opening-migration.ts b/packages/core/src/utils/floor-opening-migration.ts index eb9541e9d4..a7e8334aa0 100644 --- a/packages/core/src/utils/floor-opening-migration.ts +++ b/packages/core/src/utils/floor-opening-migration.ts @@ -2,6 +2,7 @@ import { adjacentLevelId } from '../lib/floor-opening-intent' import { area, intersection, type Ring, union } from '../lib/polygon-boolean' import type { AnyNode, CeilingNode, SlabNode } from '../schema' import { FloorOpeningNode } from '../schema/nodes/floor-opening' +import { loadMigration } from './load-migration' function polygonKey(polygon: Ring) { return polygon.map(([x, z]) => [Math.round(x * 10_000), Math.round(z * 10_000)]) @@ -35,7 +36,7 @@ function matchingCeilingHole(nodes: Record, levelId: string, po }) } -export function migrateFloorOpeningNodes(sourceNodes: Record) { +function migrateFloorOpeningNodesOnView(sourceNodes: Record) { let nodes: Record = sourceNodes const groups = new Map< string, @@ -157,3 +158,16 @@ export function migrateFloorOpeningNodes(sourceNodes: Record) { } return { nodes, changed: nodes !== sourceNodes, adoptedHoles, created, dedupes, unions } } + +export const migrateFloorOpeningNodes = loadMigration( + 'floor openings', + migrateFloorOpeningNodesOnView, + (nodes) => ({ + nodes, + changed: false, + adoptedHoles: 0, + created: 0, + dedupes: 0, + unions: 0, + }), +) diff --git a/packages/core/src/utils/floor-plate-migration.ts b/packages/core/src/utils/floor-plate-migration.ts index 6bd72fba78..29e615d4e0 100644 --- a/packages/core/src/utils/floor-plate-migration.ts +++ b/packages/core/src/utils/floor-plate-migration.ts @@ -41,6 +41,7 @@ import { planFloorPieceAdoption, } from './floor-piece-adoption' import { legacyWallElevations } from './legacy-wall-datums' +import { loadMigration } from './load-migration' import { omitUndefined } from './omit-undefined' export type FloorPlateMigrationReport = { @@ -465,7 +466,7 @@ function clearDanglingFloorSources(nodes: Record) { /** A level whose hand-drawn floor pieces cannot be adopted exactly: migrate it without them. */ class AdoptionRejected extends Error {} -export function migrateFloorPlates(sourceNodes: Record) { +function migrateFloorPlatesOnView(sourceNodes: Record) { // Adoption is all-or-nothing per level: a level whose pieces are not all // consumed migrates exactly as it would without adoption. const skip = new Set() @@ -1609,7 +1610,7 @@ function runFloorPlateMigration( return { nodes: changed ? nodes : sourceNodes, plateIds, reports } } -export function migrateSlabSlots(sourceNodes: Record) { +function migrateSlabSlotsOnView(sourceNodes: Record) { const nodes = { ...sourceNodes } as Record let changed = false for (const node of Object.values(nodes)) { @@ -1623,3 +1624,14 @@ export function migrateSlabSlots(sourceNodes: Record) { } return { nodes: changed ? nodes : sourceNodes, changed } } + +export const migrateFloorPlates = loadMigration( + 'floor plates', + migrateFloorPlatesOnView, + (nodes) => ({ nodes, plateIds: [], reports: [] }), +) + +export const migrateSlabSlots = loadMigration('slab slots', migrateSlabSlotsOnView, (nodes) => ({ + nodes, + changed: false, +})) diff --git a/packages/core/src/utils/legacy-material-slots.ts b/packages/core/src/utils/legacy-material-slots.ts index d323dbfb3f..f950fccab8 100644 --- a/packages/core/src/utils/legacy-material-slots.ts +++ b/packages/core/src/utils/legacy-material-slots.ts @@ -1,6 +1,7 @@ import { parseMaterialRef, toSceneMaterialRef } from '../material-library' import { getEffectiveWallSurfaceMaterial, type WallSurfaceSide } from '../schema/nodes/wall' import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material' +import { loadMigration } from './load-migration' // Legacy inline finishes (`material*` / `materialPreset*`) → `node.slots`, // shared by the client loader and the hosted authority. Both must run it @@ -130,7 +131,7 @@ export function migrateSingleMaterialSlots( * read (walls, slabs, ceilings), for the hosted authority: the client loader * runs the same per-node functions in `migrateNodes`. */ -export function migrateStructuralMaterialSlots(sourceNodes: Record) { +function migrateStructuralMaterialSlotsOnView(sourceNodes: Record) { const materials: MintedMaterials = {} let nodes: Record | null = null for (const [id, value] of Object.entries(sourceNodes)) { @@ -148,3 +149,9 @@ export function migrateStructuralMaterialSlots(sourceNodes: Record ({ nodes, materials: {}, changed: false }), +) diff --git a/packages/core/src/utils/load-migration.test.ts b/packages/core/src/utils/load-migration.test.ts new file mode 100644 index 0000000000..9fe7f7c5c3 --- /dev/null +++ b/packages/core/src/utils/load-migration.test.ts @@ -0,0 +1,86 @@ +import { expect, spyOn, test } from 'bun:test' +import { loadMigration, loadNodeView } from './load-migration' + +const raw = () => ({ + wall: { id: 'wall', type: 'wall', start: [0, 0], end: [4, 0] }, + door: { id: 'door', type: 'door', parentId: 'wall', offset: 1 }, + slab: { id: 'slab', type: 'slab', polygon: [], metadata: { keep: true } }, +}) + +test('the view supplies container defaults and leaves scalar absences for migrations to read', () => { + const source = raw() + const { view } = loadNodeView(source) + expect(view.wall).toMatchObject({ children: [], metadata: {} }) + expect(view.door).toMatchObject({ position: [0, 0, 0], rotation: [0, 0, 0], offset: 1 }) + // Absence a migration reads: a legacy opening, a legacy slab solid to its level. + expect(view.door).not.toHaveProperty('floorThresholdVersion') + expect(view.slab).not.toHaveProperty('thickness') + expect(view.slab).toMatchObject({ holes: [], metadata: { keep: true } }) + expect(source).toEqual(raw()) + const complete = view as Record + expect(loadNodeView(complete).view).toBe(complete) +}) + +test('a migration that changes nothing returns the stored nodes themselves', () => { + const source = raw() + const migrate = loadMigration( + 'noop', + (nodes) => ({ nodes, changed: false }), + (nodes) => ({ nodes, changed: false }), + ) + expect(migrate(source).nodes).toBe(source) +}) + +test('fills a migration carried through are not written; values it wrote are', () => { + const source = raw() + const migrate = loadMigration( + 'rewrite', + (nodes) => ({ + nodes: { + ...nodes, + wall: { ...(nodes.wall as object), height: 3 }, + door: { ...(nodes.door as object), rotation: [0, 0, 0] }, + slab: nodes.slab, + }, + }), + (nodes) => ({ nodes }), + ) + const { nodes } = migrate(source) + expect(nodes.wall).toEqual({ ...source.wall, height: 3 }) + expect(nodes.door).toEqual({ ...source.door, rotation: [0, 0, 0] }) + expect(nodes.slab).toBe(source.slab) +}) + +test('a fill a migration grew in place is its output and stays', () => { + const source = raw() + const migrate = loadMigration( + 'grow', + (nodes) => { + ;(nodes.wall as { children: string[] }).children.push('door') + return { nodes } + }, + (nodes) => ({ nodes }), + ) + const { nodes } = migrate(source) + expect(nodes.wall).toEqual({ ...source.wall, children: ['door'] }) + expect(source.wall).not.toHaveProperty('children') +}) + +test('a migration that throws is reported and the scene loads without it', () => { + const error = spyOn(console, 'error').mockImplementation(() => {}) + try { + const source = raw() + const migrate = loadMigration( + 'broken', + () => { + throw new Error('boom') + }, + (nodes) => ({ nodes, changed: false }), + ) + expect(migrate(source)).toEqual({ nodes: source, changed: false }) + expect(migrate(source).nodes).toBe(source) + expect(String(error.mock.calls[0]?.[0])).toStartWith('[scene load] broken failed') + } finally { + error.mockRestore() + } +}) diff --git a/packages/core/src/utils/load-migration.ts b/packages/core/src/utils/load-migration.ts new file mode 100644 index 0000000000..75bde72460 --- /dev/null +++ b/packages/core/src/utils/load-migration.ts @@ -0,0 +1,101 @@ +import { containerDefaultFields } from './node-defaults' + +type Nodes = Record +type Filled = Map + +/** + * The stored nodes as the load migrations read them: container schema + * defaults (`children`, `holes`, `position`, `metadata`, …) supplied where a + * raw legacy node leaves them out. Scalar defaults stay absent: migrations read + * their absence (see `materializeNodeDefaults`). + */ +export function loadNodeView(source: Nodes): { view: Nodes; filled: Filled } { + let view: Nodes | null = null + const filled: Filled = new Map() + for (const [id, value] of Object.entries(source)) { + if (!value || typeof value !== 'object' || Array.isArray(value)) continue + const node = value as Record + if (typeof node.type !== 'string') continue + let next: Record | null = null + const keys: { key: string; json: string; value: unknown }[] = [] + for (const { key, json, value } of containerDefaultFields(node.type)) { + if (node[key] !== undefined) continue + next ??= { ...node } + next[key] = structuredClone(value) + keys.push({ key, json, value: next[key] }) + } + if (!next) continue + view ??= { ...source } + view[id] = next + filled.set(id, keys) + } + return { view: view ?? source, filled } +} + +/** + * Drops the view's fills a migration carried through untouched (the same + * instance, same content), so no stored node is rewritten by them. A value the + * migration wrote itself, even an equal one, is its output and stays. + */ +function restoreStoredShape(source: Nodes, view: Nodes, filled: Filled, migrated: Nodes): Nodes { + const out: Nodes = { ...migrated } + let same = Object.keys(out).length === Object.keys(source).length + for (const [id, node] of Object.entries(out)) { + const fields = filled.get(id) + if (fields && node && typeof node === 'object') { + const record = node as Record + const untouched = fields.filter( + ({ key, json, value }) => record[key] === value && JSON.stringify(value) === json, + ) + if (node === view[id] && untouched.length === fields.length) out[id] = source[id] + else if (untouched.length) { + const next = { ...record } + for (const { key } of untouched) delete next[key] + out[id] = next + } + } + if (out[id] !== source[id]) same = false + } + return same ? source : out +} + +function reportFailure(name: string, error: unknown) { + console.error(`[scene load] ${name} failed; the scene loads without it`, error) +} + +/** + * Wraps a load migration so a raw legacy scene can never stop a project + * opening: the migration reads {@link loadNodeView}, and if it still throws the + * scene loads without it — as main loaded it before the migration existed. + */ +export function loadMigration( + name: string, + migrate: (nodes: Nodes, ...args: A) => R, + unchanged: (nodes: Nodes) => R, +): (nodes: Nodes, ...args: A) => R { + return (source, ...args) => { + const { view, filled } = loadNodeView(source) + let result: R + try { + result = migrate(view, ...args) + } catch (error) { + reportFailure(name, error) + return unchanged(source) + } + if (!filled.size) return result + return { ...result, nodes: restoreStoredShape(source, view, filled, result.nodes) } as R + } +} + +/** {@link loadMigration} for a migration that returns the node map itself. */ +export function loadMapMigration( + name: string, + migrate: (nodes: Nodes, ...args: A) => R, +): (nodes: Nodes, ...args: A) => R { + const wrapped = loadMigration( + name, + (nodes: Nodes, ...args: A) => ({ nodes: migrate(nodes, ...args) as Nodes }), + (nodes) => ({ nodes }), + ) + return (source, ...args) => wrapped(source, ...args).nodes as R +} diff --git a/packages/core/src/utils/node-defaults.ts b/packages/core/src/utils/node-defaults.ts index 661df110a9..6edf8e395e 100644 --- a/packages/core/src/utils/node-defaults.ts +++ b/packages/core/src/utils/node-defaults.ts @@ -44,6 +44,33 @@ function defaultFields(kind: string): DefaultField[] | undefined { return defaultsByKind.get(kind) } +let containerDefaultsByKind: Map | undefined + +type ContainerDefault = { key: string; value: unknown; json: string } + +/** + * The container defaults of a kind: arrays (`children`, `holes`, `position`, …) + * and empty objects (`metadata`). Unlike a scalar default (a legacy slab + * without `thickness` is solid down to its level), an omitted container means + * exactly its default: the reload after {@link materializeNodeDefaults} reads + * it filled and must migrate the same. + */ +export function containerDefaultFields(kind: string): readonly ContainerDefault[] { + containerDefaultsByKind ??= new Map() + let fields = containerDefaultsByKind.get(kind) + if (!fields) { + fields = (defaultFields(kind) ?? []).flatMap(({ key, schema }) => { + const value = schema.parse(undefined) + const container = + Array.isArray(value) || + (value !== null && typeof value === 'object' && Object.keys(value).length === 0) + return container ? [{ key, value, json: JSON.stringify(value) }] : [] + }) + containerDefaultsByKind.set(kind, fields) + } + return fields +} + /** * Fills the schema defaults a stored built-in node leaves out, as the last * step of every load (client `setScene`, hosted authority). The migrations run diff --git a/packages/core/src/utils/owned-floor-opening-migration.ts b/packages/core/src/utils/owned-floor-opening-migration.ts index b008444f8c..2bd9c63bfc 100644 --- a/packages/core/src/utils/owned-floor-opening-migration.ts +++ b/packages/core/src/utils/owned-floor-opening-migration.ts @@ -6,8 +6,9 @@ import { syncAutoElevatorOpenings } from '../systems/elevator/elevator-opening-s import { ensureMissingSlabOpenings } from '../systems/slab/ensure-slab-openings' import { syncAutoStairOpenings } from '../systems/stair/stair-opening-sync' import { syncStairRises } from '../systems/stair/stair-rise-query' +import { loadMapMigration, loadMigration } from './load-migration' -export function materializeLegacyAutoOpenings( +function materializeLegacyAutoOpeningsOnView( sourceNodes: Record, refreshExisting = false, ) { @@ -229,7 +230,7 @@ function collapseOwnedOpenings(sourceNodes: Record) { return nodes } -export function migrateOwnedFloorOpenings(sourceNodes: Record) { +function migrateOwnedFloorOpeningsOnView(sourceNodes: Record) { let nodes = sourceNodes for (const value of Object.values(sourceNodes)) { const surface = value as SlabNode | CeilingNode @@ -312,3 +313,14 @@ export function migrateOwnedFloorOpenings(sourceNodes: Record) } return { nodes, changed: nodes !== sourceNodes } } + +export const materializeLegacyAutoOpenings = loadMapMigration( + 'legacy auto openings', + materializeLegacyAutoOpeningsOnView, +) + +export const migrateOwnedFloorOpenings = loadMigration( + 'owned floor openings', + migrateOwnedFloorOpeningsOnView, + (nodes) => ({ nodes, changed: false }), +) diff --git a/packages/core/src/utils/raw-legacy-load.test.ts b/packages/core/src/utils/raw-legacy-load.test.ts new file mode 100644 index 0000000000..07eb5e4f1f --- /dev/null +++ b/packages/core/src/utils/raw-legacy-load.test.ts @@ -0,0 +1,257 @@ +import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import type { z } from 'zod' +import { type AnyNode, AnyNode as AnyNodeSchema, nodeKindOf } from '../schema/types' +import useScene, { clearSceneHistory } from '../store/use-scene' +import * as m from './scene-migrations' + +// The hosted authority's load (`normalize-authority-scene.ts` in the hosted +// app) without its plugin and reachability steps: every structure migration, +// called one by one on the stored nodes. +function authorityLoad(source: Record) { + const healed = m.healSceneNodes(m.normalizeLegacyStructure(source)) + const { nodes: retained } = m.removeRetiredDrawingSheetNodes(healed.nodes) + const legacyMaterials = m.migrateStructuralMaterialSlots(retained) + const vertical = m.migrateVerticalSceneNodes(legacyMaterials.nodes) + const rooms = m.migrateRoomZones(vertical.nodes) + const ceilings = m.migrateCeilingRoomLinks(rooms.nodes) + const nodes = Object.values(ceilings.nodes) as AnyNode[] + const legacyOpeningsPrepared = + nodes.some((node) => node.type === 'slab' && node.autoFromWalls && !node.plateRole) && + nodes.some((node) => node.type === 'stair' || node.type === 'elevator') + const plates = m.migrateFloorPlates(m.materializeLegacyAutoOpenings(ceilings.nodes, true)) + const slots = m.migrateSlabSlots(plates.nodes) + const openings = m.ensureSceneOpenings(slots.nodes) + const wallFaces = m.migrateWallFaceKeys(openings.nodes) + const wallBands = m.migrateWallFaceBands(wallFaces.nodes) + const structure = m.reconcileStructureOnLoad(wallBands.nodes, vertical.nodes, { + legacyOpeningsPrepared, + }) + return m.materializeNodeDefaults(structure.nodes, m.STRUCTURE_NODE_KINDS).nodes +} + +function clientLoad(source: Record, rootNodeIds?: string[]) { + const roots = + rootNodeIds ?? + Object.values(source) + .filter((node) => (node as AnyNode).type === 'site') + .map((node) => (node as AnyNode).id) + useScene.getState().setScene(source as Record, roots as AnyNode['id'][]) + return useScene.getState().nodes as Record +} + +// Shaped like the oldest stored scenes: walls without `children`, openings +// placed by a legacy `offset` with no `position`, a slab drawn by `vertices`, +// a documentation-only zone, no level `height`. +function rawLegacyScene(): Record> { + const wall = (id: string, start: number[], end: number[]) => ({ + id, + type: 'wall', + object: 'node', + visible: true, + parentId: 'level_floor1', + start, + end, + height: 3, + thickness: 0.2, + frontSide: 'exterior', + backSide: 'interior', + }) + return { + site: { + id: 'site', + type: 'site', + object: 'node', + visible: true, + metadata: {}, + parentId: null, + }, + building: { + id: 'building', + type: 'building', + object: 'node', + visible: true, + metadata: {}, + parentId: 'site', + }, + level_floor1: { + id: 'level_floor1', + type: 'level', + object: 'node', + name: 'First Floor', + visible: true, + metadata: { elevation: 0 }, + parentId: 'building', + }, + wall_north: wall('wall_north', [0, 0], [5, 0]), + wall_east: wall('wall_east', [5, 0], [5, 4]), + wall_south: wall('wall_south', [5, 4], [0, 4]), + wall_west: wall('wall_west', [0, 4], [0, 0]), + door_001: { + id: 'door_001', + type: 'door', + object: 'node', + visible: true, + width: 0.9, + height: 2.1, + offset: 1, + metadata: { type: 'single', swing: 'inward' }, + parentId: 'wall_south', + }, + window_001: { + id: 'window_001', + type: 'window', + object: 'node', + visible: true, + width: 1.5, + height: 1.2, + offset: 1.5, + sillHeight: 0.9, + metadata: { type: 'fixed' }, + parentId: 'wall_west', + }, + slab_floor: { + id: 'slab_floor', + type: 'slab', + object: 'node', + visible: true, + metadata: { material: 'concrete' }, + parentId: 'level_floor1', + vertices: [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], + ], + thickness: 0.15, + }, + zone_ward_room: { + id: 'zone_ward_room', + type: 'zone', + object: 'node', + name: 'Patient Ward #101', + visible: true, + metadata: { area: 20, capacity: 2, function: 'patient_room' }, + parentId: 'level_floor1', + }, + } +} + +let loadErrors: unknown[][] = [] +let restoreSpies: Array<() => void> = [] +const previous = useScene.getState() +beforeEach(() => { + loadErrors = [] + // A load migration that throws is reported and skipped; these tests demand + // none is, so a crash cannot hide behind the fallback. + const error = spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + if (String(args[0]).startsWith('[scene load]')) loadErrors.push(args) + }) + const warn = spyOn(console, 'warn').mockImplementation((...args: unknown[]) => { + if (String(args[0]).startsWith('[floor plates] Keeping existing')) loadErrors.push(args) + }) + restoreSpies = [() => error.mockRestore(), () => warn.mockRestore()] +}) +afterEach(() => { + for (const restore of restoreSpies) restore() + useScene.setState(previous, true) + clearSceneHistory() +}) + +test('a raw legacy scene loads through the hosted authority without crashing', () => { + const source = rawLegacyScene() + const snapshot = structuredClone(source) + const loaded = authorityLoad(source) + expect(loadErrors).toEqual([]) + expect(source).toEqual(snapshot) + for (const id of Object.keys(source)) expect(loaded).toHaveProperty(id) + // The room the four walls enclose exists, with its floor built. + const room = Object.values(loaded).find( + (node) => + (node as AnyNode).type === 'zone' && + (node as AnyNode & { autoFromWalls?: boolean }).autoFromWalls, + ) + expect(room).toBeDefined() + // Openings keep main's stored shape: the legacy `offset` stays, and the load + // writes no `position` main never stored. + expect(loaded.door_001).toMatchObject({ offset: 1, parentId: 'wall_south' }) + expect(loaded.door_001).not.toHaveProperty('position') + expect(loaded.window_001).not.toHaveProperty('position') + expect(loaded.wall_north).toMatchObject({ start: [0, 0], end: [5, 0], thickness: 0.2 }) + // A second load (the authority re-normalizes what it serves) changes nothing. + expect(authorityLoad(loaded)).toEqual(loaded) +}) + +test('a raw legacy scene loads through setScene without crashing', () => { + const loaded = clientLoad(rawLegacyScene()) + expect(loadErrors).toEqual([]) + for (const id of Object.keys(rawLegacyScene())) expect(loaded).toHaveProperty(id) + // As main loaded it: the schema default pose, the legacy offset untouched. + expect(loaded.door_001).toMatchObject({ position: [0, 0, 0], offset: 1 }) + expect((loaded.wall_south as AnyNode & { children: string[] }).children).toContain('door_001') + expect(loaded.wall_east).toMatchObject({ children: [], start: [5, 0], end: [5, 4] }) +}) + +// Robustness gate: stored nodes may omit any field the schema defaults or +// leaves optional. Strip them at random (seeded) and demand every load +// migration still runs. +const optionalKeys = new Map() +for (const option of AnyNodeSchema.options) { + optionalKeys.set( + nodeKindOf(option), + Object.entries(option.shape as Record) + .filter( + ([key, schema]) => + !['id', 'type', 'object', 'parentId'].includes(key) && + schema.safeParse(undefined).success, + ) + .map(([key]) => key), + ) +} + +function stripOptionalFields(nodes: Record, seed: number, rate: number) { + let state = seed >>> 0 + const random = () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + return state / 2 ** 32 + } + return Object.fromEntries( + Object.entries(nodes).map(([id, value]) => { + const node = { ...(value as Record) } + for (const key of optionalKeys.get(node.type as string) ?? []) + if (random() < rate) delete node[key] + return [id, node] + }), + ) +} + +const corpus = [ + 'plate-corpus/frozen-gate/scene-10.json', + 'plate-corpus/frozen-gate/scene-21.json', + 'plate-corpus/legacy-load/scene-04.json', + 'plate-corpus/legacy-load/scene-22.json', + 'plate-corpus/review/scene-06.json', + 'plate-corpus/review/scene-17.json', +] + +for (const file of ['raw legacy scene', ...corpus]) { + test(`raw legacy robustness: ${file} loads with optional fields stripped`, () => { + const scene = + file === 'raw legacy scene' + ? { nodes: rawLegacyScene() } + : JSON.parse(readFileSync(new URL(`../lib/__fixtures__/${file}`, import.meta.url), 'utf8')) + const nodes = (scene.nodes ?? scene) as Record + const roots = Array.isArray(scene.rootNodeIds) ? scene.rootNodeIds : undefined + for (const [seed, rate] of [ + [1, 1], + [2, 0.5], + [3, 0.5], + [4, 0.25], + ] as const) { + const stripped = stripOptionalFields(nodes, seed, rate) + expect(() => authorityLoad(stripped)).not.toThrow() + expect(() => clientLoad(stripped, roots)).not.toThrow() + expect({ seed, errors: loadErrors }).toEqual({ seed, errors: [] }) + } + }) +} diff --git a/packages/core/src/utils/reconcile-structure-on-load.ts b/packages/core/src/utils/reconcile-structure-on-load.ts index a4268450d7..4a7bd7a3d9 100644 --- a/packages/core/src/utils/reconcile-structure-on-load.ts +++ b/packages/core/src/utils/reconcile-structure-on-load.ts @@ -11,6 +11,7 @@ import { migrateExteriorThresholds } from './exterior-threshold-migration' import { migrateFootprintFollowing } from './floor-follow-migration' import { migrateFloorOpeningNodes } from './floor-opening-migration' import { preserveLegacyWallDatums } from './legacy-wall-datums' +import { loadMigration, loadNodeView } from './load-migration' import { materializeLegacyAutoOpenings, migrateOwnedFloorOpenings, @@ -128,10 +129,10 @@ export function alignLegacyStairCuts(source: Record) { return nodes } -export function reconcileStructureOnLoad( +function reconcileStructureOnView( sourceNodes: Record, - legacyNodes: Record = sourceNodes, - options: { legacyOpeningsPrepared?: boolean } = {}, + legacyNodes: Record, + options: { legacyOpeningsPrepared?: boolean }, ) { const legacyOpeningsPrepared = options.legacyOpeningsPrepared || @@ -329,3 +330,18 @@ export function reconcileStructureOnLoad( upperStoreyFootprints(nodes) return { nodes, changed: nodes !== sourceNodes } } + +export const reconcileStructureOnLoad = loadMigration( + 'structure reconcile', + ( + view: Record, + legacyNodes?: Record, + options: { legacyOpeningsPrepared?: boolean } = {}, + ) => + reconcileStructureOnView( + view, + legacyNodes === undefined ? view : loadNodeView(legacyNodes).view, + options, + ), + (nodes) => ({ nodes: nodes as SceneNodes, changed: false }), +) diff --git a/packages/core/src/utils/room-zone-migration.ts b/packages/core/src/utils/room-zone-migration.ts index 9f884080e0..a994417333 100644 --- a/packages/core/src/utils/room-zone-migration.ts +++ b/packages/core/src/utils/room-zone-migration.ts @@ -24,6 +24,7 @@ import type { SeparatorNode } from '../schema/nodes/separator' import type { WallNode } from '../schema/nodes/wall' import { ZoneNode } from '../schema/nodes/zone' import { calculateLevelMiters, getWallPlanFootprint } from '../systems/wall/wall-footprint' +import { loadMigration } from './load-migration' import { omitUndefined } from './omit-undefined' type SceneNodes = Record @@ -111,7 +112,7 @@ function roomZoneId(levelId: string, face: RoomFace, attempt = 0): ZoneNode['id' return `zone_${hash.toString(36).padStart(16, '0')}` } -export function migrateRoomZones(sourceNodes: Record): RoomZoneMigration { +function migrateRoomZonesOnView(sourceNodes: Record): RoomZoneMigration { const nodes: SceneNodes = { ...sourceNodes } const createdZoneIds: string[] = [] const adoptedZoneIds: string[] = [] @@ -228,7 +229,7 @@ export function migrateRoomZones(sourceNodes: Record): RoomZone } } -export function migrateCeilingRoomLinks( +function migrateCeilingRoomLinksOnView( sourceNodes: Record, ): CeilingRoomLinkMigration { const nodes: SceneNodes = { ...sourceNodes } @@ -325,3 +326,15 @@ export function migrateCeilingRoomLinks( ceilinglessZoneIds, } } + +export const migrateRoomZones = loadMigration('room zones', migrateRoomZonesOnView, (nodes) => ({ + nodes, + createdZoneIds: [], + adoptedZoneIds: [], +})) + +export const migrateCeilingRoomLinks = loadMigration( + 'ceiling room links', + migrateCeilingRoomLinksOnView, + (nodes) => ({ nodes, linkedCeilingIds: [], ceilinglessZoneIds: [] }), +) diff --git a/packages/core/src/utils/vertical-scene-migration.ts b/packages/core/src/utils/vertical-scene-migration.ts index d490f8a3a1..b8243fc93e 100644 --- a/packages/core/src/utils/vertical-scene-migration.ts +++ b/packages/core/src/utils/vertical-scene-migration.ts @@ -4,6 +4,7 @@ import { deriveLegacyLevelHeight } from '../services/level-height' import { getCeilingClampBound } from '../services/storey' import { computeWallSlabSupport } from '../systems/slab/slab-support' import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint' +import { loadMigration } from './load-migration' export type VerticalSceneMigration = { changed: boolean @@ -38,7 +39,7 @@ const BURIED_PIN_EPSILON = 1e-3 * hosted scene authority both call it so they compare and persist the same * canonical fields during collaboration. */ -export function migrateVerticalSceneNodes( +function migrateVerticalSceneNodesOnView( sourceNodes: Record, ): VerticalSceneMigration { const nodes: Record = { ...sourceNodes } @@ -231,3 +232,9 @@ export function migrateVerticalSceneNodes( return changed ? { changed, nodes } : { changed, nodes: sourceNodes } } + +export const migrateVerticalSceneNodes = loadMigration( + 'vertical migration', + migrateVerticalSceneNodesOnView, + (nodes) => ({ nodes, changed: false }), +) diff --git a/packages/core/src/utils/wall-face-migration.ts b/packages/core/src/utils/wall-face-migration.ts index e0b967dcdb..2072ac8257 100644 --- a/packages/core/src/utils/wall-face-migration.ts +++ b/packages/core/src/utils/wall-face-migration.ts @@ -1,4 +1,5 @@ import type { WallFace, WallFaceRegion } from '../schema/nodes/wall' +import { loadMigration } from './load-migration' // Load migrations M1 and M2 of the room-first structure plan. Both are pure, // idempotent and server-safe: they read and write plain node records only. @@ -170,7 +171,7 @@ function migrateWallNode(node: RawNode): RawNode { * side finishes move to `legacyFaceMaterials`; single-sided trims name a face. * The resolved finish of every face is unchanged. */ -export function migrateWallFaceKeys(sourceNodes: Record) { +function migrateWallFaceKeysOnView(sourceNodes: Record) { let nodes: Record | null = null for (const [id, node] of Object.entries(sourceNodes)) { if (!isRecord(node) || node.type !== 'wall' || !needsFaceKeyMigration(node)) continue @@ -255,7 +256,7 @@ const BAND_SLOT_KEYS = new Set( * the same heights and finishes; `faceBands` and the band slot keys are dropped. * Runs after M1 (it reads the geometric band keys M1 writes). */ -export function migrateWallFaceBands(sourceNodes: Record) { +function migrateWallFaceBandsOnView(sourceNodes: Record) { let nodes: Record | null = null for (const [id, node] of Object.entries(sourceNodes)) { if (!isRecord(node) || node.type !== 'wall') continue @@ -279,3 +280,15 @@ export function migrateWallFaceBands(sourceNodes: Record) { } return { nodes: nodes ?? sourceNodes, changed: nodes !== null } } + +export const migrateWallFaceKeys = loadMigration( + 'wall face keys', + migrateWallFaceKeysOnView, + (nodes) => ({ nodes, changed: false }), +) + +export const migrateWallFaceBands = loadMigration( + 'wall face bands', + migrateWallFaceBandsOnView, + (nodes) => ({ nodes, changed: false }), +)