Skip to main content
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:
1

Get the current level ID

import { useViewer } from '@pascal-app/viewer'

const levelId = useViewer.getState().selection.levelId
2

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:
  1. The item footprint is within the ceiling polygon
  2. The item is not in a ceiling hole
  3. 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:
1

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)
}, [])
2

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])
3

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>
4

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)
})

Performance Considerations

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

Build docs developers (and LLMs) love