wiki: add node-definitions doc for three-checkbox composition model

New page covering the geometry/renderer/system trio that registry-driven
kinds opt into. Documents:

- The three optional fields on NodeDefinition and when each applies
- Generic <GeometrySystem> + <ParametricNodeRenderer> runtime
- GeometryContext shape (resolve / children / siblings / parent)
- Combination matrix for shelf / spawn / zone / door / window / GLB items
- Migration recipe from custom renderer+system files to def.geometry
- Rules around purity, dispose-on-rebuild, register-once

renderers.md and systems.md gain "prefer registry-driven" banners and
link out to the new page. Architecture README adds the page to the
index so review-architecture skill picks it up.

The pattern was validated by the shelf spike: inline-JSX geometry was
visibly laggy on parametric edits; moving to a per-kind system reading
dirtyNodes (mirroring door/wall/item) restored smoothness. The three-
checkbox model generalises that win so most future kinds need only a
pure geometry function.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 08:29:54 -04:00
co-authored by Claude Opus 4.7
parent 7f24593041
commit 7b946ce8b7
4 changed files with 193 additions and 0 deletions
+1
View File
@@ -9,6 +9,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa
| [layers](layers.md) | Three.js layer constants, ownership, and rendering separation | | [layers](layers.md) | Three.js layer constants, ownership, and rendering separation |
| [systems](systems.md) | Core and viewer systems architecture | | [systems](systems.md) | Core and viewer systems architecture |
| [renderers](renderers.md) | Node renderer pattern in `packages/viewer` | | [renderers](renderers.md) | Node renderer pattern in `packages/viewer` |
| [node-definitions](node-definitions.md) | Three-checkbox composition model for registry-driven kinds (`geometry` / `renderer` / `system`) |
| [tools](tools.md) | Editor tools structure in `apps/editor` | | [tools](tools.md) | Editor tools structure in `apps/editor` |
| [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic | | [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic |
| [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner | | [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner |
+186
View File
@@ -0,0 +1,186 @@
# Node definitions
*The registry-driven composition model for node kinds.*
Applies to: `packages/core/src/registry/`, `packages/nodes/src/<kind>/`, `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: `<Html>`, `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<typeof ShelfNode> = {
// ...
geometry: buildShelfGeometry, // pure function in geometry.ts
}
// zone — built once via React (uses <Html>), animated per-frame via system
export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
// ...
renderer: () => import('./renderer'), // composes <Html> + TSL materials
system: { module: () => import('./system') }, // pokes uniforms per frame
}
// door — pure geometry + animation system
export const doorDefinition: NodeDefinition<typeof DoorNode> = {
// ...
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/`:
- **`<NodeRenderer>`** chooses what React mounts for a node:
1. If `def.renderer` is set → mount the custom renderer.
2. Otherwise → mount `<ParametricNodeRenderer>` — a thin empty `<group>` that registers with `sceneRegistry`, attaches pointer handlers via `useNodeEvents`, reads `useLiveTransforms` for drag overrides, and calls `useScene.getState().markDirty(node.id)` on mount.
- **`<GeometrySystem>`** 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 `<RegisteredSystems>`. 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: <N = AnyNode>(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 `<GeometrySystem>` 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<typeof ShelfNode> = {
// ...
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/<kind>/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<Group>(null!)
const { scene } = useGLTF(node.asset.url)
const handlers = useNodeEvents(node, 'furniture')
useRegistry(node.id, 'furniture', ref)
return <primitive object={scene.clone()} ref={ref} {...handlers} />
}
```
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. `<Html>`) 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
<group ref={ref} {...handlers}>
<Html name="label" position={centroid}>{node.name}</Html>
<mesh name="floor" geometry={floorGeometry} material={floorMaterial} />
<mesh name="walls" geometry={wallGeometry} material={wallMaterial} />
</group>
// 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 (`<mesh name="walls" />`) 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/<kind>/geometry.ts`.
2. Replace `def.renderer` with nothing — the framework's `<ParametricNodeRenderer>` 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 `<group>` 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 — `<ParametricNodeRenderer>` 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.
+4
View File
@@ -6,6 +6,8 @@ Applies to: `packages/viewer/**`.
Renderers live in `packages/viewer/src/components/renderers/`. Each renderer is responsible for one node type's Three.js geometry and materials — nothing else. Renderers live in `packages/viewer/src/components/renderers/`. Each renderer is responsible for one node type's Three.js geometry and materials — nothing else.
> **For registry-driven kinds, the default is no custom renderer.** Set `def.geometry` instead and the framework mounts a generic renderer + geometry system for you. See [node-definitions.md](node-definitions.md). The pattern below applies to kinds that *do* need a custom renderer (GLB, `<Html>`, drei, instancing, shader materials).
## Dispatch Chain ## Dispatch Chain
``` ```
@@ -54,6 +56,8 @@ export function MyNodeRenderer({ node }: { node: MyNode }) {
## Adding a New Node Type ## Adding a New Node Type
For new kinds, prefer the registry-driven model in [node-definitions.md](node-definitions.md). The legacy steps below apply only when a kind needs a custom React renderer (GLB loaders, `<Html>` portals, etc.) **and** lives in `packages/viewer` rather than `packages/nodes/<kind>`:
1. Create `packages/viewer/src/components/renderers/<type>/index.tsx` 1. Create `packages/viewer/src/components/renderers/<type>/index.tsx`
2. Add a case to `NodeRenderer` in `node-renderer.tsx` 2. Add a case to `NodeRenderer` in `node-renderer.tsx`
3. Add the corresponding system in `packages/core/src/systems/` if the node needs derived geometry 3. Add the corresponding system in `packages/core/src/systems/` if the node needs derived geometry
+2
View File
@@ -6,6 +6,8 @@ Applies to: `packages/core/src/systems/**`, `packages/viewer/src/systems/**`.
Systems own business logic, geometry generation, and constraints. They run in the Three.js frame loop and are never rendered directly. Systems own business logic, geometry generation, and constraints. They run in the Three.js frame loop and are never rendered directly.
> **For registry-driven kinds, prefer no per-kind system.** If your kind's only job is "rebuild geometry on dirty", set `def.geometry` and let the framework's `<GeometrySystem>` handle the rebuild loop. Per-kind systems remain for *extra* responsibilities — animations, cross-kind dirty cascades, named-mesh material poking. See [node-definitions.md](node-definitions.md).
## Two Kinds of Systems ## Two Kinds of Systems
### Core Systems — `packages/core/src/systems/` ### Core Systems — `packages/core/src/systems/`