Core Concepts
Nodes
Nodes are the data primitives that describe the 3D scene. All nodes extend BaseNode:
BaseNode {
id: string // Auto-generated with type prefix (e.g., "wall_abc123")
type: string // Discriminator for type-safe handling
parentId: string | null // Parent node reference
visible: boolean
camera?: Camera // Optional saved camera position
metadata?: JSON // Arbitrary metadata (e.g., { isTransient: true })
}Node Hierarchy:
Site
└── Building
└── Level
├── Wall → Item (doors, windows)
├── Slab
├── Ceiling → Item (lights)
├── Roof
├── Zone
├── Scan (3D reference)
└── Guide (2D reference)Nodes are stored in a flat dictionary (Record<id, Node>), not a nested tree. Parent-child relationships are defined via parentId and children arrays.
Scene State (Zustand Store)
The scene is managed by a Zustand store in @pascal-app/core:
useScene.getState() = {
nodes: Record<id, AnyNode>, // All nodes
rootNodeIds: string[], // Top-level nodes (sites)
dirtyNodes: Set<string>, // Nodes pending system updates
createNode(node, parentId),
updateNode(id, updates),
deleteNode(id),
}Middleware:
- Persist - Saves to IndexedDB (excludes transient nodes)
- Temporal (Zundo) - Undo/redo with 50-step history
Scene Registry
The registry maps node IDs to their Three.js objects for fast lookup:
sceneRegistry = {
nodes: Map<id, Object3D>, // ID → 3D object
byType: {
wall: Set<id>,
item: Set<id>,
zone: Set<id>,
// ...
}
}Renderers register their refs using the useRegistry hook:
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'wall', ref)This allows systems to access 3D objects directly without traversing the scene graph.
Node Renderers
Renderers are React components that create Three.js objects for each node type:
SceneRenderer
└── NodeRenderer (dispatches by type)
├── BuildingRenderer
├── LevelRenderer
├── WallRenderer
├── SlabRenderer
├── ZoneRenderer
├── ItemRenderer
└── ...Pattern:
- Renderer creates a placeholder mesh/group
- Registers it with
useRegistry - Systems update geometry based on node data
Example (simplified):
const WallRenderer = ({ node }) => {
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'wall', ref)
return (
<mesh ref={ref}>
<boxGeometry args={[0, 0, 0]} /> {/* Replaced by WallSystem */}
<meshStandardMaterial />
{node.children.map(id => <NodeRenderer key={id} nodeId={id} />)}
</mesh>
)
}Systems
Systems are React components that run in the render loop (useFrame) to update geometry and transforms. They process dirty nodes marked by the store.
Core Systems (in @pascal-app/core):
| System | Responsibility |
|---|---|
WallSystem | Generates wall geometry with mitering and CSG cutouts for doors/windows |
SlabSystem | Generates floor geometry from polygons |
CeilingSystem | Generates ceiling geometry |
RoofSystem | Generates roof geometry |
ItemSystem | Positions items on walls, ceilings, or floors (slab elevation) |
Viewer Systems (in @pascal-app/viewer):
| System | Responsibility |
|---|---|
LevelSystem | Handles level visibility and vertical positioning (stacked/exploded/solo modes) |
ScanSystem | Controls 3D scan visibility |
GuideSystem | Controls guide image visibility |
Processing Pattern:
useFrame(() => {
for (const id of dirtyNodes) {
const obj = sceneRegistry.nodes.get(id)
const node = useScene.getState().nodes[id]
// Update geometry, transforms, etc.
updateGeometry(obj, node)
dirtyNodes.delete(id)
}
})Dirty Nodes
When a node changes, it's marked as dirty in useScene.getState().dirtyNodes. Systems check this set each frame and only recompute geometry for dirty nodes.
// Automatic: createNode, updateNode, deleteNode mark nodes dirty
useScene.getState().updateNode(wallId, { thickness: 0.2 })
// → wallId added to dirtyNodes
// → WallSystem regenerates geometry next frame
// → wallId removed from dirtyNodesManual marking:
useScene.getState().dirtyNodes.add(wallId)Event Bus
Inter-component communication uses a typed event emitter (mitt):
// Node events
emitter.on('wall:click', (event) => { ... })
emitter.on('item:enter', (event) => { ... })
emitter.on('zone:context-menu', (event) => { ... })
// Grid events (background)
emitter.on('grid:click', (event) => { ... })
// Event payload
NodeEvent {
node: AnyNode
position: [x, y, z]
localPosition: [x, y, z]
normal?: [x, y, z]
stopPropagation: () => void
}Spatial Grid Manager
Handles collision detection and placement validation:
spatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation)
spatialGridManager.canPlaceOnWall(wallId, t, height, dimensions)
spatialGridManager.getSlabElevationAt(levelId, x, z)Used by item placement tools to validate positions and calculate slab elevations.
Source captured: 2026-10-11