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
+22 -15
View File
@@ -298,20 +298,23 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[]
/**
* Column — Stage A registration. Wrap-export of the legacy
* `ColumnRenderer` (no system — column geometry is computed inline in
* the renderer). Inspector / move / floorplan still go through legacy
* paths via panel-manager.tsx / item-move-tool.tsx / floorplan-panel.tsx
* (their hardcoded `case 'column':` entries fire before the registry
* fallback).
* the renderer). Inspector / floorplan still go through legacy paths via
* panel-manager.tsx / floorplan-panel.tsx (their hardcoded `case 'column':`
* entries fire before the registry fallback).
*
* Capabilities: column doesn't declare `movable` because its move is
* bespoke (legacy MoveColumnTool snaps to slab + free placement on
* the X/Z plane with rotation).
* Capabilities: column declares the generic `movable` (translate on XZ
* with grid snap), so its 3D move runs through the shared
* `MoveRegistryNodeTool` — which gives it grid/line/off snapping, alignment,
* R/T rotation, slab-elevation lift, and the `collides` red/green placement
* box for free. (2D move still routes through `floorplanMoveTarget`, which
* wins the 2D dispatch.)
*
* Defaults computed via stub-parse so we leverage every zod
* `.default()` annotation on the schema (~60 fields).
*/
export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
kind: 'column',
snapProfile: 'item',
schemaVersion: 1,
schema: ColumnNode,
category: 'structure',
@@ -327,19 +330,29 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
// Generic 3D translate-on-XZ via `MoveRegistryNodeTool` (grid snap + the
// mode-driven snapping the overhaul standardised). 2D move keeps using
// `floorplanMoveTarget`, which wins the 2D move dispatch.
movable: { axes: ['x', 'z'], gridSnap: true },
slots: (node) => columnSlots(node as ColumnNodeType),
paint: columnPaint,
// Slab elevation lift via the generic `<FloorElevationSystem>`.
// Slab elevation lift via the generic `<FloorElevationSystem>` + the
// placement/collision box. Use the VISIBLE footprint (round → radius,
// square → width, rectangular → width/depth, plus brace spread) so the
// box, slab-overlap, and collision all track the real column size rather
// than the raw width/depth (stale for a round column resized by radius).
floorPlaced: {
footprint: (node) => {
const column = node as ColumnNodeType
const { halfX, halfZ } = columnFootprintHalf(column)
return {
dimensions: [column.width, column.height, column.depth] as [number, number, number],
dimensions: [halfX * 2, column.height, halfZ * 2] as [number, number, number],
// Column stores Y rotation as a scalar; the slab-overlap query
// expects the full Euler tuple.
rotation: [0, column.rotation, 0] as [number, number, number],
}
},
collides: true,
},
},
@@ -350,12 +363,6 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
kind: 'parametric',
module: () => import('./renderer'),
},
// Stage D — 3D move-tool (registry-driven). Replaces the legacy
// `MoveColumnTool` in editor's dispatcher. Same 0.5m grid snap +
// live-transform preview the legacy used.
affordanceTools: {
move: () => import('./move-tool'),
},
// Registry-driven placement tool — renders a translucent `ColumnPreview`
// ghost at the cursor (mirroring the shelf build tool) instead of the
// bare sphere the legacy editor-side `ColumnTool` showed. `ToolManager`'s
-295
View File
@@ -1,295 +0,0 @@
'use client'
import {
type AnyNodeId,
type ColumnNode,
ColumnNode as ColumnNodeSchema,
collectAlignmentAnchors,
emitter,
type GridEvent,
movingFootprintAnchors,
resolveAlignment,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import {
CursorSphere,
commitFreshPlacementSubtree,
consumePlacementDragRelease,
DragBoundingBox,
getFloorStackPreviewPosition,
markToolCancelConsumed,
resolvePlanarCursorPosition,
stripPlacementMetadataFlags,
triggerSFX,
useAlignmentGuides,
useEditor,
useFreshPlacementVisibility,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useState } from 'react'
/**
* Phase 5 Stage D — column's registry-driven 3D move affordance.
*
* Replaces the legacy `MoveColumnTool` in `editor/src/components/tools/
* column/move-column-tool.tsx`. Behaviour is identical: grid:move
* snaps the cursor to a 0.5m grid and previews the column at that
* position via `useLiveTransforms` + a direct `sceneRegistry.nodes.get
* (id).position.set(...)` (the live-drag exception documented in
* `wiki/architecture/tools.md`); grid:click commits via `useScene.
* updateNode`. Cancel restores the pre-drag position.
*
* Wired via `def.affordanceTools.move`. The editor's `MoveTool`
* dispatcher's `getRegistryAffordanceTool('column', 'move')` lookup
* picks this up before its legacy chain reaches `<MoveColumnTool>`.
*/
/** Snap to the editor's active grid step (0.5 / 0.25 / 0.1 / 0.05), read live. */
const snapToGridStep = (value: number) => {
const step = useEditor.getState().gridSnapStep
return Math.round(value / step) * step
}
/** 45° steps, matching the generic move tool's R/T rotation. */
const ROTATION_STEP = Math.PI / 4
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function MoveColumnTool({ node }: { node: ColumnNode }) {
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
const [previewRotation, setPreviewRotation] = useState<number>(node.rotation)
const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
useFreshPlacementVisibility({ node })
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
useScene.temporal.getState().pause()
let committed = false
// Ignore a commit before the cursor has moved into place: it's the stray
// trailing click of whatever armed this move (e.g. a preset re-arming the
// next copy right after a placement click), not a deliberate drop.
let hasMoved = false
// Live Y-rotation, seeded from the column and bumped by R/T.
let rotationY = node.rotation
// Latest previewed position, so an R/T press can re-apply at the spot.
let lastPosition: [number, number, number] = node.position
let dragAnchor: [number, number] | null = null
const isNew = isFreshPlacement
const getVisualPosition = (
position: [number, number, number],
rotation = rotationY,
): [number, number, number] =>
getFloorStackPreviewPosition({
node,
position,
rotation,
levelId: node.parentId ?? null,
})
// Alignment candidates — every other alignable object's anchors, gathered
// once (the scene graph is stable during the imperative drag).
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, node.id)
const applyPreview = (position: [number, number, number]) => {
lastPosition = position
const visualPosition = getVisualPosition(position)
setPreviewPosition(visualPosition)
setPreviewRotation(rotationY)
useLiveTransforms.getState().set(node.id, {
position,
rotation: rotationY,
})
useScene.getState().markDirty(node.id as AnyNodeId)
const m = sceneRegistry.nodes.get(node.id)
if (m) {
m.position.set(...visualPosition)
m.rotation.y = rotationY
}
}
setPreviewPosition(getVisualPosition(node.position, node.rotation))
const onGridMove = (event: GridEvent) => {
hasMoved = true
const rawX = event.localPosition[0]
const rawZ = event.localPosition[2]
revealFreshPlacement()
const resolved = resolvePlanarCursorPosition({
cursor: [rawX, rawZ],
original: [node.position[0], node.position[2]],
anchor: dragAnchor,
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep,
})
dragAnchor = resolved.anchor
let [x, z] = resolved.point
// Figma-style alignment snap on top of grid snap; Alt bypasses alignment; Shift all snap. The
// guide connects to the candidate's nearest real anchor (resolver
// tie-break), so the dot always sits on an actual point.
const bypass = event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true
if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: movingFootprintAnchors(node, x, z, rotationY),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
x += result.snap.dx
z += result.snap.dz
}
useAlignmentGuides.getState().set(result.guides)
} else {
useAlignmentGuides.getState().clear()
}
applyPreview([x, 0, z])
}
// R / T rotate the dragged column about Y in 45° steps (matches the move
// HUD's "Rotate" hints), committed on drop.
const onKeyDown = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return
let delta = 0
if (e.key === 'r' || e.key === 'R') delta = ROTATION_STEP
else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP
else return
e.preventDefault()
rotationY += delta
applyPreview(lastPosition)
}
const onGridClick = (event: GridEvent) => {
if (committed) return
if (!hasMoved) return
useAlignmentGuides.getState().clear()
// Commit at the last previewed position so the alignment snap (which
// may pull off-grid) is preserved, rather than re-snapping the raw
// click to the grid.
const position: [number, number, number] = [...lastPosition]
const nodeId = (node as { id?: ColumnNode['id'] }).id
let committedId = node.id as AnyNodeId
if (nodeId && useScene.getState().nodes[nodeId]) {
const data = {
position,
rotation: rotationY,
...(isNew
? {
metadata: stripPlacementMetadataFlags(node.metadata) as ColumnNode['metadata'],
visible: true,
}
: null),
}
if (isNew) {
const finalId = commitFreshPlacementSubtree(nodeId as AnyNodeId, data)
if (finalId) {
committed = true
committedId = finalId
}
} else {
committed = true
useScene.temporal.getState().resume()
useScene.getState().updateNode(nodeId, data)
}
useLiveTransforms.getState().clear(nodeId)
const m = sceneRegistry.nodes.get(nodeId)
if (m) {
m.position.set(...getVisualPosition(position, rotationY))
m.rotation.y = rotationY
}
} else if (node.parentId) {
const column = ColumnNodeSchema.parse({
...node,
id: undefined,
metadata: {},
position,
rotation: rotationY,
})
committed = true
useScene.temporal.getState().resume()
useScene.getState().createNode(column, node.parentId as AnyNodeId)
}
useLiveTransforms.getState().clear(node.id)
if (isNew && committed) {
useViewer.getState().setSelection({ selectedIds: [committedId] })
}
triggerSFX('sfx:item-place')
useEditor.getState().setMovingNodeOrigin('3d')
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => {
useLiveTransforms.getState().clear(node.id)
useAlignmentGuides.getState().clear()
if (isNew) {
useScene.getState().deleteNode(node.id as AnyNodeId)
} else {
const m = sceneRegistry.nodes.get(node.id)
if (m) {
m.position.set(...getVisualPosition(node.position, node.rotation))
m.rotation.y = node.rotation
}
useScene.getState().markDirty(node.id as AnyNodeId)
}
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('pointerup', onPlacementDragPointerUp)
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
useLiveTransforms.getState().clear(node.id)
useAlignmentGuides.getState().clear()
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
if (!(committed || isNew || finalisedBy2D)) {
const m = sceneRegistry.nodes.get(node.id)
if (m) {
m.position.set(...getVisualPosition(node.position, node.rotation))
m.rotation.y = node.rotation
}
useScene.getState().markDirty(node.id as AnyNodeId)
}
useScene.temporal.getState().resume()
}
}, [exitMoveMode, isFreshPlacement, node, revealFreshPlacement, useAbsoluteCursorPlacement])
if (!previewVisible) return null
return (
<>
<CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
<DragBoundingBox
fallbackSize={[node.width, node.height, node.depth]}
nodeId={node.id}
position={previewPosition}
rotationY={previewRotation}
/>
</>
)
}
export default MoveColumnTool
+6 -7
View File
@@ -11,6 +11,8 @@ import {
} from '@pascal-app/core'
import {
getFloorStackPreviewPosition,
isGridSnapActive,
isMagneticSnapActive,
triggerSFX,
useAlignmentGuides,
useEditor,
@@ -87,8 +89,8 @@ const ColumnTool = () => {
rawZ: event.localPosition[2],
gridStep: useEditor.getState().gridSnapStep,
candidates: alignmentCandidates,
bypassAlignment: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassGrid: event.nativeEvent?.shiftKey === true,
bypassAlignment: !isMagneticSnapActive(),
bypassGrid: !isGridSnapActive(),
})
useAlignmentGuides.getState().set(guides)
@@ -108,10 +110,7 @@ const ColumnTool = () => {
usePlacementPreview.getState().set({ ...previewNode, position })
const prev = previousSnapRef.current
if (
event.nativeEvent?.shiftKey !== true &&
(!prev || prev[0] !== position[0] || prev[1] !== position[2])
) {
if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) {
triggerSFX('sfx:grid-snap')
previousSnapRef.current = [position[0], position[2]]
}
@@ -124,7 +123,7 @@ const ColumnTool = () => {
activeLevelId,
event,
useEditor.getState().gridSnapStep,
event.nativeEvent?.shiftKey === true,
!isGridSnapActive(),
)
const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position)