Overview
The scene state is managed by a Zustand store in @pascal-app/core that provides a centralized, reactive state container for all nodes in the 3D scene.
Store Structure
type SceneState = {
// 1. The Data: A flat dictionary of all nodes
nodes: Record<AnyNodeId, AnyNode>
// 2. The Root: Which nodes are at the top level?
rootNodeIds: AnyNodeId[]
// 3. The "Dirty" Set: For the systems
dirtyNodes: Set<AnyNodeId>
// Actions
loadScene: () => void
clearScene: () => void
setScene: (nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]) => void
markDirty: (id: AnyNodeId) => void
clearDirty: (id: AnyNodeId) => void
createNode: (node: AnyNode, parentId?: AnyNodeId) => void
createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void
updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void
deleteNode: (id: AnyNodeId) => void
deleteNodes: (ids: AnyNodeId[]) => void
}
Location: packages/core/src/store/use-scene.ts:14-40
CRUD Operations
Create Node
Create a single node and optionally attach it to a parent:
import useScene from '@pascal-app/core/store/use-scene'
import { WallNode } from '@pascal-app/core/schema'
// Create a wall node
const wall = WallNode.parse({
start: [0, 0],
end: [5, 0],
thickness: 0.15,
height: 2.7,
})
useScene.getState().createNode(wall, levelId)
The node is automatically:
- Added to the
nodes dictionary
- Added to parent’s
children array (if parentId provided)
- Marked as dirty for system processing
Location: packages/core/src/store/actions/node-actions.ts:5-52
Batch Create Nodes
Create multiple nodes efficiently:
const operations = [
{ node: wall1, parentId: levelId },
{ node: wall2, parentId: levelId },
{ node: door, parentId: wall1.id },
]
useScene.getState().createNodes(operations)
Batch operations are more efficient than individual creates as they only trigger one state update and re-render.
Update Node
Update properties of an existing node:
// Update wall thickness
useScene.getState().updateNode(wallId, {
thickness: 0.2,
height: 3.0,
})
// Move an item
useScene.getState().updateNode(itemId, {
position: [2, 0, 3],
rotation: [0, Math.PI / 2, 0],
})
The node is automatically:
- Updated in the
nodes dictionary
- Marked as dirty for system processing
- Parent is also marked dirty (to update spatial relationships)
Location: packages/core/src/store/actions/node-actions.ts:54-109
Reparenting
Change a node’s parent by updating its parentId:
// Move door from wall1 to wall2
useScene.getState().updateNode(doorId, {
parentId: wall2.id,
})
The update action handles:
- Removing node from old parent’s
children array
- Adding node to new parent’s
children array
- Marking both old and new parents as dirty
Delete Node
Delete a node and all its descendants:
// Delete a wall (also deletes all doors/windows attached to it)
useScene.getState().deleteNode(wallId)
The deletion:
- Recursively deletes all children
- Removes node from parent’s
children array
- Removes node from
nodes dictionary
- Removes node from
rootNodeIds (if applicable)
- Triggers full scene re-validation (some deletions like slabs affect elevations)
Location: packages/core/src/store/actions/node-actions.ts:111-161
Deletion is recursive - deleting a wall will also delete all doors, windows, and items attached to it.
Dirty Nodes
The dirtyNodes set tracks which nodes need geometry updates. Systems check this set each frame and only recompute geometry for dirty nodes.
Automatic Marking
Nodes are automatically marked dirty when:
- Created with
createNode / createNodes
- Updated with
updateNode / updateNodes
- Their parent is modified
- Scene is loaded or imported
// Automatic: node is marked dirty
useScene.getState().updateNode(wallId, { thickness: 0.2 })
// → wallId added to dirtyNodes
// → WallSystem regenerates geometry next frame
// → wallId removed from dirtyNodes
Location: packages/core/src/store/use-scene.ts:126-132
Manual Marking
You can manually mark nodes dirty when needed:
// Mark a specific node dirty
useScene.getState().markDirty(wallId)
// Clear dirty flag
useScene.getState().clearDirty(wallId)
Systems are responsible for clearing the dirty flag after processing. If a node’s mesh isn’t registered yet, the system will keep it dirty for the next frame.
Middleware
The scene store uses two Zustand middleware layers:
Persistence Middleware
Automatically saves scene to IndexedDB for local persistence:
persist(
// ... store definition
{
name: 'editor-storage',
version: 1,
partialize: (state) => ({
nodes: Object.fromEntries(
Object.entries(state.nodes).filter(([_, node]) => {
const meta = node.metadata
const isTransient = isObject(meta) && 'isTransient' in meta && meta.isTransient === true
return !isTransient
}),
),
rootNodeIds: state.rootNodeIds,
}),
}
)
Location: packages/core/src/store/use-scene.ts:155-172
Transient Nodes
Nodes marked with metadata: { isTransient: true } are not persisted to IndexedDB:
const previewWall = WallNode.parse({
start: [0, 0],
end: [5, 0],
metadata: { isTransient: true }, // Won't be saved
})
useScene.getState().createNode(previewWall, levelId)
Use transient nodes for preview geometry, guides, or temporary visual elements that shouldn’t be persisted.
Temporal Middleware (Undo/Redo)
Provides undo/redo functionality using Zundo:
temporal(
// ... store definition
{
partialize: (state) => {
const { nodes, rootNodeIds } = state
return { nodes, rootNodeIds } // Only track nodes and rootNodeIds in history
},
limit: 50, // Limit to last 50 actions
}
)
Location: packages/core/src/store/use-scene.ts:147-153
Using Undo/Redo
import useScene from '@pascal-app/core/store/use-scene'
// Undo last action
useScene.temporal.getState().undo()
// Redo
useScene.temporal.getState().redo()
// Check if undo/redo available
const canUndo = useScene.temporal((state) => state.pastStates.length > 0)
const canRedo = useScene.temporal((state) => state.futureStates.length > 0)
Dirty Node Re-validation
After undo/redo, all nodes are marked dirty to trigger re-validation:
useScene.temporal.subscribe((state) => {
const didUndo = currentFutureLength > prevFutureLength
const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength
if (didUndo || didRedo) {
requestAnimationFrame(() => {
const currentNodes = useScene.getState().nodes
// Trigger full scene re-validation
Object.values(currentNodes).forEach((node) => {
useScene.getState().markDirty(node.id)
})
})
}
})
Location: packages/core/src/store/use-scene.ts:237-257
Loading and Clearing Scenes
Load Default Scene
Create a default scene hierarchy (Site → Building → Level):
useScene.getState().loadScene()
Location: packages/core/src/store/use-scene.ts:90-124
Set Scene from External Data
Load a scene from external source (file, database, etc.):
const sceneData = await fetch('/api/project/123').then(r => r.json())
useScene.getState().setScene(
sceneData.nodes,
sceneData.rootNodeIds
)
This:
- Replaces current scene
- Applies backward compatibility patches (e.g., adding
scale to old ItemNodes)
- Marks all nodes dirty for re-validation
Location: packages/core/src/store/use-scene.ts:70-88
Clear Scene
Reset to empty scene and reload default:
useScene.getState().clearScene()
Location: packages/core/src/store/use-scene.ts:61-68
Usage Patterns
In React Components
import useScene from '@pascal-app/core/store/use-scene'
function WallProperties({ wallId }: { wallId: string }) {
// Subscribe to specific node
const wall = useScene((state) => state.nodes[wallId])
if (!wall || wall.type !== 'wall') return null
const handleThicknessChange = (value: number) => {
useScene.getState().updateNode(wallId, { thickness: value })
}
return (
<div>
<label>Thickness: {wall.thickness}</label>
<input
type="number"
value={wall.thickness ?? 0.1}
onChange={(e) => handleThicknessChange(Number(e.target.value))}
/>
</div>
)
}
Outside React (Systems, Utilities)
import useScene from '@pascal-app/core/store/use-scene'
function getWallsOnLevel(levelId: string) {
const { nodes } = useScene.getState()
const level = nodes[levelId]
if (!level || level.type !== 'level') return []
return level.children
.map(id => nodes[id])
.filter(node => node?.type === 'wall')
}
Use useScene.getState() to access state outside React components. This doesn’t create a subscription and won’t cause re-renders.