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:
co-authored by
Claude Opus 4.8
parent
1f829d52ed
commit
8a5c232685
@@ -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 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,
|
||||
),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user