diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 49d8a354..40ded051 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -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 + } } } diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index a1a77b87..8100cb74 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -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 } /** diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index cd426217..780a29d9 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -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 }) => { diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index e4e0eeb6..141624d5 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -220,18 +220,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // commit / cancel / unmount so a follow-on drag starts clean. const overriddenIdsRef = useRef([]) - // 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) diff --git a/packages/editor/src/lib/contextual-help.ts b/packages/editor/src/lib/contextual-help.ts index 8a741198..0e9023ae 100644 --- a/packages/editor/src/lib/contextual-help.ts +++ b/packages/editor/src/lib/contextual-help.ts @@ -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. diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index b2ba870a..c6c8fd78 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -298,20 +298,23 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor[] /** * 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 = { kind: 'column', + snapProfile: 'item', schemaVersion: 1, schema: ColumnNode, category: 'structure', @@ -327,19 +330,29 @@ export const columnDefinition: NodeDefinition = { 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 ``. + // Slab elevation lift via the generic `` + 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 = { 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 diff --git a/packages/nodes/src/column/move-tool.tsx b/packages/nodes/src/column/move-tool.tsx deleted file mode 100644 index 4df41bd3..00000000 --- a/packages/nodes/src/column/move-tool.tsx +++ /dev/null @@ -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 ``. - */ -/** 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(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 ( - <> - - - - ) -} - -export default MoveColumnTool diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index c8e1bc7b..630e0e20 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -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) diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index 0f6f41eb..fae9bb17 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -225,6 +225,7 @@ export const itemDefinition: NodeDefinition = { 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 diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index 6513ee8e..a4725f74 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -132,6 +132,7 @@ function shelfHandles(_node: ShelfNodeType): HandleDescriptor[] { export const shelfDefinition: NodeDefinition = { kind: 'shelf', + snapProfile: 'item', schemaVersion: 2, schema: ShelfNode, category: 'furnish', @@ -197,6 +198,7 @@ export const shelfDefinition: NodeDefinition = { rotation: shelf.rotation, } }, + collides: true, }, }, diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index 585de70f..ea235c79 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -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(), diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts index 482d32eb..08aedf69 100644 --- a/packages/nodes/src/spawn/definition.ts +++ b/packages/nodes/src/spawn/definition.ts @@ -47,6 +47,7 @@ function spawnMoveHandle(): HandleDescriptor { export const spawnDefinition: NodeDefinition = { kind: 'spawn', + snapProfile: 'item', schemaVersion: 1, schema: SpawnNode, category: 'site', diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx index 54e6ce75..04f907af 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -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(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