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
+1
View File
@@ -22,6 +22,7 @@ export type {
CuttableConfig,
DragAction,
EditorCtx,
GeometryContext,
HostableConfig,
IconRef,
Issue,
+37
View File
@@ -1,7 +1,31 @@
import type { ComponentType } from 'react'
import type { Object3D } from 'three'
import type { ZodObject, z } from 'zod'
import type { AnyNode, AnyNodeId } from '../schema/types'
// ─── GeometryContext ─────────────────────────────────────────────────
//
// Read-only scene access passed to `def.geometry(node, ctx)`. Most kinds'
// builders ignore `ctx` and read only `node` (shelf, item, spawn). Kinds
// whose meshes reference other nodes by ID — wall miters with siblings,
// door cutouts read parent wall — use `ctx` to resolve those references
// without importing `useScene`. Builders stay pure and unit-testable.
//
// Future extension: `levelData?: { miters?: ... }` for level-scoped batch
// data (wall mitering across an entire level). Decided alongside the wall
// migration off its dedicated system (Phase 3+).
export type GeometryContext = {
/** Look up any node by ID. Returns undefined if the node doesn't exist. */
resolve: <N = AnyNode>(id: AnyNodeId) => N | undefined
/** Resolved children of this node (filters out unresolvable IDs). */
children: AnyNode[]
/** Same kind, same parent — drives wall mitering / endpoint-match. */
siblings: AnyNode[]
/** Resolved parent (null for root-level nodes). */
parent: AnyNode | null
}
// ─── Plugin manifest ─────────────────────────────────────────────────
export type Plugin = {
@@ -39,6 +63,19 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* already null-guard on `def.renderer` so omitting it is safe.
*/
renderer?: RendererSource<z.infer<S>>
/**
* Pure geometry builder. When set, the framework's generic
* `<GeometrySystem>` calls this on every dirty mark — `nodes` keyed by
* `def.geometry`'s presence are picked up; the returned `Object3D`'s
* children replace the registered group's children. Together with
* `<ParametricNodeRenderer>` this lets a kind ship without per-kind
* `renderer.tsx` or `system.tsx` files (see
* `wiki/architecture/node-definitions.md`). Combine with `renderer` if
* you want JSX-side composition (drei, `<Html>`, GLB) AND parametric
* rebuilds; combine with `system` if you also need per-frame imperative
* work (animations, named-mesh material poking).
*/
geometry?: (node: z.infer<S>, ctx: GeometryContext) => Object3D
system?: SystemContribution
tool?: LazyComponent
affordances?: Affordance<z.infer<S>>[]
+10 -8
View File
@@ -1,4 +1,5 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildShelfGeometry } from './geometry'
import { shelfParametrics } from './parametrics'
import { ShelfNode } from './schema'
@@ -42,14 +43,15 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
parametrics: shelfParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 5,
},
// Three-checkbox composition: shelf needs only a pure geometry function.
// The framework's <ParametricNodeRenderer> mounts an empty group + wires
// events / registry / dirty-on-mount; the global <GeometrySystem> calls
// `buildShelfGeometry(node)` on every dirty mark and swaps the group's
// children. No `renderer.tsx`, no `system.tsx` — see
// `wiki/architecture/node-definitions.md`. Shelf is the reference port
// proving Phase 4's boilerplate collapse end-to-end.
geometry: buildShelfGeometry,
preview: () => import('./preview'),
tool: () => import('./tool'),
-44
View File
@@ -1,44 +0,0 @@
'use client'
import { useLiveTransforms, useRegistry, useScene } from '@pascal-app/core'
import { useNodeEvents } from '@pascal-app/viewer'
import { useLayoutEffect, useRef } from 'react'
import type { Group } from 'three'
import type { ShelfNode } from './schema'
/**
* 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.
*
* 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!)
const handlers = useNodeEvents(node, 'shelf')
const liveTransform = useLiveTransforms((state) => state.get(node.id))
useRegistry(node.id, 'shelf', ref)
// 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
position={liveTransform?.position ?? node.position}
ref={ref}
rotation={liveTransform?.rotation ? [0, liveTransform.rotation, 0] : node.rotation}
visible={node.visible}
{...handlers}
/>
)
}
export default ShelfRenderer
-62
View File
@@ -1,62 +0,0 @@
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
@@ -11,6 +11,7 @@ import { FenceRenderer } from './fence/fence-renderer'
import { GuideRenderer } from './guide/guide-renderer'
import { ItemRenderer } from './item/item-renderer'
import { LevelRenderer } from './level/level-renderer'
import { ParametricNodeRenderer } from './parametric-node-renderer'
import { RoofRenderer } from './roof/roof-renderer'
import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer'
import { ScanRenderer } from './scan/scan-renderer'
@@ -43,18 +44,28 @@ function getRegistryRenderer(
function RegistryRenderer({ node }: { node: AnyNode }) {
const def = nodeRegistry.get(node.type)
if (!def) return null
// A registered kind may omit `renderer` — in that flow the framework's
// generic empty-group renderer covers it (Phase 4 work). Until that ships,
// returning null here lets <NodeRenderer> fall through to the legacy switch,
// which is how wall's milestone-A skeleton stays inert.
if (!def.renderer) return null
const Renderer = getRegistryRenderer(def.renderer as RendererSource<AnyNode>)
if (!Renderer) return null
return (
<Suspense fallback={null}>
<Renderer node={node} />
</Suspense>
)
// Three-checkbox dispatch (see wiki/architecture/node-definitions.md):
// 1. Custom renderer overrides everything — JSX-side composition for
// kinds that need GLB, drei, <Html>, instancing, shader materials.
// 2. Else, if the kind ships `def.geometry`, use the generic empty-group
// <ParametricNodeRenderer>. `<GeometrySystem>` fills it from the pure
// builder. No per-kind renderer.tsx needed.
// 3. Else, the kind has neither — registered but unrenderable. Fall
// through to null; <NodeRenderer> falls back to the legacy switch
// (used by wall during milestone A before its runtime wired up).
if (def.renderer) {
const Renderer = getRegistryRenderer(def.renderer as RendererSource<AnyNode>)
if (!Renderer) return null
return (
<Suspense fallback={null}>
<Renderer node={node} />
</Suspense>
)
}
if (def.geometry) {
return <ParametricNodeRenderer node={node} />
}
return null
}
export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
@@ -0,0 +1,86 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
useLiveTransforms,
useRegistry,
useScene,
} from '@pascal-app/core'
import { useLayoutEffect, useRef } from 'react'
import type { Group } from 'three'
import { useNodeEvents } from '../../hooks/use-node-events'
import { NodeRenderer } from './node-renderer'
/**
* Generic renderer for any kind that ships `def.geometry` but no custom
* `def.renderer`.
*
* The renderer is intentionally featureless:
* - Mounts an empty `<group>`.
* - Registers the group with `sceneRegistry` so `<GeometrySystem>` can find
* it and inject children built by `def.geometry(node, ctx)`.
* - Wires `useNodeEvents(node, node.type)` on the group so pointer events
* bubble through to the editor's selection / hover bus.
* - Marks the node dirty on mount so the geometry system runs once on
* first render (and on every subsequent identity change).
* - Reads `useLiveTransforms` so drag tools that imperatively override
* position / rotation (the shelf-style smooth move) still work.
* - Renders hosted children recursively via `<NodeRenderer>`.
*
* This is what lets shelf — and every future registry-driven parametric
* kind — ship without a per-kind `renderer.tsx`. See
* `wiki/architecture/node-definitions.md` for the three-checkbox model.
*
* Typing note: `useNodeEvents` is keyed by a literal kind, but at this
* dispatch level we have a union. The cast is contained here so callers
* stay clean. Selection/hover events still fire on the kind-specific
* event key (`shelf:click`, `item:enter`, etc.) — that's the runtime
* behavior the bus consumers care about.
*/
type RenderableNode = AnyNode & {
id: AnyNodeId
position?: [number, number, number]
rotation?: [number, number, number] | number
visible?: boolean
children?: AnyNodeId[]
}
export const ParametricNodeRenderer = ({ node }: { node: AnyNode }) => {
const ref = useRef<Group>(null!)
const n = node as RenderableNode
// biome-ignore lint/suspicious/noExplicitAny: useNodeEvents is keyed by
// literal kind; the registry path passes a runtime kind union. Routing
// through the type cast is safer than widening the hook signature.
const handlers = useNodeEvents(node as any, node.type as any)
const liveTransform = useLiveTransforms((s) => s.get(node.id as AnyNodeId))
useRegistry(node.id, node.type, ref)
useLayoutEffect(() => {
useScene.getState().markDirty(node.id as AnyNodeId)
}, [node.id])
const position = liveTransform?.position ?? n.position ?? [0, 0, 0]
const rotation: [number, number, number] =
liveTransform?.rotation !== undefined
? [0, liveTransform.rotation, 0]
: typeof n.rotation === 'number'
? [0, n.rotation, 0]
: (n.rotation ?? [0, 0, 0])
return (
<group
position={position}
ref={ref}
rotation={rotation}
visible={n.visible !== false}
{...handlers}
>
{Array.isArray(n.children) &&
n.children.map((childId) => (
<NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} />
))}
</group>
)
}
@@ -11,6 +11,7 @@ import { DoorAnimationSystem } from '../../systems/door/door-animation-system'
import { DoorSystem } from '../../systems/door/door-system'
import { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-system'
import { FenceSystem } from '../../systems/fence/fence-system'
import { GeometrySystem } from '../../systems/geometry/geometry-system'
import { GuideSystem } from '../../systems/guide/guide-system'
import { ItemSystem } from '../../systems/item/item-system'
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
@@ -283,6 +284,13 @@ const Viewer: React.FC<ViewerProps> = ({
<LegacySystem kind="zone">
<ZoneSystem />
</LegacySystem>
{/* Generic geometry rebuild loop for any registered kind that
ships `def.geometry`. Reads dirtyNodes, calls the kind's pure
builder, swaps the registered group's children. Runs alongside
per-kind systems — they coexist, this system only acts on
kinds whose definition exposes a `geometry` function. See
wiki/architecture/node-definitions.md. */}
<GeometrySystem />
{/* Mounts systems contributed by registry-backed kinds. Today the
registry is empty so this renders nothing. Once kinds register
(Phase 2+), each kind's registered system runs here and its
@@ -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