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