diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index c31ab41b..784892a0 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -371,6 +371,7 @@ export type ParamField = visibleIf?: (n: N) => boolean 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: 'vec3'; visibleIf?: (n: N) => boolean } | { key: keyof N; kind: 'color'; visibleIf?: (n: N) => boolean } diff --git a/packages/editor/src/components/ui/panels/fence-panel.tsx b/packages/editor/src/components/ui/panels/fence-panel.tsx deleted file mode 100644 index 8d8d3531..00000000 --- a/packages/editor/src/components/ui/panels/fence-panel.tsx +++ /dev/null @@ -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) => { - 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 ( - - - handleUpdate({ style: value })} - options={FENCE_STYLE_OPTIONS} - value={node.style} - /> - handleUpdate({ baseStyle: value })} - options={FENCE_BASE_STYLE_OPTIONS} - value={node.baseStyle} - /> - handleUpdate({ showInfill: checked })} - /> - - - - - handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })} - precision={2} - step={0.1} - unit="m" - value={Math.round(curveOffset * 100) / 100} - /> - handleUpdate({ height: Math.max(0.4, value) })} - precision={2} - step={0.05} - unit="m" - value={node.height} - /> - handleUpdate({ thickness: Math.max(0.03, value) })} - precision={3} - step={0.005} - unit="m" - value={node.thickness} - /> - - - - handleUpdate({ baseHeight: Math.max(0.04, value) })} - precision={3} - step={0.01} - unit="m" - value={node.baseHeight} - /> - handleUpdate({ topRailHeight: Math.max(0.01, value) })} - precision={3} - step={0.005} - unit="m" - value={node.topRailHeight} - /> - handleUpdate({ postSpacing: Math.max(0.2, value) })} - precision={2} - step={0.05} - unit="m" - value={node.postSpacing} - /> - handleUpdate({ postSize: Math.max(0.01, value) })} - precision={3} - step={0.005} - unit="m" - value={node.postSize} - /> - handleUpdate({ groundClearance: Math.max(0, value) })} - precision={3} - step={0.005} - unit="m" - value={node.groundClearance} - /> - handleUpdate({ edgeInset: Math.max(0.005, value) })} - precision={3} - step={0.005} - unit="m" - value={node.edgeInset} - /> - - - ) -} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 1d2eec52..3b36ae4d 100644 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -24,11 +24,9 @@ import { useCallback, useEffect, useState } from 'react' import { useIsMobile } from '../../../hooks/use-mobile' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' -import { CeilingPanel } from './ceiling-panel' import { ColumnPanel } from './column-panel' import { DoorPanel } from './door-panel' import { ElevatorPanel } from './elevator-panel' -import { FencePanel } from './fence-panel' import { ItemPanel } from './item-panel' import { MobilePanelSheet } from './mobile-panel-sheet' import { MobileSelectionBar } from './mobile-selection-bar' @@ -38,7 +36,6 @@ import { ParametricInspector } from './parametric-inspector' import { ReferencePanel } from './reference-panel' import { RoofPanel } from './roof-panel' import { RoofSegmentPanel } from './roof-segment-panel' -import { SlabPanel } from './slab-panel' import { SpawnPanel } from './spawn-panel' import { StairPanel } from './stair-panel' import { StairSegmentPanel } from './stair-segment-panel' @@ -95,18 +92,12 @@ function panelForType(type: string | null) { return case 'stair-segment': return - case 'slab': - return case 'spawn': return - case 'ceiling': - return case 'column': return case 'wall': return - case 'fence': - return case 'door': return case 'elevator': diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index 0f153cc6..c4443c6b 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -9,12 +9,13 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' 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 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 { ToggleControl } from '../controls/toggle-control' import { PanelWrapper } from './panel-wrapper' /** @@ -75,6 +76,21 @@ export function ParametricInspector() { 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 ( + + + + ) + } + const presentation = def.presentation const title = presentation?.label ?? nodeType ?? '' 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, ComponentType>() + +function resolveCustomPanel(loader: () => Promise<{ default: ComponentType }>): 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 ───────────────────────────────────────────── interface FieldRendererProps { @@ -163,6 +191,17 @@ function FieldRenderer({ field, nodeId, onUpdate }: FieldRendererProps) { ) } + case 'boolean': { + const checked = value === true + return ( + onUpdate({ [key]: next } as Partial)} + /> + ) + } + case 'enum': { const str = typeof value === 'string' ? value : (field.options[0] ?? '') return ( diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 9310c167..2d88a021 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -32,9 +32,17 @@ export { export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions' export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles' 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 { ToggleControl } from './components/ui/controls/toggle-control' export { FloatingLevelSelector } from './components/ui/floating-level-selector' 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 { useSidebarStore } from './components/ui/primitives/sidebar' export { Slider } from './components/ui/primitives/slider' diff --git a/packages/editor/src/components/ui/panels/ceiling-panel.tsx b/packages/nodes/src/ceiling/panel.tsx similarity index 94% rename from packages/editor/src/components/ui/panels/ceiling-panel.tsx rename to packages/nodes/src/ceiling/panel.tsx index 31acea62..67eceeff 100644 --- a/packages/editor/src/components/ui/panels/ceiling-panel.tsx +++ b/packages/nodes/src/ceiling/panel.tsx @@ -1,16 +1,27 @@ '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' -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() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) @@ -111,7 +122,7 @@ export function CeilingPanel() { const handleMove = useCallback(() => { if (!node) return - sfxEmitter.emit('sfx:item-pick') + triggerSFX('sfx:item-pick') setMovingNode(node) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) @@ -252,3 +263,5 @@ export function CeilingPanel() { ) } + +export default CeilingPanel diff --git a/packages/nodes/src/ceiling/parametrics.ts b/packages/nodes/src/ceiling/parametrics.ts index 87793ae3..9d63be3b 100644 --- a/packages/nodes/src/ceiling/parametrics.ts +++ b/packages/nodes/src/ceiling/parametrics.ts @@ -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 `` 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 = { groups: [ @@ -13,4 +15,5 @@ export const ceilingParametrics: ParametricDescriptor = { fields: [{ key: 'height', kind: 'number', unit: 'm', min: 1.5, max: 6, step: 0.05 }], }, ], + customPanel: () => import('./panel'), } diff --git a/packages/nodes/src/fence/parametrics.ts b/packages/nodes/src/fence/parametrics.ts index 9c821eb3..19f341cb 100644 --- a/packages/nodes/src/fence/parametrics.ts +++ b/packages/nodes/src/fence/parametrics.ts @@ -34,6 +34,7 @@ export const fenceParametrics: ParametricDescriptor = { fields: [ { key: 'style', kind: 'enum', options: ['slat', 'rail', 'privacy'] }, { key: 'baseStyle', kind: 'enum', options: ['floating', 'grounded'] }, + { key: 'showInfill', kind: 'boolean' }, { key: 'color', kind: 'color' }, ], }, diff --git a/packages/editor/src/components/ui/panels/slab-panel.tsx b/packages/nodes/src/slab/panel.tsx similarity index 93% rename from packages/editor/src/components/ui/panels/slab-panel.tsx rename to packages/nodes/src/slab/panel.tsx index 9dc330d6..fca302c2 100644 --- a/packages/editor/src/components/ui/panels/slab-panel.tsx +++ b/packages/nodes/src/slab/panel.tsx @@ -1,16 +1,30 @@ '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' -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 + * `` 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) @@ -112,7 +126,7 @@ export function SlabPanel() { const handleMove = useCallback(() => { if (!node) return - sfxEmitter.emit('sfx:item-pick') + triggerSFX('sfx:item-pick') setMovingNode(node) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) @@ -254,3 +268,5 @@ export function SlabPanel() { ) } + +export default SlabPanel diff --git a/packages/nodes/src/slab/parametrics.ts b/packages/nodes/src/slab/parametrics.ts index 3490912a..e1d148e6 100644 --- a/packages/nodes/src/slab/parametrics.ts +++ b/packages/nodes/src/slab/parametrics.ts @@ -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 `` 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 = { groups: [ @@ -14,4 +18,5 @@ export const slabParametrics: ParametricDescriptor = { fields: [{ key: 'elevation', kind: 'number', unit: 'm', min: 0.02, max: 1, step: 0.01 }], }, ], + customPanel: () => import('./panel'), }