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
@@ -1,8 +1,10 @@
import { nodeRegistry } from '../../registry'
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { isCurvedWall, sampleWallCenterline } from '../../systems/wall/wall-curve' import { isCurvedWall, sampleWallCenterline } from '../../systems/wall/wall-curve'
import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint'
import { getFloorPlacedFootprints } from './floor-placed-elevation'
import { SpatialGrid } from './spatial-grid' import { SpatialGrid } from './spatial-grid'
import { WallSpatialGrid } from './wall-spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid'
@@ -54,6 +56,29 @@ function getItemFootprint(
] ]
} }
/**
* Axis-aligned XZ extent of a footprint at `position`, rotated by `yRot`. The
* rotated width/depth is the same conservative bound the floor-placement draft
* uses, so a draft and an existing node are compared with identical math.
*/
function footprintBoundsXZ(
position: [number, number, number],
dimensions: [number, number, number],
yRot: number,
): { minX: number; maxX: number; minZ: number; maxZ: number } {
const [width, , depth] = dimensions
const cos = Math.abs(Math.cos(yRot))
const sin = Math.abs(Math.sin(yRot))
const rotatedW = width * cos + depth * sin
const rotatedD = width * sin + depth * cos
return {
minX: position[0] - rotatedW / 2,
maxX: position[0] + rotatedW / 2,
minZ: position[2] - rotatedD / 2,
maxZ: position[2] + rotatedD / 2,
}
}
type ItemLocalBounds = { type ItemLocalBounds = {
min: [number, number, number] min: [number, number, number]
max: [number, number, number] max: [number, number, number]
@@ -647,34 +672,38 @@ export class SpatialGridManager {
) { ) {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const ignoreSet = new Set(ignoreIds ?? []) const ignoreSet = new Set(ignoreIds ?? [])
const [width, , depth] = dimensions const draftBounds = footprintBoundsXZ(position, dimensions, rotation[1])
const yRot = rotation[1]
const cos = Math.abs(Math.cos(yRot))
const sin = Math.abs(Math.sin(yRot))
const rotatedW = width * cos + depth * sin
const rotatedD = width * sin + depth * cos
const draftBounds = {
minX: position[0] - rotatedW / 2,
maxX: position[0] + rotatedW / 2,
minZ: position[2] - rotatedD / 2,
maxZ: position[2] + rotatedD / 2,
}
// A floor placement conflicts with any other COLLIDING floor-resting node,
// not just items — every kind whose `floorPlaced.collides` is set (item /
// shelf / column) contributes its footprint(s) as an obstacle. Each
// candidate's XZ extent is read from the same declarative footprint the
// elevation + sync paths use, so adding a colliding kind needs no change here.
const conflicts: string[] = [] const conflicts: string[] = []
for (const node of Object.values(nodes)) { for (const node of Object.values(nodes)) {
if (node.type !== 'item') continue if (ignoreSet.has(node.id)) continue
const item = node as ItemNode const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
if (item.asset.attachTo) continue if (!floorPlaced?.collides) continue
if (isLowProfileItemSurface(item)) continue if (floorPlaced.applies && !floorPlaced.applies(node)) continue
if (ignoreSet.has(item.id)) continue // Low-profile item surfaces (rugs, mats) are stack-on targets, not
if (resolveNodeLevelId(item, nodes) !== levelId) continue // obstacles — keep the long-standing item-only exemption.
if (node.type === 'item' && isLowProfileItemSurface(node as ItemNode)) continue
if (resolveNodeLevelId(node, nodes) !== levelId) continue
const bounds = getItemParentAabb(item) for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) {
if ( const fpRotation = Array.isArray(footprint.rotation) ? (footprint.rotation[1] ?? 0) : 0
intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && const bounds = footprintBoundsXZ(
intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ) footprint.position ?? (node as { position: [number, number, number] }).position,
) { footprint.dimensions,
conflicts.push(item.id) fpRotation,
)
if (
intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) &&
intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ)
) {
conflicts.push(node.id)
break
}
} }
} }
+9
View File
@@ -1575,6 +1575,15 @@ export type FloorPlacedConfig = {
footprint?: FloorPlacedFootprintResolver footprint?: FloorPlacedFootprintResolver
footprints?: FloorPlacedFootprintsResolver footprints?: FloorPlacedFootprintsResolver
applies?: (node: AnyNode) => boolean applies?: (node: AnyNode) => boolean
/**
* Opt this kind into floor-placement collision: its footprint blocks other
* placements (it's an obstacle in `canPlaceOnFloor`) AND its own
* placement/move refuses to overlap another colliding footprint (red ghost,
* Alt to force). Solid furniture-like kinds (item / shelf / column) set this;
* markers and port-mated kinds (spawn / MEP / stair) leave it off so they
* neither block nor get blocked. Default off.
*/
collides?: boolean
} }
/** /**
@@ -43,7 +43,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants' 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 { createEditorApi } from '../../lib/editor-api'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
@@ -681,16 +681,18 @@ function LinearArrow({
return { return {
overrideId, overrideId,
onBegin: () => { onBegin: () => {
if (measureLabel) { // Always claim the handle-drag scope so the HUD knows a resize is the
useInteractionScope // active interaction (keeps the idle select hints off-screen). The
.getState() // dimension-pill handles carry their `measureLabel`; plain resize
.begin({ kind: 'handle-drag', nodeId, handle: measureLabel }) // arrows use the generic label.
} useInteractionScope.getState().begin({
kind: 'handle-drag',
nodeId,
handle: measureLabel ?? RESIZE_HANDLE_DRAG_LABEL,
})
}, },
onEnd: () => { onEnd: () => {
if (measureLabel) { useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
}
if (onDrag) useOpeningGuides.getState().clear() if (onDrag) useOpeningGuides.getState().clear()
}, },
move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => { 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. // commit / cancel / unmount so a follow-on drag starts clean.
const overriddenIdsRef = useRef<AnyNodeId[]>([]) const overriddenIdsRef = useRef<AnyNodeId[]>([])
// Shelf placement shows the same green/red footprint box GLB items use // Colliding floor kinds (item / shelf / column) show the same green/red
// (instead of the vertical-arrow cursor) and refuses an invalid drop unless // footprint box GLB items use (instead of the vertical-arrow cursor) and
// Shift forces it. The footprint comes from the kind's `floorPlaced` // refuse an invalid drop unless Alt forces it. The gate + footprint both come
// capability so this stays generic if we ever opt other kinds in. // from the kind's declarative `floorPlaced` capability, so opting a new kind
const isShelf = node.type === 'shelf' // in is just `collides: true` — no change here.
const collides = nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true
const boxDimensions = useMemo( const boxDimensions = useMemo(
() => () =>
isShelf collides
? (nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ?? ? (nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ??
null) null)
: null, : null,
[isShelf, node], [collides, node],
) )
const [valid, setValid] = useState(true) const [valid, setValid] = useState(true)
const [cursorRotationY, setCursorRotationY] = useState(originalRotationY) const [cursorRotationY, setCursorRotationY] = useState(originalRotationY)
@@ -10,6 +10,12 @@ export type ContextualShortcutHint = {
// which route their own measurement label here. // which route their own measurement label here.
export const ROTATE_HANDLE_DRAG_LABEL = 'rotate-handle' 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 // 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 // (free rotation), the same toggle wall drafting exposes. `active` lights the
// pill while Shift is held. // pill while Shift is held.
+22 -15
View File
@@ -298,20 +298,23 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[]
/** /**
* Column — Stage A registration. Wrap-export of the legacy * Column — Stage A registration. Wrap-export of the legacy
* `ColumnRenderer` (no system — column geometry is computed inline in * `ColumnRenderer` (no system — column geometry is computed inline in
* the renderer). Inspector / move / floorplan still go through legacy * the renderer). Inspector / floorplan still go through legacy paths via
* paths via panel-manager.tsx / item-move-tool.tsx / floorplan-panel.tsx * panel-manager.tsx / floorplan-panel.tsx (their hardcoded `case 'column':`
* (their hardcoded `case 'column':` entries fire before the registry * entries fire before the registry fallback).
* fallback).
* *
* Capabilities: column doesn't declare `movable` because its move is * Capabilities: column declares the generic `movable` (translate on XZ
* bespoke (legacy MoveColumnTool snaps to slab + free placement on * with grid snap), so its 3D move runs through the shared
* the X/Z plane with rotation). * `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 * Defaults computed via stub-parse so we leverage every zod
* `.default()` annotation on the schema (~60 fields). * `.default()` annotation on the schema (~60 fields).
*/ */
export const columnDefinition: NodeDefinition<typeof ColumnNode> = { export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
kind: 'column', kind: 'column',
snapProfile: 'item',
schemaVersion: 1, schemaVersion: 1,
schema: ColumnNode, schema: ColumnNode,
category: 'structure', category: 'structure',
@@ -327,19 +330,29 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
selectable: { hitVolume: 'bbox' }, selectable: { hitVolume: 'bbox' },
duplicable: true, duplicable: true,
deletable: 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), slots: (node) => columnSlots(node as ColumnNodeType),
paint: columnPaint, 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: { floorPlaced: {
footprint: (node) => { footprint: (node) => {
const column = node as ColumnNodeType const column = node as ColumnNodeType
const { halfX, halfZ } = columnFootprintHalf(column)
return { 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 // Column stores Y rotation as a scalar; the slab-overlap query
// expects the full Euler tuple. // expects the full Euler tuple.
rotation: [0, column.rotation, 0] as [number, number, number], rotation: [0, column.rotation, 0] as [number, number, number],
} }
}, },
collides: true,
}, },
}, },
@@ -350,12 +363,6 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
kind: 'parametric', kind: 'parametric',
module: () => import('./renderer'), 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` // Registry-driven placement tool — renders a translucent `ColumnPreview`
// ghost at the cursor (mirroring the shelf build tool) instead of the // ghost at the cursor (mirroring the shelf build tool) instead of the
// bare sphere the legacy editor-side `ColumnTool` showed. `ToolManager`'s // 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' } from '@pascal-app/core'
import { import {
getFloorStackPreviewPosition, getFloorStackPreviewPosition,
isGridSnapActive,
isMagneticSnapActive,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
useEditor, useEditor,
@@ -87,8 +89,8 @@ const ColumnTool = () => {
rawZ: event.localPosition[2], rawZ: event.localPosition[2],
gridStep: useEditor.getState().gridSnapStep, gridStep: useEditor.getState().gridSnapStep,
candidates: alignmentCandidates, candidates: alignmentCandidates,
bypassAlignment: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, bypassAlignment: !isMagneticSnapActive(),
bypassGrid: event.nativeEvent?.shiftKey === true, bypassGrid: !isGridSnapActive(),
}) })
useAlignmentGuides.getState().set(guides) useAlignmentGuides.getState().set(guides)
@@ -108,10 +110,7 @@ const ColumnTool = () => {
usePlacementPreview.getState().set({ ...previewNode, position }) usePlacementPreview.getState().set({ ...previewNode, position })
const prev = previousSnapRef.current const prev = previousSnapRef.current
if ( if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) {
event.nativeEvent?.shiftKey !== true &&
(!prev || prev[0] !== position[0] || prev[1] !== position[2])
) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
previousSnapRef.current = [position[0], position[2]] previousSnapRef.current = [position[0], position[2]]
} }
@@ -124,7 +123,7 @@ const ColumnTool = () => {
activeLevelId, activeLevelId,
event, event,
useEditor.getState().gridSnapStep, useEditor.getState().gridSnapStep,
event.nativeEvent?.shiftKey === true, !isGridSnapActive(),
) )
const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position) const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position)
+1
View File
@@ -225,6 +225,7 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
return { dimensions: getScaledDimensions(item), rotation: item.rotation } return { dimensions: getScaledDimensions(item), rotation: item.rotation }
}, },
applies: (node) => !(node as ItemNodeType).asset.attachTo, applies: (node) => !(node as ItemNodeType).asset.attachTo,
collides: true,
}, },
// Recessed ceiling fixtures cut a hole in their host ceiling. The viewer's // Recessed ceiling fixtures cut a hole in their host ceiling. The viewer's
// CeilingSystem queries this capability on each child of a ceiling so it // CeilingSystem queries this capability on each child of a ceiling so it
+2
View File
@@ -132,6 +132,7 @@ function shelfHandles(_node: ShelfNodeType): HandleDescriptor<ShelfNodeType>[] {
export const shelfDefinition: NodeDefinition<typeof ShelfNode> = { export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
kind: 'shelf', kind: 'shelf',
snapProfile: 'item',
schemaVersion: 2, schemaVersion: 2,
schema: ShelfNode, schema: ShelfNode,
category: 'furnish', category: 'furnish',
@@ -197,6 +198,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
rotation: shelf.rotation, rotation: shelf.rotation,
} }
}, },
collides: true,
}, },
}, },
+6 -7
View File
@@ -9,6 +9,8 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
getFloorStackPreviewPosition, getFloorStackPreviewPosition,
isGridSnapActive,
isMagneticSnapActive,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
useEditor, useEditor,
@@ -83,8 +85,8 @@ const ShelfTool = () => {
rawZ: event.localPosition[2], rawZ: event.localPosition[2],
gridStep: useEditor.getState().gridSnapStep, gridStep: useEditor.getState().gridSnapStep,
candidates: alignmentCandidates, candidates: alignmentCandidates,
bypassAlignment: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, bypassAlignment: !isMagneticSnapActive(),
bypassGrid: event.nativeEvent?.shiftKey === true, bypassGrid: !isGridSnapActive(),
}) })
useAlignmentGuides.getState().set(guides) useAlignmentGuides.getState().set(guides)
@@ -98,10 +100,7 @@ const ShelfTool = () => {
lastCursorRef.current = position lastCursorRef.current = position
const prev = previousSnapRef.current const prev = previousSnapRef.current
if ( if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) {
event.nativeEvent?.shiftKey !== true &&
(!prev || prev[0] !== position[0] || prev[1] !== position[2])
) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
previousSnapRef.current = [position[0], position[2]] previousSnapRef.current = [position[0], position[2]]
} }
@@ -118,7 +117,7 @@ const ShelfTool = () => {
activeLevelId, activeLevelId,
event, event,
useEditor.getState().gridSnapStep, useEditor.getState().gridSnapStep,
event.nativeEvent?.shiftKey === true, !isGridSnapActive(),
) )
const shelf = ShelfNode.parse({ const shelf = ShelfNode.parse({
...shelfDefinition.defaults(), ...shelfDefinition.defaults(),
+1
View File
@@ -47,6 +47,7 @@ function spawnMoveHandle(): HandleDescriptor<SpawnNodeType> {
export const spawnDefinition: NodeDefinition<typeof SpawnNode> = { export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
kind: 'spawn', kind: 'spawn',
snapProfile: 'item',
schemaVersion: 1, schemaVersion: 1,
schema: SpawnNode, schema: SpawnNode,
category: 'site', category: 'site',
+45 -45
View File
@@ -1,25 +1,28 @@
'use client' 'use client'
import { import {
collectAlignmentAnchors,
emitter, emitter,
type GridEvent, type GridEvent,
SpawnNode, SpawnNode,
sceneRegistry,
snapScalar,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
CursorSphere, CursorSphere,
getFloorStackPreviewPosition, getFloorStackPreviewPosition,
isGridSnapActive,
isMagneticSnapActive,
triggerSFX, triggerSFX,
useAlignmentGuides,
useEditor, useEditor,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import { type Group, Vector3 } from 'three' import type { Group } from 'three'
import {
const snapToGrid = (value: number) => snapScalar(value, useEditor.getState().gridSnapStep) getLevelLocalSnappedPosition,
const worldVector = new Vector3() resolveAlignedFloorPlacement,
} from '../shared/floor-placement'
function getExistingSpawnIds() { function getExistingSpawnIds() {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
@@ -29,53 +32,42 @@ function getExistingSpawnIds() {
.sort() .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 * Registry-driven spawn placement tool. Reads `activeLevelId` from useViewer
* directly (no props), broadcasts placement via store updates + SFX, and * directly (no props), broadcasts placement via store updates + SFX, and
* uses the shared CursorSphere from @pascal-app/editor for visual parity * 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 SpawnTool = () => {
const activeLevelId = useViewer((state) => state.selection.levelId) const activeLevelId = useViewer((state) => state.selection.levelId)
const cursorRef = useRef<Group>(null) const cursorRef = useRef<Group>(null)
const previousSnapRef = useRef<[number, number] | null>(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(() => { useEffect(() => {
if (!activeLevelId) return if (!activeLevelId) return
previousSnapRef.current = null previousSnapRef.current = null
const lastCursorRef: { current: [number, number, number] | null } = { current: null }
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
// Cursor lives in the ToolManager's building-local group. Use const { position, guides } = resolveAlignedFloorPlacement({
// event.localPosition directly (already building-local), snapped to the node: previewNode,
// editor's configured grid step (Shift bypasses). rawX: event.localPosition[0],
const bypassSnap = event.nativeEvent?.shiftKey === true rawZ: event.localPosition[2],
const nextX = bypassSnap ? event.localPosition[0] : snapToGrid(event.localPosition[0]) gridStep: useEditor.getState().gridSnapStep,
const nextZ = bypassSnap ? event.localPosition[2] : snapToGrid(event.localPosition[2]) candidates: alignmentCandidates,
const position: [number, number, number] = [nextX, 0, nextZ] bypassAlignment: !isMagneticSnapActive(),
const previewNode = SpawnNode.parse({ bypassGrid: !isGridSnapActive(),
name: 'Spawn Point',
position,
rotation: 0,
}) })
useAlignmentGuides.getState().set(guides)
const visualPosition = getFloorStackPreviewPosition({ const visualPosition = getFloorStackPreviewPosition({
node: previewNode, node: previewNode,
position, position,
@@ -83,19 +75,24 @@ const SpawnTool = () => {
levelId: activeLevelId, levelId: activeLevelId,
}) })
cursorRef.current?.position.set(...visualPosition) 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 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') triggerSFX('sfx:grid-snap')
previousSnapRef.current = [nextX, nextZ] previousSnapRef.current = [position[0], position[2]]
} }
} }
const onGridClick = (event: GridEvent) => { 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() const [existingSpawnId, ...duplicates] = getExistingSpawnIds()
let placedId: SpawnNode['id'] let placedId: SpawnNode['id']
@@ -121,6 +118,8 @@ const SpawnTool = () => {
useViewer.getState().setSelection({ selectedIds: [placedId] }) useViewer.getState().setSelection({ selectedIds: [placedId] })
triggerSFX('sfx:structure-build') triggerSFX('sfx:structure-build')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
useAlignmentGuides.getState().clear()
useEditor.getState().setTool(null) useEditor.getState().setTool(null)
useEditor.getState().setMode('select') useEditor.getState().setMode('select')
} }
@@ -131,8 +130,9 @@ const SpawnTool = () => {
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
useAlignmentGuides.getState().clear()
} }
}, [activeLevelId]) }, [activeLevelId, previewNode])
if (!activeLevelId) return null if (!activeLevelId) return null