Phase 5 Stage E: fence / slab / ceiling drop their legacy panels

Three flavors of Stage E in one PR:

- **Fence** — fully auto-derived. Adds `kind: 'boolean'` to ParamField
  (rendered as ToggleControl), wires `showInfill` through
  `def.parametrics`. Legacy `FencePanel` deleted; the auto-derived
  `<ParametricInspector>` now drives fence editing entirely.

- **Slab / Ceiling** — kind-owned via `parametrics.customPanel`. The
  legacy panels have shape-specific bits (elevation/height presets,
  area display, holes list with auto-vs-manual provenance) that don't
  fit the auto-derived field model yet. `<ParametricInspector>` learns
  to lazy-load and mount `parametrics.customPanel` when present;
  legacy `SlabPanel` + `CeilingPanel` files relocate to
  `nodes/src/<kind>/panel.tsx` and the legacy copies delete.

  When `list` / `computed` / `action` field kinds eventually graduate
  to auto-derived support, these custom panels collapse back into
  `parametrics.groups`. The plan calls this out under "Custom-behavior
  escape hatch" and Foot-gun 5 of the recipe.

Public-surface additions in `@pascal-app/editor`:
- `ActionButton`, `ActionGroup`, `PanelSection`, `SegmentedControl`,
  `ToggleControl`, `PanelWrapper` — needed by the kind-owned panels.

Per-kind progress table: fence/slab/ceiling all flip to E .

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-18 10:04:11 -04:00
co-authored by Claude Opus 4.7
parent c3e255b58a
commit 3c37ff009b
10 changed files with 108 additions and 268 deletions
+1
View File
@@ -371,6 +371,7 @@ export type ParamField<N> =
visibleIf?: (n: N) => boolean visibleIf?: (n: N) => boolean
customEditor?: ComponentType customEditor?: ComponentType
} }
| { key: keyof N; kind: 'boolean'; visibleIf?: (n: N) => boolean }
| { key: keyof N; kind: 'enum'; options: readonly string[]; visibleIf?: (n: N) => boolean } | { key: keyof N; kind: 'enum'; options: readonly string[]; visibleIf?: (n: N) => boolean }
| { key: keyof N; kind: 'vec3'; visibleIf?: (n: N) => boolean } | { key: keyof N; kind: 'vec3'; visibleIf?: (n: N) => boolean }
| { key: keyof N; kind: 'color'; visibleIf?: (n: N) => boolean } | { key: keyof N; kind: 'color'; visibleIf?: (n: N) => boolean }
@@ -1,237 +0,0 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type FenceNode,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallCurveLength,
type MaterialSchema,
normalizeWallCurveOffset,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
type FenceStyleValue = 'slat' | 'rail' | 'privacy'
type FenceBaseStyleValue = 'grounded' | 'floating'
const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyleValue }[] = [
{ label: 'Slat', value: 'slat' },
{ label: 'Rail', value: 'rail' },
{ label: 'Privacy', value: 'privacy' },
]
const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyleValue }[] = [
{ label: 'Grounded', value: 'grounded' },
{ label: 'Floating', value: 'floating' },
]
export function FencePanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingFence = useEditor((s) => s.setCurvingFence)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
)
// Mirror the latest node into a ref so the slider handlers below have
// stable identities across re-renders. Without this, every store tick
// (one per pointermove during a slider drag) rebuilt the handler
// refs, which destabilised SliderControl's pointer-capture listeners
// and combined with float drift in `getWallCurveLength` produced a
// "Maximum update depth exceeded" cascade.
const nodeRef = useRef(node)
nodeRef.current = node
const handleUpdate = useCallback(
(updates: Partial<FenceNode>) => {
if (!selectedId) return
useScene.getState().updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId],
)
const handleUpdateLength = useCallback(
(newLength: number) => {
const n = nodeRef.current
if (!n || newLength <= 0) return
const dx = n.end[0] - n.start[0]
const dz = n.end[1] - n.start[1]
const currentLength = Math.sqrt(dx * dx + dz * dz)
if (currentLength === 0) return
const dirX = dx / currentLength
const dirZ = dz / currentLength
const newEnd: [number, number] = [
n.start[0] + dirX * newLength,
n.start[1] + dirZ * newLength,
]
handleUpdate({ end: newEnd })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
const length = getWallCurveLength(node)
const curveOffset = getClampedWallCurveOffset(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
return (
<PanelWrapper
icon="/icons/build.png"
onClose={handleClose}
title={node.name || 'Fence'}
width={300}
>
<PanelSection title="Style">
<SegmentedControl
onChange={(value) => handleUpdate({ style: value })}
options={FENCE_STYLE_OPTIONS}
value={node.style}
/>
<SegmentedControl
className="mt-2"
onChange={(value) => handleUpdate({ baseStyle: value })}
options={FENCE_BASE_STYLE_OPTIONS}
value={node.baseStyle}
/>
<ToggleControl
checked={node.showInfill ?? true}
className="mt-2"
label="Fence Infill"
onChange={(checked) => handleUpdate({ showInfill: checked })}
/>
</PanelSection>
<PanelSection title="Dimensions">
<SliderControl
label="Length"
max={50}
min={0.1}
onChange={handleUpdateLength}
precision={2}
step={0.01}
unit="m"
value={length}
/>
<SliderControl
label="Curve"
max={Math.max(0.01, maxCurveOffset)}
min={-Math.max(0.01, maxCurveOffset)}
onChange={(value) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })}
precision={2}
step={0.1}
unit="m"
value={Math.round(curveOffset * 100) / 100}
/>
<SliderControl
label="Height"
max={4}
min={0.4}
onChange={(value) => handleUpdate({ height: Math.max(0.4, value) })}
precision={2}
step={0.05}
unit="m"
value={node.height}
/>
<SliderControl
label="Thickness"
max={0.5}
min={0.03}
onChange={(value) => handleUpdate({ thickness: Math.max(0.03, value) })}
precision={3}
step={0.005}
unit="m"
value={node.thickness}
/>
</PanelSection>
<PanelSection title="Structure">
<SliderControl
label="Base Height"
max={1}
min={0.04}
onChange={(value) => handleUpdate({ baseHeight: Math.max(0.04, value) })}
precision={3}
step={0.01}
unit="m"
value={node.baseHeight}
/>
<SliderControl
label="Top Rail"
max={0.25}
min={0.01}
onChange={(value) => handleUpdate({ topRailHeight: Math.max(0.01, value) })}
precision={3}
step={0.005}
unit="m"
value={node.topRailHeight}
/>
<SliderControl
label="Post Spacing"
max={5}
min={0.2}
onChange={(value) => handleUpdate({ postSpacing: Math.max(0.2, value) })}
precision={2}
step={0.05}
unit="m"
value={node.postSpacing}
/>
<SliderControl
label="Post Size"
max={0.4}
min={0.01}
onChange={(value) => handleUpdate({ postSize: Math.max(0.01, value) })}
precision={3}
step={0.005}
unit="m"
value={node.postSize}
/>
<SliderControl
label="Ground Clear"
max={0.6}
min={0}
onChange={(value) => handleUpdate({ groundClearance: Math.max(0, value) })}
precision={3}
step={0.005}
unit="m"
value={node.groundClearance}
/>
<SliderControl
label="Edge Inset"
max={0.25}
min={0.005}
onChange={(value) => handleUpdate({ edgeInset: Math.max(0.005, value) })}
precision={3}
step={0.005}
unit="m"
value={node.edgeInset}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -24,11 +24,9 @@ import { useCallback, useEffect, useState } from 'react'
import { useIsMobile } from '../../../hooks/use-mobile' import { useIsMobile } from '../../../hooks/use-mobile'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CeilingPanel } from './ceiling-panel'
import { ColumnPanel } from './column-panel' import { ColumnPanel } from './column-panel'
import { DoorPanel } from './door-panel' import { DoorPanel } from './door-panel'
import { ElevatorPanel } from './elevator-panel' import { ElevatorPanel } from './elevator-panel'
import { FencePanel } from './fence-panel'
import { ItemPanel } from './item-panel' import { ItemPanel } from './item-panel'
import { MobilePanelSheet } from './mobile-panel-sheet' import { MobilePanelSheet } from './mobile-panel-sheet'
import { MobileSelectionBar } from './mobile-selection-bar' import { MobileSelectionBar } from './mobile-selection-bar'
@@ -38,7 +36,6 @@ import { ParametricInspector } from './parametric-inspector'
import { ReferencePanel } from './reference-panel' import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel' import { RoofPanel } from './roof-panel'
import { RoofSegmentPanel } from './roof-segment-panel' import { RoofSegmentPanel } from './roof-segment-panel'
import { SlabPanel } from './slab-panel'
import { SpawnPanel } from './spawn-panel' import { SpawnPanel } from './spawn-panel'
import { StairPanel } from './stair-panel' import { StairPanel } from './stair-panel'
import { StairSegmentPanel } from './stair-segment-panel' import { StairSegmentPanel } from './stair-segment-panel'
@@ -95,18 +92,12 @@ function panelForType(type: string | null) {
return <StairPanel /> return <StairPanel />
case 'stair-segment': case 'stair-segment':
return <StairSegmentPanel /> return <StairSegmentPanel />
case 'slab':
return <SlabPanel />
case 'spawn': case 'spawn':
return <SpawnPanel /> return <SpawnPanel />
case 'ceiling':
return <CeilingPanel />
case 'column': case 'column':
return <ColumnPanel /> return <ColumnPanel />
case 'wall': case 'wall':
return <WallPanel /> return <WallPanel />
case 'fence':
return <FencePanel />
case 'door': case 'door':
return <DoorPanel /> return <DoorPanel />
case 'elevator': case 'elevator':
@@ -9,12 +9,13 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Move, Trash2 } from 'lucide-react' import { Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { type ComponentType, lazy, Suspense, useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
/** /**
@@ -75,6 +76,21 @@ export function ParametricInspector() {
if (!selectedId || !def || !parametrics) return null if (!selectedId || !def || !parametrics) return null
// `parametrics.customPanel` escape hatch — kind owns its panel
// entirely (loaded lazily so the bundle isn't eager). Used by kinds
// whose editor has non-parametric concerns (slab holes list, ceiling
// height presets, etc.) until per-field `customEditor` + missing
// field kinds (list/action/computed) graduate the auto-derived
// panel to cover them.
if (parametrics.customPanel) {
const CustomPanel = resolveCustomPanel(parametrics.customPanel)
return (
<Suspense fallback={null}>
<CustomPanel />
</Suspense>
)
}
const presentation = def.presentation const presentation = def.presentation
const title = presentation?.label ?? nodeType ?? '' const title = presentation?.label ?? nodeType ?? ''
const canMove = !!def.capabilities.movable const canMove = !!def.capabilities.movable
@@ -115,6 +131,18 @@ export function ParametricInspector() {
) )
} }
// Cache lazy custom panel components by their loader so React.lazy isn't
// re-invoked across renders.
const customPanelCache = new WeakMap<() => Promise<unknown>, ComponentType>()
function resolveCustomPanel(loader: () => Promise<{ default: ComponentType<any> }>): ComponentType {
const cached = customPanelCache.get(loader)
if (cached) return cached
const Comp = lazy(loader)
customPanelCache.set(loader, Comp as ComponentType)
return Comp as ComponentType
}
// ─── Per-field renderers ───────────────────────────────────────────── // ─── Per-field renderers ─────────────────────────────────────────────
interface FieldRendererProps { interface FieldRendererProps {
@@ -163,6 +191,17 @@ function FieldRenderer({ field, nodeId, onUpdate }: FieldRendererProps) {
) )
} }
case 'boolean': {
const checked = value === true
return (
<ToggleControl
checked={checked}
label={prettifyKey(key)}
onChange={(next) => onUpdate({ [key]: next } as Partial<AnyNode>)}
/>
)
}
case 'enum': { case 'enum': {
const str = typeof value === 'string' ? value : (field.options[0] ?? '') const str = typeof value === 'string' ? value : (field.options[0] ?? '')
return ( return (
+8
View File
@@ -32,9 +32,17 @@ export {
export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions' export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions'
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles' export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
export { useCommandPalette } from './components/ui/command-palette' export { useCommandPalette } from './components/ui/command-palette'
export { ActionButton, ActionGroup } from './components/ui/controls/action-button'
export { PanelSection } from './components/ui/controls/panel-section'
export { SegmentedControl } from './components/ui/controls/segmented-control'
export { SliderControl } from './components/ui/controls/slider-control' export { SliderControl } from './components/ui/controls/slider-control'
export { ToggleControl } from './components/ui/controls/toggle-control'
export { FloatingLevelSelector } from './components/ui/floating-level-selector' export { FloatingLevelSelector } from './components/ui/floating-level-selector'
export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items' export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items'
// Phase 5 Stage E — kinds with bespoke editors (slab holes list,
// ceiling height presets, etc.) use `parametrics.customPanel` to mount
// a kind-owned panel and need PanelWrapper for the chrome.
export { PanelWrapper } from './components/ui/panels/panel-wrapper'
export { PALETTE_COLORS } from './components/ui/primitives/color-dot' export { PALETTE_COLORS } from './components/ui/primitives/color-dot'
export { useSidebarStore } from './components/ui/primitives/sidebar' export { useSidebarStore } from './components/ui/primitives/sidebar'
export { Slider } from './components/ui/primitives/slider' export { Slider } from './components/ui/primitives/slider'
@@ -1,16 +1,27 @@
'use client' 'use client'
import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core' import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
import {
ActionButton,
ActionGroup,
PanelSection,
PanelWrapper,
SliderControl,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { Edit, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
/**
* Phase 5 Stage E ceiling inspector (kind-owned).
*
* 1:1 port of the legacy `CeilingPanel`. Mounted via
* `parametrics.customPanel`. Same rationale as slab/panel.tsx the
* holes list + height presets need richer field kinds before this
* panel can collapse into auto-derived groups.
*/
export function CeilingPanel() { export function CeilingPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
@@ -111,7 +122,7 @@ export function CeilingPanel() {
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
if (!node) return if (!node) return
sfxEmitter.emit('sfx:item-pick') triggerSFX('sfx:item-pick')
setMovingNode(node) setMovingNode(node)
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection]) }, [node, setMovingNode, setSelection])
@@ -252,3 +263,5 @@ export function CeilingPanel() {
</PanelWrapper> </PanelWrapper>
) )
} }
export default CeilingPanel
+6 -3
View File
@@ -2,9 +2,11 @@ import type { ParametricDescriptor } from '@pascal-app/core'
import type { CeilingNode } from './schema' import type { CeilingNode } from './schema'
/** /**
* Inspector descriptor for ceiling. Polygon + holes are edited via the * Inspector descriptor for ceiling.
* floor-plan boundary / hole editors — not number inputs. Inspector *
* exposes only the per-instance scalar (height). * Mounts the kind-owned `<CeilingPanel>` via `customPanel` — same
* rationale as slab (holes list + height presets need richer field
* kinds before this can collapse into pure parametrics).
*/ */
export const ceilingParametrics: ParametricDescriptor<CeilingNode> = { export const ceilingParametrics: ParametricDescriptor<CeilingNode> = {
groups: [ groups: [
@@ -13,4 +15,5 @@ export const ceilingParametrics: ParametricDescriptor<CeilingNode> = {
fields: [{ key: 'height', kind: 'number', unit: 'm', min: 1.5, max: 6, step: 0.05 }], fields: [{ key: 'height', kind: 'number', unit: 'm', min: 1.5, max: 6, step: 0.05 }],
}, },
], ],
customPanel: () => import('./panel'),
} }
+1
View File
@@ -34,6 +34,7 @@ export const fenceParametrics: ParametricDescriptor<FenceNode> = {
fields: [ fields: [
{ key: 'style', kind: 'enum', options: ['slat', 'rail', 'privacy'] }, { key: 'style', kind: 'enum', options: ['slat', 'rail', 'privacy'] },
{ key: 'baseStyle', kind: 'enum', options: ['floating', 'grounded'] }, { key: 'baseStyle', kind: 'enum', options: ['floating', 'grounded'] },
{ key: 'showInfill', kind: 'boolean' },
{ key: 'color', kind: 'color' }, { key: 'color', kind: 'color' },
], ],
}, },
@@ -1,16 +1,30 @@
'use client' 'use client'
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core' import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
import {
ActionButton,
ActionGroup,
PanelSection,
PanelWrapper,
SliderControl,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { Edit, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
/**
* Phase 5 Stage E slab inspector (kind-owned).
*
* 1:1 port of the legacy `SlabPanel`. Mounted via
* `parametrics.customPanel` because the slab editor has shape-specific
* concerns (elevation presets, area display, holes list with auto-
* vs-manual provenance) that don't fit the auto-derived
* `<ParametricInspector>` field model yet. When the inspector grows
* `list` / `computed` / `action` field kinds, this panel collapses
* into `parametrics.groups`.
*/
export function SlabPanel() { export function SlabPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
@@ -112,7 +126,7 @@ export function SlabPanel() {
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
if (!node) return if (!node) return
sfxEmitter.emit('sfx:item-pick') triggerSFX('sfx:item-pick')
setMovingNode(node) setMovingNode(node)
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection]) }, [node, setMovingNode, setSelection])
@@ -254,3 +268,5 @@ export function SlabPanel() {
</PanelWrapper> </PanelWrapper>
) )
} }
export default SlabPanel
+9 -4
View File
@@ -2,10 +2,14 @@ import type { ParametricDescriptor } from '@pascal-app/core'
import type { SlabNode } from './schema' import type { SlabNode } from './schema'
/** /**
* Inspector descriptor for slab. Polygon + holes are edited via the * Inspector descriptor for slab.
* floor-plan boundary / hole editors — not number inputs. The inspector *
* exposes only the per-instance scalars (elevation + auto-from-walls * Mounts the kind-owned `<SlabPanel>` via `customPanel` — the slab
* toggle). * editor has shape-specific concerns (elevation presets, area display,
* holes list with auto-vs-manual provenance) that don't fit the
* auto-derived field model. `groups` retained as a placeholder for the
* future when `list` / `computed` / `action` field kinds let this
* collapse into pure parametrics.
*/ */
export const slabParametrics: ParametricDescriptor<SlabNode> = { export const slabParametrics: ParametricDescriptor<SlabNode> = {
groups: [ groups: [
@@ -14,4 +18,5 @@ export const slabParametrics: ParametricDescriptor<SlabNode> = {
fields: [{ key: 'elevation', kind: 'number', unit: 'm', min: 0.02, max: 1, step: 0.01 }], fields: [{ key: 'elevation', kind: 'number', unit: 'm', min: 0.02, max: 1, step: 0.01 }],
}, },
], ],
customPanel: () => import('./panel'),
} }