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,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