162 lines
5.3 KiB
TypeScript
162 lines
5.3 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type AnyNodeId,
|
|
DownspoutNode,
|
|
type DownspoutNode as DownspoutNodeType,
|
|
type GutterNode,
|
|
generateId,
|
|
type RoofSegmentNode,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import { ActionButton, ActionGroup, PanelSection, triggerSFX } from '@pascal-app/editor'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { useShallow } from 'zustand/react/shallow'
|
|
import { computeEaveY } from './eave-snap'
|
|
import { resolveGutterOutletById } from './outlet-lookup'
|
|
|
|
const DEFAULT_OUTLET_DIAMETER = 0.07
|
|
|
|
/**
|
|
* Pick an along-length offset for a new outlet that doesn't land on an
|
|
* existing one: the first goes near the right end, the rest drop into
|
|
* the midpoint of the widest gap (including the gaps to each end). So
|
|
* "Add Downspout" repeatedly spreads outlets along the run instead of
|
|
* stacking them.
|
|
*/
|
|
function nextOutletOffset(gutter: GutterNode): number {
|
|
const len = Math.max(0.05, gutter.length)
|
|
const margin = 0.12
|
|
const lo = -len / 2 + margin
|
|
const hi = len / 2 - margin
|
|
if (hi <= lo) return 0
|
|
|
|
const existing = (gutter.outlets ?? [])
|
|
.map((o) => Math.max(lo, Math.min(hi, o.offset ?? 0)))
|
|
.sort((a, b) => a - b)
|
|
if (existing.length === 0) return hi
|
|
|
|
const bounds = [lo, ...existing, hi]
|
|
let bestMid = (lo + hi) / 2
|
|
let bestGap = -1
|
|
for (let i = 0; i < bounds.length - 1; i++) {
|
|
const gap = bounds[i + 1]! - bounds[i]!
|
|
if (gap > bestGap) {
|
|
bestGap = gap
|
|
bestMid = (bounds[i]! + bounds[i + 1]!) / 2
|
|
}
|
|
}
|
|
return bestMid
|
|
}
|
|
|
|
/**
|
|
* Downspouts subsection at the bottom of the gutter inspector. Lists the
|
|
* downspouts attached to this gutter (one per outlet); "Add Downspout"
|
|
* drills a fresh outlet at a spread-out position and drops a downspout
|
|
* on it, and each row's ✕ removes both the downspout and its outlet.
|
|
*/
|
|
export default function DownspoutsPanel() {
|
|
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
|
|
const setSelection = useViewer((s) => s.setSelection)
|
|
|
|
const gutter = useScene((s) =>
|
|
selectedId ? (s.nodes[selectedId] as GutterNode | undefined) : undefined,
|
|
)
|
|
|
|
const downspouts = useScene(
|
|
useShallow((s) => {
|
|
if (!selectedId) return [] as DownspoutNodeType[]
|
|
const out: DownspoutNodeType[] = []
|
|
for (const n of Object.values(s.nodes)) {
|
|
if (n?.type === 'downspout' && n.gutterId === selectedId) {
|
|
out.push(n as DownspoutNodeType)
|
|
}
|
|
}
|
|
return out
|
|
}),
|
|
)
|
|
|
|
if (gutter?.type !== 'gutter') return null
|
|
|
|
const handleSelectDownspout = (id: AnyNodeId) => {
|
|
setSelection({ selectedIds: [id] })
|
|
}
|
|
|
|
const handleAddDownspout = () => {
|
|
const segmentId = gutter.roofSegmentId as AnyNodeId | undefined
|
|
if (!segmentId) return
|
|
const segment = useScene.getState().nodes[segmentId] as RoofSegmentNode | undefined
|
|
if (!segment) return
|
|
|
|
const outletId = generateId('outlet')
|
|
const outlets = [
|
|
...(gutter.outlets ?? []),
|
|
{ id: outletId, offset: nextOutletOffset(gutter), diameter: DEFAULT_OUTLET_DIAMETER },
|
|
]
|
|
const state = useScene.getState()
|
|
state.updateNode(gutter.id as AnyNodeId, { outlets })
|
|
state.dirtyNodes.add(gutter.id as AnyNodeId)
|
|
|
|
const outlet = resolveGutterOutletById({ ...gutter, outlets }, outletId)
|
|
const dropLength = Math.max(0.1, computeEaveY(segment) + (outlet?.y ?? -gutter.size))
|
|
|
|
const downspout = DownspoutNode.parse({
|
|
name: 'Downspout',
|
|
gutterId: gutter.id,
|
|
outletId,
|
|
length: dropLength,
|
|
diameter: (outlet?.bore ?? DEFAULT_OUTLET_DIAMETER / 2) * 2,
|
|
})
|
|
state.createNode(downspout, segmentId)
|
|
state.dirtyNodes.add(segmentId)
|
|
setSelection({ selectedIds: [downspout.id] })
|
|
triggerSFX('sfx:item-place')
|
|
}
|
|
|
|
const handleRemove = (downspout: DownspoutNodeType) => {
|
|
const state = useScene.getState()
|
|
// Drop the outlet this downspout drained so its hole closes up.
|
|
if (downspout.outletId) {
|
|
state.updateNode(gutter.id as AnyNodeId, {
|
|
outlets: (gutter.outlets ?? []).filter((o) => o.id !== downspout.outletId),
|
|
})
|
|
state.dirtyNodes.add(gutter.id as AnyNodeId)
|
|
}
|
|
state.deleteNode(downspout.id as AnyNodeId)
|
|
if (gutter.roofSegmentId) state.dirtyNodes.add(gutter.roofSegmentId as AnyNodeId)
|
|
setSelection({ selectedIds: [gutter.id as AnyNodeId] })
|
|
}
|
|
|
|
return (
|
|
<PanelSection title="Downspouts">
|
|
<div className="flex flex-col gap-1">
|
|
{downspouts.map((d, i) => (
|
|
<div
|
|
className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm"
|
|
key={d.id}
|
|
>
|
|
<button
|
|
className="flex-1 truncate text-left transition-colors hover:text-white"
|
|
onClick={() => handleSelectDownspout(d.id as AnyNodeId)}
|
|
type="button"
|
|
>
|
|
{d.name || `Downspout ${i + 1}`}
|
|
</button>
|
|
<button
|
|
aria-label="Remove downspout"
|
|
className="ml-2 text-muted-foreground text-xs transition-colors hover:text-red-400"
|
|
onClick={() => handleRemove(d)}
|
|
type="button"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
<ActionGroup>
|
|
<ActionButton label="Add Downspout" onClick={handleAddDownspout} />
|
|
</ActionGroup>
|
|
</div>
|
|
</PanelSection>
|
|
)
|
|
}
|