fix(core): preserve legacy site children during scene healing (#544)

This commit is contained in:
Wassim SAMAD
2026-07-24 10:12:27 -04:00
committed by GitHub
parent 99812cd4cb
commit f0985df887
3 changed files with 124 additions and 10 deletions
@@ -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<string, AnyNode>,
['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'])
})
})
@@ -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'] },
+27 -10
View File
@@ -23,7 +23,7 @@ export interface HealSceneResult {
nodes: Record<string, unknown>
/** 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<string, unknown>): 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<string, unknown> = {}
for (const [id, node] of Object.entries(kept)) {
const children = (node as { children?: unknown })?.children
if (Array.isArray(children)) {
const seen = new Set<string>()
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
}