From 7f24593041def605bb3f8f2ef95c2a9b6c07d9ba Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 15 May 2026 07:51:36 -0400 Subject: [PATCH] Shelf: move geometry build into a system, slim renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- packages/nodes/src/shelf/definition.ts | 4 ++ packages/nodes/src/shelf/renderer.tsx | 73 +++++++------------------- packages/nodes/src/shelf/system.tsx | 62 ++++++++++++++++++++++ 3 files changed, 84 insertions(+), 55 deletions(-) create mode 100644 packages/nodes/src/shelf/system.tsx diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index 2cd79116..69aac305 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -46,6 +46,10 @@ export const shelfDefinition: NodeDefinition = { kind: 'parametric', module: () => import('./renderer'), }, + system: { + module: () => import('./system'), + priority: 5, + }, preview: () => import('./preview'), tool: () => import('./tool'), diff --git a/packages/nodes/src/shelf/renderer.tsx b/packages/nodes/src/shelf/renderer.tsx index cf7b3071..e0ebbd0c 100644 --- a/packages/nodes/src/shelf/renderer.tsx +++ b/packages/nodes/src/shelf/renderer.tsx @@ -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 ``, 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(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 ( { ref={ref} rotation={liveTransform?.rotation ? [0, liveTransform.rotation, 0] : node.rotation} visible={node.visible} - > - {/* Top board */} - - - - - - {/* Brackets (skipped for 'hidden' style) */} - {node.bracketStyle !== 'hidden' && ( - <> - - - - - - - - - - )} - + {...handlers} + /> ) } diff --git a/packages/nodes/src/shelf/system.tsx b/packages/nodes/src/shelf/system.tsx new file mode 100644 index 00000000..ee7e30e5 --- /dev/null +++ b/packages/nodes/src/shelf/system.tsx @@ -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