Item Placement with Wall and Floor Validation in Pascal Editor

Pascal Editor validates every item placement in real-time through surface-specific spatial grids that check collisions against floors, walls, and ceilings before committing any object to the scene.

The pascalorg/editor repository implements a strict validation pipeline that prevents geometric intersections by decoupling UI interaction logic from spatial-grid computations. This architecture guarantees that items snapped to floors, walls, or ceilings never penetrate slabs, existing objects, or surface boundaries, with immediate visual feedback driven by the core spatial-grid manager.

Real-Time Validation Architecture

The validation flow follows a strict sequence: cursor movement triggers a placement strategy, which computes a tentative PlacementResult, then queries the unified validator, updates the Three.js cursor color (green for valid, red for invalid), and only commits the draft node when checkCanPlace returns true.

The Placement Coordinator Hook

At packages/editor/src/components/tools/item/use-placement_coordinator.tsx, the usePlacementCoordinator hook orchestrates the entire interaction loop. It instantiates a Three.js cursor mesh and wires it to the current draft node, invoking revalidate on every grid:move, wall:enter, wall:move, or grid:click event.

The revalidate function aggregates surface-specific validators and mutates cursor materials in place:

const revalidate = (): boolean => {
  const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators)
  const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green : red
  edgeMaterial.color.setHex(color)
  basePlaneMaterial.color.setHex(color)
  return placeable
}

If validation fails while a draft exists, the coordinator immediately destroys the draft node to keep the scene graph clean.

Surface-Specific Placement Strategies

Located in packages/editor/src/components/tools/item/placement-strategies.ts, the strategy implementations compute world transforms and trigger the correct validator:

  • floorStrategy – Snaps the cursor to the grid and, on click, invokes validators.canPlaceOnFloor.
  • wallStrategy – Validates the attachment side (front/back), calculates the Y-offset via the validator's adjustedY return value, and reparents the draft node to the wall scene node.

The wall validation excerpt demonstrates this adjustment:

const validation = validators.canPlaceOnWall(
  ctx.levelId,
  event.node.id,
  x, y,
  ctx.draftItem ? getScaledDimensions(ctx.draftItem) : DEFAULT_DIMENSIONS,
  attachTo,
  side,
  [],
)
const adjustedY = validation.adjustedY ?? y

Unified Validation API

All surface queries funnel through checkCanPlace, which dispatches to the appropriate spatial-grid manager method based on the item's attachTo property.

The checkCanPlace Function

Defined at the bottom of packages/editor/src/components/tools/item/placement-strategies.ts, this function acts as a single entry point for validity checks:

export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
  if (!(ctx.levelId && ctx.draftItem)) return false

  // Item-surface – only needs a non-null surfaceItemId
  if (ctx.state.surface === 'item-surface') return ctx.state.surfaceItemId !== null

  const attachTo = ctx.draftItem.asset.attachTo

  // Ceiling validation
  if (attachTo === 'ceiling') {
    return validators.canPlaceOnCeiling(...).valid
  }

  // Wall validation
  if (attachTo === 'wall' || attachTo === 'wall-side') {
    return validators.canPlaceOnWall(...).valid
  }

  // Floor (default)
  return validators.canPlaceOnFloor(...).valid
}

Spatial Grid Manager Implementation

The concrete collision logic resides in packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts.

Floor validation queries a 2-D occupancy map and performs AABB overlap checks:

canPlaceOnFloor(levelId, position, dimensions, rotation, ignoreIds) {
  const grid = this.getFloorGrid(levelId)
  return grid.canPlace(position, dimensions, rotation, ignoreIds)
}

Wall validation uses a parametric grid that respects side-specific constraints (wall for two-sided items, wall-side for single-sided). The method signature returns both a validity flag and an adjusted height:

canPlaceOnWall(
  levelId,
  wallId,
  localX,
  localY,
  dimensions,
  attachType = 'wall',
  side,
  ignoreIds,
) {
  const wallLength = this.getWallLength(wallId)
  const wallHeight = this.getWallHeight(wallId)
  const tCenter = localX / wallLength
  const [itemWidth, itemHeight] = dimensions
  return this.getWallGrid(levelId).canPlaceOnWall(
    wallId,
    wallLength,
    wallHeight,
    tCenter,
    itemWidth,
    localY,
    itemHeight,
    attachType,
    side,
    ignoreIds,
  )
}

The returned object includes adjustedY, which the placement coordinator uses to snap the item upward when wall steps or slabs intersect the intended height. Ceiling validation mirrors floor logic but operates on a separate ceiling grid.

Implementation Examples

Placing Items on the Floor

The following pattern demonstrates floor placement using the coordinator and spatial query hooks:

import { usePlacementCoordinator } from '@pascal-app/editor/src/components/tools/item/use-placement_coordinator'
import { useSpatialQuery } from '@pascal-app/core/src/hooks/spatial-grid/use-spatial-query'

function FloorItemPlacer({ asset }) {
  const { canPlaceOnFloor } = useSpatialQuery()
  const draft = useDraftNode() // Creates the visual draft mesh
  const cursor = useRef<Group>(null!)

  const placementNode = usePlacementCoordinator({
    asset,
    draftNode: draft,
    initDraft: (gridPos) => draft.create(gridPos, asset, [0, 0, 0]),
    onCommitted: () => true,
    onCancel: () => draft.destroy(),
  })

  // Visual feedback is automatic: the cursor turns green when
  // canPlaceOnFloor(levelId, [x,0,z], dims, [0,0,0]) returns {valid:true}
  return <>{placementNode}</>
}

When the user moves the pointer, floorStrategy.move computes the snapped [x,0,z]. The coordinator then executes validators.canPlaceOnFloor. If the floor grid reports a collision—such as another item occupying the same space or the point lying inside a slab hole—the cursor material switches to red and the click handler prevents the commit.

Wall Placement with Y-Adjustment

Wall placement requires transitioning the draft node to the wall's coordinate space and applying the validator's height adjustment:

import { wallStrategy } from '@pascal-app/editor/src/components/tools/item/placement-strategies'

function onWallEnter(event: WallEvent) {
  const ctx = getPlacementContext()
  const result = wallStrategy.enter(ctx, event, resolveLevelId, nodes, validators)
  if (result) {
    // Transition the draft to wall-parented state
    applyTransition(result)
  }
}

Inside wallStrategy.enter, the code verifies the asset’s attachTo property (either wall or wall-side), calls validators.canPlaceOnWall to obtain the adjustedY, and returns a TransitionResult that reparents the draft node and sets its Y-position to the validated height. The coordinator immediately calls revalidate() again, updating the cursor color based on the wall grid's response.

Summary

  • Unified entry point – The checkCanPlace function in placement-strategies.ts dispatches to surface-specific validators (canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling) based on the item's attachTo property.
  • Real-time feedback – usePlacementCoordinator updates Three.js cursor materials to green (0x22_c5_5e) or red (0xef_44_44) instantly as the user moves the cursor, preventing invalid commits before they occur.
  • Wall-specific logic – The spatial-grid manager returns an adjustedY value that automatically raises items above wall steps or slab intersections, respecting both single-sided (wall-side) and double-sided (wall) attachment modes.
  • Clean separation – The editor package handles UI concerns (cursor meshes, events, draft nodes) while the core package encapsulates all geometric collision detection in spatial-grid-manager.ts.

Frequently Asked Questions

How does Pascal Editor prevent items from intersecting walls during placement?

The canPlaceOnWall method in packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts queries a parametric wall grid that checks side-specific collisions against the wall's length and height. It returns {valid: boolean, adjustedY?: number}, where adjustedY shifts the item upward if a step or slab blocks the original height, ensuring the cursor turns red and blocks the commit when geometric overlap is detected.

What is the difference between wall and wall-side attachment types?

wall allows items to occupy space on both the front and back surfaces of a wall simultaneously (centered placement), while wall-side restricts validation to a single surface side (front or back). The placement strategy passes this attachType parameter to canPlaceOnWall, which toggles the collision detection logic in the wall grid to respect single- or double-sided occupancy rules.

Why does the placement cursor turn red even when the surface looks empty?

The spatial-grid manager maintains internal 2-D occupancy maps for floors and ceilings and parametric grids for walls that track all committed items and architectural elements like slabs. If the cursor enters a cell marked as occupied by an existing object or excluded by a slab hole, checkCanPlace returns false, triggering the red color (0xef_44_44) and preventing the draft node from being committed to the scene graph.

Can the validation system handle items attached to other items rather than surfaces?

Yes. When ctx.state.surface === 'item-surface', the checkCanPlace function short-circuits surface validation and simply verifies that ctx.state.surfaceItemId !== null. This allows the placement coordinator to snap cursors to valid item anchors without invoking floor, wall, or ceiling grid queries, streamlining the validation path for stacked or hanging objects.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →