From ff34d3c5f58b9b4ffdb4c9130cc86f7a69ac0dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Tue, 16 Jun 2026 13:53:05 -0300 Subject: [PATCH 1/2] 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. --- packages/core/src/store/use-scene.ts | 68 ++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index a3e7a009e..f1eda5474 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -419,6 +419,14 @@ function migrateNodes(nodes: Record): Record { // any per-type migration runs, so already-saved scenes load cleanly. const { nodes: healed } = healSceneNodes(nodes) const patchedNodes = { ...healed } as Record + + // Pass 1: all node types except elevator. + // Elevator migration (migrateElevatorParent) mutates level.children to remove + // the elevator ID. If the elevator is processed before its parent level in + // Object.entries order, the level migration in this same pass would then see + // a children array that still contains the elevator ID and filter it out as + // "missing" — corrupting the level. Running elevators in a second pass after + // all levels are stable avoids the race entirely. for (const [id, node] of Object.entries(patchedNodes)) { // 1. Item scale migration if (node.type === 'item' && !('scale' in node)) { @@ -525,14 +533,6 @@ function migrateNodes(nodes: Record): Record { } } - if (node.type === 'elevator') { - const parentMigrated = migrateElevatorParent(id, node, patchedNodes) - const normalized = normalizeElevatorNode(parentMigrated) - if (normalized) { - patchedNodes[id] = normalized - } - } - // Roof-segment hosting was added in this migration cycle (the same // pattern as shelf above). Older segments saved before the schema // gained `children` need the field initialised so @@ -621,7 +621,59 @@ function migrateNodes(nodes: Record): Record { patchedNodes[id] = { ...node, children: flattened } } } + + // Level children normalization. + // Pre-0.9.1 JSONs may carry child IDs that no longer exist in the node + // map (e.g. elevator IDs that lived under a level before the elevator + // parent migration moved them up to building). If those dangling IDs are + // left in place, collectReachableNodeIds marks the level as having + // reachable children that don't exist, which corrupts the scene graph + // traversal and leaves the LevelNode in a broken state — making floors + // impossible to drag or delete after import. + // We intentionally do NOT filter by type prefix here; being permissive + // about which types are allowed as children prevents data loss when new + // child types are added to the schema in the future. + if (node.type === 'level') { + const rawChildren = getStringArray(node.children) + const validChildren = rawChildren.filter((childId) => { + const exists = Boolean(patchedNodes[childId]) + if (!exists) { + console.warn( + '[migrateNodes] level', + id, + 'references missing child', + childId, + '— dropping', + ) + } + return exists + }) + const levelNumber = getFiniteNumber(node.level, 0) + patchedNodes[id] = { + ...node, + level: levelNumber, + children: validChildren, + } + } + } + + // Pass 2: elevator migration. + // migrateElevatorParent mutates the parent level's children array (removes + // the elevator ID from it). Running this after Pass 1 guarantees that the + // level normalization above has already seen a clean children list — if we + // ran elevator migration inside Pass 1, the order of Object.entries + // iteration would be non-deterministic: processing an elevator before its + // parent level would mutate the level's children mid-iteration, potentially + // causing the level branch above to see a stale node reference. + for (const [id, node] of Object.entries(patchedNodes)) { + if (node.type !== 'elevator') continue + const parentMigrated = migrateElevatorParent(id, node, patchedNodes) + const normalized = normalizeElevatorNode(parentMigrated) + if (normalized) { + patchedNodes[id] = normalized + } } + return patchedNodes as Record } From fd9b73678811bbe434c45670c320da94197fe4a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Thu, 18 Jun 2026 15:38:26 -0300 Subject: [PATCH 2/2] Update use-scene.ts --- packages/core/src/store/use-scene.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 48136a4d5..3d1bf03a2 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -1318,4 +1318,4 @@ useScene.temporal.subscribe((state) => { prevPastLength = currentPastLength prevFutureLength = currentFutureLength prevNodesSnapshot = useScene.getState().nodes -}) \ No newline at end of file +})