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 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
} 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user