'use client' import { type AnyNode, type AnyNodeId, type RoofNode, type RoofSegmentNode, SKYLIGHT_TYPE_ORDER, SKYLIGHT_TYPE_PRESETS, type SkylightNode, type SkylightType, sceneRegistry, useLiveNodeOverrides, useScene, } from '@pascal-app/core' import { ActionButton, ActionGroup, PanelSection, PanelWrapper, SegmentedControl, SliderControl, triggerSFX, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Trash2 } from 'lucide-react' import { useCallback } from 'react' import { Vector3 } from 'three' const cn = (...classes: Array): string => classes.filter(Boolean).join(' ') export default function SkylightPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const deleteNode = useScene((s) => s.deleteNode) const storeNode = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as SkylightNode | undefined) : undefined, ) const overrides = useLiveNodeOverrides((s) => selectedId ? (s.get(selectedId as AnyNodeId) as Partial | undefined) : undefined, ) const node = storeNode && overrides ? ({ ...storeNode, ...overrides } as SkylightNode) : storeNode const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return updateNode(selectedId as AnyNode['id'], updates) }, [selectedId, updateNode], ) const previewProp = useCallback( (updates: Partial) => { if (!selectedId) return useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, updates) }, [selectedId], ) const commitProp = useCallback( (updates: Partial) => { if (!selectedId) return updateNode(selectedId as AnyNode['id'], updates) if (updates.roofSegmentId !== undefined) { const state = useScene.getState() const prev = node?.roofSegmentId if (prev) state.dirtyNodes.add(prev as AnyNodeId) state.dirtyNodes.add(updates.roofSegmentId as AnyNodeId) state.dirtyNodes.add(selectedId as AnyNodeId) } useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) }, [node, selectedId, updateNode], ) const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) const handleBack = useCallback(() => { if (node?.roofSegmentId) { setSelection({ selectedIds: [node.roofSegmentId as AnyNode['id']] }) } }, [node?.roofSegmentId, setSelection]) const handleDelete = useCallback(() => { if (!(selectedId && node)) return triggerSFX('sfx:item-delete') const segmentId = node.roofSegmentId if (segmentId) { const state = useScene.getState() const segment = state.nodes[segmentId as AnyNodeId] as RoofSegmentNode | undefined if (segment) { state.updateNode(segmentId as AnyNode['id'], { children: (segment.children ?? []).filter((id) => id !== selectedId), }) } } deleteNode(selectedId as AnyNodeId) if (segmentId) { useScene.getState().dirtyNodes.add(segmentId as AnyNodeId) setSelection({ selectedIds: [segmentId as AnyNode['id']] }) } else { setSelection({ selectedIds: [] }) } }, [selectedId, node, deleteNode, setSelection]) if (!(node && node.type === 'skylight' && selectedId)) return null const scenestate = useScene.getState() const segment = node.roofSegmentId ? (scenestate.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) : undefined const roof = segment?.parentId ? (scenestate.nodes[segment.parentId as AnyNodeId] as RoofNode | undefined) : undefined const skylightObj = sceneRegistry.nodes.get(selectedId) if (skylightObj) skylightObj.updateWorldMatrix(true, false) const computeWorldPos = () => { if (!skylightObj) return { x: 0, z: 0 } const localPt = new Vector3(node.position[0] ?? 0, 0, node.position[2] ?? 0) const worldPt = localPt.applyMatrix4(skylightObj.matrixWorld) return { x: worldPt.x, z: worldPt.z } } const computeWorldRotation = () => { if (!skylightObj) return node.rotation ?? 0 const m = skylightObj.matrixWorld.elements const ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1) return ancestorWorldY + (node.rotation ?? 0) } const { x: worldX_now, z: worldZ_now } = computeWorldPos() const worldRotation_now = computeWorldRotation() const findSegmentForWorldPoint = ( wx: number, wz: number, ): { segment: RoofSegmentNode; localX: number; localZ: number } | null => { const state = useScene.getState() const worldPt = new Vector3(wx, 0, wz) for (const candidate of Object.values(state.nodes)) { if (!candidate || candidate.type !== 'roof-segment') continue const seg = candidate as RoofSegmentNode const segObj = sceneRegistry.nodes.get(seg.id) if (!segObj) continue segObj.updateWorldMatrix(true, false) const local = segObj.worldToLocal(worldPt.clone()) if (Math.abs(local.x) <= seg.width / 2 && Math.abs(local.z) <= seg.depth / 2) { return { segment: seg, localX: local.x, localZ: local.z } } } return null } const worldToSegLocal = ( wx: number, wz: number, seg: RoofSegmentNode, ): { localX: number; localZ: number } => { const segObj = sceneRegistry.nodes.get(seg.id) if (!segObj) return { localX: wx, localZ: wz } segObj.updateWorldMatrix(true, false) const local = segObj.worldToLocal(new Vector3(wx, 0, wz)) return { localX: local.x, localZ: local.z } } let worldMinX = worldX_now - 20 let worldMaxX = worldX_now + 20 let worldMinZ = worldZ_now - 20 let worldMaxZ = worldZ_now + 20 if (roof) { let lo_x = Number.POSITIVE_INFINITY let hi_x = Number.NEGATIVE_INFINITY let lo_z = Number.POSITIVE_INFINITY let hi_z = Number.NEGATIVE_INFINITY for (const childId of roof.children ?? []) { const seg = scenestate.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined if (!seg) continue const segObj = sceneRegistry.nodes.get(seg.id) if (!segObj) continue segObj.updateWorldMatrix(true, false) const segWorldCenter = new Vector3().applyMatrix4(segObj.matrixWorld) const r = Math.hypot(seg.width, seg.depth) / 2 lo_x = Math.min(lo_x, segWorldCenter.x - r) hi_x = Math.max(hi_x, segWorldCenter.x + r) lo_z = Math.min(lo_z, segWorldCenter.z - r) hi_z = Math.max(hi_z, segWorldCenter.z + r) } if (Number.isFinite(lo_x)) { worldMinX = lo_x worldMaxX = hi_x worldMinZ = lo_z worldMaxZ = hi_z } } const commitWorldPosition = (newWorldX: number, newWorldZ: number) => { if (!segment) return const target = findSegmentForWorldPoint(newWorldX, newWorldZ) if (target && target.segment.id !== segment.id) { const oldWorldRotation = worldRotation_now const newSegObj = sceneRegistry.nodes.get(target.segment.id) let newAncestorWorldY = 0 if (newSegObj) { newSegObj.updateWorldMatrix(true, false) const m = newSegObj.matrixWorld.elements newAncestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1) } const newSegLocalRot = oldWorldRotation - newAncestorWorldY commitProp({ roofSegmentId: target.segment.id, parentId: target.segment.id, position: [target.localX, 0, target.localZ], rotation: newSegLocalRot, } as Partial) } else { const local = worldToSegLocal(newWorldX, newWorldZ, segment) commitProp({ position: [local.localX, 0, local.localZ] }) } } const commitWorldRotation = (newWorldRot: number) => { if (!skylightObj) return const m = skylightObj.matrixWorld.elements const ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1) commitProp({ rotation: newWorldRot - ancestorWorldY }) } const activeSkylightType = node.skylightType ?? 'flat' const handleTypeChange = (skylightType: SkylightType) => { const preset = SKYLIGHT_TYPE_PRESETS[skylightType] commitProp({ skylightType, width: preset.width, height: preset.height, frameThickness: preset.frameThickness, frameDepth: preset.frameDepth, glassThickness: preset.glassThickness, curb: preset.curb, curbHeight: preset.curbHeight, cutoutOffset: preset.cutoutOffset, lanternHeight: preset.lanternHeight, lanternTopScale: preset.lanternTopScale, openingAngle: preset.openingAngle, openingSide: preset.openingSide, operationState: preset.operationState, motorHousing: preset.motorHousing, slideFraction: preset.slideFraction, slideDirection: preset.slideDirection, trackWidth: preset.trackWidth, motorHousingSize: preset.motorHousingSize, } as Partial) } return (
{SKYLIGHT_TYPE_ORDER.map((skylightType) => { const isSelected = activeSkylightType === skylightType return ( ) })}
previewProp({ glassThickness: v })} onCommit={(v) => commitProp({ glassThickness: v })} precision={3} restoreOnCommit={false} step={0.001} unit="m" value={Math.round((node.glassThickness ?? 0.01) * 1000) / 1000} /> {activeSkylightType === 'lantern' && ( <> previewProp({ lanternHeight: v })} onCommit={(v) => commitProp({ lanternHeight: v })} precision={3} restoreOnCommit={false} step={0.01} unit="m" value={Math.round((node.lanternHeight ?? 0.25) * 1000) / 1000} /> previewProp({ lanternTopScale: v })} onCommit={(v) => commitProp({ lanternTopScale: v })} precision={2} restoreOnCommit={false} step={0.05} unit="" value={Math.round((node.lanternTopScale ?? 0.25) * 100) / 100} /> )} {activeSkylightType === 'opening' && ( <> previewProp({ operationState: v })} onCommit={(v) => commitProp({ operationState: v })} precision={2} restoreOnCommit={false} step={0.01} unit="" value={Math.round((node.operationState ?? 0) * 100) / 100} /> previewProp({ openingAngle: (deg * Math.PI) / 180 })} onCommit={(deg) => commitProp({ openingAngle: (deg * Math.PI) / 180 })} precision={0} restoreOnCommit={false} step={1} unit="°" value={Math.round(((node.openingAngle ?? Math.PI / 10) * 180) / Math.PI)} /> commitProp({ openingSide: v as SkylightNode['openingSide'] })} options={[ { label: 'Top', value: 'top' }, { label: 'Bottom', value: 'bottom' }, { label: 'Left', value: 'left' }, { label: 'Right', value: 'right' }, ]} value={(node.openingSide ?? 'top') as any} /> commitProp({ motorHousing: v === 'yes' })} options={[ { label: 'Motor', value: 'yes' }, { label: 'No Motor', value: 'no' }, ]} value={(node.motorHousing ?? false) ? 'yes' : 'no'} /> {(node.motorHousing ?? false) && ( previewProp({ motorHousingSize: v })} onCommit={(v) => commitProp({ motorHousingSize: v })} precision={3} restoreOnCommit={false} step={0.005} unit="m" value={Math.round((node.motorHousingSize ?? 0.08) * 1000) / 1000} /> )} )} {activeSkylightType === 'sliding' && ( <> previewProp({ operationState: v })} onCommit={(v) => commitProp({ operationState: v })} precision={2} restoreOnCommit={false} step={0.01} unit="" value={Math.round((node.operationState ?? 0) * 100) / 100} /> commitProp({ slideDirection: v as SkylightNode['slideDirection'] })} options={[ { label: 'Along Z', value: 'z' }, { label: 'Along X', value: 'x' }, ]} value={(node.slideDirection ?? 'z') as any} /> previewProp({ trackWidth: v })} onCommit={(v) => commitProp({ trackWidth: v })} precision={3} restoreOnCommit={false} step={0.005} unit="m" value={Math.round((node.trackWidth ?? 0.045) * 1000) / 1000} /> )}
previewProp({ width: v })} onCommit={(v) => commitProp({ width: v })} precision={2} restoreOnCommit={false} step={0.05} unit="m" value={Math.round(node.width * 100) / 100} /> previewProp({ height: v })} onCommit={(v) => commitProp({ height: v })} precision={2} restoreOnCommit={false} step={0.05} unit="m" value={Math.round(node.height * 100) / 100} /> previewProp({ frameThickness: v })} onCommit={(v) => commitProp({ frameThickness: v })} precision={3} restoreOnCommit={false} step={0.005} unit="m" value={Math.round((node.frameThickness ?? 0.05) * 1000) / 1000} /> previewProp({ frameDepth: v })} onCommit={(v) => commitProp({ frameDepth: v })} precision={3} restoreOnCommit={false} step={0.005} unit="m" value={Math.round((node.frameDepth ?? 0.08) * 1000) / 1000} /> previewProp({ cutoutOffset: v })} onCommit={(v) => commitProp({ cutoutOffset: v })} precision={3} restoreOnCommit={false} step={0.005} unit="m" value={Math.round((node.cutoutOffset ?? 0.01) * 1000) / 1000} /> handleUpdate({ curb: v === 'yes' })} options={[ { label: 'Yes', value: 'yes' }, { label: 'No', value: 'no' }, ]} value={(node.curb ?? false) ? 'yes' : 'no'} /> {(node.curb ?? false) && ( previewProp({ curbHeight: v })} onCommit={(v) => commitProp({ curbHeight: v })} precision={3} restoreOnCommit={false} step={0.005} unit="m" value={Math.round((node.curbHeight ?? 0.1) * 1000) / 1000} /> )} { if (!segment) return const local = worldToSegLocal(newWorldX, worldZ_now, segment) previewProp({ position: [local.localX, 0, local.localZ] }) }} onCommit={(newWorldX) => commitWorldPosition(newWorldX, worldZ_now)} precision={2} restoreOnCommit={false} step={0.05} unit="m" value={Math.round(worldX_now * 100) / 100} /> { if (!segment) return const local = worldToSegLocal(worldX_now, newWorldZ, segment) previewProp({ position: [local.localX, 0, local.localZ] }) }} onCommit={(newWorldZ) => commitWorldPosition(worldX_now, newWorldZ)} precision={2} restoreOnCommit={false} step={0.05} unit="m" value={Math.round(worldZ_now * 100) / 100} /> { const newWorldRot = (degrees * Math.PI) / 180 if (!skylightObj) return const m = skylightObj.matrixWorld.elements const ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1) previewProp({ rotation: newWorldRot - ancestorWorldY }) }} onCommit={(degrees) => commitWorldRotation((degrees * Math.PI) / 180)} precision={0} restoreOnCommit={false} step={1} unit="°" value={Math.round((worldRotation_now * 180) / Math.PI)} /> } label="Delete" onClick={handleDelete} />
) }