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:
co-authored by
Claude Opus 4.8
parent
04f1b0d59e
commit
8a57105eec
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user