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