'use client' import { type AnyNode, type AnyNodeId, DoorNode, emitter, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' import { sfxEmitter } from '@/lib/sfx-bus' import useEditor from '@/store/use-editor' import { PanelWrapper } from './panel-wrapper' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { MetricControl } from '../controls/metric-control' import { ToggleControl } from '../controls/toggle-control' import { SegmentedControl } from '../controls/segmented-control' import { ActionButton, ActionGroup } from '../controls/action-button' import { PresetsPopover } from './presets/presets-popover' export function DoorPanel() { const selectedIds = useViewer((s) => s.selection.selectedIds) const setSelection = useViewer((s) => s.setSelection) const nodes = useScene((s) => s.nodes) const updateNode = useScene((s) => s.updateNode) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) const selectedId = selectedIds[0] const node = selectedId ? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return updateNode(selectedId as AnyNode['id'], updates) useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) }, [selectedId, updateNode], ) const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) const handleFlip = useCallback(() => { if (!node) return handleUpdate({ side: node.side === 'front' ? 'back' : 'front', rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]], }) }, [node, handleUpdate]) const handleMove = useCallback(() => { if (!node) return sfxEmitter.emit('sfx:item-pick') setMovingNode(node) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) const handleDelete = useCallback(() => { if (!selectedId || !node) return sfxEmitter.emit('sfx:item-delete') deleteNode(selectedId as AnyNode['id']) if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId) setSelection({ selectedIds: [] }) }, [selectedId, node, deleteNode, setSelection]) const handleDuplicate = useCallback(() => { if (!node || !node.parentId) return sfxEmitter.emit('sfx:item-pick') useScene.temporal.getState().pause() const cloned = structuredClone(node) as any delete cloned.id cloned.metadata = { ...cloned.metadata, isNew: true } const duplicate = DoorNode.parse(cloned) useScene.getState().createNode(duplicate, node.parentId as AnyNodeId) setMovingNode(duplicate) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) const setSegmentHeightRatio = (segIdx: number, newVal: number) => { const numSegs = node!.segments.length const totalH = node!.segments.reduce((sum, s) => sum + s.heightRatio, 0) const normH = node!.segments.map(s => s.heightRatio / totalH) const clamped = Math.max(0.05, Math.min(0.95, newVal)) const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1 const delta = clamped - normH[segIdx]! const neighborVal = Math.max(0.05, normH[neighborIdx]! - delta) const newRatios = normH.map((v, i) => { if (i === segIdx) return clamped if (i === neighborIdx) return neighborVal return v }) const updated = node!.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! })) handleUpdate({ segments: updated }) } const setSegmentColumnRatio = (segIdx: number, colIdx: number, newVal: number) => { const seg = node!.segments[segIdx]! const normRatios = (() => { const sum = seg.columnRatios.reduce((a, b) => a + b, 0) return seg.columnRatios.map(r => r / sum) })() const numCols = normRatios.length const clamped = Math.max(0.05, Math.min(0.95, newVal)) const neighborIdx = colIdx < numCols - 1 ? colIdx + 1 : colIdx - 1 const delta = clamped - normRatios[colIdx]! const neighborVal = Math.max(0.05, normRatios[neighborIdx]! - delta) const newRatios = normRatios.map((v, i) => { if (i === colIdx) return clamped if (i === neighborIdx) return neighborVal return v }) const updated = node!.segments.map((s, idx) => idx === segIdx ? { ...s, columnRatios: newRatios } : s, ) handleUpdate({ segments: updated }) } const getDoorPresetData = useCallback(() => { if (!node) return null return { width: node.width, height: node.height, frameThickness: node.frameThickness, frameDepth: node.frameDepth, contentPadding: node.contentPadding, hingesSide: node.hingesSide, swingDirection: node.swingDirection, threshold: node.threshold, thresholdHeight: node.thresholdHeight, handle: node.handle, handleHeight: node.handleHeight, handleSide: node.handleSide, doorCloser: node.doorCloser, panicBar: node.panicBar, panicBarHeight: node.panicBarHeight, segments: node.segments, } }, [node]) const handleSavePreset = useCallback(async (name: string) => { const data = getDoorPresetData() if (!data || !selectedId) return const res = await fetch('/api/presets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'door', name, data }), }) if (res.ok) { const json = await res.json() const presetId = json.preset?.id if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId }) } }, [getDoorPresetData, selectedId]) const handleOverwritePreset = useCallback(async (id: string) => { const data = getDoorPresetData() if (!data || !selectedId) return const res = await fetch(`/api/presets/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ data }), }) if (res.ok) emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId }) }, [getDoorPresetData, selectedId]) const handleApplyPreset = useCallback((data: Record) => { handleUpdate(data as Partial) }, [handleUpdate]) if (!node || node.type !== 'door' || selectedIds.length !== 1) return null const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0) const normHeights = node.segments.map(seg => seg.heightRatio / hSum) return ( {/* Presets strip */}
Xwall} value={Math.round(node.position[0] * 100) / 100} onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })} min={-10} max={10} precision={2} step={0.1} unit="m" />
} label="Flip Side" onClick={handleFlip} className="w-full" />
handleUpdate({ width: v })} min={0.5} max={3} precision={2} step={0.05} unit="m" /> handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })} min={1.0} max={4} precision={2} step={0.05} unit="m" /> handleUpdate({ frameThickness: v })} min={0.01} max={0.2} precision={3} step={0.01} unit="m" /> handleUpdate({ frameDepth: v })} min={0.01} max={0.3} precision={3} step={0.01} unit="m" /> handleUpdate({ contentPadding: [v, node.contentPadding[1]] })} min={0} max={0.2} precision={3} step={0.005} unit="m" /> handleUpdate({ contentPadding: [node.contentPadding[0], v] })} min={0} max={0.2} precision={3} step={0.005} unit="m" />
Hinges Side handleUpdate({ hingesSide: v })} options={[ { label: 'Left', value: 'left' }, { label: 'Right', value: 'right' }, ]} />
Direction handleUpdate({ swingDirection: v })} options={[ { label: 'Inward', value: 'inward' }, { label: 'Outward', value: 'outward' }, ]} />
handleUpdate({ threshold: checked })} /> {node.threshold && (
handleUpdate({ thresholdHeight: v })} min={0.005} max={0.1} precision={3} step={0.005} unit="m" />
)}
handleUpdate({ handle: checked })} /> {node.handle && (
handleUpdate({ handleHeight: v })} min={0.5} max={node.height - 0.1} precision={2} step={0.05} unit="m" />
Handle Side handleUpdate({ handleSide: v })} options={[ { label: 'Left', value: 'left' }, { label: 'Right', value: 'right' }, ]} />
)}
handleUpdate({ doorCloser: checked })} /> handleUpdate({ panicBar: checked })} /> {node.panicBar && (
handleUpdate({ panicBarHeight: v })} min={0.5} max={node.height - 0.1} precision={2} step={0.05} unit="m" />
)}
{node.segments.map((seg, i) => { const numCols = seg.columnRatios.length const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) const normCols = seg.columnRatios.map(r => r / colSum) return (
Segment {i + 1}
{ const updated = node.segments.map((s, idx) => idx === i ? { ...s, type: t } : s) handleUpdate({ segments: updated }) }} options={[ { label: 'Panel', value: 'panel' }, { label: 'Glass', value: 'glass' }, { label: 'Empty', value: 'empty' }, ]} /> setSegmentHeightRatio(i, v / 100)} min={5} max={95} precision={1} step={1} unit="%" /> { const n = Math.max(1, Math.min(8, Math.round(v))) const updated = node.segments.map((s, idx) => idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s, ) handleUpdate({ segments: updated }) }} min={1} max={8} precision={0} step={1} /> {numCols > 1 && (
{normCols.map((ratio, ci) => ( setSegmentColumnRatio(i, ci, v / 100)} min={5} max={95} precision={1} step={1} unit="%" /> ))} { const updated = node.segments.map((s, idx) => idx === i ? { ...s, dividerThickness: v } : s, ) handleUpdate({ segments: updated }) }} min={0.005} max={0.1} precision={3} step={0.005} unit="m" />
)} {seg.type === 'panel' && (
{ const updated = node.segments.map((s, idx) => idx === i ? { ...s, panelInset: v } : s, ) handleUpdate({ segments: updated }) }} min={0} max={0.1} precision={3} step={0.005} unit="m" /> { const updated = node.segments.map((s, idx) => idx === i ? { ...s, panelDepth: v } : s, ) handleUpdate({ segments: updated }) }} min={0} max={0.1} precision={3} step={0.005} unit="m" />
)}
) })}
{ const updated = [ ...node.segments, { type: 'panel' as const, heightRatio: 1, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 }, ] handleUpdate({ segments: updated }) }} /> {node.segments.length > 1 && ( handleUpdate({ segments: node.segments.slice(0, -1) })} className="text-white/60 hover:text-white" /> )}
} label="Move" onClick={handleMove} /> } label="Duplicate" onClick={handleDuplicate} /> } label="Delete" onClick={handleDelete} className="hover:bg-red-500/20" />
) }