From 9883f1cdc15e7b8e1ea033be643d1387ab6f1aeb Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 15 May 2026 11:50:41 -0400 Subject: [PATCH] Phase 5 first batch kind: fence migrates to registry behind feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 and 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) --- packages/nodes/src/fence/definition.ts | 102 ++++++++++++++++++ packages/nodes/src/fence/feature-flag.ts | 13 +++ packages/nodes/src/fence/index.ts | 3 + packages/nodes/src/fence/parametrics.ts | 41 +++++++ packages/nodes/src/fence/renderer.tsx | 54 ++++++++++ packages/nodes/src/fence/schema.ts | 9 ++ packages/nodes/src/fence/system.tsx | 35 ++++++ packages/nodes/src/index.ts | 33 +++--- .../renderers/fence/fence-renderer.tsx | 16 ++- packages/viewer/src/index.ts | 5 + 10 files changed, 296 insertions(+), 15 deletions(-) create mode 100644 packages/nodes/src/fence/definition.ts create mode 100644 packages/nodes/src/fence/feature-flag.ts create mode 100644 packages/nodes/src/fence/index.ts create mode 100644 packages/nodes/src/fence/parametrics.ts create mode 100644 packages/nodes/src/fence/renderer.tsx create mode 100644 packages/nodes/src/fence/schema.ts create mode 100644 packages/nodes/src/fence/system.tsx diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts new file mode 100644 index 00000000..c9ed499c --- /dev/null +++ b/packages/nodes/src/fence/definition.ts @@ -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 = { + 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.', + }, +} diff --git a/packages/nodes/src/fence/feature-flag.ts b/packages/nodes/src/fence/feature-flag.ts new file mode 100644 index 00000000..74123a65 --- /dev/null +++ b/packages/nodes/src/fence/feature-flag.ts @@ -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' +} diff --git a/packages/nodes/src/fence/index.ts b/packages/nodes/src/fence/index.ts new file mode 100644 index 00000000..d79345fe --- /dev/null +++ b/packages/nodes/src/fence/index.ts @@ -0,0 +1,3 @@ +export { fenceDefinition } from './definition' +export { isFenceRegistryEnabled } from './feature-flag' +export { FenceNode } from './schema' diff --git a/packages/nodes/src/fence/parametrics.ts b/packages/nodes/src/fence/parametrics.ts new file mode 100644 index 00000000..9c821eb3 --- /dev/null +++ b/packages/nodes/src/fence/parametrics.ts @@ -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 ``. 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 = { + 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' }, + ], + }, + ], +} diff --git a/packages/nodes/src/fence/renderer.tsx b/packages/nodes/src/fence/renderer.tsx new file mode 100644 index 00000000..7503030b --- /dev/null +++ b/packages/nodes/src/fence/renderer.tsx @@ -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(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 ( + + + + ) +} + +export default FenceRenderer diff --git a/packages/nodes/src/fence/schema.ts b/packages/nodes/src/fence/schema.ts new file mode 100644 index 00000000..80a3134a --- /dev/null +++ b/packages/nodes/src/fence/schema.ts @@ -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' diff --git a/packages/nodes/src/fence/system.tsx b/packages/nodes/src/fence/system.tsx new file mode 100644 index 00000000..62954971 --- /dev/null +++ b/packages/nodes/src/fence/system.tsx @@ -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 + * `` wrapper around `` 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 `` 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 +} + +export default FenceSystems diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index eb421747..cfd50e62 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -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 - * `` 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' diff --git a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx index e87186c5..192f877e 100644 --- a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx +++ b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx @@ -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(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 (