98 lines
3.8 KiB
TypeScript
98 lines
3.8 KiB
TypeScript
import type { GutterNode, RoofSegmentNode } from '@pascal-app/core'
|
||
import { CORNER_EPSILON_SQ, gutterEndpointsInFrame, planDistSq } from './corner-mitre'
|
||
import { computeEaveY } from './eave-snap'
|
||
|
||
/**
|
||
* Shared eave-Y for a connected run of gutters.
|
||
*
|
||
* Each gutter normally derives its mount height independently from its
|
||
* own host segment via `computeEaveY` (wallHeight − overhang·tan(pitch)
|
||
* + tuck, or wallHeight on a flat deck). Two segments that read as the
|
||
* same height in the inspector can still land at different eave Ys when
|
||
* their pitch / overhang / roofType differ — so gutters that meet at a
|
||
* corner inherit two heights and the run visibly steps at the joint.
|
||
*
|
||
* This walks the connected component of gutters that meet at corners
|
||
* (same plan-space endpoint test the mitre detector uses — Y is
|
||
* deliberately ignored, so the grouping survives the very height drift
|
||
* we're correcting) and returns ONE height for the whole run: the
|
||
* HIGHEST member eave. Aligning up means no gutter ever sinks into a
|
||
* roof surface; a lower roof gets a small fascia gap, which reads
|
||
* cleaner than a gutter clipping through its slope.
|
||
*
|
||
* Deterministic + symmetric: every gutter in the run computes the same
|
||
* component and the same max, so they all converge on the identical Y
|
||
* without any shared coordinator or store write. Isolated gutters (no
|
||
* corner neighbour) get their own eave Y unchanged.
|
||
*
|
||
* Pure: no React, no scene access, no store mutation.
|
||
*/
|
||
|
||
/** A sibling gutter paired with its FULL host segment (needs the eave-Y inputs). */
|
||
export type GutterWithSegment = {
|
||
gutter: GutterNode
|
||
segment: RoofSegmentNode
|
||
}
|
||
|
||
function guttersMeet(
|
||
a: GutterNode,
|
||
aSeg: RoofSegmentNode,
|
||
b: GutterNode,
|
||
bSeg: RoofSegmentNode,
|
||
): boolean {
|
||
const ea = gutterEndpointsInFrame(a, aSeg)
|
||
const eb = gutterEndpointsInFrame(b, bSeg)
|
||
return (
|
||
planDistSq(ea.minus.pos, eb.plus.pos) <= CORNER_EPSILON_SQ ||
|
||
planDistSq(ea.minus.pos, eb.minus.pos) <= CORNER_EPSILON_SQ ||
|
||
planDistSq(ea.plus.pos, eb.plus.pos) <= CORNER_EPSILON_SQ ||
|
||
planDistSq(ea.plus.pos, eb.minus.pos) <= CORNER_EPSILON_SQ
|
||
)
|
||
}
|
||
|
||
// Each gutter mounts at `segment.position[1] + computeEaveY(segment)` in
|
||
// the roof frame (the renderer adds the segment-local eave Y under the
|
||
// segment's group). Segments can sit at different Y offsets, so the run
|
||
// has to be compared — and the answer returned — in the SHARED roof
|
||
// frame, not raw segment-local eave Ys.
|
||
function worldEaveY(segment: RoofSegmentNode): number {
|
||
return (segment.position?.[1] ?? 0) + computeEaveY(segment)
|
||
}
|
||
|
||
export function computeSharedEaveY(
|
||
subject: GutterNode,
|
||
subjectSegment: RoofSegmentNode,
|
||
siblings: readonly GutterWithSegment[],
|
||
): number {
|
||
const subjectBaseY = subjectSegment.position?.[1] ?? 0
|
||
if (siblings.length === 0) return computeEaveY(subjectSegment)
|
||
|
||
// Index 0 is the subject; the rest are candidates. BFS the corner
|
||
// graph from the subject and keep the tallest eave in its component.
|
||
const nodes: GutterWithSegment[] = [{ gutter: subject, segment: subjectSegment }, ...siblings]
|
||
const visited = new Array<boolean>(nodes.length).fill(false)
|
||
visited[0] = true
|
||
const queue = [0]
|
||
let maxWorldEaveY = worldEaveY(subjectSegment)
|
||
|
||
while (queue.length > 0) {
|
||
const i = queue.pop()!
|
||
const cur = nodes[i]!
|
||
for (let j = 0; j < nodes.length; j++) {
|
||
if (visited[j]) continue
|
||
const other = nodes[j]!
|
||
if (guttersMeet(cur.gutter, cur.segment, other.gutter, other.segment)) {
|
||
visited[j] = true
|
||
queue.push(j)
|
||
const eaveY = worldEaveY(other.segment)
|
||
if (eaveY > maxWorldEaveY) maxWorldEaveY = eaveY
|
||
}
|
||
}
|
||
}
|
||
|
||
// Back to the SUBJECT's segment-local frame — the renderer applies the
|
||
// returned value under the subject segment's group, which already adds
|
||
// `subjectBaseY`.
|
||
return maxWorldEaveY - subjectBaseY
|
||
}
|