@jscad/modeling 2.12.6 → 2.13.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 (39) hide show
  1. package/CHANGELOG.md +12 -299
  2. package/bench/booleans.bench.js +103 -0
  3. package/bench/primitives.bench.js +108 -0
  4. package/dist/jscad-modeling.min.js +404 -395
  5. package/package.json +2 -2
  6. package/src/geometries/geom3/index.d.ts +1 -0
  7. package/src/geometries/geom3/index.js +1 -0
  8. package/src/geometries/geom3/isConvex.d.ts +3 -0
  9. package/src/geometries/geom3/isConvex.js +68 -0
  10. package/src/geometries/geom3/isConvex.test.js +45 -0
  11. package/src/geometries/path2/appendArc.js +1 -1
  12. package/src/geometries/path2/appendArc.test.js +16 -20
  13. package/src/index.d.ts +1 -0
  14. package/src/index.js +1 -0
  15. package/src/operations/booleans/index.d.ts +1 -0
  16. package/src/operations/booleans/index.js +1 -0
  17. package/src/operations/booleans/trees/PolygonTreeNode.js +18 -5
  18. package/src/operations/booleans/trees/splitPolygonByPlane.js +27 -25
  19. package/src/operations/booleans/trees/splitPolygonByPlane.test.js +132 -0
  20. package/src/operations/booleans/unionGeom3.test.js +35 -0
  21. package/src/operations/extrusions/extrudeFromSlices.js +14 -4
  22. package/src/operations/extrusions/extrudeRectangular.test.js +3 -3
  23. package/src/operations/extrusions/extrudeRotate.js +4 -1
  24. package/src/operations/extrusions/extrudeRotate.test.js +33 -0
  25. package/src/operations/extrusions/extrudeWalls.js +2 -1
  26. package/src/operations/extrusions/extrudeWalls.test.js +72 -0
  27. package/src/operations/minkowski/index.d.ts +1 -0
  28. package/src/operations/minkowski/index.js +17 -0
  29. package/src/operations/minkowski/minkowskiSum.d.ts +4 -0
  30. package/src/operations/minkowski/minkowskiSum.js +224 -0
  31. package/src/operations/minkowski/minkowskiSum.test.js +195 -0
  32. package/src/operations/modifiers/reTesselateCoplanarPolygons.js +8 -3
  33. package/src/operations/modifiers/reTesselateCoplanarPolygons.test.js +36 -1
  34. package/src/operations/modifiers/retessellate.js +5 -2
  35. package/src/operations/modifiers/snap.test.js +24 -15
  36. package/src/primitives/arc.js +2 -2
  37. package/src/primitives/arc.test.js +122 -111
  38. package/src/utils/flatten.js +1 -1
  39. package/src/utils/flatten.test.js +94 -0
@@ -26,10 +26,11 @@ const repartitionEdges = (newlength, edges) => {
26
26
  }
27
27
 
28
28
  const divisor = vec3.fromValues(multiple, multiple, multiple)
29
+ const increment = vec3.create() // reuse across all edge iterations
29
30
 
30
31
  const newEdges = []
31
32
  edges.forEach((edge) => {
32
- const increment = vec3.subtract(vec3.create(), edge[1], edge[0])
33
+ vec3.subtract(increment, edge[1], edge[0])
33
34
  vec3.divide(increment, increment, divisor)
34
35
 
35
36
  // repartition the edge
@@ -80,3 +80,75 @@ test('extrudeWalls (different shapes)', (t) => {
80
80
  walls = extrudeWalls(slice3, slice.transform(matrix, slice2))
81
81
  t.is(walls.length, 24)
82
82
  })
83
+
84
+ // Test for vec3 reuse optimization in repartitionEdges
85
+ // When shapes have different edge counts, edges are repartitioned using vec3 operations
86
+ test('extrudeWalls (repartitionEdges vec3 reuse)', (t) => {
87
+ const matrix = mat4.fromTranslation(mat4.create(), [0, 0, 5])
88
+
89
+ // Triangle (3 edges)
90
+ const triangle = [
91
+ [[0, 10], [-8.66, -5]],
92
+ [[-8.66, -5], [8.66, -5]],
93
+ [[8.66, -5], [0, 10]]
94
+ ]
95
+
96
+ // Hexagon (6 edges) - LCM with triangle is 6, so triangle edges get split
97
+ const hexagon = [
98
+ [[0, 10], [-8.66, 5]],
99
+ [[-8.66, 5], [-8.66, -5]],
100
+ [[-8.66, -5], [0, -10]],
101
+ [[0, -10], [8.66, -5]],
102
+ [[8.66, -5], [8.66, 5]],
103
+ [[8.66, 5], [0, 10]]
104
+ ]
105
+
106
+ const sliceTriangle = slice.fromSides(triangle)
107
+ const sliceHexagon = slice.fromSides(hexagon)
108
+
109
+ // Triangle to hexagon requires repartitioning (3 -> 6 edges)
110
+ // This exercises the vec3 reuse optimization in repartitionEdges
111
+ const walls = extrudeWalls(sliceTriangle, slice.transform(matrix, sliceHexagon))
112
+
113
+ // 6 edges * 2 triangles per edge = 12 wall polygons
114
+ t.is(walls.length, 12)
115
+
116
+ // Verify all walls are valid triangles
117
+ walls.forEach((wall) => {
118
+ t.is(wall.vertices.length, 3)
119
+ })
120
+ })
121
+
122
+ // Test for vec3 reuse with higher repartition multiple
123
+ test('extrudeWalls (repartitionEdges with high multiple)', (t) => {
124
+ const matrix = mat4.fromTranslation(mat4.create(), [0, 0, 10])
125
+
126
+ // Square (4 edges)
127
+ const square = [
128
+ [[-5, 5], [-5, -5]],
129
+ [[-5, -5], [5, -5]],
130
+ [[5, -5], [5, 5]],
131
+ [[5, 5], [-5, 5]]
132
+ ]
133
+
134
+ // Octagon (8 edges) - LCM with square is 8, so square edges get doubled
135
+ const octagon = [
136
+ [[0, 5], [-3.54, 3.54]],
137
+ [[-3.54, 3.54], [-5, 0]],
138
+ [[-5, 0], [-3.54, -3.54]],
139
+ [[-3.54, -3.54], [0, -5]],
140
+ [[0, -5], [3.54, -3.54]],
141
+ [[3.54, -3.54], [5, 0]],
142
+ [[5, 0], [3.54, 3.54]],
143
+ [[3.54, 3.54], [0, 5]]
144
+ ]
145
+
146
+ const sliceSquare = slice.fromSides(square)
147
+ const sliceOctagon = slice.fromSides(octagon)
148
+
149
+ // Square to octagon requires repartitioning (4 -> 8 edges)
150
+ const walls = extrudeWalls(sliceSquare, slice.transform(matrix, sliceOctagon))
151
+
152
+ // 8 edges * 2 triangles per edge = 16 wall polygons
153
+ t.is(walls.length, 16)
154
+ })
@@ -0,0 +1 @@
1
+ export { minkowskiSum } from './minkowskiSum'
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Minkowski sum operations for 3D geometries.
3
+ *
4
+ * The Minkowski sum of two shapes A and B is the set of all points that are
5
+ * the sum of a point in A and a point in B. This is useful for:
6
+ * - Offsetting/inflating shapes (using a sphere creates rounded edges)
7
+ * - Collision detection (shapes collide iff their Minkowski difference contains origin)
8
+ * - Motion planning and swept volumes
9
+ *
10
+ * @module modeling/minkowski
11
+ * @example
12
+ * const { minkowskiSum } = require('@jscad/modeling').minkowski
13
+ * const rounded = minkowskiSum(cube, sphere)
14
+ */
15
+ module.exports = {
16
+ minkowskiSum: require('./minkowskiSum')
17
+ }
@@ -0,0 +1,4 @@
1
+ import type { Geom3 } from '../../geometries/types'
2
+
3
+ export function minkowskiSum(geometryA: Geom3, geometryB: Geom3): Geom3
4
+ export function minkowskiSum(...geometries: Geom3[]): Geom3
@@ -0,0 +1,224 @@
1
+ const flatten = require('../../utils/flatten')
2
+
3
+ const geom3 = require('../../geometries/geom3')
4
+ const poly3 = require('../../geometries/poly3')
5
+
6
+ const hullPoints3 = require('../hulls/hullPoints3')
7
+ const unionGeom3 = require('../booleans/unionGeom3')
8
+
9
+ /**
10
+ * Compute the Minkowski sum of two 3D geometries.
11
+ *
12
+ * The Minkowski sum A ⊕ B is the set of all points a + b where a ∈ A and b ∈ B.
13
+ * Geometrically, this "inflates" geometry A by the shape of geometry B.
14
+ *
15
+ * Common use cases:
16
+ * - Offset a solid by a sphere to round all edges and corners
17
+ * - Offset a solid by a cube to create chamfered edges
18
+ * - Collision detection (if Minkowski sum contains origin, shapes overlap)
19
+ *
20
+ * For best performance, use convex geometries. Non-convex geometries are supported
21
+ * when the second operand is convex, but require decomposition and are slower.
22
+ *
23
+ * @param {...Object} geometries - two geom3 geometries (second should be convex for non-convex first)
24
+ * @returns {geom3} new 3D geometry representing the Minkowski sum
25
+ * @alias module:modeling/minkowski.minkowskiSum
26
+ *
27
+ * @example
28
+ * const { primitives, minkowski } = require('@jscad/modeling')
29
+ * const cube = primitives.cuboid({ size: [10, 10, 10] })
30
+ * const sphere = primitives.sphere({ radius: 2, segments: 16 })
31
+ * const rounded = minkowski.minkowskiSum(cube, sphere)
32
+ */
33
+ const minkowskiSum = (...geometries) => {
34
+ geometries = flatten(geometries)
35
+
36
+ if (geometries.length !== 2) {
37
+ throw new Error('minkowskiSum requires exactly two geometries')
38
+ }
39
+
40
+ const [geomA, geomB] = geometries
41
+
42
+ if (!geom3.isA(geomA) || !geom3.isA(geomB)) {
43
+ throw new Error('minkowskiSum requires geom3 geometries')
44
+ }
45
+
46
+ const aConvex = geom3.isConvex(geomA)
47
+ const bConvex = geom3.isConvex(geomB)
48
+
49
+ // Fast path: both convex
50
+ if (aConvex && bConvex) {
51
+ return minkowskiSumConvex(geomA, geomB)
52
+ }
53
+
54
+ // Non-convex A + convex B: decompose A into tetrahedra
55
+ if (!aConvex && bConvex) {
56
+ return minkowskiSumNonConvexConvex(geomA, geomB)
57
+ }
58
+
59
+ // Convex A + non-convex B: swap operands (Minkowski sum is commutative)
60
+ if (aConvex && !bConvex) {
61
+ return minkowskiSumNonConvexConvex(geomB, geomA)
62
+ }
63
+
64
+ // Both non-convex: not yet supported
65
+ throw new Error('minkowskiSum of two non-convex geometries is not yet supported')
66
+ }
67
+
68
+ /**
69
+ * Compute Minkowski sum of non-convex A with convex B.
70
+ *
71
+ * Decomposes A into tetrahedra, computes Minkowski sum of each with B,
72
+ * then unions all results.
73
+ */
74
+ const minkowskiSumNonConvexConvex = (geomA, geomB) => {
75
+ const tetrahedra = decomposeIntoTetrahedra(geomA)
76
+
77
+ if (tetrahedra.length === 0) {
78
+ return geom3.create()
79
+ }
80
+
81
+ // Compute Minkowski sum for each tetrahedron
82
+ const parts = tetrahedra.map((tet) => minkowskiSumConvex(tet, geomB))
83
+
84
+ // Union all parts using internal unionGeom3
85
+ if (parts.length === 1) {
86
+ return parts[0]
87
+ }
88
+
89
+ return unionGeom3(parts)
90
+ }
91
+
92
+ /**
93
+ * Decompose a geom3 into tetrahedra using face-local apex points.
94
+ * Each resulting tetrahedron is guaranteed to be convex.
95
+ *
96
+ * Unlike centroid-based decomposition, this approach works correctly for
97
+ * shapes where the centroid is outside the geometry (e.g., torus, U-shapes).
98
+ * Each polygon gets its own apex point, offset inward along its normal.
99
+ */
100
+ const decomposeIntoTetrahedra = (geometry) => {
101
+ const polygons = geom3.toPolygons(geometry)
102
+
103
+ if (polygons.length === 0) {
104
+ return []
105
+ }
106
+
107
+ const tetrahedra = []
108
+
109
+ // For each polygon, compute a face-local apex and create tetrahedra
110
+ for (let i = 0; i < polygons.length; i++) {
111
+ const polygon = polygons[i]
112
+ const vertices = polygon.vertices
113
+
114
+ // Compute polygon center
115
+ let cx = 0, cy = 0, cz = 0
116
+ for (let k = 0; k < vertices.length; k++) {
117
+ cx += vertices[k][0]
118
+ cy += vertices[k][1]
119
+ cz += vertices[k][2]
120
+ }
121
+ cx /= vertices.length
122
+ cy /= vertices.length
123
+ cz /= vertices.length
124
+
125
+ // Get polygon plane (normal + offset)
126
+ const plane = poly3.plane(polygon)
127
+ const nx = plane[0], ny = plane[1], nz = plane[2]
128
+
129
+ // Offset inward along negative normal to create face-local apex
130
+ // The normal points outward, so we go in the negative direction
131
+ // Use a small offset - the actual distance doesn't matter much
132
+ // as long as the apex is on the interior side of the face
133
+ const offset = 0.1
134
+ const apex = [ // Vertex used as apex in tetrahedron polygons below
135
+ cx - nx * offset,
136
+ cy - ny * offset,
137
+ cz - nz * offset
138
+ ]
139
+
140
+ // Fan triangulate the polygon and create tetrahedra from apex
141
+ for (let j = 1; j < vertices.length - 1; j++) {
142
+ const v0 = vertices[0]
143
+ const v1 = vertices[j]
144
+ const v2 = vertices[j + 1]
145
+
146
+ // Create tetrahedron from apex and triangle
147
+ const tetPolygons = createTetrahedronPolygons(apex, v0, v1, v2)
148
+ tetrahedra.push(geom3.create(tetPolygons))
149
+ }
150
+ }
151
+
152
+ return tetrahedra
153
+ }
154
+
155
+ /**
156
+ * Create the 4 triangular faces of a tetrahedron.
157
+ */
158
+ const createTetrahedronPolygons = (p0, p1, p2, p3) => {
159
+ // Tetrahedron has 4 faces, each a triangle
160
+ // We need to ensure consistent winding (outward-facing normals)
161
+ return [
162
+ poly3.create([p0, p2, p1]), // base seen from p3
163
+ poly3.create([p0, p1, p3]), // face opposite p2
164
+ poly3.create([p1, p2, p3]), // face opposite p0
165
+ poly3.create([p2, p0, p3]) // face opposite p1
166
+ ]
167
+ }
168
+
169
+ /**
170
+ * Compute Minkowski sum of two convex polyhedra.
171
+ *
172
+ * For convex polyhedra, the Minkowski sum equals the convex hull of
173
+ * all pairwise vertex sums. This is O(n*m) for n and m vertices,
174
+ * plus the cost of the convex hull algorithm.
175
+ */
176
+ const minkowskiSumConvex = (geomA, geomB) => {
177
+ const pointsA = extractUniqueVertices(geomA)
178
+ const pointsB = extractUniqueVertices(geomB)
179
+
180
+ if (pointsA.length === 0 || pointsB.length === 0) {
181
+ return geom3.create()
182
+ }
183
+
184
+ // Compute all pairwise sums
185
+ const summedPoints = []
186
+ for (let i = 0; i < pointsA.length; i++) {
187
+ const a = pointsA[i]
188
+ for (let j = 0; j < pointsB.length; j++) {
189
+ const b = pointsB[j]
190
+ summedPoints.push([a[0] + b[0], a[1] + b[1], a[2] + b[2]])
191
+ }
192
+ }
193
+
194
+ // Compute convex hull of the summed points
195
+ const hullPolygons = hullPoints3(summedPoints)
196
+
197
+ return geom3.create(hullPolygons)
198
+ }
199
+
200
+ /**
201
+ * Extract unique vertices from a geom3.
202
+ * Uses a Set with string keys for deduplication.
203
+ */
204
+ const extractUniqueVertices = (geometry) => {
205
+ const found = new Set()
206
+ const unique = []
207
+
208
+ const polygons = geom3.toPolygons(geometry)
209
+ for (let i = 0; i < polygons.length; i++) {
210
+ const vertices = polygons[i].vertices
211
+ for (let j = 0; j < vertices.length; j++) {
212
+ const v = vertices[j]
213
+ const key = `${v[0]},${v[1]},${v[2]}`
214
+ if (!found.has(key)) {
215
+ found.add(key)
216
+ unique.push(v)
217
+ }
218
+ }
219
+ }
220
+
221
+ return unique
222
+ }
223
+
224
+ module.exports = minkowskiSum
@@ -0,0 +1,195 @@
1
+ const test = require('ava')
2
+
3
+ const { geometries, primitives, measurements, booleans, minkowski } = require('../../index')
4
+ const { geom3 } = geometries
5
+
6
+ test('minkowskiSum: throws for non-geom3 inputs', (t) => {
7
+ t.throws(() => minkowski.minkowskiSum('invalid', primitives.cuboid()), { message: /requires geom3/ })
8
+ t.throws(() => minkowski.minkowskiSum(primitives.cuboid(), 'invalid'), { message: /requires geom3/ })
9
+ })
10
+
11
+ test('minkowskiSum: throws for wrong number of geometries', (t) => {
12
+ t.throws(() => minkowski.minkowskiSum(), { message: /exactly two/ })
13
+ t.throws(() => minkowski.minkowskiSum(primitives.cuboid()), { message: /exactly two/ })
14
+ t.throws(() => minkowski.minkowskiSum(primitives.cuboid(), primitives.cuboid(), primitives.cuboid()), { message: /exactly two/ })
15
+ })
16
+
17
+ test('minkowskiSum: cube + cube produces correct bounds', (t) => {
18
+ // Cube1: size 10 (±5 from origin)
19
+ // Cube2: size 4 (±2 from origin)
20
+ // Minkowski sum should be size 14 (±7 from origin)
21
+ const cube1 = primitives.cuboid({ size: [10, 10, 10] })
22
+ const cube2 = primitives.cuboid({ size: [4, 4, 4] })
23
+
24
+ const result = minkowski.minkowskiSum(cube1, cube2)
25
+
26
+ t.notThrows(() => geom3.validate(result))
27
+
28
+ const bounds = measurements.measureBoundingBox(result)
29
+ // Allow small tolerance for floating point
30
+ t.true(Math.abs(bounds[0][0] - (-7)) < 0.001)
31
+ t.true(Math.abs(bounds[0][1] - (-7)) < 0.001)
32
+ t.true(Math.abs(bounds[0][2] - (-7)) < 0.001)
33
+ t.true(Math.abs(bounds[1][0] - 7) < 0.001)
34
+ t.true(Math.abs(bounds[1][1] - 7) < 0.001)
35
+ t.true(Math.abs(bounds[1][2] - 7) < 0.001)
36
+ })
37
+
38
+ test('minkowskiSum: cube + sphere produces correct bounds', (t) => {
39
+ // Cube: size 10 (±5 from origin)
40
+ // Sphere: radius 2
41
+ // Minkowski sum should be ±7 from origin
42
+ const cube = primitives.cuboid({ size: [10, 10, 10] })
43
+ const sph = primitives.sphere({ radius: 2, segments: 16 })
44
+
45
+ const result = minkowski.minkowskiSum(cube, sph)
46
+
47
+ t.notThrows(() => geom3.validate(result))
48
+
49
+ const bounds = measurements.measureBoundingBox(result)
50
+ // Allow small tolerance
51
+ t.true(Math.abs(bounds[0][0] - (-7)) < 0.1)
52
+ t.true(Math.abs(bounds[1][0] - 7) < 0.1)
53
+ })
54
+
55
+ test('minkowskiSum: sphere + sphere produces correct bounds', (t) => {
56
+ // Sphere1: radius 3
57
+ // Sphere2: radius 2
58
+ // Minkowski sum should be a sphere-like shape with radius ~5
59
+ const sph1 = primitives.sphere({ radius: 3, segments: 16 })
60
+ const sph2 = primitives.sphere({ radius: 2, segments: 16 })
61
+
62
+ const result = minkowski.minkowskiSum(sph1, sph2)
63
+
64
+ t.notThrows(() => geom3.validate(result))
65
+
66
+ const bounds = measurements.measureBoundingBox(result)
67
+ // Should be approximately ±5
68
+ t.true(Math.abs(bounds[0][0] - (-5)) < 0.2)
69
+ t.true(Math.abs(bounds[1][0] - 5) < 0.2)
70
+ })
71
+
72
+ test('minkowskiSum: empty geometry returns empty', (t) => {
73
+ const empty = geom3.create()
74
+ const cube = primitives.cuboid({ size: [10, 10, 10] })
75
+
76
+ const result = minkowski.minkowskiSum(empty, cube)
77
+
78
+ t.notThrows(() => geom3.validate(result))
79
+ t.is(geom3.toPolygons(result).length, 0)
80
+ })
81
+
82
+ test('minkowskiSum: result is convex', (t) => {
83
+ const cube = primitives.cuboid({ size: [10, 10, 10] })
84
+ const sph = primitives.sphere({ radius: 2, segments: 12 })
85
+
86
+ const result = minkowski.minkowskiSum(cube, sph)
87
+
88
+ t.notThrows(() => geom3.validate(result))
89
+ t.true(geom3.isConvex(result))
90
+ })
91
+
92
+ // Non-convex tests
93
+
94
+ test('minkowskiSum: non-convex + convex produces valid geometry', (t) => {
95
+ // Create L-shaped non-convex geometry
96
+ const big = primitives.cuboid({ size: [10, 10, 10] })
97
+ const corner = primitives.cuboid({ size: [6, 6, 12], center: [3, 3, 0] })
98
+ const lShape = booleans.subtract(big, corner)
99
+
100
+ t.false(geom3.isConvex(lShape))
101
+
102
+ const sph = primitives.sphere({ radius: 1, segments: 8 })
103
+
104
+ const result = minkowski.minkowskiSum(lShape, sph)
105
+
106
+ t.true(geom3.toPolygons(result).length > 0)
107
+ t.true(geom3.isA(result))
108
+ })
109
+
110
+ test('minkowskiSum: non-convex + convex produces correct bounds', (t) => {
111
+ // Cube with hole through it
112
+ const cube = primitives.cuboid({ size: [10, 10, 10] })
113
+ const hole = primitives.cuboid({ size: [4, 4, 20] })
114
+ const cubeWithHole = booleans.subtract(cube, hole)
115
+
116
+ t.false(geom3.isConvex(cubeWithHole))
117
+
118
+ // Offset by sphere of radius 1
119
+ const sph = primitives.sphere({ radius: 1, segments: 8 })
120
+ const result = minkowski.minkowskiSum(cubeWithHole, sph)
121
+
122
+ t.true(geom3.isA(result))
123
+
124
+ const bounds = measurements.measureBoundingBox(result)
125
+
126
+ // Original cube is ±5, plus sphere radius 1 = ±6
127
+ t.true(Math.abs(bounds[0][0] - (-6)) < 0.2)
128
+ t.true(Math.abs(bounds[1][0] - 6) < 0.2)
129
+ })
130
+
131
+ test('minkowskiSum: convex + non-convex swaps operands', (t) => {
132
+ // Minkowski sum is commutative, so A⊕B = B⊕A
133
+ const cube = primitives.cuboid({ size: [10, 10, 10] })
134
+ const hole = primitives.cuboid({ size: [4, 4, 20] })
135
+ const cubeWithHole = booleans.subtract(cube, hole)
136
+
137
+ const sph = primitives.sphere({ radius: 1, segments: 8 })
138
+
139
+ // convex + non-convex should work (swaps internally)
140
+ const result = minkowski.minkowskiSum(sph, cubeWithHole)
141
+
142
+ t.true(geom3.isA(result))
143
+ t.true(geom3.toPolygons(result).length > 0)
144
+ })
145
+
146
+ test('minkowskiSum: throws for two non-convex geometries', (t) => {
147
+ const cube1 = primitives.cuboid({ size: [10, 10, 10] })
148
+ const hole1 = primitives.cuboid({ size: [4, 4, 20] })
149
+ const nonConvex1 = booleans.subtract(cube1, hole1)
150
+
151
+ const cube2 = primitives.cuboid({ size: [8, 8, 8] })
152
+ const hole2 = primitives.cuboid({ size: [3, 3, 16] })
153
+ const nonConvex2 = booleans.subtract(cube2, hole2)
154
+
155
+ t.throws(() => minkowski.minkowskiSum(nonConvex1, nonConvex2), { message: /two non-convex/ })
156
+ })
157
+
158
+ test('minkowskiSum: torus + sphere preserves hole (face-local apex)', (t) => {
159
+ // Torus with innerRadius=3 (tube radius) and outerRadius=8 (distance to tube center)
160
+ // At z=0, the torus extends from radius 5 to 11 (8-3 to 8+3)
161
+ // Adding sphere of radius 1 should give 4 to 12
162
+ const torusShape = primitives.torus({
163
+ innerRadius: 3,
164
+ outerRadius: 8,
165
+ innerSegments: 16,
166
+ outerSegments: 24
167
+ })
168
+
169
+ const sph = primitives.sphere({ radius: 1, segments: 8 })
170
+
171
+ t.false(geom3.isConvex(torusShape))
172
+
173
+ const result = minkowski.minkowskiSum(torusShape, sph)
174
+
175
+ t.true(geom3.isA(result))
176
+ t.true(geom3.toPolygons(result).length > 0)
177
+
178
+ // Check that the hole is preserved by examining vertices at z≈0
179
+ const polygons = geom3.toPolygons(result)
180
+ let minRadius = Infinity
181
+
182
+ for (const poly of polygons) {
183
+ for (const v of poly.vertices) {
184
+ if (Math.abs(v[2]) < 0.5) {
185
+ const r = Math.sqrt(v[0] * v[0] + v[1] * v[1])
186
+ if (r < minRadius) minRadius = r
187
+ }
188
+ }
189
+ }
190
+
191
+ // With face-local apex, hole should be preserved
192
+ // Inner radius should be around 4 (8-3-1 = 4)
193
+ // If centroid-based (buggy), hole would be filled and minRadius would be ~0
194
+ t.true(minRadius > 3, `hole should be preserved, got minRadius=${minRadius}`)
195
+ })
@@ -120,6 +120,7 @@ const reTesselateCoplanarPolygons = (sourcepolygons) => {
120
120
  // at the the left and right side of the polygon
121
121
  // Iterate over all polygons that have a corner at this y coordinate:
122
122
  const polygonindexeswithcorner = ycoordinatetopolygonindexes.get(ycoordinate)
123
+ let removeCount = 0 // track removals to filter at end (avoids O(n²) splice)
123
124
  for (let activepolygonindex = 0; activepolygonindex < activepolygons.length; ++activepolygonindex) {
124
125
  const activepolygon = activepolygons[activepolygonindex]
125
126
  const polygonindex = activepolygon.polygonindex
@@ -143,9 +144,9 @@ const reTesselateCoplanarPolygons = (sourcepolygons) => {
143
144
  }
144
145
  if ((newleftvertexindex !== activepolygon.leftvertexindex) && (newleftvertexindex === newrightvertexindex)) {
145
146
  // We have increased leftvertexindex or decreased rightvertexindex, and now they point to the same vertex
146
- // This means that this is the bottom point of the polygon. We'll remove it:
147
- activepolygons.splice(activepolygonindex, 1)
148
- --activepolygonindex
147
+ // This means that this is the bottom point of the polygon. Mark it for removal:
148
+ activepolygon._remove = true
149
+ removeCount++
149
150
  } else {
150
151
  activepolygon.leftvertexindex = newleftvertexindex
151
152
  activepolygon.rightvertexindex = newrightvertexindex
@@ -160,6 +161,10 @@ const reTesselateCoplanarPolygons = (sourcepolygons) => {
160
161
  }
161
162
  } // if polygon has corner here
162
163
  } // for activepolygonindex
164
+ // Filter out marked polygons in single pass (O(n) instead of O(n²) splice)
165
+ if (removeCount > 0) {
166
+ activepolygons = activepolygons.filter((p) => !p._remove)
167
+ }
163
168
  let nextycoordinate
164
169
  if (yindex >= ycoordinates.length - 1) {
165
170
  // last row, all polygons must be finished here:
@@ -16,7 +16,7 @@ const rotatePoly3 = (angles, polygon) => {
16
16
  return poly3.transform(matrix, polygon)
17
17
  }
18
18
 
19
- test.only('retessellateCoplanarPolygons: should merge coplanar polygons', (t) => {
19
+ test('retessellateCoplanarPolygons: should merge coplanar polygons', (t) => {
20
20
  const polyA = poly3.create([[-5, -5, 0], [5, -5, 0], [5, 5, 0], [-5, 5, 0]])
21
21
  const polyB = poly3.create([[5, -5, 0], [8, 0, 0], [5, 5, 0]])
22
22
  const polyC = poly3.create([[-5, 5, 0], [-8, 0, 0], [-5, -5, 0]])
@@ -68,3 +68,38 @@ test.only('retessellateCoplanarPolygons: should merge coplanar polygons', (t) =>
68
68
  obs = reTesselateCoplanarPolygons([polyH, polyI, polyJ, polyK, polyL])
69
69
  t.is(obs.length, 1)
70
70
  })
71
+
72
+ // Test for mark-and-filter optimization: multiple polygons that reach their
73
+ // bottom point at the same y-coordinate (triggering the removal code path)
74
+ test('retessellateCoplanarPolygons: should correctly handle multiple polygon removals', (t) => {
75
+ // Create multiple triangular polygons that all end at the same y-coordinate
76
+ // This exercises the mark-and-filter removal optimization
77
+ const poly1 = poly3.create([[0, 0, 0], [2, 0, 0], [1, 3, 0]]) // triangle pointing up
78
+ const poly2 = poly3.create([[3, 0, 0], [5, 0, 0], [4, 3, 0]]) // triangle pointing up
79
+ const poly3a = poly3.create([[6, 0, 0], [8, 0, 0], [7, 3, 0]]) // triangle pointing up
80
+
81
+ // These polygons share the same plane and have vertices at y=0 and y=3
82
+ // During retessellation, all three will be active and then removed at y=3
83
+ const obs = reTesselateCoplanarPolygons([poly1, poly2, poly3a])
84
+
85
+ // Each triangle should be preserved (they don't overlap)
86
+ t.is(obs.length, 3)
87
+
88
+ // Verify each polygon has 3 vertices (triangles)
89
+ obs.forEach((polygon) => {
90
+ t.is(polygon.vertices.length, 3)
91
+ })
92
+ })
93
+
94
+ // Test for mark-and-filter with overlapping polygons that get merged
95
+ test('retessellateCoplanarPolygons: should merge adjacent polygons with shared edges', (t) => {
96
+ // Two adjacent squares sharing an edge at x=5
97
+ const poly1 = poly3.create([[0, 0, 0], [5, 0, 0], [5, 5, 0], [0, 5, 0]])
98
+ const poly2 = poly3.create([[5, 0, 0], [10, 0, 0], [10, 5, 0], [5, 5, 0]])
99
+
100
+ const obs = reTesselateCoplanarPolygons([poly1, poly2])
101
+
102
+ // Should merge into a single rectangle
103
+ t.is(obs.length, 1)
104
+ t.is(obs[0].vertices.length, 4) // rectangle has 4 vertices
105
+ })
@@ -22,8 +22,11 @@ const retessellate = (geometry) => {
22
22
  const destPolygons = []
23
23
  classified.forEach((group) => {
24
24
  if (Array.isArray(group)) {
25
- const reTessellateCoplanarPolygons = reTesselateCoplanarPolygons(group)
26
- destPolygons.push(...reTessellateCoplanarPolygons)
25
+ const coplanarPolygons = reTesselateCoplanarPolygons(group)
26
+ // Use loop instead of spread to avoid stack overflow with large arrays
27
+ for (let i = 0; i < coplanarPolygons.length; i++) {
28
+ destPolygons.push(coplanarPolygons[i])
29
+ }
27
30
  } else {
28
31
  destPolygons.push(group)
29
32
  }
@@ -25,31 +25,40 @@ test('snap: snap of a path2 produces an expected path2', (t) => {
25
25
 
26
26
  pts = path2.toPoints(results[1])
27
27
  exp = [
28
- [0.5, 0], [0.383022221559489, 0.3213938048432696],
29
- [0.08682408883346521, 0.492403876506104], [-0.2499999999999999, 0.43301270189221935],
30
- [-0.46984631039295416, 0.17101007166283444], [-0.4698463103929542, -0.17101007166283433],
31
- [-0.2500000000000002, -0.43301270189221924], [0.08682408883346499, -0.49240387650610407],
32
- [0.3830222215594889, -0.3213938048432698]
28
+ [ 0.5, 0 ],
29
+ [ 0.35355000000000003, 0.35355000000000003 ],
30
+ [ 0, 0.5 ],
31
+ [ -0.35355000000000003, 0.35355000000000003 ],
32
+ [ -0.5, 0 ],
33
+ [ -0.35355000000000003, -0.35355000000000003 ],
34
+ [ 0, -0.5 ],
35
+ [ 0.35355000000000003, -0.35355000000000003 ]
33
36
  ]
34
37
  t.true(comparePoints(pts, exp))
35
38
 
36
39
  pts = path2.toPoints(results[2])
37
40
  exp = [
38
- [0.6666666666666666, 0], [0.5106962954126519, 0.4285250731243595],
39
- [0.11576545177795361, 0.6565385020081387], [-0.33333333333333315, 0.5773502691896257],
40
- [-0.6264617471906055, 0.22801342888377923], [-0.6264617471906055, -0.2280134288837791],
41
- [-0.3333333333333336, -0.5773502691896256], [0.1157654517779533, -0.6565385020081387],
42
- [0.5106962954126518, -0.4285250731243597]
41
+ [ 0.6666666666666666, 0 ],
42
+ [ 0.4714, 0.4714 ],
43
+ [ 0, 0.6666666666666666 ],
44
+ [ -0.4714, 0.4714 ],
45
+ [ -0.6666666666666666, 0 ],
46
+ [ -0.4714, -0.4714 ],
47
+ [ 0, -0.6666666666666666 ],
48
+ [ 0.4714, -0.4714 ]
43
49
  ]
44
50
  t.true(comparePoints(pts, exp))
45
51
 
46
52
  pts = path2.toPoints(results[3])
47
53
  exp = [
48
- [1570.7979271820118, 0], [1203.3061290889411, 1009.6890116376164],
49
- [272.7710864950155, 1546.9412033856784], [-785.3989635910059, 1360.3552181729126],
50
- [-1476.0772155839566, 537.2521917480618], [-1476.0772155839566, -537.2521917480618],
51
- [-785.3989635910059, -1360.3552181729126], [272.7710864950155, -1546.9412033856784],
52
- [1203.3061290889411, -1009.6890116376164]
54
+ [ 1570.7963267948967, 0 ],
55
+ [ 1110.7100826766714, 1110.7100826766714 ],
56
+ [ 0, 1570.7963267948967 ],
57
+ [ -1110.7100826766714, 1110.7100826766714 ],
58
+ [ -1570.7963267948967, 0 ],
59
+ [ -1110.7100826766714, -1110.7100826766714 ],
60
+ [ 0, -1570.7963267948967 ],
61
+ [ 1110.7100826766714, -1110.7100826766714 ]
53
62
  ]
54
63
  t.true(comparePoints(pts, exp))
55
64
  })