Wassim SAMAD 3c37ff009b 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>
2026-05-18 10:04:11 -04:00

247 lines
6.8 KiB
TypeScript

'use client'
import {
type AnyNode,
type AnyNodeId,
type BuildingNode,
type CeilingNode,
type ColumnNode,
type DoorNode,
type ElevatorNode,
type FenceNode,
type ItemNode,
type RoofNode,
type RoofSegmentNode,
type SlabNode,
type StairNode,
type StairSegmentNode,
useScene,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
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 { ColumnPanel } from './column-panel'
import { DoorPanel } from './door-panel'
import { ElevatorPanel } from './elevator-panel'
import { ItemPanel } from './item-panel'
import { MobilePanelSheet } from './mobile-panel-sheet'
import { MobileSelectionBar } from './mobile-selection-bar'
import { getNodeDisplay } from './node-display'
import { PaintPanel } from './paint-panel'
import { ParametricInspector } from './parametric-inspector'
import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel'
import { RoofSegmentPanel } from './roof-segment-panel'
import { SpawnPanel } from './spawn-panel'
import { StairPanel } from './stair-panel'
import { StairSegmentPanel } from './stair-segment-panel'
import { WallPanel } from './wall-panel'
import { WindowPanel } from './window-panel'
type MovableNode =
| ItemNode
| WindowNode
| DoorNode
| ElevatorNode
| CeilingNode
| ColumnNode
| SlabNode
| WallNode
| FenceNode
| RoofNode
| RoofSegmentNode
| StairNode
| StairSegmentNode
| BuildingNode
const MOVABLE_TYPES = new Set<string>([
'item',
'window',
'door',
'elevator',
'ceiling',
'column',
'slab',
'wall',
'fence',
'roof',
'roof-segment',
'stair',
'stair-segment',
'building',
])
function isMovableNode(node: AnyNode | null): node is MovableNode {
return !!node && MOVABLE_TYPES.has(node.type)
}
function panelForType(type: string | null) {
if (!type) return null
switch (type) {
case 'item':
return <ItemPanel />
case 'roof':
return <RoofPanel />
case 'roof-segment':
return <RoofSegmentPanel />
case 'stair':
return <StairPanel />
case 'stair-segment':
return <StairSegmentPanel />
case 'spawn':
return <SpawnPanel />
case 'column':
return <ColumnPanel />
case 'wall':
return <WallPanel />
case 'door':
return <DoorPanel />
case 'elevator':
return <ElevatorPanel />
case 'window':
return <WindowPanel />
default:
// Registry fallback: any kind registered via @pascal-app/nodes with a
// `parametrics` descriptor on its NodeDefinition gets an auto-derived
// panel. Phase 4 will replace the hardcoded switch above with the
// registry-first path; until then this fallback lets new kinds (shelf,
// etc.) have a working inspector without per-kind panel files.
return <ParametricInspector />
}
}
function MobilePanelLayer({
node,
panel,
isReference,
}: {
node: AnyNode | null
panel: React.ReactNode
isReference: boolean
}) {
const setSelection = useViewer((s) => s.setSelection)
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
const setMovingNode = useEditor((s) => s.setMovingNode)
const deleteNode = useScene((s) => s.deleteNode)
const [isSheetOpen, setIsSheetOpen] = useState(false)
// Reset sheet open state when the selection changes / clears
const selectionKey = node?.id ?? (isReference ? 'reference' : null)
useEffect(() => {
setIsSheetOpen(false)
}, [selectionKey])
const clearSelection = useCallback(() => {
setSelection({ selectedIds: [] })
setSelectedReferenceId(null)
}, [setSelection, setSelectedReferenceId])
const handleMove = useCallback(() => {
if (!isMovableNode(node)) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
clearSelection()
}, [node, setMovingNode, clearSelection])
const handleDuplicate = useCallback(() => {
if (!isMovableNode(node)) return
sfxEmitter.emit('sfx:item-pick')
const cloned = structuredClone(node) as MovableNode & { id?: AnyNodeId }
delete (cloned as { id?: AnyNodeId }).id
const prevMeta =
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
? (cloned.metadata as Record<string, unknown>)
: {}
cloned.metadata = { ...prevMeta, isNew: true }
setMovingNode(cloned as MovableNode)
clearSelection()
}, [node, setMovingNode, clearSelection])
const handleDelete = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-delete')
deleteNode(node.id)
clearSelection()
}, [node, deleteNode, clearSelection])
if (!(node || isReference)) return null
const display = getNodeDisplay(node)
return (
<>
{node && (
<MobileSelectionBar
node={node}
onDelete={handleDelete}
onDuplicate={handleDuplicate}
onEdit={() => setIsSheetOpen((v) => !v)}
onMove={handleMove}
/>
)}
<MobilePanelSheet
icon={display.icon}
onClose={() => setIsSheetOpen(false)}
open={isSheetOpen}
title={display.label}
>
{panel}
</MobilePanelSheet>
</>
)
}
export function PanelManager() {
const isMobile = useIsMobile()
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
const mode = useEditor((s) => s.mode)
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
// Only subscribe to the *type* of the single-selected node — string primitive
// so we don't re-render on unrelated scene mutations.
const selectedNodeType = useScene((s) => {
if (selectedIds.length !== 1) return null
const id = selectedIds[0]
return id ? (s.nodes[id as AnyNodeId]?.type ?? null) : null
})
const selectedNode = useScene((s) => {
if (selectedIds.length !== 1) return null
const id = selectedIds[0]
return id ? (s.nodes[id as AnyNodeId] ?? null) : null
})
if (isMobile) {
if (selectedReferenceId) {
return <MobilePanelLayer isReference={true} node={null} panel={<ReferencePanel />} />
}
return (
<MobilePanelLayer
isReference={false}
node={selectedNode}
panel={panelForType(selectedNodeType)}
/>
)
}
// Show reference panel if a reference is selected
if (selectedReferenceId) {
return <ReferencePanel />
}
if (
isPaintPanelOpen &&
mode === 'material-paint' &&
activePaintMaterial?.material?.properties &&
!activePaintMaterial.materialPreset
) {
return <PaintPanel />
}
return panelForType(selectedNodeType)
}