@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,163 @@
|
|
|
1
|
+
import { createPath, appendPathRectangle, flattenPath } from '@flighthq/path';
|
|
2
|
+
import type { Path, PathWinding } from '@flighthq/types';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { booleanPaths, differencePaths, intersectPaths, unionPaths, xorPaths } from './booleanPaths';
|
|
6
|
+
import { setPathBooleanBackend } from './pathBooleanBackend';
|
|
7
|
+
|
|
8
|
+
function rectanglePath(x: number, y: number, w: number, h: number, winding: PathWinding = 'nonZero'): Path {
|
|
9
|
+
const path = createPath(winding);
|
|
10
|
+
appendPathRectangle(path, x, y, w, h);
|
|
11
|
+
return path;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Net filled area of a path under non-zero fill: flatten to contours and sum signed ring areas so a
|
|
15
|
+
// hole's opposite winding cancels an outer ring.
|
|
16
|
+
function pathFilledArea(path: Readonly<Path>): number {
|
|
17
|
+
let total = 0;
|
|
18
|
+
for (const ring of flattenPath(path)) {
|
|
19
|
+
let area = 0;
|
|
20
|
+
const n = ring.length >> 1;
|
|
21
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
22
|
+
area += ring[j * 2] * ring[i * 2 + 1] - ring[i * 2] * ring[j * 2 + 1];
|
|
23
|
+
}
|
|
24
|
+
total += area / 2;
|
|
25
|
+
}
|
|
26
|
+
return Math.abs(total);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pathFillContains(path: Readonly<Path>, x: number, y: number): boolean {
|
|
30
|
+
let winding = 0;
|
|
31
|
+
for (const ring of flattenPath(path)) {
|
|
32
|
+
const n = ring.length >> 1;
|
|
33
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
34
|
+
const xi = ring[i * 2];
|
|
35
|
+
const yi = ring[i * 2 + 1];
|
|
36
|
+
const xj = ring[j * 2];
|
|
37
|
+
const yj = ring[j * 2 + 1];
|
|
38
|
+
if (yi <= y ? yj > y : yj <= y) {
|
|
39
|
+
const t = (y - yi) / (yj - yi);
|
|
40
|
+
if (x < xi + t * (xj - xi)) winding += yj > yi ? 1 : -1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return winding !== 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('booleanPaths', () => {
|
|
48
|
+
it('dispatches to the operation named in the argument', () => {
|
|
49
|
+
const a = rectanglePath(0, 0, 10, 10);
|
|
50
|
+
const b = rectanglePath(5, 5, 10, 10);
|
|
51
|
+
expect(pathFilledArea(booleanPaths(a, b, 'union'))).toBeCloseTo(175, 4);
|
|
52
|
+
expect(pathFilledArea(booleanPaths(a, b, 'intersection'))).toBeCloseTo(25, 4);
|
|
53
|
+
expect(pathFilledArea(booleanPaths(a, b, 'difference'))).toBeCloseTo(75, 4);
|
|
54
|
+
expect(pathFilledArea(booleanPaths(a, b, 'xor'))).toBeCloseTo(150, 4);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns a fresh non-zero path when out is omitted', () => {
|
|
58
|
+
const result = booleanPaths(rectanglePath(0, 0, 10, 10), rectanglePath(20, 20, 5, 5), 'union');
|
|
59
|
+
expect(result.winding).toBe('nonZero');
|
|
60
|
+
expect(result.commands.length).toBeGreaterThan(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('writes into the provided out path and returns it', () => {
|
|
64
|
+
const out = createPath('nonZero');
|
|
65
|
+
const returned = booleanPaths(rectanglePath(0, 0, 10, 10), rectanglePath(5, 5, 10, 10), 'union', out);
|
|
66
|
+
expect(returned).toBe(out);
|
|
67
|
+
expect(pathFilledArea(out)).toBeCloseTo(175, 4);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('is safe when out aliases the subject input', () => {
|
|
71
|
+
const a = rectanglePath(0, 0, 10, 10);
|
|
72
|
+
const b = rectanglePath(5, 5, 10, 10);
|
|
73
|
+
const result = booleanPaths(a, b, 'union', a);
|
|
74
|
+
expect(result).toBe(a);
|
|
75
|
+
expect(pathFilledArea(result)).toBeCloseTo(175, 4);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('is safe when out aliases the clip input', () => {
|
|
79
|
+
const a = rectanglePath(0, 0, 10, 10);
|
|
80
|
+
const b = rectanglePath(5, 5, 10, 10);
|
|
81
|
+
const result = booleanPaths(a, b, 'intersection', b);
|
|
82
|
+
expect(result).toBe(b);
|
|
83
|
+
expect(pathFilledArea(result)).toBeCloseTo(25, 4);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('honors the even-odd fill rule option on self-overlapping input', () => {
|
|
87
|
+
// A single subject path with two overlapping rectangles: non-zero fills the overlap, even-odd holes
|
|
88
|
+
// it. (A path holds many contours; two appendPathRectangle calls append two.)
|
|
89
|
+
const a = createPath('nonZero');
|
|
90
|
+
appendPathRectangle(a, 0, 0, 10, 10);
|
|
91
|
+
appendPathRectangle(a, 5, 5, 10, 10);
|
|
92
|
+
const empty = createPath('nonZero');
|
|
93
|
+
expect(pathFilledArea(booleanPaths(a, empty, 'union', undefined, { fillRule: 'nonZero' }))).toBeCloseTo(175, 4);
|
|
94
|
+
expect(pathFilledArea(booleanPaths(a, empty, 'union', undefined, { fillRule: 'evenOdd' }))).toBeCloseTo(150, 4);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('accepts a coarser tolerance option without error', () => {
|
|
98
|
+
const a = rectanglePath(0, 0, 10, 10);
|
|
99
|
+
const b = rectanglePath(5, 5, 10, 10);
|
|
100
|
+
expect(pathFilledArea(booleanPaths(a, b, 'union', undefined, { tolerance: 2 }))).toBeCloseTo(175, 4);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('differencePaths', () => {
|
|
105
|
+
it('cuts a contained hole', () => {
|
|
106
|
+
const result = differencePaths(rectanglePath(0, 0, 30, 30), rectanglePath(10, 10, 10, 10));
|
|
107
|
+
expect(pathFilledArea(result)).toBeCloseTo(800, 4);
|
|
108
|
+
expect(pathFillContains(result, 15, 15)).toBe(false);
|
|
109
|
+
expect(pathFillContains(result, 2, 2)).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('is empty for a path differenced against itself', () => {
|
|
113
|
+
const a = rectanglePath(0, 0, 10, 10);
|
|
114
|
+
const b = rectanglePath(0, 0, 10, 10);
|
|
115
|
+
expect(pathFilledArea(differencePaths(a, b))).toBeCloseTo(0, 4);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('intersectPaths', () => {
|
|
120
|
+
it('keeps the overlap', () => {
|
|
121
|
+
const result = intersectPaths(rectanglePath(0, 0, 10, 10), rectanglePath(5, 5, 10, 10));
|
|
122
|
+
expect(pathFilledArea(result)).toBeCloseTo(25, 4);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('is empty for disjoint inputs', () => {
|
|
126
|
+
const result = intersectPaths(rectanglePath(0, 0, 10, 10), rectanglePath(20, 20, 10, 10));
|
|
127
|
+
expect(pathFilledArea(result)).toBeCloseTo(0, 4);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('unionPaths', () => {
|
|
132
|
+
it('merges overlapping rectangles', () => {
|
|
133
|
+
const result = unionPaths(rectanglePath(0, 0, 10, 10), rectanglePath(5, 5, 10, 10));
|
|
134
|
+
expect(pathFilledArea(result)).toBeCloseTo(175, 4);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('commutes', () => {
|
|
138
|
+
const a = rectanglePath(0, 0, 10, 10);
|
|
139
|
+
const b = rectanglePath(4, 4, 10, 10);
|
|
140
|
+
expect(pathFilledArea(unionPaths(a, b))).toBeCloseTo(pathFilledArea(unionPaths(b, a)), 4);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('routes through a swapped-in backend', () => {
|
|
144
|
+
let seen = false;
|
|
145
|
+
setPathBooleanBackend({
|
|
146
|
+
computePathBoolean() {
|
|
147
|
+
seen = true;
|
|
148
|
+
return [];
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
const result = unionPaths(rectanglePath(0, 0, 10, 10), rectanglePath(5, 5, 10, 10));
|
|
152
|
+
setPathBooleanBackend(null);
|
|
153
|
+
expect(seen).toBe(true);
|
|
154
|
+
expect(result.commands.length).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('xorPaths', () => {
|
|
159
|
+
it('keeps the symmetric difference', () => {
|
|
160
|
+
const result = xorPaths(rectanglePath(0, 0, 10, 10), rectanglePath(5, 5, 10, 10));
|
|
161
|
+
expect(pathFilledArea(result)).toBeCloseTo(150, 4);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { appendPathClose, appendPathLineTo, appendPathMoveTo, createPath, flattenPath } from '@flighthq/path';
|
|
2
|
+
import type { Path } from '@flighthq/types';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { differencePaths, unionPaths } from './booleanPaths';
|
|
6
|
+
import { offsetPath } from './offsetPath';
|
|
7
|
+
import { simplifyPath } from './simplifyPath';
|
|
8
|
+
import { unionAllPaths } from './unionAllPaths';
|
|
9
|
+
|
|
10
|
+
// Deterministic xorshift32 PRNG. Seeded by a constant so every fuzz case is reproducible run to run — a
|
|
11
|
+
// failing invariant always reproduces from the same seed. Never uses Math.random.
|
|
12
|
+
function makeRandom(seed: number): () => number {
|
|
13
|
+
let state = seed >>> 0 || 1;
|
|
14
|
+
return () => {
|
|
15
|
+
state ^= state << 13;
|
|
16
|
+
state >>>= 0;
|
|
17
|
+
state ^= state >>> 17;
|
|
18
|
+
state ^= state << 5;
|
|
19
|
+
state >>>= 0;
|
|
20
|
+
return state / 4294967296;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Builds a closed polygon path from a flat [x0, y0, ...] vertex list.
|
|
25
|
+
function polygonPath(vertices: readonly number[]): Path {
|
|
26
|
+
const path = createPath('nonZero');
|
|
27
|
+
appendPathMoveTo(path, vertices[0], vertices[1]);
|
|
28
|
+
for (let i = 2; i < vertices.length; i += 2) appendPathLineTo(path, vertices[i], vertices[i + 1]);
|
|
29
|
+
appendPathClose(path);
|
|
30
|
+
return path;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A simple (non-self-intersecting) star-shaped polygon: distinct sorted angles about a center, each at a
|
|
34
|
+
// random radius. Sorting the angles guarantees the boundary never crosses itself.
|
|
35
|
+
function randomSimplePolygon(random: () => number, count: number): number[] {
|
|
36
|
+
const angles: number[] = [];
|
|
37
|
+
for (let i = 0; i < count; i++) angles.push(random() * Math.PI * 2);
|
|
38
|
+
angles.sort((a, b) => a - b);
|
|
39
|
+
const vertices: number[] = [];
|
|
40
|
+
for (const angle of angles) {
|
|
41
|
+
const radius = 20 + random() * 30;
|
|
42
|
+
vertices.push(50 + radius * Math.cos(angle), 50 + radius * Math.sin(angle));
|
|
43
|
+
}
|
|
44
|
+
return vertices;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A random convex polygon: the convex hull of random points, always simple and convex.
|
|
48
|
+
function randomConvexPolygon(random: () => number, count: number): number[] {
|
|
49
|
+
const points: [number, number][] = [];
|
|
50
|
+
for (let i = 0; i < count; i++) points.push([10 + random() * 80, 10 + random() * 80]);
|
|
51
|
+
return convexHull(points);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// An arbitrary, possibly self-intersecting polygon: random points visited in random order.
|
|
55
|
+
function randomMessyPolygon(random: () => number, count: number): number[] {
|
|
56
|
+
const vertices: number[] = [];
|
|
57
|
+
for (let i = 0; i < count; i++) vertices.push(random() * 100, random() * 100);
|
|
58
|
+
return vertices;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Counter-clockwise convex hull (Andrew's monotone chain) of a point set, as a flat vertex list.
|
|
62
|
+
function convexHull(points: readonly (readonly [number, number])[]): number[] {
|
|
63
|
+
const sorted = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
64
|
+
const cross = (o: readonly number[], a: readonly number[], b: readonly number[]): number =>
|
|
65
|
+
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
66
|
+
const lower: (readonly number[])[] = [];
|
|
67
|
+
for (const p of sorted) {
|
|
68
|
+
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
|
|
69
|
+
lower.push(p);
|
|
70
|
+
}
|
|
71
|
+
const upper: (readonly number[])[] = [];
|
|
72
|
+
for (let i = sorted.length - 1; i >= 0; i--) {
|
|
73
|
+
const p = sorted[i];
|
|
74
|
+
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
|
|
75
|
+
upper.push(p);
|
|
76
|
+
}
|
|
77
|
+
lower.pop();
|
|
78
|
+
upper.pop();
|
|
79
|
+
const hull = lower.concat(upper);
|
|
80
|
+
const flat: number[] = [];
|
|
81
|
+
for (const p of hull) flat.push(p[0], p[1]);
|
|
82
|
+
return flat;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Total absolute filled area of a path's flattened outline (sum of signed ring areas).
|
|
86
|
+
function pathArea(path: Readonly<Path>): number {
|
|
87
|
+
let total = 0;
|
|
88
|
+
for (const ring of flattenPath(path)) {
|
|
89
|
+
let area = 0;
|
|
90
|
+
const n = ring.length >> 1;
|
|
91
|
+
for (let i = 0, j = n - 1; i < n; j = i++) area += ring[j * 2] * ring[i * 2 + 1] - ring[i * 2] * ring[j * 2 + 1];
|
|
92
|
+
total += area / 2;
|
|
93
|
+
}
|
|
94
|
+
return Math.abs(total);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Number of contours in a path's flattened outline.
|
|
98
|
+
function ringCount(path: Readonly<Path>): number {
|
|
99
|
+
return flattenPath(path).length;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Whether two areas agree within a combined absolute + relative tolerance.
|
|
103
|
+
function areasClose(a: number, b: number, relative = 1e-3, absolute = 1e-3): boolean {
|
|
104
|
+
return Math.abs(a - b) <= absolute + relative * Math.max(Math.abs(a), Math.abs(b));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('fuzz invariants', () => {
|
|
108
|
+
it('union is commutative: A ∪ B has the same area and ring count as B ∪ A', () => {
|
|
109
|
+
const random = makeRandom(0x1234abcd);
|
|
110
|
+
for (let iteration = 0; iteration < 40; iteration++) {
|
|
111
|
+
const a = polygonPath(randomSimplePolygon(random, 3 + (iteration % 6)));
|
|
112
|
+
const b = polygonPath(randomSimplePolygon(random, 3 + ((iteration + 3) % 6)));
|
|
113
|
+
const ab = unionPaths(a, b);
|
|
114
|
+
const ba = unionPaths(b, a);
|
|
115
|
+
expect(areasClose(pathArea(ab), pathArea(ba))).toBe(true);
|
|
116
|
+
expect(ringCount(ab)).toBe(ringCount(ba));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('self-difference is empty: A ∖ A == ∅', () => {
|
|
121
|
+
const random = makeRandom(0x55aa33cc);
|
|
122
|
+
for (let iteration = 0; iteration < 40; iteration++) {
|
|
123
|
+
const a = polygonPath(randomMessyPolygon(random, 4 + (iteration % 5)));
|
|
124
|
+
const result = differencePaths(a, a);
|
|
125
|
+
expect(pathArea(result)).toBeCloseTo(0, 6);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('self-union equals simplify: A ∪ A == simplifyPath(A)', () => {
|
|
130
|
+
const random = makeRandom(0x0f0f0f0f);
|
|
131
|
+
for (let iteration = 0; iteration < 40; iteration++) {
|
|
132
|
+
const a = polygonPath(randomSimplePolygon(random, 3 + (iteration % 6)));
|
|
133
|
+
const union = unionPaths(a, a);
|
|
134
|
+
const simplified = simplifyPath(a);
|
|
135
|
+
expect(areasClose(pathArea(union), pathArea(simplified))).toBe(true);
|
|
136
|
+
expect(ringCount(union)).toBe(ringCount(simplified));
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('simplify is idempotent: simplify(simplify(A)) == simplify(A)', () => {
|
|
141
|
+
const random = makeRandom(0x7e577e57);
|
|
142
|
+
for (let iteration = 0; iteration < 40; iteration++) {
|
|
143
|
+
const a = polygonPath(randomMessyPolygon(random, 4 + (iteration % 6)));
|
|
144
|
+
const once = simplifyPath(a);
|
|
145
|
+
const twice = simplifyPath(once);
|
|
146
|
+
expect(areasClose(pathArea(once), pathArea(twice))).toBe(true);
|
|
147
|
+
expect(ringCount(once)).toBe(ringCount(twice));
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('unionAllPaths of one path equals its simplification', () => {
|
|
152
|
+
const random = makeRandom(0x13571357);
|
|
153
|
+
for (let iteration = 0; iteration < 40; iteration++) {
|
|
154
|
+
const a = polygonPath(randomMessyPolygon(random, 4 + (iteration % 6)));
|
|
155
|
+
const union = unionAllPaths([a]);
|
|
156
|
+
const simplified = simplifyPath(a);
|
|
157
|
+
expect(areasClose(pathArea(union), pathArea(simplified))).toBe(true);
|
|
158
|
+
expect(ringCount(union)).toBe(ringCount(simplified));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('offset identity: offsetPath(A, 0) ≈ A', () => {
|
|
163
|
+
const random = makeRandom(0x2468ace0);
|
|
164
|
+
for (let iteration = 0; iteration < 40; iteration++) {
|
|
165
|
+
const vertices = randomSimplePolygon(random, 3 + (iteration % 6));
|
|
166
|
+
const a = polygonPath(vertices);
|
|
167
|
+
const zeroOffset = offsetPath(a, 0);
|
|
168
|
+
expect(areasClose(pathArea(zeroOffset), pathArea(a), 1e-2, 1e-2)).toBe(true);
|
|
169
|
+
expect(ringCount(zeroOffset)).toBe(1);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('double-offset stability: offset +d then −d recovers a convex A within tolerance', () => {
|
|
174
|
+
const random = makeRandom(0x0badf00d);
|
|
175
|
+
for (let iteration = 0; iteration < 40; iteration++) {
|
|
176
|
+
const vertices = randomConvexPolygon(random, 5 + (iteration % 8));
|
|
177
|
+
if (vertices.length < 8) continue; // need at least a quad after hull dedup
|
|
178
|
+
const a = polygonPath(vertices);
|
|
179
|
+
const baseArea = pathArea(a);
|
|
180
|
+
if (baseArea < 50) continue; // skip near-degenerate hulls where the erosion tolerance dominates
|
|
181
|
+
// For a convex polygon, inflating by d then deflating by d with a miter join recovers the polygon:
|
|
182
|
+
// the corner miters added on the grow are exactly removed on the shrink. A high miter limit keeps
|
|
183
|
+
// sharp corners from falling back to a bevel (which would not fully recover).
|
|
184
|
+
const grown = offsetPath(a, 3, { join: 'miter', miterLimit: 100 });
|
|
185
|
+
const recovered = offsetPath(grown, -3, { join: 'miter', miterLimit: 100 });
|
|
186
|
+
expect(ringCount(recovered)).toBe(1);
|
|
187
|
+
expect(areasClose(pathArea(recovered), baseArea, 3e-2, 1)).toBe(true);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('concave-offset validity: an offset outline has no self-intersections (equals its own simplify)', () => {
|
|
192
|
+
const random = makeRandom(0x0c0ffee0);
|
|
193
|
+
for (let iteration = 0; iteration < 40; iteration++) {
|
|
194
|
+
const a = polygonPath(randomSimplePolygon(random, 5 + (iteration % 8)));
|
|
195
|
+
const offset = offsetPath(a, 4);
|
|
196
|
+
if (offset.commands.length === 0) continue;
|
|
197
|
+
// A self-intersection-free outline is unchanged by a non-zero simplify: its area and ring count hold.
|
|
198
|
+
const simplified = simplifyPath(offset, { fillRule: 'nonZero' });
|
|
199
|
+
expect(areasClose(pathArea(simplified), pathArea(offset), 1e-2, 1e-2)).toBe(true);
|
|
200
|
+
expect(ringCount(simplified)).toBe(ringCount(offset));
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|