Overview
Systems are React components that run in the render loop (useFrame) to update 3D geometry and transforms. They process dirty nodes marked by the store and use the scene registry to access Three.js objects directly.
System Pattern
Systems follow a consistent pattern:
Subscribe to dirtyNodes from the scene store
Run in useFrame (called every frame by React Three Fiber)
Check for dirty nodes of their target type
Update Three.js geometry via scene registry
Clear dirty flag when complete
import { useFrame } from '@react-three/fiber'
import useScene from '@pascal-app/core/store/use-scene'
import { sceneRegistry } from '@pascal-app/core/hooks/scene-registry'
export const WallSystem = () => {
const dirtyNodes = useScene (( state ) => state . dirtyNodes )
const clearDirty = useScene (( state ) => state . clearDirty )
useFrame (() => {
if ( dirtyNodes . size === 0 ) return
const nodes = useScene . getState (). nodes
dirtyNodes . forEach (( id ) => {
const node = nodes [ id ]
if ( ! node || node . type !== 'wall' ) return
const mesh = sceneRegistry . nodes . get ( id ) as THREE . Mesh
if ( mesh ) {
updateWallGeometry ( node , mesh )
clearDirty ( id )
}
// If mesh not found, keep it dirty for next frame
})
}, 4 ) // Priority 4 (walls update after slabs)
return null
}
Location: packages/core/src/systems/wall/wall-system.tsx:26-81
Core Systems
The @pascal-app/core package provides systems for geometry generation:
WallSystem
Generates wall geometry with advanced features:
Mitering at wall junctions (corners and T-junctions)
CSG cutouts for doors, windows, and items
Slab elevation integration (walls extend below or sit on slabs)
function updateWallGeometry ( wallId : string , miterData : WallMiterData ) {
const nodes = useScene . getState (). nodes
const node = nodes [ wallId ]
if ( ! node || node . type !== 'wall' ) return
const mesh = sceneRegistry . nodes . get ( wallId ) as THREE . Mesh
if ( ! mesh ) return
const levelId = resolveLevelId ( node , nodes )
const slabElevation = spatialGridManager . getSlabElevationForWall (
levelId ,
node . start ,
node . end
)
const childrenIds = node . children || []
const childrenNodes = childrenIds
. map (( childId ) => nodes [ childId ])
. filter (( n ) : n is AnyNode => n !== undefined )
const newGeo = generateExtrudedWall ( node , childrenNodes , miterData , slabElevation )
mesh . geometry . dispose ()
mesh . geometry = newGeo
mesh . position . set ( node . start [ 0 ], slabElevation , node . start [ 1 ])
const angle = Math . atan2 ( node . end [ 1 ] - node . start [ 1 ], node . end [ 0 ] - node . start [ 0 ])
mesh . rotation . y = - angle
}
Location: packages/core/src/systems/wall/wall-system.tsx:106-137
Wall Mitering
The system calculates miter joints at wall junctions using computational geometry:
export function calculateLevelMiters ( walls : WallNode []) : WallMiterData {
const getThickness = ( wall : WallNode ) => wall . thickness ?? 0.1
const junctions = findJunctions ( walls )
const junctionData : JunctionData = new Map ()
for ( const [ key , junction ] of junctions . entries ()) {
const wallIntersections = calculateJunctionIntersections ( junction , getThickness )
junctionData . set ( key , wallIntersections )
}
return { junctionData , junctions }
}
Location: packages/core/src/systems/wall/wall-mitering.ts:254-265
Mitering ensures clean corner and T-junction geometry without gaps or overlaps, even with walls of different thicknesses.
SlabSystem
Generates floor slab geometry from polygons:
export const SlabSystem = () => {
const dirtyNodes = useScene (( state ) => state . dirtyNodes )
const clearDirty = useScene (( state ) => state . clearDirty )
useFrame (() => {
if ( dirtyNodes . size === 0 ) return
const nodes = useScene . getState (). nodes
dirtyNodes . forEach (( id ) => {
const node = nodes [ id ]
if ( ! node || node . type !== 'slab' ) return
const mesh = sceneRegistry . nodes . get ( id ) as THREE . Mesh
if ( mesh ) {
updateSlabGeometry ( node , mesh )
clearDirty ( id )
}
})
}, 1 ) // Priority 1 (slabs update first)
return null
}
Location: packages/core/src/systems/slab/slab-system.tsx:11-35
The geometry generator handles:
Polygon extrusion
Holes for openings (stairs, shafts)
Automatic outset to extend under walls
function updateSlabGeometry ( node : SlabNode , mesh : THREE . Mesh ) {
const newGeo = generateSlabGeometry ( node )
mesh . geometry . dispose ()
mesh . geometry = newGeo
}
export function generateSlabGeometry ( slabNode : SlabNode ) : THREE . BufferGeometry {
const polygon = outsetPolygon ( slabNode . polygon , SLAB_OUTSET )
const elevation = slabNode . elevation ?? 0.05
// Create shape from polygon
const shape = new THREE . Shape ()
const firstPt = polygon [ 0 ] !
shape . moveTo ( firstPt [ 0 ], - firstPt [ 1 ])
for ( let i = 1 ; i < polygon . length ; i ++ ) {
const pt = polygon [ i ] !
shape . lineTo ( pt [ 0 ], - pt [ 1 ])
}
shape . closePath ()
// Add holes
for ( const holePolygon of slabNode . holes || []) {
const holePath = new THREE . Path ()
// ... hole creation logic
shape . holes . push ( holePath )
}
// Extrude and rotate
const geometry = new THREE . ExtrudeGeometry ( shape , {
depth: elevation ,
bevelEnabled: false ,
})
geometry . rotateX ( - Math . PI / 2 )
geometry . computeVertexNormals ()
return geometry
}
Location: packages/core/src/systems/slab/slab-system.tsx:40-155
ItemSystem
Positions items based on their attachment mode:
export const ItemSystem = () => {
const dirtyNodes = useScene (( state ) => state . dirtyNodes )
const clearDirty = useScene (( state ) => state . clearDirty )
useFrame (() => {
if ( dirtyNodes . size === 0 ) return
const nodes = useScene . getState (). nodes
dirtyNodes . forEach (( id ) => {
const node = nodes [ id ]
if ( ! node || node . type !== 'item' ) return
const item = node as ItemNode
const mesh = sceneRegistry . nodes . get ( id ) as THREE . Object3D
if ( ! mesh ) return
if ( item . asset . attachTo === 'wall-side' ) {
// Wall-attached: offset by half wall thickness
const parentWall = item . parentId ? nodes [ item . parentId ] : undefined
if ( parentWall && parentWall . type === 'wall' ) {
const wallThickness = parentWall . thickness ?? 0.1
const side = item . side === 'front' ? 1 : - 1
mesh . position . z = ( wallThickness / 2 ) * side
}
} else if ( ! item . asset . attachTo ) {
// Floor item: elevate by slab height
const parentNode = item . parentId ? nodes [ item . parentId ] : undefined
if ( parentNode ?. type !== 'item' ) {
const levelId = resolveLevelId ( item , nodes )
const slabElevation = spatialGridManager . getSlabElevationForItem (
levelId ,
item . position ,
getScaledDimensions ( item ),
item . rotation ,
)
mesh . position . y = slabElevation + item . position [ 1 ]
}
}
clearDirty ( id )
})
}, 2 ) // Priority 2 (items update after slabs)
return null
}
Location: packages/core/src/systems/item/item-system.tsx:13-58
CeilingSystem
Generates ceiling geometry (similar pattern to SlabSystem).
Location: packages/core/src/systems/ceiling/ceiling-system.tsx
RoofSystem
Generates roof geometry from roof nodes.
Location: packages/core/src/systems/roof/roof-system.tsx
WindowSystem & DoorSystem
Handle specialized item types for windows and doors.
Location: packages/core/src/systems/window/window-system.tsx, packages/core/src/systems/door/door-system.tsx
System Priority
Systems run at different priorities (second argument to useFrame):
useFrame (() => { /* ... */ }, priority )
Priority System Reason 1 SlabSystem Slabs must update first (other systems query slab elevations) 2 ItemSystem Items need slab elevations to position correctly 4 WallSystem Walls need slab elevations for base positioning
Lower priority numbers run first . This ensures slabs update before systems that depend on slab elevation data.
Scene Registry Integration
Systems access Three.js objects through the scene registry :
import { sceneRegistry } from '@pascal-app/core/hooks/scene-registry'
// Get mesh by node ID
const mesh = sceneRegistry . nodes . get ( wallId ) as THREE . Mesh
// Get all nodes of a type
const allWallIds = sceneRegistry . byType . get ( 'wall' ) // Set<string>
The registry is populated by renderers using the useRegistry hook:
import { useRegistry } from '@pascal-app/core/hooks/scene-registry'
function WallRenderer ({ node } : { node : WallNode }) {
const ref = useRef < Mesh >( null ! )
useRegistry ( node . id , 'wall' , ref ) // Register mesh in registry
return (
< mesh ref = { ref } >
< boxGeometry args = { [ 0 , 0 , 0 ] } /> { /* Placeholder, WallSystem updates */ }
< meshStandardMaterial />
</ mesh >
)
}
Processing Dirty Nodes
Systems only process nodes marked as dirty:
useFrame (() => {
if ( dirtyNodes . size === 0 ) return // Early exit if nothing to do
const nodes = useScene . getState (). nodes
dirtyNodes . forEach (( id ) => {
const node = nodes [ id ]
if ( ! node || node . type !== 'wall' ) return // Type filter
const mesh = sceneRegistry . nodes . get ( id ) as THREE . Mesh
if ( ! mesh ) return // Skip if mesh not registered yet (keep dirty)
// Update geometry
updateWallGeometry ( node , mesh )
// Clear dirty flag
clearDirty ( id )
})
})
Cascade Updates
Some systems need to update adjacent nodes:
// WallSystem: update adjacent walls that share junctions
const adjacentWallIds = getAdjacentWallIds ( levelWalls , dirtyWallIds )
for ( const wallId of adjacentWallIds ) {
if ( ! dirtyWallIds . has ( wallId )) {
const mesh = sceneRegistry . nodes . get ( wallId ) as THREE . Mesh
if ( mesh ) {
updateWallGeometry ( wallId , miterData )
}
}
}
Location: packages/core/src/systems/wall/wall-system.tsx:68-76
Adjacent walls are updated without clearing their dirty flag, as they may be dirty for other reasons.
Custom Systems
You can create custom systems following the same pattern:
import { useFrame } from '@react-three/fiber'
import useScene from '@pascal-app/core/store/use-scene'
import { sceneRegistry } from '@pascal-app/core/hooks/scene-registry'
export const CustomSystem = () => {
const dirtyNodes = useScene (( state ) => state . dirtyNodes )
const clearDirty = useScene (( state ) => state . clearDirty )
useFrame (() => {
if ( dirtyNodes . size === 0 ) return
const nodes = useScene . getState (). nodes
dirtyNodes . forEach (( id ) => {
const node = nodes [ id ]
if ( ! node || node . type !== 'custom' ) return
const object = sceneRegistry . nodes . get ( id )
if ( ! object ) return
// Your custom update logic here
updateCustomGeometry ( node , object )
clearDirty ( id )
})
}, 3 ) // Choose appropriate priority
return null
}
Best Practices
Early Exit Check dirtyNodes.size === 0 first to avoid unnecessary work
Type Filter Filter by node type early to skip irrelevant nodes
Mesh Check Check if mesh is registered before updating (keep dirty if not)
Dispose Geometry Always dispose old geometry before replacing: mesh.geometry.dispose()
Clear Dirty Only clear dirty flag after successful update
Priority Order Set priority based on dependencies (lower = earlier)