User feedback: the move tool's CursorSphere is just a vertical line,
hard to tell what you're moving. Placement gets a translucent shape
that follows the cursor; move should too.
NodeDefinition.preview?: () => Promise<{ default: ComponentType<{ node }> }>
opt-in lazy component that renders a translucent ghost of the node.
Used by:
- The placement tool (ShelfTool) — renders the preview at the cursor
position so the user sees the shape they're placing.
- The move tool (MoveRegistryNodeTool) — renders the preview at the
drag target alongside the CursorSphere. Plus the original node is
also dragged via live transforms, so the user sees both: the actual
node moving + a translucent ghost at the same spot.
Implementation:
- New nodes/shelf/preview.tsx: ShelfPreview component. Renders the
same shape as ShelfRenderer but `transparent: true, opacity: 0.5`.
- shelfDefinition.preview = () => import('./preview').
- ShelfTool's placement preview now uses <ShelfPreview node={defaults} />
instead of an inline copy of the box geometry.
- MoveRegistryNodeTool lazy-loads `def.preview` (cached by loader,
Suspense-wrapped). If a kind doesn't define `preview`, only the
CursorSphere shows — matches today's behavior.
Phase 4 may merge `preview` with `renderer` behind an `opacity` prop
so kinds don't duplicate JSX between the solid and translucent
versions; until then defining `preview` is opt-in and one extra file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
104 lines
3.4 KiB
TypeScript
104 lines
3.4 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')
|
|
// biome-ignore lint/suspicious/noConsole: dev-only verification log
|
|
console.info('[shelf] placed', shelf.id, 'level-local', position, 'parent', activeLevelId)
|
|
}
|
|
|
|
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
|