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
@@ -43,7 +43,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
import { ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
import { createEditorApi } from '../../lib/editor-api'
import { sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
@@ -681,16 +681,18 @@ function LinearArrow({
return {
overrideId,
onBegin: () => {
if (measureLabel) {
useInteractionScope
.getState()
.begin({ kind: 'handle-drag', nodeId, handle: measureLabel })
}
// Always claim the handle-drag scope so the HUD knows a resize is the
// active interaction (keeps the idle select hints off-screen). The
// dimension-pill handles carry their `measureLabel`; plain resize
// arrows use the generic label.
useInteractionScope.getState().begin({
kind: 'handle-drag',
nodeId,
handle: measureLabel ?? RESIZE_HANDLE_DRAG_LABEL,
})
},
onEnd: () => {
if (measureLabel) {
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
}
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
if (onDrag) useOpeningGuides.getState().clear()
},
move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
@@ -220,18 +220,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// commit / cancel / unmount so a follow-on drag starts clean.
const overriddenIdsRef = useRef<AnyNodeId[]>([])
// Shelf placement shows the same green/red footprint box GLB items use
// (instead of the vertical-arrow cursor) and refuses an invalid drop unless
// Shift forces it. The footprint comes from the kind's `floorPlaced`
// capability so this stays generic if we ever opt other kinds in.
const isShelf = node.type === 'shelf'
// Colliding floor kinds (item / shelf / column) show the same green/red
// footprint box GLB items use (instead of the vertical-arrow cursor) and
// refuse an invalid drop unless Alt forces it. The gate + footprint both come
// from the kind's declarative `floorPlaced` capability, so opting a new kind
// in is just `collides: true` — no change here.
const collides = nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true
const boxDimensions = useMemo(
() =>
isShelf
collides
? (nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ??
null)
: null,
[isShelf, node],
[collides, node],
)
const [valid, setValid] = useState(true)
const [cursorRotationY, setCursorRotationY] = useState(originalRotationY)
@@ -10,6 +10,12 @@ export type ContextualShortcutHint = {
// which route their own measurement label here.
export const ROTATE_HANDLE_DRAG_LABEL = 'rotate-handle'
// `activeHandleDrag.label` a plain resize / radial-resize arrow sets while
// dragging (when it carries no dimension `measureLabel`). It exists only so the
// interaction scope is non-idle during a resize, which keeps the idle
// select-mode hints off-screen — a resize is its own action, not a selection.
export const RESIZE_HANDLE_DRAG_LABEL = 'resize-handle'
// Hints shown while a rotate gizmo is mid-drag: Shift bypasses the angle step
// (free rotation), the same toggle wall drafting exposes. `active` lights the
// pill while Shift is held.