Phase 5 first batch kind: fence migrates to registry behind feature flag

Same shape as wall milestone B — thin renderer + system re-export, no
geometry / floor-plan / tool ports yet (later milestones). Feature flag
NEXT_PUBLIC_USE_REGISTRY_FOR_FENCE gates the dispatch flip.

Files added (packages/nodes/src/fence/):
 - schema.ts: re-exports FenceNode from core.
 - parametrics.ts: dimensions / posts / style fields for the auto-
   inspector. Endpoints + curveOffset edited via tools, not in
   parametrics.
 - feature-flag.ts: mirrors the wall flag pattern.
 - definition.ts: capabilities (snappable + surfaces sides +
   selectable + duplicable + deletable), relations (linkedBy
   endpoint-match, no hosts, no affectsSpatial — matches legacy),
   parametrics, renderer, system, toolHints (Left click / Shift /
   Esc — fence has no helper file today so this adds a panel where
   there wasn't one). Tool field absent: fence has 4 tools (build,
   curve, move, move-endpoint) wired through editor state, not the
   registry dispatch — they keep running unchanged.
 - renderer.tsx: thin placeholder mesh + markDirty on mount + node
   events + DEFAULT_STAIR_MATERIAL (matches legacy material reuse).
   Verification log fires once on first mount.
 - system.tsx: re-exports the legacy FenceSystem from viewer.
   Verification log on mount/unmount confirms the bundle activates.
 - index.ts: barrel.

Files changed:
 - packages/viewer/src/index.ts: new exports for FenceSystem and
   DEFAULT_STAIR_MATERIAL so the @pascal-app/nodes bundle can
   compose them without reaching into viewer internals.
 - packages/viewer/src/components/renderers/fence/fence-renderer.tsx:
   paired one-shot legacy verification log so the dispatch path is
   unambiguous from the browser console.
 - packages/nodes/src/index.ts: conditional fenceEntries appended to
   builtinPlugin.nodes based on isFenceRegistryEnabled. With the flag
   off (default), behavior is unchanged; with it on, Phase 0 shims
   switch fence to the registry path — legacy <FenceRenderer> and
   <LegacySystem kind="fence"><FenceSystem /></LegacySystem> short-
   circuit, the bundled system.tsx re-mounts FenceSystem via
   RegisteredSystems, and the new renderer takes over the dispatch.

No behavior change with the flag off. With it on, behavior should be
byte-identical (same FenceSystem code, same priority, same geometry
path).

Phase 5 batch order continues with slab / ceiling / door / window /
item / etc. as flagged migrations after fence parity signs off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 11:50:41 -04:00
co-authored by Claude Opus 4.7
parent 713ef5009e
commit 9883f1cdc1
10 changed files with 296 additions and 15 deletions
+102
View File
@@ -0,0 +1,102 @@
import type { NodeDefinition } from '@pascal-app/core'
import { fenceParametrics } from './parametrics'
import { FenceNode } from './schema'
/**
* Fence — the first Phase 5 batch-migration kind.
*
* What this definition encodes:
* - **Capabilities**: snappable (other walls/fences/items snap to it),
* surfaces (front + back faces host items), selectable, duplicable,
* deletable. No `movable` capability — fence move is bespoke
* endpoint-drag, same shape as wall (handled by legacy MoveFenceTool
* until the affordance port).
* - **Relations**: `linkedBy: 'endpoint-match'` for fence-corner cascade.
* No `hosts` field — doors/windows don't mount on fences. `affectsSpatial`
* omitted: moving a fence doesn't dirty slabs/zones in the legacy
* behavior, so the registry stays parity-equivalent until we
* explicitly add the cascade (separate decision).
* - **Parametrics**: dimensions, posts, style — see `./parametrics.ts`.
* - **toolHints**: placement panel hints for the fence-build tool.
* - **Renderer + system**: thin placeholder mesh + re-export of the
* legacy `FenceSystem`. Same shape as wall milestone B; future Phase 5+
* extracts the pure geometry function and migrates to `def.geometry`.
*
* Tool field stays absent: fence has 4 separate tools (build, curve,
* move, move-endpoint) wired through editor state, not the registry
* tool dispatch. They keep running unchanged until the affordance port.
*
* Migration is gated by `feature-flag.ts`
* (env: `NEXT_PUBLIC_USE_REGISTRY_FOR_FENCE`). See
* `plans/editor-node-registry.md#phase-5` for the batch order.
*/
export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
kind: 'fence',
schemaVersion: 1,
schema: FenceNode,
category: 'structure',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
start: [0, 0],
end: [3, 0],
height: 1.8,
thickness: 0.08,
baseHeight: 0.22,
postSpacing: 2,
postSize: 0.1,
topRailHeight: 0.04,
groundClearance: 0,
edgeInset: 0.015,
baseStyle: 'grounded',
showInfill: true,
color: '#ffffff',
style: 'slat',
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
surfaces: { sides: { faces: 'all' } },
duplicable: true,
deletable: true,
},
relations: {
linkedBy: 'endpoint-match',
cascadeDelete: 'none',
},
parametrics: fenceParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
// Same frame priority as the legacy FenceSystem (4 — runs after door/
// window animations at 2-3, before zone/level systems at 6+).
priority: 4,
},
toolHints: [
{ key: 'Left click', label: 'Set fence start / end' },
{ key: 'Shift', label: 'Allow non-45° angles' },
{ key: 'Esc', label: 'Cancel' },
],
presentation: {
label: 'Fence',
description: 'A straight or curved fence segment with configurable posts and infill.',
icon: { kind: 'iconify', name: 'lucide:fence' },
paletteSection: 'structure',
paletteOrder: 20,
},
mcp: {
description: 'A fence segment defined by start + end points, with optional curve sagitta.',
},
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Feature flag for the registry-driven fence.
*
* Same pattern as wall (Phase 3) and spawn (Phase 2): with the flag on,
* `fenceDefinition` is appended to `builtinPlugin.nodes` and the Phase 0
* dispatch shims hand fence over to the registry. With the flag off,
* the legacy fence paths run unchanged.
*
* Drops the moment Phase 5 fence parity is signed off.
*/
export const isFenceRegistryEnabled = (): boolean => {
return process.env.NEXT_PUBLIC_USE_REGISTRY_FOR_FENCE === 'true'
}
+3
View File
@@ -0,0 +1,3 @@
export { fenceDefinition } from './definition'
export { isFenceRegistryEnabled } from './feature-flag'
export { FenceNode } from './schema'
+41
View File
@@ -0,0 +1,41 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { FenceNode } from './schema'
/**
* Inspector descriptor for fence.
*
* Mirrors the legacy `fence-panel.tsx` controls but rendered by the
* generic `<ParametricInspector>`. Endpoints (`start` / `end`) and
* `curveOffset` are edited via floor-plan affordances and 3D handles,
* not number inputs — kept out of parametrics.
*/
export const fenceParametrics: ParametricDescriptor<FenceNode> = {
groups: [
{
label: 'Dimensions',
fields: [
{ key: 'height', kind: 'number', unit: 'm', min: 0.4, max: 3.5, step: 0.05 },
{ key: 'thickness', kind: 'number', unit: 'm', min: 0.02, max: 0.3, step: 0.005 },
{ key: 'baseHeight', kind: 'number', unit: 'm', min: 0, max: 0.6, step: 0.01 },
{ key: 'groundClearance', kind: 'number', unit: 'm', min: 0, max: 0.5, step: 0.01 },
],
},
{
label: 'Posts',
fields: [
{ key: 'postSpacing', kind: 'number', unit: 'm', min: 0.5, max: 5, step: 0.1 },
{ key: 'postSize', kind: 'number', unit: 'm', min: 0.04, max: 0.4, step: 0.01 },
{ key: 'topRailHeight', kind: 'number', unit: 'm', min: 0, max: 0.2, step: 0.005 },
{ key: 'edgeInset', kind: 'number', unit: 'm', min: 0, max: 0.1, step: 0.005 },
],
},
{
label: 'Style',
fields: [
{ key: 'style', kind: 'enum', options: ['slat', 'rail', 'privacy'] },
{ key: 'baseStyle', kind: 'enum', options: ['floating', 'grounded'] },
{ key: 'color', kind: 'color' },
],
},
],
}
+54
View File
@@ -0,0 +1,54 @@
'use client'
import { type FenceNode, useRegistry, useScene } from '@pascal-app/core'
import { DEFAULT_STAIR_MATERIAL, useNodeEvents } from '@pascal-app/viewer'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
/**
* Thin fence renderer — registers an empty mesh, marks the node dirty so
* `FenceSystem` (re-exported via `./system`) fills the geometry next
* frame, and wires pointer events through `useNodeEvents`.
*
* Behaviorally identical to the legacy `FenceRenderer` in
* `@pascal-app/viewer/components/renderers/fence/fence-renderer.tsx`.
* Phase 0 shims pick which one mounts based on `nodeRegistry.has('fence')`.
*
* Material is `DEFAULT_STAIR_MATERIAL` (legacy reuse; fence and stairs
* share the wood-tone preset).
*/
let didLogFirstRegistryFenceMount = false
const FenceRenderer = ({ node }: { node: FenceNode }) => {
const ref = useRef<Mesh>(null!)
const handlers = useNodeEvents(node, 'fence')
const material = useMemo(() => DEFAULT_STAIR_MATERIAL, [])
useRegistry(node.id, 'fence', ref)
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])
useEffect(() => {
if (didLogFirstRegistryFenceMount) return
didLogFirstRegistryFenceMount = true
console.info(
'[fence:registry] first registry-driven FenceRenderer mounted — legacy FenceRenderer is NOT in use',
)
}, [])
return (
<mesh
castShadow
material={material}
receiveShadow
ref={ref}
visible={node.visible}
{...handlers}
>
<boxGeometry args={[0, 0, 0]} />
</mesh>
)
}
export default FenceRenderer
+9
View File
@@ -0,0 +1,9 @@
/**
* Fence schema re-export.
*
* Lives in `@pascal-app/core` for now (same as wall + door + window +
* spawn — the canonical schemas stay there until Phase 6 derives them
* from the registry). The registry definition consumes it here so the
* rest of the bundle imports a single canonical type.
*/
export { FenceNode } from '@pascal-app/core'
+35
View File
@@ -0,0 +1,35 @@
'use client'
import { FenceSystem } from '@pascal-app/viewer'
import { useEffect } from 'react'
/**
* Registry-driven fence system bundle.
*
* Wraps the legacy `FenceSystem` (re-exported from viewer) so it mounts
* via `RegisteredSystems` when fence is registry-driven. The legacy
* `<LegacySystem kind="fence">` wrapper around `<FenceSystem />` in
* `viewer/components/viewer/index.tsx` short-circuits whenever
* `nodeRegistry.has('fence')` is true — same pattern wall used in
* milestone B.
*
* Phase 6 deletes the legacy mount point; until then this bundle is the
* single mount surface for fence's per-frame work when registry-driven.
*
* Future Phase 5+ work: extract fence geometry out of `FenceSystem`'s
* useFrame body into a pure `buildFenceGeometry(node, ctx)` and migrate
* to `def.geometry`. The generic `<GeometrySystem>` will then handle
* the rebuild loop and this bundle can be deleted.
*/
const FenceSystems = () => {
useEffect(() => {
console.info('[fence:registry] system bundle mounted — registry path active')
return () => {
console.info('[fence:registry] system bundle unmounted')
}
}, [])
return <FenceSystem />
}
export default FenceSystems
+19 -14
View File
@@ -1,4 +1,5 @@
import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
import { fenceDefinition, isFenceRegistryEnabled } from './fence'
import { shelfDefinition } from './shelf'
import { spawnDefinition } from './spawn'
import { isWallRegistryEnabled, wallDefinition } from './wall'
@@ -14,25 +15,27 @@ import { isWallRegistryEnabled, wallDefinition } from './wall'
* `loadPlugin` call path. This is intentional: the API is stress-tested
* by built-ins before any third-party plugin lands.
*
* Phase 2 status: shelf is a brand-new kind. Spawn is migrated to the
* registry path — the legacy SpawnRenderer / SpawnTool files are still
* 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.
* Status by kind:
* - **shelf**: brand-new kind, registry-driven, no legacy. Registered
* unconditionally.
* - **spawn**: migrated to the registry path during Phase 2. Legacy
* SpawnRenderer / SpawnTool files still present in viewer/editor but
* short-circuited by the Phase 0 shims. Registered unconditionally.
* - **wall**: registry-driven behind `NEXT_PUBLIC_USE_REGISTRY_FOR_WALL`.
* Phase 3 stress test; flag drops when fixture parity signs off.
* - **fence**: registry-driven behind `NEXT_PUBLIC_USE_REGISTRY_FOR_FENCE`.
* First Phase 5 batch-migration kind. Same shape as wall (thin
* renderer + system re-export); pure geometry / floor-plan / tool
* affordance ports as later milestones.
*/
const wallEntries: AnyNodeDefinition[] = isWallRegistryEnabled()
? [wallDefinition as unknown as AnyNodeDefinition]
: []
const fenceEntries: AnyNodeDefinition[] = isFenceRegistryEnabled()
? [fenceDefinition as unknown as AnyNodeDefinition]
: []
export const builtinPlugin: Plugin = {
id: 'pascal:core',
apiVersion: 1,
@@ -40,9 +43,11 @@ export const builtinPlugin: Plugin = {
shelfDefinition as unknown as AnyNodeDefinition,
spawnDefinition as unknown as AnyNodeDefinition,
...wallEntries,
...fenceEntries,
],
}
export { fenceDefinition } from './fence'
export { shelfDefinition } from './shelf'
export { spawnDefinition } from './spawn'
export { wallDefinition } from './wall'
@@ -1,9 +1,15 @@
import { type FenceNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
// Phase 5 verification log — see matching `[fence:registry]` log in
// nodes/src/fence/renderer.tsx. Fires once if the legacy path is active
// (flag off or kind not registered). Drop alongside the legacy file at
// Phase 6 cleanup.
let didLogFirstLegacyFenceMount = false
export const FenceRenderer = ({ node }: { node: FenceNode }) => {
const ref = useRef<Mesh>(null!)
const handlers = useNodeEvents(node, 'fence')
@@ -14,6 +20,14 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => {
useScene.getState().markDirty(node.id)
}, [node.id])
useEffect(() => {
if (didLogFirstLegacyFenceMount) return
didLogFirstLegacyFenceMount = true
console.info(
'[fence:legacy] first legacy FenceRenderer mounted — registry-driven FenceRenderer is NOT in use',
)
}, [])
return (
<mesh
castShadow
+5
View File
@@ -23,6 +23,7 @@ export {
DEFAULT_DOOR_MATERIAL,
DEFAULT_ROOF_MATERIAL,
DEFAULT_SLAB_MATERIAL,
DEFAULT_STAIR_MATERIAL,
DEFAULT_WALL_MATERIAL,
DEFAULT_WINDOW_MATERIAL,
disposeMaterial,
@@ -30,6 +31,10 @@ export {
} from './lib/materials'
export { mergedOutline } from './lib/merged-outline-node'
export { default as useViewer } from './store/use-viewer'
// Fence system follows the wall re-export pattern — composed into the
// registry-driven fence definition's `def.system`. Removed in Phase 6
// alongside the legacy fence mount point.
export { FenceSystem } from './systems/fence/fence-system'
export { InteractiveSystem } from './systems/interactive/interactive-system'
export { snapLevelsToTruePositions } from './systems/level/level-utils'
export { getRoofMaterialArray } from './systems/roof/roof-materials'