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
@@ -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
|
||||
Reference in New Issue
Block a user