The spatial grid system provides efficient collision detection and placement validation for your 3D scene. It tracks items, walls, slabs, and ceilings in optimized spatial data structures.
Understanding the Spatial Grid
The spatialGridManager is a singleton that maintains spatial indices for:
Floor items - Items placed on the floor/slabs
Wall items - Items attached to walls
Ceiling items - Items attached to ceilings
Slabs - Floor polygons with elevation data
Walls - Wall segments for attachment validation
The spatial grid is automatically synchronized with the scene state. You don’t need to manually update it when creating or moving nodes.
Importing the Spatial Grid Manager
import { spatialGridManager } from '@pascal-app/core'
Placement Validation
Floor Placement
Validate if an item can be placed on the floor without collisions:
Get the current level ID
import { useViewer } from '@pascal-app/viewer'
const levelId = useViewer . getState (). selection . levelId
Call canPlaceOnFloor
import { spatialGridManager } from '@pascal-app/core'
const canPlace = spatialGridManager . canPlaceOnFloor (
levelId ,
[ 2.5 , 0 , 2.0 ], // Position [x, y, z]
[ 1.0 , 0.8 , 0.5 ], // Dimensions [width, height, depth]
[ 0 , Math . PI / 4 , 0 ], // Rotation [x, y, z]
[ 'item_abc123' ] // Optional: IDs to ignore (e.g., item being moved)
)
if ( canPlace . valid ) {
// Safe to place item
createItem ()
} else {
// Collision detected
console . log ( 'Conflicts with:' , canPlace . conflictIds )
}
Return value:
{
valid : boolean , // Whether placement is valid
conflictIds : string [] // IDs of conflicting items
}
Use the ignoreIds parameter when validating movement of an existing item to avoid self-collision.
Wall Placement
Validate if an item can be placed on a wall:
const result = spatialGridManager . canPlaceOnWall (
levelId , // Level containing the wall
wallId , // Wall ID to attach to
2.5 , // localX: distance from wall start point (meters)
1.2 , // localY: height from floor (meters)
[ 1.2 , 0.7 , 0.1 ], // Item dimensions [width, height, depth]
'wall-side' , // 'wall' (both sides) or 'wall-side' (one side)
'front' , // 'front' or 'back' (for wall-side items)
[ 'item_xyz' ] // Optional: IDs to ignore
)
if ( result . valid ) {
// Create wall-attached item
const item = ItemNode . parse ({
position: [ 2.5 , 1.2 , 0 ],
side: 'front' ,
asset: { ... asset , attachTo: 'wall-side' },
})
useScene . getState (). createNode ( item , wallId )
} else {
console . log ( 'Wall placement conflicts:' , result . conflictIds )
}
Parameters explained:
localX - Position along the wall from its start point
localY - Height above the floor
attachType:
'wall' - Item spans both sides (requires both sides free)
'wall-side' - Item on one side only (e.g., wall-mounted TV)
side - Which wall side for 'wall-side' items
Ceiling Placement
Validate if an item can be placed on a ceiling:
const result = spatialGridManager . canPlaceOnCeiling (
ceilingId ,
[ 2.5 , 2.5 , 2.0 ], // Position in ceiling-local coordinates
[ 0.6 , 0.2 , 0.6 ], // Dimensions
[ 0 , 0 , 0 ], // Rotation
[] // IDs to ignore
)
if ( result . valid ) {
// Place ceiling item (e.g., light fixture)
const light = ItemNode . parse ({
position: [ 2.5 , 2.5 , 2.0 ],
asset: { ... lightAsset , attachTo: 'ceiling' },
})
useScene . getState (). createNode ( light , ceilingId )
}
The ceiling placement check validates:
The item footprint is within the ceiling polygon
The item is not in a ceiling hole
No collision with other ceiling items
Elevation Queries
Get Slab Elevation at Point
Find the floor elevation at a specific (x, z) coordinate:
const elevation = spatialGridManager . getSlabElevationAt (
levelId ,
2.5 , // x coordinate
3.0 // z coordinate
)
console . log ( 'Floor elevation:' , elevation ) // e.g., 0.05 meters
How it works:
Returns the highest slab elevation if the point is inside a slab polygon
Returns 0 if the point is not on any slab
Respects holes in slabs (returns 0 if point is in a hole)
Get Slab Elevation for Item
Find the slab elevation for an item based on its full footprint:
const elevation = spatialGridManager . getSlabElevationForItem (
levelId ,
[ 2.5 , 0 , 3.0 ], // Item position
[ 1.0 , 0.8 , 0.5 ], // Item dimensions
[ 0 , Math . PI / 4 , 0 ] // Item rotation
)
// Use elevation to position item correctly
item . position [ 1 ] = elevation
Use case: This is used by the ItemSystem to automatically elevate floor items to sit on slabs:
// From the ItemSystem source
const slabElevation = spatialGridManager . getSlabElevationForItem (
levelId ,
item . position ,
getScaledDimensions ( item ),
item . rotation ,
)
mesh . position . y = slabElevation + item . position [ 1 ]
Get Slab Elevation for Wall
Find the slab elevation for a wall segment:
const elevation = spatialGridManager . getSlabElevationForWall (
levelId ,
[ 0 , 0 ], // Wall start [x, z]
[ 5 , 0 ] // Wall end [x, z]
)
console . log ( 'Wall sits on slab at elevation:' , elevation )
This is useful for elevating walls when they sit on raised slabs or platforms.
Collision Detection Workflow
Here’s a complete example of validating item placement with real-time feedback:
Track mouse position
import { emitter , type GridEvent } from '@pascal-app/core'
import { useState } from 'react'
const [ cursorPosition , setCursorPosition ] = useState <[ number , number , number ] | null >( null )
useEffect (() => {
const handleGridMove = ( event : GridEvent ) => {
setCursorPosition ( event . position )
}
emitter . on ( 'grid:move' , handleGridMove )
return () => emitter . off ( 'grid:move' , handleGridMove )
}, [])
Validate placement in real-time
import { spatialGridManager } from '@pascal-app/core'
const [ isValidPlacement , setIsValidPlacement ] = useState ( true )
useEffect (() => {
if ( ! cursorPosition ) return
const levelId = useViewer . getState (). selection . levelId
const result = spatialGridManager . canPlaceOnFloor (
levelId ,
cursorPosition ,
assetDimensions ,
[ 0 , rotation , 0 ],
[] // No IDs to ignore
)
setIsValidPlacement ( result . valid )
}, [ cursorPosition , rotation ])
Visualize validity
// Render a preview item with different materials based on validity
< mesh position = { cursorPosition } >
< boxGeometry args = { assetDimensions } />
< meshStandardMaterial
color = {isValidPlacement ? 0x00ff00 : 0xff0000 }
transparent
opacity = { 0.5 }
/>
</ mesh >
Place item on click
useEffect (() => {
const handleGridClick = ( event : GridEvent ) => {
if ( ! isValidPlacement ) {
console . warn ( 'Cannot place item: collision detected' )
return
}
const item = ItemNode . parse ({
position: event . position ,
rotation: [ 0 , rotation , 0 ],
asset: selectedAsset ,
})
const levelId = useViewer . getState (). selection . levelId
useScene . getState (). createNode ( item , levelId )
}
emitter . on ( 'grid:click' , handleGridClick )
return () => emitter . off ( 'grid:click' , handleGridClick )
}, [ isValidPlacement , rotation , selectedAsset ])
Advanced Use Cases
Finding Available Space
Iterate through positions to find the first available spot:
function findAvailablePosition (
levelId : string ,
dimensions : [ number , number , number ],
searchArea : { minX : number ; maxX : number ; minZ : number ; maxZ : number },
step = 0.5
) : [ number , number , number ] | null {
for ( let x = searchArea . minX ; x <= searchArea . maxX ; x += step ) {
for ( let z = searchArea . minZ ; z <= searchArea . maxZ ; z += step ) {
const result = spatialGridManager . canPlaceOnFloor (
levelId ,
[ x , 0 , z ],
dimensions ,
[ 0 , 0 , 0 ],
[]
)
if ( result . valid ) {
return [ x , 0 , z ]
}
}
}
return null // No available position found
}
Snapping to Walls
Find the nearest valid wall attachment point:
import { useScene } from '@pascal-app/core'
function findNearestWallPosition (
levelId : string ,
targetPosition : [ number , number , number ],
itemDimensions : [ number , number , number ]
) : { wallId : string ; localX : number ; localY : number } | null {
const nodes = useScene . getState (). nodes
const level = nodes [ levelId ]
if ( ! level || level . type !== 'level' ) return null
let closestWall : { wallId : string ; localX : number ; localY : number ; distance : number } | null = null
// Check each wall in the level
for ( const wallId of level . children ) {
const wall = nodes [ wallId ]
if ( ! wall || wall . type !== 'wall' ) continue
// Project target position onto wall
const wallVec = {
x: wall . end [ 0 ] - wall . start [ 0 ],
z: wall . end [ 1 ] - wall . start [ 1 ],
}
const wallLength = Math . sqrt ( wallVec . x ** 2 + wallVec . z ** 2 )
const toTarget = {
x: targetPosition [ 0 ] - wall . start [ 0 ],
z: targetPosition [ 2 ] - wall . start [ 1 ],
}
// Parametric position along wall (0-1)
const t = ( toTarget . x * wallVec . x + toTarget . z * wallVec . z ) / ( wallLength ** 2 )
const clampedT = Math . max ( 0 , Math . min ( 1 , t ))
const localX = clampedT * wallLength
// Check if position is valid
const result = spatialGridManager . canPlaceOnWall (
levelId ,
wallId ,
localX ,
targetPosition [ 1 ],
itemDimensions ,
'wall-side' ,
'front' ,
[]
)
if ( result . valid ) {
// Calculate distance from target to wall position
const wallX = wall . start [ 0 ] + clampedT * wallVec . x
const wallZ = wall . start [ 1 ] + clampedT * wallVec . z
const distance = Math . sqrt (
( targetPosition [ 0 ] - wallX ) ** 2 +
( targetPosition [ 2 ] - wallZ ) ** 2
)
if ( ! closestWall || distance < closestWall . distance ) {
closestWall = {
wallId ,
localX ,
localY: targetPosition [ 1 ],
distance ,
}
}
}
}
return closestWall
? { wallId: closestWall . wallId , localX: closestWall . localX , localY: closestWall . localY }
: null
}
Checking Item Overlaps
Find all items that overlap with a given area:
function getOverlappingItems (
levelId : string ,
position : [ number , number , number ],
dimensions : [ number , number , number ],
rotation : [ number , number , number ]
) : string [] {
const result = spatialGridManager . canPlaceOnFloor (
levelId ,
position ,
dimensions ,
rotation ,
[] // Don't ignore any items
)
return result . conflictIds
}
// Usage: Clear an area by deleting overlapping items
const overlapping = getOverlappingItems ( levelId , [ 5 , 0 , 5 ], [ 2 , 2 , 2 ], [ 0 , 0 , 0 ])
overlapping . forEach ( itemId => {
useScene . getState (). deleteNode ( itemId )
})
Spatial Grid Cell Size
The spatial grid divides space into cells for efficient lookup. The default cell size is 0.5 meters:
// The grid is optimized for typical furniture-sized objects
// Cell size balances memory usage vs. query performance
const manager = new SpatialGridManager ( 0.5 ) // 0.5m cells
The cell size is automatically configured. You don’t need to adjust it for typical use cases.
Efficient Queries
// Good: Single query
const canPlace = spatialGridManager . canPlaceOnFloor ( levelId , pos , dims , rot )
// Bad: Multiple unnecessary queries
for ( let i = 0 ; i < 100 ; i ++ ) {
spatialGridManager . canPlaceOnFloor ( levelId , pos , dims , rot ) // Wasteful
}
Batch Operations
When placing multiple items, validate all positions first:
const positions = generateGridPositions ( 10 , 10 )
const validPositions = positions . filter ( pos => {
return spatialGridManager . canPlaceOnFloor (
levelId ,
pos ,
dimensions ,
[ 0 , 0 , 0 ],
[]
). valid
})
// Create items at all valid positions
const items = validPositions . map ( pos =>
ItemNode . parse ({ position: pos , asset })
)
useScene . getState (). createNodes (
items . map ( item => ({ node: item , parentId: levelId }))
)
Best Practices
Always Validate Before Creating
// Good
const result = spatialGridManager . canPlaceOnFloor ( levelId , pos , dims , rot )
if ( result . valid ) {
useScene . getState (). createNode ( item , levelId )
} else {
showError ( 'Cannot place item: collision detected' )
}
// Bad: Create without validation
useScene . getState (). createNode ( item , levelId ) // May overlap!
Use Scaled Dimensions
import { getScaledDimensions } from '@pascal-app/core'
// Good: Account for item scale
const scaledDims = getScaledDimensions ( item )
const result = spatialGridManager . canPlaceOnFloor ( levelId , pos , scaledDims , rot )
// Bad: Use base dimensions
const result = spatialGridManager . canPlaceOnFloor (
levelId ,
pos ,
item . asset . dimensions , // Wrong if item is scaled!
rot
)
Always use getScaledDimensions() when querying the spatial grid to account for item scaling.
Ignore Self in Movement Validation
// When moving an item, ignore its current position
const currentItemId = 'item_abc123'
const result = spatialGridManager . canPlaceOnFloor (
levelId ,
newPosition ,
dimensions ,
rotation ,
[ currentItemId ] // Ignore self
)
Next Steps
Creating Nodes Create validated nodes using spatial queries
Event Handling Use spatial queries in event handlers