585 lines
20 KiB
TypeScript

'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 | false | undefined | null>): 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<SkylightNode> | undefined) : undefined,
)
const node = storeNode && overrides ? ({ ...storeNode, ...overrides } as SkylightNode) : storeNode
const handleUpdate = useCallback(
(updates: Partial<SkylightNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const previewProp = useCallback(
(updates: Partial<SkylightNode>) => {
if (!selectedId) return
useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, updates)
},
[selectedId],
)
const commitProp = useCallback(
(updates: Partial<SkylightNode>) => {
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?.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<SkylightNode>)
} 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<SkylightNode>)
}
return (
<PanelWrapper
icon="/icons/roof.webp"
onBack={node.roofSegmentId ? handleBack : undefined}
onClose={handleClose}
title={node.name || 'Skylight'}
width={300}
>
<PanelSection title="Type">
<div className="grid grid-cols-2 gap-2 px-1 pt-1">
{SKYLIGHT_TYPE_ORDER.map((skylightType) => {
const isSelected = activeSkylightType === skylightType
return (
<button
className={cn(
'flex min-h-12 items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left text-xs transition-colors',
isSelected
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
)}
key={skylightType}
onClick={() => handleTypeChange(skylightType)}
type="button"
>
<span className="min-w-0 truncate font-medium">
{SKYLIGHT_TYPE_PRESETS[skylightType].label}
</span>
</button>
)
})}
</div>
<SliderControl
label="Glass Thickness"
max={0.05}
min={0.002}
onChange={(v) => 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' && (
<>
<SliderControl
label="Lantern Height"
max={1.0}
min={0.05}
onChange={(v) => 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}
/>
<SliderControl
label="Top Scale"
max={0.95}
min={0}
onChange={(v) => 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' && (
<>
<SliderControl
label="Open"
max={1}
min={0}
onChange={(v) => previewProp({ operationState: v })}
onCommit={(v) => commitProp({ operationState: v })}
precision={2}
restoreOnCommit={false}
step={0.01}
unit=""
value={Math.round((node.operationState ?? 0) * 100) / 100}
/>
<SliderControl
label="Opening Angle"
max={80}
min={0}
onChange={(deg) => 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)}
/>
<SegmentedControl
onChange={(v) => 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}
/>
<SegmentedControl
onChange={(v) => commitProp({ motorHousing: v === 'yes' })}
options={[
{ label: 'Motor', value: 'yes' },
{ label: 'No Motor', value: 'no' },
]}
value={(node.motorHousing ?? false) ? 'yes' : 'no'}
/>
{(node.motorHousing ?? false) && (
<SliderControl
label="Motor Housing"
max={0.2}
min={0.03}
onChange={(v) => 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' && (
<>
<SliderControl
label="Open"
max={1}
min={0}
onChange={(v) => previewProp({ operationState: v })}
onCommit={(v) => commitProp({ operationState: v })}
precision={2}
restoreOnCommit={false}
step={0.01}
unit=""
value={Math.round((node.operationState ?? 0) * 100) / 100}
/>
<SegmentedControl
onChange={(v) => commitProp({ slideDirection: v as SkylightNode['slideDirection'] })}
options={[
{ label: 'Along Z', value: 'z' },
{ label: 'Along X', value: 'x' },
]}
value={(node.slideDirection ?? 'z') as any}
/>
<SliderControl
label="Track Width"
max={0.12}
min={0.02}
onChange={(v) => 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}
/>
</>
)}
</PanelSection>
<PanelSection title="Dimensions">
<SliderControl
label="Width"
max={3}
min={0.3}
onChange={(v) => previewProp({ width: v })}
onCommit={(v) => commitProp({ width: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(node.width * 100) / 100}
/>
<SliderControl
label="Height"
max={3}
min={0.3}
onChange={(v) => previewProp({ height: v })}
onCommit={(v) => commitProp({ height: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(node.height * 100) / 100}
/>
</PanelSection>
<PanelSection title="Frame">
<SliderControl
label="Thickness"
max={0.2}
min={0.02}
onChange={(v) => 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}
/>
<SliderControl
label="Depth"
max={0.3}
min={0.02}
onChange={(v) => 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}
/>
<SliderControl
label="Cutout Offset"
max={0.2}
min={0}
onChange={(v) => 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}
/>
</PanelSection>
<PanelSection title="Curb">
<SegmentedControl
onChange={(v) => handleUpdate({ curb: v === 'yes' })}
options={[
{ label: 'Yes', value: 'yes' },
{ label: 'No', value: 'no' },
]}
value={(node.curb ?? false) ? 'yes' : 'no'}
/>
{(node.curb ?? false) && (
<SliderControl
label="Height"
max={0.3}
min={0.02}
onChange={(v) => 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}
/>
)}
</PanelSection>
<PanelSection title="Position">
<SliderControl
label="X"
max={Math.round(worldMaxX * 10) / 10}
min={Math.round(worldMinX * 10) / 10}
onChange={(newWorldX) => {
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}
/>
<SliderControl
label="Z"
max={Math.round(worldMaxZ * 10) / 10}
min={Math.round(worldMinZ * 10) / 10}
onChange={(newWorldZ) => {
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}
/>
<SliderControl
label="Rotation"
max={180}
min={-180}
onChange={(degrees) => {
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)}
/>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton
className="hover:bg-red-500/20"
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
label="Delete"
onClick={handleDelete}
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}