ai-chatbot/packages/core/src/services/opening-guides.ts
Aymeric Rabot 8a5c232685 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>
2026-06-14 10:16:15 -04:00

405 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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,
),
}
}