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:
co-authored by
Claude Opus 4.7
parent
c3e255b58a
commit
3c37ff009b
@@ -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 (
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
+20
-7
@@ -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
|
||||||
@@ -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'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+23
-7
@@ -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
|
||||||
@@ -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'),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user