diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 7836b2e3..1d2eec52 100644 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -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 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 } } diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx new file mode 100644 index 00000000..f3b4ebd7 --- /dev/null +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -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 + * `` per group, one control per field. Field kinds supported: + * - `number` → SliderControl with min/max/step/unit from the descriptor + * - `enum` → dark-themed ` onUpdate({ [key]: e.target.value } as Partial)} + value={str} + > + {field.options.map((opt) => ( + + ))} + + + ) + } + + case 'color': { + const str = typeof value === 'string' ? value : '#888888' + return ( +
+ {prettifyKey(key)} +
+ onUpdate({ [key]: e.target.value } as Partial)} + type="color" + value={str} + /> + onUpdate({ [key]: e.target.value } as Partial)} + type="text" + value={str} + /> +
+
+ ) + } + + 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 ( + { + const updated = [...v] as [number, number, number] + updated[index] = next + onUpdate({ [key]: updated } as Partial) + }} + 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(' ') +}