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
+267
View File
@@ -0,0 +1,267 @@
'use client'
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 { Edit, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useRef } from 'react'
/**
* 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() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole)
const setMovingNode = useEditor((s) => s.setMovingNode)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) : undefined,
)
// Panel slider-drag fix recipe (plans/editor-node-registry.md): stable
// handler refs so slider drags don't trigger Maximum update depth.
const nodeRef = useRef(node)
nodeRef.current = node
const handleUpdate = useCallback(
(updates: Partial<CeilingNode>) => {
if (!selectedId) return
useScene.getState().updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
setEditingHole(null)
}, [setSelection, setEditingHole])
useEffect(() => {
if (!node) {
setEditingHole(null)
}
}, [node, setEditingHole])
useEffect(() => {
return () => {
setEditingHole(null)
}
}, [setEditingHole])
const handleAddHole = useCallback(() => {
if (!(node && selectedId)) return
const polygon = node.polygon
let cx = 0
let cz = 0
for (const [x, z] of polygon) {
cx += x
cz += z
}
cx /= polygon.length
cz /= polygon.length
const holeSize = 0.5
const newHole: Array<[number, number]> = [
[cx - holeSize, cz - holeSize],
[cx + holeSize, cz - holeSize],
[cx + holeSize, cz + holeSize],
[cx - holeSize, cz + holeSize],
]
const currentHoles = node?.holes || []
const currentMetadata = currentHoles.map(
(_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const },
)
handleUpdate({
holes: [...currentHoles, newHole],
holeMetadata: [...currentMetadata, { source: 'manual' }],
})
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole])
const handleEditHole = useCallback(
(index: number) => {
if (!selectedId) return
setEditingHole({ nodeId: selectedId, holeIndex: index })
},
[selectedId, setEditingHole],
)
const handleDeleteHole = useCallback(
(index: number) => {
if (!selectedId) return
const currentHoles = node?.holes || []
if ((node?.holeMetadata?.[index]?.source ?? 'manual') !== 'manual') return
const newHoles = currentHoles.filter((_, i) => i !== index)
const currentMetadata = currentHoles.map(
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
)
const newMetadata = currentMetadata.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
setEditingHole(null)
}
},
[selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
)
const handleMove = useCallback(() => {
if (!node) return
triggerSFX('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
if (!(node && node.type === 'ceiling' && selectedId)) return null
const calculateArea = (polygon: Array<[number, number]>): number => {
if (polygon.length < 3) return 0
let area = 0
const n = polygon.length
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const current = polygon[i]!
const next = polygon[j]!
area += current[0] * next[1]
area -= next[0] * current[1]
}
return Math.abs(area) / 2
}
const area = calculateArea(node.polygon)
return (
<PanelWrapper
icon="/icons/ceiling.png"
onClose={handleClose}
title={node.name || 'Ceiling'}
width={320}
>
<PanelSection title="Height">
<SliderControl
label="Height"
max={6}
min={0}
onChange={(v) => handleUpdate({ height: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.height * 1000) / 1000}
/>
<div className="mt-2 grid grid-cols-3 gap-1.5 px-1 pb-1">
<ActionButton label="Low (2.4m)" onClick={() => handleUpdate({ height: 2.4 })} />
<ActionButton label="Standard (2.5m)" onClick={() => handleUpdate({ height: 2.5 })} />
<ActionButton label="High (3.0m)" onClick={() => handleUpdate({ height: 3.0 })} />
</div>
</PanelSection>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-sm">
<span>Area</span>
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
</div>
</PanelSection>
<PanelSection title="Holes">
{node.holes && node.holes.length > 0 ? (
<div className="flex flex-col gap-1 pb-2">
{node.holes.map((hole, index) => {
const holeArea = calculateArea(hole)
const isEditing =
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
const source = node.holeMetadata?.[index]?.source ?? 'manual'
const isAutoHole = source !== 'manual'
const autoLabel = source === 'elevator' ? 'Auto elevator cutout' : 'Auto stair cutout'
return (
<div
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
isEditing
? 'border-primary/50 bg-primary/10'
: 'border-transparent hover:bg-accent/30'
}`}
key={index}
>
<div className="min-w-0 flex-1">
<p
className={`font-medium text-xs ${isEditing ? 'text-primary' : 'text-white'}`}
>
Hole {index + 1} {isEditing && '(Editing)'}
</p>
<p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
{isAutoHole ? autoLabel : 'Manual'}
</p>
</div>
<div className="flex items-center gap-1">
{isEditing ? (
<ActionButton
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
label="Done"
onClick={() => setEditingHole(null)}
/>
) : isAutoHole ? (
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
Auto
</div>
) : (
<>
<button
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground"
onClick={() => handleEditHole(index)}
type="button"
>
<Edit className="h-3.5 w-3.5" />
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300"
onClick={() => handleDeleteHole(index)}
type="button"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
)
})}
</div>
) : (
<div className="px-2 py-3 text-center text-muted-foreground text-xs">No holes</div>
)}
<div className="px-1 pt-1 pb-1">
<ActionButton
className="w-full"
disabled={editingHole?.nodeId === selectedId}
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Hole"
onClick={handleAddHole}
/>
</div>
</PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
</ActionGroup>
</PanelWrapper>
)
}
export default CeilingPanel
+6 -3
View File
@@ -2,9 +2,11 @@ import type { ParametricDescriptor } from '@pascal-app/core'
import type { CeilingNode } from './schema'
/**
* Inspector descriptor for ceiling. Polygon + holes are edited via the
* floor-plan boundary / hole editors — not number inputs. Inspector
* exposes only the per-instance scalar (height).
* Inspector descriptor for ceiling.
*
* 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> = {
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 }],
},
],
customPanel: () => import('./panel'),
}
+1
View File
@@ -34,6 +34,7 @@ export const fenceParametrics: ParametricDescriptor<FenceNode> = {
fields: [
{ key: 'style', kind: 'enum', options: ['slat', 'rail', 'privacy'] },
{ key: 'baseStyle', kind: 'enum', options: ['floating', 'grounded'] },
{ key: 'showInfill', kind: 'boolean' },
{ key: 'color', kind: 'color' },
],
},
+272
View File
@@ -0,0 +1,272 @@
'use client'
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 { Edit, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useRef } from 'react'
/**
* 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() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole)
const setMovingNode = useEditor((s) => s.setMovingNode)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined,
)
// See "Panel slider-drag fix recipe" in plans/editor-node-registry.md.
// Stable handler refs across re-renders so slider drags don't trigger
// a Maximum update depth cascade on the panel's SliderControls.
const nodeRef = useRef(node)
nodeRef.current = node
const handleUpdate = useCallback(
(updates: Partial<SlabNode>) => {
if (!selectedId) return
useScene.getState().updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
setEditingHole(null)
}, [setSelection, setEditingHole])
useEffect(() => {
if (!node) {
setEditingHole(null)
}
}, [node, setEditingHole])
useEffect(() => {
return () => {
setEditingHole(null)
}
}, [setEditingHole])
const handleAddHole = useCallback(() => {
if (!(node && selectedId)) return
const polygon = node.polygon
let cx = 0
let cz = 0
for (const [x, z] of polygon) {
cx += x
cz += z
}
cx /= polygon.length
cz /= polygon.length
const holeSize = 0.5
const newHole: Array<[number, number]> = [
[cx - holeSize, cz - holeSize],
[cx + holeSize, cz - holeSize],
[cx + holeSize, cz + holeSize],
[cx - holeSize, cz + holeSize],
]
const currentHoles = node?.holes || []
const currentMetadata = currentHoles.map(
(_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const },
)
handleUpdate({
holes: [...currentHoles, newHole],
holeMetadata: [...currentMetadata, { source: 'manual' }],
})
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole])
const handleEditHole = useCallback(
(index: number) => {
if (!selectedId) return
setEditingHole({ nodeId: selectedId, holeIndex: index })
},
[selectedId, setEditingHole],
)
const handleDeleteHole = useCallback(
(index: number) => {
if (!selectedId) return
const currentHoles = node?.holes || []
if ((node?.holeMetadata?.[index]?.source ?? 'manual') !== 'manual') return
const newHoles = currentHoles.filter((_, i) => i !== index)
const currentMetadata = currentHoles.map(
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
)
const newMetadata = currentMetadata.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
setEditingHole(null)
}
},
[selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
)
const handleMove = useCallback(() => {
if (!node) return
triggerSFX('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
if (!(node && node.type === 'slab' && selectedId)) return null
const calculateArea = (polygon: Array<[number, number]>): number => {
if (polygon.length < 3) return 0
let area = 0
const n = polygon.length
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const current = polygon[i]
const next = polygon[j]
if (!(current && next)) continue
area += current[0] * next[1]
area -= next[0] * current[1]
}
return Math.abs(area) / 2
}
const area = calculateArea(node.polygon)
return (
<PanelWrapper
icon="/icons/floor.png"
onClose={handleClose}
title={node.name || 'Slab'}
width={320}
>
<PanelSection title="Elevation">
<SliderControl
label="Height"
max={1}
min={-1}
onChange={(v) => handleUpdate({ elevation: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.elevation * 1000) / 1000}
/>
<div className="mt-2 grid grid-cols-2 gap-1.5 px-1 pb-1">
<ActionButton label="Sunken (-15cm)" onClick={() => handleUpdate({ elevation: -0.15 })} />
<ActionButton label="Ground (0m)" onClick={() => handleUpdate({ elevation: 0 })} />
<ActionButton label="Raised (+5cm)" onClick={() => handleUpdate({ elevation: 0.05 })} />
<ActionButton label="Step (+15cm)" onClick={() => handleUpdate({ elevation: 0.15 })} />
</div>
</PanelSection>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-sm">
<span>Area</span>
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
</div>
</PanelSection>
<PanelSection title="Holes">
{node.holes && node.holes.length > 0 ? (
<div className="flex flex-col gap-1 pb-2">
{node.holes.map((hole, index) => {
const holeArea = calculateArea(hole)
const isEditing =
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
const source = node.holeMetadata?.[index]?.source ?? 'manual'
const isAutoHole = source !== 'manual'
const autoLabel = source === 'elevator' ? 'Auto elevator cutout' : 'Auto stair cutout'
return (
<div
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
isEditing
? 'border-primary/50 bg-primary/10'
: 'border-transparent hover:bg-accent/30'
}`}
key={index}
>
<div className="min-w-0 flex-1">
<p
className={`font-medium text-xs ${isEditing ? 'text-primary' : 'text-white'}`}
>
Hole {index + 1} {isEditing && '(Editing)'}
</p>
<p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
{isAutoHole ? autoLabel : 'Manual'}
</p>
</div>
<div className="flex items-center gap-1">
{isEditing ? (
<ActionButton
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
label="Done"
onClick={() => setEditingHole(null)}
/>
) : isAutoHole ? (
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
Auto
</div>
) : (
<>
<button
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground"
onClick={() => handleEditHole(index)}
type="button"
>
<Edit className="h-3.5 w-3.5" />
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300"
onClick={() => handleDeleteHole(index)}
type="button"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
)
})}
</div>
) : (
<div className="px-2 py-3 text-center text-muted-foreground text-xs">No holes</div>
)}
<div className="px-1 pt-1 pb-1">
<ActionButton
className="w-full"
disabled={editingHole?.nodeId === selectedId}
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Hole"
onClick={handleAddHole}
/>
</div>
</PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
</ActionGroup>
</PanelWrapper>
)
}
export default SlabPanel
+9 -4
View File
@@ -2,10 +2,14 @@ import type { ParametricDescriptor } from '@pascal-app/core'
import type { SlabNode } from './schema'
/**
* Inspector descriptor for slab. Polygon + holes are edited via the
* floor-plan boundary / hole editors — not number inputs. The inspector
* exposes only the per-instance scalars (elevation + auto-from-walls
* toggle).
* Inspector descriptor for slab.
*
* Mounts the kind-owned `<SlabPanel>` via `customPanel` — 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 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> = {
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 }],
},
],
customPanel: () => import('./panel'),
}