From 1c2bd68d67a2ed2199765b5dca494bdac08e69d9 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 24 Jul 2026 10:10:16 -0400 Subject: [PATCH] fix(core): preserve legacy site children during scene healing --- .../use-scene-site-child-migration.test.ts | 75 +++++++++++++++++++ .../core/src/utils/heal-scene-graph.test.ts | 22 ++++++ packages/core/src/utils/heal-scene-graph.ts | 37 ++++++--- 3 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/store/use-scene-site-child-migration.test.ts diff --git a/packages/core/src/store/use-scene-site-child-migration.test.ts b/packages/core/src/store/use-scene-site-child-migration.test.ts new file mode 100644 index 000000000..fb5e343b7 --- /dev/null +++ b/packages/core/src/store/use-scene-site-child-migration.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import useScene from './use-scene' + +describe('legacy site child migration', () => { + beforeEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + } as never) + useScene.temporal.getState().clear() + }) + + test('keeps a flat building subtree referenced by an embedded site child', () => { + const embeddedBuilding = { + object: 'node', + id: 'building_legacy', + type: 'building', + parentId: null, + visible: true, + metadata: {}, + children: ['level_legacy'], + position: [0, 0, 0], + rotation: [0, 0, 0], + } + + useScene.getState().setScene( + { + site_legacy: { + object: 'node', + id: 'site_legacy', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: [embeddedBuilding], + }, + building_legacy: embeddedBuilding, + level_legacy: { + object: 'node', + id: 'level_legacy', + type: 'level', + parentId: null, + visible: true, + metadata: {}, + children: ['wall_legacy'], + level: 0, + }, + wall_legacy: { + object: 'node', + id: 'wall_legacy', + type: 'wall', + parentId: 'level_legacy', + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + }, + } as unknown as Record, + ['site_legacy'] as never, + ) + + const nodes = useScene.getState().nodes + expect(Object.keys(nodes).sort()).toEqual([ + 'building_legacy', + 'level_legacy', + 'site_legacy', + 'wall_legacy', + ]) + expect((nodes.site_legacy as { children: string[] }).children).toEqual(['building_legacy']) + }) +}) diff --git a/packages/core/src/utils/heal-scene-graph.test.ts b/packages/core/src/utils/heal-scene-graph.test.ts index 699d9ce1b..9245ea103 100644 --- a/packages/core/src/utils/heal-scene-graph.test.ts +++ b/packages/core/src/utils/heal-scene-graph.test.ts @@ -17,6 +17,28 @@ describe('healSceneNodes', () => { expect((nodes.wall_a as { children: string[] }).children).toEqual(['item_x']) }) + test('preserves legacy embedded site children for the scene migration', () => { + const building = { + id: 'building_legacy', + type: 'building', + parentId: null, + children: ['level_legacy'], + } + const { nodes, strippedChildRefs } = healSceneNodes({ + site_legacy: { + id: 'site_legacy', + type: 'site', + parentId: null, + children: [building, null], + }, + building_legacy: building, + level_legacy: { id: 'level_legacy', type: 'level', parentId: null, children: [] }, + }) + + expect(strippedChildRefs).toBe(1) + expect((nodes.site_legacy as { children: unknown[] }).children).toEqual([building]) + }) + test('drops childless zero-length walls and removes their parent reference', () => { const { nodes, droppedWallIds } = healSceneNodes({ level_0: { id: 'level_0', type: 'level', children: ['wall_zero', 'wall_real'] }, diff --git a/packages/core/src/utils/heal-scene-graph.ts b/packages/core/src/utils/heal-scene-graph.ts index 80780b2b2..e84ac7d3b 100644 --- a/packages/core/src/utils/heal-scene-graph.ts +++ b/packages/core/src/utils/heal-scene-graph.ts @@ -23,7 +23,7 @@ export interface HealSceneResult { nodes: Record /** Ids of zero-length walls that were dropped. */ droppedWallIds: string[] - /** Count of non-string (e.g. null) entries removed from `children` arrays. */ + /** Count of invalid non-string (e.g. null) entries removed from `children` arrays. */ strippedChildRefs: number /** * Count of child references removed because the child's `parentId` points at @@ -74,26 +74,43 @@ export function healSceneNodes(input: Record): HealSceneResult let strippedChildRefs = 0 let strippedStaleChildRefs = 0 - // Pass 2: clean `children` arrays — drop non-string entries (the `[null]` - // bug), references to walls we just removed, same-array duplicates, and - // stale references whose child's `parentId` names a different parent. + // Pass 2: clean `children` arrays — drop invalid non-string entries (the + // `[null]` bug), references to walls we just removed, same-array duplicates, + // and stale references whose child's `parentId` names a different parent. + // Legacy sites embedded full child objects; keep those for migrateNodes to + // flatten after healing instead of disconnecting the entire building. const nodes: Record = {} for (const [id, node] of Object.entries(kept)) { const children = (node as { children?: unknown })?.children if (Array.isArray(children)) { const seen = new Set() - const cleaned = children.filter((c): c is string => { - if (typeof c !== 'string' || dropped.has(c)) { + const cleaned = children.filter((child) => { + const embeddedSiteChildId = + (node as { type?: unknown }).type === 'site' && + child && + typeof child === 'object' && + typeof (child as { id?: unknown }).id === 'string' + ? (child as { id: string }).id + : null + if (embeddedSiteChildId) { + if (seen.has(embeddedSiteChildId)) { + strippedStaleChildRefs++ + return false + } + seen.add(embeddedSiteChildId) + return true + } + if (typeof child !== 'string' || dropped.has(child)) { strippedChildRefs++ return false } - if (seen.has(c)) { + if (seen.has(child)) { strippedStaleChildRefs++ return false } - seen.add(c) - const child = kept[c] as { parentId?: unknown } | undefined - if (child && typeof child.parentId === 'string' && child.parentId !== id) { + seen.add(child) + const childNode = kept[child] as { parentId?: unknown } | undefined + if (childNode && typeof childNode.parentId === 'string' && childNode.parentId !== id) { strippedStaleChildRefs++ return false }