@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.
- package/CHANGELOG.md +12 -299
- package/bench/booleans.bench.js +103 -0
- package/bench/primitives.bench.js +108 -0
- package/dist/jscad-modeling.min.js +404 -395
- package/package.json +2 -2
- package/src/geometries/geom3/index.d.ts +1 -0
- package/src/geometries/geom3/index.js +1 -0
- package/src/geometries/geom3/isConvex.d.ts +3 -0
- package/src/geometries/geom3/isConvex.js +68 -0
- package/src/geometries/geom3/isConvex.test.js +45 -0
- package/src/geometries/path2/appendArc.js +1 -1
- package/src/geometries/path2/appendArc.test.js +16 -20
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
- package/src/operations/booleans/index.d.ts +1 -0
- package/src/operations/booleans/index.js +1 -0
- package/src/operations/booleans/trees/PolygonTreeNode.js +18 -5
- package/src/operations/booleans/trees/splitPolygonByPlane.js +27 -25
- package/src/operations/booleans/trees/splitPolygonByPlane.test.js +132 -0
- package/src/operations/booleans/unionGeom3.test.js +35 -0
- package/src/operations/extrusions/extrudeFromSlices.js +14 -4
- package/src/operations/extrusions/extrudeRectangular.test.js +3 -3
- package/src/operations/extrusions/extrudeRotate.js +4 -1
- package/src/operations/extrusions/extrudeRotate.test.js +33 -0
- package/src/operations/extrusions/extrudeWalls.js +2 -1
- package/src/operations/extrusions/extrudeWalls.test.js +72 -0
- package/src/operations/minkowski/index.d.ts +1 -0
- package/src/operations/minkowski/index.js +17 -0
- package/src/operations/minkowski/minkowskiSum.d.ts +4 -0
- package/src/operations/minkowski/minkowskiSum.js +224 -0
- package/src/operations/minkowski/minkowskiSum.test.js +195 -0
- package/src/operations/modifiers/reTesselateCoplanarPolygons.js +8 -3
- package/src/operations/modifiers/reTesselateCoplanarPolygons.test.js +36 -1
- package/src/operations/modifiers/retessellate.js +5 -2
- package/src/operations/modifiers/snap.test.js +24 -15
- package/src/primitives/arc.js +2 -2
- package/src/primitives/arc.test.js +122 -111
- package/src/utils/flatten.js +1 -1
- 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
|
-
|
|
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,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.
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
|
26
|
-
|
|
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
|
|
29
|
-
[
|
|
30
|
-
[
|
|
31
|
-
[
|
|
32
|
-
[0.
|
|
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
|
|
39
|
-
[
|
|
40
|
-
[
|
|
41
|
-
[
|
|
42
|
-
[0.
|
|
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.
|
|
49
|
-
[
|
|
50
|
-
[
|
|
51
|
-
[
|
|
52
|
-
[
|
|
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
|
})
|