Wall Phase 3 milestone B: runtime port behind feature flag

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
  <WallSystem /> + <WallCutout /> (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:
    * <LegacySystem kind="wall"> around WallSystem returns null
    * <LegacySystem kind="wall"> around WallCutout returns null
    * <NodeRenderer> 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) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 09:00:15 -04:00
co-authored by Claude Opus 4.7
parent 0a723fa1f2
commit 02aeca8439
5 changed files with 172 additions and 3 deletions
+16
View File
@@ -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
* `<LegacySystem kind="wall">` 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'
+22 -3
View File
@@ -90,9 +90,28 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
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 `<LegacySystem kind="wall">` 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',
+85
View File
@@ -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<Mesh>(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 (
<mesh
castShadow
geometry={placeholderGeometry}
material={material}
receiveShadow
ref={ref}
visible={node.visible}
>
<mesh
geometry={collisionPlaceholderGeometry}
name="collision-mesh"
visible={false}
{...handlers}
/>
{node.children.map((childId) => (
<NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} />
))}
</mesh>
)
}
export default WallRenderer
+38
View File
@@ -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
* `<LegacySystem kind="wall">` 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 `<LegacySystem kind="wall">` wrappers; until
* then this file is the single mount surface for wall's per-frame work.
*/
const WallSystems = () => {
return (
<>
<WallSystem />
<WallCutout />
</>
)
}
export default WallSystems