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:
co-authored by
Claude Opus 4.7
parent
7f24593041
commit
7b946ce8b7
@@ -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 |
|
||||
| [systems](systems.md) | Core and viewer systems architecture |
|
||||
| [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` |
|
||||
| [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic |
|
||||
| [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner |
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
> **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
|
||||
|
||||
```
|
||||
@@ -54,6 +56,8 @@ export function MyNodeRenderer({ node }: { node: MyNode }) {
|
||||
|
||||
## 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`
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
> **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
|
||||
|
||||
### Core Systems — `packages/core/src/systems/`
|
||||
|
||||
Reference in New Issue
Block a user