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:
Wassim SAMAD
2026-05-18 10:04:11 -04:00
co-authored by Claude Opus 4.7
parent c3e255b58a
commit 3c37ff009b
10 changed files with 108 additions and 268 deletions
@@ -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>
)
}
+8
View File
@@ -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'