Shelf: move geometry build into a system, slim renderer

Follows the renderer/system split documented in wiki/architecture/
renderers.md and systems.md: the renderer must not run geometry
generation. Mirrors the door-renderer/door-system pattern.

- New ShelfSystem reads dirtyNodes in useFrame, retrieves the shelf's
  registered Group from sceneRegistry, swaps its children with the
  output of buildShelfGeometry(node), then clears the dirty flag.
  Geometry rebuild is fully imperative — no React work involved.
- ShelfRenderer is now a thin empty <group> that registers with
  sceneRegistry, marks the node dirty on mount, and carries the
  pointer-event handlers + live transform overrides at the root.
- Wired system into shelfDefinition so RegisteredSystems mounts it
  alongside the renderer.

Net effect: dragging shelf parametric sliders no longer re-renders
the renderer per tick — the system rebuilds meshes at frame cadence
based on dirtyNodes, the inspector's per-field subscription only
re-renders the dragged field, and the rest of the React tree stays
quiet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 07:51:36 -04:00
co-authored by Claude Opus 4.7
parent f874cecf8f
commit 7f24593041
3 changed files with 84 additions and 55 deletions
+4
View File
@@ -46,6 +46,10 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 5,
},
preview: () => import('./preview'),
tool: () => import('./tool'),
+18 -55
View File
@@ -1,24 +1,20 @@
'use client'
import { useLiveTransforms, useRegistry } from '@pascal-app/core'
import { useLiveTransforms, useRegistry, useScene } from '@pascal-app/core'
import { useNodeEvents } from '@pascal-app/viewer'
import { useMemo, useRef } from 'react'
import { Color, type Group } from 'three'
import { useLayoutEffect, useRef } from 'react'
import 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.
* Thin shelf renderer. Mounts an empty `<group>`, registers it with
* `sceneRegistry`, and marks the node dirty so `ShelfSystem` populates it
* with geometry on the next frame.
*
* 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.
* Mirrors the door/item pattern (see `wiki/architecture/renderers.md`):
* "Renderers must not run geometry generation logic (that belongs in a
* System)." Keeping the renderer tiny means parametric edits don't re-run
* any React work — only the system's `useFrame` rebuilds the meshes.
*/
const ShelfRenderer = ({ node }: { node: ShelfNode }) => {
const ref = useRef<Group>(null!)
@@ -27,18 +23,12 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => {
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
// Mark dirty on mount and whenever the node identity changes so the system
// builds (or rebuilds) geometry. Subsequent parametric edits set dirty via
// the store's updateNode → dirtyNodes wiring.
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])
return (
<group
@@ -46,35 +36,8 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => {
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>
{...handlers}
/>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import type { Group } from 'three'
import { buildShelfGeometry } from './geometry'
import type { ShelfNode } from './schema'
/**
* Imperative shelf system. Mirrors the pattern used by door/wall/item systems
* (see `wiki/architecture/systems.md` and `renderers.md`): geometry generation
* lives here, the renderer is a thin mount point.
*
* On every frame, walks `dirtyNodes`, finds the registered group for each
* dirty shelf in `sceneRegistry`, swaps its children with the result of
* `buildShelfGeometry(node)`, then clears the dirty flag. No React re-render
* is involved in the rebuild, so parametric edits stay smooth even when the
* inspector emits an `updateNode` every pointermove.
*/
export const ShelfSystem = () => {
const dirtyNodes = useScene((s) => s.dirtyNodes)
const clearDirty = useScene((s) => s.clearDirty)
useFrame(() => {
if (dirtyNodes.size === 0) return
const nodes = useScene.getState().nodes
dirtyNodes.forEach((id) => {
const node = nodes[id]
if (!node || node.type !== 'shelf') return
const group = sceneRegistry.nodes.get(id) as Group | undefined
if (!group) return // mount hasn't run yet — keep dirty for next frame
// Clear previous geometry. Disposing materials/geometries here keeps
// long shelf-editing sessions from leaking GPU resources.
for (const child of [...group.children]) {
group.remove(child)
if ('geometry' in child && (child as { geometry?: { dispose: () => void } }).geometry) {
;(child as { geometry: { dispose: () => void } }).geometry.dispose()
}
if ('material' in child) {
const m = (child as { material: unknown }).material
if (Array.isArray(m)) {
for (const mat of m) (mat as { dispose: () => void }).dispose()
} else if (m && typeof (m as { dispose?: () => void }).dispose === 'function') {
;(m as { dispose: () => void }).dispose()
}
}
}
const built = buildShelfGeometry(node as ShelfNode)
for (const child of [...built.children]) {
group.add(child)
}
clearDirty(id as AnyNodeId)
})
}, 2)
return null
}
export default ShelfSystem