Wire shelf + spawn placement polish: SFX, cursor, sidebar, selection
User-visible follow-ups after first running the Phase 2 spike.
Spawn tool now matches legacy UX:
- CursorSphere from @pascal-app/editor for the placement indicator
(ring + line + tool-icon tooltip) — was a plain sphere mesh.
- Emits sfx:structure-build on commit + setTool(null) + setMode
('select') to exit build mode, matching legacy spawn-tool.
Shelf tool placement:
- Emits sfx:structure-build on commit.
- Cursor preview now shows top board + brackets (was just the top),
matching what gets placed.
Shelf selectable from the 3D canvas:
- ShelfEvent type added to @pascal-app/core/events/bus.
- 'shelf' added to NodeConfig in useNodeEvents.
- ShelfRenderer wires `useNodeEvents(node, 'shelf')` handlers onto
every mesh. Clicks/hovers now bubble through the editor's selection
manager and update useViewer.selection.
Shelf appears in the sidebar:
- ShelfTreeNode component (mirrors spawn-tree-node's shape +
selection/hover/rename wiring; lucide Layers icon).
- TreeNode dispatcher adds a `case 'shelf':` arm.
Framework changes:
- @pascal-app/editor exports CursorSphere alongside triggerSFX.
- @pascal-app/nodes now declares @pascal-app/editor as peer/dev dep.
Pre-existing typecheck errors in @pascal-app/editor (ceiling-tree-node,
fence-tree-node, slab-tree-node, scene.ts) are unchanged — present on
main and not introduced by this commit.
630 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a89a1efccf
commit
76794ceb7d
@@ -1,15 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useLiveTransforms, useRegistry } from '@pascal-app/core'
|
||||
import { useNodeEvents } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { Color, type Group } from 'three'
|
||||
import type { ShelfNode } from './schema'
|
||||
|
||||
// Note: useNodeEvents from @pascal-app/viewer has a hardcoded kind list and
|
||||
// doesn't yet know about 'shelf'. Phase 4 generalizes it via the registry —
|
||||
// until then, shelf selection works via R3F's default raycast (clicks bubble
|
||||
// through; the editor's selection manager hit-tests the registered Object3D).
|
||||
|
||||
/**
|
||||
* Registry-driven shelf renderer. Renders top board + brackets as inline R3F
|
||||
* primitives so React owns the scene graph end-to-end — no imperative
|
||||
@@ -19,9 +15,14 @@ import type { ShelfNode } from './schema'
|
||||
* shape outside of React (used by tests + reachable by AI-authored consumers
|
||||
* that want a Three.js Group). Keeping both costs nothing because the shape
|
||||
* primitives are tiny.
|
||||
*
|
||||
* `useNodeEvents(node, 'shelf')` wires pointer events on each mesh into the
|
||||
* editor's emitter — the selection manager subscribes to `shelf:click` etc.
|
||||
* and updates `useViewer.selection`. Required for selection from the canvas.
|
||||
*/
|
||||
const ShelfRenderer = ({ node }: { node: ShelfNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
const handlers = useNodeEvents(node, 'shelf')
|
||||
const liveTransform = useLiveTransforms((state) => state.get(node.id))
|
||||
|
||||
useRegistry(node.id, 'shelf', ref)
|
||||
@@ -29,7 +30,7 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => {
|
||||
const color = useMemo(() => new Color(node.color), [node.color])
|
||||
const topY = node.height + node.thickness / 2
|
||||
|
||||
// Bracket dimensions mirror buildShelfGeometry — keep these in sync if the
|
||||
// Bracket dimensions mirror buildShelfGeometry — keep in sync if the
|
||||
// geometry function evolves. Phase 4 may consolidate.
|
||||
const inset = Math.min(0.12, node.width / 6)
|
||||
const bracketHeight = Math.max(0.01, node.height)
|
||||
@@ -52,7 +53,7 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => {
|
||||
visible={node.visible}
|
||||
>
|
||||
{/* Top board */}
|
||||
<mesh position={[0, topY, 0]} name="shelf-top">
|
||||
<mesh position={[0, topY, 0]} name="shelf-top" {...handlers}>
|
||||
<boxGeometry args={[node.width, node.thickness, node.depth]} />
|
||||
<meshStandardMaterial color={color} roughness={0.65} metalness={0.05} />
|
||||
</mesh>
|
||||
@@ -63,6 +64,7 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => {
|
||||
<mesh
|
||||
position={[-(node.width / 2 - inset), bracketHeight / 2, 0]}
|
||||
name="shelf-bracket-left"
|
||||
{...handlers}
|
||||
>
|
||||
<boxGeometry args={[bracketWidth, bracketHeight, bracketDepth]} />
|
||||
<meshStandardMaterial color={color} roughness={0.65} metalness={0.05} />
|
||||
@@ -70,6 +72,7 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => {
|
||||
<mesh
|
||||
position={[node.width / 2 - inset, bracketHeight / 2, 0]}
|
||||
name="shelf-bracket-right"
|
||||
{...handlers}
|
||||
>
|
||||
<boxGeometry args={[bracketWidth, bracketHeight, bracketDepth]} />
|
||||
<meshStandardMaterial color={color} roughness={0.65} metalness={0.05} />
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
snapPointToGrid,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { type Group, Vector3 } from 'three'
|
||||
@@ -16,13 +17,11 @@ const worldVector = new Vector3()
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
/**
|
||||
* Convert a click event 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.
|
||||
* 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.
|
||||
*
|
||||
* Different from the cursor preview path: the cursor lives inside the
|
||||
* ToolManager's building-local group and snaps to `event.localPosition`
|
||||
* directly. This conversion only applies to the *committed* data.
|
||||
* Cursor display uses event.localPosition (building-local) — see onGridMove.
|
||||
*/
|
||||
function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] {
|
||||
const levelObject = sceneRegistry.nodes.get(levelId)
|
||||
@@ -37,6 +36,17 @@ function getLevelLocalPosition(levelId: string, event: GridEvent): [number, numb
|
||||
return [sx, worldVector.y, sz]
|
||||
}
|
||||
|
||||
// Cursor preview dimensions — match the shelf's default schema dimensions.
|
||||
// Once the shelf has user-tunable defaults in the inspector, this can pull
|
||||
// from the active draft.
|
||||
const PREVIEW_WIDTH = 1.2
|
||||
const PREVIEW_DEPTH = 0.3
|
||||
const PREVIEW_THICKNESS = 0.04
|
||||
const PREVIEW_HEIGHT = 0.9
|
||||
const PREVIEW_INSET = Math.min(0.12, PREVIEW_WIDTH / 6)
|
||||
const PREVIEW_BRACKET_WIDTH = Math.max(0.02, PREVIEW_DEPTH * 0.12)
|
||||
const PREVIEW_BRACKET_DEPTH = PREVIEW_DEPTH * 0.7
|
||||
|
||||
const ShelfTool = () => {
|
||||
const activeLevelId = useViewer((state) => state.selection.levelId)
|
||||
const cursorRef = useRef<Group>(null)
|
||||
@@ -45,10 +55,6 @@ const ShelfTool = () => {
|
||||
if (!activeLevelId) return
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
// Cursor lives in the ToolManager's building-local group. Use
|
||||
// `event.localPosition` (already building-local) so the visual cursor
|
||||
// sits where the mouse hits the floor. Legacy spawn-tool does the
|
||||
// same — don't apply worldToLocal here.
|
||||
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP)
|
||||
cursorRef.current?.position.set(sx, event.localPosition[1], sz)
|
||||
}
|
||||
@@ -62,6 +68,7 @@ const ShelfTool = () => {
|
||||
})
|
||||
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)
|
||||
}
|
||||
@@ -77,10 +84,24 @@ const ShelfTool = () => {
|
||||
|
||||
if (!activeLevelId) return null
|
||||
|
||||
// Cursor preview: ghostly version of the full shelf (top board + brackets)
|
||||
// so the user sees the same shape they're placing. Position is updated
|
||||
// imperatively via the ref; no React state, no re-render cycles.
|
||||
return (
|
||||
<group ref={cursorRef}>
|
||||
<mesh position={[0, 0.9, 0]}>
|
||||
<boxGeometry args={[1.2, 0.04, 0.3]} />
|
||||
{/* Top board */}
|
||||
<mesh position={[0, PREVIEW_HEIGHT + PREVIEW_THICKNESS / 2, 0]}>
|
||||
<boxGeometry args={[PREVIEW_WIDTH, PREVIEW_THICKNESS, PREVIEW_DEPTH]} />
|
||||
<meshStandardMaterial color="#a07050" transparent opacity={0.5} />
|
||||
</mesh>
|
||||
{/* Left bracket */}
|
||||
<mesh position={[-(PREVIEW_WIDTH / 2 - PREVIEW_INSET), PREVIEW_HEIGHT / 2, 0]}>
|
||||
<boxGeometry args={[PREVIEW_BRACKET_WIDTH, PREVIEW_HEIGHT, PREVIEW_BRACKET_DEPTH]} />
|
||||
<meshStandardMaterial color="#a07050" transparent opacity={0.5} />
|
||||
</mesh>
|
||||
{/* Right bracket */}
|
||||
<mesh position={[PREVIEW_WIDTH / 2 - PREVIEW_INSET, PREVIEW_HEIGHT / 2, 0]}>
|
||||
<boxGeometry args={[PREVIEW_BRACKET_WIDTH, PREVIEW_HEIGHT, PREVIEW_BRACKET_DEPTH]} />
|
||||
<meshStandardMaterial color="#a07050" transparent opacity={0.5} />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
Reference in New Issue
Block a user