Files
editor/packages/viewer/src/components/renderers/node-renderer.tsx
T
Wassim SAMADandClaude Opus 4.7 3f3818f3b0 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>
2026-05-15 10:03:51 -04:00

109 lines
5.0 KiB
TypeScript

'use client'
import { type AnyNode, nodeRegistry, type RendererSource, useScene } from '@pascal-app/core'
import { type ComponentType, lazy, Suspense } from 'react'
import { BuildingRenderer } from './building/building-renderer'
import { CeilingRenderer } from './ceiling/ceiling-renderer'
import { ColumnRenderer } from './column/column-renderer'
import { DoorRenderer } from './door/door-renderer'
import { ElevatorRenderer } from './elevator/elevator-renderer'
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'
import { SiteRenderer } from './site/site-renderer'
import { SlabRenderer } from './slab/slab-renderer'
import { SpawnRenderer } from './spawn/spawn-renderer'
import { StairRenderer } from './stair/stair-renderer'
import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer'
import { WallRenderer } from './wall/wall-renderer'
import { WindowRenderer } from './window/window-renderer'
import { ZoneRenderer } from './zone/zone-renderer'
// Cache lazy components by their RendererSource so React.lazy isn't re-invoked
// on every render — that would create a new Suspense boundary each time.
const lazyCache = new WeakMap<RendererSource<AnyNode>, ComponentType<{ node: AnyNode }>>()
function getRegistryRenderer(
source: RendererSource<AnyNode>,
): ComponentType<{ node: AnyNode }> | null {
const cached = lazyCache.get(source)
if (cached) return cached
// GLB / instanced-GLB sources lower onto built-in renderers landed in
// Phase 5 — for now only parametric (lazy module) sources are honored.
if (source.kind !== 'parametric') return null
const Comp = lazy(source.module) as unknown as ComponentType<{ node: AnyNode }>
lazyCache.set(source, Comp)
return Comp
}
function RegistryRenderer({ node }: { node: AnyNode }) {
const def = nodeRegistry.get(node.type)
if (!def) return null
// 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'] }) => {
const node = useScene((state) => state.nodes[nodeId])
if (!node) return null
// Registry-first: if a NodeDefinition is registered for this kind (via
// @pascal-app/nodes or a future plugin), it owns the render. Falls through
// to the legacy chain below for kinds not yet migrated. Legacy chain is
// removed in Phase 6 once every kind is registry-backed.
if (nodeRegistry.has(node.type)) {
return <RegistryRenderer node={node} />
}
return (
<>
{node.type === 'site' && <SiteRenderer node={node} />}
{node.type === 'building' && <BuildingRenderer node={node} />}
{node.type === 'ceiling' && <CeilingRenderer node={node} />}
{node.type === 'column' && <ColumnRenderer node={node} />}
{node.type === 'elevator' && <ElevatorRenderer node={node} />}
{node.type === 'level' && <LevelRenderer node={node} />}
{node.type === 'item' && <ItemRenderer node={node} />}
{node.type === 'slab' && <SlabRenderer node={node} />}
{node.type === 'spawn' && <SpawnRenderer node={node} />}
{node.type === 'wall' && <WallRenderer node={node} />}
{node.type === 'fence' && <FenceRenderer node={node} />}
{node.type === 'door' && <DoorRenderer node={node} />}
{node.type === 'window' && <WindowRenderer node={node} />}
{node.type === 'zone' && <ZoneRenderer node={node} />}
{node.type === 'roof' && <RoofRenderer node={node} />}
{node.type === 'roof-segment' && <RoofSegmentRenderer node={node} />}
{node.type === 'stair' && <StairRenderer node={node} />}
{node.type === 'stair-segment' && <StairSegmentRenderer node={node} />}
{node.type === 'scan' && <ScanRenderer node={node} />}
{node.type === 'guide' && <GuideRenderer node={node} />}
</>
)
}