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
@@ -1,254 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
|
||||
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'
|
||||
|
||||
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
|
||||
sfxEmitter.emit('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>
|
||||
)
|
||||
}
|
||||
@@ -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 { 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 <StairPanel />
|
||||
case 'stair-segment':
|
||||
return <StairSegmentPanel />
|
||||
case 'slab':
|
||||
return <SlabPanel />
|
||||
case 'spawn':
|
||||
return <SpawnPanel />
|
||||
case 'ceiling':
|
||||
return <CeilingPanel />
|
||||
case 'column':
|
||||
return <ColumnPanel />
|
||||
case 'wall':
|
||||
return <WallPanel />
|
||||
case 'fence':
|
||||
return <FencePanel />
|
||||
case 'door':
|
||||
return <DoorPanel />
|
||||
case 'elevator':
|
||||
|
||||
@@ -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 (
|
||||
<Suspense fallback={null}>
|
||||
<CustomPanel />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
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<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 ─────────────────────────────────────────────
|
||||
|
||||
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': {
|
||||
const str = typeof value === 'string' ? value : (field.options[0] ?? '')
|
||||
return (
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
|
||||
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'
|
||||
|
||||
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
|
||||
sfxEmitter.emit('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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user