@gkucmierz/utils 5.0.0 → 5.4.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,748 @@
1
+ /**
2
+ * @module geometry
3
+ */
4
+
5
+ const attachBounds = (tuple, leftClosed = true, rightClosed = true) => {
6
+ const lc = Boolean(leftClosed);
7
+ const rc = Boolean(rightClosed);
8
+ Object.defineProperties(tuple, {
9
+ leftClosed: { value: lc, enumerable: false, writable: false, configurable: true },
10
+ rightClosed: { value: rc, enumerable: false, writable: false, configurable: true },
11
+ });
12
+ return tuple;
13
+ };
14
+
15
+ /**
16
+ * Creates a 1D range tuple `[min, max]` annotated with endpoint inclusivity flags
17
+ * (`leftClosed`, `rightClosed`, both defaulting to `true` for closed `<min, max>`).
18
+ * @param {number} min - Lower bound
19
+ * @param {number} max - Upper bound
20
+ * @param {boolean} [leftClosed=true] - Whether `min` is included (`<min` / `[min` vs `(min`)
21
+ * @param {boolean} [rightClosed=true] - Whether `max` is included (`max>` / `max]` vs `max)`)
22
+ * @returns {Array<number>} Annotated `[min, max]` interval tuple
23
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4JgQwHYHMCmCC+CAZjFGAgEQACWA1gK4DGYIOMAXgPT1wgA2AZwoBuYMHTYcooA|▶ Try it live in Instacode}
24
+ */
25
+ export const range = (min, max, leftClosed = true, rightClosed = true) => {
26
+ const a = Number(min);
27
+ const b = Number(max);
28
+ if (a <= b) {
29
+ return attachBounds([a, b], leftClosed, rightClosed);
30
+ }
31
+ return attachBounds([b, a], rightClosed, leftClosed);
32
+ };
33
+
34
+ /**
35
+ * Parses mathematical or Polish interval notation (`"[4, 6)"`, `"<4, 6)"`, `"(4, 6]"`, `"(4, 6>"`, `"[4, 6]"`, `"(4, 6)"`)
36
+ * into an annotated `[min, max]` interval tuple with `.leftClosed` and `.rightClosed`.
37
+ * @param {string|Array} input - Interval string or tuple
38
+ * @param {boolean} [defaultLeftClosed=true] - Fallback left closure when `input` is a 2-tuple
39
+ * @param {boolean} [defaultRightClosed=true] - Fallback right closure when `input` is a 2-tuple
40
+ * @returns {Array<number>|null} Annotated `[min, max]` tuple or `null` if invalid/empty
41
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IgQxgZwKYCU0B2A5tggL4IBmMUYCARAAJEDWArgMZgjYwBeAejZwQAG0z0A3MGDoseQiWlA|▶ Try it live in Instacode}
42
+ */
43
+ export const parseRange = (input, defaultLeftClosed = true, defaultRightClosed = true) => {
44
+ if (typeof input === 'string') {
45
+ const trimmed = input.trim();
46
+ const match = /^([([<])\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)\s*,\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)\s*([)\]>])$/i.exec(
47
+ trimmed
48
+ );
49
+ if (!match) return null;
50
+ const leftChar = match[1];
51
+ const a = Number(match[2]);
52
+ const b = Number(match[3]);
53
+ const rightChar = match[4];
54
+ const lc = leftChar === '[' || leftChar === '<';
55
+ const rc = rightChar === ']' || rightChar === '>';
56
+ return normalizeInterval1D(attachBounds([a, b], lc, rc), lc, rc);
57
+ }
58
+ return normalizeInterval1D(input, defaultLeftClosed, defaultRightClosed);
59
+ };
60
+
61
+ /**
62
+ * Formats an interval tuple into mathematical (`"[4, 6)"`) or angle (`"<4, 6)"`) notation.
63
+ * @param {Array<number>} r - Interval tuple
64
+ * @param {'bracket'|'angle'} [style='bracket'] - Notation style for closed endpoints
65
+ * @returns {string} Formatted interval string
66
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IGazAQzgJUwOwHMBTBAXzRijAQCIABQgawFcBjMEYmALwHoWcEABsAzrQDcwYOhhZcBElKA|▶ Try it live in Instacode}
67
+ */
68
+ export const formatRange = (r, style = 'bracket') => {
69
+ const iv = normalizeInterval1D(r);
70
+ if (!iv) return '∅';
71
+ const lc = iv.leftClosed ?? true;
72
+ const rc = iv.rightClosed ?? true;
73
+ const openL = lc ? (style === 'angle' ? '<' : '[') : '(';
74
+ const closeR = rc ? (style === 'angle' ? '>' : ']') : ')';
75
+ return `${openL}${iv[0]}, ${iv[1]}${closeR}`;
76
+ };
77
+
78
+ const isNonEmptyInterval = (a, b, lc, rc) => a < b || (a === b && lc && rc);
79
+
80
+ const normalizeInterval1D = (r, defaultLeftClosed = true, defaultRightClosed = true) => {
81
+ if (typeof r === 'string') {
82
+ return parseRange(r, defaultLeftClosed, defaultRightClosed);
83
+ }
84
+ if (r && typeof r === 'object' && !Array.isArray(r)) {
85
+ const a = Number(r.min ?? r.from ?? r[0]);
86
+ const b = Number(r.max ?? r.to ?? r[1]);
87
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
88
+ const lc = typeof r.leftClosed === 'boolean' ? r.leftClosed : Boolean(defaultLeftClosed);
89
+ const rc = typeof r.rightClosed === 'boolean' ? r.rightClosed : Boolean(defaultRightClosed);
90
+ const [lo, hi, fLo, fHi] = a <= b ? [a, b, lc, rc] : [b, a, rc, lc];
91
+ return isNonEmptyInterval(lo, hi, fLo, fHi) ? attachBounds([lo, hi], fLo, fHi) : null;
92
+ }
93
+ if (!Array.isArray(r) || r.length < 2) return null;
94
+ const a = Number(r[0]);
95
+ const b = Number(r[1]);
96
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
97
+
98
+ const rawLc =
99
+ typeof r[2] === 'boolean'
100
+ ? r[2]
101
+ : typeof r.leftClosed === 'boolean'
102
+ ? r.leftClosed
103
+ : Boolean(defaultLeftClosed);
104
+ const rawRc =
105
+ typeof r[3] === 'boolean'
106
+ ? r[3]
107
+ : typeof r.rightClosed === 'boolean'
108
+ ? r.rightClosed
109
+ : Boolean(defaultRightClosed);
110
+
111
+ const [lo, hi, lc, rc] = a <= b ? [a, b, rawLc, rawRc] : [b, a, rawRc, rawLc];
112
+ if (!isNonEmptyInterval(lo, hi, lc, rc)) return null;
113
+ return attachBounds([lo, hi], lc, rc);
114
+ };
115
+
116
+ const isSingleIntervalInput1D = (input) =>
117
+ typeof input === 'string' ||
118
+ (Array.isArray(input) && input.length >= 2 && typeof input[0] === 'number' && typeof input[1] === 'number');
119
+
120
+ const mergeIntervals1D = (intervals) => {
121
+ if (!Array.isArray(intervals) || intervals.length === 0) return [];
122
+ const valid = [];
123
+ for (let i = 0; i < intervals.length; i += 1) {
124
+ const iv = intervals[i];
125
+ if (Array.isArray(iv) && isNonEmptyInterval(iv[0], iv[1], iv.leftClosed ?? true, iv.rightClosed ?? true)) {
126
+ valid.push(attachBounds([iv[0], iv[1]], iv.leftClosed ?? true, iv.rightClosed ?? true));
127
+ }
128
+ }
129
+ if (valid.length === 0) return [];
130
+
131
+ valid.sort((x, y) => {
132
+ if (x[0] !== y[0]) return x[0] - y[0];
133
+ // At identical start point, closed left endpoint `[a` comes before open `(a`
134
+ if (x.leftClosed !== y.leftClosed) return x.leftClosed ? -1 : 1;
135
+ if (x[1] !== y[1]) return x[1] - y[1];
136
+ return (y.rightClosed ? 1 : 0) - (x.rightClosed ? 1 : 0);
137
+ });
138
+
139
+ const merged = [valid[0]];
140
+ for (let i = 1; i < valid.length; i += 1) {
141
+ const prev = merged[merged.length - 1];
142
+ const curr = valid[i];
143
+
144
+ const canMerge =
145
+ curr[0] < prev[1] || (curr[0] === prev[1] && (prev.rightClosed || curr.leftClosed));
146
+
147
+ if (canMerge) {
148
+ const nextLo = prev[0];
149
+ const nextLc = prev[0] === curr[0] ? prev.leftClosed || curr.leftClosed : prev.leftClosed;
150
+ let nextHi = prev[1];
151
+ let nextRc = prev.rightClosed;
152
+ if (curr[1] > prev[1]) {
153
+ nextHi = curr[1];
154
+ nextRc = curr.rightClosed;
155
+ } else if (curr[1] === prev[1]) {
156
+ nextRc = prev.rightClosed || curr.rightClosed;
157
+ }
158
+ merged[merged.length - 1] = attachBounds([nextLo, nextHi], nextLc, nextRc);
159
+ } else {
160
+ merged.push(curr);
161
+ }
162
+ }
163
+ return merged;
164
+ };
165
+
166
+ const toIntervalList1D = (input, defaultLeftClosed = true, defaultRightClosed = true) => {
167
+ if (input === null || input === undefined) return [];
168
+ if (isSingleIntervalInput1D(input)) {
169
+ const single = normalizeInterval1D(input, defaultLeftClosed, defaultRightClosed);
170
+ return single ? [single] : [];
171
+ }
172
+ if (!Array.isArray(input) || input.length === 0) return [];
173
+ const list = [];
174
+ for (let i = 0; i < input.length; i += 1) {
175
+ const norm = normalizeInterval1D(input[i], defaultLeftClosed, defaultRightClosed);
176
+ if (norm) list.push(norm);
177
+ }
178
+ return mergeIntervals1D(list);
179
+ };
180
+
181
+ const intersectTwoIntervals1D = (a, b) => {
182
+ let lo;
183
+ let lc;
184
+ if (a[0] > b[0]) {
185
+ lo = a[0];
186
+ lc = a.leftClosed;
187
+ } else if (b[0] > a[0]) {
188
+ lo = b[0];
189
+ lc = b.leftClosed;
190
+ } else {
191
+ lo = a[0];
192
+ lc = a.leftClosed && b.leftClosed;
193
+ }
194
+
195
+ let hi;
196
+ let rc;
197
+ if (a[1] < b[1]) {
198
+ hi = a[1];
199
+ rc = a.rightClosed;
200
+ } else if (b[1] < a[1]) {
201
+ hi = b[1];
202
+ rc = b.rightClosed;
203
+ } else {
204
+ hi = a[1];
205
+ rc = a.rightClosed && b.rightClosed;
206
+ }
207
+
208
+ if (!isNonEmptyInterval(lo, hi, lc, rc)) return null;
209
+ return attachBounds([lo, hi], lc, rc);
210
+ };
211
+
212
+ const subtractTwoIntervals1D = (iv, cut) => {
213
+ const inter = intersectTwoIntervals1D(iv, cut);
214
+ if (!inter) return [iv];
215
+
216
+ const out = [];
217
+ // Left remainder: [iv[0], inter[0]] with right closure = !inter.leftClosed
218
+ const leftLo = iv[0];
219
+ const leftHi = inter[0];
220
+ const leftLc = iv.leftClosed;
221
+ const leftRc = !inter.leftClosed;
222
+ if (isNonEmptyInterval(leftLo, leftHi, leftLc, leftRc)) {
223
+ out.push(attachBounds([leftLo, leftHi], leftLc, leftRc));
224
+ }
225
+
226
+ // Right remainder: [inter[1], iv[1]] with left closure = !inter.rightClosed
227
+ const rightLo = inter[1];
228
+ const rightHi = iv[1];
229
+ const rightLc = !inter.rightClosed;
230
+ const rightRc = iv.rightClosed;
231
+ if (isNonEmptyInterval(rightLo, rightHi, rightLc, rightRc)) {
232
+ out.push(attachBounds([rightLo, rightHi], rightLc, rightRc));
233
+ }
234
+
235
+ return out;
236
+ };
237
+
238
+ /**
239
+ * Computes the intersection (AND / mutual part) of two 1D ranges or range sets.
240
+ * Supports closed `<a, b>`, half-open `<a, b)` / `(a, b>`, and open `(a, b)` intervals via
241
+ * optional `leftClosed` and `rightClosed` booleans (both defaulting to `true`).
242
+ * @param {Array|string} rangeA - First 1D range (`[min, max]` or `[min, max, leftClosed, rightClosed]`) or array of ranges
243
+ * @param {Array|string} rangeB - Second 1D range or array of ranges
244
+ * @param {boolean} [leftClosed=true] - Default left-endpoint inclusivity (`true` = `<a`, `false` = `(a`)
245
+ * @param {boolean} [rightClosed=true] - Default right-endpoint inclusivity (`true` = `b>`, `false` = `b)`)
246
+ * @returns {Array<Array<number>>} Disjoint sorted array of intersecting `[min, max]` ranges
247
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4LAVzmghgGwEpYB2A5gKYIC+CAZjFGAgEQACxA1mgMZgikwBeAegwgcAZ0YBuYMHSZcBEqWlA|▶ Try it live in Instacode}
248
+ */
249
+ export const mutualRange = (rangeA, rangeB, leftClosed = true, rightClosed = true) => {
250
+ const listA = toIntervalList1D(rangeA, leftClosed, rightClosed);
251
+ const listB = toIntervalList1D(rangeB, leftClosed, rightClosed);
252
+ if (listA.length === 0 || listB.length === 0) return [];
253
+
254
+ const result = [];
255
+ let i = 0;
256
+ let j = 0;
257
+ while (i < listA.length && j < listB.length) {
258
+ const inter = intersectTwoIntervals1D(listA[i], listB[j]);
259
+ if (inter) {
260
+ result.push(inter);
261
+ }
262
+ if (listA[i][1] < listB[j][1] || (listA[i][1] === listB[j][1] && !listA[i].rightClosed && listB[j].rightClosed)) {
263
+ i += 1;
264
+ } else {
265
+ j += 1;
266
+ }
267
+ }
268
+ return mergeIntervals1D(result);
269
+ };
270
+
271
+ /**
272
+ * Computes the union (OR / sum) of 1D ranges or range sets.
273
+ * Touching intervals at boundary `p` coalesce if and only if at least one interval includes `p` (`rightClosed || leftClosed`).
274
+ * Can be called as `sumRange(listOfRanges, leftClosed, rightClosed)` or `sumRange(rangeA, rangeB, leftClosed, rightClosed)`.
275
+ * @param {Array|string} rangeA - First 1D range or array of 1D ranges
276
+ * @param {Array|string|boolean} [rangeB] - Second 1D range / array of ranges, or `leftClosed` boolean when 1st arg is a list
277
+ * @param {boolean} [leftClosed=true] - Default left-endpoint inclusivity
278
+ * @param {boolean} [rightClosed=true] - Default right-endpoint inclusivity
279
+ * @returns {Array<Array<number>>} Disjoint sorted array of `[min, max]` ranges
280
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IM4FcwCUCGA7AcwFMEBfBAMxijAQCIABAga3QGMwQiYAvAenRwQAG1R0A3MGAZs+YpKA|▶ Try it live in Instacode}
281
+ */
282
+ export const sumRange = (rangeA, rangeB = undefined, leftClosed = true, rightClosed = true) => {
283
+ if (typeof rangeB === 'boolean' || rangeB === undefined) {
284
+ const lc = typeof rangeB === 'boolean' ? rangeB : leftClosed;
285
+ const rc = typeof rangeB === 'boolean' ? Boolean(leftClosed) : rightClosed;
286
+ return toIntervalList1D(rangeA, lc, rc);
287
+ }
288
+ const listA = toIntervalList1D(rangeA, leftClosed, rightClosed);
289
+ const listB = toIntervalList1D(rangeB, leftClosed, rightClosed);
290
+ return mergeIntervals1D([...listA, ...listB]);
291
+ };
292
+
293
+ /**
294
+ * Computes the set difference (A \ B) of two 1D ranges or range sets, flipping boundary closures accurately.
295
+ * @param {Array|string} rangeA - Base 1D range or array of 1D ranges
296
+ * @param {Array|string} rangeB - Subtracted 1D range or array of 1D ranges
297
+ * @param {boolean} [leftClosed=true] - Default left-endpoint inclusivity
298
+ * @param {boolean} [rightClosed=true] - Default right-endpoint inclusivity
299
+ * @returns {Array<Array<number>>} Disjoint sorted array of `[min, max]` ranges in A but not in B
300
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4ICYgGYYEoEMB2A5gKYIC+CGMUYCARAAKEDWArgMZgjEwBeA9KzggANgGc6AbmDB0WPEWLSgA|▶ Try it live in Instacode}
301
+ */
302
+ export const diffRange = (rangeA, rangeB, leftClosed = true, rightClosed = true) => {
303
+ let current = toIntervalList1D(rangeA, leftClosed, rightClosed);
304
+ const subList = toIntervalList1D(rangeB, leftClosed, rightClosed);
305
+ if (current.length === 0 || subList.length === 0) return current;
306
+
307
+ for (let s = 0; s < subList.length; s += 1) {
308
+ const cut = subList[s];
309
+ const next = [];
310
+ for (let i = 0; i < current.length; i += 1) {
311
+ next.push(...subtractTwoIntervals1D(current[i], cut));
312
+ }
313
+ current = next;
314
+ if (current.length === 0) break;
315
+ }
316
+ return current;
317
+ };
318
+
319
+ /**
320
+ * Computes the symmetric difference (XOR / A Δ B) of two 1D ranges or range sets.
321
+ * @param {Array|string} rangeA - First 1D range or array of 1D ranges
322
+ * @param {Array|string} rangeB - Second 1D range or array of 1D ranges
323
+ * @param {boolean} [leftClosed=true] - Default left-endpoint inclusivity
324
+ * @param {boolean} [rightClosed=true] - Default right-endpoint inclusivity
325
+ * @returns {Array<Array<number>>} Disjoint sorted array of `[min, max]` ranges in exactly one of A or B
326
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IB6wEoEMB2BzAUwQF8EAzGKMBAIgAE8BrAVwGMwQCYAvAembggANgGcaAbmDB0MbPgKSgA|▶ Try it live in Instacode}
327
+ */
328
+ export const xorRange = (rangeA, rangeB, leftClosed = true, rightClosed = true) => {
329
+ const onlyA = diffRange(rangeA, rangeB, leftClosed, rightClosed);
330
+ const onlyB = diffRange(rangeB, rangeA, leftClosed, rightClosed);
331
+ return mergeIntervals1D([...onlyA, ...onlyB]);
332
+ };
333
+
334
+ /**
335
+ * Checks whether a 1D point `x` is contained in `rangeOrRanges`, respecting open vs closed endpoints.
336
+ * @param {Array|string} rangeOrRanges - 1D range or array of 1D ranges
337
+ * @param {number} x - Point coordinate to test
338
+ * @param {boolean} [leftClosed=true] - Default left-endpoint inclusivity
339
+ * @param {boolean} [rightClosed=true] - Default right-endpoint inclusivity
340
+ * @returns {boolean} `true` if `x` belongs to the range set
341
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IMZQHZwIYgwZwAUo84BGAEQQF8EAzGKMBAIgAEBzAawFdUwQAUxgAvAPQ84IADb4WAbmDB0WXAWKlKioA|▶ Try it live in Instacode}
342
+ */
343
+ export const containsPoint1D = (rangeOrRanges, x, leftClosed = true, rightClosed = true) => {
344
+ const val = Number(x);
345
+ if (!Number.isFinite(val)) return false;
346
+ const list = toIntervalList1D(rangeOrRanges, leftClosed, rightClosed);
347
+ for (let i = 0; i < list.length; i += 1) {
348
+ const iv = list[i];
349
+ const aboveMin = iv.leftClosed ? val >= iv[0] : val > iv[0];
350
+ const belowMax = iv.rightClosed ? val <= iv[1] : val < iv[1];
351
+ if (aboveMin && belowMax) return true;
352
+ }
353
+ return false;
354
+ };
355
+
356
+ /**
357
+ * Computes the total 1D Lebesgue measure (length) of a single range `[min, max]` or array of ranges.
358
+ * Single points `[a, a]` have length `0`.
359
+ * @param {Array|string} rangeOrRanges - 1D range or array of 1D ranges
360
+ * @returns {number} Total 1D measure (length >= 0)
361
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4LAUwIYGcCuM0BKGAdgOZoIC+CAZjFGAgEQACpA1jgMZghowAvAPQ44IADZYmAbmDB02PIRLlZQA|▶ Try it live in Instacode}
362
+ */
363
+ export const measureRange = (rangeOrRanges) => {
364
+ const list = toIntervalList1D(rangeOrRanges, true, true);
365
+ let total = 0;
366
+ for (let i = 0; i < list.length; i += 1) {
367
+ total += list[i][1] - list[i][0];
368
+ }
369
+ return total;
370
+ };
371
+
372
+ const normalizeBoxNd = (box, defaultLeftClosed = true, defaultRightClosed = true) => {
373
+ if (!Array.isArray(box) || box.length === 0) return null;
374
+ const axes = [];
375
+ for (let d = 0; d < box.length; d += 1) {
376
+ const iv = normalizeInterval1D(box[d], defaultLeftClosed, defaultRightClosed);
377
+ if (!iv) return null;
378
+ axes.push(iv);
379
+ }
380
+ return axes;
381
+ };
382
+
383
+ const isSingleBoxNd = (input) =>
384
+ Array.isArray(input) &&
385
+ input.length > 0 &&
386
+ (typeof input[0] === 'string' ||
387
+ (Array.isArray(input[0]) && typeof input[0][0] === 'number' && typeof input[0][1] === 'number'));
388
+
389
+ const toBoxListNd = (input, defaultLeftClosed = true, defaultRightClosed = true) => {
390
+ if (!Array.isArray(input) || input.length === 0) return [];
391
+ if (isSingleBoxNd(input)) {
392
+ const b = normalizeBoxNd(input, defaultLeftClosed, defaultRightClosed);
393
+ return b ? [b] : [];
394
+ }
395
+ const boxes = [];
396
+ for (let i = 0; i < input.length; i += 1) {
397
+ const b = normalizeBoxNd(input[i], defaultLeftClosed, defaultRightClosed);
398
+ if (b) boxes.push(b);
399
+ }
400
+ return boxes;
401
+ };
402
+
403
+ const cloneBoxNd = (box) =>
404
+ box.map((iv) => attachBounds([iv[0], iv[1]], iv.leftClosed ?? true, iv.rightClosed ?? true));
405
+
406
+ const intersectTwoBoxesNd = (boxA, boxB) => {
407
+ if (!boxA || !boxB || boxA.length !== boxB.length) return null;
408
+ const dim = boxA.length;
409
+ const out = new Array(dim);
410
+ for (let d = 0; d < dim; d += 1) {
411
+ const inter1D = intersectTwoIntervals1D(boxA[d], boxB[d]);
412
+ if (!inter1D) return null;
413
+ out[d] = inter1D;
414
+ }
415
+ return out;
416
+ };
417
+
418
+ const subtractSingleBoxNd = (boxA, boxB) => {
419
+ const inter = intersectTwoBoxesNd(boxA, boxB);
420
+ if (!inter) return [cloneBoxNd(boxA)];
421
+
422
+ const dim = boxA.length;
423
+ const shards = [];
424
+ const core = cloneBoxNd(boxA);
425
+
426
+ for (let d = 0; d < dim; d += 1) {
427
+ const remainders = subtractTwoIntervals1D(core[d], inter[d]);
428
+ for (let r = 0; r < remainders.length; r += 1) {
429
+ const remIv = remainders[r];
430
+ const shard = core.map((iv, idx) =>
431
+ idx === d
432
+ ? attachBounds([remIv[0], remIv[1]], remIv.leftClosed, remIv.rightClosed)
433
+ : attachBounds([iv[0], iv[1]], iv.leftClosed, iv.rightClosed)
434
+ );
435
+ shards.push(shard);
436
+ }
437
+ core[d] = attachBounds([inter[d][0], inter[d][1]], inter[d].leftClosed, inter[d].rightClosed);
438
+ }
439
+
440
+ return shards;
441
+ };
442
+
443
+ const tryMergeTwoBoxesNd = (boxA, boxB) => {
444
+ if (!boxA || !boxB || boxA.length !== boxB.length) return null;
445
+ const dim = boxA.length;
446
+ let diffAxis = -1;
447
+
448
+ for (let d = 0; d < dim; d += 1) {
449
+ const sameAxis =
450
+ boxA[d][0] === boxB[d][0] &&
451
+ boxA[d][1] === boxB[d][1] &&
452
+ boxA[d].leftClosed === boxB[d].leftClosed &&
453
+ boxA[d].rightClosed === boxB[d].rightClosed;
454
+ if (!sameAxis) {
455
+ if (diffAxis !== -1) return null;
456
+ diffAxis = d;
457
+ }
458
+ }
459
+
460
+ if (diffAxis === -1) return cloneBoxNd(boxA);
461
+ const mergedAxis = mergeIntervals1D([boxA[diffAxis], boxB[diffAxis]]);
462
+ if (mergedAxis.length !== 1) return null;
463
+
464
+ return boxA.map((iv, idx) =>
465
+ idx === diffAxis
466
+ ? mergedAxis[0]
467
+ : attachBounds([iv[0], iv[1]], iv.leftClosed, iv.rightClosed)
468
+ );
469
+ };
470
+
471
+ const coalesceDisjointBoxesNd = (boxes) => {
472
+ if (boxes.length <= 1) return boxes;
473
+ const result = boxes.map(cloneBoxNd);
474
+ let changed = true;
475
+ while (changed) {
476
+ changed = false;
477
+ outer: for (let i = 0; i < result.length; i += 1) {
478
+ for (let j = i + 1; j < result.length; j += 1) {
479
+ const merged = tryMergeTwoBoxesNd(result[i], result[j]);
480
+ if (merged) {
481
+ result[i] = merged;
482
+ result.splice(j, 1);
483
+ changed = true;
484
+ break outer;
485
+ }
486
+ }
487
+ }
488
+ }
489
+ return result;
490
+ };
491
+
492
+ const disjointizeBoxesNd = (boxes) => {
493
+ const disjoint = [];
494
+ for (let i = 0; i < boxes.length; i += 1) {
495
+ let candidates = [cloneBoxNd(boxes[i])];
496
+ for (let j = 0; j < disjoint.length && candidates.length > 0; j += 1) {
497
+ const existing = disjoint[j];
498
+ const nextCandidates = [];
499
+ for (let c = 0; c < candidates.length; c += 1) {
500
+ nextCandidates.push(...subtractSingleBoxNd(candidates[c], existing));
501
+ }
502
+ candidates = nextCandidates;
503
+ }
504
+ disjoint.push(...candidates);
505
+ }
506
+ return coalesceDisjointBoxesNd(disjoint);
507
+ };
508
+
509
+ /**
510
+ * Computes the N-dimensional intersection (AND) of two N-orthotopes (hyperrectangles) or sets of N-orthotopes.
511
+ * Each N-box is represented as `[[min_1, max_1], [min_2, max_2], ..., [min_N, max_N]]`.
512
+ * @param {Array} boxA - First N-box or array of N-boxes
513
+ * @param {Array} boxB - Second N-box or array of N-boxes
514
+ * @param {boolean} [leftClosed=true] - Default left-endpoint inclusivity on each axis
515
+ * @param {boolean} [rightClosed=true] - Default right-endpoint inclusivity on each axis
516
+ * @returns {Array<Array<Array<number>>>} Array of disjoint intersecting N-boxes
517
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4LAVzmghgGwEJQAeCAvggGYxRgIBEAAgOYDWaAxmCAKYwBeA9BhA4AzrQDcwYOky4ChSUA|▶ Try it live in Instacode}
518
+ */
519
+ export const mutualBox = (boxA, boxB, leftClosed = true, rightClosed = true) => {
520
+ const listA = disjointizeBoxesNd(toBoxListNd(boxA, leftClosed, rightClosed));
521
+ const listB = disjointizeBoxesNd(toBoxListNd(boxB, leftClosed, rightClosed));
522
+ if (listA.length === 0 || listB.length === 0) return [];
523
+
524
+ const out = [];
525
+ for (let i = 0; i < listA.length; i += 1) {
526
+ for (let j = 0; j < listB.length; j += 1) {
527
+ const inter = intersectTwoBoxesNd(listA[i], listB[j]);
528
+ if (inter) out.push(inter);
529
+ }
530
+ }
531
+ return coalesceDisjointBoxesNd(out);
532
+ };
533
+
534
+ /**
535
+ * Computes the N-dimensional set difference (A \ B) decomposed into pairwise-disjoint N-orthotopes.
536
+ * @param {Array} boxA - Base N-box or array of N-boxes
537
+ * @param {Array} boxB - Subtracted N-box or array of N-boxes
538
+ * @param {boolean} [leftClosed=true] - Default left-endpoint inclusivity on each axis
539
+ * @param {boolean} [rightClosed=true] - Default right-endpoint inclusivity on each axis
540
+ * @returns {Array<Array<Array<number>>>} Pairwise-disjoint N-boxes covering A \ B
541
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4ICYgGYYEJQB4IC+CGMUYCARAAIDmA1gK4DGYIApjAF4D0jcIADYBnSgG5gwdFlx4JQA|▶ Try it live in Instacode}
542
+ */
543
+ export const diffBox = (boxA, boxB, leftClosed = true, rightClosed = true) => {
544
+ let current = disjointizeBoxesNd(toBoxListNd(boxA, leftClosed, rightClosed));
545
+ const subList = toBoxListNd(boxB, leftClosed, rightClosed);
546
+ if (current.length === 0 || subList.length === 0) return current;
547
+
548
+ for (let s = 0; s < subList.length; s += 1) {
549
+ const cut = subList[s];
550
+ const next = [];
551
+ for (let i = 0; i < current.length; i += 1) {
552
+ next.push(...subtractSingleBoxNd(current[i], cut));
553
+ }
554
+ current = next;
555
+ if (current.length === 0) break;
556
+ }
557
+ return coalesceDisjointBoxesNd(current);
558
+ };
559
+
560
+ /**
561
+ * Computes the N-dimensional union (OR) decomposed into pairwise-disjoint N-orthotopes.
562
+ * @param {Array} boxA - First N-box or array of N-boxes
563
+ * @param {Array} [boxB] - Second N-box or array of N-boxes
564
+ * @param {boolean} [leftClosed=true] - Default left-endpoint inclusivity on each axis
565
+ * @param {boolean} [rightClosed=true] - Default right-endpoint inclusivity on each axis
566
+ * @returns {Array<Array<Array<number>>>} Pairwise-disjoint N-boxes covering A U B
567
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IM4FcwCEoA8EC+CAZjFGAgEQACA5gNboDGYIApjAF4D06cIAG1SUA3MGAZseMUA|▶ Try it live in Instacode}
568
+ */
569
+ export const sumBox = (boxA, boxB = undefined, leftClosed = true, rightClosed = true) => {
570
+ if (typeof boxB === 'boolean' || boxB === undefined) {
571
+ const lc = typeof boxB === 'boolean' ? boxB : leftClosed;
572
+ const rc = typeof boxB === 'boolean' ? Boolean(leftClosed) : rightClosed;
573
+ return disjointizeBoxesNd(toBoxListNd(boxA, lc, rc));
574
+ }
575
+ const listA = disjointizeBoxesNd(toBoxListNd(boxA, leftClosed, rightClosed));
576
+ const listB = toBoxListNd(boxB, leftClosed, rightClosed);
577
+ return disjointizeBoxesNd([...listA, ...listB]);
578
+ };
579
+
580
+ /**
581
+ * Computes the N-dimensional symmetric difference (XOR / A Δ B) as pairwise-disjoint N-orthotopes.
582
+ * @param {Array} boxA - First N-box or array of N-boxes
583
+ * @param {Array} boxB - Second N-box or array of N-boxes
584
+ * @param {boolean} [leftClosed=true] - Default left-endpoint inclusivity on each axis
585
+ * @param {boolean} [rightClosed=true] - Default right-endpoint inclusivity on each axis
586
+ * @returns {Array<Array<Array<number>>>} Pairwise-disjoint N-boxes covering A Δ B
587
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IB6wEJVQgvggMxijAQCIABAcwGsBXAYzBAFMYAvAejrhABsAzmQDcwYOhhZUooA|▶ Try it live in Instacode}
588
+ */
589
+ export const xorBox = (boxA, boxB, leftClosed = true, rightClosed = true) => {
590
+ const onlyA = diffBox(boxA, boxB, leftClosed, rightClosed);
591
+ const onlyB = diffBox(boxB, boxA, leftClosed, rightClosed);
592
+ return coalesceDisjointBoxesNd([...onlyA, ...onlyB]);
593
+ };
594
+
595
+ /**
596
+ * Checks whether an N-dimensional point `[x_1, x_2, ..., x_N]` belongs to `boxOrBoxes`,
597
+ * respecting open vs closed boundaries on every axis.
598
+ * @param {Array} boxOrBoxes - Single N-box or array of N-boxes
599
+ * @param {Array<number>} point - N-dimensional coordinate vector
600
+ * @param {boolean} [leftClosed=true] - Default left-endpoint inclusivity
601
+ * @param {boolean} [rightClosed=true] - Default right-endpoint inclusivity
602
+ * @returns {boolean} `true` if `point` lies inside the N-orthotope set
603
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IMZQHZwIYgwZwAUo84A5AEwQF8EAzGKMBAIgAEBzAawFdUwQAUxgAvAPQ84IADb4WAbmDB0WXAWKlKioA|▶ Try it live in Instacode}
604
+ */
605
+ export const containsPointNd = (boxOrBoxes, point, leftClosed = true, rightClosed = true) => {
606
+ if (!Array.isArray(point) || point.length === 0) return false;
607
+ const boxes = toBoxListNd(boxOrBoxes, leftClosed, rightClosed);
608
+ for (let i = 0; i < boxes.length; i += 1) {
609
+ const b = boxes[i];
610
+ if (b.length !== point.length) continue;
611
+ let insideAllAxes = true;
612
+ for (let d = 0; d < b.length; d += 1) {
613
+ const val = Number(point[d]);
614
+ const iv = b[d];
615
+ const aboveMin = iv.leftClosed ? val >= iv[0] : val > iv[0];
616
+ const belowMax = iv.rightClosed ? val <= iv[1] : val < iv[1];
617
+ if (!aboveMin || !belowMax) {
618
+ insideAllAxes = false;
619
+ break;
620
+ }
621
+ }
622
+ if (insideAllAxes) return true;
623
+ }
624
+ return false;
625
+ };
626
+
627
+ /**
628
+ * Computes the Lebesgue measure (1D length, 2D area, 3D volume, 4D hypervolume, N-D measure)
629
+ * of a single N-box `[[min_1, max_1], ..., [min_N, max_N]]` or an array of N-boxes.
630
+ * Automatically decomposes overlapping N-boxes into disjoint shards so overlapping regions are never double-counted.
631
+ * @param {Array} boxOrBoxes - Single N-box or array of N-boxes
632
+ * @returns {number} Exact N-dimensional measure
633
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4LAUwIYGcCuM0BCUAHggL4IBmMUYCARAAIDmA1jgMZghowBeAehxwQAGyz0A3MGDpseQiWlA|▶ Try it live in Instacode}
634
+ */
635
+ export const measureBox = (boxOrBoxes) => {
636
+ const disjoint = disjointizeBoxesNd(toBoxListNd(boxOrBoxes, true, true));
637
+ let total = 0;
638
+ for (let i = 0; i < disjoint.length; i += 1) {
639
+ const b = disjoint[i];
640
+ let prod = 1;
641
+ for (let d = 0; d < b.length; d += 1) {
642
+ prod *= b[d][1] - b[d][0];
643
+ }
644
+ total += prod;
645
+ }
646
+ return total;
647
+ };
648
+
649
+ /**
650
+ * Fast O(N) calculation of the shared N-dimensional measure (intersection length/area/volume/hypervolume)
651
+ * of two single N-orthotopes without allocating shard arrays.
652
+ * @param {Array<Array<number>>} boxA - First N-box `[[a1, b1], ..., [aN, bN]]`
653
+ * @param {Array<Array<number>>} boxB - Second N-box `[[c1, d1], ..., [cN, dN]]`
654
+ * @returns {number} Shared N-dimensional measure (>= 0)
655
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IM4AsCGMCmATAWR01QFdcA5PBAXwQDMYowEAiAAQHMBrUgYzAgcMAF4B6UnBAAbVKwDcwYBmz4iJcjiqKgA|▶ Try it live in Instacode}
656
+ */
657
+ export const sharedMeasureNd = (boxA, boxB) => {
658
+ if (!Array.isArray(boxA) || !Array.isArray(boxB) || boxA.length === 0 || boxA.length !== boxB.length) {
659
+ return 0;
660
+ }
661
+ let measure = 1;
662
+ for (let d = 0; d < boxA.length; d += 1) {
663
+ const a0 = Math.min(boxA[d][0], boxA[d][1]);
664
+ const a1 = Math.max(boxA[d][0], boxA[d][1]);
665
+ const b0 = Math.min(boxB[d][0], boxB[d][1]);
666
+ const b1 = Math.max(boxB[d][0], boxB[d][1]);
667
+ const overlap = Math.min(a1, b1) - Math.max(a0, b0);
668
+ if (overlap <= 0) return 0;
669
+ measure *= overlap;
670
+ }
671
+ return measure;
672
+ };
673
+
674
+ /**
675
+ * Chainable N-dimensional Orthotope (Hyperrectangle) Set supporting Boolean algebra
676
+ * (`and`, `or`, `sub`, `xor`), point membership (`contains`), and Lebesgue measure (`measure`)
677
+ * in any dimension N >= 1, with full open/closed boundary awareness.
678
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IPLwBZQMoFNEC+CAZjFGAgEQACA5gNYCuAxmCLjAF4D0jcIAGwDOlANzBg6OFjxxxQA|▶ Try it live in Instacode}
679
+ */
680
+ export class OrthoSet {
681
+ /**
682
+ * @param {Array} [boxes=[]] - Single N-box or array of N-boxes (or 1D interval)
683
+ * @param {boolean} [leftClosed=true] - Default left-endpoint inclusivity
684
+ * @param {boolean} [rightClosed=true] - Default right-endpoint inclusivity
685
+ */
686
+ constructor(boxes = [], leftClosed = true, rightClosed = true) {
687
+ this.leftClosed = Boolean(leftClosed);
688
+ this.rightClosed = Boolean(rightClosed);
689
+ if (Array.isArray(boxes) && boxes.length >= 2 && typeof boxes[0] === 'number' && typeof boxes[1] === 'number') {
690
+ const iv = normalizeInterval1D(boxes, this.leftClosed, this.rightClosed);
691
+ this.boxes = iv ? [[iv]] : [];
692
+ return;
693
+ }
694
+ this.boxes = disjointizeBoxesNd(toBoxListNd(boxes, this.leftClosed, this.rightClosed));
695
+ }
696
+
697
+ static fromRect2D(rect, leftClosed = true, rightClosed = true) {
698
+ if (!rect || typeof rect !== 'object') return new OrthoSet([], leftClosed, rightClosed);
699
+ const left = Number(rect.left ?? rect.x ?? 0);
700
+ const top = Number(rect.top ?? rect.y ?? 0);
701
+ const right = left + Number(rect.width ?? 0);
702
+ const bottom = top + Number(rect.height ?? 0);
703
+ return new OrthoSet(
704
+ [
705
+ [left, right],
706
+ [top, bottom],
707
+ ],
708
+ leftClosed,
709
+ rightClosed
710
+ );
711
+ }
712
+
713
+ and(other, leftClosed = this.leftClosed, rightClosed = this.rightClosed) {
714
+ const rhs = other instanceof OrthoSet ? other.boxes : other;
715
+ return new OrthoSet(mutualBox(this.boxes, rhs, leftClosed, rightClosed), leftClosed, rightClosed);
716
+ }
717
+
718
+ or(other, leftClosed = this.leftClosed, rightClosed = this.rightClosed) {
719
+ const rhs = other instanceof OrthoSet ? other.boxes : other;
720
+ return new OrthoSet(sumBox(this.boxes, rhs, leftClosed, rightClosed), leftClosed, rightClosed);
721
+ }
722
+
723
+ sub(other, leftClosed = this.leftClosed, rightClosed = this.rightClosed) {
724
+ const rhs = other instanceof OrthoSet ? other.boxes : other;
725
+ return new OrthoSet(diffBox(this.boxes, rhs, leftClosed, rightClosed), leftClosed, rightClosed);
726
+ }
727
+
728
+ xor(other, leftClosed = this.leftClosed, rightClosed = this.rightClosed) {
729
+ const rhs = other instanceof OrthoSet ? other.boxes : other;
730
+ return new OrthoSet(xorBox(this.boxes, rhs, leftClosed, rightClosed), leftClosed, rightClosed);
731
+ }
732
+
733
+ contains(point) {
734
+ return containsPointNd(this.boxes, point, this.leftClosed, this.rightClosed);
735
+ }
736
+
737
+ measure() {
738
+ return measureBox(this.boxes);
739
+ }
740
+
741
+ isEmpty() {
742
+ return this.boxes.length === 0;
743
+ }
744
+
745
+ toArray() {
746
+ return this.boxes.map((b) => b.map((iv) => attachBounds([iv[0], iv[1]], iv.leftClosed, iv.rightClosed)));
747
+ }
748
+ }