fix: prevent crash when duplicating elements (#239)

- Guard _buildCache in merged-outline-node against stale/disposed
  Object3D refs that cause TypeError on .id access during render
- Reset children array when duplicating roofs to prevent inconsistent
  parent-child relationships (matching existing stair behavior)
- Use obj?.parent check in EditorOutlinerSync to ensure objects are
  still in the scene graph before adding to outliner arrays
- Resume temporal state on parse failure to prevent undo/redo freeze

Closes #232
This commit is contained in:
Huy Hoang
2026-04-15 17:25:03 -04:00
committed by GitHub
parent e3ba4ab921
commit 3d1005847b
3 changed files with 17 additions and 5 deletions
@@ -147,6 +147,7 @@ export function FloatingActionMenu() {
duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1] duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1]
duplicate.end = [duplicate.end[0] + 1, duplicate.end[1] + 1] duplicate.end = [duplicate.end[0] + 1, duplicate.end[1] + 1]
} else if (node.type === 'roof') { } else if (node.type === 'roof') {
duplicateInfo.children = []
duplicate = RoofNode.parse(duplicateInfo) duplicate = RoofNode.parse(duplicateInfo)
} else if (node.type === 'roof-segment') { } else if (node.type === 'roof-segment') {
duplicate = RoofSegmentNode.parse(duplicateInfo) duplicate = RoofSegmentNode.parse(duplicateInfo)
@@ -160,6 +161,12 @@ export function FloatingActionMenu() {
} }
} catch (error) { } catch (error) {
console.error('Failed to parse duplicate', error) console.error('Failed to parse duplicate', error)
useScene.temporal.getState().resume()
return
}
if (!duplicate) {
useScene.temporal.getState().resume()
return return
} }
@@ -920,13 +920,13 @@ const EditorOutlinerSync = () => {
outliner.selectedObjects.length = 0 outliner.selectedObjects.length = 0
for (const id of idsToHighlight) { for (const id of idsToHighlight) {
const obj = sceneRegistry.nodes.get(id) const obj = sceneRegistry.nodes.get(id)
if (obj) outliner.selectedObjects.push(obj) if (obj?.parent) outliner.selectedObjects.push(obj)
} }
outliner.hoveredObjects.length = 0 outliner.hoveredObjects.length = 0
if (hoveredId) { if (hoveredId) {
const obj = sceneRegistry.nodes.get(hoveredId) const obj = sceneRegistry.nodes.get(hoveredId)
if (obj) outliner.hoveredObjects.push(obj) if (obj?.parent) outliner.hoveredObjects.push(obj)
} }
}, [phase, previewSelectedIds, selection, hoveredId, outliner]) }, [phase, previewSelectedIds, selection, hoveredId, outliner])
@@ -600,9 +600,14 @@ export class MergedOutlineNode extends TempNode {
private _buildCache(objects: Object3D[], cache: Set<Object3D>) { private _buildCache(objects: Object3D[], cache: Set<Object3D>) {
for (const obj of objects) { for (const obj of objects) {
obj.traverse((child: any) => { if (!obj || !obj.traverse) continue
if (child.isMesh || child.isSprite) cache.add(child) try {
}) obj.traverse((child: any) => {
if (child.isMesh || child.isSprite) cache.add(child)
})
} catch {
// Skip objects that were disposed or removed from the scene graph
}
} }
} }
} }