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
@@ -1,8 +1,10 @@
|
||||
import { nodeRegistry } from '../../registry'
|
||||
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
||||
import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { isCurvedWall, sampleWallCenterline } from '../../systems/wall/wall-curve'
|
||||
import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint'
|
||||
import { getFloorPlacedFootprints } from './floor-placed-elevation'
|
||||
import { SpatialGrid } from './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 = {
|
||||
min: [number, number, number]
|
||||
max: [number, number, number]
|
||||
@@ -647,34 +672,38 @@ export class SpatialGridManager {
|
||||
) {
|
||||
const nodes = useScene.getState().nodes
|
||||
const ignoreSet = new Set(ignoreIds ?? [])
|
||||
const [width, , depth] = dimensions
|
||||
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,
|
||||
}
|
||||
const draftBounds = footprintBoundsXZ(position, dimensions, rotation[1])
|
||||
|
||||
// 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[] = []
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type !== 'item') continue
|
||||
const item = node as ItemNode
|
||||
if (item.asset.attachTo) continue
|
||||
if (isLowProfileItemSurface(item)) continue
|
||||
if (ignoreSet.has(item.id)) continue
|
||||
if (resolveNodeLevelId(item, nodes) !== levelId) continue
|
||||
if (ignoreSet.has(node.id)) continue
|
||||
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
|
||||
if (!floorPlaced?.collides) continue
|
||||
if (floorPlaced.applies && !floorPlaced.applies(node)) continue
|
||||
// Low-profile item surfaces (rugs, mats) are stack-on targets, not
|
||||
// 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)
|
||||
if (
|
||||
intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) &&
|
||||
intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ)
|
||||
) {
|
||||
conflicts.push(item.id)
|
||||
for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) {
|
||||
const fpRotation = Array.isArray(footprint.rotation) ? (footprint.rotation[1] ?? 0) : 0
|
||||
const bounds = footprintBoundsXZ(
|
||||
footprint.position ?? (node as { position: [number, number, number] }).position,
|
||||
footprint.dimensions,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1575,6 +1575,15 @@ export type FloorPlacedConfig = {
|
||||
footprint?: FloorPlacedFootprintResolver
|
||||
footprints?: FloorPlacedFootprintsResolver
|
||||
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 { 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -225,6 +225,7 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
|
||||
return { dimensions: getScaledDimensions(item), rotation: item.rotation }
|
||||
},
|
||||
applies: (node) => !(node as ItemNodeType).asset.attachTo,
|
||||
collides: true,
|
||||
},
|
||||
// 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
|
||||
|
||||
@@ -132,6 +132,7 @@ function shelfHandles(_node: ShelfNodeType): HandleDescriptor<ShelfNodeType>[] {
|
||||
|
||||
export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
||||
kind: 'shelf',
|
||||
snapProfile: 'item',
|
||||
schemaVersion: 2,
|
||||
schema: ShelfNode,
|
||||
category: 'furnish',
|
||||
@@ -197,6 +198,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
||||
rotation: shelf.rotation,
|
||||
}
|
||||
},
|
||||
collides: true,
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
getFloorStackPreviewPosition,
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
@@ -83,8 +85,8 @@ const ShelfTool = () => {
|
||||
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)
|
||||
|
||||
@@ -98,10 +100,7 @@ const ShelfTool = () => {
|
||||
lastCursorRef.current = 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]]
|
||||
}
|
||||
@@ -118,7 +117,7 @@ const ShelfTool = () => {
|
||||
activeLevelId,
|
||||
event,
|
||||
useEditor.getState().gridSnapStep,
|
||||
event.nativeEvent?.shiftKey === true,
|
||||
!isGridSnapActive(),
|
||||
)
|
||||
const shelf = ShelfNode.parse({
|
||||
...shelfDefinition.defaults(),
|
||||
|
||||
@@ -47,6 +47,7 @@ function spawnMoveHandle(): HandleDescriptor<SpawnNodeType> {
|
||||
|
||||
export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
|
||||
kind: 'spawn',
|
||||
snapProfile: 'item',
|
||||
schemaVersion: 1,
|
||||
schema: SpawnNode,
|
||||
category: 'site',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user