Skip to content

Commit ff34d3c

Browse files
authored
Fix: Restore floor drag/delete functionality for imported legacy JSON files
Summary Fixed an issue where floors became impossible to drag or delete after importing JSON configuration files created in versions prior to 0.9.1. Root Cause The migration process was executing elevator parent migration before all level nodes had their children fully normalized. As a result, imported scenes could end up with inconsistent parent-child relationships, causing floor management operations such as dragging and deletion to fail. Changes Made Refactored migrateNodes() into a two-pass migration process. Added normalization for level nodes: Ensures level values are valid finite numbers. Removes references to missing child nodes. Preserves only valid children during migration. Moved elevator migration logic to a dedicated second pass: Elevator parent migration now runs only after all level.children relationships have been stabilized. Prevents invalid hierarchy reconstruction when importing legacy JSON files. Result Imported layouts from versions prior to 0.9.1 now correctly preserve floor hierarchy, allowing floors to be dragged, reordered, and deleted as expected.
1 parent a0d3d9c commit ff34d3c

1 file changed

Lines changed: 60 additions & 8 deletions

File tree

packages/core/src/store/use-scene.ts

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,14 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
419419
// any per-type migration runs, so already-saved scenes load cleanly.
420420
const { nodes: healed } = healSceneNodes(nodes)
421421
const patchedNodes = { ...healed } as Record<string, any>
422+
423+
// Pass 1: all node types except elevator.
424+
// Elevator migration (migrateElevatorParent) mutates level.children to remove
425+
// the elevator ID. If the elevator is processed before its parent level in
426+
// Object.entries order, the level migration in this same pass would then see
427+
// a children array that still contains the elevator ID and filter it out as
428+
// "missing" — corrupting the level. Running elevators in a second pass after
429+
// all levels are stable avoids the race entirely.
422430
for (const [id, node] of Object.entries(patchedNodes)) {
423431
// 1. Item scale migration
424432
if (node.type === 'item' && !('scale' in node)) {
@@ -525,14 +533,6 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
525533
}
526534
}
527535

528-
if (node.type === 'elevator') {
529-
const parentMigrated = migrateElevatorParent(id, node, patchedNodes)
530-
const normalized = normalizeElevatorNode(parentMigrated)
531-
if (normalized) {
532-
patchedNodes[id] = normalized
533-
}
534-
}
535-
536536
// Roof-segment hosting was added in this migration cycle (the same
537537
// pattern as shelf above). Older segments saved before the schema
538538
// gained `children` need the field initialised so
@@ -621,7 +621,59 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
621621
patchedNodes[id] = { ...node, children: flattened }
622622
}
623623
}
624+
625+
// Level children normalization.
626+
// Pre-0.9.1 JSONs may carry child IDs that no longer exist in the node
627+
// map (e.g. elevator IDs that lived under a level before the elevator
628+
// parent migration moved them up to building). If those dangling IDs are
629+
// left in place, collectReachableNodeIds marks the level as having
630+
// reachable children that don't exist, which corrupts the scene graph
631+
// traversal and leaves the LevelNode in a broken state — making floors
632+
// impossible to drag or delete after import.
633+
// We intentionally do NOT filter by type prefix here; being permissive
634+
// about which types are allowed as children prevents data loss when new
635+
// child types are added to the schema in the future.
636+
if (node.type === 'level') {
637+
const rawChildren = getStringArray(node.children)
638+
const validChildren = rawChildren.filter((childId) => {
639+
const exists = Boolean(patchedNodes[childId])
640+
if (!exists) {
641+
console.warn(
642+
'[migrateNodes] level',
643+
id,
644+
'references missing child',
645+
childId,
646+
'— dropping',
647+
)
648+
}
649+
return exists
650+
})
651+
const levelNumber = getFiniteNumber(node.level, 0)
652+
patchedNodes[id] = {
653+
...node,
654+
level: levelNumber,
655+
children: validChildren,
656+
}
657+
}
658+
}
659+
660+
// Pass 2: elevator migration.
661+
// migrateElevatorParent mutates the parent level's children array (removes
662+
// the elevator ID from it). Running this after Pass 1 guarantees that the
663+
// level normalization above has already seen a clean children list — if we
664+
// ran elevator migration inside Pass 1, the order of Object.entries
665+
// iteration would be non-deterministic: processing an elevator before its
666+
// parent level would mutate the level's children mid-iteration, potentially
667+
// causing the level branch above to see a stale node reference.
668+
for (const [id, node] of Object.entries(patchedNodes)) {
669+
if (node.type !== 'elevator') continue
670+
const parentMigrated = migrateElevatorParent(id, node, patchedNodes)
671+
const normalized = normalizeElevatorNode(parentMigrated)
672+
if (normalized) {
673+
patchedNodes[id] = normalized
674+
}
624675
}
676+
625677
return patchedNodes as Record<string, AnyNode>
626678
}
627679

0 commit comments

Comments
 (0)