@jbroll/jscad-modeling 2.12.7 → 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/bench/booleans.bench.js +103 -0
- package/bench/primitives.bench.js +108 -0
- package/bench/splitPolygon.bench.js +143 -0
- package/benchmarks/compare.js +673 -0
- package/benchmarks/memory-test.js +103 -0
- package/benchmarks/primitives.bench.js +83 -0
- package/benchmarks/run.js +37 -0
- package/benchmarks/workflows.bench.js +105 -0
- package/dist/jscad-modeling.min.js +116 -107
- package/isolate-0x1e680000-4181-v8.log +6077 -0
- package/package.json +2 -1
- package/src/geometries/poly3/create.js +5 -1
- package/src/geometries/poly3/create.test.js +1 -1
- package/src/geometries/poly3/fromPointsAndPlane.js +2 -3
- package/src/geometries/poly3/invert.js +7 -1
- package/src/geometries/poly3/measureBoundingSphere.js +9 -7
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
- package/src/operations/booleans/trees/PolygonTreeNode.js +14 -5
- package/src/operations/booleans/trees/splitPolygonByPlane.js +64 -29
- package/src/operations/booleans/unionGeom3.test.js +35 -0
- 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 +2 -0
- package/src/operations/minkowski/index.js +18 -0
- package/src/operations/minkowski/isConvex.d.ts +5 -0
- package/src/operations/minkowski/isConvex.js +67 -0
- package/src/operations/minkowski/isConvex.test.js +48 -0
- package/src/operations/minkowski/minkowskiSum.d.ts +6 -0
- package/src/operations/minkowski/minkowskiSum.js +223 -0
- package/src/operations/minkowski/minkowskiSum.test.js +161 -0
- package/src/operations/modifiers/reTesselateCoplanarPolygons.js +16 -13
- package/src/operations/modifiers/reTesselateCoplanarPolygons.test.js +36 -1
- package/src/operations/modifiers/retessellate.js +5 -2
|
@@ -0,0 +1,223 @@
|
|
|
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 union = require('../booleans/union')
|
|
8
|
+
|
|
9
|
+
const isConvex = require('./isConvex')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compute the Minkowski sum of two 3D geometries.
|
|
13
|
+
*
|
|
14
|
+
* The Minkowski sum A ⊕ B is the set of all points a + b where a ∈ A and b ∈ B.
|
|
15
|
+
* Geometrically, this "inflates" geometry A by the shape of geometry B.
|
|
16
|
+
*
|
|
17
|
+
* Common use cases:
|
|
18
|
+
* - Offset a solid by a sphere to round all edges and corners
|
|
19
|
+
* - Offset a solid by a cube to create chamfered edges
|
|
20
|
+
* - Collision detection (if Minkowski sum contains origin, shapes overlap)
|
|
21
|
+
*
|
|
22
|
+
* For best performance, use convex geometries. Non-convex geometries are supported
|
|
23
|
+
* when the second operand is convex, but require decomposition and are slower.
|
|
24
|
+
*
|
|
25
|
+
* @param {...Object} geometries - two geom3 geometries (second should be convex for non-convex first)
|
|
26
|
+
* @returns {geom3} new 3D geometry representing the Minkowski sum
|
|
27
|
+
* @alias module:modeling/operations/minkowski.minkowskiSum
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* const { primitives, minkowski } = require('@jscad/modeling')
|
|
31
|
+
* const cube = primitives.cuboid({ size: [10, 10, 10] })
|
|
32
|
+
* const sphere = primitives.sphere({ radius: 2, segments: 16 })
|
|
33
|
+
* const rounded = minkowski.minkowskiSum(cube, sphere)
|
|
34
|
+
*/
|
|
35
|
+
const minkowskiSum = (...geometries) => {
|
|
36
|
+
geometries = flatten(geometries)
|
|
37
|
+
|
|
38
|
+
if (geometries.length < 2) {
|
|
39
|
+
throw new Error('minkowskiSum requires at least two geometries')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (geometries.length > 2) {
|
|
43
|
+
throw new Error('minkowskiSum currently supports exactly two geometries')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const [geomA, geomB] = geometries
|
|
47
|
+
|
|
48
|
+
if (!geom3.isA(geomA) || !geom3.isA(geomB)) {
|
|
49
|
+
throw new Error('minkowskiSum requires geom3 geometries')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const aConvex = isConvex(geomA)
|
|
53
|
+
const bConvex = isConvex(geomB)
|
|
54
|
+
|
|
55
|
+
// Fast path: both convex
|
|
56
|
+
if (aConvex && bConvex) {
|
|
57
|
+
return minkowskiSumConvex(geomA, geomB)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Non-convex A + convex B: decompose A into tetrahedra
|
|
61
|
+
if (!aConvex && bConvex) {
|
|
62
|
+
return minkowskiSumNonConvexConvex(geomA, geomB)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Convex A + non-convex B: swap operands (Minkowski sum is commutative)
|
|
66
|
+
if (aConvex && !bConvex) {
|
|
67
|
+
return minkowskiSumNonConvexConvex(geomB, geomA)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Both non-convex: not yet supported
|
|
71
|
+
throw new Error('minkowskiSum of two non-convex geometries is not yet supported')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Compute Minkowski sum of non-convex A with convex B.
|
|
76
|
+
*
|
|
77
|
+
* Decomposes A into tetrahedra, computes Minkowski sum of each with B,
|
|
78
|
+
* then unions all results.
|
|
79
|
+
*/
|
|
80
|
+
const minkowskiSumNonConvexConvex = (geomA, geomB) => {
|
|
81
|
+
const tetrahedra = decomposeIntoTetrahedra(geomA)
|
|
82
|
+
|
|
83
|
+
if (tetrahedra.length === 0) {
|
|
84
|
+
return geom3.create()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Compute Minkowski sum for each tetrahedron
|
|
88
|
+
const parts = tetrahedra.map((tet) => minkowskiSumConvex(tet, geomB))
|
|
89
|
+
|
|
90
|
+
// Union all parts
|
|
91
|
+
if (parts.length === 1) {
|
|
92
|
+
return parts[0]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return union(parts)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Decompose a geom3 into tetrahedra using fan triangulation from centroid.
|
|
100
|
+
* Each resulting tetrahedron is guaranteed to be convex.
|
|
101
|
+
*/
|
|
102
|
+
const decomposeIntoTetrahedra = (geometry) => {
|
|
103
|
+
const polygons = geom3.toPolygons(geometry)
|
|
104
|
+
|
|
105
|
+
if (polygons.length === 0) {
|
|
106
|
+
return []
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Compute centroid of the geometry
|
|
110
|
+
const centroid = computeCentroid(geometry)
|
|
111
|
+
|
|
112
|
+
const tetrahedra = []
|
|
113
|
+
|
|
114
|
+
// For each polygon, create tetrahedra from centroid to each triangle
|
|
115
|
+
for (let i = 0; i < polygons.length; i++) {
|
|
116
|
+
const vertices = polygons[i].vertices
|
|
117
|
+
|
|
118
|
+
// Fan triangulate the polygon and create tetrahedra
|
|
119
|
+
for (let j = 1; j < vertices.length - 1; j++) {
|
|
120
|
+
const v0 = vertices[0]
|
|
121
|
+
const v1 = vertices[j]
|
|
122
|
+
const v2 = vertices[j + 1]
|
|
123
|
+
|
|
124
|
+
// Create tetrahedron from centroid and triangle
|
|
125
|
+
const tetPolygons = createTetrahedronPolygons(centroid, v0, v1, v2)
|
|
126
|
+
tetrahedra.push(geom3.create(tetPolygons))
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return tetrahedra
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Create the 4 triangular faces of a tetrahedron.
|
|
135
|
+
*/
|
|
136
|
+
const createTetrahedronPolygons = (p0, p1, p2, p3) => {
|
|
137
|
+
// Tetrahedron has 4 faces, each a triangle
|
|
138
|
+
// We need to ensure consistent winding (outward-facing normals)
|
|
139
|
+
return [
|
|
140
|
+
poly3.create([p0, p2, p1]), // base seen from p3
|
|
141
|
+
poly3.create([p0, p1, p3]), // face opposite p2
|
|
142
|
+
poly3.create([p1, p2, p3]), // face opposite p0
|
|
143
|
+
poly3.create([p2, p0, p3]) // face opposite p1
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Compute the centroid of a geom3.
|
|
149
|
+
*/
|
|
150
|
+
const computeCentroid = (geometry) => {
|
|
151
|
+
const vertices = extractUniqueVertices(geometry)
|
|
152
|
+
|
|
153
|
+
if (vertices.length === 0) {
|
|
154
|
+
return [0, 0, 0]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let x = 0, y = 0, z = 0
|
|
158
|
+
for (let i = 0; i < vertices.length; i++) {
|
|
159
|
+
x += vertices[i][0]
|
|
160
|
+
y += vertices[i][1]
|
|
161
|
+
z += vertices[i][2]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const n = vertices.length
|
|
165
|
+
return [x / n, y / n, z / n]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Compute Minkowski sum of two convex polyhedra.
|
|
170
|
+
*
|
|
171
|
+
* For convex polyhedra, the Minkowski sum equals the convex hull of
|
|
172
|
+
* all pairwise vertex sums. This is O(n*m) for n and m vertices,
|
|
173
|
+
* plus the cost of the convex hull algorithm.
|
|
174
|
+
*/
|
|
175
|
+
const minkowskiSumConvex = (geomA, geomB) => {
|
|
176
|
+
const pointsA = extractUniqueVertices(geomA)
|
|
177
|
+
const pointsB = extractUniqueVertices(geomB)
|
|
178
|
+
|
|
179
|
+
if (pointsA.length === 0 || pointsB.length === 0) {
|
|
180
|
+
return geom3.create()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Compute all pairwise sums
|
|
184
|
+
const summedPoints = []
|
|
185
|
+
for (let i = 0; i < pointsA.length; i++) {
|
|
186
|
+
const a = pointsA[i]
|
|
187
|
+
for (let j = 0; j < pointsB.length; j++) {
|
|
188
|
+
const b = pointsB[j]
|
|
189
|
+
summedPoints.push([a[0] + b[0], a[1] + b[1], a[2] + b[2]])
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Compute convex hull of the summed points
|
|
194
|
+
const hullPolygons = hullPoints3(summedPoints)
|
|
195
|
+
|
|
196
|
+
return geom3.create(hullPolygons)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Extract unique vertices from a geom3.
|
|
201
|
+
* Uses a Set with string keys for deduplication.
|
|
202
|
+
*/
|
|
203
|
+
const extractUniqueVertices = (geometry) => {
|
|
204
|
+
const found = new Set()
|
|
205
|
+
const unique = []
|
|
206
|
+
|
|
207
|
+
const polygons = geom3.toPolygons(geometry)
|
|
208
|
+
for (let i = 0; i < polygons.length; i++) {
|
|
209
|
+
const vertices = polygons[i].vertices
|
|
210
|
+
for (let j = 0; j < vertices.length; j++) {
|
|
211
|
+
const v = vertices[j]
|
|
212
|
+
const key = `${v[0]},${v[1]},${v[2]}`
|
|
213
|
+
if (!found.has(key)) {
|
|
214
|
+
found.add(key)
|
|
215
|
+
unique.push(v)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return unique
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = minkowskiSum
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
const test = require('ava')
|
|
2
|
+
|
|
3
|
+
const { geom3 } = require('../../geometries')
|
|
4
|
+
const { cuboid, sphere } = require('../../primitives')
|
|
5
|
+
const { measureBoundingBox } = require('../../measurements')
|
|
6
|
+
const { subtract } = require('../booleans')
|
|
7
|
+
|
|
8
|
+
const minkowskiSum = require('./minkowskiSum')
|
|
9
|
+
const isConvex = require('./isConvex')
|
|
10
|
+
|
|
11
|
+
test('minkowskiSum: throws for non-geom3 inputs', (t) => {
|
|
12
|
+
t.throws(() => minkowskiSum('invalid', cuboid()), { message: /requires geom3/ })
|
|
13
|
+
t.throws(() => minkowskiSum(cuboid(), 'invalid'), { message: /requires geom3/ })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('minkowskiSum: throws for less than two geometries', (t) => {
|
|
17
|
+
t.throws(() => minkowskiSum(), { message: /requires at least two/ })
|
|
18
|
+
t.throws(() => minkowskiSum(cuboid()), { message: /requires at least two/ })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('minkowskiSum: throws for more than two geometries', (t) => {
|
|
22
|
+
t.throws(() => minkowskiSum(cuboid(), cuboid(), cuboid()), { message: /exactly two/ })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('minkowskiSum: cube + cube produces correct bounds', (t) => {
|
|
26
|
+
// Cube1: size 10 (±5 from origin)
|
|
27
|
+
// Cube2: size 4 (±2 from origin)
|
|
28
|
+
// Minkowski sum should be size 14 (±7 from origin)
|
|
29
|
+
const cube1 = cuboid({ size: [10, 10, 10] })
|
|
30
|
+
const cube2 = cuboid({ size: [4, 4, 4] })
|
|
31
|
+
|
|
32
|
+
const result = minkowskiSum(cube1, cube2)
|
|
33
|
+
|
|
34
|
+
t.true(geom3.isA(result))
|
|
35
|
+
|
|
36
|
+
const bounds = measureBoundingBox(result)
|
|
37
|
+
// Allow small tolerance for floating point
|
|
38
|
+
t.true(Math.abs(bounds[0][0] - (-7)) < 0.001)
|
|
39
|
+
t.true(Math.abs(bounds[0][1] - (-7)) < 0.001)
|
|
40
|
+
t.true(Math.abs(bounds[0][2] - (-7)) < 0.001)
|
|
41
|
+
t.true(Math.abs(bounds[1][0] - 7) < 0.001)
|
|
42
|
+
t.true(Math.abs(bounds[1][1] - 7) < 0.001)
|
|
43
|
+
t.true(Math.abs(bounds[1][2] - 7) < 0.001)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('minkowskiSum: cube + sphere produces correct bounds', (t) => {
|
|
47
|
+
// Cube: size 10 (±5 from origin)
|
|
48
|
+
// Sphere: radius 2
|
|
49
|
+
// Minkowski sum should be ±7 from origin
|
|
50
|
+
const cube = cuboid({ size: [10, 10, 10] })
|
|
51
|
+
const sph = sphere({ radius: 2, segments: 16 })
|
|
52
|
+
|
|
53
|
+
const result = minkowskiSum(cube, sph)
|
|
54
|
+
|
|
55
|
+
t.true(geom3.isA(result))
|
|
56
|
+
|
|
57
|
+
const bounds = measureBoundingBox(result)
|
|
58
|
+
// Allow small tolerance
|
|
59
|
+
t.true(Math.abs(bounds[0][0] - (-7)) < 0.1)
|
|
60
|
+
t.true(Math.abs(bounds[1][0] - 7) < 0.1)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('minkowskiSum: sphere + sphere produces correct bounds', (t) => {
|
|
64
|
+
// Sphere1: radius 3
|
|
65
|
+
// Sphere2: radius 2
|
|
66
|
+
// Minkowski sum should be a sphere-like shape with radius ~5
|
|
67
|
+
const sph1 = sphere({ radius: 3, segments: 16 })
|
|
68
|
+
const sph2 = sphere({ radius: 2, segments: 16 })
|
|
69
|
+
|
|
70
|
+
const result = minkowskiSum(sph1, sph2)
|
|
71
|
+
|
|
72
|
+
t.true(geom3.isA(result))
|
|
73
|
+
|
|
74
|
+
const bounds = measureBoundingBox(result)
|
|
75
|
+
// Should be approximately ±5
|
|
76
|
+
t.true(Math.abs(bounds[0][0] - (-5)) < 0.2)
|
|
77
|
+
t.true(Math.abs(bounds[1][0] - 5) < 0.2)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('minkowskiSum: empty geometry returns empty', (t) => {
|
|
81
|
+
const empty = geom3.create()
|
|
82
|
+
const cube = cuboid({ size: [10, 10, 10] })
|
|
83
|
+
|
|
84
|
+
const result = minkowskiSum(empty, cube)
|
|
85
|
+
|
|
86
|
+
t.true(geom3.isA(result))
|
|
87
|
+
t.is(geom3.toPolygons(result).length, 0)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('minkowskiSum: result is convex', (t) => {
|
|
91
|
+
const cube = cuboid({ size: [10, 10, 10] })
|
|
92
|
+
const sph = sphere({ radius: 2, segments: 12 })
|
|
93
|
+
|
|
94
|
+
const result = minkowskiSum(cube, sph)
|
|
95
|
+
|
|
96
|
+
t.true(isConvex(result))
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// Non-convex tests
|
|
100
|
+
|
|
101
|
+
test('minkowskiSum: non-convex + convex produces valid geometry', (t) => {
|
|
102
|
+
// Create L-shaped non-convex geometry
|
|
103
|
+
const big = cuboid({ size: [10, 10, 10] })
|
|
104
|
+
const corner = cuboid({ size: [6, 6, 12], center: [3, 3, 0] })
|
|
105
|
+
const lShape = subtract(big, corner)
|
|
106
|
+
|
|
107
|
+
t.false(isConvex(lShape))
|
|
108
|
+
|
|
109
|
+
const sph = sphere({ radius: 1, segments: 8 })
|
|
110
|
+
|
|
111
|
+
const result = minkowskiSum(lShape, sph)
|
|
112
|
+
|
|
113
|
+
t.true(geom3.isA(result))
|
|
114
|
+
t.true(geom3.toPolygons(result).length > 0)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('minkowskiSum: non-convex + convex produces correct bounds', (t) => {
|
|
118
|
+
// Cube with hole through it
|
|
119
|
+
const cube = cuboid({ size: [10, 10, 10] })
|
|
120
|
+
const hole = cuboid({ size: [4, 4, 20] })
|
|
121
|
+
const cubeWithHole = subtract(cube, hole)
|
|
122
|
+
|
|
123
|
+
t.false(isConvex(cubeWithHole))
|
|
124
|
+
|
|
125
|
+
// Offset by sphere of radius 1
|
|
126
|
+
const sph = sphere({ radius: 1, segments: 8 })
|
|
127
|
+
const result = minkowskiSum(cubeWithHole, sph)
|
|
128
|
+
|
|
129
|
+
const bounds = measureBoundingBox(result)
|
|
130
|
+
|
|
131
|
+
// Original cube is ±5, plus sphere radius 1 = ±6
|
|
132
|
+
t.true(Math.abs(bounds[0][0] - (-6)) < 0.2)
|
|
133
|
+
t.true(Math.abs(bounds[1][0] - 6) < 0.2)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('minkowskiSum: convex + non-convex swaps operands', (t) => {
|
|
137
|
+
// Minkowski sum is commutative, so A⊕B = B⊕A
|
|
138
|
+
const cube = cuboid({ size: [10, 10, 10] })
|
|
139
|
+
const hole = cuboid({ size: [4, 4, 20] })
|
|
140
|
+
const cubeWithHole = subtract(cube, hole)
|
|
141
|
+
|
|
142
|
+
const sph = sphere({ radius: 1, segments: 8 })
|
|
143
|
+
|
|
144
|
+
// convex + non-convex should work (swaps internally)
|
|
145
|
+
const result = minkowskiSum(sph, cubeWithHole)
|
|
146
|
+
|
|
147
|
+
t.true(geom3.isA(result))
|
|
148
|
+
t.true(geom3.toPolygons(result).length > 0)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('minkowskiSum: throws for two non-convex geometries', (t) => {
|
|
152
|
+
const cube1 = cuboid({ size: [10, 10, 10] })
|
|
153
|
+
const hole1 = cuboid({ size: [4, 4, 20] })
|
|
154
|
+
const nonConvex1 = subtract(cube1, hole1)
|
|
155
|
+
|
|
156
|
+
const cube2 = cuboid({ size: [8, 8, 8] })
|
|
157
|
+
const hole2 = cuboid({ size: [3, 3, 16] })
|
|
158
|
+
const nonConvex2 = subtract(cube2, hole2)
|
|
159
|
+
|
|
160
|
+
t.throws(() => minkowskiSum(nonConvex1, nonConvex2), { message: /two non-convex/ })
|
|
161
|
+
})
|
|
@@ -40,7 +40,7 @@ const reTesselateCoplanarPolygons = (sourcepolygons) => {
|
|
|
40
40
|
let miny
|
|
41
41
|
let maxy
|
|
42
42
|
for (let i = 0; i < numvertices; i++) {
|
|
43
|
-
|
|
43
|
+
const pos2d = orthobasis.to2D(poly3d.vertices[i])
|
|
44
44
|
// perform binning of y coordinates: If we have multiple vertices very
|
|
45
45
|
// close to each other, give them the same y coordinate:
|
|
46
46
|
const ycoordinatebin = Math.floor(pos2d[1] * ycoordinateBinningFactor)
|
|
@@ -55,7 +55,8 @@ const reTesselateCoplanarPolygons = (sourcepolygons) => {
|
|
|
55
55
|
newy = pos2d[1]
|
|
56
56
|
ycoordinatebins.set(ycoordinatebin, pos2d[1])
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
// Modify y in place instead of creating new vec2
|
|
59
|
+
pos2d[1] = newy
|
|
59
60
|
vertices2d.push(pos2d)
|
|
60
61
|
const y = pos2d[1]
|
|
61
62
|
if ((i === 0) || (y < miny)) {
|
|
@@ -120,6 +121,7 @@ const reTesselateCoplanarPolygons = (sourcepolygons) => {
|
|
|
120
121
|
// at the the left and right side of the polygon
|
|
121
122
|
// Iterate over all polygons that have a corner at this y coordinate:
|
|
122
123
|
const polygonindexeswithcorner = ycoordinatetopolygonindexes.get(ycoordinate)
|
|
124
|
+
let removeCount = 0 // track removals to filter at end (avoids O(n²) splice)
|
|
123
125
|
for (let activepolygonindex = 0; activepolygonindex < activepolygons.length; ++activepolygonindex) {
|
|
124
126
|
const activepolygon = activepolygons[activepolygonindex]
|
|
125
127
|
const polygonindex = activepolygon.polygonindex
|
|
@@ -143,9 +145,9 @@ const reTesselateCoplanarPolygons = (sourcepolygons) => {
|
|
|
143
145
|
}
|
|
144
146
|
if ((newleftvertexindex !== activepolygon.leftvertexindex) && (newleftvertexindex === newrightvertexindex)) {
|
|
145
147
|
// 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
|
-
|
|
148
|
+
// This means that this is the bottom point of the polygon. Mark it for removal:
|
|
149
|
+
activepolygon._remove = true
|
|
150
|
+
removeCount++
|
|
149
151
|
} else {
|
|
150
152
|
activepolygon.leftvertexindex = newleftvertexindex
|
|
151
153
|
activepolygon.rightvertexindex = newrightvertexindex
|
|
@@ -160,6 +162,10 @@ const reTesselateCoplanarPolygons = (sourcepolygons) => {
|
|
|
160
162
|
}
|
|
161
163
|
} // if polygon has corner here
|
|
162
164
|
} // for activepolygonindex
|
|
165
|
+
// Filter out marked polygons in single pass (O(n) instead of O(n²) splice)
|
|
166
|
+
if (removeCount > 0) {
|
|
167
|
+
activepolygons = activepolygons.filter((p) => !p._remove)
|
|
168
|
+
}
|
|
163
169
|
let nextycoordinate
|
|
164
170
|
if (yindex >= ycoordinates.length - 1) {
|
|
165
171
|
// last row, all polygons must be finished here:
|
|
@@ -221,14 +227,11 @@ const reTesselateCoplanarPolygons = (sourcepolygons) => {
|
|
|
221
227
|
for (const activepolygonKey in activepolygons) {
|
|
222
228
|
const activepolygon = activepolygons[activepolygonKey]
|
|
223
229
|
|
|
224
|
-
|
|
225
|
-
const topleft =
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
const bottomleft = vec2.fromValues(x, nextycoordinate)
|
|
230
|
-
x = interpolateBetween2DPointsForY(activepolygon.topright, activepolygon.bottomright, nextycoordinate)
|
|
231
|
-
const bottomright = vec2.fromValues(x, nextycoordinate)
|
|
230
|
+
// Inline vec2 creation to avoid function call overhead
|
|
231
|
+
const topleft = [interpolateBetween2DPointsForY(activepolygon.topleft, activepolygon.bottomleft, ycoordinate), ycoordinate]
|
|
232
|
+
const topright = [interpolateBetween2DPointsForY(activepolygon.topright, activepolygon.bottomright, ycoordinate), ycoordinate]
|
|
233
|
+
const bottomleft = [interpolateBetween2DPointsForY(activepolygon.topleft, activepolygon.bottomleft, nextycoordinate), nextycoordinate]
|
|
234
|
+
const bottomright = [interpolateBetween2DPointsForY(activepolygon.topright, activepolygon.bottomright, nextycoordinate), nextycoordinate]
|
|
232
235
|
const outpolygon = {
|
|
233
236
|
topleft: topleft,
|
|
234
237
|
topright: topright,
|
|
@@ -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
|
}
|