feat(core): opening-guides service — proximity/alignment geometry for openings

Pure, Three.js-free wall-local geometry that will power directly-visible
proximity guides for doors and windows (rendered in the follow-up phases):

  - sill / head height (floor → bottom edge, top edge → wall top)
  - edge-to-edge proximity clearance to the nearest neighbour on each side
    (or the wall end), with overlap suppression
  - along-wall alignment (edge/centre coincidence with a neighbour)
  - vertical alignment (shared sill / centre / top — "same sill height")
  - Figma-style equal-spacing run detection across a series of 3+ openings

Single `computeOpeningGuides` entry plus exported detectors; 23 unit tests.
Codex-reviewed — equal-spacing uses a longest-equal-window scan (a greedy
first-gap anchor dropped valid runs), edge gaps suppress straddling overlaps,
and the alignment detectors guard against the moving opening appearing in its
own sibling list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-14 10:16:15 -04:00
co-authored by Claude Opus 4.8
parent 1f829d52ed
commit 8a5c232685
3 changed files with 665 additions and 0 deletions
+20
View File
@@ -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,
@@ -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)
})
})
@@ -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<OpeningGuideTolerances>
}
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 02 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,
),
}
}