# Node definitions *The registry-driven composition model for node kinds.* Applies to: `packages/core/src/registry/`, `packages/nodes/src//`, `packages/viewer/src/components/viewer/{registered-systems.tsx,node-renderer.tsx}`. A *node kind* — shelf, wall, door, item, spawn, zone — is described by a `NodeDefinition` registered with `nodeRegistry`. The definition is plain data + lazy module references. Three optional fields decide how the kind appears in the scene at runtime; pick whichever combination matches the kind's needs. This page covers those three fields. For the broader registry contract (schemas, capabilities, parametrics, MCP), see [the registry plan](../../../plans/editor-node-registry.md) in the private repo. ## The three-checkbox model | Field | Purpose | Pick it when | |---|---|---| | `geometry?: (node, ctx) => Object3D` | Pure builder. Returns the meshes for this node. | The kind has parametric meshes that should rebuild when `updateNode` runs. | | `renderer?: () => Promise<{ default: ComponentType<{ node }> }>` | Optional custom React component. Owns mesh creation. | The kind needs JSX-only features: ``, `useGLTF`, drei helpers, instancing, TSL shader materials, R3F portals. | | `system?: () => Promise<{ default: ComponentType }>` | Optional per-frame component (`useFrame` returning `null`). | The kind needs imperative work per frame: animations, opacity transitions, named-mesh material poking, cross-kind dirty cascades. | The three fields are **independent**. There is no discriminator tag — presence is participation: ```ts // shelf — pure geometry, no React, no per-frame work export const shelfDefinition: NodeDefinition = { // ... geometry: buildShelfGeometry, // pure function in geometry.ts } // zone — built once via React (uses ), animated per-frame via system export const zoneDefinition: NodeDefinition = { // ... renderer: () => import('./renderer'), // composes + TSL materials system: { module: () => import('./system') }, // pokes uniforms per frame } // door — pure geometry + animation system export const doorDefinition: NodeDefinition = { // ... geometry: buildDoorGeometry, system: { module: () => import('./animation') }, // advances operationState } ``` ## Runtime: how the three fields are wired Two framework components live in `packages/viewer/src/components/viewer/`: - **``** chooses what React mounts for a node: 1. If `def.renderer` is set → mount the custom renderer. 2. Otherwise → mount `` — a thin empty `` that registers with `sceneRegistry`, attaches pointer handlers via `useNodeEvents`, reads `useLiveTransforms` for drag overrides, and calls `useScene.getState().markDirty(node.id)` on mount. - **``** runs every frame: 1. Read `dirtyNodes` from `useScene`. 2. For each dirty node whose kind has `def.geometry`, look up the registered `Group` from `sceneRegistry`, build a `GeometryContext`, call `def.geometry(node, ctx)`, dispose old children, attach the new ones, call `clearDirty(id)`. 3. Kinds with no `def.geometry` are skipped — their custom `def.renderer` handles geometry on its own. Per-kind `def.system` components mount alongside via ``. They run their own `useFrame` and can mark nodes dirty, address meshes by `getObjectByName`, advance animation state, etc. They run **in addition** to `GeometrySystem`, not instead of it. ## `GeometryContext` The second arg to `geometry()` is scene read access for builders that reference other nodes by ID. Most kinds ignore it. ```ts type GeometryContext = { resolve: (id: AnyNodeId) => N | undefined children: AnyNode[] // resolved children of this node siblings: AnyNode[] // same kind, same parent (drives wall mitering) parent: AnyNode | null } ``` - **Shelf, spawn, item, column, fence segment** — builder reads only `node`. `ctx` argument unused. - **Wall** — `ctx.siblings` for corner mitering with adjacent walls. `ctx.children` for cutout footprints (doors / windows hosted on the wall). - **Door / window** — `ctx.parent` for parent-wall thickness, so the frame depth lines up with the wall it's cut into. `GeometryContext` exists so builders stay pure (no `useScene` import, no store mutation) and trivially unit-testable. The generic `` builds `ctx` from the current scene snapshot once per dirty node; the cost is a few `Map.get` calls. For level-scoped batch data (wall mitering across an entire level), `ctx` can be extended with `ctx.levelData?.miters` in a future revision — decided alongside the wall migration (Phase 3 of the registry plan). ## Choosing the right combination ### `geometry` only Use this when the kind's meshes are a pure function of its node data. **Shelf, spawn, item, column, fence segment, wall, door (geometry side), window (geometry side).** ```ts // packages/nodes/src/shelf/geometry.ts export function buildShelfGeometry(node: ShelfNode): Group { const group = new Group() group.add(buildTopBoard(node)) group.add(buildBracket(node, -1)) group.add(buildBracket(node, +1)) return group } // packages/nodes/src/shelf/definition.ts export const shelfDefinition: NodeDefinition = { // ... geometry: buildShelfGeometry, } ``` No renderer.tsx, no system.tsx. The generic renderer mounts an empty group, the generic system fills it. ### `renderer` only (no `geometry`, no `system`) Use this when the kind composes its scene via JSX-only features and never needs imperative per-frame work. **GLB-backed items, kinds that mount drei helpers.** ```tsx // packages/nodes/src//renderer.tsx import { useGLTF } from '@react-three/drei' import { useRegistry } from '@pascal-app/core' import { useNodeEvents } from '@pascal-app/viewer' const FurnitureRenderer = ({ node }: { node: FurnitureNode }) => { const ref = useRef(null!) const { scene } = useGLTF(node.asset.url) const handlers = useNodeEvents(node, 'furniture') useRegistry(node.id, 'furniture', ref) return } ``` No `def.geometry` — geometry is the GLB. No `def.system` — there's nothing to animate. ### `renderer` + `system` (no `geometry`) Use this when the kind's tree contains React-only primitives (e.g. ``) and needs per-frame imperative work that doesn't rebuild geometry. **Zone.** The renderer composes the tree once. The system pokes uniforms / opacity / transforms by name: ```tsx // renderer.tsx {node.name} // system.tsx useFrame(() => { sceneRegistry.byType.zone.forEach((id) => { const group = sceneRegistry.nodes.get(id) as Group const walls = group.getObjectByName('walls') as Mesh const material = walls.material as MeshBasicNodeMaterial material.userData.uOpacity.value = lerp(currentOpacity, targetOpacity, lerpSpeed) }) }) ``` ### `geometry` + `system` Use this when the kind has parametric geometry **and** extra responsibilities. **Door, window.** - `geometry` builds the visible meshes (frame, panels, hardware) as a pure function of node state + parent wall. - `system` advances animation (`operationState`), then calls `markDirty(node.id)` so the geometry system rebuilds on the next frame. This split keeps animation state outside the node schema (it's ephemeral — lives in `useInteractive`) while still re-using the generic rebuild path. ## Named meshes work in either pattern Setting `mesh.name = 'walls'` is just a three.js property. A system targeting `getObjectByName('walls')` doesn't care whether the mesh was created in JSX (``) or imperatively in a pure builder (`mesh.name = 'walls'; group.add(mesh)`). Use whichever fits the kind. ## Migrating from custom renderer+system files to `def.geometry` If your kind's current system *only* rebuilds geometry on dirty (no animations, no cascades, no material poking), it can collapse to a single `def.geometry` function: 1. Extract the imperative `updateXMesh(node, group)` from the system into a pure `buildXGeometry(node): Group` in `packages/nodes/src//geometry.ts`. 2. Replace `def.renderer` with nothing — the framework's `` covers it. 3. Replace `def.system` with `def.geometry: buildXGeometry`. 4. Delete `renderer.tsx` and `system.tsx`. If the system also handles cascades, animations, or material updates, keep `def.system` and *also* set `def.geometry` — they run side by side. ## Rules - **Builders must be pure.** No `useScene` import inside a `def.geometry` function. Read scene state via `ctx`. Mutating the store from a builder breaks idempotence. - **One mesh registered per node ID.** The generic renderer registers a single `` per node. If a custom renderer mounts multiple meshes, register the parent group (or whichever object the system needs to address). - **Custom systems run in addition to the generic system, not instead of it.** A kind with `def.geometry` + `def.system` will see the generic system rebuild children on dirty AND the per-kind system run its `useFrame`. Plan priorities accordingly: door-animation runs at priority 2, geometry rebuild at priority 3. - **Dispose on rebuild.** The generic system disposes the previous children's geometry + material before swapping. Custom systems that imperatively add children must dispose what they replace, or accept the GPU-memory cost. - **`def.renderer` overrides the generic renderer.** Once you set it, you own the mount — `` is not invoked. The generic geometry system still runs for the kind if `def.geometry` is set, so a custom renderer can register an empty group and let the system fill it. ## See also - [renderers.md](renderers.md) — the legacy renderer pattern (still authoritative for kinds with custom `def.renderer`). - [systems.md](systems.md) — per-kind systems, frame-priority ordering, and core/viewer split. - [scene-registry.md](scene-registry.md) — how `sceneRegistry` indexes nodes by ID and type. - [Node registry plan](../../../plans/editor-node-registry.md) *(in private-editor)* — the multi-phase migration that produced this model.