Normalize root scene nodes and fix level duplication

This commit is contained in:
sudhir
2026-04-29 12:53:40 +05:30
parent 9ff657419c
commit e761084635
7 changed files with 204 additions and 18 deletions
@@ -238,27 +238,29 @@ export const createNodesAction = (
const nextRootIds = [...state.rootNodeIds] const nextRootIds = [...state.rootNodeIds]
for (const { node, parentId } of ops) { for (const { node, parentId } of ops) {
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
// 1. Assign parentId to the child (Safe because BaseNode has parentId) // 1. Assign parentId to the child (Safe because BaseNode has parentId)
const newNode = { const newNode = {
...node, ...node,
parentId: parentId ?? null, parentId: effectiveParentId,
} }
nextNodes[newNode.id] = newNode nextNodes[newNode.id] = newNode
// 2. Update the Parent's children list // 2. Update the Parent's children list
if (parentId && nextNodes[parentId]) { if (effectiveParentId && nextNodes[effectiveParentId]) {
const parent = nextNodes[parentId] const parent = nextNodes[effectiveParentId]
// Type Guard: Check if the parent node is a container that supports children // Type Guard: Check if the parent node is a container that supports children
if ('children' in parent && Array.isArray(parent.children)) { if ('children' in parent && Array.isArray(parent.children)) {
nextNodes[parentId] = { nextNodes[effectiveParentId] = {
...parent, ...parent,
// Use Set to prevent duplicate IDs if createNode is called twice // Use Set to prevent duplicate IDs if createNode is called twice
children: Array.from(new Set([...parent.children, newNode.id])) as any, // We don't verify child types here children: Array.from(new Set([...parent.children, newNode.id])) as any, // We don't verify child types here
} }
} }
} else if (!parentId) { } else if (!effectiveParentId) {
// 3. Handle Root nodes // 3. Handle Root nodes
if (!nextRootIds.includes(newNode.id)) { if (!nextRootIds.includes(newNode.id)) {
nextRootIds.push(newNode.id) nextRootIds.push(newNode.id)
+73 -2
View File
@@ -11,8 +11,8 @@ import { SiteNode } from '../schema/nodes/site'
import { StairNode as StairNodeSchema } from '../schema/nodes/stair' import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment' import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
import { resetSceneHistoryPauseDepth } from './history-control'
import * as nodeActions from './actions/node-actions' import * as nodeActions from './actions/node-actions'
import { resetSceneHistoryPauseDepth } from './history-control'
function getFiniteNumber(value: unknown, fallback: number) { function getFiniteNumber(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback return typeof value === 'number' && Number.isFinite(value) ? value : fallback
@@ -349,6 +349,67 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
return patchedNodes as Record<string, AnyNode> return patchedNodes as Record<string, AnyNode>
} }
function getNodeChildIds(node: AnyNode): AnyNodeId[] {
if (!('children' in node) || !Array.isArray(node.children)) {
return []
}
return (node.children as unknown[])
.map((child) => {
if (typeof child === 'string') return child
if (child && typeof child === 'object' && 'id' in child && typeof child.id === 'string') {
return child.id
}
return null
})
.filter((id): id is AnyNodeId => typeof id === 'string')
}
function normalizeRootNodeIds(
nodes: Record<AnyNodeId, AnyNode>,
rootNodeIds: AnyNodeId[],
): AnyNodeId[] {
const existingRootIds = rootNodeIds.filter((id) => Boolean(nodes[id]))
const siteRootIds = existingRootIds.filter((id) => nodes[id]?.type === 'site')
if (siteRootIds.length > 0) {
return siteRootIds
}
return existingRootIds.filter((id) => nodes[id]?.parentId === null)
}
function collectReachableNodeIds(
nodes: Record<AnyNodeId, AnyNode>,
rootNodeIds: AnyNodeId[],
): Set<AnyNodeId> {
const reachable = new Set<AnyNodeId>()
const stack = [...rootNodeIds]
const childIdsByParentId = new Map<AnyNodeId, AnyNodeId[]>()
for (const node of Object.values(nodes)) {
if (!node.parentId) continue
const parentId = node.parentId as AnyNodeId
const children = childIdsByParentId.get(parentId) ?? []
children.push(node.id as AnyNodeId)
childIdsByParentId.set(parentId, children)
}
while (stack.length > 0) {
const id = stack.pop()
if (!id || reachable.has(id)) continue
const node = nodes[id]
if (!node) continue
reachable.add(id)
stack.push(...getNodeChildIds(node))
stack.push(...(childIdsByParentId.get(id) ?? []))
}
return reachable
}
export type SceneState = { export type SceneState = {
// 1. The Data: A flat dictionary of all nodes // 1. The Data: A flat dictionary of all nodes
nodes: Record<AnyNodeId, AnyNode> nodes: Record<AnyNodeId, AnyNode>
@@ -450,9 +511,19 @@ const useScene: UseSceneStore = create<SceneState>()(
} }
} }
const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds)
const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds)
if (normalizedRootNodeIds.length > 0) {
for (const node of Object.values(cleanedNodes)) {
if (reachableNodeIds.has(node.id as AnyNodeId)) continue
console.warn('[Scene] Removing unreachable node', node.id)
delete cleanedNodes[node.id]
}
}
set({ set({
nodes: cleanedNodes, nodes: cleanedNodes,
rootNodeIds, rootNodeIds: normalizedRootNodeIds,
dirtyNodes: new Set<AnyNodeId>(), dirtyNodes: new Set<AnyNodeId>(),
collections: {}, collections: {},
}) })
@@ -293,13 +293,21 @@ export function FloatingLevelSelector() {
const handleDuplicateLevel = useCallback( const handleDuplicateLevel = useCallback(
(level: LevelNode, preset: LevelDuplicatePreset = 'everything') => { (level: LevelNode, preset: LevelDuplicatePreset = 'everything') => {
const { createOps, newLevelId } = buildLevelDuplicateCreateOps({ const { createOps, newLevelId, shiftedLevels } = buildLevelDuplicateCreateOps({
nodes: useScene.getState().nodes, nodes: useScene.getState().nodes,
level, level,
levels, levels,
preset, preset,
}) })
if (shiftedLevels.length > 0) {
updateNodes(
shiftedLevels.map((shiftedLevel) => ({
id: shiftedLevel.id as AnyNodeId,
data: { level: shiftedLevel.level } as Partial<AnyNode>,
})),
)
}
createNodes(createOps) createNodes(createOps)
setSelection({ setSelection({
@@ -307,7 +315,7 @@ export function FloatingLevelSelector() {
levelId: newLevelId as LevelNode['id'], levelId: newLevelId as LevelNode['id'],
}) })
}, },
[createNodes, levels, resolvedBuildingId, setSelection], [createNodes, levels, resolvedBuildingId, setSelection, updateNodes],
) )
if (levels.length === 0) return null if (levels.length === 0) return null
@@ -587,10 +587,19 @@ const LevelItem = memo(function LevelItem({
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const createNodes = useScene((s) => s.createNodes) const createNodes = useScene((s) => s.createNodes)
const updateNodes = useScene((s) => s.updateNodes)
const itemRef = useRef<HTMLDivElement>(null) const itemRef = useRef<HTMLDivElement>(null)
const isSelected = selectedLevelId === level.id const isSelected = selectedLevelId === level.id
const canDeleteLevel = level.level !== 0 const canDeleteLevel = level.level !== 0
const [isExpanded, setIsExpanded] = useState(isSelected) const [isExpanded, setIsExpanded] = useState(isSelected)
const buildingId =
typeof level.parentId === 'string' && level.parentId.startsWith('building_')
? (level.parentId as BuildingNode['id'])
: undefined
const selectLevel = (levelId: LevelNode['id']) => {
setSelection(buildingId ? { buildingId, levelId } : { levelId })
}
useEffect(() => { useEffect(() => {
setIsExpanded(isSelected) setIsExpanded(isSelected)
@@ -603,7 +612,7 @@ const LevelItem = memo(function LevelItem({
}, [isSelected]) }, [isSelected])
const handleSelect = () => { const handleSelect = () => {
setSelection({ levelId: level.id }) selectLevel(level.id)
} }
const handleDoubleClick = () => { const handleDoubleClick = () => {
@@ -611,15 +620,23 @@ const LevelItem = memo(function LevelItem({
} }
const handleDuplicateLevel = (preset: LevelDuplicatePreset = 'everything') => { const handleDuplicateLevel = (preset: LevelDuplicatePreset = 'everything') => {
const { createOps, newLevelId } = buildLevelDuplicateCreateOps({ const { createOps, newLevelId, shiftedLevels } = buildLevelDuplicateCreateOps({
nodes: useScene.getState().nodes, nodes: useScene.getState().nodes,
level, level,
levels, levels,
preset, preset,
}) })
if (shiftedLevels.length > 0) {
updateNodes(
shiftedLevels.map((shiftedLevel) => ({
id: shiftedLevel.id as AnyNodeId,
data: { level: shiftedLevel.level } as Partial<AnyNode>,
})),
)
}
createNodes(createOps) createNodes(createOps)
setSelection({ levelId: newLevelId }) selectLevel(newLevelId as LevelNode['id'])
setDuplicateDialogOpen(false) setDuplicateDialogOpen(false)
} }
@@ -664,7 +681,7 @@ const LevelItem = memo(function LevelItem({
if (isSelected) { if (isSelected) {
setIsExpanded(!isExpanded) setIsExpanded(!isExpanded)
} else { } else {
setSelection({ levelId: level.id }) selectLevel(level.id)
} }
}} }}
> >
@@ -869,7 +886,7 @@ const LevelsSection = memo(function LevelsSection({
parentId: building.id, parentId: building.id,
}) })
createNode(newLevel, building.id) createNode(newLevel, building.id)
setSelection({ levelId: newLevel.id }) setSelection({ buildingId: building.id, levelId: newLevel.id })
} }
return ( return (
@@ -0,0 +1,41 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; editor does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
BuildingNode,
LevelNode,
WallNode,
} from '@pascal-app/core/schema'
import { buildLevelDuplicateCreateOps } from './level-duplication'
describe('buildLevelDuplicateCreateOps', () => {
test('parents a duplicated bootstrap level back to its building', () => {
const level = LevelNode.parse({ level: 0, children: [] })
const building = BuildingNode.parse({ children: [level.id] })
const wall = WallNode.parse({
parentId: level.id,
start: [0, 0],
end: [4, 0],
})
const sourceLevel = { ...level, children: [wall.id] } satisfies LevelNode
const nodes = {
[building.id]: building,
[sourceLevel.id]: sourceLevel,
[wall.id]: wall,
} as Record<AnyNodeId, AnyNode>
const { createOps, newLevelId } = buildLevelDuplicateCreateOps({
nodes,
level: sourceLevel,
levels: [sourceLevel],
preset: 'everything',
})
const levelCreateOp = createOps.find((op) => op.node.id === newLevelId)
expect(sourceLevel.parentId).toBeNull()
expect(levelCreateOp?.parentId).toBe(building.id)
})
})
+30 -4
View File
@@ -1,5 +1,5 @@
import type { AnyNode, AnyNodeId, LevelNode } from '@pascal-app/core' import { cloneLevelSubtree } from '@pascal-app/core/clone-scene-graph'
import { cloneLevelSubtree } from '@pascal-app/core' import type { AnyNode, AnyNodeId, LevelNode } from '@pascal-app/core/schema'
export type LevelDuplicatePreset = export type LevelDuplicatePreset =
| 'everything' | 'everything'
@@ -79,6 +79,20 @@ function stripMaterials(node: AnyNode): AnyNode {
return next as AnyNode return next as AnyNode
} }
function findLevelBuildingId(nodes: Record<AnyNodeId, AnyNode>, levelId: AnyNodeId) {
for (const node of Object.values(nodes)) {
if (node.type !== 'building' || !('children' in node) || !Array.isArray(node.children)) {
continue
}
if ((node.children as AnyNodeId[]).includes(levelId)) {
return node.id as AnyNodeId
}
}
return undefined
}
export function buildLevelDuplicateCreateOps({ export function buildLevelDuplicateCreateOps({
nodes, nodes,
level, level,
@@ -91,7 +105,15 @@ export function buildLevelDuplicateCreateOps({
preset: LevelDuplicatePreset preset: LevelDuplicatePreset
}) { }) {
const { clonedNodes, newLevelId } = cloneLevelSubtree(nodes, level.id) const { clonedNodes, newLevelId } = cloneLevelSubtree(nodes, level.id)
const nextLevelNumber = Math.max(...levels.map((entry) => entry.level), -1) + 1 const parentBuildingId =
(level.parentId as AnyNodeId | null) ?? findLevelBuildingId(nodes, level.id)
const nextLevelNumber = level.level + 1
const shiftedLevels = levels
.filter((entry) => entry.id !== level.id && entry.level >= nextLevelNumber)
.map((entry) => ({
id: entry.id,
level: entry.level + 1,
}))
const filteredNodes = clonedNodes const filteredNodes = clonedNodes
.filter((node) => shouldKeepNode(node, preset)) .filter((node) => shouldKeepNode(node, preset))
@@ -119,8 +141,12 @@ export function buildLevelDuplicateCreateOps({
level: nextLevelNumber, level: nextLevelNumber,
} as AnyNode) } as AnyNode)
: node, : node,
parentId: node.parentId as AnyNodeId | undefined, parentId:
node.id === newLevelId
? parentBuildingId
: ((node.parentId as AnyNodeId | null) ?? undefined),
})), })),
newLevelId, newLevelId,
shiftedLevels,
} }
} }
@@ -401,6 +401,27 @@ describe('SceneBridge', () => {
}) })
describe('setScene / exportJSON / loadJSON', () => { describe('setScene / exportJSON / loadJSON', () => {
test('setScene prunes duplicated levels that were accidentally saved as roots', () => {
const level0 = LevelNode.parse({ level: 0, children: [] })
const building = BuildingNode.parse({ children: [level0.id] })
const site = SiteNode.parse({ children: [building] })
const orphanLevel = LevelNode.parse({ level: 1, children: [] })
bridge.setScene(
{
[site.id]: site,
[building.id]: building,
[level0.id]: level0,
[orphanLevel.id]: orphanLevel,
} as any,
[site.id, orphanLevel.id] as any,
)
expect(bridge.getRootNodeIds()).toEqual([site.id])
expect(bridge.getNode(orphanLevel.id)).toBeNull()
expect(bridge.findNodes({ type: 'level' }).map((node) => node.id)).toEqual([level0.id])
})
test('exportJSON returns the scene shape', () => { test('exportJSON returns the scene shape', () => {
const exp = bridge.exportJSON() const exp = bridge.exportJSON()
expect(typeof exp.nodes).toBe('object') expect(typeof exp.nodes).toBe('object')