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:
co-authored by
Claude Opus 4.7
parent
0a723fa1f2
commit
02aeca8439
@@ -1,6 +1,7 @@
|
|||||||
import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
|
import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
|
||||||
import { shelfDefinition } from './shelf'
|
import { shelfDefinition } from './shelf'
|
||||||
import { spawnDefinition } from './spawn'
|
import { spawnDefinition } from './spawn'
|
||||||
|
import { isWallRegistryEnabled, wallDefinition } from './wall'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Built-in plugin bundling every node kind shipped with the Pascal editor.
|
* 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
|
* present in viewer/editor packages but short-circuited by the Phase 0
|
||||||
* dispatch shims (`nodeRegistry.has('spawn')` is true → legacy path
|
* dispatch shims (`nodeRegistry.has('spawn')` is true → legacy path
|
||||||
* yields). Legacy spawn files are deleted in a follow-up PR.
|
* 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 = {
|
export const builtinPlugin: Plugin = {
|
||||||
id: 'pascal:core',
|
id: 'pascal:core',
|
||||||
apiVersion: 1,
|
apiVersion: 1,
|
||||||
nodes: [
|
nodes: [
|
||||||
shelfDefinition as unknown as AnyNodeDefinition,
|
shelfDefinition as unknown as AnyNodeDefinition,
|
||||||
spawnDefinition as unknown as AnyNodeDefinition,
|
spawnDefinition as unknown as AnyNodeDefinition,
|
||||||
|
...wallEntries,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export { shelfDefinition } from './shelf'
|
export { shelfDefinition } from './shelf'
|
||||||
export { spawnDefinition } from './spawn'
|
export { spawnDefinition } from './spawn'
|
||||||
|
export { wallDefinition } from './wall'
|
||||||
|
|||||||
@@ -90,9 +90,28 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
|||||||
|
|
||||||
parametrics: wallParametrics,
|
parametrics: wallParametrics,
|
||||||
|
|
||||||
// No `geometry` / `renderer` / `system` / `tool` fields yet — see file
|
// Wall's renderer is the thin placeholder-mesh mount point from milestone
|
||||||
// header. Adding them registers wall via `builtinPlugin.nodes` and flips
|
// B; the system bundle composes the legacy `WallSystem` + `WallCutout`
|
||||||
// the dispatch shims; do that in milestone B once the runtime port lands.
|
// 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: {
|
presentation: {
|
||||||
label: 'Wall',
|
label: 'Wall',
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 { default as Viewer } from './components/viewer'
|
||||||
export type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
|
export type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
|
||||||
export {
|
export {
|
||||||
@@ -29,4 +34,10 @@ export { InteractiveSystem } from './systems/interactive/interactive-system'
|
|||||||
export { snapLevelsToTruePositions } from './systems/level/level-utils'
|
export { snapLevelsToTruePositions } from './systems/level/level-utils'
|
||||||
export { getRoofMaterialArray } from './systems/roof/roof-materials'
|
export { getRoofMaterialArray } from './systems/roof/roof-materials'
|
||||||
export { getStairBodyMaterials, getStairRailingMaterial } from './systems/stair/stair-materials'
|
export { getStairBodyMaterials, getStairRailingMaterial } from './systems/stair/stair-materials'
|
||||||
|
export { WallCutout } from './systems/wall/wall-cutout'
|
||||||
export { getVisibleWallMaterials } from './systems/wall/wall-materials'
|
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'
|
||||||
|
|||||||
Reference in New Issue
Block a user