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', kind: 'parametric',
module: () => import('./renderer'), module: () => import('./renderer'),
}, },
system: {
module: () => import('./system'),
priority: 5,
},
preview: () => import('./preview'), preview: () => import('./preview'),
tool: () => import('./tool'), tool: () => import('./tool'),
+17 -54
View File
@@ -1,24 +1,20 @@
'use client' 'use client'
import { useLiveTransforms, useRegistry } from '@pascal-app/core' import { useLiveTransforms, useRegistry, useScene } from '@pascal-app/core'
import { useNodeEvents } from '@pascal-app/viewer' import { useNodeEvents } from '@pascal-app/viewer'
import { useMemo, useRef } from 'react' import { useLayoutEffect, useRef } from 'react'
import { Color, type Group } from 'three' import type { Group } from 'three'
import type { ShelfNode } from './schema' import type { ShelfNode } from './schema'
/** /**
* Registry-driven shelf renderer. Renders top board + brackets as inline R3F * Thin shelf renderer. Mounts an empty `<group>`, registers it with
* primitives so React owns the scene graph end-to-end — no imperative * `sceneRegistry`, and marks the node dirty so `ShelfSystem` populates it
* children swap. * with geometry on the next frame.
* *
* The pure `buildShelfGeometry` function in `./geometry.ts` produces the same * Mirrors the door/item pattern (see `wiki/architecture/renderers.md`):
* shape outside of React (used by tests + reachable by AI-authored consumers * "Renderers must not run geometry generation logic (that belongs in a
* that want a Three.js Group). Keeping both costs nothing because the shape * System)." Keeping the renderer tiny means parametric edits don't re-run
* primitives are tiny. * any React work — only the system's `useFrame` rebuilds the meshes.
*
* `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 ShelfRenderer = ({ node }: { node: ShelfNode }) => {
const ref = useRef<Group>(null!) const ref = useRef<Group>(null!)
@@ -27,18 +23,12 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => {
useRegistry(node.id, 'shelf', ref) useRegistry(node.id, 'shelf', ref)
const color = useMemo(() => new Color(node.color), [node.color]) // Mark dirty on mount and whenever the node identity changes so the system
const topY = node.height + node.thickness / 2 // builds (or rebuilds) geometry. Subsequent parametric edits set dirty via
// the store's updateNode → dirtyNodes wiring.
// Bracket dimensions mirror buildShelfGeometry — keep in sync if the useLayoutEffect(() => {
// geometry function evolves. Phase 4 may consolidate. useScene.getState().markDirty(node.id)
const inset = Math.min(0.12, node.width / 6) }, [node.id])
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
return ( return (
<group <group
@@ -46,35 +36,8 @@ const ShelfRenderer = ({ node }: { node: ShelfNode }) => {
ref={ref} ref={ref}
rotation={liveTransform?.rotation ? [0, liveTransform.rotation, 0] : node.rotation} rotation={liveTransform?.rotation ? [0, liveTransform.rotation, 0] : node.rotation}
visible={node.visible} 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} {...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>
) )
} }
+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