Files
editor/packages/nodes/src/shelf/tool.tsx
T
Wassim SAMADandClaude Opus 4.7 166e860bfe Move: pure imperative via sceneRegistry (smooth, zero re-renders); drop dev logs
User feedback: updating the store per tick caused tons of React
re-renders → laggy drag. Switched to pure imperative.

MoveRegistryNodeTool now:
- Mutates `sceneRegistry.nodes.get(id).position` directly per
  grid:move tick. No useScene.updateNode during drag. No store
  change → no renderer re-render → R3F doesn't reapply
  `position={node.position}` → the imperative mutation sticks.
- On commit: single tracked `useScene.updateNode(id, { position })`.
  Undo replays one step (original → final), no per-tick spam.
- On cancel / unmount: imperatively snap the mesh back to original.
  Store was never touched so no data revert needed.

Trade-off vs the items pattern (which does update the store per tick
and re-renders per tick): our approach is faster but assumes the
renderer doesn't re-render mid-drag. Items get away with constant
re-renders because their renderer is heavily optimized; for parametric
shelves (and future kinds) the imperative path is simpler and faster.

Cleanup: removed the dev `[shelf] rendered` and `[shelf] placed`
console.info logs from the shelf renderer and tool. They were Phase 2
verification scaffolding — no longer needed now that everything works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:19:37 -04:00

102 lines
3.2 KiB
TypeScript

'use client'
import {
emitter,
type GridEvent,
ShelfNode,
sceneRegistry,
snapPointToGrid,
useScene,
} from '@pascal-app/core'
import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import { type Group, Vector3 } from 'three'
import ShelfPreview from './preview'
const worldVector = new Vector3()
const GRID_STEP = 0.5
/**
* Convert a click into the shelf's commit position (level-local). The shelf
* node's `position` field is stored relative to its level parent, so we
* project the click point into the level's local frame before storing.
*
* Cursor display uses event.localPosition (building-local) — see onGridMove.
*/
function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] {
const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) {
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP)
return [sx, event.localPosition[1], 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, worldVector.y, 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. Same shape the move tool
// uses (both reach for `shelfDefinition.preview`) so placement and move
// look identical.
const previewNode = useMemo(
() => ShelfNode.parse({ name: 'Shelf', position: [0, 0, 0], rotation: [0, 0, 0] }),
[],
)
useEffect(() => {
if (!activeLevelId) return
previousSnapRef.current = null
const onGridMove = (event: GridEvent) => {
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP)
cursorRef.current?.position.set(sx, event.localPosition[1], sz)
const prev = previousSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) {
triggerSFX('sfx:grid-snap')
previousSnapRef.current = [sx, sz]
}
}
const onGridClick = (event: GridEvent) => {
const position = getLevelLocalPosition(activeLevelId, event)
const shelf = ShelfNode.parse({
name: 'Shelf',
position,
rotation: [0, 0, 0],
})
useScene.getState().createNode(shelf, activeLevelId)
useViewer.getState().setSelection({ selectedIds: [shelf.id] })
triggerSFX('sfx:structure-build')
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
}
}, [activeLevelId])
if (!activeLevelId) return null
// Cursor preview: defers to the shared ShelfPreview component used by the
// move tool too. Position is updated imperatively via the ref; no React
// state, no re-render cycles.
return (
<group ref={cursorRef}>
<ShelfPreview node={previewNode} />
</group>
)
}
export default ShelfTool