'use client' import { type AnyNode, type AnyNodeId, useInteractive, useScene, WindowNode, } from '@pascal-app/core' import { ActionButton, ActionGroup, cn, PanelSection, PanelWrapper, SegmentedControl, SliderControl, ToggleControl, triggerSFX, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useRef } from 'react' function isSameWindowValue(current: unknown, next: unknown): boolean { if (typeof current === 'number' && typeof next === 'number') { return Math.abs(current - next) < 1e-6 } if (Array.isArray(current) && Array.isArray(next)) { return ( current.length === next.length && current.every((value, index) => isSameWindowValue(value, next[index])) ) } return Object.is(current, next) } function getMaxSharedWindowRadius(width: number, height: number) { return Math.max(0, Math.min(width / 2, height / 2)) } function normalizeWindowCornerRadii( radii: [number, number, number, number], width: number, height: number, ): [number, number, number, number] { const next = radii.map((radius) => Math.max(radius, 0)) as [number, number, number, number] const scale = Math.min( 1, Math.max(width, 0) / Math.max(next[0] + next[1], 1e-6), Math.max(width, 0) / Math.max(next[3] + next[2], 1e-6), Math.max(height, 0) / Math.max(next[0] + next[3], 1e-6), Math.max(height, 0) / Math.max(next[1] + next[2], 1e-6), ) if (scale >= 1) return next return next.map((radius) => radius * scale) as [number, number, number, number] } function isSameRadiusTuple( current: [number, number, number, number], next: [number, number, number, number], ) { return current.every((value, index) => Math.abs(value - (next[index] ?? 0)) < 1e-6) } const windowTypeOptions: Array<{ label: string; value: WindowNode['windowType'] }> = [ { label: 'Fixed', value: 'fixed' }, { label: 'Sliding', value: 'sliding' }, { label: 'Casement', value: 'casement' }, { label: 'Awning', value: 'awning' }, { label: 'Single Hung', value: 'single-hung' }, { label: 'Double Hung', value: 'double-hung' }, { label: 'Bay', value: 'bay' }, { label: 'Bow', value: 'bow' }, { label: 'Louvered', value: 'louvered' }, ] const shapedWindowTypes = new Set([ 'fixed', 'casement', 'awning', 'hopper', 'louvered', ]) const silllessWindowTypes = new Set(['bay', 'bow']) export default function WindowPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) const previewRef = useRef<{ id: AnyNodeId key: keyof WindowNode value: unknown } | null>(null) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined, ) // Panel slider-drag fix recipe (plans/editor-node-registry.md). Without // it, the 15+ SliderControls in this panel would loop on drag. const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] if (liveNode?.type !== 'window') return const hasChange = Object.entries(updates).some(([key, value]) => { const currentValue = liveNode[key as keyof WindowNode] return !isSameWindowValue(currentValue, value) }) if (!hasChange) return useScene.getState().updateNode(selectedId as AnyNode['id'], updates) const scene = useScene.getState() scene.dirtyNodes.add(selectedId as AnyNodeId) if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId) }, [selectedId], ) const previewWindowUpdate = useCallback( (key: K, value: WindowNode[K]) => { if (!selectedId) return const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] if (liveNode?.type !== 'window') return if ( !( previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key ) ) { previewRef.current = { id: selectedId as AnyNodeId, key, value: liveNode[key], } } if (isSameWindowValue(liveNode[key], value)) return ;(liveNode as WindowNode)[key] = value useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) }, [selectedId], ) const commitWindowPreview = useCallback( (key: K, value: WindowNode[K]) => { if (!selectedId) return const scene = useScene.getState() const liveNode = scene.nodes[selectedId as AnyNodeId] const preview = previewRef.current if (liveNode?.type === 'window' && preview?.id === selectedId && preview.key === key) { ;(liveNode as WindowNode)[key] = preview.value as WindowNode[K] scene.dirtyNodes.add(selectedId as AnyNodeId) } previewRef.current = null useScene.getState().updateNode( selectedId as AnyNode['id'], { [key]: value, } as Partial, ) scene.dirtyNodes.add(selectedId as AnyNodeId) }, [selectedId], ) 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 triggerSFX('sfx:item-pick') setMovingNode(node) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) const handleDelete = useCallback(() => { if (!(selectedId && node)) return triggerSFX('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?.parentId) return triggerSFX('sfx:item-pick') useScene.temporal.getState().pause() const duplicate = WindowNode.parse({ position: [...node.position] as [number, number, number], rotation: [...node.rotation] as [number, number, number], side: node.side, wallId: node.wallId, roofSegmentId: node.roofSegmentId, roofFace: node.roofFace, parentId: node.parentId, width: node.width, height: node.height, windowType: node.windowType, operationState: node.operationState, awningDirection: node.awningDirection, casementStyle: node.casementStyle, hingesSide: node.hingesSide, frameThickness: node.frameThickness, frameDepth: node.frameDepth, openingKind: node.openingKind, openingShape: node.openingShape, openingRadiusMode: node.openingRadiusMode ?? 'all', openingCornerRadii: [...(node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15])], cornerRadius: node.cornerRadius, archHeight: node.archHeight, openingRevealRadius: node.openingRevealRadius, columnRatios: [...node.columnRatios], rowRatios: [...node.rowRatios], columnDividerThickness: node.columnDividerThickness, rowDividerThickness: node.rowDividerThickness, sill: node.sill, sillDepth: node.sillDepth, sillThickness: node.sillThickness, metadata: { isNew: true }, }) useScene.getState().createNode(duplicate, node.parentId as AnyNodeId) setMovingNode(duplicate) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) if (!(node && node.type === 'window' && selectedId)) return null const numCols = node.columnRatios.length const numRows = node.rowRatios.length const colSum = node.columnRatios.reduce((a, b) => a + b, 0) const rowSum = node.rowRatios.reduce((a, b) => a + b, 0) const normCols = node.columnRatios.map((r) => r / colSum) const normRows = node.rowRatios.map((r) => r / rowSum) const isOpening = node.openingKind === 'opening' const openingShape = node.openingShape ?? 'rectangle' const windowShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle' const openingRadiusMode = node.openingRadiusMode ?? 'all' const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15] const cornerRadius = node.cornerRadius ?? 0.15 const archHeight = node.archHeight ?? 0.35 const openingRevealRadius = node.openingRevealRadius ?? 0.025 const maxRoundedRadius = Math.max(0.01, getMaxSharedWindowRadius(node.width, node.height)) const displayedWindowType = node.windowType === 'hopper' ? 'awning' : (node.windowType ?? 'fixed') const awningDirection = node.windowType === 'hopper' ? 'down' : (node.awningDirection ?? 'up') const windowType = node.windowType ?? 'fixed' const isFixedWindow = windowType === 'fixed' const isTrackSashWindow = windowType === 'sliding' || windowType === 'single-hung' || windowType === 'double-hung' const isOperableSashWindow = windowType === 'casement' || windowType === 'awning' || windowType === 'hopper' || windowType === 'louvered' const isOperableWindow = isTrackSashWindow || isOperableSashWindow const supportsWindowShape = shapedWindowTypes.has(node.windowType ?? 'fixed') const supportsGrid = isFixedWindow const supportsSill = !silllessWindowTypes.has(node.windowType) const showWindowTypeSection = !isOpening const showWindowShapeSection = !isOpening && supportsWindowShape const showOpeningShapeSection = isOpening const showFrameSection = !isOpening const showGridSection = !isOpening && supportsGrid const showSillSection = !isOpening && supportsSill const showOperationSection = !isOpening && isOperableWindow const showAwningDirectionSection = !isOpening && displayedWindowType === 'awning' const showCasementSection = !isOpening && windowType === 'casement' const showFlipSide = !isOpening const operationLabel = isTrackSashWindow ? windowType === 'sliding' ? 'Slide' : 'Raise' : windowType === 'casement' ? 'Swing' : windowType === 'louvered' ? 'Slats' : 'Tilt' const setOperationState = (value: number) => { useInteractive.getState().cancelWindowAnimation(node.id) useInteractive.getState().removeWindowOpenState(node.id) handleUpdate({ operationState: Math.max(0, Math.min(1, value)) }) } const getDimensionUpdates = (updates: Partial>) => { const nextWidth = updates.width ?? node.width const nextHeight = updates.height ?? node.height const nextUpdates: Partial = { ...updates } if (openingShape === 'rounded') { if (openingRadiusMode === 'individual') { const currentRadii = openingCornerRadii as [number, number, number, number] const nextRadii = normalizeWindowCornerRadii( openingCornerRadii as [number, number, number, number], nextWidth, nextHeight, ) if (!isSameRadiusTuple(currentRadii, nextRadii)) { nextUpdates.openingCornerRadii = nextRadii } } else { const nextRadius = Math.min( Math.max(cornerRadius, 0), getMaxSharedWindowRadius(nextWidth, nextHeight), ) if (Math.abs(nextRadius - cornerRadius) > 1e-6) { nextUpdates.cornerRadius = nextRadius } } } if (openingShape === 'arch') { const nextArchHeight = Math.min(Math.max(archHeight, 0.05), Math.max(nextHeight, 0.05)) if (Math.abs(nextArchHeight - archHeight) > 1e-6) { nextUpdates.archHeight = nextArchHeight } } return nextUpdates } const setOpeningCornerRadius = (index: number, value: number, commit = false) => { const next = [...openingCornerRadii] as [number, number, number, number] next[index] = value if (commit) { commitWindowPreview('openingCornerRadii', next) } else { previewWindowUpdate('openingCornerRadii', next) } } const setColumnRatio = (index: number, newVal: number) => { const clamped = Math.max(0.05, Math.min(0.95, newVal)) const neighborIdx = index < numCols - 1 ? index + 1 : index - 1 const delta = clamped - normCols[index]! const neighborVal = Math.max(0.05, normCols[neighborIdx]! - delta) const newRatios = normCols.map((v, i) => { if (i === index) return clamped if (i === neighborIdx) return neighborVal return v }) handleUpdate({ columnRatios: newRatios }) } const setRowRatio = (index: number, newVal: number) => { const clamped = Math.max(0.05, Math.min(0.95, newVal)) const neighborIdx = index < numRows - 1 ? index + 1 : index - 1 const delta = clamped - normRows[index]! const neighborVal = Math.max(0.05, normRows[neighborIdx]! - delta) const newRatios = normRows.map((v, i) => { if (i === index) return clamped if (i === neighborIdx) return neighborVal return v }) handleUpdate({ rowRatios: newRatios }) } return ( handleUpdate({ openingKind: value as WindowNode['openingKind'], ...(value === 'opening' ? { openingShape, openingRadiusMode, openingCornerRadii, cornerRadius, archHeight, openingRevealRadius, } : {}), }) } options={[ { value: 'window', label: 'Window' }, { value: 'opening', label: 'Opening' }, ]} value={node.openingKind ?? 'window'} /> {showWindowTypeSection && (
{windowTypeOptions.map((option) => { const isSelected = displayedWindowType === option.value return ( ) })}
{showAwningDirectionSection && (
handleUpdate({ windowType: 'awning', awningDirection: value as WindowNode['awningDirection'], }) } options={[ { value: 'up', label: 'Up' }, { value: 'down', label: 'Down' }, ]} value={awningDirection} />
)} {showCasementSection && (
handleUpdate({ casementStyle: value as WindowNode['casementStyle'] }) } options={[ { value: 'single', label: 'Single' }, { value: 'french', label: 'French' }, ]} value={node.casementStyle ?? 'single'} /> {(node.casementStyle ?? 'single') === 'single' && ( handleUpdate({ hingesSide: value as WindowNode['hingesSide'] }) } options={[ { value: 'left', label: 'Left' }, { value: 'right', label: 'Right' }, ]} value={node.hingesSide ?? 'left'} /> )}
)} {showOperationSection && (
)}
)} Xpos } onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })} precision={2} step={0.1} unit="m" value={Math.round(node.position[0] * 100) / 100} /> Ypos } onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })} precision={2} step={0.1} unit="m" value={Math.round(node.position[1] * 100) / 100} /> {showFlipSide && (
} label="Flip Side" onClick={handleFlip} />
)}
handleUpdate(getDimensionUpdates({ width: v }))} precision={2} restoreOnCommit={false} step={0.1} unit="m" value={Math.round(node.width * 100) / 100} /> handleUpdate(getDimensionUpdates({ height: v }))} precision={2} restoreOnCommit={false} step={0.1} unit="m" value={Math.round(node.height * 100) / 100} /> {showWindowShapeSection && ( handleUpdate({ openingShape: value as WindowNode['openingShape'], ...(value === 'rounded' ? { openingRadiusMode, openingCornerRadii, cornerRadius: Math.min(cornerRadius, maxRoundedRadius), openingRevealRadius, sill: false, } : {}), ...(value === 'arch' ? { archHeight } : {}), }) } options={[ { value: 'rectangle', label: 'Rect' }, { value: 'rounded', label: 'Rounded' }, { value: 'arch', label: 'Arch' }, ]} value={windowShape} /> {windowShape === 'rounded' && (
handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] }) } options={[ { value: 'all', label: 'All' }, { value: 'individual', label: 'Individual' }, ]} value={openingRadiusMode} /> {openingRadiusMode === 'all' ? ( previewWindowUpdate('cornerRadius', value)} onCommit={(value) => commitWindowPreview('cornerRadius', value)} precision={2} step={0.05} unit="m" value={Math.round(cornerRadius * 100) / 100} /> ) : ( <> {[ ['Top Left', 0], ['Top Right', 1], ['Bottom Right', 2], ['Bottom Left', 3], ].map(([label, index]) => ( setOpeningCornerRadius(index as number, value)} onCommit={(value) => setOpeningCornerRadius(index as number, value, true)} precision={2} step={0.05} unit="m" value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100} /> ))} )} previewWindowUpdate('openingRevealRadius', value)} onCommit={(value) => commitWindowPreview('openingRevealRadius', value)} precision={3} step={0.005} unit="m" value={Math.round(openingRevealRadius * 1000) / 1000} />
)} {windowShape === 'arch' && (
handleUpdate({ archHeight: value })} precision={2} restoreOnCommit={false} step={0.05} unit="m" value={Math.round(archHeight * 100) / 100} />
)}
)} {showOpeningShapeSection && ( handleUpdate({ openingShape: value as WindowNode['openingShape'] }) } options={[ { value: 'rectangle', label: 'Rect' }, { value: 'rounded', label: 'Rounded' }, { value: 'arch', label: 'Arch' }, ]} value={openingShape} /> {openingShape === 'rounded' && (
handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] }) } options={[ { value: 'all', label: 'All' }, { value: 'individual', label: 'Individual' }, ]} value={openingRadiusMode} /> {openingRadiusMode === 'all' ? ( previewWindowUpdate('cornerRadius', value)} onCommit={(value) => commitWindowPreview('cornerRadius', value)} precision={2} step={0.05} unit="m" value={Math.round(cornerRadius * 100) / 100} /> ) : ( <> {[ ['Top Left', 0], ['Top Right', 1], ['Bottom Right', 2], ['Bottom Left', 3], ].map(([label, index]) => ( setOpeningCornerRadius(index as number, value)} onCommit={(value) => setOpeningCornerRadius(index as number, value, true)} precision={2} step={0.05} unit="m" value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100} /> ))} )} previewWindowUpdate('openingRevealRadius', value)} onCommit={(value) => commitWindowPreview('openingRevealRadius', value)} precision={3} step={0.005} unit="m" value={Math.round(openingRevealRadius * 1000) / 1000} />
)} {openingShape === 'arch' && (
handleUpdate({ archHeight: value })} precision={2} restoreOnCommit={false} step={0.05} unit="m" value={Math.round(archHeight * 100) / 100} />
)}
)} {!isOpening && ( <> {showFrameSection && ( handleUpdate({ frameThickness: v })} precision={3} step={0.01} unit="m" value={Math.round(node.frameThickness * 1000) / 1000} /> handleUpdate({ frameDepth: v })} precision={3} step={0.01} unit="m" value={Math.round(node.frameDepth * 1000) / 1000} /> )} {showGridSection && ( { const n = Math.max(1, Math.min(8, Math.round(v))) handleUpdate({ columnRatios: Array(n).fill(1 / n) }) }} precision={0} step={1} value={numCols} /> { const n = Math.max(1, Math.min(8, Math.round(v))) handleUpdate({ rowRatios: Array(n).fill(1 / n) }) }} precision={0} step={1} value={numRows} /> {numCols > 1 && (
Col Widths
{normCols.map((ratio, i) => ( setColumnRatio(i, v / 100)} precision={1} step={1} unit="%" value={Math.round(ratio * 100 * 10) / 10} /> ))}
handleUpdate({ columnDividerThickness: v })} precision={3} step={0.01} unit="m" value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000} />
)} {numRows > 1 && (
Row Heights
{normRows.map((ratio, i) => ( setRowRatio(i, v / 100)} precision={1} step={1} unit="%" value={Math.round(ratio * 100 * 10) / 10} /> ))}
handleUpdate({ rowDividerThickness: v })} precision={3} step={0.01} unit="m" value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000} />
)}
)} {showSillSection && ( handleUpdate({ sill: checked })} /> {node.sill && (
handleUpdate({ sillDepth: v })} precision={3} step={0.01} unit="m" value={Math.round(node.sillDepth * 1000) / 1000} /> handleUpdate({ sillThickness: v })} precision={3} step={0.01} unit="m" value={Math.round(node.sillThickness * 1000) / 1000} />
)}
)} )} } label="Move" onClick={handleMove} /> } label="Duplicate" onClick={handleDuplicate} /> } label="Delete" onClick={handleDelete} />
) }