@flighthq/path-boolean 0.5.1-next.3302.76ece6b → 0.5.1-next.4304.322dc96

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.
Files changed (66) hide show
  1. package/README.md +2 -2
  2. package/dist/.tsbuildinfo.contract +1 -0
  3. package/dist/.tsbuildinfo.runtime +1 -0
  4. package/dist/booleanPaths.d.ts +33 -6
  5. package/dist/booleanPaths.d.ts.map +1 -1
  6. package/dist/booleanPaths.js +42 -3
  7. package/dist/booleanPaths.js.map +1 -1
  8. package/dist/contract.d.ts +12 -8
  9. package/dist/contract.d.ts.map +1 -1
  10. package/dist/contract.js +11 -8
  11. package/dist/contract.js.map +1 -1
  12. package/dist/contract.path-boolean-types.d.ts +4 -0
  13. package/dist/contract.path-boolean-types.d.ts.map +1 -0
  14. package/dist/contract.path-boolean-types.js +2 -0
  15. package/dist/contract.path-boolean-types.js.map +1 -0
  16. package/dist/explainPathBoolean.d.ts +15 -3
  17. package/dist/explainPathBoolean.d.ts.map +1 -1
  18. package/dist/explainPathBoolean.js +35 -9
  19. package/dist/explainPathBoolean.js.map +1 -1
  20. package/dist/index.d.ts +3 -8
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +3 -8
  23. package/dist/index.js.map +1 -1
  24. package/dist/martinezKernel.d.ts +44 -4
  25. package/dist/martinezKernel.d.ts.map +1 -1
  26. package/dist/martinezKernel.js +777 -559
  27. package/dist/martinezKernel.js.map +1 -1
  28. package/dist/offsetPath.d.ts +15 -2
  29. package/dist/offsetPath.d.ts.map +1 -1
  30. package/dist/offsetPath.js +205 -215
  31. package/dist/offsetPath.js.map +1 -1
  32. package/dist/pathBoolean.d.ts +53 -0
  33. package/dist/pathBoolean.d.ts.map +1 -0
  34. package/dist/pathBoolean.js +115 -0
  35. package/dist/pathBoolean.js.map +1 -0
  36. package/dist/pathBooleanCompat.d.ts +3 -0
  37. package/dist/pathBooleanCompat.d.ts.map +1 -0
  38. package/dist/pathBooleanCompat.js +2 -0
  39. package/dist/pathBooleanCompat.js.map +1 -0
  40. package/dist/resolvePathRegions.d.ts +8 -2
  41. package/dist/resolvePathRegions.d.ts.map +1 -1
  42. package/dist/resolvePathRegions.js +11 -3
  43. package/dist/resolvePathRegions.js.map +1 -1
  44. package/dist/simplifyPath.d.ts +9 -2
  45. package/dist/simplifyPath.d.ts.map +1 -1
  46. package/dist/simplifyPath.js +12 -2
  47. package/dist/simplifyPath.js.map +1 -1
  48. package/dist/unionAllPaths.d.ts +9 -2
  49. package/dist/unionAllPaths.d.ts.map +1 -1
  50. package/dist/unionAllPaths.js +23 -8
  51. package/dist/unionAllPaths.js.map +1 -1
  52. package/dist/writePathBooleanContours.d.ts +8 -2
  53. package/dist/writePathBooleanContours.d.ts.map +1 -1
  54. package/dist/writePathBooleanContours.js +16 -4
  55. package/dist/writePathBooleanContours.js.map +1 -1
  56. package/package.json +5 -4
  57. package/src/booleanPaths.test.ts +237 -114
  58. package/src/explainPathBoolean.test.ts +35 -21
  59. package/src/fuzzInvariants.test.ts +241 -154
  60. package/src/martinezKernel.test.ts +334 -328
  61. package/src/offsetPath.test.ts +296 -166
  62. package/src/pathBoolean.test.ts +95 -0
  63. package/src/resolvePathRegions.test.ts +41 -30
  64. package/src/simplifyPath.test.ts +143 -112
  65. package/src/unionAllPaths.test.ts +93 -61
  66. package/src/writePathBooleanContours.test.ts +44 -6
@@ -1,398 +1,404 @@
1
- import type { PathBooleanContour, PathBooleanFillRule, PathBooleanOperation } from '@flighthq/types/contract';
1
+ // Generated from crates/flight-path-boolean/tests/martinez_kernel_test.rs by flight-compiler.
2
+ // Rust is authoritative; run `npm run generate` after changing it.
3
+
2
4
  import { describe, expect, it } from 'vitest';
3
5
 
4
6
  import {
5
7
  createMartinezPathBooleanKernel,
8
+ getPathBooleanFillRuleString,
9
+ getPathBooleanOperationString,
6
10
  initializeMartinezPathBooleanKernel,
7
- martinezPathBooleanKernel,
8
- } from './martinezKernel.ts';
11
+ parsePathBooleanFillRule,
12
+ parsePathBooleanOperation,
13
+ } from './contract.ts';
14
+ import type { PathBooleanContour, PathBooleanFillRule, PathBooleanOperation } from './contract.ts';
15
+
16
+ function computeEmptyTestPathBoolean(
17
+ _subject: Readonly<PathBooleanContour[]>,
18
+ _clip: Readonly<PathBooleanContour[]>,
19
+ _operation: PathBooleanOperation,
20
+ _fillRule: PathBooleanFillRule,
21
+ ): PathBooleanContour[] {
22
+ return [] as never[];
23
+ }
9
24
 
10
- // A closed square contour [x, y, x+s, y, x+s, y+s, x, y+s], wound consistently (screen y-down CW).
11
25
  function square(x: number, y: number, s: number): number[] {
12
26
  return [x, y, x + s, y, x + s, y + s, x, y + s];
13
27
  }
14
28
 
15
- // A donut: an outer square with a reverse-wound inner square that cuts a hole under non-zero fill.
16
29
  function donut(x: number, y: number, outer: number, inset: number, inner: number): number[][] {
17
30
  const ix = x + inset;
18
31
  const iy = y + inset;
19
32
  return [square(x, y, outer), [ix, iy, ix, iy + inner, ix + inner, iy + inner, ix + inner, iy]];
20
33
  }
21
34
 
22
- // Net filled area of a correctly-wound result: outer rings and holes carry opposite signs, so the
23
- // signed-area sum is the true fill. Uses the shoelace sum without the abs-per-ring the geometry hides.
24
- function netArea(rings: readonly PathBooleanContour[]): number {
25
- let total = 0;
26
- for (const ring of rings) total += signedArea(ring);
35
+ function netArea(rings: Readonly<number[][]>): number {
36
+ let total: number = 0.0;
37
+ for (const ring of rings) {
38
+ total += signedAreaRing(ring);
39
+ }
27
40
  return Math.abs(total);
28
41
  }
29
42
 
30
- function signedArea(ring: PathBooleanContour): number {
31
- let area = 0;
43
+ function overlap(s: number): number[][] {
44
+ return [square(0.0, 0.0, 10.0 * s), square(5.0 * s, 5.0 * s, 10.0 * s)];
45
+ }
46
+
47
+ function signedAreaRing(ring: Readonly<number[]>): number {
48
+ let area: number = 0.0;
32
49
  const n = ring.length >> 1;
33
- for (let i = 0, j = n - 1; i < n; j = i++) {
50
+ let j = n - 1;
51
+ for (let i = 0; i < n; i += 1) {
34
52
  area += ring[j * 2] * ring[i * 2 + 1] - ring[i * 2] * ring[j * 2 + 1];
53
+ j = i;
35
54
  }
36
- return area / 2;
55
+ return area / 2.0;
37
56
  }
38
57
 
39
- function boundsOf(rings: readonly PathBooleanContour[]): {
40
- minX: number;
41
- minY: number;
42
- maxX: number;
43
- maxY: number;
44
- } {
45
- let minX = Infinity;
46
- let minY = Infinity;
47
- let maxX = -Infinity;
48
- let maxY = -Infinity;
58
+ function boundsOf(rings: Readonly<number[][]>): readonly [number, number, number, number] {
59
+ let minX = Number.POSITIVE_INFINITY;
60
+ let minY = Number.POSITIVE_INFINITY;
61
+ let maxX = Number.NEGATIVE_INFINITY;
62
+ let maxY = Number.NEGATIVE_INFINITY;
49
63
  for (const ring of rings) {
50
- for (let i = 0; i < ring.length; i += 2) {
51
- minX = Math.min(minX, ring[i]);
52
- maxX = Math.max(maxX, ring[i]);
53
- minY = Math.min(minY, ring[i + 1]);
54
- maxY = Math.max(maxY, ring[i + 1]);
64
+ let i = 0;
65
+ while (i < ring.length) {
66
+ if (ring[i] < minX) {
67
+ minX = ring[i];
68
+ }
69
+ if (ring[i] > maxX) {
70
+ maxX = ring[i];
71
+ }
72
+ if (ring[i + 1] < minY) {
73
+ minY = ring[i + 1];
74
+ }
75
+ if (ring[i + 1] > maxY) {
76
+ maxY = ring[i + 1];
77
+ }
78
+ i += 2;
55
79
  }
56
80
  }
57
- return { minX, minY, maxX, maxY };
81
+ return [minX, minY, maxX, maxY];
58
82
  }
59
83
 
60
- // Whether (x, y) is inside the result region under non-zero fill (holes counter-wound).
61
- function fillContains(rings: readonly PathBooleanContour[], x: number, y: number): boolean {
62
- let winding = 0;
84
+ function fillContains(rings: Readonly<number[][]>, x: number, y: number): boolean {
85
+ let winding: number = 0;
63
86
  for (const ring of rings) {
64
87
  const n = ring.length >> 1;
65
- for (let i = 0, j = n - 1; i < n; j = i++) {
88
+ let j = n - 1;
89
+ for (let i = 0; i < n; i += 1) {
66
90
  const xi = ring[i * 2];
67
91
  const yi = ring[i * 2 + 1];
68
92
  const xj = ring[j * 2];
69
93
  const yj = ring[j * 2 + 1];
70
94
  if (yi <= y ? yj > y : yj <= y) {
71
95
  const t = (y - yi) / (yj - yi);
72
- if (x < xi + t * (xj - xi)) winding += yj > yi ? 1 : -1;
96
+ if (x < xi + t * (xj - xi)) {
97
+ winding += yj > yi ? 1 : -1;
98
+ }
73
99
  }
100
+ j = i;
74
101
  }
75
102
  }
76
103
  return winding !== 0;
77
104
  }
78
105
 
79
106
  function run(
80
- subject: readonly number[][],
81
- clip: readonly number[][],
107
+ subject: Readonly<number[][]>,
108
+ clip: Readonly<number[][]>,
82
109
  operation: PathBooleanOperation,
83
- fillRule: PathBooleanFillRule = 'nonZero',
84
- ): readonly PathBooleanContour[] {
85
- return createMartinezPathBooleanKernel().computePathBoolean(subject, clip, operation, fillRule);
110
+ fillRule: PathBooleanFillRule,
111
+ ): number[][] {
112
+ const kernel = createMartinezPathBooleanKernel();
113
+ return kernel.computePathBoolean(subject, clip, operation, fillRule);
86
114
  }
87
115
 
88
116
  describe('createMartinezPathBooleanKernel', () => {
89
- describe('coincident and shared-boundary degeneracies', () => {
90
- it('unions two squares sharing a full edge into one rectangle', () => {
91
- const result = run([square(0, 0, 10)], [square(10, 0, 10)], 'union');
92
- expect(netArea(result)).toBeCloseTo(200, 6);
93
- expect(boundsOf(result)).toMatchObject({ minX: 0, maxX: 20, minY: 0, maxY: 10 });
94
- expect(fillContains(result, 10, 5)).toBe(true); // the former shared edge is now interior
95
- });
96
-
97
- it('intersects two squares sharing an edge to nothing (zero-width overlap)', () => {
98
- const result = run([square(0, 0, 10)], [square(10, 0, 10)], 'intersection');
99
- expect(netArea(result)).toBeCloseTo(0, 6);
100
- });
101
-
102
- it('handles a partially overlapping shared edge (offset abutment)', () => {
103
- // Clip's left edge overlaps only the top half of subject's right edge.
104
- const result = run([square(0, 0, 10)], [[10, 5, 20, 5, 20, 15, 10, 15]], 'union');
105
- expect(netArea(result)).toBeCloseTo(200, 6);
106
- expect(fillContains(result, 10, 7)).toBe(true);
107
- });
108
-
109
- it('collapses two identical squares under union to a single square', () => {
110
- const result = run([square(0, 0, 10)], [square(0, 0, 10)], 'union');
111
- expect(netArea(result)).toBeCloseTo(100, 6);
112
- });
113
-
114
- it('collapses two identical squares under intersection to the same square', () => {
115
- const result = run([square(0, 0, 10)], [square(0, 0, 10)], 'intersection');
116
- expect(netArea(result)).toBeCloseTo(100, 6);
117
- });
118
-
119
- it('empties two identical squares under xor', () => {
120
- const result = run([square(0, 0, 10)], [square(0, 0, 10)], 'xor');
121
- expect(netArea(result)).toBeCloseTo(0, 6);
122
- });
123
-
124
- it('empties a square differenced against an identical square', () => {
125
- const result = run([square(0, 0, 10)], [square(0, 0, 10)], 'difference');
126
- expect(netArea(result)).toBeCloseTo(0, 6);
127
- });
117
+ it('creates a kernel that computes boolean operations', () => {
118
+ const kernel = createMartinezPathBooleanKernel();
119
+ const result = kernel.computePathBoolean([square(0.0, 0.0, 10.0)], [square(5.0, 5.0, 10.0)], 'union', 'nonZero');
120
+ expect(Math.abs(netArea(result) - 175.0) < 0.1).toBe(true);
128
121
  });
129
-
130
- describe('difference', () => {
131
- it('cuts a hole when the clip is fully contained', () => {
132
- const result = run([square(0, 0, 30)], [square(10, 10, 10)], 'difference');
133
- expect(netArea(result)).toBeCloseTo(800, 6);
134
- expect(result.length).toBe(2); // outer boundary + hole
135
- expect(fillContains(result, 2, 2)).toBe(true);
136
- expect(fillContains(result, 15, 15)).toBe(false); // inside the hole
137
- });
138
-
139
- it('subtracts a partial overlap', () => {
140
- const result = run([square(0, 0, 10)], [square(5, 5, 10)], 'difference');
141
- expect(netArea(result)).toBeCloseTo(75, 6);
142
- expect(fillContains(result, 2, 2)).toBe(true);
143
- expect(fillContains(result, 7, 7)).toBe(false);
144
- });
145
-
146
- it('is empty when subtracting a superset', () => {
147
- const result = run([square(10, 10, 10)], [square(0, 0, 30)], 'difference');
148
- expect(netArea(result)).toBeCloseTo(0, 6);
149
- });
122
+ it('unions two squares sharing a full edge into one rectangle', () => {
123
+ const result = run([square(0.0, 0.0, 10.0)], [square(10.0, 0.0, 10.0)], 'union', 'nonZero');
124
+ expect(Math.abs(netArea(result) - 200.0) < 5e-7).toBe(true);
125
+ const [minX, minY, maxX, maxY] = boundsOf(result);
126
+ expect(Math.abs(minX - 0.0) < 5e-7).toBe(true);
127
+ expect(Math.abs(maxX - 20.0) < 5e-7).toBe(true);
128
+ expect(Math.abs(minY - 0.0) < 5e-7).toBe(true);
129
+ expect(Math.abs(maxY - 10.0) < 5e-7).toBe(true);
130
+ expect(fillContains(result, 10.0, 5.0)).toBe(true);
150
131
  });
151
-
152
- describe('disjoint inputs', () => {
153
- it('unions disjoint squares into two rings', () => {
154
- const result = run([square(0, 0, 10)], [square(20, 20, 10)], 'union');
155
- expect(netArea(result)).toBeCloseTo(200, 6);
156
- expect(result.length).toBe(2);
157
- });
158
-
159
- it('intersects disjoint squares to nothing', () => {
160
- const result = run([square(0, 0, 10)], [square(20, 20, 10)], 'intersection');
161
- expect(netArea(result)).toBeCloseTo(0, 6);
162
- });
163
-
164
- it('xors disjoint squares to both squares', () => {
165
- const result = run([square(0, 0, 10)], [square(20, 20, 10)], 'xor');
166
- expect(netArea(result)).toBeCloseTo(200, 6);
167
- });
132
+ it('intersects two squares sharing an edge to nothing', () => {
133
+ const result = run([square(0.0, 0.0, 10.0)], [square(10.0, 0.0, 10.0)], 'intersection', 'nonZero');
134
+ expect(netArea(result) < 5e-7).toBe(true);
168
135
  });
169
-
170
- describe('empty and degenerate inputs', () => {
171
- it('returns empty for two empty operands', () => {
172
- expect(run([], [], 'union')).toEqual([]);
173
- });
174
-
175
- it('passes the subject through union with an empty clip', () => {
176
- const result = run([square(0, 0, 10)], [], 'union');
177
- expect(netArea(result)).toBeCloseTo(100, 6);
178
- });
179
-
180
- it('empties intersection with an empty clip', () => {
181
- expect(netArea(run([square(0, 0, 10)], [], 'intersection'))).toBeCloseTo(0, 6);
182
- });
183
-
184
- it('ignores a zero-area (collinear) contour', () => {
185
- const degenerate = [0, 0, 10, 0, 5, 0]; // three collinear points, no area
186
- const result = run([square(0, 0, 10)], [degenerate], 'union');
187
- expect(netArea(result)).toBeCloseTo(100, 6);
188
- });
189
-
190
- it('ignores a single-point contour', () => {
191
- const result = run([square(0, 0, 10)], [[5, 5, 5, 5, 5, 5]], 'union');
192
- expect(netArea(result)).toBeCloseTo(100, 6);
193
- });
194
-
195
- it('ignores a contour with too few points', () => {
196
- const result = run([square(0, 0, 10)], [[1, 1, 2, 2]], 'union');
197
- expect(netArea(result)).toBeCloseTo(100, 6);
198
- });
136
+ it('handles a partially overlapping shared edge', () => {
137
+ const result = run([square(0.0, 0.0, 10.0)], [[10.0, 5.0, 20.0, 5.0, 20.0, 15.0, 10.0, 15.0]], 'union', 'nonZero');
138
+ expect(Math.abs(netArea(result) - 200.0) < 5e-7).toBe(true);
139
+ expect(fillContains(result, 10.0, 7.0)).toBe(true);
199
140
  });
200
-
201
- describe('holes', () => {
202
- it('unions a donut with an overlapping square, filling part of the hole', () => {
203
- const result = run(donut(0, 0, 30, 10, 10), [square(12, 12, 20)], 'union');
204
- // Donut is 30x30 minus a 10x10 hole (800); the union square (12,12)-(32,32) plugs the hole and
205
- // extends past the donut. Assert the former hole center is now filled and a hole corner sample
206
- // that the plug does not cover stays empty.
207
- expect(fillContains(result, 15, 15)).toBe(true); // plugged
208
- expect(fillContains(result, 11, 11)).toBe(false); // still a hole corner outside the plug
209
- });
210
-
211
- it('intersects a donut with a square covering its hole to a frame', () => {
212
- const result = run(donut(0, 0, 30, 10, 10), [square(5, 5, 20)], 'intersection');
213
- // Intersection keeps donut material within (5,5)-(25,25), which is a frame around the hole.
214
- expect(fillContains(result, 15, 15)).toBe(false); // the hole survives
215
- expect(fillContains(result, 8, 8)).toBe(true); // donut material inside the clip
216
- });
217
-
218
- it('differences a square from a donut, deepening the hole', () => {
219
- const result = run(donut(0, 0, 30, 5, 20), [square(10, 10, 10)], 'difference');
220
- // Donut hole is (5,5)-(25,25); subtracting (10,10)-(20,20) removes material only where it overlaps
221
- // the donut ring. Hole center stays empty; ring material outside the clip stays filled.
222
- expect(fillContains(result, 15, 15)).toBe(false);
223
- expect(fillContains(result, 2, 2)).toBe(true);
224
- });
141
+ it('collapses two identical squares under union to a single square', () => {
142
+ const result = run([square(0.0, 0.0, 10.0)], [square(0.0, 0.0, 10.0)], 'union', 'nonZero');
143
+ expect(Math.abs(netArea(result) - 100.0) < 5e-7).toBe(true);
225
144
  });
226
-
227
- describe('self-intersecting input and fill rules', () => {
228
- it('fills the overlap of two same-wound squares under non-zero (winding 2)', () => {
229
- const result = run([square(0, 0, 10), square(5, 5, 10)], [], 'union', 'nonZero');
230
- expect(netArea(result)).toBeCloseTo(175, 6);
231
- expect(fillContains(result, 7, 7)).toBe(true); // overlap filled
232
- expect(result.length).toBe(1); // one solid outline, no hole
233
- });
234
-
235
- it('holes out the overlap of two same-wound squares under even-odd', () => {
236
- const result = run([square(0, 0, 10), square(5, 5, 10)], [], 'union', 'evenOdd');
237
- expect(netArea(result)).toBeCloseTo(150, 6);
238
- expect(fillContains(result, 7, 7)).toBe(false); // overlap is a hole
239
- expect(fillContains(result, 2, 2)).toBe(true); // non-overlap stays filled
240
- });
241
-
242
- it('treats a bowtie contour under non-zero as its two filled lobes', () => {
243
- // A self-crossing quad (0,0)-(10,0)-(0,10)-(10,10): the diagonals cross at (5,5) forming two
244
- // triangles. Under non-zero the opposite-wound lobes each fill.
245
- const bowtie = [0, 0, 10, 0, 0, 10, 10, 10];
246
- const result = run([bowtie], [], 'union', 'nonZero');
247
- expect(fillContains(result, 5, 1)).toBe(true); // lower lobe
248
- expect(fillContains(result, 5, 9)).toBe(true); // upper lobe
249
- expect(fillContains(result, 1, 5)).toBe(false); // between lobes, outside
250
- });
145
+ it('collapses two identical squares under intersection to the same square', () => {
146
+ const result = run([square(0.0, 0.0, 10.0)], [square(0.0, 0.0, 10.0)], 'intersection', 'nonZero');
147
+ expect(Math.abs(netArea(result) - 100.0) < 5e-7).toBe(true);
251
148
  });
252
-
253
- describe('positive and negative fill rules', () => {
254
- it('keeps a counter-clockwise (positively-wound) region under positive fill, drops it under negative', () => {
255
- const ccw = square(0, 0, 10); // shoelace-positive winding
256
- expect(netArea(run([ccw], [], 'union', 'positive'))).toBeCloseTo(100, 6);
257
- expect(netArea(run([ccw], [], 'union', 'negative'))).toBeCloseTo(0, 6);
258
- });
259
-
260
- it('mirrors that for a clockwise (negatively-wound) region', () => {
261
- const cw = [0, 0, 0, 10, 10, 10, 10, 0]; // reversed traversal, shoelace-negative winding
262
- expect(netArea(run([cw], [], 'union', 'negative'))).toBeCloseTo(100, 6);
263
- expect(netArea(run([cw], [], 'union', 'positive'))).toBeCloseTo(0, 6);
264
- });
265
-
266
- it('dissolves a same-wound self-overlap under positive fill (winding 2 stays filled)', () => {
267
- // The offset-cleanup fill: two overlapping same-wound squares fill solid (area 175, one ring), the
268
- // doubly-wound overlap kept rather than holed — this is why offsetPath resolves under positive.
269
- const result = run([square(0, 0, 10), square(5, 5, 10)], [], 'union', 'positive');
270
- expect(netArea(result)).toBeCloseTo(175, 6);
271
- expect(result.length).toBe(1);
272
- expect(fillContains(result, 7, 7)).toBe(true);
273
- });
149
+ it('empties two identical squares under xor', () => {
150
+ const result = run([square(0.0, 0.0, 10.0)], [square(0.0, 0.0, 10.0)], 'xor', 'nonZero');
151
+ expect(netArea(result) < 5e-7).toBe(true);
274
152
  });
275
-
276
- describe('scale invariance', () => {
277
- it('resolves the same topology and scale-relative area across a 1e9 span of coordinate scales', () => {
278
- // Two overlapping squares — a real interior crossing, the regime where the vertex-merge snap
279
- // matters. Scaling the coordinates must not change the ring count nor the area-relative-to-scale²;
280
- // the magnitude-relative snap is what makes the resolved topology invariant to coordinate scale.
281
- const overlap = (s: number): number[][] => [square(0, 0, 10 * s), square(5 * s, 5 * s, 10 * s)];
282
- const small = run(overlap(1e-3), [], 'union', 'nonZero');
283
- const mid = run(overlap(1), [], 'union', 'nonZero');
284
- const large = run(overlap(1e6), [], 'union', 'nonZero');
285
- expect(small.length).toBe(mid.length);
286
- expect(large.length).toBe(mid.length);
287
- // Union of the two squares is one solid ring of area 175 at unit scale; area scales with s².
288
- expect(netArea(mid)).toBeCloseTo(175, 6);
289
- expect(netArea(small) / 1e-3 ** 2).toBeCloseTo(175, 4);
290
- expect(netArea(large) / (1e6 * 1e6)).toBeCloseTo(175, 4);
291
- });
153
+ it('empties a square differenced against an identical square', () => {
154
+ const result = run([square(0.0, 0.0, 10.0)], [square(0.0, 0.0, 10.0)], 'difference', 'nonZero');
155
+ expect(netArea(result) < 5e-7).toBe(true);
292
156
  });
293
-
294
- describe('shared single vertex (corner touching)', () => {
295
- it('unions two corner-touching squares to their combined area', () => {
296
- const result = run([square(0, 0, 10)], [square(10, 10, 10)], 'union');
297
- expect(netArea(result)).toBeCloseTo(200, 6);
298
- expect(fillContains(result, 5, 5)).toBe(true);
299
- expect(fillContains(result, 15, 15)).toBe(true);
300
- });
301
-
302
- it('intersects two corner-touching squares to nothing', () => {
303
- const result = run([square(0, 0, 10)], [square(10, 10, 10)], 'intersection');
304
- expect(netArea(result)).toBeCloseTo(0, 6);
305
- });
157
+ it('cuts a hole when the clip is fully contained', () => {
158
+ const result = run([square(0.0, 0.0, 30.0)], [square(10.0, 10.0, 10.0)], 'difference', 'nonZero');
159
+ expect(Math.abs(netArea(result) - 800.0) < 5e-7).toBe(true);
160
+ expect(result.length).toEqual(2);
161
+ expect(fillContains(result, 2.0, 2.0)).toBe(true);
162
+ expect(!fillContains(result, 15.0, 15.0)).toBe(true);
306
163
  });
307
-
308
- describe('commutativity and symmetry', () => {
309
- it('unions commute', () => {
310
- const a = square(0, 0, 10);
311
- const b = square(5, 5, 10);
312
- expect(netArea(run([a], [b], 'union'))).toBeCloseTo(netArea(run([b], [a], 'union')), 6);
313
- });
314
-
315
- it('intersections commute', () => {
316
- const a = square(0, 0, 10);
317
- const b = square(3, 4, 10);
318
- expect(netArea(run([a], [b], 'intersection'))).toBeCloseTo(netArea(run([b], [a], 'intersection')), 6);
319
- });
320
-
321
- it('xor is symmetric', () => {
322
- const a = square(0, 0, 10);
323
- const b = square(4, 4, 10);
324
- expect(netArea(run([a], [b], 'xor'))).toBeCloseTo(netArea(run([b], [a], 'xor')), 6);
325
- });
164
+ it('subtracts a partial overlap', () => {
165
+ const result = run([square(0.0, 0.0, 10.0)], [square(5.0, 5.0, 10.0)], 'difference', 'nonZero');
166
+ expect(Math.abs(netArea(result) - 75.0) < 5e-7).toBe(true);
167
+ expect(fillContains(result, 2.0, 2.0)).toBe(true);
168
+ expect(!fillContains(result, 7.0, 7.0)).toBe(true);
326
169
  });
327
-
328
- describe('contained squares', () => {
329
- it('unions to the outer square', () => {
330
- const result = run([square(0, 0, 30)], [square(10, 10, 10)], 'union');
331
- expect(netArea(result)).toBeCloseTo(900, 6);
332
- });
333
-
334
- it('intersects to the inner square', () => {
335
- const result = run([square(0, 0, 30)], [square(10, 10, 10)], 'intersection');
336
- expect(netArea(result)).toBeCloseTo(100, 6);
337
- });
338
-
339
- it('xors to the outer-with-hole frame', () => {
340
- const result = run([square(0, 0, 30)], [square(10, 10, 10)], 'xor');
341
- expect(netArea(result)).toBeCloseTo(800, 6);
342
- expect(fillContains(result, 15, 15)).toBe(false);
343
- expect(fillContains(result, 2, 2)).toBe(true);
344
- });
170
+ it('is empty when subtracting a superset', () => {
171
+ const result = run([square(10.0, 10.0, 10.0)], [square(0.0, 0.0, 30.0)], 'difference', 'nonZero');
172
+ expect(netArea(result) < 5e-7).toBe(true);
345
173
  });
174
+ it('unions disjoint squares into two rings', () => {
175
+ const result = run([square(0.0, 0.0, 10.0)], [square(20.0, 20.0, 10.0)], 'union', 'nonZero');
176
+ expect(Math.abs(netArea(result) - 200.0) < 5e-7).toBe(true);
177
+ expect(result.length).toEqual(2);
178
+ });
179
+ it('intersects disjoint squares to nothing', () => {
180
+ const result = run([square(0.0, 0.0, 10.0)], [square(20.0, 20.0, 10.0)], 'intersection', 'nonZero');
181
+ expect(netArea(result) < 5e-7).toBe(true);
182
+ });
183
+ it('xors disjoint squares to both squares', () => {
184
+ const result = run([square(0.0, 0.0, 10.0)], [square(20.0, 20.0, 10.0)], 'xor', 'nonZero');
185
+ expect(Math.abs(netArea(result) - 200.0) < 5e-7).toBe(true);
186
+ });
187
+ it('returns empty for two empty operands', () => {
188
+ const result = run([], [], 'union', 'nonZero');
189
+ expect(result.length === 0).toBe(true);
190
+ });
191
+ it('passes the subject through union with an empty clip', () => {
192
+ const result = run([square(0.0, 0.0, 10.0)], [], 'union', 'nonZero');
193
+ expect(Math.abs(netArea(result) - 100.0) < 5e-7).toBe(true);
194
+ });
195
+ it('empties intersection with an empty clip', () => {
196
+ const result = run([square(0.0, 0.0, 10.0)], [], 'intersection', 'nonZero');
197
+ expect(netArea(result) < 5e-7).toBe(true);
198
+ });
199
+ it('ignores a zero area collinear contour', () => {
200
+ const degenerate = [0.0, 0.0, 10.0, 0.0, 5.0, 0.0];
201
+ const result = run([square(0.0, 0.0, 10.0)], [degenerate], 'union', 'nonZero');
202
+ expect(Math.abs(netArea(result) - 100.0) < 5e-7).toBe(true);
203
+ });
204
+ it('ignores a single point contour', () => {
205
+ const result = run([square(0.0, 0.0, 10.0)], [[5.0, 5.0, 5.0, 5.0, 5.0, 5.0]], 'union', 'nonZero');
206
+ expect(Math.abs(netArea(result) - 100.0) < 5e-7).toBe(true);
207
+ });
208
+ it('ignores a contour with too few points', () => {
209
+ const result = run([square(0.0, 0.0, 10.0)], [[1.0, 1.0, 2.0, 2.0]], 'union', 'nonZero');
210
+ expect(Math.abs(netArea(result) - 100.0) < 5e-7).toBe(true);
211
+ });
212
+ it('unions a donut with an overlapping square filling part of the hole', () => {
213
+ const result = run(donut(0.0, 0.0, 30.0, 10.0, 10.0), [square(12.0, 12.0, 20.0)], 'union', 'nonZero');
214
+ expect(fillContains(result, 15.0, 15.0)).toBe(true);
215
+ expect(!fillContains(result, 11.0, 11.0)).toBe(true);
216
+ });
217
+ it('intersects a donut with a square covering its hole to a frame', () => {
218
+ const result = run(donut(0.0, 0.0, 30.0, 10.0, 10.0), [square(5.0, 5.0, 20.0)], 'intersection', 'nonZero');
219
+ expect(!fillContains(result, 15.0, 15.0)).toBe(true);
220
+ expect(fillContains(result, 8.0, 8.0)).toBe(true);
221
+ });
222
+ it('differences a square from a donut deepening the hole', () => {
223
+ const result = run(donut(0.0, 0.0, 30.0, 5.0, 20.0), [square(10.0, 10.0, 10.0)], 'difference', 'nonZero');
224
+ expect(!fillContains(result, 15.0, 15.0)).toBe(true);
225
+ expect(fillContains(result, 2.0, 2.0)).toBe(true);
226
+ });
227
+ it('fills the overlap of two same wound squares under non zero', () => {
228
+ const result = run([square(0.0, 0.0, 10.0), square(5.0, 5.0, 10.0)], [], 'union', 'nonZero');
229
+ expect(Math.abs(netArea(result) - 175.0) < 5e-7).toBe(true);
230
+ expect(fillContains(result, 7.0, 7.0)).toBe(true);
231
+ expect(result.length).toEqual(1);
232
+ });
233
+ it('holes out the overlap of two same wound squares under even odd', () => {
234
+ const result = run([square(0.0, 0.0, 10.0), square(5.0, 5.0, 10.0)], [], 'union', 'evenOdd');
235
+ expect(Math.abs(netArea(result) - 150.0) < 5e-7).toBe(true);
236
+ expect(!fillContains(result, 7.0, 7.0)).toBe(true);
237
+ expect(fillContains(result, 2.0, 2.0)).toBe(true);
238
+ });
239
+ it('treats a bowtie contour under non zero as its two filled lobes', () => {
240
+ const bowtie = [0.0, 0.0, 10.0, 0.0, 0.0, 10.0, 10.0, 10.0];
241
+ const result = run([bowtie], [], 'union', 'nonZero');
242
+ expect(fillContains(result, 5.0, 1.0)).toBe(true);
243
+ expect(fillContains(result, 5.0, 9.0)).toBe(true);
244
+ expect(!fillContains(result, 1.0, 5.0)).toBe(true);
245
+ });
246
+ it('keeps a counter clockwise region under positive fill drops it under negative', () => {
247
+ const ccw = square(0.0, 0.0, 10.0);
248
+ expect(Math.abs(netArea(run([ccw.slice()], [], 'union', 'positive')) - 100.0) < 5e-7).toBe(true);
249
+ expect(netArea(run([ccw], [], 'union', 'negative')) < 5e-7).toBe(true);
250
+ });
251
+ it('mirrors that for a clockwise region', () => {
252
+ const cw = [0.0, 0.0, 0.0, 10.0, 10.0, 10.0, 10.0, 0.0];
253
+ expect(Math.abs(netArea(run([cw.slice()], [], 'union', 'negative')) - 100.0) < 5e-7).toBe(true);
254
+ expect(netArea(run([cw], [], 'union', 'positive')) < 5e-7).toBe(true);
255
+ });
256
+ it('dissolves a same wound self overlap under positive fill', () => {
257
+ const result = run([square(0.0, 0.0, 10.0), square(5.0, 5.0, 10.0)], [], 'union', 'positive');
258
+ expect(Math.abs(netArea(result) - 175.0) < 5e-7).toBe(true);
259
+ expect(result.length).toEqual(1);
260
+ expect(fillContains(result, 7.0, 7.0)).toBe(true);
261
+ });
262
+ it('resolves the same topology across coordinate scales', () => {
263
+ const small = run(overlap(1e-3), [], 'union', 'nonZero');
264
+ const mid = run(overlap(1.0), [], 'union', 'nonZero');
265
+ const large = run(overlap(1e6), [], 'union', 'nonZero');
266
+ expect(small.length).toEqual(mid.length);
267
+ expect(large.length).toEqual(mid.length);
268
+ expect(Math.abs(netArea(mid) - 175.0) < 5e-7).toBe(true);
269
+ expect(Math.abs(netArea(small) / 1e-6 - 175.0) < 5e-5).toBe(true);
270
+ expect(Math.abs(netArea(large) / 1e12 - 175.0) < 5e-5).toBe(true);
271
+ });
272
+ it('unions two corner touching squares to their combined area', () => {
273
+ const result = run([square(0.0, 0.0, 10.0)], [square(10.0, 10.0, 10.0)], 'union', 'nonZero');
274
+ expect(Math.abs(netArea(result) - 200.0) < 5e-7).toBe(true);
275
+ expect(fillContains(result, 5.0, 5.0)).toBe(true);
276
+ expect(fillContains(result, 15.0, 15.0)).toBe(true);
277
+ });
278
+ it('intersects two corner touching squares to nothing', () => {
279
+ const result = run([square(0.0, 0.0, 10.0)], [square(10.0, 10.0, 10.0)], 'intersection', 'nonZero');
280
+ expect(netArea(result) < 5e-7).toBe(true);
281
+ });
282
+ it('unions commute', () => {
283
+ const a = square(0.0, 0.0, 10.0);
284
+ const b = square(5.0, 5.0, 10.0);
285
+ const ab = netArea(run([a.slice()], [b.slice()], 'union', 'nonZero'));
286
+ const ba = netArea(run([b], [a], 'union', 'nonZero'));
287
+ expect(Math.abs(ab - ba) < 5e-7).toBe(true);
288
+ });
289
+ it('intersections commute', () => {
290
+ const a = square(0.0, 0.0, 10.0);
291
+ const b = square(3.0, 4.0, 10.0);
292
+ const ab = netArea(run([a.slice()], [b.slice()], 'intersection', 'nonZero'));
293
+ const ba = netArea(run([b], [a], 'intersection', 'nonZero'));
294
+ expect(Math.abs(ab - ba) < 5e-7).toBe(true);
295
+ });
296
+ it('xor is symmetric', () => {
297
+ const a = square(0.0, 0.0, 10.0);
298
+ const b = square(4.0, 4.0, 10.0);
299
+ const ab = netArea(run([a.slice()], [b.slice()], 'xor', 'nonZero'));
300
+ const ba = netArea(run([b], [a], 'xor', 'nonZero'));
301
+ expect(Math.abs(ab - ba) < 5e-7).toBe(true);
302
+ });
303
+ it('unions contained to the outer square', () => {
304
+ const result = run([square(0.0, 0.0, 30.0)], [square(10.0, 10.0, 10.0)], 'union', 'nonZero');
305
+ expect(Math.abs(netArea(result) - 900.0) < 5e-7).toBe(true);
306
+ });
307
+ it('intersects contained to the inner square', () => {
308
+ const result = run([square(0.0, 0.0, 30.0)], [square(10.0, 10.0, 10.0)], 'intersection', 'nonZero');
309
+ expect(Math.abs(netArea(result) - 100.0) < 5e-7).toBe(true);
310
+ });
311
+ it('xors contained to the outer with hole frame', () => {
312
+ const result = run([square(0.0, 0.0, 30.0)], [square(10.0, 10.0, 10.0)], 'xor', 'nonZero');
313
+ expect(Math.abs(netArea(result) - 800.0) < 5e-7).toBe(true);
314
+ expect(!fillContains(result, 15.0, 15.0)).toBe(true);
315
+ expect(fillContains(result, 2.0, 2.0)).toBe(true);
316
+ });
317
+ it('unions two triangles sharing a diagonal into a square', () => {
318
+ const lower = [0.0, 0.0, 10.0, 0.0, 10.0, 10.0];
319
+ const upper = [0.0, 0.0, 10.0, 10.0, 0.0, 10.0];
320
+ const result = run([lower], [upper], 'union', 'nonZero');
321
+ expect(Math.abs(netArea(result) - 100.0) < 5e-7).toBe(true);
322
+ expect(fillContains(result, 5.0, 5.0)).toBe(true);
323
+ });
324
+ it('intersects two diagonal sharing triangles to nothing', () => {
325
+ const lower = [0.0, 0.0, 10.0, 0.0, 10.0, 10.0];
326
+ const upper = [0.0, 0.0, 10.0, 10.0, 0.0, 10.0];
327
+ const result = run([lower], [upper], 'intersection', 'nonZero');
328
+ expect(netArea(result) < 5e-7).toBe(true);
329
+ });
330
+ it('intersects a diamond overlapping a square', () => {
331
+ const diamond = [5.0, 5.0, 10.0, 0.0, 15.0, 5.0, 10.0, 10.0];
332
+ const result = run([square(0.0, 0.0, 10.0)], [diamond], 'intersection', 'nonZero');
333
+ expect(Math.abs(netArea(result) - 25.0) < 5e-7).toBe(true);
334
+ expect(fillContains(result, 8.0, 5.0)).toBe(true);
335
+ expect(!fillContains(result, 12.0, 5.0)).toBe(true);
336
+ });
337
+ it('unions a rotated square with an overlapping axis square', () => {
338
+ const diamond = [0.0, 10.0, 10.0, 0.0, 20.0, 10.0, 10.0, 20.0];
339
+ const result = run([square(0.0, 0.0, 12.0)], [diamond], 'union', 'nonZero');
340
+ expect(fillContains(result, 2.0, 2.0)).toBe(true);
341
+ expect(fillContains(result, 18.0, 10.0)).toBe(true);
342
+ expect(!fillContains(result, 19.0, 19.0)).toBe(true);
343
+ });
344
+ });
346
345
 
347
- describe('non-axis-aligned inputs', () => {
348
- it('unions two triangles sharing a diagonal into a square', () => {
349
- const lower = [0, 0, 10, 0, 10, 10]; // lower-right half of the unit-10 square
350
- const upper = [0, 0, 10, 10, 0, 10]; // upper-left half, sharing the (0,0)-(10,10) diagonal
351
- const result = run([lower], [upper], 'union');
352
- expect(netArea(result)).toBeCloseTo(100, 6);
353
- expect(fillContains(result, 5, 5)).toBe(true); // the former shared diagonal is interior
354
- });
355
-
356
- it('intersects two diagonal-sharing triangles to nothing', () => {
357
- const lower = [0, 0, 10, 0, 10, 10];
358
- const upper = [0, 0, 10, 10, 0, 10];
359
- expect(netArea(run([lower], [upper], 'intersection'))).toBeCloseTo(0, 6);
360
- });
361
-
362
- it('intersects a diamond overlapping a square (real crossing points)', () => {
363
- // Diamond centered on the square's right edge; only its left triangle (area 25) is inside.
364
- const diamond = [5, 5, 10, 0, 15, 5, 10, 10];
365
- const result = run([square(0, 0, 10)], [diamond], 'intersection');
366
- expect(netArea(result)).toBeCloseTo(25, 6);
367
- expect(fillContains(result, 8, 5)).toBe(true);
368
- expect(fillContains(result, 12, 5)).toBe(false);
369
- });
370
-
371
- it('unions a rotated square (diamond) with an overlapping axis square', () => {
372
- const diamond = [0, 10, 10, 0, 20, 10, 10, 20]; // area 200, centered (10,10)
373
- const result = run([square(0, 0, 12)], [diamond], 'union');
374
- // Both regions are inside the union; a point deep in each stays filled and the exterior stays out.
375
- expect(fillContains(result, 2, 2)).toBe(true); // square-only corner
376
- expect(fillContains(result, 18, 10)).toBe(true); // diamond-only tip region
377
- expect(fillContains(result, 19, 19)).toBe(false); // outside both
378
- });
346
+ describe('getPathBooleanFillRuleString', () => {
347
+ it('returns the exact ts strings', () => {
348
+ expect(getPathBooleanFillRuleString('evenOdd')).toEqual('evenOdd');
349
+ expect(getPathBooleanFillRuleString('nonZero')).toEqual('nonZero');
350
+ expect(getPathBooleanFillRuleString('negative')).toEqual('negative');
351
+ expect(getPathBooleanFillRuleString('positive')).toEqual('positive');
379
352
  });
380
353
  });
381
354
 
382
- describe('initializeMartinezPathBooleanKernel', () => {
383
- it('is the construction initializer of createMartinezPathBooleanKernel', () => {
384
- expect(typeof initializeMartinezPathBooleanKernel).toBe('function');
355
+ describe('getPathBooleanOperationString', () => {
356
+ it('returns the exact ts strings', () => {
357
+ expect(getPathBooleanOperationString('difference')).toEqual('difference');
358
+ expect(getPathBooleanOperationString('intersection')).toEqual('intersection');
359
+ expect(getPathBooleanOperationString('union')).toEqual('union');
360
+ expect(getPathBooleanOperationString('xor')).toEqual('xor');
385
361
  });
386
362
  });
387
363
 
388
- describe('martinezPathBooleanKernel', () => {
389
- it('is the shared kernel the boolean operations are handed', () => {
390
- const result = martinezPathBooleanKernel.computePathBoolean(
391
- [square(0, 0, 10)],
392
- [square(5, 5, 10)],
393
- 'union',
364
+ describe('initializeMartinezPathBooleanKernel', () => {
365
+ it('is the construction initializer of create martinez path boolean kernel', () => {
366
+ let kernel = createMartinezPathBooleanKernel();
367
+ kernel.computePathBoolean = computeEmptyTestPathBoolean;
368
+ initializeMartinezPathBooleanKernel(kernel);
369
+ expect(kernel).toEqual(createMartinezPathBooleanKernel());
370
+ const result = kernel.computePathBoolean(
371
+ [square(0.0, 0.0, 10.0)],
372
+ [square(5.0, 5.0, 10.0)],
373
+ 'intersection',
394
374
  'nonZero',
395
375
  );
396
- expect(netArea(result)).toBeCloseTo(175, 4);
376
+ expect(Math.abs(netArea(result) - 25.0) < 0.1).toBe(true);
377
+ });
378
+ });
379
+
380
+ describe('parsePathBooleanFillRule', () => {
381
+ it('parses the exact ts strings', () => {
382
+ expect(parsePathBooleanFillRule('evenOdd')).toEqual('evenOdd');
383
+ expect(parsePathBooleanFillRule('nonZero')).toEqual('nonZero');
384
+ expect(parsePathBooleanFillRule('negative')).toEqual('negative');
385
+ expect(parsePathBooleanFillRule('positive')).toEqual('positive');
386
+ });
387
+ it('rejects other text and wrong casing', () => {
388
+ expect(parsePathBooleanFillRule('EVENODD')).toEqual(undefined);
389
+ expect(parsePathBooleanFillRule('')).toEqual(undefined);
390
+ });
391
+ });
392
+
393
+ describe('parsePathBooleanOperation', () => {
394
+ it('parses the exact ts strings', () => {
395
+ expect(parsePathBooleanOperation('difference')).toEqual('difference');
396
+ expect(parsePathBooleanOperation('intersection')).toEqual('intersection');
397
+ expect(parsePathBooleanOperation('union')).toEqual('union');
398
+ expect(parsePathBooleanOperation('xor')).toEqual('xor');
399
+ });
400
+ it('rejects other text and wrong casing', () => {
401
+ expect(parsePathBooleanOperation('DIFFERENCE')).toEqual(undefined);
402
+ expect(parsePathBooleanOperation('')).toEqual(undefined);
397
403
  });
398
404
  });