Files
editor/packages/nodes/src/shelf/tool.tsx
T
Aymeric RabotandGitHub 0b338cf647 feat(editor): live floor-stacking, unified handle system, slab-hole editing + interaction polish (#375)
- 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.
2026-06-05 16:24:48 -04:00

233 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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