@flighthq/path-boolean 0.1.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.
- package/dist/booleanPaths.d.ts +7 -0
- package/dist/booleanPaths.d.ts.map +1 -0
- package/dist/booleanPaths.js +47 -0
- package/dist/booleanPaths.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/martinezKernel.d.ts +3 -0
- package/dist/martinezKernel.d.ts.map +1 -0
- package/dist/martinezKernel.js +625 -0
- package/dist/martinezKernel.js.map +1 -0
- package/dist/offsetPath.d.ts +3 -0
- package/dist/offsetPath.d.ts.map +1 -0
- package/dist/offsetPath.js +318 -0
- package/dist/offsetPath.js.map +1 -0
- package/dist/pathBooleanBackend.d.ts +5 -0
- package/dist/pathBooleanBackend.d.ts.map +1 -0
- package/dist/pathBooleanBackend.js +24 -0
- package/dist/pathBooleanBackend.js.map +1 -0
- package/dist/resolvePathRegions.d.ts +3 -0
- package/dist/resolvePathRegions.d.ts.map +1 -0
- package/dist/resolvePathRegions.js +28 -0
- package/dist/resolvePathRegions.js.map +1 -0
- package/dist/simplifyPath.d.ts +3 -0
- package/dist/simplifyPath.d.ts.map +1 -0
- package/dist/simplifyPath.js +16 -0
- package/dist/simplifyPath.js.map +1 -0
- package/dist/unionAllPaths.d.ts +3 -0
- package/dist/unionAllPaths.d.ts.map +1 -0
- package/dist/unionAllPaths.js +35 -0
- package/dist/unionAllPaths.js.map +1 -0
- package/package.json +38 -0
- package/src/booleanPaths.test.ts +163 -0
- package/src/fuzzInvariants.test.ts +203 -0
- package/src/martinezKernel.test.ts +376 -0
- package/src/offsetPath.test.ts +223 -0
- package/src/pathBooleanBackend.test.ts +50 -0
- package/src/resolvePathRegions.test.ts +48 -0
- package/src/simplifyPath.test.ts +146 -0
- package/src/unionAllPaths.test.ts +92 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import type { PathBooleanContour, PathBooleanFillRule, PathBooleanOperation } from '@flighthq/types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createMartinezPathBooleanBackend } from './martinezKernel';
|
|
5
|
+
|
|
6
|
+
// A closed square contour [x, y, x+s, y, x+s, y+s, x, y+s], wound consistently (screen y-down CW).
|
|
7
|
+
function square(x: number, y: number, s: number): number[] {
|
|
8
|
+
return [x, y, x + s, y, x + s, y + s, x, y + s];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// A donut: an outer square with a reverse-wound inner square that cuts a hole under non-zero fill.
|
|
12
|
+
function donut(x: number, y: number, outer: number, inset: number, inner: number): number[][] {
|
|
13
|
+
const ix = x + inset;
|
|
14
|
+
const iy = y + inset;
|
|
15
|
+
return [square(x, y, outer), [ix, iy, ix, iy + inner, ix + inner, iy + inner, ix + inner, iy]];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Net filled area of a correctly-wound result: outer rings and holes carry opposite signs, so the
|
|
19
|
+
// signed-area sum is the true fill. Uses the shoelace sum without the abs-per-ring the geometry hides.
|
|
20
|
+
function netArea(rings: readonly PathBooleanContour[]): number {
|
|
21
|
+
let total = 0;
|
|
22
|
+
for (const ring of rings) total += signedArea(ring);
|
|
23
|
+
return Math.abs(total);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function signedArea(ring: PathBooleanContour): number {
|
|
27
|
+
let area = 0;
|
|
28
|
+
const n = ring.length >> 1;
|
|
29
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
30
|
+
area += ring[j * 2] * ring[i * 2 + 1] - ring[i * 2] * ring[j * 2 + 1];
|
|
31
|
+
}
|
|
32
|
+
return area / 2;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function boundsOf(rings: readonly PathBooleanContour[]): {
|
|
36
|
+
minX: number;
|
|
37
|
+
minY: number;
|
|
38
|
+
maxX: number;
|
|
39
|
+
maxY: number;
|
|
40
|
+
} {
|
|
41
|
+
let minX = Infinity;
|
|
42
|
+
let minY = Infinity;
|
|
43
|
+
let maxX = -Infinity;
|
|
44
|
+
let maxY = -Infinity;
|
|
45
|
+
for (const ring of rings) {
|
|
46
|
+
for (let i = 0; i < ring.length; i += 2) {
|
|
47
|
+
minX = Math.min(minX, ring[i]);
|
|
48
|
+
maxX = Math.max(maxX, ring[i]);
|
|
49
|
+
minY = Math.min(minY, ring[i + 1]);
|
|
50
|
+
maxY = Math.max(maxY, ring[i + 1]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { minX, minY, maxX, maxY };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Whether (x, y) is inside the result region under non-zero fill (holes counter-wound).
|
|
57
|
+
function fillContains(rings: readonly PathBooleanContour[], x: number, y: number): boolean {
|
|
58
|
+
let winding = 0;
|
|
59
|
+
for (const ring of rings) {
|
|
60
|
+
const n = ring.length >> 1;
|
|
61
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
62
|
+
const xi = ring[i * 2];
|
|
63
|
+
const yi = ring[i * 2 + 1];
|
|
64
|
+
const xj = ring[j * 2];
|
|
65
|
+
const yj = ring[j * 2 + 1];
|
|
66
|
+
if (yi <= y ? yj > y : yj <= y) {
|
|
67
|
+
const t = (y - yi) / (yj - yi);
|
|
68
|
+
if (x < xi + t * (xj - xi)) winding += yj > yi ? 1 : -1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return winding !== 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function run(
|
|
76
|
+
subject: readonly number[][],
|
|
77
|
+
clip: readonly number[][],
|
|
78
|
+
operation: PathBooleanOperation,
|
|
79
|
+
fillRule: PathBooleanFillRule = 'nonZero',
|
|
80
|
+
): readonly PathBooleanContour[] {
|
|
81
|
+
return createMartinezPathBooleanBackend().computePathBoolean(subject, clip, operation, fillRule);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('createMartinezPathBooleanBackend', () => {
|
|
85
|
+
describe('coincident and shared-boundary degeneracies', () => {
|
|
86
|
+
it('unions two squares sharing a full edge into one rectangle', () => {
|
|
87
|
+
const result = run([square(0, 0, 10)], [square(10, 0, 10)], 'union');
|
|
88
|
+
expect(netArea(result)).toBeCloseTo(200, 6);
|
|
89
|
+
expect(boundsOf(result)).toMatchObject({ minX: 0, maxX: 20, minY: 0, maxY: 10 });
|
|
90
|
+
expect(fillContains(result, 10, 5)).toBe(true); // the former shared edge is now interior
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('intersects two squares sharing an edge to nothing (zero-width overlap)', () => {
|
|
94
|
+
const result = run([square(0, 0, 10)], [square(10, 0, 10)], 'intersection');
|
|
95
|
+
expect(netArea(result)).toBeCloseTo(0, 6);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('handles a partially overlapping shared edge (offset abutment)', () => {
|
|
99
|
+
// Clip's left edge overlaps only the top half of subject's right edge.
|
|
100
|
+
const result = run([square(0, 0, 10)], [[10, 5, 20, 5, 20, 15, 10, 15]], 'union');
|
|
101
|
+
expect(netArea(result)).toBeCloseTo(200, 6);
|
|
102
|
+
expect(fillContains(result, 10, 7)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('collapses two identical squares under union to a single square', () => {
|
|
106
|
+
const result = run([square(0, 0, 10)], [square(0, 0, 10)], 'union');
|
|
107
|
+
expect(netArea(result)).toBeCloseTo(100, 6);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('collapses two identical squares under intersection to the same square', () => {
|
|
111
|
+
const result = run([square(0, 0, 10)], [square(0, 0, 10)], 'intersection');
|
|
112
|
+
expect(netArea(result)).toBeCloseTo(100, 6);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('empties two identical squares under xor', () => {
|
|
116
|
+
const result = run([square(0, 0, 10)], [square(0, 0, 10)], 'xor');
|
|
117
|
+
expect(netArea(result)).toBeCloseTo(0, 6);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('empties a square differenced against an identical square', () => {
|
|
121
|
+
const result = run([square(0, 0, 10)], [square(0, 0, 10)], 'difference');
|
|
122
|
+
expect(netArea(result)).toBeCloseTo(0, 6);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('difference', () => {
|
|
127
|
+
it('cuts a hole when the clip is fully contained', () => {
|
|
128
|
+
const result = run([square(0, 0, 30)], [square(10, 10, 10)], 'difference');
|
|
129
|
+
expect(netArea(result)).toBeCloseTo(800, 6);
|
|
130
|
+
expect(result.length).toBe(2); // outer boundary + hole
|
|
131
|
+
expect(fillContains(result, 2, 2)).toBe(true);
|
|
132
|
+
expect(fillContains(result, 15, 15)).toBe(false); // inside the hole
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('subtracts a partial overlap', () => {
|
|
136
|
+
const result = run([square(0, 0, 10)], [square(5, 5, 10)], 'difference');
|
|
137
|
+
expect(netArea(result)).toBeCloseTo(75, 6);
|
|
138
|
+
expect(fillContains(result, 2, 2)).toBe(true);
|
|
139
|
+
expect(fillContains(result, 7, 7)).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('is empty when subtracting a superset', () => {
|
|
143
|
+
const result = run([square(10, 10, 10)], [square(0, 0, 30)], 'difference');
|
|
144
|
+
expect(netArea(result)).toBeCloseTo(0, 6);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('disjoint inputs', () => {
|
|
149
|
+
it('unions disjoint squares into two rings', () => {
|
|
150
|
+
const result = run([square(0, 0, 10)], [square(20, 20, 10)], 'union');
|
|
151
|
+
expect(netArea(result)).toBeCloseTo(200, 6);
|
|
152
|
+
expect(result.length).toBe(2);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('intersects disjoint squares to nothing', () => {
|
|
156
|
+
const result = run([square(0, 0, 10)], [square(20, 20, 10)], 'intersection');
|
|
157
|
+
expect(netArea(result)).toBeCloseTo(0, 6);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('xors disjoint squares to both squares', () => {
|
|
161
|
+
const result = run([square(0, 0, 10)], [square(20, 20, 10)], 'xor');
|
|
162
|
+
expect(netArea(result)).toBeCloseTo(200, 6);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('empty and degenerate inputs', () => {
|
|
167
|
+
it('returns empty for two empty operands', () => {
|
|
168
|
+
expect(run([], [], 'union')).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('passes the subject through union with an empty clip', () => {
|
|
172
|
+
const result = run([square(0, 0, 10)], [], 'union');
|
|
173
|
+
expect(netArea(result)).toBeCloseTo(100, 6);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('empties intersection with an empty clip', () => {
|
|
177
|
+
expect(netArea(run([square(0, 0, 10)], [], 'intersection'))).toBeCloseTo(0, 6);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('ignores a zero-area (collinear) contour', () => {
|
|
181
|
+
const degenerate = [0, 0, 10, 0, 5, 0]; // three collinear points, no area
|
|
182
|
+
const result = run([square(0, 0, 10)], [degenerate], 'union');
|
|
183
|
+
expect(netArea(result)).toBeCloseTo(100, 6);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('ignores a single-point contour', () => {
|
|
187
|
+
const result = run([square(0, 0, 10)], [[5, 5, 5, 5, 5, 5]], 'union');
|
|
188
|
+
expect(netArea(result)).toBeCloseTo(100, 6);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('ignores a contour with too few points', () => {
|
|
192
|
+
const result = run([square(0, 0, 10)], [[1, 1, 2, 2]], 'union');
|
|
193
|
+
expect(netArea(result)).toBeCloseTo(100, 6);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('holes', () => {
|
|
198
|
+
it('unions a donut with an overlapping square, filling part of the hole', () => {
|
|
199
|
+
const result = run(donut(0, 0, 30, 10, 10), [square(12, 12, 20)], 'union');
|
|
200
|
+
// Donut is 30x30 minus a 10x10 hole (800); the union square (12,12)-(32,32) plugs the hole and
|
|
201
|
+
// extends past the donut. Assert the former hole center is now filled and a hole corner sample
|
|
202
|
+
// that the plug does not cover stays empty.
|
|
203
|
+
expect(fillContains(result, 15, 15)).toBe(true); // plugged
|
|
204
|
+
expect(fillContains(result, 11, 11)).toBe(false); // still a hole corner outside the plug
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('intersects a donut with a square covering its hole to a frame', () => {
|
|
208
|
+
const result = run(donut(0, 0, 30, 10, 10), [square(5, 5, 20)], 'intersection');
|
|
209
|
+
// Intersection keeps donut material within (5,5)-(25,25), which is a frame around the hole.
|
|
210
|
+
expect(fillContains(result, 15, 15)).toBe(false); // the hole survives
|
|
211
|
+
expect(fillContains(result, 8, 8)).toBe(true); // donut material inside the clip
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('differences a square from a donut, deepening the hole', () => {
|
|
215
|
+
const result = run(donut(0, 0, 30, 5, 20), [square(10, 10, 10)], 'difference');
|
|
216
|
+
// Donut hole is (5,5)-(25,25); subtracting (10,10)-(20,20) removes material only where it overlaps
|
|
217
|
+
// the donut ring. Hole center stays empty; ring material outside the clip stays filled.
|
|
218
|
+
expect(fillContains(result, 15, 15)).toBe(false);
|
|
219
|
+
expect(fillContains(result, 2, 2)).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('self-intersecting input and fill rules', () => {
|
|
224
|
+
it('fills the overlap of two same-wound squares under non-zero (winding 2)', () => {
|
|
225
|
+
const result = run([square(0, 0, 10), square(5, 5, 10)], [], 'union', 'nonZero');
|
|
226
|
+
expect(netArea(result)).toBeCloseTo(175, 6);
|
|
227
|
+
expect(fillContains(result, 7, 7)).toBe(true); // overlap filled
|
|
228
|
+
expect(result.length).toBe(1); // one solid outline, no hole
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('holes out the overlap of two same-wound squares under even-odd', () => {
|
|
232
|
+
const result = run([square(0, 0, 10), square(5, 5, 10)], [], 'union', 'evenOdd');
|
|
233
|
+
expect(netArea(result)).toBeCloseTo(150, 6);
|
|
234
|
+
expect(fillContains(result, 7, 7)).toBe(false); // overlap is a hole
|
|
235
|
+
expect(fillContains(result, 2, 2)).toBe(true); // non-overlap stays filled
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('treats a bowtie contour under non-zero as its two filled lobes', () => {
|
|
239
|
+
// A self-crossing quad (0,0)-(10,0)-(0,10)-(10,10): the diagonals cross at (5,5) forming two
|
|
240
|
+
// triangles. Under non-zero the opposite-wound lobes each fill.
|
|
241
|
+
const bowtie = [0, 0, 10, 0, 0, 10, 10, 10];
|
|
242
|
+
const result = run([bowtie], [], 'union', 'nonZero');
|
|
243
|
+
expect(fillContains(result, 5, 1)).toBe(true); // lower lobe
|
|
244
|
+
expect(fillContains(result, 5, 9)).toBe(true); // upper lobe
|
|
245
|
+
expect(fillContains(result, 1, 5)).toBe(false); // between lobes, outside
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('positive and negative fill rules', () => {
|
|
250
|
+
it('keeps a counter-clockwise (positively-wound) region under positive fill, drops it under negative', () => {
|
|
251
|
+
const ccw = square(0, 0, 10); // shoelace-positive winding
|
|
252
|
+
expect(netArea(run([ccw], [], 'union', 'positive'))).toBeCloseTo(100, 6);
|
|
253
|
+
expect(netArea(run([ccw], [], 'union', 'negative'))).toBeCloseTo(0, 6);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('mirrors that for a clockwise (negatively-wound) region', () => {
|
|
257
|
+
const cw = [0, 0, 0, 10, 10, 10, 10, 0]; // reversed traversal, shoelace-negative winding
|
|
258
|
+
expect(netArea(run([cw], [], 'union', 'negative'))).toBeCloseTo(100, 6);
|
|
259
|
+
expect(netArea(run([cw], [], 'union', 'positive'))).toBeCloseTo(0, 6);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('dissolves a same-wound self-overlap under positive fill (winding 2 stays filled)', () => {
|
|
263
|
+
// The offset-cleanup fill: two overlapping same-wound squares fill solid (area 175, one ring), the
|
|
264
|
+
// doubly-wound overlap kept rather than holed — this is why offsetPath resolves under positive.
|
|
265
|
+
const result = run([square(0, 0, 10), square(5, 5, 10)], [], 'union', 'positive');
|
|
266
|
+
expect(netArea(result)).toBeCloseTo(175, 6);
|
|
267
|
+
expect(result.length).toBe(1);
|
|
268
|
+
expect(fillContains(result, 7, 7)).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('scale invariance', () => {
|
|
273
|
+
it('resolves the same topology and scale-relative area across a 1e9 span of coordinate scales', () => {
|
|
274
|
+
// Two overlapping squares — a real interior crossing, the regime where the vertex-merge snap
|
|
275
|
+
// matters. Scaling the coordinates must not change the ring count nor the area-relative-to-scale²;
|
|
276
|
+
// the magnitude-relative snap is what makes the resolved topology invariant to coordinate scale.
|
|
277
|
+
const overlap = (s: number): number[][] => [square(0, 0, 10 * s), square(5 * s, 5 * s, 10 * s)];
|
|
278
|
+
const small = run(overlap(1e-3), [], 'union', 'nonZero');
|
|
279
|
+
const mid = run(overlap(1), [], 'union', 'nonZero');
|
|
280
|
+
const large = run(overlap(1e6), [], 'union', 'nonZero');
|
|
281
|
+
expect(small.length).toBe(mid.length);
|
|
282
|
+
expect(large.length).toBe(mid.length);
|
|
283
|
+
// Union of the two squares is one solid ring of area 175 at unit scale; area scales with s².
|
|
284
|
+
expect(netArea(mid)).toBeCloseTo(175, 6);
|
|
285
|
+
expect(netArea(small) / 1e-3 ** 2).toBeCloseTo(175, 4);
|
|
286
|
+
expect(netArea(large) / (1e6 * 1e6)).toBeCloseTo(175, 4);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('shared single vertex (corner touching)', () => {
|
|
291
|
+
it('unions two corner-touching squares to their combined area', () => {
|
|
292
|
+
const result = run([square(0, 0, 10)], [square(10, 10, 10)], 'union');
|
|
293
|
+
expect(netArea(result)).toBeCloseTo(200, 6);
|
|
294
|
+
expect(fillContains(result, 5, 5)).toBe(true);
|
|
295
|
+
expect(fillContains(result, 15, 15)).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('intersects two corner-touching squares to nothing', () => {
|
|
299
|
+
const result = run([square(0, 0, 10)], [square(10, 10, 10)], 'intersection');
|
|
300
|
+
expect(netArea(result)).toBeCloseTo(0, 6);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('commutativity and symmetry', () => {
|
|
305
|
+
it('unions commute', () => {
|
|
306
|
+
const a = square(0, 0, 10);
|
|
307
|
+
const b = square(5, 5, 10);
|
|
308
|
+
expect(netArea(run([a], [b], 'union'))).toBeCloseTo(netArea(run([b], [a], 'union')), 6);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('intersections commute', () => {
|
|
312
|
+
const a = square(0, 0, 10);
|
|
313
|
+
const b = square(3, 4, 10);
|
|
314
|
+
expect(netArea(run([a], [b], 'intersection'))).toBeCloseTo(netArea(run([b], [a], 'intersection')), 6);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('xor is symmetric', () => {
|
|
318
|
+
const a = square(0, 0, 10);
|
|
319
|
+
const b = square(4, 4, 10);
|
|
320
|
+
expect(netArea(run([a], [b], 'xor'))).toBeCloseTo(netArea(run([b], [a], 'xor')), 6);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('contained squares', () => {
|
|
325
|
+
it('unions to the outer square', () => {
|
|
326
|
+
const result = run([square(0, 0, 30)], [square(10, 10, 10)], 'union');
|
|
327
|
+
expect(netArea(result)).toBeCloseTo(900, 6);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('intersects to the inner square', () => {
|
|
331
|
+
const result = run([square(0, 0, 30)], [square(10, 10, 10)], 'intersection');
|
|
332
|
+
expect(netArea(result)).toBeCloseTo(100, 6);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('xors to the outer-with-hole frame', () => {
|
|
336
|
+
const result = run([square(0, 0, 30)], [square(10, 10, 10)], 'xor');
|
|
337
|
+
expect(netArea(result)).toBeCloseTo(800, 6);
|
|
338
|
+
expect(fillContains(result, 15, 15)).toBe(false);
|
|
339
|
+
expect(fillContains(result, 2, 2)).toBe(true);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe('non-axis-aligned inputs', () => {
|
|
344
|
+
it('unions two triangles sharing a diagonal into a square', () => {
|
|
345
|
+
const lower = [0, 0, 10, 0, 10, 10]; // lower-right half of the unit-10 square
|
|
346
|
+
const upper = [0, 0, 10, 10, 0, 10]; // upper-left half, sharing the (0,0)-(10,10) diagonal
|
|
347
|
+
const result = run([lower], [upper], 'union');
|
|
348
|
+
expect(netArea(result)).toBeCloseTo(100, 6);
|
|
349
|
+
expect(fillContains(result, 5, 5)).toBe(true); // the former shared diagonal is interior
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('intersects two diagonal-sharing triangles to nothing', () => {
|
|
353
|
+
const lower = [0, 0, 10, 0, 10, 10];
|
|
354
|
+
const upper = [0, 0, 10, 10, 0, 10];
|
|
355
|
+
expect(netArea(run([lower], [upper], 'intersection'))).toBeCloseTo(0, 6);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('intersects a diamond overlapping a square (real crossing points)', () => {
|
|
359
|
+
// Diamond centered on the square's right edge; only its left triangle (area 25) is inside.
|
|
360
|
+
const diamond = [5, 5, 10, 0, 15, 5, 10, 10];
|
|
361
|
+
const result = run([square(0, 0, 10)], [diamond], 'intersection');
|
|
362
|
+
expect(netArea(result)).toBeCloseTo(25, 6);
|
|
363
|
+
expect(fillContains(result, 8, 5)).toBe(true);
|
|
364
|
+
expect(fillContains(result, 12, 5)).toBe(false);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('unions a rotated square (diamond) with an overlapping axis square', () => {
|
|
368
|
+
const diamond = [0, 10, 10, 0, 20, 10, 10, 20]; // area 200, centered (10,10)
|
|
369
|
+
const result = run([square(0, 0, 12)], [diamond], 'union');
|
|
370
|
+
// Both regions are inside the union; a point deep in each stays filled and the exterior stays out.
|
|
371
|
+
expect(fillContains(result, 2, 2)).toBe(true); // square-only corner
|
|
372
|
+
expect(fillContains(result, 18, 10)).toBe(true); // diamond-only tip region
|
|
373
|
+
expect(fillContains(result, 19, 19)).toBe(false); // outside both
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { createPath, appendPathClose, appendPathLineTo, appendPathMoveTo, flattenPath } from '@flighthq/path';
|
|
2
|
+
import type { Path } from '@flighthq/types';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { offsetPath } from './offsetPath';
|
|
6
|
+
import { simplifyPath } from './simplifyPath';
|
|
7
|
+
|
|
8
|
+
// Builds a polygon path from a flat [x0, y0, ...] vertex list, closed or left open.
|
|
9
|
+
function polygonPath(vertices: readonly number[], closed: boolean): Path {
|
|
10
|
+
const path = createPath('nonZero');
|
|
11
|
+
appendPathMoveTo(path, vertices[0], vertices[1]);
|
|
12
|
+
for (let i = 2; i < vertices.length; i += 2) appendPathLineTo(path, vertices[i], vertices[i + 1]);
|
|
13
|
+
if (closed) appendPathClose(path);
|
|
14
|
+
return path;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Axis-aligned bounds of a path's flattened outline.
|
|
18
|
+
function pathBounds(path: Readonly<Path>): { minX: number; minY: number; maxX: number; maxY: number } {
|
|
19
|
+
let minX = Infinity;
|
|
20
|
+
let minY = Infinity;
|
|
21
|
+
let maxX = -Infinity;
|
|
22
|
+
let maxY = -Infinity;
|
|
23
|
+
for (const ring of flattenPath(path)) {
|
|
24
|
+
for (let i = 0; i < ring.length; i += 2) {
|
|
25
|
+
minX = Math.min(minX, ring[i]);
|
|
26
|
+
minY = Math.min(minY, ring[i + 1]);
|
|
27
|
+
maxX = Math.max(maxX, ring[i]);
|
|
28
|
+
maxY = Math.max(maxY, ring[i + 1]);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { minX, minY, maxX, maxY };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Absolute filled area of a path's flattened outline, summing signed ring areas.
|
|
35
|
+
function pathArea(path: Readonly<Path>): number {
|
|
36
|
+
let total = 0;
|
|
37
|
+
for (const ring of flattenPath(path)) {
|
|
38
|
+
let area = 0;
|
|
39
|
+
const n = ring.length >> 1;
|
|
40
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
41
|
+
area += ring[j * 2] * ring[i * 2 + 1] - ring[i * 2] * ring[j * 2 + 1];
|
|
42
|
+
}
|
|
43
|
+
total += area / 2;
|
|
44
|
+
}
|
|
45
|
+
return Math.abs(total);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Total flattened vertex count across all contours — grows with round-arc tessellation density.
|
|
49
|
+
function pathVertexCount(path: Readonly<Path>): number {
|
|
50
|
+
let count = 0;
|
|
51
|
+
for (const ring of flattenPath(path)) count += ring.length / 2;
|
|
52
|
+
return count;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Number of contours in a path's flattened outline.
|
|
56
|
+
function ringCount(path: Readonly<Path>): number {
|
|
57
|
+
return flattenPath(path).length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const UNIT_SQUARE = [0, 0, 1, 0, 1, 1, 0, 1];
|
|
61
|
+
|
|
62
|
+
describe('offsetPath', () => {
|
|
63
|
+
it('inflates a closed square by delta on every side with a miter join', () => {
|
|
64
|
+
const result = offsetPath(polygonPath(UNIT_SQUARE, true), 1);
|
|
65
|
+
const bounds = pathBounds(result);
|
|
66
|
+
expect(bounds.minX).toBeCloseTo(-1, 6);
|
|
67
|
+
expect(bounds.minY).toBeCloseTo(-1, 6);
|
|
68
|
+
expect(bounds.maxX).toBeCloseTo(2, 6);
|
|
69
|
+
expect(bounds.maxY).toBeCloseTo(2, 6);
|
|
70
|
+
// Sharp miter corners keep the full 3x3 square.
|
|
71
|
+
expect(pathArea(result)).toBeCloseTo(9, 4);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('deflates a closed square on a negative delta', () => {
|
|
75
|
+
const result = offsetPath(polygonPath([0, 0, 4, 0, 4, 4, 0, 4], 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(3, 6);
|
|
80
|
+
expect(bounds.maxY).toBeCloseTo(3, 6);
|
|
81
|
+
expect(pathArea(result)).toBeCloseTo(4, 4);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('emits an empty path when deflation collapses the region', () => {
|
|
85
|
+
const result = offsetPath(polygonPath(UNIT_SQUARE, true), -1);
|
|
86
|
+
expect(result.commands.length).toBe(0);
|
|
87
|
+
expect(pathArea(result)).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('chamfers corners with a bevel join', () => {
|
|
91
|
+
const result = offsetPath(polygonPath(UNIT_SQUARE, true), 1, { join: 'bevel' });
|
|
92
|
+
const bounds = pathBounds(result);
|
|
93
|
+
// Bevel still reaches delta along each edge but cuts each corner triangle (area 0.5 each).
|
|
94
|
+
expect(bounds.minX).toBeCloseTo(-1, 6);
|
|
95
|
+
expect(bounds.maxX).toBeCloseTo(2, 6);
|
|
96
|
+
expect(pathArea(result)).toBeCloseTo(7, 4);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('rounds corners with a round join and tessellates denser with radius and finer tolerance', () => {
|
|
100
|
+
const rounded = offsetPath(polygonPath(UNIT_SQUARE, true), 1, { join: 'round' });
|
|
101
|
+
const bounds = pathBounds(rounded);
|
|
102
|
+
// Corner arcs reach delta at their endpoints, so the bounds match the miter/bevel extent.
|
|
103
|
+
expect(bounds.minX).toBeCloseTo(-1, 6);
|
|
104
|
+
expect(bounds.maxX).toBeCloseTo(2, 6);
|
|
105
|
+
// Finely tessellated, the Minkowski sum of a unit square and a radius-1 disk: 1 + perimeter + pi.
|
|
106
|
+
const fineArea = pathArea(offsetPath(polygonPath(UNIT_SQUARE, true), 1, { join: 'round', arcTolerance: 0.001 }));
|
|
107
|
+
expect(fineArea).toBeCloseTo(5 + Math.PI, 1);
|
|
108
|
+
|
|
109
|
+
const largeRadius = offsetPath(polygonPath(UNIT_SQUARE, true), 5, { join: 'round' });
|
|
110
|
+
expect(pathVertexCount(largeRadius)).toBeGreaterThan(pathVertexCount(rounded));
|
|
111
|
+
|
|
112
|
+
const fineTolerance = offsetPath(polygonPath(UNIT_SQUARE, true), 1, { join: 'round', arcTolerance: 0.01 });
|
|
113
|
+
expect(pathVertexCount(fineTolerance)).toBeGreaterThan(pathVertexCount(rounded));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('falls back to a bevel when a sharp miter exceeds the miter limit', () => {
|
|
117
|
+
const wedge = [0, 0, 4, 0.5, 4, -0.5];
|
|
118
|
+
const clipped = offsetPath(polygonPath(wedge, true), 1, { miterLimit: 2 });
|
|
119
|
+
const sharp = offsetPath(polygonPath(wedge, true), 1, { miterLimit: 50 });
|
|
120
|
+
// The acute apex miter runs ~8 units past the vertex; miterLimit 2 clips it to a bevel much closer in.
|
|
121
|
+
expect(pathBounds(clipped).minX).toBeGreaterThan(pathBounds(sharp).minX + 3);
|
|
122
|
+
// A generous limit keeps the long spike.
|
|
123
|
+
expect(pathBounds(sharp).minX).toBeLessThan(-5);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('squares corners with a square join', () => {
|
|
127
|
+
const result = offsetPath(polygonPath(UNIT_SQUARE, true), 1, { join: 'square' });
|
|
128
|
+
const bounds = pathBounds(result);
|
|
129
|
+
expect(bounds.minX).toBeCloseTo(-1, 6);
|
|
130
|
+
expect(bounds.maxX).toBeCloseTo(2, 6);
|
|
131
|
+
// On a right-angle corner the squared extension lands exactly on the miter apex, filling the corner
|
|
132
|
+
// (area 9) and keeping strictly more than the bevel chamfer (7).
|
|
133
|
+
expect(pathArea(result)).toBeCloseTo(9, 4);
|
|
134
|
+
expect(pathArea(result)).toBeGreaterThan(
|
|
135
|
+
pathArea(offsetPath(polygonPath(UNIT_SQUARE, true), 1, { join: 'bevel' })),
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('cleans a concave corner into a single valid outline', () => {
|
|
140
|
+
const lShape = [0, 0, 2, 0, 2, 2, 1, 2, 1, 1, 0, 1];
|
|
141
|
+
const result = offsetPath(polygonPath(lShape, true), 0.25);
|
|
142
|
+
const bounds = pathBounds(result);
|
|
143
|
+
expect(bounds.minX).toBeCloseTo(-0.25, 6);
|
|
144
|
+
expect(bounds.minY).toBeCloseTo(-0.25, 6);
|
|
145
|
+
expect(bounds.maxX).toBeCloseTo(2.25, 6);
|
|
146
|
+
expect(bounds.maxY).toBeCloseTo(2.25, 6);
|
|
147
|
+
// A single closed ring survives the self-union (one move-to command), and the area grew from 3.
|
|
148
|
+
expect(result.commands.filter((c) => c === 1).length).toBe(1);
|
|
149
|
+
expect(pathArea(result)).toBeGreaterThan(3);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('closes a concave slot narrower than 2·delta into a valid self-intersection-free outline', () => {
|
|
153
|
+
// A U-shape: a 10x10 square with a width-1 slot cut down from the top to y=3. Inflating by 1 advances
|
|
154
|
+
// each slot wall 1 unit inward; because the slot (width 1) is narrower than 2·delta (2), the two walls
|
|
155
|
+
// overlap and must dissolve. Positive-fill cleanup collapses the slot into a solid top with no
|
|
156
|
+
// self-crossing — the case non-zero fill's inner-miter emission could leave self-intersecting.
|
|
157
|
+
const uShape = [0, 0, 10, 0, 10, 10, 5.5, 10, 5.5, 3, 4.5, 3, 4.5, 10, 0, 10];
|
|
158
|
+
const result = offsetPath(polygonPath(uShape, true), 1);
|
|
159
|
+
expect(ringCount(result)).toBe(1);
|
|
160
|
+
const bounds = pathBounds(result);
|
|
161
|
+
expect(bounds.minX).toBeCloseTo(-1, 6);
|
|
162
|
+
expect(bounds.maxX).toBeCloseTo(11, 6);
|
|
163
|
+
expect(bounds.minY).toBeCloseTo(-1, 6);
|
|
164
|
+
expect(bounds.maxY).toBeCloseTo(11, 6);
|
|
165
|
+
// Validity: the outline is already simple, so simplifying it under non-zero fill changes neither its
|
|
166
|
+
// ring count nor its area. A self-intersecting outline would resolve to a different area here.
|
|
167
|
+
const simplified = simplifyPath(result, { fillRule: 'nonZero' });
|
|
168
|
+
expect(ringCount(simplified)).toBe(ringCount(result));
|
|
169
|
+
expect(pathArea(simplified)).toBeCloseTo(pathArea(result), 4);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('offsets consistently across a 1e9 span of coordinate scales (magnitude-relative epsilons)', () => {
|
|
173
|
+
// The same L-shape offset by a proportional delta at three scales must land on the same relative
|
|
174
|
+
// outline: ring count fixed and area-relative-to-scale² invariant. The magnitude-relative point and
|
|
175
|
+
// vertex epsilons are what keep the result from degrading at very large or very small coordinates.
|
|
176
|
+
const lShape = (s: number): number[] => [0, 0, 2 * s, 0, 2 * s, 2 * s, s, 2 * s, s, s, 0, s];
|
|
177
|
+
const at = (s: number): Readonly<Path> => polygonPath(lShape(s), true);
|
|
178
|
+
const small = offsetPath(at(1e-3), 0.25e-3);
|
|
179
|
+
const mid = offsetPath(at(1), 0.25);
|
|
180
|
+
const large = offsetPath(at(1e6), 0.25e6);
|
|
181
|
+
expect(ringCount(small)).toBe(ringCount(mid));
|
|
182
|
+
expect(ringCount(large)).toBe(ringCount(mid));
|
|
183
|
+
expect(pathArea(small) / 1e-3 ** 2).toBeCloseTo(pathArea(mid), 4);
|
|
184
|
+
expect(pathArea(large) / (1e6 * 1e6)).toBeCloseTo(pathArea(mid), 4);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('strokes an open path into a butt-capped rectangle', () => {
|
|
188
|
+
const result = offsetPath(polygonPath([0, 0, 2, 0], false), 0.5, { end: 'butt' });
|
|
189
|
+
const bounds = pathBounds(result);
|
|
190
|
+
expect(bounds.minX).toBeCloseTo(0, 6);
|
|
191
|
+
expect(bounds.maxX).toBeCloseTo(2, 6);
|
|
192
|
+
expect(bounds.minY).toBeCloseTo(-0.5, 6);
|
|
193
|
+
expect(bounds.maxY).toBeCloseTo(0.5, 6);
|
|
194
|
+
expect(pathArea(result)).toBeCloseTo(2, 4);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('extends an open path past its ends with a square cap', () => {
|
|
198
|
+
const result = offsetPath(polygonPath([0, 0, 2, 0], false), 0.5, { end: 'square' });
|
|
199
|
+
const bounds = pathBounds(result);
|
|
200
|
+
expect(bounds.minX).toBeCloseTo(-0.5, 6);
|
|
201
|
+
expect(bounds.maxX).toBeCloseTo(2.5, 6);
|
|
202
|
+
expect(pathArea(result)).toBeCloseTo(3, 4);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('caps an open path with half-circles on a round end', () => {
|
|
206
|
+
const result = offsetPath(polygonPath([0, 0, 2, 0], false), 0.5, { end: 'round', arcTolerance: 0.001 });
|
|
207
|
+
const bounds = pathBounds(result);
|
|
208
|
+
// The arc tip is only reached within the arc tolerance, not to full float precision.
|
|
209
|
+
expect(bounds.minX).toBeCloseTo(-0.5, 2);
|
|
210
|
+
expect(bounds.maxX).toBeCloseTo(2.5, 2);
|
|
211
|
+
// Central 2x1 rectangle plus two half-disks of radius 0.5.
|
|
212
|
+
expect(pathArea(result)).toBeCloseTo(2 + Math.PI * 0.25, 2);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('offsets the same vertices differently as an open vs a closed contour', () => {
|
|
216
|
+
const vertices = [0, 0, 2, 0, 2, 2, 0, 2];
|
|
217
|
+
const closed = offsetPath(polygonPath(vertices, true), 0.5);
|
|
218
|
+
const open = offsetPath(polygonPath(vertices, false), 0.5, { end: 'butt' });
|
|
219
|
+
// Closed inflates the filled square (3x3 = 9); open strokes a ring around the polyline only.
|
|
220
|
+
expect(pathArea(closed)).toBeCloseTo(9, 4);
|
|
221
|
+
expect(pathArea(open)).toBeLessThan(pathArea(closed));
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { PathBooleanBackend } from '@flighthq/types';
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createDefaultPathBooleanBackend, getPathBooleanBackend, setPathBooleanBackend } from './pathBooleanBackend';
|
|
5
|
+
|
|
6
|
+
// The backend is module-level state; reset to the lazy default after each test so cases stay isolated.
|
|
7
|
+
afterEach(() => setPathBooleanBackend(null));
|
|
8
|
+
|
|
9
|
+
describe('createDefaultPathBooleanBackend', () => {
|
|
10
|
+
it('builds a working kernel that computes a boolean', () => {
|
|
11
|
+
const backend = createDefaultPathBooleanBackend();
|
|
12
|
+
const result = backend.computePathBoolean(
|
|
13
|
+
[[0, 0, 10, 0, 10, 10, 0, 10]],
|
|
14
|
+
[[20, 20, 30, 20, 30, 30, 20, 30]],
|
|
15
|
+
'union',
|
|
16
|
+
'nonZero',
|
|
17
|
+
);
|
|
18
|
+
expect(result.length).toBe(2);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('builds an independent instance each call', () => {
|
|
22
|
+
expect(createDefaultPathBooleanBackend()).not.toBe(createDefaultPathBooleanBackend());
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('getPathBooleanBackend', () => {
|
|
27
|
+
it('lazily installs and returns the default kernel', () => {
|
|
28
|
+
const backend = getPathBooleanBackend();
|
|
29
|
+
expect(typeof backend.computePathBoolean).toBe('function');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns the same installed backend on repeat calls', () => {
|
|
33
|
+
expect(getPathBooleanBackend()).toBe(getPathBooleanBackend());
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('setPathBooleanBackend', () => {
|
|
38
|
+
it('installs a custom backend that getPathBooleanBackend then returns', () => {
|
|
39
|
+
const custom: PathBooleanBackend = { computePathBoolean: () => [] };
|
|
40
|
+
setPathBooleanBackend(custom);
|
|
41
|
+
expect(getPathBooleanBackend()).toBe(custom);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('clears back to a lazily-created default when passed null', () => {
|
|
45
|
+
const custom: PathBooleanBackend = { computePathBoolean: () => [] };
|
|
46
|
+
setPathBooleanBackend(custom);
|
|
47
|
+
setPathBooleanBackend(null);
|
|
48
|
+
expect(getPathBooleanBackend()).not.toBe(custom);
|
|
49
|
+
});
|
|
50
|
+
});
|