Phase 4: generic GeometrySystem + ParametricNodeRenderer; shelf ports off renderer/system files

Lands the three-checkbox composition runtime documented in
wiki/architecture/node-definitions.md. A kind with only a pure
geometry function now needs zero per-kind React or system code.

Type-side additions (packages/core/src/registry/types.ts):
 - New `GeometryContext` (resolve / children / siblings / parent) — read-
   only scene access for builders that reference other nodes by ID
   (wall miters, door cutouts). Most kinds ignore it.
 - New `geometry?: (node, ctx) => Object3D` field on NodeDefinition,
   independent of renderer/system. Three orthogonal opt-ins replace the
   v0 RendererSource union.
 - Re-exported via packages/core/src/registry/index.ts (consumed by
   nodes packages through `export * from './registry'`).

Runtime (packages/viewer):
 - New <GeometrySystem> (systems/geometry/geometry-system.tsx) walks
   dirtyNodes, builds a GeometryContext per dirty node, calls
   def.geometry, disposes old children, attaches new ones, clearDirty.
   Frame priority 2 (matches the priority shelf's per-kind system had).
   Mounted in viewer/index.tsx alongside <RegisteredSystems>.
 - New <ParametricNodeRenderer> (components/renderers/parametric-node-
   renderer.tsx) — empty <group> + useRegistry + useNodeEvents +
   markDirty-on-mount + useLiveTransforms. Mounts hosted children via
   <NodeRenderer> recursively. The default renderer for any registered
   kind without a custom def.renderer.
 - <NodeRenderer> dispatch updated: custom renderer wins, else
   geometry-only kinds fall through to ParametricNodeRenderer, else
   null (legacy switch fallback). Documented inline.

Shelf migration (proof of the boilerplate collapse):
 - Deleted nodes/src/shelf/renderer.tsx (was 45 lines of registry +
   handler boilerplate).
 - Deleted nodes/src/shelf/system.tsx (was 60 lines of dirty-loop +
   dispose plumbing).
 - shelfDefinition now: `geometry: buildShelfGeometry`. One line.
   buildShelfGeometry is the pure function from geometry.ts that already
   existed.

End-to-end effect: registry-driven shelf now mounts via the framework's
generic renderer + system. Parametric edits flow through the same
dirty-driven rebuild path, but the kind ships ~100 fewer lines of
boilerplate. Every future kind that fits the same shape (item, fence
segment, column, etc. as they migrate in Phase 5) follows the same
"one line, one pure function" pattern.

Wall stays on its dedicated def.renderer + def.system — its mitering
needs level-batch context (`ctx.levelData?.miters`, future extension)
that the generic system doesn't yet provide. Decided at Phase 3+, not
blocking Phase 4 acceptance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 10:03:51 -04:00
co-authored by Claude Opus 4.7
parent 60117e848b
commit 3f3818f3b0
9 changed files with 304 additions and 126 deletions
@@ -0,0 +1,139 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type GeometryContext,
nodeRegistry,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import type { Group, Mesh } from 'three'
/**
* Generic geometry system.
*
* For every node in `dirtyNodes` whose definition exposes `def.geometry`,
* this system:
* 1. Looks up the registered `Group` from `sceneRegistry` (mounted by the
* framework's `<ParametricNodeRenderer>`, or a custom renderer that
* opts into the same mount contract).
* 2. Builds a `GeometryContext` from the current scene snapshot.
* 3. Calls `def.geometry(node, ctx)` to get the new `Object3D`.
* 4. Disposes the registered group's existing children + their geometries
* and materials.
* 5. Reparents the returned object's children onto the registered group.
* 6. Clears the dirty flag.
*
* This is the "no per-kind system needed" path documented in
* `wiki/architecture/node-definitions.md`. A kind that only rebuilds on
* dirty (shelf, item, fence segment, etc.) ships nothing more than a pure
* `geometry` function — no `renderer.tsx`, no `system.tsx`.
*
* Kinds with `def.system` declared run their own systems *in addition* to
* this one — animation + cascade + named-mesh material poking stay
* kind-specific.
*
* Frame priority 2 mirrors the per-kind shelf system it replaces. Door
* animation systems run at priority 2 today too, marking dirty so the
* geometry rebuild lands at priority 3-4 next frame. Door/window/wall
* still have their own systems (they need cross-cutting work this system
* doesn't cover) — they coexist; this system only acts on kinds that
* declare `def.geometry`.
*/
export const GeometrySystem = () => {
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) return
const def = nodeRegistry.get(node.type)
const builder = def?.geometry
if (!builder) return
const group = sceneRegistry.nodes.get(id) as Group | undefined
if (!group) return // mount hasn't run — keep dirty for next frame
const ctx = buildGeometryContext(node, nodes)
// The builder is typed against the kind's specific node — at the
// generic system level we lose that refinement, so the cast lands
// here. Builders are responsible for trusting their schema.
const built = (builder as (n: AnyNode, c: GeometryContext) => { children: unknown[] })(
node,
ctx,
) as unknown as Group
disposeChildren(group)
for (const child of [...built.children]) {
group.add(child)
}
clearDirty(id as AnyNodeId)
})
}, 2)
return null
}
function buildGeometryContext(node: AnyNode, nodes: Record<string, AnyNode>): GeometryContext {
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
const children: AnyNode[] = Array.isArray(childIds)
? childIds.map((cid) => nodes[cid]).filter((n): n is AnyNode => n !== undefined)
: []
const parentId = node.parentId as AnyNodeId | null
const parent: AnyNode | null = parentId ? (nodes[parentId] ?? null) : null
// Siblings = same kind, same parent, excluding self. Walks the parent's
// children array; falls back to scanning the whole scene if the parent
// doesn't carry a `children` list (rare — most parents do).
let siblings: AnyNode[] = []
if (parent) {
const parentChildIds = (parent as unknown as { children?: AnyNodeId[] }).children
if (Array.isArray(parentChildIds)) {
for (const sid of parentChildIds) {
if (sid === node.id) continue
const s = nodes[sid]
if (s && s.type === node.type) siblings.push(s)
}
} else {
siblings = Object.values(nodes).filter(
(n) => n !== node && n.type === node.type && n.parentId === parentId,
)
}
}
return { resolve, children, siblings, parent }
}
function disposeChildren(group: Group) {
for (const child of [...group.children]) {
group.remove(child)
const mesh = child as Partial<Mesh> & { geometry?: { dispose?: () => void } }
if (mesh.geometry?.dispose) mesh.geometry.dispose()
if ('material' in mesh) {
const m = (mesh as { material: unknown }).material
if (Array.isArray(m)) {
for (const mat of m) {
if (mat && typeof (mat as { dispose?: () => void }).dispose === 'function') {
;(mat as { dispose: () => void }).dispose()
}
}
} else if (m && typeof (m as { dispose?: () => void }).dispose === 'function') {
;(m as { dispose: () => void }).dispose()
}
}
}
}
export default GeometrySystem