Add ParametricInspector — auto-derive right-panel UI from definition.parametrics

First slice of Phase 4 work pulled in: an inspector that reads any
registered node's `parametrics` descriptor and renders the right-side
panel automatically. Verifies the parametric descriptor design end-to-end
on shelf and proves the loop (descriptor → UI → store update → render).

ParametricInspector component:
- Reads `nodeRegistry.get(node.type)?.parametrics`.
- Renders one `<PanelSection>` per group, one control per field.
- Field kinds supported in v1:
  * `number` → SliderControl with min/max/step/unit from descriptor
  * `enum`   → dark-themed <select> with prettified labels
  * `color`  → native color picker + hex input pair
  * `vec3`   → 3× SliderControl (X / Y / Z)
- Honors `field.visibleIf(node)` to gate conditional fields.
- Generic Actions footer with Move / Delete, gated on
  `capabilities.movable` / `capabilities.deletable !== false`.
- Title from `presentation.label`, defaults to `node.type`.

Wired as the `default:` arm of panel-manager.tsx's switch — registered
kinds without a hardcoded case (shelf, future kinds) get the auto-derived
panel. Spawn keeps its hand-written panel (the legacy switch catches
it first); we'll switch to registry-first dispatch when Phase 4 finishes
and the hand-written panels can be deleted.

What you can verify after this:
- Click a shelf → right panel shows Dimensions (width/depth/thickness/
  height sliders with units + bounds from the schema) + Style (bracket
  style select + color picker) + Actions (Move / Delete).
- Drag a slider → mesh updates live (store update → renderer re-renders).
- Try to set width > 3.0 — schema rejects, no update fires (the
  parametric bounds are enforced by Zod, same source of truth as MCP
  bound generation in Phase 4's MCP work).

Not in scope for this commit:
- `parametrics.customPanel?` escape hatch.
- `material` / `ref` field kinds.
- `invariants` validation feedback in the UI.
- Migration of legacy panels (spawn/column/etc.) to the auto-generated
  path — those keep working unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-14 16:45:57 -04:00
co-authored by Claude Opus 4.7
parent d254582597
commit 9d17683feb
2 changed files with 270 additions and 1 deletions
@@ -34,6 +34,7 @@ import { MobilePanelSheet } from './mobile-panel-sheet'
import { MobileSelectionBar } from './mobile-selection-bar'
import { getNodeDisplay } from './node-display'
import { PaintPanel } from './paint-panel'
import { ParametricInspector } from './parametric-inspector'
import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel'
import { RoofSegmentPanel } from './roof-segment-panel'
@@ -113,7 +114,12 @@ function panelForType(type: string | null) {
case 'window':
return <WindowPanel />
default:
return null
// Registry fallback: any kind registered via @pascal-app/nodes with a
// `parametrics` descriptor on its NodeDefinition gets an auto-derived
// panel. Phase 4 will replace the hardcoded switch above with the
// registry-first path; until then this fallback lets new kinds (shelf,
// etc.) have a working inspector without per-kind panel files.
return <ParametricInspector />
}
}
@@ -0,0 +1,263 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type AnyNodeDefinition,
nodeRegistry,
type ParamField,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Trash2 } from 'lucide-react'
import { useCallback } 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'
/**
* Auto-derived right-panel inspector for any registry-backed node.
*
* Reads `definition.parametrics` from the registry and renders one
* `<PanelSection>` per group, one control per field. Field kinds supported:
* - `number` → SliderControl with min/max/step/unit from the descriptor
* - `enum` → dark-themed `<select>`
* - `color` → native color picker + hex input
* - `vec3` → three SliderControls for X / Y / Z
*
* Generic Actions section appends Move / Delete based on `capabilities`.
*
* Phase 4 will expand this with per-field `customEditor` support and a
* `parametrics.customPanel?` escape hatch for kinds whose parametric editor
* can't be auto-generated (topology editors etc.).
*/
export function ParametricInspector() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const node = useScene((s) =>
selectedId ? s.nodes[selectedId as AnyNodeId] : undefined,
)
const def = node ? nodeRegistry.get(node.type) : undefined
const parametrics = def?.parametrics
const handleUpdate = useCallback(
(patch: Partial<AnyNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNodeId, patch)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as any)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!selectedId) return
sfxEmitter.emit('sfx:structure-delete')
deleteNode(selectedId as AnyNodeId)
setSelection({ selectedIds: [] })
}, [deleteNode, selectedId, setSelection])
if (!node || !def || !parametrics) return null
const presentation = def.presentation
const title = presentation?.label ?? node.type
const canMove = !!def.capabilities.movable
const canDelete = def.capabilities.deletable !== false
return (
<PanelWrapper onClose={handleClose} title={title} width={320}>
{parametrics.groups.map((group, gi) => (
<PanelSection key={`group-${gi}`} title={group.label}>
{group.fields.map((field, fi) => {
// Skip fields whose visibleIf predicate excludes the current node.
const visibleIf = (field as { visibleIf?: (n: AnyNode) => boolean }).visibleIf
if (visibleIf && !visibleIf(node)) return null
return (
<FieldRenderer
key={`field-${gi}-${fi}-${String(field.key)}`}
field={field as ParamField<AnyNode>}
node={node}
onUpdate={handleUpdate}
/>
)
})}
</PanelSection>
))}
{(canMove || canDelete) && (
<PanelSection title="Actions">
<ActionGroup>
{canMove && (
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
)}
{canDelete && (
<ActionButton
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
icon={<Trash2 className="h-4 w-4" />}
label="Delete"
onClick={handleDelete}
/>
)}
</ActionGroup>
</PanelSection>
)}
</PanelWrapper>
)
}
// ─── Per-field renderers ─────────────────────────────────────────────
interface FieldRendererProps {
field: ParamField<AnyNode>
node: AnyNode
onUpdate: (patch: Partial<AnyNode>) => void
}
function FieldRenderer({ field, node, onUpdate }: FieldRendererProps) {
const key = String(field.key)
const value = (node as Record<string, unknown>)[key]
switch (field.kind) {
case 'number': {
const num = typeof value === 'number' ? value : 0
const step = field.step ?? 0.01
const precision = precisionForStep(step)
return (
<SliderControl
label={prettifyKey(key)}
max={field.max}
min={field.min}
onChange={(next) => onUpdate({ [key]: next } as Partial<AnyNode>)}
precision={precision}
step={step}
unit={field.unit ?? ''}
value={num}
/>
)
}
case 'enum': {
const str = typeof value === 'string' ? value : (field.options[0] ?? '')
return (
<div className="flex items-center justify-between px-3 py-2">
<span className="text-foreground/80 text-xs">{prettifyKey(key)}</span>
<select
className="rounded-md border border-border/50 bg-[#2C2C2E] px-2 py-1 text-foreground text-xs focus:outline-none focus:ring-1 focus:ring-foreground/30"
onChange={(e) => onUpdate({ [key]: e.target.value } as Partial<AnyNode>)}
value={str}
>
{field.options.map((opt) => (
<option key={opt} value={opt}>
{prettifyEnumValue(opt)}
</option>
))}
</select>
</div>
)
}
case 'color': {
const str = typeof value === 'string' ? value : '#888888'
return (
<div className="flex items-center justify-between px-3 py-2">
<span className="text-foreground/80 text-xs">{prettifyKey(key)}</span>
<div className="flex items-center gap-2">
<input
className="h-6 w-8 cursor-pointer rounded border border-border/50 bg-transparent"
onChange={(e) => onUpdate({ [key]: e.target.value } as Partial<AnyNode>)}
type="color"
value={str}
/>
<input
className="w-20 rounded-md border border-border/50 bg-[#2C2C2E] px-2 py-1 text-foreground text-xs focus:outline-none focus:ring-1 focus:ring-foreground/30"
onChange={(e) => onUpdate({ [key]: e.target.value } as Partial<AnyNode>)}
type="text"
value={str}
/>
</div>
</div>
)
}
case 'vec3': {
const v = Array.isArray(value) && value.length >= 3
? (value as [number, number, number])
: [0, 0, 0]
const axes: Array<{ label: string; index: 0 | 1 | 2 }> = [
{ label: 'X', index: 0 },
{ label: 'Y', index: 1 },
{ label: 'Z', index: 2 },
]
return (
<>
{axes.map(({ label, index }) => {
// v is a [number, number, number] tuple; the explicit local
// resolves TS's noUncheckedIndexedAccess concern that v[index]
// could be undefined.
const axisValue = v[index] ?? 0
return (
<SliderControl
key={`${key}-${label}`}
label={label}
max={axisValue + 5}
min={axisValue - 5}
onChange={(next) => {
const updated = [...v] as [number, number, number]
updated[index] = next
onUpdate({ [key]: updated } as Partial<AnyNode>)
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(axisValue * 100) / 100}
/>
)
})}
</>
)
}
default:
// material / ref / unrecognized kinds — not implemented in v1.
return null
}
}
// ─── helpers ─────────────────────────────────────────────────────────
function precisionForStep(step: number): number {
if (step <= 0) return 0
return Math.max(0, Math.ceil(-Math.log10(step)))
}
function prettifyKey(key: string): string {
// 'bracketStyle' → 'Bracket style'
const spaced = key.replace(/([A-Z])/g, ' $1').toLowerCase()
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
}
function prettifyEnumValue(value: string): string {
// 'minimal' → 'Minimal'; 'roof-segment' → 'Roof segment'
return value
.split(/[-_\s]/)
.map((word, i) =>
i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word.toLowerCase(),
)
.join(' ')
}