diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 147bcf41..bba20cf8 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -22,6 +22,7 @@ export type { CuttableConfig, DragAction, EditorCtx, + GeometryContext, HostableConfig, IconRef, Issue, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 8c860980..a190d64a 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1,7 +1,31 @@ import type { ComponentType } from 'react' +import type { Object3D } from 'three' import type { ZodObject, z } from 'zod' import type { AnyNode, AnyNodeId } from '../schema/types' +// ─── GeometryContext ───────────────────────────────────────────────── +// +// Read-only scene access passed to `def.geometry(node, ctx)`. Most kinds' +// builders ignore `ctx` and read only `node` (shelf, item, spawn). Kinds +// whose meshes reference other nodes by ID — wall miters with siblings, +// door cutouts read parent wall — use `ctx` to resolve those references +// without importing `useScene`. Builders stay pure and unit-testable. +// +// Future extension: `levelData?: { miters?: ... }` for level-scoped batch +// data (wall mitering across an entire level). Decided alongside the wall +// migration off its dedicated system (Phase 3+). + +export type GeometryContext = { + /** Look up any node by ID. Returns undefined if the node doesn't exist. */ + resolve: (id: AnyNodeId) => N | undefined + /** Resolved children of this node (filters out unresolvable IDs). */ + children: AnyNode[] + /** Same kind, same parent — drives wall mitering / endpoint-match. */ + siblings: AnyNode[] + /** Resolved parent (null for root-level nodes). */ + parent: AnyNode | null +} + // ─── Plugin manifest ───────────────────────────────────────────────── export type Plugin = { @@ -39,6 +63,19 @@ export type NodeDefinition> = { * already null-guard on `def.renderer` so omitting it is safe. */ renderer?: RendererSource> + /** + * Pure geometry builder. When set, the framework's generic + * `` calls this on every dirty mark — `nodes` keyed by + * `def.geometry`'s presence are picked up; the returned `Object3D`'s + * children replace the registered group's children. Together with + * `` this lets a kind ship without per-kind + * `renderer.tsx` or `system.tsx` files (see + * `wiki/architecture/node-definitions.md`). Combine with `renderer` if + * you want JSX-side composition (drei, ``, GLB) AND parametric + * rebuilds; combine with `system` if you also need per-frame imperative + * work (animations, named-mesh material poking). + */ + geometry?: (node: z.infer, ctx: GeometryContext) => Object3D system?: SystemContribution tool?: LazyComponent affordances?: Affordance>[] diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index 69aac305..7b1c5cd8 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -1,4 +1,5 @@ import type { NodeDefinition } from '@pascal-app/core' +import { buildShelfGeometry } from './geometry' import { shelfParametrics } from './parametrics' import { ShelfNode } from './schema' @@ -42,14 +43,15 @@ export const shelfDefinition: NodeDefinition = { parametrics: shelfParametrics, - renderer: { - kind: 'parametric', - module: () => import('./renderer'), - }, - system: { - module: () => import('./system'), - priority: 5, - }, + // Three-checkbox composition: shelf needs only a pure geometry function. + // The framework's mounts an empty group + wires + // events / registry / dirty-on-mount; the global calls + // `buildShelfGeometry(node)` on every dirty mark and swaps the group's + // children. No `renderer.tsx`, no `system.tsx` — see + // `wiki/architecture/node-definitions.md`. Shelf is the reference port + // proving Phase 4's boilerplate collapse end-to-end. + geometry: buildShelfGeometry, + preview: () => import('./preview'), tool: () => import('./tool'), diff --git a/packages/nodes/src/shelf/renderer.tsx b/packages/nodes/src/shelf/renderer.tsx deleted file mode 100644 index e0ebbd0c..00000000 --- a/packages/nodes/src/shelf/renderer.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client' - -import { useLiveTransforms, useRegistry, useScene } from '@pascal-app/core' -import { useNodeEvents } from '@pascal-app/viewer' -import { useLayoutEffect, useRef } from 'react' -import type { Group } from 'three' -import type { ShelfNode } from './schema' - -/** - * Thin shelf renderer. Mounts an empty ``, registers it with - * `sceneRegistry`, and marks the node dirty so `ShelfSystem` populates it - * with geometry on the next frame. - * - * Mirrors the door/item pattern (see `wiki/architecture/renderers.md`): - * "Renderers must not run geometry generation logic (that belongs in a - * System)." Keeping the renderer tiny means parametric edits don't re-run - * any React work — only the system's `useFrame` rebuilds the meshes. - */ -const ShelfRenderer = ({ node }: { node: ShelfNode }) => { - const ref = useRef(null!) - const handlers = useNodeEvents(node, 'shelf') - const liveTransform = useLiveTransforms((state) => state.get(node.id)) - - useRegistry(node.id, 'shelf', ref) - - // Mark dirty on mount and whenever the node identity changes so the system - // builds (or rebuilds) geometry. Subsequent parametric edits set dirty via - // the store's updateNode → dirtyNodes wiring. - useLayoutEffect(() => { - useScene.getState().markDirty(node.id) - }, [node.id]) - - return ( - - ) -} - -export default ShelfRenderer diff --git a/packages/nodes/src/shelf/system.tsx b/packages/nodes/src/shelf/system.tsx deleted file mode 100644 index ee7e30e5..00000000 --- a/packages/nodes/src/shelf/system.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core' -import { useFrame } from '@react-three/fiber' -import type { Group } from 'three' -import { buildShelfGeometry } from './geometry' -import type { ShelfNode } from './schema' - -/** - * Imperative shelf system. Mirrors the pattern used by door/wall/item systems - * (see `wiki/architecture/systems.md` and `renderers.md`): geometry generation - * lives here, the renderer is a thin mount point. - * - * On every frame, walks `dirtyNodes`, finds the registered group for each - * dirty shelf in `sceneRegistry`, swaps its children with the result of - * `buildShelfGeometry(node)`, then clears the dirty flag. No React re-render - * is involved in the rebuild, so parametric edits stay smooth even when the - * inspector emits an `updateNode` every pointermove. - */ -export const ShelfSystem = () => { - 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 || node.type !== 'shelf') return - - const group = sceneRegistry.nodes.get(id) as Group | undefined - if (!group) return // mount hasn't run yet — keep dirty for next frame - - // Clear previous geometry. Disposing materials/geometries here keeps - // long shelf-editing sessions from leaking GPU resources. - for (const child of [...group.children]) { - group.remove(child) - if ('geometry' in child && (child as { geometry?: { dispose: () => void } }).geometry) { - ;(child as { geometry: { dispose: () => void } }).geometry.dispose() - } - if ('material' in child) { - const m = (child as { material: unknown }).material - if (Array.isArray(m)) { - for (const mat of m) (mat as { dispose: () => void }).dispose() - } else if (m && typeof (m as { dispose?: () => void }).dispose === 'function') { - ;(m as { dispose: () => void }).dispose() - } - } - } - - const built = buildShelfGeometry(node as ShelfNode) - for (const child of [...built.children]) { - group.add(child) - } - - clearDirty(id as AnyNodeId) - }) - }, 2) - - return null -} - -export default ShelfSystem diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 562f0c9c..12486a4e 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -11,6 +11,7 @@ import { FenceRenderer } from './fence/fence-renderer' import { GuideRenderer } from './guide/guide-renderer' import { ItemRenderer } from './item/item-renderer' import { LevelRenderer } from './level/level-renderer' +import { ParametricNodeRenderer } from './parametric-node-renderer' import { RoofRenderer } from './roof/roof-renderer' import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer' import { ScanRenderer } from './scan/scan-renderer' @@ -43,18 +44,28 @@ function getRegistryRenderer( function RegistryRenderer({ node }: { node: AnyNode }) { const def = nodeRegistry.get(node.type) if (!def) return null - // A registered kind may omit `renderer` — in that flow the framework's - // generic empty-group renderer covers it (Phase 4 work). Until that ships, - // returning null here lets fall through to the legacy switch, - // which is how wall's milestone-A skeleton stays inert. - if (!def.renderer) return null - const Renderer = getRegistryRenderer(def.renderer as RendererSource) - if (!Renderer) return null - return ( - - - - ) + // Three-checkbox dispatch (see wiki/architecture/node-definitions.md): + // 1. Custom renderer overrides everything — JSX-side composition for + // kinds that need GLB, drei, , instancing, shader materials. + // 2. Else, if the kind ships `def.geometry`, use the generic empty-group + // . `` fills it from the pure + // builder. No per-kind renderer.tsx needed. + // 3. Else, the kind has neither — registered but unrenderable. Fall + // through to null; falls back to the legacy switch + // (used by wall during milestone A before its runtime wired up). + if (def.renderer) { + const Renderer = getRegistryRenderer(def.renderer as RendererSource) + if (!Renderer) return null + return ( + + + + ) + } + if (def.geometry) { + return + } + return null } export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { diff --git a/packages/viewer/src/components/renderers/parametric-node-renderer.tsx b/packages/viewer/src/components/renderers/parametric-node-renderer.tsx new file mode 100644 index 00000000..edb69136 --- /dev/null +++ b/packages/viewer/src/components/renderers/parametric-node-renderer.tsx @@ -0,0 +1,86 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + useLiveTransforms, + useRegistry, + useScene, +} from '@pascal-app/core' +import { useLayoutEffect, useRef } from 'react' +import type { Group } from 'three' +import { useNodeEvents } from '../../hooks/use-node-events' +import { NodeRenderer } from './node-renderer' + +/** + * Generic renderer for any kind that ships `def.geometry` but no custom + * `def.renderer`. + * + * The renderer is intentionally featureless: + * - Mounts an empty ``. + * - Registers the group with `sceneRegistry` so `` can find + * it and inject children built by `def.geometry(node, ctx)`. + * - Wires `useNodeEvents(node, node.type)` on the group so pointer events + * bubble through to the editor's selection / hover bus. + * - Marks the node dirty on mount so the geometry system runs once on + * first render (and on every subsequent identity change). + * - Reads `useLiveTransforms` so drag tools that imperatively override + * position / rotation (the shelf-style smooth move) still work. + * - Renders hosted children recursively via ``. + * + * This is what lets shelf — and every future registry-driven parametric + * kind — ship without a per-kind `renderer.tsx`. See + * `wiki/architecture/node-definitions.md` for the three-checkbox model. + * + * Typing note: `useNodeEvents` is keyed by a literal kind, but at this + * dispatch level we have a union. The cast is contained here so callers + * stay clean. Selection/hover events still fire on the kind-specific + * event key (`shelf:click`, `item:enter`, etc.) — that's the runtime + * behavior the bus consumers care about. + */ +type RenderableNode = AnyNode & { + id: AnyNodeId + position?: [number, number, number] + rotation?: [number, number, number] | number + visible?: boolean + children?: AnyNodeId[] +} + +export const ParametricNodeRenderer = ({ node }: { node: AnyNode }) => { + const ref = useRef(null!) + const n = node as RenderableNode + // biome-ignore lint/suspicious/noExplicitAny: useNodeEvents is keyed by + // literal kind; the registry path passes a runtime kind union. Routing + // through the type cast is safer than widening the hook signature. + const handlers = useNodeEvents(node as any, node.type as any) + const liveTransform = useLiveTransforms((s) => s.get(node.id as AnyNodeId)) + + useRegistry(node.id, node.type, ref) + + useLayoutEffect(() => { + useScene.getState().markDirty(node.id as AnyNodeId) + }, [node.id]) + + const position = liveTransform?.position ?? n.position ?? [0, 0, 0] + const rotation: [number, number, number] = + liveTransform?.rotation !== undefined + ? [0, liveTransform.rotation, 0] + : typeof n.rotation === 'number' + ? [0, n.rotation, 0] + : (n.rotation ?? [0, 0, 0]) + + return ( + + {Array.isArray(n.children) && + n.children.map((childId) => ( + + ))} + + ) +} diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index ce56c85e..2599d320 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -11,6 +11,7 @@ import { DoorAnimationSystem } from '../../systems/door/door-animation-system' import { DoorSystem } from '../../systems/door/door-system' import { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-system' import { FenceSystem } from '../../systems/fence/fence-system' +import { GeometrySystem } from '../../systems/geometry/geometry-system' import { GuideSystem } from '../../systems/guide/guide-system' import { ItemSystem } from '../../systems/item/item-system' import { ItemLightSystem } from '../../systems/item-light/item-light-system' @@ -283,6 +284,13 @@ const Viewer: React.FC = ({ + {/* Generic geometry rebuild loop for any registered kind that + ships `def.geometry`. Reads dirtyNodes, calls the kind's pure + builder, swaps the registered group's children. Runs alongside + per-kind systems — they coexist, this system only acts on + kinds whose definition exposes a `geometry` function. See + wiki/architecture/node-definitions.md. */} + {/* Mounts systems contributed by registry-backed kinds. Today the registry is empty so this renders nothing. Once kinds register (Phase 2+), each kind's registered system runs here and its diff --git a/packages/viewer/src/systems/geometry/geometry-system.tsx b/packages/viewer/src/systems/geometry/geometry-system.tsx new file mode 100644 index 00000000..b5fbc220 --- /dev/null +++ b/packages/viewer/src/systems/geometry/geometry-system.tsx @@ -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 ``, 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): GeometryContext { + const resolve = (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 & { 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