feat(editor): mode-driven shelf/column/spawn placement + cross-kind floor collision

Migrate the remaining floor-placed kinds onto the unified snapping/modifier
model and generalize floor collision so any solid floor kind blocks any other.

- shelf/column/spawn declare `snapProfile: 'item'` → contextual snapping chip,
  Shift=cycle, Ctrl=grid step during placement; their tools read the active
  mode (grid/lines/off) instead of legacy Shift/Alt bypass; spawn fresh
  placement now respects alignment ("lines") like its move.
- Resize/radial handles claim the handle-drag scope (new RESIZE_HANDLE_DRAG_LABEL)
  so the HUD shows no select-mode shortcuts mid-resize.
- Column move migrated to the generic MoveRegistryNodeTool (declare `movable`,
  drop the bespoke move-tool) — gains mode-driven snapping, alignment, R/T,
  slab lift, grid SFX, and the collision box for free. 2D move still routes
  through `floorplanMoveTarget`.
- Cross-kind floor collision: new declarative `FloorPlacedConfig.collides`
  (item/shelf/column opt in; spawn/MEP/stair stay off). `canPlaceOnFloor` now
  treats every colliding floor kind as an obstacle (was item-only), reading the
  declarative footprint; the generic move tool's red/green placement box gates
  on `collides`. Column footprint uses the visible `columnFootprintHalf` extent
  so the box/slab-lift/collision track the real (round/square) column size.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-24 16:22:04 -04:00
co-authored by Claude Opus 4.8
parent 04f1b0d59e
commit 8a57105eec
13 changed files with 170 additions and 409 deletions
+45 -45
View File
@@ -1,25 +1,28 @@
'use client'
import {
collectAlignmentAnchors,
emitter,
type GridEvent,
SpawnNode,
sceneRegistry,
snapScalar,
useScene,
} from '@pascal-app/core'
import {
CursorSphere,
getFloorStackPreviewPosition,
isGridSnapActive,
isMagneticSnapActive,
triggerSFX,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { type Group, Vector3 } from 'three'
const snapToGrid = (value: number) => snapScalar(value, useEditor.getState().gridSnapStep)
const worldVector = new Vector3()
import { useEffect, useMemo, useRef } from 'react'
import type { Group } from 'three'
import {
getLevelLocalSnappedPosition,
resolveAlignedFloorPlacement,
} from '../shared/floor-placement'
function getExistingSpawnIds() {
const nodes = useScene.getState().nodes
@@ -29,53 +32,42 @@ function getExistingSpawnIds() {
.sort()
}
function getLevelLocalPosition(
levelId: string,
event: GridEvent,
bypassSnap: boolean,
): [number, number, number] {
const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) {
return bypassSnap
? [event.localPosition[0], 0, event.localPosition[2]]
: [snapToGrid(event.localPosition[0]), 0, snapToGrid(event.localPosition[2])]
}
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector)
return bypassSnap
? [worldVector.x, 0, worldVector.z]
: [snapToGrid(worldVector.x), 0, snapToGrid(worldVector.z)]
}
/**
* Registry-driven spawn placement tool. Reads `activeLevelId` from useViewer
* directly (no props), broadcasts placement via store updates + SFX, and
* uses the shared CursorSphere from @pascal-app/editor for visual parity
* with legacy placement tools.
* with legacy placement tools. Snapping is mode-driven (grid + Figma-style
* alignment "lines"), matching the shelf / column build tools.
*/
const SpawnTool = () => {
const activeLevelId = useViewer((state) => state.selection.levelId)
const cursorRef = useRef<Group>(null)
const previousSnapRef = useRef<[number, number] | null>(null)
// Default spawn for the footprint anchors the alignment solver reads.
const previewNode = useMemo(
() => SpawnNode.parse({ name: 'Spawn Point', position: [0, 0, 0], rotation: 0 }),
[],
)
useEffect(() => {
if (!activeLevelId) return
previousSnapRef.current = null
const lastCursorRef: { current: [number, number, number] | null } = { current: null }
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
const onGridMove = (event: GridEvent) => {
// Cursor lives in the ToolManager's building-local group. Use
// event.localPosition directly (already building-local), snapped to the
// editor's configured grid step (Shift bypasses).
const bypassSnap = event.nativeEvent?.shiftKey === true
const nextX = bypassSnap ? event.localPosition[0] : snapToGrid(event.localPosition[0])
const nextZ = bypassSnap ? event.localPosition[2] : snapToGrid(event.localPosition[2])
const position: [number, number, number] = [nextX, 0, nextZ]
const previewNode = SpawnNode.parse({
name: 'Spawn Point',
position,
rotation: 0,
const { position, guides } = resolveAlignedFloorPlacement({
node: previewNode,
rawX: event.localPosition[0],
rawZ: event.localPosition[2],
gridStep: useEditor.getState().gridSnapStep,
candidates: alignmentCandidates,
bypassAlignment: !isMagneticSnapActive(),
bypassGrid: !isGridSnapActive(),
})
useAlignmentGuides.getState().set(guides)
const visualPosition = getFloorStackPreviewPosition({
node: previewNode,
position,
@@ -83,19 +75,24 @@ const SpawnTool = () => {
levelId: activeLevelId,
})
cursorRef.current?.position.set(...visualPosition)
lastCursorRef.current = position
// Fire grid-snap SFX only when the snapped position crosses a cell,
// not every frame the mouse moves within the same cell. Matches the
// wall / slab / curve tools.
const prev = previousSnapRef.current
if (!bypassSnap && (!prev || prev[0] !== nextX || prev[1] !== nextZ)) {
if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) {
triggerSFX('sfx:grid-snap')
previousSnapRef.current = [nextX, nextZ]
previousSnapRef.current = [position[0], position[2]]
}
}
const onGridClick = (event: GridEvent) => {
const next = getLevelLocalPosition(activeLevelId, event, event.nativeEvent?.shiftKey === true)
const next =
lastCursorRef.current ??
getLevelLocalSnappedPosition(
activeLevelId,
event,
useEditor.getState().gridSnapStep,
!isGridSnapActive(),
)
const [existingSpawnId, ...duplicates] = getExistingSpawnIds()
let placedId: SpawnNode['id']
@@ -121,6 +118,8 @@ const SpawnTool = () => {
useViewer.getState().setSelection({ selectedIds: [placedId] })
triggerSFX('sfx:structure-build')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
useAlignmentGuides.getState().clear()
useEditor.getState().setTool(null)
useEditor.getState().setMode('select')
}
@@ -131,8 +130,9 @@ const SpawnTool = () => {
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
useAlignmentGuides.getState().clear()
}
}, [activeLevelId])
}, [activeLevelId, previewNode])
if (!activeLevelId) return null