560 lines
16 KiB
TypeScript

'use client'
import {
type AnyNode,
type AnyNodeId,
createDefaultRidgeVentsForSegment,
isAutoRidgeVentEnabled,
isDefaultRidgeVentNode,
ROOF_SHAPE_DEFAULTS,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
type RoofType,
useScene,
} from '@pascal-app/core'
import {
ActionButton,
ActionGroup,
PanelSection,
PanelWrapper,
SegmentedControl,
SliderControl,
ToggleControl,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
const ROOF_TYPE_OPTIONS: { label: string; value: RoofType }[] = [
{ label: 'Hip', value: 'hip' },
{ label: 'Gable', value: 'gable' },
{ label: 'Shed', value: 'shed' },
]
const ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [
{ label: 'Flat', value: 'flat' },
{ label: 'Gambrel', value: 'gambrel' },
{ label: 'Dutch', value: 'dutch' },
{ label: 'Mansard', value: 'mansard' },
]
// Carpenter / roofer convention: rise over a 12" run, converted to degrees.
// atan(3/12) ≈ 14.04°, atan(6/12) ≈ 26.57°, atan(9/12) ≈ 36.87°, atan(12/12) = 45°.
const PITCH_PRESETS: { label: string; deg: number }[] = [
{ label: '3/12', deg: 14.04 },
{ label: '6/12', deg: 26.57 },
{ label: '9/12', deg: 36.87 },
{ label: '12/12', deg: 45 },
]
function shouldShowTrimPlanes(metadata: unknown): boolean {
return metadataRecord(metadata).showTrimPlanes === true
}
function metadataRecord(metadata: unknown): Record<string, unknown> {
if (typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata)) {
return metadata as Record<string, unknown>
}
return {}
}
export default function RoofSegmentPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setRoofHostDragArmedId = useEditor((s) => s.setRoofHostDragArmedId)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined) : undefined,
)
const autoRidgeVentEnabled = useScene((s) => {
const current = selectedId
? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined)
: undefined
if (current?.type !== 'roof-segment') return false
return isAutoRidgeVentEnabled(current, s.nodes)
})
const handleUpdate = useCallback(
(updates: Partial<RoofSegmentNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const handleRoofTypeChange = useCallback(
(roofType: RoofType) => {
// Switching to Dutch resets the shape parameters to their defaults so the
// gablet is well-formed regardless of the leftover values from the
// previous roof type.
handleUpdate(
roofType === 'dutch'
? {
roofType,
dutchHipWidthRatio: ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio,
dutchHipHeightRatio: ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio,
dutchWaistLengthRatio: ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio,
dutchGabletRake: ROOF_SHAPE_DEFAULTS.dutchGabletRake,
dutchTopRakeThickness: ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness,
}
: { roofType },
)
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleBack = useCallback(() => {
if (node?.parentId) {
setRoofHostDragArmedId(node.parentId as AnyNodeId)
setSelection({ selectedIds: [node.parentId] })
}
}, [node?.parentId, setRoofHostDragArmedId, setSelection])
const handleDuplicate = useCallback(() => {
if (!node?.parentId) return
triggerSFX('sfx:item-pick')
let duplicateInfo = structuredClone(node) as any
delete duplicateInfo.id
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
// Offset slightly so it's visible
duplicateInfo.position = [
duplicateInfo.position[0] + 1,
duplicateInfo.position[1],
duplicateInfo.position[2] + 1,
]
try {
const duplicate = RoofSegmentNodeSchema.parse(duplicateInfo)
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
setSelection({ selectedIds: [] })
setMovingNode(duplicate)
} catch (e) {
console.error('Failed to duplicate roof segment', e)
}
}, [node, setSelection, setMovingNode])
const handleMove = useCallback(() => {
if (node) {
triggerSFX('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
triggerSFX('sfx:item-delete')
const parentId = node.parentId
useScene.getState().deleteNode(selectedId as AnyNodeId)
if (parentId) {
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
setSelection({ selectedIds: [parentId] })
} else {
setSelection({ selectedIds: [] })
}
}, [selectedId, node, setSelection])
const handleAutoRidgeVentToggle = useCallback(
(checked: boolean) => {
if (!selectedId) return
const scene = useScene.getState()
const current = scene.nodes[selectedId as AnyNodeId] as RoofSegmentNode | undefined
if (current?.type !== 'roof-segment') return
scene.updateNode(selectedId as AnyNodeId, {
metadata: { ...metadataRecord(current.metadata), autoRidgeVent: checked },
})
const latest = useScene.getState().nodes[selectedId as AnyNodeId] as
| RoofSegmentNode
| undefined
if (latest?.type !== 'roof-segment') return
const defaultVentIds = (latest.children ?? []).filter((childId) =>
isDefaultRidgeVentNode(useScene.getState().nodes[childId as AnyNodeId], latest.id),
) as AnyNodeId[]
if (!checked) {
if (defaultVentIds.length > 0) {
useScene.getState().deleteNodes(defaultVentIds)
}
return
}
if (defaultVentIds.length > 0) return
const ridgeVents = createDefaultRidgeVentsForSegment(latest)
if (ridgeVents.length === 0) return
scene.createNodes(
ridgeVents.map((ridgeVent) => ({
node: ridgeVent,
parentId: latest.id as AnyNodeId,
})),
)
},
[selectedId],
)
if (!(node && node.type === 'roof-segment' && selectedId)) return null
const showTrimPlanes = shouldShowTrimPlanes(node.metadata)
return (
<PanelWrapper
icon="/icons/roof.webp"
onBack={handleBack}
onClose={handleClose}
title={node.name || 'Roof Segment'}
width={300}
>
<PanelSection title="Roof Type">
<SegmentedControl
onChange={(v) => handleRoofTypeChange(v)}
options={ROOF_TYPE_OPTIONS}
value={node.roofType}
/>
<SegmentedControl
onChange={(v) => handleRoofTypeChange(v)}
options={ROOF_TYPE_OPTIONS_2}
value={node.roofType}
/>
</PanelSection>
<PanelSection title="Trim">
<ToggleControl
checked={showTrimPlanes}
label="Show trim planes"
onChange={(checked) =>
handleUpdate({
metadata: { ...metadataRecord(node.metadata), showTrimPlanes: checked },
})
}
/>
{node.roofType !== 'shed' && node.roofType !== 'flat' && (
<ToggleControl
checked={autoRidgeVentEnabled}
label="Auto ridge vent"
onChange={handleAutoRidgeVentToggle}
/>
)}
</PanelSection>
<PanelSection title="Footprint">
<SliderControl
label="Width"
max={25}
min={0.5}
onChange={(v) => handleUpdate({ width: v })}
precision={2}
step={0.5}
unit="m"
value={Math.round(node.width * 100) / 100}
/>
<SliderControl
label="Depth"
max={25}
min={0.5}
onChange={(v) => handleUpdate({ depth: v })}
precision={2}
step={0.5}
unit="m"
value={Math.round(node.depth * 100) / 100}
/>
</PanelSection>
<PanelSection title="Wall Height">
<SliderControl
label="Wall"
max={5}
min={0}
onChange={(v) => handleUpdate({ wallHeight: v })}
precision={2}
step={0.1}
unit="m"
value={Math.round(node.wallHeight * 100) / 100}
/>
</PanelSection>
<PanelSection title="Pitch">
<SliderControl
label="Angle"
max={60}
min={0}
onChange={(v) => handleUpdate({ pitch: v })}
precision={0}
step={1}
unit="°"
value={Math.round(node.pitch)}
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
{PITCH_PRESETS.map((preset) => (
<ActionButton
key={preset.label}
label={preset.label}
onClick={() => handleUpdate({ pitch: preset.deg })}
/>
))}
</div>
</PanelSection>
{node.roofType === 'gambrel' && (
<PanelSection title="Shape">
<SliderControl
label="Kink Depth"
max={0.9}
min={0.1}
onChange={(v) => handleUpdate({ gambrelLowerWidthRatio: v })}
precision={2}
step={0.01}
unit=""
value={Math.round(node.gambrelLowerWidthRatio * 100) / 100}
/>
<SliderControl
label="Kink Height"
max={0.9}
min={0.1}
onChange={(v) => handleUpdate({ gambrelLowerHeightRatio: v })}
precision={2}
step={0.01}
unit=""
value={Math.round(node.gambrelLowerHeightRatio * 100) / 100}
/>
</PanelSection>
)}
{node.roofType === 'mansard' && (
<PanelSection title="Shape">
<SliderControl
label="Waist Width"
max={0.45}
min={0.05}
onChange={(v) => handleUpdate({ mansardSteepWidthRatio: v })}
precision={2}
step={0.01}
unit=""
value={Math.round(node.mansardSteepWidthRatio * 100) / 100}
/>
<SliderControl
label="Waist Height"
max={0.9}
min={0.1}
onChange={(v) => handleUpdate({ mansardSteepHeightRatio: v })}
precision={2}
step={0.01}
unit=""
value={Math.round(node.mansardSteepHeightRatio * 100) / 100}
/>
</PanelSection>
)}
{node.roofType === 'dutch' && (
<PanelSection title="Shape">
<SliderControl
label="Waist Width"
max={0.45}
min={0.05}
onChange={(v) => handleUpdate({ dutchHipWidthRatio: v })}
precision={2}
step={0.01}
unit=""
value={Math.round(node.dutchHipWidthRatio * 100) / 100}
/>
<SliderControl
label="Waist Height"
max={0.9}
min={0.1}
onChange={(v) => handleUpdate({ dutchHipHeightRatio: v })}
precision={2}
step={0.01}
unit=""
value={Math.round(node.dutchHipHeightRatio * 100) / 100}
/>
<SliderControl
label="Waist Length"
max={1}
min={0.1}
onChange={(v) => handleUpdate({ dutchWaistLengthRatio: v })}
precision={2}
step={0.01}
unit=""
value={
Math.round(
(node.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio) * 100,
) / 100
}
/>
<SliderControl
label="Top Rake Thick."
max={0.5}
min={0.01}
onChange={(v) => handleUpdate({ dutchTopRakeThickness: v })}
precision={2}
step={0.01}
unit="m"
value={
Math.round(
(node.dutchTopRakeThickness ?? ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness) * 100,
) / 100
}
/>
<SliderControl
label="Top Rake Length"
max={3}
min={0}
onChange={(v) => handleUpdate({ dutchGabletRake: v })}
precision={2}
step={0.01}
unit="m"
value={
Math.round((node.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake) * 100) / 100
}
/>
</PanelSection>
)}
<PanelSection title="Structure">
<SliderControl
label="Wall Thick."
max={1}
min={0.05}
onChange={(v) => handleUpdate({ wallThickness: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.wallThickness * 100) / 100}
/>
<SliderControl
label="Deck Thick."
max={0.3}
min={0.04}
onChange={(v) => handleUpdate({ deckThickness: v })}
precision={2}
step={0.01}
unit="m"
value={Math.round(node.deckThickness * 100) / 100}
/>
<SliderControl
label="Overhang"
max={1}
min={0}
onChange={(v) => handleUpdate({ overhang: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.overhang * 100) / 100}
/>
<SliderControl
label="Shingle Thick."
max={0.3}
min={0.02}
onChange={(v) => handleUpdate({ shingleThickness: v })}
precision={2}
step={0.01}
unit="m"
value={Math.round(node.shingleThickness * 100) / 100}
/>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label="X"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[0] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<SliderControl
label="Y"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[1] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
<SliderControl
label="Z"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[2] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[2] * 100) / 100}
/>
<SliderControl
label="Rotation"
max={180}
min={-180}
onChange={(degrees) => {
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
}}
precision={0}
step={1}
unit="°"
value={Math.round((node.rotation * 180) / Math.PI)}
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => {
triggerSFX('sfx:item-rotate')
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
}}
/>
<ActionButton
label="+45°"
onClick={() => {
triggerSFX('sfx:item-rotate')
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
}}
/>
</div>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton
icon={<Copy className="h-3.5 w-3.5" />}
label="Duplicate"
onClick={handleDuplicate}
/>
<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>
)
}