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>
87 lines
3.2 KiB
TypeScript
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
|