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:
co-authored by
Claude Opus 4.7
parent
60117e848b
commit
3f3818f3b0
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user