- Live slab-stacking Y previews for all floor-placed kinds (item/shelf/spawn/column/stair) during placement + both move pathways, via a shared core resolver; canonical positions unchanged. - Unified 3D handle system (one drag pipeline + one visual primitive) with forgiving invisible hit-areas on every handle, kept on EDITOR_LAYER so they don't poison the MRT scene pass. - Hover + click-to-edit slab holes in 3D (manual hole -> hole editor; stair/elevator hole -> select owner); generic cross-arrow polygon-move grip; normalized handle interaction colors. - NaN-safe node mutations + non-finite shadow-light bounds guard. - Built on #373 (level-scoped alignment / registry slab tool); #373 owns X/Z alignment, this owns Y floor-stacking.
233 lines
8.3 KiB
TypeScript
233 lines
8.3 KiB
TypeScript
'use client'
|
||
|
||
import {
|
||
type AnyNode,
|
||
collectAlignmentAnchors,
|
||
type EventSuffix,
|
||
emitter,
|
||
type GridEvent,
|
||
movingFootprintAnchors,
|
||
type NodeEvent,
|
||
resolveAlignment,
|
||
ShelfNode,
|
||
sceneRegistry,
|
||
snapPointToGrid,
|
||
useAlignmentGuides,
|
||
useScene,
|
||
} from '@pascal-app/core'
|
||
import { getFloorStackPreviewPosition, triggerSFX } from '@pascal-app/editor'
|
||
import { useViewer } from '@pascal-app/viewer'
|
||
import { useEffect, useMemo, useRef } from 'react'
|
||
import { type Group, Vector3 } from 'three'
|
||
import { shelfDefinition } from './definition'
|
||
import ShelfPreview from './preview'
|
||
|
||
const worldVector = new Vector3()
|
||
const GRID_STEP = 0.5
|
||
|
||
/** Figma-style alignment-snap threshold (meters), matching the move tools and
|
||
* the 2D floor-plan overlay. 8 cm gives a magnetic pull layered on top of the
|
||
* grid snap without fighting it. */
|
||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||
|
||
/**
|
||
* Click-trigger kinds: when the user clicks ANY of these during shelf
|
||
* placement, we commit at the latest cursor position. R3F's pointer
|
||
* raycaster dispatches to the closest intersected mesh, so a click on
|
||
* a wall / slab / item / etc. would otherwise never reach `grid:click`
|
||
* — the placement would silently drop. Listening for each kind's click
|
||
* (and committing at the snapshot of the last `grid:move` cursor)
|
||
* mirrors the fix in `MoveRegistryNodeTool`.
|
||
*/
|
||
const CLICK_TRIGGER_KINDS = [
|
||
'shelf',
|
||
'item',
|
||
'slab',
|
||
'ceiling',
|
||
'wall',
|
||
'fence',
|
||
'column',
|
||
'roof',
|
||
'roof-segment',
|
||
'stair',
|
||
'stair-segment',
|
||
] as const
|
||
|
||
type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
|
||
|
||
/**
|
||
* Convert the latest cursor world hit into level-local coords for the
|
||
* commit `position`. The cursor's local position from `event.localPosition`
|
||
* (building-local) needs to come back through the level's world transform
|
||
* so the shelf is stored in its parent's frame.
|
||
*/
|
||
function getLevelLocalPosition(
|
||
levelId: string,
|
||
event: GridEvent | NodeEvent<AnyNode>,
|
||
): [number, number, number] {
|
||
const levelObject = sceneRegistry.nodes.get(levelId)
|
||
if (!levelObject) {
|
||
const local = (event as GridEvent).localPosition
|
||
if (local) {
|
||
const [sx, sz] = snapPointToGrid([local[0], local[2]], GRID_STEP)
|
||
return [sx, 0, sz]
|
||
}
|
||
const [sx, sz] = snapPointToGrid([event.position[0], event.position[2]], GRID_STEP)
|
||
return [sx, 0, sz]
|
||
}
|
||
worldVector.set(event.position[0], event.position[1], event.position[2])
|
||
levelObject.updateWorldMatrix(true, false)
|
||
levelObject.worldToLocal(worldVector)
|
||
const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], GRID_STEP)
|
||
return [sx, 0, sz]
|
||
}
|
||
|
||
const ShelfTool = () => {
|
||
const activeLevelId = useViewer((state) => state.selection.levelId)
|
||
const cursorRef = useRef<Group>(null)
|
||
const previousSnapRef = useRef<[number, number] | null>(null)
|
||
|
||
// Default-shaped shelf for the placement preview. Pulls from
|
||
// `shelfDefinition.defaults()` so the preview matches what the commit
|
||
// will actually create (a 1m × 0.5m × 1.8m cubby 3x2 with closed back
|
||
// + bottom). The schema-level defaults are deliberately the v1
|
||
// wall-shelf — those exist so v1 scenes loading under v2 keep their
|
||
// original visual; the placement default is a separate, user-facing
|
||
// choice that lives on the definition.
|
||
const previewNode = useMemo(
|
||
() =>
|
||
ShelfNode.parse({
|
||
...shelfDefinition.defaults(),
|
||
name: 'Shelf',
|
||
position: [0, 0, 0],
|
||
rotation: [0, 0, 0],
|
||
}),
|
||
[],
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (!activeLevelId) return
|
||
previousSnapRef.current = null
|
||
/**
|
||
* Snapped cursor position from the latest `grid:move`. Used as the
|
||
* commit position for ANY click variant (grid or node), so clicks
|
||
* on vertical surfaces (other shelves, walls, etc.) still commit
|
||
* where the user was visually targeting.
|
||
*/
|
||
const lastCursorRef: { current: [number, number, number] | null } = { current: null }
|
||
|
||
// Alignment candidates — anchors of every OTHER alignable object (items,
|
||
// walls, fences, slabs, ceilings, columns, other shelves). Gathered once
|
||
// here and refreshed after each placement so a just-placed shelf becomes a
|
||
// target for the next one. `previewNode.id` never collides with a scene
|
||
// node, so nothing real is excluded.
|
||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
|
||
|
||
const onGridMove = (event: GridEvent) => {
|
||
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP)
|
||
|
||
// Figma-style alignment snap layered on top of grid snap: when the
|
||
// preview shelf's footprint edge lines up (on X or Z) with another
|
||
// object's edge, snap there and publish a guide. The probe uses the
|
||
// shelf's footprint corners at the proposed grid position so it aligns
|
||
// by its edges, not its centre — matching `MoveRegistryNodeTool`. Alt
|
||
// bypasses.
|
||
let ax = sx
|
||
let az = sz
|
||
const bypass = event.nativeEvent?.altKey === true
|
||
if (!bypass && alignmentCandidates.length > 0) {
|
||
const result = resolveAlignment({
|
||
moving: movingFootprintAnchors(previewNode, sx, sz, 0),
|
||
candidates: alignmentCandidates,
|
||
threshold: ALIGNMENT_THRESHOLD_M,
|
||
})
|
||
if (result.snap) {
|
||
ax += result.snap.dx
|
||
az += result.snap.dz
|
||
}
|
||
useAlignmentGuides.getState().set(result.guides)
|
||
} else {
|
||
useAlignmentGuides.getState().clear()
|
||
}
|
||
|
||
const position: [number, number, number] = [ax, 0, az]
|
||
const visualPosition = getFloorStackPreviewPosition({
|
||
node: previewNode,
|
||
position,
|
||
rotation: previewNode.rotation,
|
||
levelId: activeLevelId,
|
||
})
|
||
cursorRef.current?.position.set(...visualPosition)
|
||
lastCursorRef.current = position
|
||
|
||
const prev = previousSnapRef.current
|
||
if (!prev || prev[0] !== ax || prev[1] !== az) {
|
||
triggerSFX('sfx:grid-snap')
|
||
previousSnapRef.current = [ax, az]
|
||
}
|
||
}
|
||
|
||
const commitAtCursor = (event: ClickTriggerEvent) => {
|
||
// Prefer the latest `grid:move` cursor snapshot; fall back to
|
||
// projecting the click event into level-local coords if no
|
||
// grid:move has fired yet (e.g. cursor entered via a node hit
|
||
// first). Both paths apply the same grid snap.
|
||
const position = lastCursorRef.current ?? getLevelLocalPosition(activeLevelId, event)
|
||
const shelf = ShelfNode.parse({
|
||
...shelfDefinition.defaults(),
|
||
name: 'Shelf',
|
||
position,
|
||
rotation: [0, 0, 0],
|
||
})
|
||
useScene.getState().createNode(shelf, activeLevelId)
|
||
useViewer.getState().setSelection({ selectedIds: [shelf.id] })
|
||
triggerSFX('sfx:structure-build')
|
||
// The placed shelf is now a valid alignment target for the next one;
|
||
// refresh the candidate pool and drop the guide from this drop.
|
||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
|
||
useAlignmentGuides.getState().clear()
|
||
|
||
const native = (event as { nativeEvent?: unknown }).nativeEvent
|
||
if (
|
||
native &&
|
||
typeof (native as { stopPropagation?: () => void }).stopPropagation === 'function'
|
||
) {
|
||
;(native as { stopPropagation: () => void }).stopPropagation()
|
||
}
|
||
const direct = (event as { stopPropagation?: () => void }).stopPropagation
|
||
if (typeof direct === 'function') direct.call(event)
|
||
}
|
||
|
||
emitter.on('grid:move', onGridMove)
|
||
emitter.on('grid:click', commitAtCursor)
|
||
type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
|
||
type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
|
||
for (const kind of CLICK_TRIGGER_KINDS) {
|
||
const key = `${kind}:click` as ClickKey
|
||
emitter.on(key, commitAtCursor as never)
|
||
}
|
||
|
||
return () => {
|
||
emitter.off('grid:move', onGridMove)
|
||
emitter.off('grid:click', commitAtCursor)
|
||
for (const kind of CLICK_TRIGGER_KINDS) {
|
||
const key = `${kind}:click` as ClickKey
|
||
emitter.off(key, commitAtCursor as never)
|
||
}
|
||
// Drop any alignment guide left over when the tool deactivates (kind
|
||
// switch, Esc, unmount) so it doesn't linger over the canvas.
|
||
useAlignmentGuides.getState().clear()
|
||
}
|
||
}, [activeLevelId, previewNode])
|
||
|
||
if (!activeLevelId) return null
|
||
|
||
return (
|
||
<group ref={cursorRef}>
|
||
<ShelfPreview node={previewNode} />
|
||
</group>
|
||
)
|
||
}
|
||
|
||
export default ShelfTool
|