Wassim SAMAD 76794ceb7d 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>
2026-05-14 14:55:03 -04:00

87 lines
3.2 KiB
TypeScript

'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'
/**
* Registry-driven shelf renderer. Renders top board + brackets as inline R3F
* primitives so React owns the scene graph end-to-end — no imperative
* children swap.
*
* The pure `buildShelfGeometry` function in `./geometry.ts` produces the same
* 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)
const color = useMemo(() => new Color(node.color), [node.color])
const topY = node.height + node.thickness / 2
// 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)
const bracketWidth =
node.bracketStyle === 'industrial'
? Math.max(0.04, node.depth * 0.2)
: Math.max(0.02, node.depth * 0.12)
const bracketDepth = node.bracketStyle === 'industrial' ? node.depth * 0.95 : node.depth * 0.7
useEffect(() => {
// biome-ignore lint/suspicious/noConsole: dev-only verification log
console.info('[shelf] rendered', node.id, 'at', node.position)
}, [node.id, node.position])
return (
<group
position={liveTransform?.position ?? node.position}
ref={ref}
rotation={liveTransform?.rotation ? [0, liveTransform.rotation, 0] : node.rotation}
visible={node.visible}
>
{/* Top board */}
<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>
{/* Brackets (skipped for 'hidden' style) */}
{node.bracketStyle !== 'hidden' && (
<>
<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} />
</mesh>
<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} />
</mesh>
</>
)}
</group>
)
}
export default ShelfRenderer