@expofp/geometry 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,362 @@
1
+ import { intersectLineRect } from './intersections.js';
2
+ import { Point } from './point.js';
3
+ // Yields each box's two corners; lets fromUnion reuse the fromPoints sweep without allocating an array.
4
+ function* boxUnionCorners(boxes) {
5
+ for (const b of boxes) {
6
+ yield b.min;
7
+ yield b.max;
8
+ }
9
+ }
10
+ /**
11
+ * Axis-aligned bounding box (AABB). Pure 2D, no elevation. Immutable value object: readonly getters;
12
+ * transforms return a new instance unless a method `target` is passed. For a rotated rectangle use
13
+ * {@link Rect}.
14
+ */
15
+ export class Box {
16
+ /** Marker so callers can narrow the type at runtime. */
17
+ isBox = true;
18
+ /** Internal mutable backing for the minimum corner. */
19
+ _min = new Point();
20
+ /** Internal mutable backing for the maximum corner. */
21
+ _max = new Point();
22
+ /** Internal mutable backing for the center point. */
23
+ _center = new Point();
24
+ /** Internal mutable backing for the size vector (x = width, y = height). */
25
+ _size = new Point();
26
+ /**
27
+ * Creates a box from an origin and dimensions. Negative width/height are normalized so
28
+ * {@link Box.min} is always the lesser corner.
29
+ * @param x - left edge x coordinate (default 0)
30
+ * @param y - top edge y coordinate (default 0)
31
+ * @param w - width; may be negative (default 0)
32
+ * @param h - height; may be negative (default 0)
33
+ */
34
+ constructor(x = 0, y = 0, w = 0, h = 0) {
35
+ this.set(x, y, x + w, y + h);
36
+ }
37
+ /**
38
+ * The lesser corner of the box (minimum x and y).
39
+ * @returns a readonly `{ x, y }` view of the minimum corner
40
+ */
41
+ get min() {
42
+ return this._min;
43
+ }
44
+ /**
45
+ * The greater corner of the box (maximum x and y).
46
+ * @returns a readonly `{ x, y }` view of the maximum corner
47
+ */
48
+ get max() {
49
+ return this._max;
50
+ }
51
+ /**
52
+ * The geometric center of the box.
53
+ * @returns a readonly `{ x, y }` view of the center point
54
+ */
55
+ get center() {
56
+ return this._center;
57
+ }
58
+ /**
59
+ * The size of the box. `width` aliases `x` and `height` aliases `y`.
60
+ * @returns a readonly size view with `x`, `y`, `width`, and `height`
61
+ */
62
+ get size() {
63
+ return this._size;
64
+ }
65
+ /**
66
+ * Area of the box (`width × height`). Delegates to {@link boxArea}.
67
+ * @returns the area in square units
68
+ */
69
+ get area() {
70
+ return boxArea(this);
71
+ }
72
+ /**
73
+ * True when the box has zero extent in BOTH axes (collapsed to a point, or the
74
+ * default / `fromPoints([])` empty box). A degenerate line — extent in only one
75
+ * axis — is NOT considered empty. Delegates to {@link boxIsEmpty}.
76
+ * @returns true when `min === max` in both x and y
77
+ */
78
+ isEmpty() {
79
+ return boxIsEmpty(this);
80
+ }
81
+ /**
82
+ * Constructs a box from two arbitrary corners. Corners are normalized so
83
+ * {@link Box.min} is always the lesser corner regardless of argument order.
84
+ * @param min - one corner of the box
85
+ * @param max - the opposite corner of the box
86
+ * @returns a new normalized {@link Box}
87
+ */
88
+ static fromMinMax(min, max) {
89
+ return new Box(min.x, min.y, max.x - min.x, max.y - min.y);
90
+ }
91
+ /**
92
+ * Constructs a box centred on `center` with the given `size`.
93
+ * @param center - the desired center point of the box
94
+ * @param size - `{ x: width, y: height }` of the box
95
+ * @returns a new {@link Box} centred at `center`
96
+ */
97
+ static fromCenter(center, size) {
98
+ return new Box(center.x - size.x / 2, center.y - size.y / 2, size.x, size.y);
99
+ }
100
+ /**
101
+ * Constructs the smallest box that contains every box in `boxes`.
102
+ * Delegates to {@link Box.fromPoints} over each box's two corners so the
103
+ * min/max sweep lives in one place. Zero args returns `new Box()`.
104
+ * @param boxes - zero or more boxes to union
105
+ * @returns a new {@link Box} that is the axis-aligned union of all inputs
106
+ */
107
+ static fromUnion(...boxes) {
108
+ return Box.fromPoints(boxUnionCorners(boxes));
109
+ }
110
+ /**
111
+ * Constructs the smallest axis-aligned box that encloses every point in `points`.
112
+ * An empty iterable returns a zero-size box at the origin (equivalent to `new Box()`).
113
+ * @param points - iterable of `{ x, y }` points
114
+ * @returns a new {@link Box} that is the AABB of all inputs, or a zero box when `points` is empty
115
+ */
116
+ static fromPoints(points) {
117
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
118
+ for (const p of points) {
119
+ if (p.x < minX)
120
+ minX = p.x;
121
+ if (p.y < minY)
122
+ minY = p.y;
123
+ if (p.x > maxX)
124
+ maxX = p.x;
125
+ if (p.y > maxY)
126
+ maxY = p.y;
127
+ }
128
+ if (minX === Infinity)
129
+ return new Box();
130
+ return Box.fromMinMax({ x: minX, y: minY }, { x: maxX, y: maxY });
131
+ }
132
+ /**
133
+ * Constructs a box from an SVG `<rect>` or `<image>` element's base values.
134
+ * @param rect - the SVG element (or any {@link SvgRectLike}) to read dimensions from
135
+ * @returns a new {@link Box} matching the element's position and size
136
+ */
137
+ static fromSvg(rect) {
138
+ return new Box(rect.x.baseVal.value, rect.y.baseVal.value, rect.width.baseVal.value, rect.height.baseVal.value);
139
+ }
140
+ /**
141
+ * Returns true when this box overlaps `b`. Delegates to {@link boxIntersects}.
142
+ * @param b - the box to test intersection against
143
+ * @returns true when the boxes share at least one point
144
+ */
145
+ intersects(b) {
146
+ return boxIntersects(this, b);
147
+ }
148
+ /**
149
+ * Returns true when this box fully contains `b`. Delegates to {@link boxContains}.
150
+ * @param b - the box to test containment of
151
+ * @returns true when `b` lies entirely within this box
152
+ */
153
+ contains(b) {
154
+ return boxContains(this, b);
155
+ }
156
+ /**
157
+ * Returns true when `p` lies inside or on the boundary of this box.
158
+ * Delegates to {@link boxContainsPoint}.
159
+ * @param p - the point to test
160
+ * @returns true when `p` is within this box
161
+ */
162
+ containsPoint(p) {
163
+ return boxContainsPoint(this, p);
164
+ }
165
+ /**
166
+ * Returns true when this box has the same corners as `b`. Delegates to {@link boxEquals}.
167
+ * @param b - the box to compare against
168
+ * @returns true when both boxes share identical min and max corners
169
+ */
170
+ equals(b) {
171
+ return boxEquals(this, b);
172
+ }
173
+ /**
174
+ * Returns the intersection of this box and `b`, or `null` when they do not overlap.
175
+ * Delegates to {@link boxIntersection}.
176
+ * @param b - the box to intersect with
177
+ * @param target - optional box to write the result into; defaults to a new {@link Box}
178
+ * @returns the intersection box, or `null` if the boxes do not overlap
179
+ */
180
+ intersection(b, target) {
181
+ return boxIntersection(this, b, target);
182
+ }
183
+ /**
184
+ * Returns a copy of this box shifted by `offset`. Delegates to {@link boxTranslate}.
185
+ * @param offset - scalar (same shift on both axes) or `{ x, y }` amount to shift by
186
+ * @param target - optional box to write the result into; defaults to a new {@link Box}
187
+ * @returns the translated box
188
+ */
189
+ translate(offset, target) {
190
+ return boxTranslate(this, offset, target);
191
+ }
192
+ /**
193
+ * Returns a copy of this box expanded outward by `amount` on all sides.
194
+ * Delegates to {@link boxExpand}.
195
+ * @param amount - scalar or `{ x, y }` expansion per side
196
+ * @param target - optional box to write the result into; defaults to a new {@link Box}
197
+ * @returns the expanded box
198
+ */
199
+ expand(amount, target) {
200
+ return boxExpand(this, amount, target);
201
+ }
202
+ /**
203
+ * Returns a copy of this box scaled by `factor` about `origin`. Delegates to {@link boxScale}.
204
+ * @param factor - uniform scale or per-axis `{ x, y }` scale
205
+ * @param origin - the fixed point of the scaling; defaults to this box's center
206
+ * @param target - optional box to write the result into; defaults to a new {@link Box}
207
+ * @returns the scaled box
208
+ */
209
+ scale(factor, origin = this._center, target) {
210
+ return boxScale(this, factor, origin, target);
211
+ }
212
+ /**
213
+ * Points where `line` crosses this box. Delegates to {@link intersectLineRect}.
214
+ * @param line - the segment
215
+ * @returns the intersection points
216
+ */
217
+ intersectLine(line) {
218
+ return intersectLineRect(line, this);
219
+ }
220
+ /**
221
+ * Returns an independent copy of this box, or writes into `target` when provided.
222
+ * @param target - optional box to write the result into; defaults to a new {@link Box}
223
+ * @returns the cloned box
224
+ */
225
+ clone(target) {
226
+ return (target ?? new Box()).set(this._min.x, this._min.y, this._max.x, this._max.y);
227
+ }
228
+ /**
229
+ * Mutates this box in place, normalizing corners so {@link Box.min} is always the lesser corner,
230
+ * and refreshes the derived center and size. Prefer the immutable transforms; use this (or a
231
+ * transform `target`) only for hot-path reuse.
232
+ * @param x0 - x coordinate of the first corner
233
+ * @param y0 - y coordinate of the first corner
234
+ * @param x1 - x coordinate of the opposite corner
235
+ * @param y1 - y coordinate of the opposite corner
236
+ * @returns `this`
237
+ */
238
+ set(x0, y0, x1, y1) {
239
+ const minX = Math.min(x0, x1), minY = Math.min(y0, y1), maxX = Math.max(x0, x1), maxY = Math.max(y0, y1);
240
+ this._min.set(minX, minY);
241
+ this._max.set(maxX, maxY);
242
+ this._center.set((minX + maxX) / 2, (minY + maxY) / 2);
243
+ this._size.set(maxX - minX, maxY - minY);
244
+ return this;
245
+ }
246
+ }
247
+ /**
248
+ * Area of an axis-aligned box (`width × height`).
249
+ * @param b - the box to measure
250
+ * @returns the area in square units
251
+ */
252
+ export function boxArea(b) {
253
+ return (b.max.x - b.min.x) * (b.max.y - b.min.y);
254
+ }
255
+ /**
256
+ * True when the box has zero extent in BOTH axes (collapsed to a point, or the
257
+ * default / `fromPoints([])` empty box). A degenerate line — extent in only one
258
+ * axis — is NOT empty.
259
+ * @param b - the box to test
260
+ * @returns true when `b.min === b.max` in both x and y
261
+ */
262
+ export function boxIsEmpty(b) {
263
+ return b.min.x === b.max.x && b.min.y === b.max.y;
264
+ }
265
+ /**
266
+ * Returns true when boxes `a` and `b` share at least one point (edge-touching counts).
267
+ * @param a - first box
268
+ * @param b - second box
269
+ * @returns true when the boxes overlap or touch
270
+ */
271
+ export function boxIntersects(a, b) {
272
+ return a.min.x <= b.max.x && a.max.x >= b.min.x && a.min.y <= b.max.y && a.max.y >= b.min.y;
273
+ }
274
+ /**
275
+ * Returns true when box `a` fully contains box `b` (boundary-inclusive).
276
+ * @param a - outer box
277
+ * @param b - inner box to test containment of
278
+ * @returns true when `b` lies entirely within `a`
279
+ */
280
+ export function boxContains(a, b) {
281
+ return b.min.x >= a.min.x && b.max.x <= a.max.x && b.min.y >= a.min.y && b.max.y <= a.max.y;
282
+ }
283
+ /**
284
+ * Returns true when `p` lies inside or on the boundary of `b`.
285
+ * @param b - the box to test against
286
+ * @param p - the point to test
287
+ * @returns true when `p` is within `b`
288
+ */
289
+ export function boxContainsPoint(b, p) {
290
+ return p.x >= b.min.x && p.x <= b.max.x && p.y >= b.min.y && p.y <= b.max.y;
291
+ }
292
+ /**
293
+ * Returns true when `a` and `b` have identical min and max corners.
294
+ * @param a - first box
295
+ * @param b - second box
296
+ * @returns true when both corners match exactly
297
+ */
298
+ export function boxEquals(a, b) {
299
+ return a.min.x === b.min.x && a.min.y === b.min.y && a.max.x === b.max.x && a.max.y === b.max.y;
300
+ }
301
+ /**
302
+ * Returns the intersection of boxes `a` and `b`, or `null` when they do not overlap.
303
+ * @param a - first box
304
+ * @param b - second box
305
+ * @param target - optional box to write the result into; defaults to a new {@link Box}
306
+ * @returns the intersection box, or `null` if the boxes do not overlap
307
+ */
308
+ export function boxIntersection(a, b, target) {
309
+ const minX = Math.max(a.min.x, b.min.x), minY = Math.max(a.min.y, b.min.y), maxX = Math.min(a.max.x, b.max.x), maxY = Math.min(a.max.y, b.max.y);
310
+ if (maxX < minX || maxY < minY)
311
+ return null;
312
+ return (target ?? new Box()).set(minX, minY, maxX, maxY);
313
+ }
314
+ /**
315
+ * Returns a copy of `b` shifted by `offset`. Pure — does not mutate `b`.
316
+ * @param b - source box
317
+ * @param offset - scalar (same shift on both axes) or `{ x, y }` translation vector
318
+ * @param target - optional box to write the result into; defaults to a new {@link Box}
319
+ * @returns the translated box
320
+ */
321
+ export function boxTranslate(b, offset, target) {
322
+ const ox = typeof offset === 'number' ? offset : offset.x;
323
+ const oy = typeof offset === 'number' ? offset : offset.y;
324
+ return (target ?? new Box()).set(b.min.x + ox, b.min.y + oy, b.max.x + ox, b.max.y + oy);
325
+ }
326
+ /**
327
+ * Returns a copy of `b` expanded outward by `amount` on all sides. A positive amount grows the box;
328
+ * a negative amount shrinks it. Shrinking an axis past zero collapses it to its midpoint (clamped to
329
+ * zero size) rather than inverting.
330
+ * @param b - source box
331
+ * @param amount - scalar (broadcast to x/y) or `{ x, y }` expansion per side
332
+ * @param target - optional box to write the result into; defaults to a new {@link Box}
333
+ * @returns the expanded (or zero-clamped) box
334
+ */
335
+ export function boxExpand(b, amount, target) {
336
+ const isNum = typeof amount === 'number';
337
+ const ex = isNum ? amount : amount.x;
338
+ const ey = isNum ? amount : amount.y;
339
+ let minX = b.min.x - ex;
340
+ let maxX = b.max.x + ex;
341
+ let minY = b.min.y - ey;
342
+ let maxY = b.max.y + ey;
343
+ // `set` would re-normalize an inverted span into a spurious positive size, so clamp here instead.
344
+ if (maxX < minX)
345
+ minX = maxX = (minX + maxX) / 2;
346
+ if (maxY < minY)
347
+ minY = maxY = (minY + maxY) / 2;
348
+ return (target ?? new Box()).set(minX, minY, maxX, maxY);
349
+ }
350
+ /**
351
+ * Returns a copy of `b` scaled by `factor` about `origin`.
352
+ * @param b - source box
353
+ * @param factor - uniform scale or per-axis `{ x, y }` scale
354
+ * @param origin - the fixed point of the scaling; defaults to `b`'s center
355
+ * @param target - optional box to write the result into; defaults to a new {@link Box}
356
+ * @returns the scaled box
357
+ */
358
+ export function boxScale(b, factor, origin = { x: (b.min.x + b.max.x) / 2, y: (b.min.y + b.max.y) / 2 }, target) {
359
+ const fx = typeof factor === 'number' ? factor : factor.x;
360
+ const fy = typeof factor === 'number' ? factor : factor.y;
361
+ return (target ?? new Box()).set(origin.x + (b.min.x - origin.x) * fx, origin.y + (b.min.y - origin.y) * fy, origin.x + (b.max.x - origin.x) * fx, origin.y + (b.max.y - origin.y) * fy);
362
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @module geo
3
+ *
4
+ * Pure geodetic math — geographic coordinate types and algorithms on the WGS-84
5
+ * sphere. This module is deliberately **firewalled** from the planar primitives:
6
+ *
7
+ * - **Coordinates are in DEGREES** (not radians).
8
+ * - **`lat` is north-positive, `lng` is east-positive.**
9
+ * - **Bearings use compass convention: 0 = North, increasing clockwise** (90 = East,
10
+ * 180 = South, 270 = West). This is the opposite of the planar convention in this
11
+ * package, where angles are in radians and positive = clockwise in a y-down screen
12
+ * system starting from the +x axis.
13
+ * - **Distances are in metres** on a WGS-84 mean-radius sphere (R = 6 371 000 m).
14
+ *
15
+ * The only deliberate bridge between planar and geographic space is
16
+ * {@link projectLocalToGps} / {@link projectGpsToLocal}, which map between a
17
+ * `Point2Like` local coordinate system and geographic `LatLng` coordinates via two
18
+ * {@link GeoAnchor} correspondences. Callers are responsible for building validated
19
+ * `GeoAnchor` / `LatLng` values and passing them in — no floorplan-domain logic
20
+ * enters this module.
21
+ */
22
+ import type { Point2Like } from './point.js';
23
+ /** A geographic coordinate pair in decimal degrees. */
24
+ export interface LatLng {
25
+ /** Latitude in decimal degrees. North is positive. */
26
+ readonly lat: number;
27
+ /** Longitude in decimal degrees. East is positive. */
28
+ readonly lng: number;
29
+ }
30
+ /**
31
+ * A correspondence between a local 2D coordinate and a geographic position.
32
+ * Used to define the affine bridge between a planar coordinate system (e.g.
33
+ * SVG floorplan units) and geographic coordinates.
34
+ */
35
+ export interface GeoAnchor {
36
+ /** Local planar coordinate (e.g. SVG pixels or metres). */
37
+ readonly local: Point2Like;
38
+ /** Geographic position corresponding to the local coordinate. */
39
+ readonly geo: LatLng;
40
+ }
41
+ /**
42
+ * Great-circle distance between two geographic points using the Haversine formula.
43
+ * @param a - first point (degrees)
44
+ * @param b - second point (degrees)
45
+ * @returns distance in metres
46
+ */
47
+ export declare function haversineDistance(a: LatLng, b: LatLng): number;
48
+ /**
49
+ * Initial compass bearing from `from` to `to`.
50
+ *
51
+ * Compass convention: **0 = North, 90 = East, 180 = South, 270 = West**, result
52
+ * in `[0, 360)`.
53
+ * @param from - origin point (degrees)
54
+ * @param to - destination point (degrees)
55
+ * @returns bearing in degrees `[0, 360)`
56
+ */
57
+ export declare function bearing(from: LatLng, to: LatLng): number;
58
+ /**
59
+ * Destination point given an origin, distance, and compass bearing.
60
+ * @param from - starting point (degrees)
61
+ * @param distanceM - distance in metres
62
+ * @param bearingDeg - initial compass bearing in degrees (0 = North, CW)
63
+ * @returns the destination {@link LatLng}
64
+ */
65
+ export declare function destinationPoint(from: LatLng, distanceM: number, bearingDeg: number): LatLng;
66
+ /**
67
+ * Projects a local 2D point to geographic coordinates using two anchor
68
+ * correspondences. The projection uses a polar-coordinate affine transform:
69
+ * it measures the local point's distance and angular offset relative to the
70
+ * anchor baseline vector, then applies that same distance ratio and angular
71
+ * offset in geographic space starting from anchor `a`.
72
+ *
73
+ * The projection is an **affine similarity transform** (scale + rotation +
74
+ * translation), valid when the local→geo mapping is approximately uniform
75
+ * scale and rotation over the region of interest (true for convention-centre
76
+ * scale floorplans).
77
+ * @param point - local 2D point to project
78
+ * @param a - first anchor (local origin of the baseline)
79
+ * @param b - second anchor (local end of the baseline)
80
+ * @returns the geographic position corresponding to `point`
81
+ */
82
+ export declare function projectLocalToGps(point: Point2Like, a: GeoAnchor, b: GeoAnchor): LatLng;
83
+ /**
84
+ * Projects a geographic point to local 2D coordinates using two anchor
85
+ * correspondences. This is the inverse of {@link projectLocalToGps}.
86
+ *
87
+ * It measures the geographic point's great-circle distance and compass-bearing
88
+ * offset relative to the anchor baseline (anchor A → anchor B), then reproduces
89
+ * that same distance ratio and angular offset in local planar space — inverting
90
+ * the affine similarity (scale + rotation + translation) bridge. Like the forward
91
+ * transform, it assumes an approximately uniform scale and rotation over the region.
92
+ * @param geo - geographic point to project
93
+ * @param a - first anchor (local origin of the baseline)
94
+ * @param b - second anchor (local end of the baseline)
95
+ * @returns the local 2D point corresponding to `geo`
96
+ */
97
+ export declare function projectGpsToLocal(geo: LatLng, a: GeoAnchor, b: GeoAnchor): Point2Like;
98
+ //# sourceMappingURL=geo.d.ts.map
@@ -0,0 +1,159 @@
1
+ /**
2
+ * @module geo
3
+ *
4
+ * Pure geodetic math — geographic coordinate types and algorithms on the WGS-84
5
+ * sphere. This module is deliberately **firewalled** from the planar primitives:
6
+ *
7
+ * - **Coordinates are in DEGREES** (not radians).
8
+ * - **`lat` is north-positive, `lng` is east-positive.**
9
+ * - **Bearings use compass convention: 0 = North, increasing clockwise** (90 = East,
10
+ * 180 = South, 270 = West). This is the opposite of the planar convention in this
11
+ * package, where angles are in radians and positive = clockwise in a y-down screen
12
+ * system starting from the +x axis.
13
+ * - **Distances are in metres** on a WGS-84 mean-radius sphere (R = 6 371 000 m).
14
+ *
15
+ * The only deliberate bridge between planar and geographic space is
16
+ * {@link projectLocalToGps} / {@link projectGpsToLocal}, which map between a
17
+ * `Point2Like` local coordinate system and geographic `LatLng` coordinates via two
18
+ * {@link GeoAnchor} correspondences. Callers are responsible for building validated
19
+ * `GeoAnchor` / `LatLng` values and passing them in — no floorplan-domain logic
20
+ * enters this module.
21
+ */
22
+ import { degToRad, fromPolar, normalizeAngle, radToDeg } from './angles.js';
23
+ /** WGS-84 mean Earth radius in metres. Matches the value used by all existing callers. */
24
+ const EARTH_RADIUS_METERS = 6_371_000;
25
+ // ---------------------------------------------------------------------------
26
+ // Geodetic algorithms
27
+ // ---------------------------------------------------------------------------
28
+ /**
29
+ * Great-circle distance between two geographic points using the Haversine formula.
30
+ * @param a - first point (degrees)
31
+ * @param b - second point (degrees)
32
+ * @returns distance in metres
33
+ */
34
+ export function haversineDistance(a, b) {
35
+ const phi1 = degToRad(a.lat);
36
+ const phi2 = degToRad(b.lat);
37
+ const deltaPhi = degToRad(b.lat - a.lat);
38
+ const deltaLambda = degToRad(b.lng - a.lng);
39
+ const s = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
40
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
41
+ const c = 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
42
+ return EARTH_RADIUS_METERS * c;
43
+ }
44
+ /**
45
+ * Initial compass bearing from `from` to `to`.
46
+ *
47
+ * Compass convention: **0 = North, 90 = East, 180 = South, 270 = West**, result
48
+ * in `[0, 360)`.
49
+ * @param from - origin point (degrees)
50
+ * @param to - destination point (degrees)
51
+ * @returns bearing in degrees `[0, 360)`
52
+ */
53
+ export function bearing(from, to) {
54
+ const phi1 = degToRad(from.lat);
55
+ const phi2 = degToRad(to.lat);
56
+ const deltaLambda = degToRad(to.lng - from.lng);
57
+ const y = Math.sin(deltaLambda) * Math.cos(phi2);
58
+ const x = Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(deltaLambda);
59
+ return (radToDeg(Math.atan2(y, x)) + 360) % 360;
60
+ }
61
+ /**
62
+ * Destination point given an origin, distance, and compass bearing.
63
+ * @param from - starting point (degrees)
64
+ * @param distanceM - distance in metres
65
+ * @param bearingDeg - initial compass bearing in degrees (0 = North, CW)
66
+ * @returns the destination {@link LatLng}
67
+ */
68
+ export function destinationPoint(from, distanceM, bearingDeg) {
69
+ const delta = distanceM / EARTH_RADIUS_METERS;
70
+ const theta = degToRad(bearingDeg);
71
+ const phi1 = degToRad(from.lat);
72
+ const lambda1 = degToRad(from.lng);
73
+ const phi2 = Math.asin(Math.sin(phi1) * Math.cos(delta) + Math.cos(phi1) * Math.sin(delta) * Math.cos(theta));
74
+ const lambda2 = lambda1 +
75
+ Math.atan2(Math.sin(theta) * Math.sin(delta) * Math.cos(phi1), Math.cos(delta) - Math.sin(phi1) * Math.sin(phi2));
76
+ // Wrap longitude into (-180, 180] so results past the antemeridian stay canonical
77
+ // (parity with the floorplan `gps.ts` source being consolidated). `lambda2` is in
78
+ // radians, so `normalizeAngle` is exactly the longitude wrap.
79
+ return { lat: radToDeg(phi2), lng: radToDeg(normalizeAngle(lambda2)) };
80
+ }
81
+ // ---------------------------------------------------------------------------
82
+ // Planar ↔ geographic projection (two-anchor affine bridge)
83
+ // ---------------------------------------------------------------------------
84
+ /**
85
+ * Projects a local 2D point to geographic coordinates using two anchor
86
+ * correspondences. The projection uses a polar-coordinate affine transform:
87
+ * it measures the local point's distance and angular offset relative to the
88
+ * anchor baseline vector, then applies that same distance ratio and angular
89
+ * offset in geographic space starting from anchor `a`.
90
+ *
91
+ * The projection is an **affine similarity transform** (scale + rotation +
92
+ * translation), valid when the local→geo mapping is approximately uniform
93
+ * scale and rotation over the region of interest (true for convention-centre
94
+ * scale floorplans).
95
+ * @param point - local 2D point to project
96
+ * @param a - first anchor (local origin of the baseline)
97
+ * @param b - second anchor (local end of the baseline)
98
+ * @returns the geographic position corresponding to `point`
99
+ */
100
+ export function projectLocalToGps(point, a, b) {
101
+ // Baseline vector in local space (anchor A → anchor B)
102
+ const baseDx = b.local.x - a.local.x;
103
+ const baseDy = b.local.y - a.local.y;
104
+ const baseLen = Math.sqrt(baseDx * baseDx + baseDy * baseDy);
105
+ // Vector from anchor A to the query point in local space
106
+ const pointDx = point.x - a.local.x;
107
+ const pointDy = point.y - a.local.y;
108
+ const pointLen = Math.sqrt(pointDx * pointDx + pointDy * pointDy);
109
+ // Angular offset of point from the baseline, in degrees
110
+ const baseAngleDeg = radToDeg(Math.atan2(baseDy, baseDx));
111
+ const pointAngleDeg = radToDeg(Math.atan2(pointDy, pointDx));
112
+ const deltaAngleDeg = pointAngleDeg - baseAngleDeg;
113
+ // Scale factor and geographic distance
114
+ const distanceRatio = baseLen === 0 ? 0 : pointLen / baseLen;
115
+ const fullGeoDistM = haversineDistance(a.geo, b.geo);
116
+ const pointDistM = distanceRatio * fullGeoDistM;
117
+ // Compass bearing of the baseline in geographic space
118
+ const baseBearingDeg = bearing(a.geo, b.geo);
119
+ // Apply: start from anchor A, go pointDistM along (baseBearing + angularOffset)
120
+ return destinationPoint(a.geo, pointDistM, baseBearingDeg + deltaAngleDeg);
121
+ }
122
+ /**
123
+ * Projects a geographic point to local 2D coordinates using two anchor
124
+ * correspondences. This is the inverse of {@link projectLocalToGps}.
125
+ *
126
+ * It measures the geographic point's great-circle distance and compass-bearing
127
+ * offset relative to the anchor baseline (anchor A → anchor B), then reproduces
128
+ * that same distance ratio and angular offset in local planar space — inverting
129
+ * the affine similarity (scale + rotation + translation) bridge. Like the forward
130
+ * transform, it assumes an approximately uniform scale and rotation over the region.
131
+ * @param geo - geographic point to project
132
+ * @param a - first anchor (local origin of the baseline)
133
+ * @param b - second anchor (local end of the baseline)
134
+ * @returns the local 2D point corresponding to `geo`
135
+ */
136
+ export function projectGpsToLocal(geo, a, b) {
137
+ // Geographic distances and bearings from anchor A
138
+ const fullGeoDistM = haversineDistance(a.geo, b.geo);
139
+ const baseBearingDeg = bearing(a.geo, b.geo);
140
+ const pointDistM = haversineDistance(a.geo, geo);
141
+ const pointBearingDeg = bearing(a.geo, geo);
142
+ // Angular offset in geographic space
143
+ const deltaAngleDeg = pointBearingDeg - baseBearingDeg;
144
+ // Scale factor
145
+ const distanceRatio = fullGeoDistM === 0 ? 0 : pointDistM / fullGeoDistM;
146
+ // Baseline vector in local space
147
+ const baseDx = b.local.x - a.local.x;
148
+ const baseDy = b.local.y - a.local.y;
149
+ const baseLen = Math.sqrt(baseDx * baseDx + baseDy * baseDy);
150
+ // Direction angle of baseline in local space (planar radians)
151
+ const baseAngleRad = Math.atan2(baseDy, baseDx);
152
+ // Shift from anchor A along baseline direction by scaled distance,
153
+ // then rotate by the angular offset
154
+ const shiftLen = baseLen * distanceRatio;
155
+ const shiftAngleRad = baseAngleRad + degToRad(deltaAngleDeg);
156
+ // Polar → cartesian in local planar space, offset from anchor A.
157
+ const offset = fromPolar(shiftLen, shiftAngleRad);
158
+ return { x: a.local.x + offset.x, y: a.local.y + offset.y };
159
+ }
@@ -0,0 +1,30 @@
1
+ import type { LineLike } from './line.js';
2
+ import { type Point } from './point.js';
3
+ import { type RectLike } from './rect.js';
4
+ /**
5
+ * Computes the crossing of lines `a` and `b` using the parametric form. Returns the crossing
6
+ * point plus flags indicating whether the crossing lies within each segment's `[0, 1]` parameter
7
+ * range. Returns `null` only when the lines are parallel (denominator is zero) or when both lines
8
+ * have a defined elevation and they differ by more than `elevationTolerance`.
9
+ * @param a - first line segment, defined by `p0` and `p1`
10
+ * @param b - second line segment, defined by `p0` and `p1`
11
+ * @param elevationTolerance - maximum elevation difference for a crossing to be reported; defaults to `1e-3`
12
+ * @returns `{ point, onLine1, onLine2 }`, or `null` when the lines are parallel or elevation-incompatible
13
+ */
14
+ export declare function lineIntersection(a: LineLike, b: LineLike, elevationTolerance?: number): {
15
+ point: Point;
16
+ onLine1: boolean;
17
+ onLine2: boolean;
18
+ } | null;
19
+ /**
20
+ * Returns the points where a line segment crosses a rect's edges. Works for any `RectLike`,
21
+ * including a rotated rect or an unrotated `Box` (a `Box` satisfies `RectLike` and is treated
22
+ * as a rect with rotation 0). When both `line` and `rect` have a defined elevation that differs
23
+ * by more than `elevationTolerance`, returns an empty array.
24
+ * @param line - the segment, defined by `p0` and `p1`
25
+ * @param rect - the rect (possibly rotated; a `Box` is accepted as an unrotated rect)
26
+ * @param elevationTolerance - maximum elevation difference for a crossing to be reported; defaults to `1e-3`
27
+ * @returns the intersection points (0–2 for a convex rect)
28
+ */
29
+ export declare function intersectLineRect(line: LineLike, rect: RectLike, elevationTolerance?: number): Point[];
30
+ //# sourceMappingURL=intersections.d.ts.map