@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.
Files changed (41) hide show
  1. package/dist/booleanPaths.d.ts +7 -0
  2. package/dist/booleanPaths.d.ts.map +1 -0
  3. package/dist/booleanPaths.js +47 -0
  4. package/dist/booleanPaths.js.map +1 -0
  5. package/dist/index.d.ts +7 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +7 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/martinezKernel.d.ts +3 -0
  10. package/dist/martinezKernel.d.ts.map +1 -0
  11. package/dist/martinezKernel.js +625 -0
  12. package/dist/martinezKernel.js.map +1 -0
  13. package/dist/offsetPath.d.ts +3 -0
  14. package/dist/offsetPath.d.ts.map +1 -0
  15. package/dist/offsetPath.js +318 -0
  16. package/dist/offsetPath.js.map +1 -0
  17. package/dist/pathBooleanBackend.d.ts +5 -0
  18. package/dist/pathBooleanBackend.d.ts.map +1 -0
  19. package/dist/pathBooleanBackend.js +24 -0
  20. package/dist/pathBooleanBackend.js.map +1 -0
  21. package/dist/resolvePathRegions.d.ts +3 -0
  22. package/dist/resolvePathRegions.d.ts.map +1 -0
  23. package/dist/resolvePathRegions.js +28 -0
  24. package/dist/resolvePathRegions.js.map +1 -0
  25. package/dist/simplifyPath.d.ts +3 -0
  26. package/dist/simplifyPath.d.ts.map +1 -0
  27. package/dist/simplifyPath.js +16 -0
  28. package/dist/simplifyPath.js.map +1 -0
  29. package/dist/unionAllPaths.d.ts +3 -0
  30. package/dist/unionAllPaths.d.ts.map +1 -0
  31. package/dist/unionAllPaths.js +35 -0
  32. package/dist/unionAllPaths.js.map +1 -0
  33. package/package.json +38 -0
  34. package/src/booleanPaths.test.ts +163 -0
  35. package/src/fuzzInvariants.test.ts +203 -0
  36. package/src/martinezKernel.test.ts +376 -0
  37. package/src/offsetPath.test.ts +223 -0
  38. package/src/pathBooleanBackend.test.ts +50 -0
  39. package/src/resolvePathRegions.test.ts +48 -0
  40. package/src/simplifyPath.test.ts +146 -0
  41. package/src/unionAllPaths.test.ts +92 -0
@@ -0,0 +1,48 @@
1
+ import { flattenPath } from '@flighthq/path';
2
+ import type { Path } from '@flighthq/types';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { resolvePathRegions } from './resolvePathRegions';
6
+
7
+ // Total absolute filled area of a path's flattened outline (sum of signed ring areas).
8
+ function pathArea(path: Readonly<Path>): number {
9
+ let total = 0;
10
+ for (const ring of flattenPath(path)) {
11
+ let area = 0;
12
+ const n = ring.length >> 1;
13
+ 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];
14
+ total += area / 2;
15
+ }
16
+ return Math.abs(total);
17
+ }
18
+
19
+ // Number of contours in a path's flattened outline.
20
+ function ringCount(path: Readonly<Path>): number {
21
+ return flattenPath(path).length;
22
+ }
23
+
24
+ const SQUARE_A = [0, 0, 4, 0, 4, 4, 0, 4];
25
+ const SQUARE_B = [2, 2, 6, 2, 6, 6, 2, 6];
26
+
27
+ describe('resolvePathRegions', () => {
28
+ it('returns an empty path for no rings', () => {
29
+ const result = resolvePathRegions([], 'nonZero');
30
+ expect(result.commands).toHaveLength(0);
31
+ expect(result.data).toHaveLength(0);
32
+ expect(result.winding).toBe('nonZero');
33
+ });
34
+
35
+ it('self-unions overlapping rings into one outline under nonZero', () => {
36
+ const result = resolvePathRegions([SQUARE_A, SQUARE_B], 'nonZero');
37
+ // Two 4x4 squares overlapping in a 2x2 corner: 16 + 16 - 4 = 28.
38
+ expect(ringCount(result)).toBe(1);
39
+ expect(pathArea(result)).toBeCloseTo(28, 6);
40
+ });
41
+
42
+ it('applies the fill rule to self-overlap under evenOdd', () => {
43
+ const result = resolvePathRegions([SQUARE_A, SQUARE_B], 'evenOdd');
44
+ // Even-odd punches the doubly-covered 2x2 corner out: (16 - 4) + (16 - 4) = 24.
45
+ expect(pathArea(result)).toBeCloseTo(24, 6);
46
+ expect(ringCount(result)).toBeGreaterThan(1);
47
+ });
48
+ });
@@ -0,0 +1,146 @@
1
+ import { appendPathClose, appendPathLineTo, appendPathMoveTo, createPath, flattenPath } from '@flighthq/path';
2
+ import type { Path, PathWinding } from '@flighthq/types';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { simplifyPath } from './simplifyPath';
6
+
7
+ // Builds a closed polygon path from a flat [x0, y0, ...] vertex list.
8
+ function polygonPath(vertices: readonly number[], winding: PathWinding = 'nonZero'): Path {
9
+ const path = createPath(winding);
10
+ appendPathMoveTo(path, vertices[0], vertices[1]);
11
+ for (let i = 2; i < vertices.length; i += 2) appendPathLineTo(path, vertices[i], vertices[i + 1]);
12
+ appendPathClose(path);
13
+ return path;
14
+ }
15
+
16
+ // Axis-aligned bounds of a path's flattened outline.
17
+ function pathBounds(path: Readonly<Path>): { minX: number; minY: number; maxX: number; maxY: number } {
18
+ let minX = Infinity;
19
+ let minY = Infinity;
20
+ let maxX = -Infinity;
21
+ let maxY = -Infinity;
22
+ for (const ring of flattenPath(path)) {
23
+ for (let i = 0; i < ring.length; i += 2) {
24
+ minX = Math.min(minX, ring[i]);
25
+ minY = Math.min(minY, ring[i + 1]);
26
+ maxX = Math.max(maxX, ring[i]);
27
+ maxY = Math.max(maxY, ring[i + 1]);
28
+ }
29
+ }
30
+ return { minX, minY, maxX, maxY };
31
+ }
32
+
33
+ // Total absolute filled area of a path's flattened outline (sum of signed ring areas).
34
+ function pathArea(path: Readonly<Path>): number {
35
+ let total = 0;
36
+ for (const ring of flattenPath(path)) {
37
+ let area = 0;
38
+ const n = ring.length >> 1;
39
+ 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];
40
+ total += area / 2;
41
+ }
42
+ return Math.abs(total);
43
+ }
44
+
45
+ // Number of contours in a path's flattened outline.
46
+ function ringCount(path: Readonly<Path>): number {
47
+ return flattenPath(path).length;
48
+ }
49
+
50
+ // Builds a pentagram (5-pointed star) as one self-intersecting contour, connecting every other outer
51
+ // point of a radius-`radius` pentagon centered at the origin.
52
+ function pentagramPath(radius: number, winding: PathWinding): Path {
53
+ const order = [0, 2, 4, 1, 3];
54
+ const vertices: number[] = [];
55
+ for (const k of order) {
56
+ const angle = -Math.PI / 2 + (k * 2 * Math.PI) / 5;
57
+ vertices.push(radius * Math.cos(angle), radius * Math.sin(angle));
58
+ }
59
+ return polygonPath(vertices, winding);
60
+ }
61
+
62
+ // A bowtie: a self-intersecting quad whose two diagonals cross at (1, 1), enclosing two unit triangles.
63
+ const BOWTIE = [0, 0, 2, 2, 2, 0, 0, 2];
64
+
65
+ const UNIT_SQUARE = [0, 0, 3, 0, 3, 3, 0, 3];
66
+
67
+ describe('simplifyPath', () => {
68
+ it('resolves a self-intersecting bowtie into two triangles', () => {
69
+ const result = simplifyPath(polygonPath(BOWTIE));
70
+ // The crossing splits the quad into two unit triangles meeting at (1, 1); their windings are opposite
71
+ // so both fill rules agree here — each triangle has area 1, total 2.
72
+ expect(ringCount(result)).toBe(2);
73
+ expect(pathArea(result)).toBeCloseTo(2, 6);
74
+ });
75
+
76
+ it('fills a self-overlapping region solid under nonZero', () => {
77
+ const path = createPath('nonZero');
78
+ appendPathMoveTo(path, 0, 0);
79
+ appendPathLineTo(path, 4, 0);
80
+ appendPathLineTo(path, 4, 4);
81
+ appendPathLineTo(path, 0, 4);
82
+ appendPathClose(path);
83
+ appendPathMoveTo(path, 2, 2);
84
+ appendPathLineTo(path, 6, 2);
85
+ appendPathLineTo(path, 6, 6);
86
+ appendPathLineTo(path, 2, 6);
87
+ appendPathClose(path);
88
+ const result = simplifyPath(path, { fillRule: 'nonZero' });
89
+ // Two overlapping 4x4 squares merge into a single outline of area 16 + 16 - 4 = 28.
90
+ expect(ringCount(result)).toBe(1);
91
+ expect(pathArea(result)).toBeCloseTo(28, 6);
92
+ });
93
+
94
+ it('punches an even-odd hole through a self-overlapping region', () => {
95
+ const path = createPath('evenOdd');
96
+ appendPathMoveTo(path, 0, 0);
97
+ appendPathLineTo(path, 4, 0);
98
+ appendPathLineTo(path, 4, 4);
99
+ appendPathLineTo(path, 0, 4);
100
+ appendPathClose(path);
101
+ appendPathMoveTo(path, 2, 2);
102
+ appendPathLineTo(path, 6, 2);
103
+ appendPathLineTo(path, 6, 6);
104
+ appendPathLineTo(path, 2, 6);
105
+ appendPathClose(path);
106
+ const result = simplifyPath(path, { fillRule: 'evenOdd' });
107
+ // Even-odd removes the doubly-covered 2x2 corner: (16 - 4) + (16 - 4) = 24 — distinct from nonZero's 28.
108
+ expect(pathArea(result)).toBeCloseTo(24, 6);
109
+ expect(ringCount(result)).toBeGreaterThan(1);
110
+ });
111
+
112
+ it('fills a self-overlapping star solid under nonZero but hollow under evenOdd', () => {
113
+ const solid = simplifyPath(pentagramPath(10, 'nonZero'), { fillRule: 'nonZero' });
114
+ const hollow = simplifyPath(pentagramPath(10, 'evenOdd'), { fillRule: 'evenOdd' });
115
+ // nonZero fills the whole star including the doubly-wound center pentagon (one solid outline);
116
+ // evenOdd leaves the center as a hole, so its total filled area is strictly smaller.
117
+ expect(ringCount(solid)).toBe(1);
118
+ expect(pathArea(solid)).toBeCloseTo(112.257, 2);
119
+ expect(ringCount(hollow)).toBeGreaterThan(1);
120
+ expect(pathArea(hollow)).toBeCloseTo(77.568, 2);
121
+ expect(pathArea(hollow)).toBeLessThan(pathArea(solid));
122
+ });
123
+
124
+ it('passes an already-simple convex path through unchanged', () => {
125
+ const result = simplifyPath(polygonPath(UNIT_SQUARE));
126
+ expect(ringCount(result)).toBe(1);
127
+ expect(pathArea(result)).toBeCloseTo(9, 6);
128
+ const bounds = pathBounds(result);
129
+ expect(bounds.minX).toBeCloseTo(0, 6);
130
+ expect(bounds.minY).toBeCloseTo(0, 6);
131
+ expect(bounds.maxX).toBeCloseTo(3, 6);
132
+ expect(bounds.maxY).toBeCloseTo(3, 6);
133
+ });
134
+
135
+ it('returns an empty path for empty input', () => {
136
+ const result = simplifyPath(createPath('nonZero'));
137
+ expect(result.commands).toHaveLength(0);
138
+ expect(result.data).toHaveLength(0);
139
+ });
140
+
141
+ it('returns an empty path for a degenerate zero-area contour', () => {
142
+ const result = simplifyPath(polygonPath([0, 0, 5, 0, 10, 0]));
143
+ expect(result.commands).toHaveLength(0);
144
+ expect(result.data).toHaveLength(0);
145
+ });
146
+ });
@@ -0,0 +1,92 @@
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 { unionAllPaths } from './unionAllPaths';
6
+
7
+ // Builds a closed square path with lower-left corner (x, y) and side s.
8
+ function squarePath(x: number, y: number, s: number): Path {
9
+ const path = createPath('nonZero');
10
+ appendPathMoveTo(path, x, y);
11
+ appendPathLineTo(path, x + s, y);
12
+ appendPathLineTo(path, x + s, y + s);
13
+ appendPathLineTo(path, x, y + s);
14
+ appendPathClose(path);
15
+ return path;
16
+ }
17
+
18
+ // Total absolute filled area of a path's flattened outline (sum of signed ring areas).
19
+ function pathArea(path: Readonly<Path>): number {
20
+ let total = 0;
21
+ for (const ring of flattenPath(path)) {
22
+ let area = 0;
23
+ const n = ring.length >> 1;
24
+ 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];
25
+ total += area / 2;
26
+ }
27
+ return Math.abs(total);
28
+ }
29
+
30
+ // Number of contours in a path's flattened outline.
31
+ function ringCount(path: Readonly<Path>): number {
32
+ return flattenPath(path).length;
33
+ }
34
+
35
+ describe('unionAllPaths', () => {
36
+ it('returns an empty path for an empty list', () => {
37
+ const result = unionAllPaths([]);
38
+ expect(result.commands).toHaveLength(0);
39
+ expect(result.data).toHaveLength(0);
40
+ });
41
+
42
+ it('simplifies a single self-overlapping path', () => {
43
+ const overlap = createPath('nonZero');
44
+ appendPathMoveTo(overlap, 0, 0);
45
+ appendPathLineTo(overlap, 4, 0);
46
+ appendPathLineTo(overlap, 4, 4);
47
+ appendPathLineTo(overlap, 0, 4);
48
+ appendPathClose(overlap);
49
+ appendPathMoveTo(overlap, 2, 2);
50
+ appendPathLineTo(overlap, 6, 2);
51
+ appendPathLineTo(overlap, 6, 6);
52
+ appendPathLineTo(overlap, 2, 6);
53
+ appendPathClose(overlap);
54
+ const result = unionAllPaths([overlap]);
55
+ // The two overlapping same-wound squares resolve to one solid outline of area 16 + 16 - 4 = 28.
56
+ expect(ringCount(result)).toBe(1);
57
+ expect(pathArea(result)).toBeCloseTo(28, 6);
58
+ });
59
+
60
+ it('merges a list of overlapping squares into one outline', () => {
61
+ const result = unionAllPaths([squarePath(0, 0, 10), squarePath(5, 5, 10), squarePath(8, 8, 10)]);
62
+ // A connected chain of overlapping squares merges to a single ring; assert connectivity, not exact area.
63
+ expect(ringCount(result)).toBe(1);
64
+ expect(pathArea(result)).toBeGreaterThan(100);
65
+ expect(pathArea(result)).toBeLessThan(300);
66
+ });
67
+
68
+ it('keeps disjoint squares as separate rings summing their areas', () => {
69
+ const result = unionAllPaths([squarePath(0, 0, 10), squarePath(20, 20, 10), squarePath(40, 0, 10)]);
70
+ expect(ringCount(result)).toBe(3);
71
+ expect(pathArea(result)).toBeCloseTo(300, 6);
72
+ });
73
+
74
+ it('folds order-independently (union is commutative across the list)', () => {
75
+ const a = squarePath(0, 0, 10);
76
+ const b = squarePath(5, 5, 10);
77
+ const c = squarePath(2, 8, 10);
78
+ const forward = unionAllPaths([a, b, c]);
79
+ const reversed = unionAllPaths([c, b, a]);
80
+ expect(pathArea(forward)).toBeCloseTo(pathArea(reversed), 6);
81
+ expect(ringCount(forward)).toBe(ringCount(reversed));
82
+ });
83
+
84
+ it('writes into a provided out path that aliases an input', () => {
85
+ const a = squarePath(0, 0, 10);
86
+ const b = squarePath(5, 5, 10);
87
+ const result = unionAllPaths([a, b], a);
88
+ expect(result).toBe(a);
89
+ expect(ringCount(result)).toBe(1);
90
+ expect(pathArea(result)).toBeCloseTo(175, 6);
91
+ });
92
+ });