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:
co-authored by
Claude Opus 4.7
parent
713ef5009e
commit
9883f1cdc1
@@ -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.',
|
||||
},
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { fenceDefinition } from './definition'
|
||||
export { isFenceRegistryEnabled } from './feature-flag'
|
||||
export { FenceNode } from './schema'
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user