From 02aeca8439ba8cd85f0853caca2374578edea765 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 15 May 2026 09:00:15 -0400 Subject: [PATCH] Wall Phase 3 milestone B: runtime port behind feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the wall kind onto the registry path when NEXT_PUBLIC_USE_REGISTRY_FOR_WALL=true; default-off keeps wall on its legacy path unchanged. Files added: - nodes/src/wall/renderer.tsx — thin placeholder-mesh mount point. Identical pattern to the legacy WallRenderer: registers ref via useRegistry, marks dirty on mount, renders hosted children recursively via NodeRenderer. The legacy WallSystem fills geometry on the next frame regardless of which mount path is active. - nodes/src/wall/system.tsx — a bundle component that renders + (both re-exported from viewer). Registered via def.system with priority 4 to mirror the legacy WallSystem's useFrame priority. Zero logic duplication — the ~970 lines of CSG/mitering/cutaway code stays in viewer. Files changed: - packages/viewer/src/index.ts — new exports for WallSystem, WallCutout, and NodeRenderer. The first two so the registry-driven system bundle can compose them; NodeRenderer so any parent kind (wall, slab, ceiling, building) can recursively render hosted children without reaching into viewer internals. - nodes/src/wall/definition.ts — adds renderer + system fields. Tool field stays absent (wall placement / endpoint drag remain bespoke for now; the affordance port is a later milestone). - nodes/src/index.ts — conditionally appends wallDefinition to builtinPlugin.nodes based on isWallRegistryEnabled(). With the flag off, the array is identical to before this commit; with it on, Phase 0 dispatch shims switch wall to the registry path: * around WallSystem returns null * around WallCutout returns null * takes the registry-first branch and mounts the new renderer instead of the legacy switch case for 'wall' * RegisteredSystems mounts the new system bundle, which re-mounts the same WallSystem + WallCutout components from viewer No behavior change with the flag off. With the flag on, behavior should be byte-identical (same components, same priority, same geometry path). Manual verification next: place walls, t-junctions, walls-with-doors with the flag toggled both ways; confirm visual + interactive parity. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/nodes/src/index.ts | 16 +++++ packages/nodes/src/wall/definition.ts | 25 +++++++- packages/nodes/src/wall/renderer.tsx | 85 +++++++++++++++++++++++++++ packages/nodes/src/wall/system.tsx | 38 ++++++++++++ packages/viewer/src/index.ts | 11 ++++ 5 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 packages/nodes/src/wall/renderer.tsx create mode 100644 packages/nodes/src/wall/system.tsx diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 3362af91..eb421747 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -1,6 +1,7 @@ import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' import { shelfDefinition } from './shelf' import { spawnDefinition } from './spawn' +import { isWallRegistryEnabled, wallDefinition } from './wall' /** * Built-in plugin bundling every node kind shipped with the Pascal editor. @@ -18,15 +19,30 @@ import { spawnDefinition } from './spawn' * present in viewer/editor packages but short-circuited by the Phase 0 * dispatch shims (`nodeRegistry.has('spawn')` is true → legacy path * yields). Legacy spawn files are deleted in a follow-up PR. + * + * Phase 3 status: wall is registry-driven *behind a feature flag*. With + * `NEXT_PUBLIC_USE_REGISTRY_FOR_WALL=true`, `wallDefinition` is included + * here and the Phase 0 shims switch wall to the registry path; the + * `` wrappers around `WallSystem` and + * `WallCutout` short-circuit and the bundled `system.tsx` re-mounts them + * via `RegisteredSystems`. Off (default): wall stays on the legacy path. + * The flag drops the moment parity is signed off across the Phase 3 + * fixture scenes — until then it gates the migration safely. */ +const wallEntries: AnyNodeDefinition[] = isWallRegistryEnabled() + ? [wallDefinition as unknown as AnyNodeDefinition] + : [] + export const builtinPlugin: Plugin = { id: 'pascal:core', apiVersion: 1, nodes: [ shelfDefinition as unknown as AnyNodeDefinition, spawnDefinition as unknown as AnyNodeDefinition, + ...wallEntries, ], } export { shelfDefinition } from './shelf' export { spawnDefinition } from './spawn' +export { wallDefinition } from './wall' diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index d88b71c5..50a43a16 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -90,9 +90,28 @@ export const wallDefinition: NodeDefinition = { parametrics: wallParametrics, - // No `geometry` / `renderer` / `system` / `tool` fields yet — see file - // header. Adding them registers wall via `builtinPlugin.nodes` and flips - // the dispatch shims; do that in milestone B once the runtime port lands. + // Wall's renderer is the thin placeholder-mesh mount point from milestone + // B; the system bundle composes the legacy `WallSystem` + `WallCutout` + // re-exported from viewer (so we don't duplicate ~970 lines of CSG / + // mitering / cutaway logic just to swap the dispatch). The legacy + // mount in `` short-circuits the moment + // `nodeRegistry.has('wall')` is true. + // + // No `tool` yet — wall placement / endpoint drag / curve drag remain + // bespoke until the affordance port lands. Phase 0 shims keep the legacy + // wall tool running while wall is registered (it's not wired through the + // registry tool dispatch). + renderer: { + kind: 'parametric', + module: () => import('./renderer'), + }, + system: { + module: () => import('./system'), + // Priority 4 mirrors the legacy WallSystem's useFrame priority — keeps + // miter cascade running after door/window animation systems (priority 2) + // but before zone/level systems that read wall positions. + priority: 4, + }, presentation: { label: 'Wall', diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx new file mode 100644 index 00000000..c1afab09 --- /dev/null +++ b/packages/nodes/src/wall/renderer.tsx @@ -0,0 +1,85 @@ +'use client' + +import { useRegistry, useScene, type WallNode } from '@pascal-app/core' +import { getVisibleWallMaterials, NodeRenderer, useNodeEvents } from '@pascal-app/viewer' +import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three' + +/** + * Thin wall renderer. + * + * Mounts a placeholder mesh, registers it with `sceneRegistry`, marks the + * node dirty so `WallSystem` fills the geometry on the next frame, and + * recursively renders hosted children (doors / windows / wall-mounted + * items) inside the wall's local frame. + * + * Behaviorally identical to the legacy `WallRenderer` in + * `@pascal-app/viewer/components/renderers/wall/wall-renderer.tsx` — same + * placeholder geometry, same material lookup, same dirty-mark on mount. + * Phase 6 deletes the legacy file; until then both coexist and the Phase 0 + * shims pick which one renders based on `nodeRegistry.has('wall')`. + * + * No `geometry` field on the wall definition yet — wall's geometry depends + * on level-batch miter data (see `WallSystem.calculateLevelMiters`), which + * doesn't fit the generic `(node, ctx) => Group` shape without `ctx.levelData`. + * That decision lands in a later milestone; for now the system retains + * ownership of the rebuild loop. + */ +function createEmptyWallGeometry(): BufferGeometry { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([], 3)) + geometry.addGroup(0, 0, 0) + geometry.addGroup(0, 0, 1) + geometry.addGroup(0, 0, 2) + return geometry +} + +const WallRenderer = ({ node }: { node: WallNode }) => { + const ref = useRef(null!) + const placeholderGeometry = useMemo(createEmptyWallGeometry, []) + const collisionPlaceholderGeometry = useMemo(() => { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([], 3)) + return geometry + }, []) + + useRegistry(node.id, 'wall', ref) + + useLayoutEffect(() => { + useScene.getState().markDirty(node.id) + }, [node.id]) + + useEffect(() => { + return () => { + placeholderGeometry.dispose() + collisionPlaceholderGeometry.dispose() + } + }, [collisionPlaceholderGeometry, placeholderGeometry]) + + const handlers = useNodeEvents(node, 'wall') + const material = getVisibleWallMaterials(node) + + return ( + + + + {node.children.map((childId) => ( + + ))} + + ) +} + +export default WallRenderer diff --git a/packages/nodes/src/wall/system.tsx b/packages/nodes/src/wall/system.tsx new file mode 100644 index 00000000..c9161303 --- /dev/null +++ b/packages/nodes/src/wall/system.tsx @@ -0,0 +1,38 @@ +'use client' + +import { WallCutout, WallSystem } from '@pascal-app/viewer' + +/** + * Registry-driven wall system bundle. + * + * Wall has two per-frame concerns that need to mount when the kind is + * registry-driven: + * + * - **`WallSystem`** — reads `dirtyNodes`, batches by level, runs + * `calculateLevelMiters(levelWalls)`, rebuilds geometry via + * `generateExtrudedWall(node, children, miterData, slabElevation)`, + * and cascades to adjacent walls that share a junction. This is the + * bulk of the wall runtime (~820 lines in viewer). + * - **`WallCutout`** — cutaway-mode hide/show logic based on camera + * direction and `frontSide` / `backSide` interior/exterior tags. + * + * Both already live in `@pascal-app/viewer` and are wrapped in + * `` at the legacy mount point. When the + * `wall` kind appears in `nodeRegistry`, those wrappers short-circuit and + * this bundle takes over the mount via `RegisteredSystems`. The + * components themselves are unchanged — no logic duplication during + * Phase 3. + * + * Phase 6 deletes the legacy `` wrappers; until + * then this file is the single mount surface for wall's per-frame work. + */ +const WallSystems = () => { + return ( + <> + + + + ) +} + +export default WallSystems diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 8a7ee8a6..23d7f96c 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -1,3 +1,8 @@ +// `NodeRenderer` is the recursive dispatch component used by parent +// renderers (wall renders doors/windows, slab renders hosted items). +// Public so registry-driven kinds can compose children without reaching +// into viewer's internal paths. +export { NodeRenderer } from './components/renderers/node-renderer' export { default as Viewer } from './components/viewer' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export { @@ -29,4 +34,10 @@ export { InteractiveSystem } from './systems/interactive/interactive-system' export { snapLevelsToTruePositions } from './systems/level/level-utils' export { getRoofMaterialArray } from './systems/roof/roof-materials' export { getStairBodyMaterials, getStairRailingMaterial } from './systems/stair/stair-materials' +export { WallCutout } from './systems/wall/wall-cutout' export { getVisibleWallMaterials } from './systems/wall/wall-materials' +// Wall internals re-exported so `@pascal-app/nodes`' registry-driven wall +// definition can compose them into `def.system` without duplicating the +// 800+ lines of CSG / mitering logic during Phase 3. These exports are +// removed in Phase 6 when the legacy mount points are deleted. +export { WallSystem } from './systems/wall/wall-system'