diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index b3eb1a26..a57aa6eb 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -49,6 +49,26 @@ export { moveToward, resolveMovable, } from './movement' +export { + type AlongWallAlignment, + type AlongWallFeature, + computeEdgeGaps, + computeOpeningGuides, + DEFAULT_OPENING_GUIDE_TOLERANCES, + detectAlongWallAlignment, + detectEqualSpacing, + detectVerticalAlignment, + type EdgeGap, + type EqualSpacingRun, + type OpeningGuideInput, + type OpeningGuides, + type OpeningGuideTolerances, + type OpeningSpan, + type SillHeadGuide, + type VerticalAlignment, + type VerticalFeature, + type WallExtent, +} from './opening-guides' export { DEFAULT_ANGLE_STEP, DEFAULT_GRID_STEP, diff --git a/packages/core/src/services/opening-guides.test.ts b/packages/core/src/services/opening-guides.test.ts new file mode 100644 index 00000000..648800b4 --- /dev/null +++ b/packages/core/src/services/opening-guides.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from 'bun:test' +import { + computeEdgeGaps, + computeOpeningGuides, + detectAlongWallAlignment, + detectEqualSpacing, + detectVerticalAlignment, + type OpeningSpan, + type WallExtent, +} from './opening-guides' + +function span(id: string, centerS: number, width: number, centerY = 1, height = 1): OpeningSpan { + return { id, centerS, width, centerY, height } +} + +const WALL: WallExtent = { length: 10, height: 2.5 } + +describe('detectEqualSpacing', () => { + test('returns null for fewer than three openings', () => { + const a = span('a', 0.5, 1) + const b = span('b', 2.5, 1) + expect(detectEqualSpacing([a, b], 'b', 0.03, 0.02)).toBeNull() + }) + + test('detects a run of equal gaps across three openings', () => { + // width 1 each: a[0,1] b[2,3] c[4,5] → two gaps of 1m. + const a = span('a', 0.5, 1) + const b = span('b', 2.5, 1) + const c = span('c', 4.5, 1) + const run = detectEqualSpacing([a, b, c], 'b', 0.03, 0.02) + expect(run).not.toBeNull() + expect(run?.gap).toBeCloseTo(1) + expect(run?.segments).toHaveLength(2) + expect(run?.openingIds).toEqual(['a', 'b', 'c']) + expect(run?.segments[0]).toEqual({ fromS: 1, toS: 2 }) + expect(run?.segments[1]).toEqual({ fromS: 3, toS: 4 }) + }) + + test('extends a run across four openings (three gaps)', () => { + const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.5, 1), span('d', 6.5, 1)] + const run = detectEqualSpacing(openings, 'c', 0.03, 0.02) + expect(run?.segments).toHaveLength(3) + expect(run?.openingIds).toEqual(['a', 'b', 'c', 'd']) + }) + + test('returns null when gaps differ beyond tolerance', () => { + const a = span('a', 0.5, 1) // [0,1] + const b = span('b', 2.5, 1) // [2,3] → gap 1 + const c = span('c', 5, 1) // [4.5,5.5] → gap 1.5 + expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull() + }) + + test('returns null when the moving opening is not part of the equal run', () => { + const a = span('a', 0.5, 1) + const b = span('b', 2.5, 1) + const c = span('c', 4.5, 1) // a,b,c form equal gaps of 1 + const d = span('d', 10, 1) // far right, breaks the run + expect(detectEqualSpacing([a, b, c, d], 'd', 0.03, 0.02)).toBeNull() + }) + + test('a near-zero (touching) gap breaks a run', () => { + const a = span('a', 0.5, 1) // [0,1] + const b = span('b', 1.505, 1) // [1.005,2.005] → gap 0.005 < minGap + const c = span('c', 3.005, 1) // [2.505,3.505] → gap 0.5 + expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull() + }) + + test('honours the equal-spacing tolerance', () => { + const a = span('a', 0.5, 1) // [0,1] + const b = span('b', 2.5, 1) // [2,3] → gap 1.0 + const c = span('c', 4.52, 1) // [4.02,5.02] → gap 1.02 + expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)?.segments).toHaveLength(2) + expect(detectEqualSpacing([a, b, c], 'b', 0.01, 0.02)).toBeNull() + }) +}) + +describe('computeEdgeGaps', () => { + test('measures clearance to the nearest neighbour on each side', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const left = span('l', 2, 1) // [1.5,2.5] + const right = span('r', 8, 1) // [7.5,8.5] + const gaps = computeEdgeGaps(moving, [left, right], WALL, 0.02) + const byside = Object.fromEntries(gaps.map((g) => [g.side, g])) + expect(byside.left?.distance).toBeCloseTo(2) + expect(byside.left?.target).toBe('opening') + expect(byside.left?.targetId).toBe('l') + expect(byside.right?.distance).toBeCloseTo(2) + expect(byside.right?.targetId).toBe('r') + }) + + test('falls back to wall ends with no neighbour', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const gaps = computeEdgeGaps(moving, [], WALL, 0.02) + const byside = Object.fromEntries(gaps.map((g) => [g.side, g])) + expect(byside.left?.target).toBe('wall-start') + expect(byside.left?.distance).toBeCloseTo(4.5) + expect(byside.right?.target).toBe('wall-end') + expect(byside.right?.distance).toBeCloseTo(4.5) + }) + + test('omits a side that is flush / overlapping (below minGap)', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const flush = span('l', 4, 1) // [3.5,4.5] right edge touches moving left + const gaps = computeEdgeGaps(moving, [flush], WALL, 0.02) + expect(gaps.find((g) => g.side === 'left')).toBeUndefined() + expect(gaps.find((g) => g.side === 'right')?.target).toBe('wall-end') + }) +}) + +describe('detectAlongWallAlignment', () => { + test('detects edge-to-edge alignment within tolerance', () => { + const moving = span('m', 5, 2) // [4,6] + const sib = span('s', 7.05, 2) // left edge 6.05 + const a = detectAlongWallAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('right') + expect(a?.targetFeature).toBe('left') + expect(a?.snap).toBeCloseTo(0.05) + expect(a?.s).toBeCloseTo(6.05) + }) + + test('detects centre alignment', () => { + const moving = span('m', 5, 2) + const sib = span('s', 5.03, 0.5) // centre 5.03, edges far from moving edges + const a = detectAlongWallAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('center') + expect(a?.targetFeature).toBe('center') + expect(a?.snap).toBeCloseTo(0.03) + }) + + test('returns null when nothing is within tolerance', () => { + const moving = span('m', 5, 2) + const sib = span('s', 9, 2) + expect(detectAlongWallAlignment(moving, [sib], 0.08)).toBeNull() + }) +}) + +describe('detectVerticalAlignment', () => { + test('detects a shared sill within tolerance', () => { + const moving = span('m', 5, 1, 1.5, 1) // sill 1.0 + const sib = span('s', 8, 1, 2.04, 2) // sill 1.04 + const a = detectVerticalAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('sill') + expect(a?.targetFeature).toBe('sill') + expect(a?.snap).toBeCloseTo(0.04) + expect(a?.y).toBeCloseTo(1.04) + }) + + test('returns null when sills/tops differ beyond tolerance', () => { + const moving = span('m', 5, 1, 1.5, 1) // sill 1, top 2, centre 1.5 + const sib = span('s', 8, 1, 0.4, 0.4) // sill 0.2, top 0.6, centre 0.4 + expect(detectVerticalAlignment(moving, [sib], 0.08)).toBeNull() + }) +}) + +describe('computeOpeningGuides', () => { + test('includes sill/head for windows', () => { + const moving = span('m', 5, 1, 1.5, 1) // bottom 1, top 2 + const guides = computeOpeningGuides({ + moving, + siblings: [], + wall: WALL, + includeVertical: true, + }) + expect(guides.sillHead?.sill).toBeCloseTo(1) + expect(guides.sillHead?.head).toBeCloseTo(0.5) // 2.5 - 2 + expect(guides.sillHead?.bottomY).toBeCloseTo(1) + expect(guides.sillHead?.topY).toBeCloseTo(2) + }) + + test('omits vertical guides for doors (sit on the floor)', () => { + const moving = span('m', 5, 1, 1, 2) + const sib = span('s', 8, 1, 1, 2) + const guides = computeOpeningGuides({ + moving, + siblings: [sib], + wall: WALL, + includeVertical: false, + }) + expect(guides.sillHead).toBeNull() + expect(guides.vertical).toBeNull() + // along-wall + proximity still computed for doors + expect(guides.gaps.length).toBeGreaterThan(0) + }) + + test('combines proximity and equal-spacing in one pass', () => { + const moving = span('b', 2.5, 1) + const guides = computeOpeningGuides({ + moving, + siblings: [span('a', 0.5, 1), span('c', 4.5, 1)], + wall: WALL, + includeVertical: true, + }) + expect(guides.gaps).toHaveLength(2) + expect(guides.equalSpacing?.gap).toBeCloseTo(1) + expect(guides.equalSpacing?.openingIds).toEqual(['a', 'b', 'c']) + }) +}) + +describe('opening-guides — review regressions', () => { + test('detectEqualSpacing finds a run that starts partway through a drifting sequence', () => { + // gaps 1.00, 1.02, 1.04 — only [b,c,d] is equal within 0.03 and includes the + // moving opening; a first-gap-anchored greedy scan used to drop it. + const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.52, 1), span('d', 6.56, 1)] + const run = detectEqualSpacing(openings, 'd', 0.03, 0.02) + expect(run?.openingIds).toEqual(['b', 'c', 'd']) + expect(run?.gap).toBeCloseTo(1.03) + expect(run?.segments).toHaveLength(2) + }) + + test('detectEqualSpacing prefers the leftmost run on a length tie', () => { + // gaps 1,1,2,2 with the moving opening in the middle — two equal-length runs. + const openings = [ + span('a', 0.5, 1), + span('b', 2.5, 1), + span('c', 4.5, 1), + span('d', 7.5, 1), + span('e', 10.5, 1), + ] + expect(detectEqualSpacing(openings, 'c', 0.03, 0.02)?.openingIds).toEqual(['a', 'b', 'c']) + }) + + test('computeEdgeGaps suppresses both sides when a sibling overlaps', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const containing = span('s', 5, 2) // [4,6] straddles both edges + expect(computeEdgeGaps(moving, [containing], WALL, 0.02)).toEqual([]) + }) + + test('alignment detectors ignore the moving opening if present in siblings', () => { + const moving = span('m', 5, 2, 1.5, 1) + expect(detectAlongWallAlignment(moving, [moving], 0.08)).toBeNull() + expect(detectVerticalAlignment(moving, [moving], 0.08)).toBeNull() + }) + + test('detectAlongWallAlignment reports a negative snap when the feature is past the target', () => { + const moving = span('m', 5, 2) // centre 5 + const sib = span('s', 4.96, 0.5) // centre 4.96 + const a = detectAlongWallAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('center') + expect(a?.snap).toBeCloseTo(-0.04) + }) +}) diff --git a/packages/core/src/services/opening-guides.ts b/packages/core/src/services/opening-guides.ts new file mode 100644 index 00000000..5f0da29e --- /dev/null +++ b/packages/core/src/services/opening-guides.ts @@ -0,0 +1,404 @@ +// Proximity / alignment guides for wall-hosted openings (doors, windows). +// +// Pure geometry over a single host wall's LOCAL frame — no Three.js, no scene +// store, no React — so it runs identically for the 3D viewport and the 2D +// floor plan and is unit-testable in isolation. Callers extract the spans from +// the scene graph (an opening's `position[0]` is its along-wall centre, its +// `position[1]` its vertical centre with the wall base at y=0) and feed them in; +// the renderers transform the returned wall-local coordinates back to world +// (3D) or plan (2D). +// +// What it produces, mirroring the affordances architects expect (and Figma's +// smart guides): +// - sill/head : a window's bottom edge → floor and top edge → wall top. +// - edge gaps : along-wall clearance to the nearest neighbour opening (or +// the wall end) on each side. +// - alongWall : the moving opening's edge/centre lining up with a +// neighbour's edge/centre along the wall. +// - vertical : two openings sharing a sill / head / vertical centre. +// - equalSpacing : a run of 3+ openings with (near-)equal gaps between them. +// +// Detection is passive — it reports what currently coincides within tolerance +// and the snap delta that would make it exact, leaving the snap decision to the +// caller's manipulation policy (grid vs. alignment vs. Shift bypass). + +/** An opening's footprint in its host wall's local frame. */ +export type OpeningSpan = { + id: string + /** Centre along the wall, measured from `wall.start` (m). */ + centerS: number + /** Along-wall extent (m). */ + width: number + /** Vertical centre above the wall base (floor at y=0) (m). */ + centerY: number + /** Vertical extent (m). */ + height: number +} + +export type WallExtent = { + /** Wall length (m). */ + length: number + /** Wall height (m). */ + height: number +} + +export type OpeningGuideTolerances = { + /** Max distance for an edge/centre to count as aligned with a neighbour (m). */ + align: number + /** Max difference between two gaps for them to count as equal (m). */ + equalSpacing: number + /** Gaps below this are treated as touching/overlap noise and ignored (m). */ + minGap: number +} + +export const DEFAULT_OPENING_GUIDE_TOLERANCES: OpeningGuideTolerances = { + // Parity with the along-wall snap threshold (`ALONG_WALL_ALIGN_THRESHOLD_M`). + align: 0.08, + equalSpacing: 0.03, + minGap: 0.02, +} + +/** Which along-wall feature of an opening a guide references. */ +export type AlongWallFeature = 'left' | 'center' | 'right' +/** Which vertical feature of an opening a guide references. */ +export type VerticalFeature = 'sill' | 'center' | 'top' + +export type SillHeadGuide = { + /** Floor (y=0) → the opening's bottom edge (m). */ + sill: number + /** Wall-local y of the bottom edge. */ + bottomY: number + /** The opening's top edge → the wall top (m). */ + head: number + /** Wall-local y of the top edge. */ + topY: number +} + +export type EdgeGap = { + side: 'left' | 'right' + /** Clearance along the wall (m). */ + distance: number + /** Wall-local s of the moving opening's edge. */ + fromS: number + /** Wall-local s of the neighbour edge / wall end. */ + toS: number + target: 'opening' | 'wall-start' | 'wall-end' + /** Set when `target === 'opening'`. */ + targetId?: string +} + +export type AlongWallAlignment = { + /** Wall-local s the two features share. */ + s: number + movingFeature: AlongWallFeature + targetId: string + targetFeature: AlongWallFeature + /** Delta to add to the moving opening's `centerS` to make them coincide. */ + snap: number +} + +export type VerticalAlignment = { + /** Wall-local y the two features share. */ + y: number + movingFeature: VerticalFeature + targetId: string + targetFeature: VerticalFeature + /** Delta to add to the moving opening's `centerY` to make them coincide. */ + snap: number +} + +export type EqualSpacingRun = { + /** The repeated gap value (average of the run's gaps) (m). */ + gap: number + /** The equal-gap segments along the wall, in order (left → right). */ + segments: { fromS: number; toS: number }[] + /** Participating opening ids, ordered along the wall, including the moving one. */ + openingIds: string[] +} + +export type OpeningGuides = { + sillHead: SillHeadGuide | null + gaps: EdgeGap[] + alongWall: AlongWallAlignment | null + vertical: VerticalAlignment | null + equalSpacing: EqualSpacingRun | null +} + +export type OpeningGuideInput = { + moving: OpeningSpan + /** Other openings on the SAME wall (the moving opening excluded). */ + siblings: readonly OpeningSpan[] + wall: WallExtent + /** + * Whether to compute vertical (sill/head/vertical-alignment) guides. True for + * windows; false for doors, which sit on the floor so their sill is always 0. + */ + includeVertical: boolean + tolerances?: Partial +} + +const leftEdge = (s: OpeningSpan) => s.centerS - s.width / 2 +const rightEdge = (s: OpeningSpan) => s.centerS + s.width / 2 +const bottomEdge = (s: OpeningSpan) => s.centerY - s.height / 2 +const topEdge = (s: OpeningSpan) => s.centerY + s.height / 2 + +function alongWallFeatureCoord(s: OpeningSpan, feature: AlongWallFeature): number { + if (feature === 'left') return leftEdge(s) + if (feature === 'right') return rightEdge(s) + return s.centerS +} + +function verticalFeatureCoord(s: OpeningSpan, feature: VerticalFeature): number { + if (feature === 'sill') return bottomEdge(s) + if (feature === 'top') return topEdge(s) + return s.centerY +} + +const ALONG_WALL_FEATURES: AlongWallFeature[] = ['left', 'center', 'right'] +const VERTICAL_FEATURES: VerticalFeature[] = ['sill', 'center', 'top'] + +/** + * Edge-to-edge clearance from the moving opening to the nearest neighbour on + * each side, falling back to the wall ends when there is no neighbour — the + * "how much wall is left here" reading. Returns 0–2 gaps (one per side); a side + * is omitted when its clearance is below `minGap` (the opening is flush against + * or overlapping that neighbour). + */ +export function computeEdgeGaps( + moving: OpeningSpan, + siblings: readonly OpeningSpan[], + wall: WallExtent, + minGap: number, +): EdgeGap[] { + const movingLeft = leftEdge(moving) + const movingRight = rightEdge(moving) + + // A sibling that straddles one of the moving opening's edges is an OVERLAP, + // not a neighbour: there is no clearance on that side, and we must not fall + // back to the wall end (which would report a misleading distance measured + // "through" the overlapping opening). + const leftCrossed = siblings.some((s) => leftEdge(s) < movingLeft && rightEdge(s) > movingLeft) + const rightCrossed = siblings.some((s) => leftEdge(s) < movingRight && rightEdge(s) > movingRight) + + let leftNeighbour: { s: number; id: string } | null = null + let rightNeighbour: { s: number; id: string } | null = null + for (const sib of siblings) { + const sibRight = rightEdge(sib) + const sibLeft = leftEdge(sib) + // Entirely to the left of the moving opening → candidate left neighbour. + if (sibRight <= movingLeft && (leftNeighbour === null || sibRight > leftNeighbour.s)) { + leftNeighbour = { s: sibRight, id: sib.id } + } + // Entirely to the right → candidate right neighbour. + if (sibLeft >= movingRight && (rightNeighbour === null || sibLeft < rightNeighbour.s)) { + rightNeighbour = { s: sibLeft, id: sib.id } + } + } + + const gaps: EdgeGap[] = [] + + if (!leftCrossed) { + const leftToS = leftNeighbour ? leftNeighbour.s : 0 + const leftDistance = movingLeft - leftToS + if (leftDistance >= minGap) { + gaps.push({ + side: 'left', + distance: leftDistance, + fromS: movingLeft, + toS: leftToS, + target: leftNeighbour ? 'opening' : 'wall-start', + targetId: leftNeighbour?.id, + }) + } + } + + if (!rightCrossed) { + const rightToS = rightNeighbour ? rightNeighbour.s : wall.length + const rightDistance = rightToS - movingRight + if (rightDistance >= minGap) { + gaps.push({ + side: 'right', + distance: rightDistance, + fromS: movingRight, + toS: rightToS, + target: rightNeighbour ? 'opening' : 'wall-end', + targetId: rightNeighbour?.id, + }) + } + } + + return gaps +} + +/** + * The closest coincidence between any of the moving opening's edges/centre and + * any sibling's edges/centre along the wall, within `tolerance`. Edge-to-edge + * and centre-to-centre are weighed equally; the single closest pair wins + * (matching the one-guide-per-axis behaviour of the floor-plane resolver). + */ +export function detectAlongWallAlignment( + moving: OpeningSpan, + siblings: readonly OpeningSpan[], + tolerance: number, +): AlongWallAlignment | null { + let best: AlongWallAlignment | null = null + let bestAbs = tolerance + for (const movingFeature of ALONG_WALL_FEATURES) { + const movingCoord = alongWallFeatureCoord(moving, movingFeature) + for (const sib of siblings) { + if (sib.id === moving.id) continue + for (const targetFeature of ALONG_WALL_FEATURES) { + const targetCoord = alongWallFeatureCoord(sib, targetFeature) + const diff = targetCoord - movingCoord + const abs = Math.abs(diff) + if (abs <= bestAbs && (best === null || abs < bestAbs)) { + bestAbs = abs + best = { + s: targetCoord, + movingFeature, + targetId: sib.id, + targetFeature, + snap: diff, + } + } + } + } + } + return best +} + +/** + * The closest coincidence between the moving opening's sill/centre/top and any + * sibling's sill/centre/top, within `tolerance` — the "these two windows share + * a sill height" detector. Same single-best-match policy as the along-wall + * variant. + */ +export function detectVerticalAlignment( + moving: OpeningSpan, + siblings: readonly OpeningSpan[], + tolerance: number, +): VerticalAlignment | null { + let best: VerticalAlignment | null = null + let bestAbs = tolerance + for (const movingFeature of VERTICAL_FEATURES) { + const movingCoord = verticalFeatureCoord(moving, movingFeature) + for (const sib of siblings) { + if (sib.id === moving.id) continue + for (const targetFeature of VERTICAL_FEATURES) { + const targetCoord = verticalFeatureCoord(sib, targetFeature) + const diff = targetCoord - movingCoord + const abs = Math.abs(diff) + if (abs <= bestAbs && (best === null || abs < bestAbs)) { + bestAbs = abs + best = { + y: targetCoord, + movingFeature, + targetId: sib.id, + targetFeature, + snap: diff, + } + } + } + } + } + return best +} + +/** + * Figma-style equal-spacing detection: order all openings along the wall, look + * at the clearances BETWEEN consecutive openings, and return the longest run of + * ≥2 consecutive gaps that are equal within `tolerance` and that the moving + * opening participates in (so the badges only appear while the drag is actually + * forming or extending a series). Returns null when no such run exists. + * + * Gaps below `minGap` (touching/overlapping openings) break a run — a row of + * flush openings is not "equally spaced". + */ +export function detectEqualSpacing( + allOpenings: readonly OpeningSpan[], + movingId: string, + tolerance: number, + minGap: number, +): EqualSpacingRun | null { + if (allOpenings.length < 3) return null + const sorted = [...allOpenings].sort((a, b) => a.centerS - b.centerS) + const movingIndex = sorted.findIndex((s) => s.id === movingId) + if (movingIndex < 0) return null + + // Clearance between opening i and i+1. + const gaps: { value: number; fromS: number; toS: number }[] = [] + for (let i = 0; i < sorted.length - 1; i++) { + const a = sorted[i] + const b = sorted[i + 1] + if (!a || !b) continue + const fromS = rightEdge(a) + const toS = leftEdge(b) + gaps.push({ value: toS - fromS, fromS, toS }) + } + + // Longest contiguous window of gaps that are (a) each ≥ minGap and (b) + // mutually equal within tolerance (window max − min ≤ tolerance), spanning at + // least 2 gaps and including the moving opening. Brute force over windows + // (openings per wall are few). A first-gap-anchored greedy scan is NOT + // equivalent: it drops a valid run that begins partway through a drifting + // sequence — e.g. gaps 1.00, 1.02, 1.04 with the moving opening at the end, + // where [1.02, 1.04] is a real run. On a length tie the leftmost window wins, + // for determinism. + let best: EqualSpacingRun | null = null + for (let lo = 0; lo < gaps.length; lo++) { + let min = Number.POSITIVE_INFINITY + let max = Number.NEGATIVE_INFINITY + for (let hi = lo; hi < gaps.length; hi++) { + const gap = gaps[hi] + if (!gap || gap.value < minGap) break // a sub-minGap gap can't join a run + min = Math.min(min, gap.value) + max = Math.max(max, gap.value) + if (max - min > tolerance) break // extending only widens the spread + const gapCount = hi - lo + 1 + if (gapCount < 2) continue + const firstOpening = lo // gap i sits between openings i and i+1 + const lastOpening = hi + 1 + if (movingIndex < firstOpening || movingIndex > lastOpening) continue + if (best !== null && gapCount <= best.segments.length) continue + const windowGaps = gaps.slice(lo, hi + 1) + best = { + gap: windowGaps.reduce((sum, g) => sum + g.value, 0) / windowGaps.length, + segments: windowGaps.map((g) => ({ fromS: g.fromS, toS: g.toS })), + openingIds: sorted.slice(firstOpening, lastOpening + 1).map((s) => s.id), + } + } + } + return best +} + +/** + * Compute every proximity/alignment guide for the moving opening in one pass. + * Pure: feed it the moving opening's wall-local span, its same-wall siblings, + * and the wall extent; render the result in whichever view. + */ +export function computeOpeningGuides(input: OpeningGuideInput): OpeningGuides { + const tol = { ...DEFAULT_OPENING_GUIDE_TOLERANCES, ...input.tolerances } + const { moving, siblings, wall, includeVertical } = input + + const sillHead: SillHeadGuide | null = includeVertical + ? { + sill: bottomEdge(moving), + bottomY: bottomEdge(moving), + head: wall.height - topEdge(moving), + topY: topEdge(moving), + } + : null + + return { + sillHead, + gaps: computeEdgeGaps(moving, siblings, wall, tol.minGap), + alongWall: detectAlongWallAlignment(moving, siblings, tol.align), + vertical: includeVertical ? detectVerticalAlignment(moving, siblings, tol.align) : null, + equalSpacing: detectEqualSpacing( + [moving, ...siblings], + moving.id, + tol.equalSpacing, + tol.minGap, + ), + } +}