@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,217 +1,347 @@
1
- import { createPath, appendPathClose, appendPathLineTo, appendPathMoveTo, flattenPath } from '@flighthq/path/contract';
2
- import type { Path, PathOffsetOptions } from '@flighthq/types/contract';
1
+ // Generated from crates/flight-path-boolean/tests/offset_path_test.rs by flight-compiler.
2
+ // Rust is authoritative; run `npm run generate` after changing it.
3
+
4
+ import {
5
+ PathCommand,
6
+ appendPathClose,
7
+ appendPathLineTo,
8
+ appendPathMoveTo,
9
+ createPath,
10
+ flattenPath,
11
+ } from '@flighthq/path/contract';
12
+ import type { Path } from '@flighthq/path/contract';
3
13
  import { describe, expect, it } from 'vitest';
4
14
 
5
- import { martinezPathBooleanKernel } from './martinezKernel.ts';
6
- import { offsetPath } from './offsetPath.ts';
7
- import { simplifyPath } from './simplifyPath.ts';
15
+ import { createMartinezPathBooleanKernel, offsetPath, simplifyPath } from './contract.ts';
16
+ import type { PathBooleanOptions, PathOffsetOptions } from './contract.ts';
8
17
 
9
- const kernel = martinezPathBooleanKernel;
18
+ const UNIT_SQUARE: Float64Array = new Float64Array([0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0]);
10
19
 
11
- function polygonPath(vertices: readonly number[], closed: boolean): Path {
12
- const path = createPath('nonZero');
20
+ function flattenedPath(path: Readonly<Path>, tolerance: number | undefined): number[][] {
21
+ const out = flattenPath(path, tolerance);
22
+ return out;
23
+ }
24
+
25
+ function polygonPath(vertices: Readonly<number[]>, closed: boolean): Path {
26
+ let path = createPath('nonZero');
13
27
  appendPathMoveTo(path, vertices[0], vertices[1]);
14
- for (let i = 2; i < vertices.length; i += 2) appendPathLineTo(path, vertices[i], vertices[i + 1]);
15
- if (closed) appendPathClose(path);
28
+ let i = 2;
29
+ while (i < vertices.length) {
30
+ appendPathLineTo(path, vertices[i], vertices[i + 1]);
31
+ i += 2;
32
+ }
33
+ if (closed) {
34
+ appendPathClose(path);
35
+ }
16
36
  return path;
17
37
  }
18
38
 
19
- function pathBounds(path: Readonly<Path>): { minX: number; minY: number; maxX: number; maxY: number } {
20
- let minX = Infinity;
21
- let minY = Infinity;
22
- let maxX = -Infinity;
23
- let maxY = -Infinity;
24
- for (const ring of flattenPath(path)) {
25
- for (let i = 0; i < ring.length; i += 2) {
26
- minX = Math.min(minX, ring[i]);
27
- minY = Math.min(minY, ring[i + 1]);
28
- maxX = Math.max(maxX, ring[i]);
29
- maxY = Math.max(maxY, ring[i + 1]);
39
+ function pathBounds(path: Readonly<Path>): readonly [number, number, number, number] {
40
+ let minX = Number.POSITIVE_INFINITY;
41
+ let minY = Number.POSITIVE_INFINITY;
42
+ let maxX = Number.NEGATIVE_INFINITY;
43
+ let maxY = Number.NEGATIVE_INFINITY;
44
+ for (const ring of flattenedPath(path, 0.25)) {
45
+ let i = 0;
46
+ while (i < ring.length) {
47
+ if (ring[i] < minX) {
48
+ minX = ring[i];
49
+ }
50
+ if (ring[i + 1] < minY) {
51
+ minY = ring[i + 1];
52
+ }
53
+ if (ring[i] > maxX) {
54
+ maxX = ring[i];
55
+ }
56
+ if (ring[i + 1] > maxY) {
57
+ maxY = ring[i + 1];
58
+ }
59
+ i += 2;
30
60
  }
31
61
  }
32
- return { minX, minY, maxX, maxY };
62
+ return [minX, minY, maxX, maxY];
63
+ }
64
+
65
+ function lShape(s: number): number[] {
66
+ return [0.0, 0.0, 2.0 * s, 0.0, 2.0 * s, 2.0 * s, s, 2.0 * s, s, s, 0.0, s];
33
67
  }
34
68
 
35
69
  function pathArea(path: Readonly<Path>): number {
36
- let total = 0;
37
- for (const ring of flattenPath(path)) {
38
- let area = 0;
70
+ let total: number = 0.0;
71
+ for (const ring of flattenedPath(path, 0.25)) {
72
+ let area: number = 0.0;
39
73
  const n = ring.length >> 1;
40
- for (let i = 0, j = n - 1; i < n; j = i++) {
74
+ let j = n - 1;
75
+ for (let i = 0; i < n; i += 1) {
41
76
  area += ring[j * 2] * ring[i * 2 + 1] - ring[i * 2] * ring[j * 2 + 1];
77
+ j = i;
42
78
  }
43
- total += area / 2;
79
+ total += area / 2.0;
44
80
  }
45
81
  return Math.abs(total);
46
82
  }
47
83
 
48
- function pathVertexCount(path: Readonly<Path>): number {
49
- let count = 0;
50
- for (const ring of flattenPath(path)) count += ring.length / 2;
84
+ function _pathVertexCount(path: Readonly<Path>): number {
85
+ let count: number = 0;
86
+ for (const ring of flattenedPath(path, 0.25)) {
87
+ count += Math.trunc(ring.length / 2);
88
+ }
51
89
  return count;
52
90
  }
53
91
 
54
92
  function ringCount(path: Readonly<Path>): number {
55
- return flattenPath(path).length;
93
+ return flattenedPath(path, 0.25).length;
56
94
  }
57
95
 
58
- function runOffsetPath(path: Readonly<Path>, delta: number, options?: Readonly<PathOffsetOptions>): Path {
59
- const out = createPath('nonZero');
60
- offsetPath(out, kernel, path, delta, options);
61
- return out;
62
- }
63
-
64
- const UNIT_SQUARE = [0, 0, 1, 0, 1, 1, 0, 1];
65
-
66
96
  describe('offsetPath', () => {
67
- it('writes into an existing output and is alias-safe', () => {
68
- const path = polygonPath([0, 0, 4, 0, 4, 4, 0, 4], true);
69
- offsetPath(path, kernel, path, 1);
70
-
71
- expect(pathBounds(path)).toEqual({ minX: -1, minY: -1, maxX: 5, maxY: 5 });
97
+ it('writes into an existing output using a distinct source snapshot', () => {
98
+ const kernel = createMartinezPathBooleanKernel();
99
+ let path = polygonPath([0.0, 0.0, 4.0, 0.0, 4.0, 4.0, 0.0, 4.0], true);
100
+ const pathClone = path;
101
+ offsetPath(path, kernel, pathClone, 1.0, undefined);
102
+ const [minX, minY, maxX, maxY] = pathBounds(path);
103
+ expect(Math.abs(minX - -1.0) < 5e-7).toBe(true);
104
+ expect(Math.abs(minY - -1.0) < 5e-7).toBe(true);
105
+ expect(Math.abs(maxX - 5.0) < 5e-7).toBe(true);
106
+ expect(Math.abs(maxY - 5.0) < 5e-7).toBe(true);
72
107
  });
73
-
74
108
  it('inflates a closed square by delta on every side with a miter join', () => {
75
- const result = runOffsetPath(polygonPath(UNIT_SQUARE, true), 1);
76
- const bounds = pathBounds(result);
77
- expect(bounds.minX).toBeCloseTo(-1, 6);
78
- expect(bounds.minY).toBeCloseTo(-1, 6);
79
- expect(bounds.maxX).toBeCloseTo(2, 6);
80
- expect(bounds.maxY).toBeCloseTo(2, 6);
81
- expect(pathArea(result)).toBeCloseTo(9, 4);
109
+ const kernel = createMartinezPathBooleanKernel();
110
+ let out = createPath('nonZero');
111
+ offsetPath(out, kernel, polygonPath(Array.from(UNIT_SQUARE), true), 1.0, undefined);
112
+ const [minX, minY, maxX, maxY] = pathBounds(out);
113
+ expect(Math.abs(minX - -1.0) < 5e-7).toBe(true);
114
+ expect(Math.abs(minY - -1.0) < 5e-7).toBe(true);
115
+ expect(Math.abs(maxX - 2.0) < 5e-7).toBe(true);
116
+ expect(Math.abs(maxY - 2.0) < 5e-7).toBe(true);
117
+ expect(Math.abs(pathArea(out) - 9.0) < 5e-5).toBe(true);
82
118
  });
83
-
84
119
  it('deflates a closed square on a negative delta', () => {
85
- const result = runOffsetPath(polygonPath([0, 0, 4, 0, 4, 4, 0, 4], true), -1);
86
- const bounds = pathBounds(result);
87
- expect(bounds.minX).toBeCloseTo(1, 6);
88
- expect(bounds.minY).toBeCloseTo(1, 6);
89
- expect(bounds.maxX).toBeCloseTo(3, 6);
90
- expect(bounds.maxY).toBeCloseTo(3, 6);
91
- expect(pathArea(result)).toBeCloseTo(4, 4);
120
+ const kernel = createMartinezPathBooleanKernel();
121
+ let out = createPath('nonZero');
122
+ offsetPath(out, kernel, polygonPath([0.0, 0.0, 4.0, 0.0, 4.0, 4.0, 0.0, 4.0], true), -1.0, undefined);
123
+ const [minX, minY, maxX, maxY] = pathBounds(out);
124
+ expect(Math.abs(minX - 1.0) < 5e-7).toBe(true);
125
+ expect(Math.abs(minY - 1.0) < 5e-7).toBe(true);
126
+ expect(Math.abs(maxX - 3.0) < 5e-7).toBe(true);
127
+ expect(Math.abs(maxY - 3.0) < 5e-7).toBe(true);
128
+ expect(Math.abs(pathArea(out) - 4.0) < 5e-5).toBe(true);
92
129
  });
93
-
94
130
  it('emits an empty path when deflation collapses the region', () => {
95
- const result = runOffsetPath(polygonPath(UNIT_SQUARE, true), -1);
96
- expect(result.commands.length).toBe(0);
97
- expect(pathArea(result)).toBe(0);
131
+ const kernel = createMartinezPathBooleanKernel();
132
+ let out = createPath('nonZero');
133
+ offsetPath(out, kernel, polygonPath(Array.from(UNIT_SQUARE), true), -1.0, undefined);
134
+ expect(out.commands.length).toEqual(0);
135
+ expect(Math.abs(pathArea(out) - 0.0) < 5e-7).toBe(true);
98
136
  });
99
-
100
137
  it('chamfers corners with a bevel join', () => {
101
- const result = runOffsetPath(polygonPath(UNIT_SQUARE, true), 1, { join: 'bevel' });
102
- const bounds = pathBounds(result);
103
- expect(bounds.minX).toBeCloseTo(-1, 6);
104
- expect(bounds.maxX).toBeCloseTo(2, 6);
105
- expect(pathArea(result)).toBeCloseTo(7, 4);
138
+ const kernel = createMartinezPathBooleanKernel();
139
+ const opts: PathOffsetOptions = {
140
+ join: 'bevel',
141
+ end: undefined,
142
+ miterLimit: undefined,
143
+ tolerance: undefined,
144
+ arcTolerance: undefined,
145
+ } as const;
146
+ let out = createPath('nonZero');
147
+ offsetPath(out, kernel, polygonPath(Array.from(UNIT_SQUARE), true), 1.0, opts);
148
+ const [minX, , maxX] = pathBounds(out);
149
+ expect(Math.abs(minX - -1.0) < 5e-7).toBe(true);
150
+ expect(Math.abs(maxX - 2.0) < 5e-7).toBe(true);
151
+ expect(Math.abs(pathArea(out) - 7.0) < 5e-5).toBe(true);
106
152
  });
107
-
108
- it('rounds corners with a round join and tessellates denser with radius and finer tolerance', () => {
109
- const rounded = runOffsetPath(polygonPath(UNIT_SQUARE, true), 1, { join: 'round' });
110
- const bounds = pathBounds(rounded);
111
- expect(bounds.minX).toBeCloseTo(-1, 6);
112
- expect(bounds.maxX).toBeCloseTo(2, 6);
113
- const fineArea = pathArea(runOffsetPath(polygonPath(UNIT_SQUARE, true), 1, { join: 'round', arcTolerance: 0.001 }));
114
- expect(fineArea).toBeCloseTo(5 + Math.PI, 1);
115
-
116
- const largeRadius = runOffsetPath(polygonPath(UNIT_SQUARE, true), 5, { join: 'round' });
117
- expect(pathVertexCount(largeRadius)).toBeGreaterThan(pathVertexCount(rounded));
118
-
119
- const fineTolerance = runOffsetPath(polygonPath(UNIT_SQUARE, true), 1, {
153
+ it('rounds corners with a round join', () => {
154
+ const kernel = createMartinezPathBooleanKernel();
155
+ const opts: PathOffsetOptions = {
156
+ join: 'round',
157
+ end: undefined,
158
+ miterLimit: undefined,
159
+ tolerance: undefined,
160
+ arcTolerance: undefined,
161
+ } as const;
162
+ let out = createPath('nonZero');
163
+ offsetPath(out, kernel, polygonPath(Array.from(UNIT_SQUARE), true), 1.0, opts);
164
+ const [minX, , maxX] = pathBounds(out);
165
+ expect(Math.abs(minX - -1.0) < 5e-7).toBe(true);
166
+ expect(Math.abs(maxX - 2.0) < 5e-7).toBe(true);
167
+ const fineOpts: PathOffsetOptions = {
120
168
  join: 'round',
121
- arcTolerance: 0.01,
122
- });
123
- expect(pathVertexCount(fineTolerance)).toBeGreaterThan(pathVertexCount(rounded));
169
+ end: undefined,
170
+ miterLimit: undefined,
171
+ tolerance: undefined,
172
+ arcTolerance: 0.001,
173
+ } as const;
174
+ let fine = createPath('nonZero');
175
+ offsetPath(fine, kernel, polygonPath(Array.from(UNIT_SQUARE), true), 1.0, fineOpts);
176
+ const fineArea = pathArea(fine);
177
+ expect(Math.abs(fineArea - (5.0 + Math.PI)) < 0.2).toBe(true);
124
178
  });
125
-
126
179
  it('falls back to a bevel when a sharp miter exceeds the miter limit', () => {
127
- const wedge = [0, 0, 4, 0.5, 4, -0.5];
128
- const clipped = runOffsetPath(polygonPath(wedge, true), 1, { miterLimit: 2 });
129
- const sharp = runOffsetPath(polygonPath(wedge, true), 1, { miterLimit: 50 });
130
- expect(pathBounds(clipped).minX).toBeGreaterThan(pathBounds(sharp).minX + 3);
131
- expect(pathBounds(sharp).minX).toBeLessThan(-5);
180
+ const kernel = createMartinezPathBooleanKernel();
181
+ const wedge = [0.0, 0.0, 4.0, 0.5, 4.0, -0.5];
182
+ const clippedOpts: PathOffsetOptions = {
183
+ join: undefined,
184
+ end: undefined,
185
+ miterLimit: 2.0,
186
+ tolerance: undefined,
187
+ arcTolerance: undefined,
188
+ };
189
+ const sharpOpts: PathOffsetOptions = {
190
+ join: undefined,
191
+ end: undefined,
192
+ miterLimit: 50.0,
193
+ tolerance: undefined,
194
+ arcTolerance: undefined,
195
+ };
196
+ let clipped = createPath('nonZero');
197
+ let sharp = createPath('nonZero');
198
+ offsetPath(clipped, kernel, polygonPath(wedge, true), 1.0, clippedOpts);
199
+ offsetPath(sharp, kernel, polygonPath(wedge, true), 1.0, sharpOpts);
200
+ const [clippedMinX, , ,] = pathBounds(clipped);
201
+ const [sharpMinX, , ,] = pathBounds(sharp);
202
+ expect(clippedMinX > sharpMinX + 3.0).toBe(true);
203
+ expect(sharpMinX < -5.0).toBe(true);
132
204
  });
133
-
134
205
  it('squares corners with a square join', () => {
135
- const result = runOffsetPath(polygonPath(UNIT_SQUARE, true), 1, { join: 'square' });
136
- const bounds = pathBounds(result);
137
- expect(bounds.minX).toBeCloseTo(-1, 6);
138
- expect(bounds.maxX).toBeCloseTo(2, 6);
139
- expect(pathArea(result)).toBeCloseTo(9, 4);
140
- expect(pathArea(result)).toBeGreaterThan(
141
- pathArea(runOffsetPath(polygonPath(UNIT_SQUARE, true), 1, { join: 'bevel' })),
142
- );
206
+ const kernel = createMartinezPathBooleanKernel();
207
+ const opts: PathOffsetOptions = {
208
+ join: 'square',
209
+ end: undefined,
210
+ miterLimit: undefined,
211
+ tolerance: undefined,
212
+ arcTolerance: undefined,
213
+ } as const;
214
+ let out = createPath('nonZero');
215
+ offsetPath(out, kernel, polygonPath(Array.from(UNIT_SQUARE), true), 1.0, opts);
216
+ const [minX, , maxX] = pathBounds(out);
217
+ expect(Math.abs(minX - -1.0) < 5e-7).toBe(true);
218
+ expect(Math.abs(maxX - 2.0) < 5e-7).toBe(true);
219
+ expect(Math.abs(pathArea(out) - 9.0) < 5e-5).toBe(true);
220
+ const bevelOpts: PathOffsetOptions = {
221
+ join: 'bevel',
222
+ end: undefined,
223
+ miterLimit: undefined,
224
+ tolerance: undefined,
225
+ arcTolerance: undefined,
226
+ } as const;
227
+ let bevel = createPath('nonZero');
228
+ offsetPath(bevel, kernel, polygonPath(Array.from(UNIT_SQUARE), true), 1.0, bevelOpts);
229
+ expect(pathArea(out) > pathArea(bevel)).toBe(true);
143
230
  });
144
-
145
231
  it('cleans a concave corner into a single valid outline', () => {
146
- const lShape = [0, 0, 2, 0, 2, 2, 1, 2, 1, 1, 0, 1];
147
- const result = runOffsetPath(polygonPath(lShape, true), 0.25);
148
- const bounds = pathBounds(result);
149
- expect(bounds.minX).toBeCloseTo(-0.25, 6);
150
- expect(bounds.minY).toBeCloseTo(-0.25, 6);
151
- expect(bounds.maxX).toBeCloseTo(2.25, 6);
152
- expect(bounds.maxY).toBeCloseTo(2.25, 6);
153
- expect(result.commands.filter((c) => c === 1).length).toBe(1);
154
- expect(pathArea(result)).toBeGreaterThan(3);
155
- });
156
-
157
- it('closes a concave slot narrower than 2·delta into a valid self-intersection-free outline', () => {
158
- const uShape = [0, 0, 10, 0, 10, 10, 5.5, 10, 5.5, 3, 4.5, 3, 4.5, 10, 0, 10];
159
- const result = runOffsetPath(polygonPath(uShape, true), 1);
160
- expect(ringCount(result)).toBe(1);
161
- const bounds = pathBounds(result);
162
- expect(bounds.minX).toBeCloseTo(-1, 6);
163
- expect(bounds.maxX).toBeCloseTo(11, 6);
164
- expect(bounds.minY).toBeCloseTo(-1, 6);
165
- expect(bounds.maxY).toBeCloseTo(11, 6);
166
- const simplified = createPath();
167
- simplifyPath(simplified, kernel, result, { fillRule: 'nonZero' });
168
- expect(ringCount(simplified)).toBe(ringCount(result));
169
- expect(pathArea(simplified)).toBeCloseTo(pathArea(result), 4);
232
+ const kernel = createMartinezPathBooleanKernel();
233
+ const lShape = [0.0, 0.0, 2.0, 0.0, 2.0, 2.0, 1.0, 2.0, 1.0, 1.0, 0.0, 1.0];
234
+ let out = createPath('nonZero');
235
+ offsetPath(out, kernel, polygonPath(lShape, true), 0.25, undefined);
236
+ const [minX, minY, maxX, maxY] = pathBounds(out);
237
+ expect(Math.abs(minX - -0.25) < 5e-7).toBe(true);
238
+ expect(Math.abs(minY - -0.25) < 5e-7).toBe(true);
239
+ expect(Math.abs(maxX - 2.25) < 5e-7).toBe(true);
240
+ expect(Math.abs(maxY - 2.25) < 5e-7).toBe(true);
241
+ let moveCount = 0;
242
+ for (const command of out.commands) {
243
+ if (command === (PathCommand.MOVE_TO & 0xff)) {
244
+ moveCount += 1;
245
+ }
246
+ }
247
+ expect(moveCount).toEqual(1);
248
+ expect(pathArea(out) > 3.0).toBe(true);
170
249
  });
171
-
172
- it('offsets consistently across a 1e9 span of coordinate scales (magnitude-relative epsilons)', () => {
173
- const lShape = (s: number): number[] => [0, 0, 2 * s, 0, 2 * s, 2 * s, s, 2 * s, s, s, 0, s];
174
- const at = (s: number): Readonly<Path> => polygonPath(lShape(s), true);
175
- const small = runOffsetPath(at(1e-3), 0.25e-3);
176
- const mid = runOffsetPath(at(1), 0.25);
177
- const large = runOffsetPath(at(1e6), 0.25e6);
178
- expect(ringCount(small)).toBe(ringCount(mid));
179
- expect(ringCount(large)).toBe(ringCount(mid));
180
- expect(pathArea(small) / 1e-3 ** 2).toBeCloseTo(pathArea(mid), 4);
181
- expect(pathArea(large) / (1e6 * 1e6)).toBeCloseTo(pathArea(mid), 4);
250
+ it('closes a concave slot narrower than two delta into a valid outline', () => {
251
+ const kernel = createMartinezPathBooleanKernel();
252
+ const uShape = [0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 5.5, 10.0, 5.5, 3.0, 4.5, 3.0, 4.5, 10.0, 0.0, 10.0];
253
+ let out = createPath('nonZero');
254
+ offsetPath(out, kernel, polygonPath(uShape, true), 1.0, undefined);
255
+ expect(ringCount(out)).toEqual(1);
256
+ const [minX, minY, maxX, maxY] = pathBounds(out);
257
+ expect(Math.abs(minX - -1.0) < 5e-7).toBe(true);
258
+ expect(Math.abs(maxX - 11.0) < 5e-7).toBe(true);
259
+ expect(Math.abs(minY - -1.0) < 5e-7).toBe(true);
260
+ expect(Math.abs(maxY - 11.0) < 5e-7).toBe(true);
261
+ let simplified = createPath('nonZero');
262
+ const simplifyOpts: PathBooleanOptions = { fillRule: 'nonZero', tolerance: undefined } as const;
263
+ simplifyPath(simplified, kernel, out, simplifyOpts);
264
+ expect(ringCount(simplified)).toEqual(ringCount(out));
265
+ expect(Math.abs(pathArea(simplified) - pathArea(out)) < 5e-5).toBe(true);
182
266
  });
183
-
184
- it('strokes an open path into a butt-capped rectangle', () => {
185
- const result = runOffsetPath(polygonPath([0, 0, 2, 0], false), 0.5, { end: 'butt' });
186
- const bounds = pathBounds(result);
187
- expect(bounds.minX).toBeCloseTo(0, 6);
188
- expect(bounds.maxX).toBeCloseTo(2, 6);
189
- expect(bounds.minY).toBeCloseTo(-0.5, 6);
190
- expect(bounds.maxY).toBeCloseTo(0.5, 6);
191
- expect(pathArea(result)).toBeCloseTo(2, 4);
267
+ it('strokes an open path into a butt capped rectangle', () => {
268
+ const kernel = createMartinezPathBooleanKernel();
269
+ const opts: PathOffsetOptions = {
270
+ join: undefined,
271
+ end: 'butt',
272
+ miterLimit: undefined,
273
+ tolerance: undefined,
274
+ arcTolerance: undefined,
275
+ } as const;
276
+ let out = createPath('nonZero');
277
+ offsetPath(out, kernel, polygonPath([0.0, 0.0, 2.0, 0.0], false), 0.5, opts);
278
+ const [minX, minY, maxX, maxY] = pathBounds(out);
279
+ expect(Math.abs(minX - 0.0) < 5e-7).toBe(true);
280
+ expect(Math.abs(maxX - 2.0) < 5e-7).toBe(true);
281
+ expect(Math.abs(minY - -0.5) < 5e-7).toBe(true);
282
+ expect(Math.abs(maxY - 0.5) < 5e-7).toBe(true);
283
+ expect(Math.abs(pathArea(out) - 2.0) < 5e-5).toBe(true);
192
284
  });
193
-
194
285
  it('extends an open path past its ends with a square cap', () => {
195
- const result = runOffsetPath(polygonPath([0, 0, 2, 0], false), 0.5, { end: 'square' });
196
- const bounds = pathBounds(result);
197
- expect(bounds.minX).toBeCloseTo(-0.5, 6);
198
- expect(bounds.maxX).toBeCloseTo(2.5, 6);
199
- expect(pathArea(result)).toBeCloseTo(3, 4);
286
+ const kernel = createMartinezPathBooleanKernel();
287
+ const opts: PathOffsetOptions = {
288
+ join: undefined,
289
+ end: 'square',
290
+ miterLimit: undefined,
291
+ tolerance: undefined,
292
+ arcTolerance: undefined,
293
+ } as const;
294
+ let out = createPath('nonZero');
295
+ offsetPath(out, kernel, polygonPath([0.0, 0.0, 2.0, 0.0], false), 0.5, opts);
296
+ const [minX, , maxX] = pathBounds(out);
297
+ expect(Math.abs(minX - -0.5) < 5e-7).toBe(true);
298
+ expect(Math.abs(maxX - 2.5) < 5e-7).toBe(true);
299
+ expect(Math.abs(pathArea(out) - 3.0) < 5e-5).toBe(true);
200
300
  });
201
-
202
- it('caps an open path with half-circles on a round end', () => {
203
- const result = runOffsetPath(polygonPath([0, 0, 2, 0], false), 0.5, { end: 'round', arcTolerance: 0.001 });
204
- const bounds = pathBounds(result);
205
- expect(bounds.minX).toBeCloseTo(-0.5, 2);
206
- expect(bounds.maxX).toBeCloseTo(2.5, 2);
207
- expect(pathArea(result)).toBeCloseTo(2 + Math.PI * 0.25, 2);
301
+ it('caps an open path with half circles on a round end', () => {
302
+ const kernel = createMartinezPathBooleanKernel();
303
+ const opts: PathOffsetOptions = {
304
+ join: undefined,
305
+ end: 'round',
306
+ miterLimit: undefined,
307
+ tolerance: undefined,
308
+ arcTolerance: 0.001,
309
+ } as const;
310
+ let out = createPath('nonZero');
311
+ offsetPath(out, kernel, polygonPath([0.0, 0.0, 2.0, 0.0], false), 0.5, opts);
312
+ const [minX, , maxX] = pathBounds(out);
313
+ expect(Math.abs(minX - -0.5) < 0.005).toBe(true);
314
+ expect(Math.abs(maxX - 2.5) < 0.005).toBe(true);
315
+ expect(Math.abs(pathArea(out) - (2.0 + Math.PI * 0.25)) < 0.005).toBe(true);
208
316
  });
209
-
210
- it('offsets the same vertices differently as an open vs a closed contour', () => {
211
- const vertices = [0, 0, 2, 0, 2, 2, 0, 2];
212
- const closed = runOffsetPath(polygonPath(vertices, true), 0.5);
213
- const open = runOffsetPath(polygonPath(vertices, false), 0.5, { end: 'butt' });
214
- expect(pathArea(closed)).toBeCloseTo(9, 4);
215
- expect(pathArea(open)).toBeLessThan(pathArea(closed));
317
+ it('offsets the same vertices differently as open vs closed', () => {
318
+ const kernel = createMartinezPathBooleanKernel();
319
+ const vertices = [0.0, 0.0, 2.0, 0.0, 2.0, 2.0, 0.0, 2.0];
320
+ let closedOut = createPath('nonZero');
321
+ offsetPath(closedOut, kernel, polygonPath(vertices, true), 0.5, undefined);
322
+ const buttOpts: PathOffsetOptions = {
323
+ join: undefined,
324
+ end: 'butt',
325
+ miterLimit: undefined,
326
+ tolerance: undefined,
327
+ arcTolerance: undefined,
328
+ } as const;
329
+ let openOut = createPath('nonZero');
330
+ offsetPath(openOut, kernel, polygonPath(vertices, false), 0.5, buttOpts);
331
+ expect(Math.abs(pathArea(closedOut) - 9.0) < 5e-5).toBe(true);
332
+ expect(pathArea(openOut) < pathArea(closedOut)).toBe(true);
333
+ });
334
+ it('offsets consistently across coordinate scales', () => {
335
+ const kernel = createMartinezPathBooleanKernel();
336
+ let small = createPath('nonZero');
337
+ let mid = createPath('nonZero');
338
+ let large = createPath('nonZero');
339
+ offsetPath(small, kernel, polygonPath(lShape(1e-3), true), 0.25e-3, undefined);
340
+ offsetPath(mid, kernel, polygonPath(lShape(1.0), true), 0.25, undefined);
341
+ offsetPath(large, kernel, polygonPath(lShape(1e6), true), 0.25e6, undefined);
342
+ expect(ringCount(small)).toEqual(ringCount(mid));
343
+ expect(ringCount(large)).toEqual(ringCount(mid));
344
+ expect(Math.abs(pathArea(small) / 1e-6 - pathArea(mid)) < 0.1).toBe(true);
345
+ expect(Math.abs(pathArea(large) / 1e12 - pathArea(mid)) < 0.1).toBe(true);
216
346
  });
217
347
  });
@@ -0,0 +1,95 @@
1
+ // Generated from crates/flight-path-boolean/tests/path_boolean_test.rs by flight-compiler.
2
+ // Rust is authoritative; run `npm run generate` after changing it.
3
+
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import {
7
+ getPathBooleanExplanationOperationString,
8
+ getPathBooleanExplanationReasonString,
9
+ getPathOffsetEndString,
10
+ getPathOffsetJoinString,
11
+ parsePathBooleanExplanationOperation,
12
+ parsePathBooleanExplanationReason,
13
+ parsePathOffsetEnd,
14
+ parsePathOffsetJoin,
15
+ } from './contract.ts';
16
+
17
+ describe('getPathBooleanExplanationOperationString', () => {
18
+ it('returns the exact ts strings', () => {
19
+ expect(getPathBooleanExplanationOperationString('offset')).toEqual('offset');
20
+ expect(getPathBooleanExplanationOperationString('simplify')).toEqual('simplify');
21
+ });
22
+ });
23
+
24
+ describe('getPathBooleanExplanationReasonString', () => {
25
+ it('returns the exact ts strings', () => {
26
+ expect(getPathBooleanExplanationReasonString('collapsed-or-degenerate')).toEqual('collapsed-or-degenerate');
27
+ expect(getPathBooleanExplanationReasonString('empty-input')).toEqual('empty-input');
28
+ expect(getPathBooleanExplanationReasonString('non-finite-delta')).toEqual('non-finite-delta');
29
+ });
30
+ });
31
+
32
+ describe('getPathOffsetEndString', () => {
33
+ it('returns the exact ts strings', () => {
34
+ expect(getPathOffsetEndString('butt')).toEqual('butt');
35
+ expect(getPathOffsetEndString('round')).toEqual('round');
36
+ expect(getPathOffsetEndString('square')).toEqual('square');
37
+ });
38
+ });
39
+
40
+ describe('getPathOffsetJoinString', () => {
41
+ it('returns the exact ts strings', () => {
42
+ expect(getPathOffsetJoinString('bevel')).toEqual('bevel');
43
+ expect(getPathOffsetJoinString('miter')).toEqual('miter');
44
+ expect(getPathOffsetJoinString('round')).toEqual('round');
45
+ expect(getPathOffsetJoinString('square')).toEqual('square');
46
+ });
47
+ });
48
+
49
+ describe('parsePathBooleanExplanationOperation', () => {
50
+ it('parses the exact ts strings', () => {
51
+ expect(parsePathBooleanExplanationOperation('offset')).toEqual('offset');
52
+ expect(parsePathBooleanExplanationOperation('simplify')).toEqual('simplify');
53
+ });
54
+ it('rejects other text and wrong casing', () => {
55
+ expect(parsePathBooleanExplanationOperation('OFFSET')).toEqual(undefined);
56
+ expect(parsePathBooleanExplanationOperation('')).toEqual(undefined);
57
+ });
58
+ });
59
+
60
+ describe('parsePathBooleanExplanationReason', () => {
61
+ it('parses the exact ts strings', () => {
62
+ expect(parsePathBooleanExplanationReason('collapsed-or-degenerate')).toEqual('collapsed-or-degenerate');
63
+ expect(parsePathBooleanExplanationReason('empty-input')).toEqual('empty-input');
64
+ expect(parsePathBooleanExplanationReason('non-finite-delta')).toEqual('non-finite-delta');
65
+ });
66
+ it('rejects other text and wrong casing', () => {
67
+ expect(parsePathBooleanExplanationReason('COLLAPSED-OR-DEGENERATE')).toEqual(undefined);
68
+ expect(parsePathBooleanExplanationReason('')).toEqual(undefined);
69
+ });
70
+ });
71
+
72
+ describe('parsePathOffsetEnd', () => {
73
+ it('parses the exact ts strings', () => {
74
+ expect(parsePathOffsetEnd('butt')).toEqual('butt');
75
+ expect(parsePathOffsetEnd('round')).toEqual('round');
76
+ expect(parsePathOffsetEnd('square')).toEqual('square');
77
+ });
78
+ it('rejects other text and wrong casing', () => {
79
+ expect(parsePathOffsetEnd('BUTT')).toEqual(undefined);
80
+ expect(parsePathOffsetEnd('')).toEqual(undefined);
81
+ });
82
+ });
83
+
84
+ describe('parsePathOffsetJoin', () => {
85
+ it('parses the exact ts strings', () => {
86
+ expect(parsePathOffsetJoin('bevel')).toEqual('bevel');
87
+ expect(parsePathOffsetJoin('miter')).toEqual('miter');
88
+ expect(parsePathOffsetJoin('round')).toEqual('round');
89
+ expect(parsePathOffsetJoin('square')).toEqual('square');
90
+ });
91
+ it('rejects other text and wrong casing', () => {
92
+ expect(parsePathOffsetJoin('BEVEL')).toEqual(undefined);
93
+ expect(parsePathOffsetJoin('')).toEqual(undefined);
94
+ });
95
+ });