From 7b946ce8b7738cba3e5c5faa4daca18746124a1d Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 15 May 2026 08:29:54 -0400 Subject: [PATCH] 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 + 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) --- wiki/architecture/README.md | 1 + wiki/architecture/node-definitions.md | 186 ++++++++++++++++++++++++++ wiki/architecture/renderers.md | 4 + wiki/architecture/systems.md | 2 + 4 files changed, 193 insertions(+) create mode 100644 wiki/architecture/node-definitions.md diff --git a/wiki/architecture/README.md b/wiki/architecture/README.md index 76b38c14..631e2013 100644 --- a/wiki/architecture/README.md +++ b/wiki/architecture/README.md @@ -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 | diff --git a/wiki/architecture/node-definitions.md b/wiki/architecture/node-definitions.md new file mode 100644 index 00000000..6e243427 --- /dev/null +++ b/wiki/architecture/node-definitions.md @@ -0,0 +1,186 @@ +# 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. diff --git a/wiki/architecture/renderers.md b/wiki/architecture/renderers.md index c517869e..a4a6a528 100644 --- a/wiki/architecture/renderers.md +++ b/wiki/architecture/renderers.md @@ -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, ``, 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, `` portals, etc.) **and** lives in `packages/viewer` rather than `packages/nodes/`: + 1. Create `packages/viewer/src/components/renderers//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 diff --git a/wiki/architecture/systems.md b/wiki/architecture/systems.md index 3fe126fd..c90500bb 100644 --- a/wiki/architecture/systems.md +++ b/wiki/architecture/systems.md @@ -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 `` 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/`