Files
editor/packages/nodes/src/shelf/tool.tsx
T
Aymeric RabotandGitHub aab48e053f fix(editor): harden editor interactions and WebGPU rendering
Fix editor bug sweep regressions, WebGPU CSG/material crashes, Shift snap bypass behavior, arrow handle drag projection, and the wall preview null guard covered by the Sentry follow-up PRs.
2026-06-11 13:03:34 -04:00

162 lines
5.6 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 {
collectAlignmentAnchors,
emitter,
type GridEvent,
ShelfNode,
useScene,
} from '@pascal-app/core'
import {
getFloorStackPreviewPosition,
triggerSFX,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Group } from 'three'
import {
type FloorPlacementClickTriggerEvent,
getLevelLocalSnappedPosition,
resolveAlignedFloorPlacement,
stopPlacementCommitPropagation,
subscribeFloorPlacementClicks,
} from '../shared/floor-placement'
import { shelfDefinition } from './definition'
import ShelfPreview from './preview'
const ShelfTool = () => {
const activeLevelId = useViewer((state) => state.selection.levelId)
const cursorRef = useRef<Group>(null)
const previousSnapRef = useRef<[number, number] | null>(null)
const cursorVisibleRef = useRef(false)
const [cursorVisible, setCursorVisible] = useState(false)
// 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
cursorVisibleRef.current = false
setCursorVisible(false)
/**
* 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) => {
if (!cursorVisibleRef.current) {
cursorVisibleRef.current = true
setCursorVisible(true)
}
const { position, guides } = resolveAlignedFloorPlacement({
node: previewNode,
rawX: event.localPosition[0],
rawZ: event.localPosition[2],
gridStep: useEditor.getState().gridSnapStep,
candidates: alignmentCandidates,
bypassAlignment: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassGrid: event.nativeEvent?.shiftKey === true,
})
useAlignmentGuides.getState().set(guides)
const visualPosition = getFloorStackPreviewPosition({
node: previewNode,
position,
rotation: previewNode.rotation,
levelId: activeLevelId,
})
cursorRef.current?.position.set(...visualPosition)
lastCursorRef.current = position
const prev = previousSnapRef.current
if (
event.nativeEvent?.shiftKey !== true &&
(!prev || prev[0] !== position[0] || prev[1] !== position[2])
) {
triggerSFX('sfx:grid-snap')
previousSnapRef.current = [position[0], position[2]]
}
}
const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => {
// 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 ??
getLevelLocalSnappedPosition(
activeLevelId,
event,
useEditor.getState().gridSnapStep,
event.nativeEvent?.shiftKey === true,
)
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:item-place')
// 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()
stopPlacementCommitPropagation(event)
}
emitter.on('grid:move', onGridMove)
const unsubscribePlacementClicks = subscribeFloorPlacementClicks(commitAtCursor)
return () => {
emitter.off('grid:move', onGridMove)
unsubscribePlacementClicks()
// 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} visible={cursorVisible}>
<ShelfPreview node={previewNode} />
</group>
)
}
export default ShelfTool