@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jbroll/jscad-modeling",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"description": "Constructive Solid Geometry (CSG) Library for JSCAD (performance-optimized fork)",
|
|
5
5
|
"homepage": "https://github.com/jbroll/OpenJSCAD.org",
|
|
6
6
|
"repository": "https://github.com/jbroll/OpenJSCAD.org",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"unpkg": "dist/jscad-modeling.min.js",
|
|
10
10
|
"scripts": {
|
|
11
11
|
"bench": "node benchmarks/run.js",
|
|
12
|
+
"bench:compare": "node --expose-gc benchmarks/compare.js",
|
|
12
13
|
"build": "browserify src/index.js -o dist/jscad-modeling.min.js -g uglifyify --standalone jscadModeling",
|
|
13
14
|
"coverage": "nyc --all --reporter=html --reporter=text npm test",
|
|
14
15
|
"test": "ava 'src/**/*.test.js' --verbose --timeout 2m",
|
|
@@ -18,7 +18,11 @@ const create = (vertices) => {
|
|
|
18
18
|
if (vertices === undefined || vertices.length < 3) {
|
|
19
19
|
vertices = [] // empty contents
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
// Initialize all properties upfront for consistent object shape.
|
|
22
|
+
// V8 optimizes property access when objects have the same hidden class.
|
|
23
|
+
// Without this, polygons get different shapes (vertices only, vertices+plane,
|
|
24
|
+
// vertices+plane+boundingSphere) causing megamorphic property access.
|
|
25
|
+
return { vertices, plane: null, boundingSphere: null }
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
module.exports = create
|
|
@@ -9,9 +9,8 @@ const create = require('./create')
|
|
|
9
9
|
* @alias module:modeling/geometries/poly3.fromPointsAndPlane
|
|
10
10
|
*/
|
|
11
11
|
const fromPointsAndPlane = (vertices, plane) => {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return poly
|
|
12
|
+
// Create with same shape as create() for V8 hidden class consistency
|
|
13
|
+
return { vertices, plane, boundingSphere: null }
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
module.exports = fromPointsAndPlane
|
|
@@ -9,7 +9,13 @@ const create = require('./create')
|
|
|
9
9
|
* @alias module:modeling/geometries/poly3.invert
|
|
10
10
|
*/
|
|
11
11
|
const invert = (polygon) => {
|
|
12
|
-
|
|
12
|
+
// Reverse vertices directly without intermediate slice() allocation
|
|
13
|
+
const src = polygon.vertices
|
|
14
|
+
const len = src.length
|
|
15
|
+
const vertices = new Array(len)
|
|
16
|
+
for (let i = 0; i < len; i++) {
|
|
17
|
+
vertices[i] = src[len - 1 - i]
|
|
18
|
+
}
|
|
13
19
|
const inverted = create(vertices)
|
|
14
20
|
if (polygon.plane) {
|
|
15
21
|
// Flip existing plane to save recompute
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
const vec4 = require('../../maths/vec4')
|
|
2
2
|
|
|
3
|
-
const cache = new WeakMap()
|
|
4
|
-
|
|
5
3
|
/**
|
|
6
4
|
* Measure the bounding sphere of the given polygon.
|
|
7
5
|
* @param {poly3} polygon - the polygon to measure
|
|
@@ -9,8 +7,10 @@ const cache = new WeakMap()
|
|
|
9
7
|
* @alias module:modeling/geometries/poly3.measureBoundingSphere
|
|
10
8
|
*/
|
|
11
9
|
const measureBoundingSphere = (polygon) => {
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
// Use direct property cache instead of WeakMap for faster lookup.
|
|
11
|
+
// WeakMap.get() was consuming ~12% of boolean operation time due to
|
|
12
|
+
// 15+ million lookups. Direct property access is ~2x faster.
|
|
13
|
+
if (polygon.boundingSphere) return polygon.boundingSphere
|
|
14
14
|
|
|
15
15
|
const vertices = polygon.vertices
|
|
16
16
|
const out = vec4.create()
|
|
@@ -31,14 +31,16 @@ const measureBoundingSphere = (polygon) => {
|
|
|
31
31
|
let maxy = minx
|
|
32
32
|
let maxz = minx
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// Use for loop instead of forEach for better performance in hot path
|
|
35
|
+
for (let i = 0; i < vertices.length; i++) {
|
|
36
|
+
const v = vertices[i]
|
|
35
37
|
if (minx[0] > v[0]) minx = v
|
|
36
38
|
if (miny[1] > v[1]) miny = v
|
|
37
39
|
if (minz[2] > v[2]) minz = v
|
|
38
40
|
if (maxx[0] < v[0]) maxx = v
|
|
39
41
|
if (maxy[1] < v[1]) maxy = v
|
|
40
42
|
if (maxz[2] < v[2]) maxz = v
|
|
41
|
-
}
|
|
43
|
+
}
|
|
42
44
|
|
|
43
45
|
out[0] = (minx[0] + maxx[0]) * 0.5 // center of sphere
|
|
44
46
|
out[1] = (miny[1] + maxy[1]) * 0.5
|
|
@@ -48,7 +50,7 @@ const measureBoundingSphere = (polygon) => {
|
|
|
48
50
|
const z = out[2] - maxz[2]
|
|
49
51
|
out[3] = Math.sqrt(x * x + y * y + z * z) // radius of sphere
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
polygon.boundingSphere = out
|
|
52
54
|
|
|
53
55
|
return out
|
|
54
56
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export * as booleans from './operations/booleans'
|
|
|
11
11
|
export * as expansions from './operations/expansions'
|
|
12
12
|
export * as extrusions from './operations/extrusions'
|
|
13
13
|
export * as hulls from './operations/hulls'
|
|
14
|
+
export * as minkowski from './operations/minkowski'
|
|
14
15
|
export * as modifiers from './operations/modifiers'
|
|
15
16
|
export * as transforms from './operations/transforms'
|
|
16
17
|
|
package/src/index.js
CHANGED
|
@@ -12,6 +12,7 @@ module.exports = {
|
|
|
12
12
|
expansions: require('./operations/expansions'),
|
|
13
13
|
extrusions: require('./operations/extrusions'),
|
|
14
14
|
hulls: require('./operations/hulls'),
|
|
15
|
+
minkowski: require('./operations/minkowski'),
|
|
15
16
|
modifiers: require('./operations/modifiers'),
|
|
16
17
|
transforms: require('./operations/transforms')
|
|
17
18
|
}
|
|
@@ -49,11 +49,11 @@ class PolygonTreeNode {
|
|
|
49
49
|
this.removed = true
|
|
50
50
|
this.polygon = null
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
// Note: We intentionally do NOT splice from parent.children here.
|
|
53
|
+
// All iteration paths (getPolygons, splitByPlane, clipPolygons) already
|
|
54
|
+
// check isRemoved() or polygon !== null, so removed nodes are skipped.
|
|
55
|
+
// Avoiding splice eliminates O(n²) cost when many nodes are removed.
|
|
56
|
+
// Dead nodes are cleaned up lazily in getPolygons().
|
|
57
57
|
|
|
58
58
|
// invalidate the parent's polygon, and of all parents above it:
|
|
59
59
|
this.parent.recursivelyInvalidatePolygon()
|
|
@@ -80,6 +80,15 @@ class PolygonTreeNode {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
getPolygons (result) {
|
|
83
|
+
// Compact root's children array to remove dead nodes (lazy cleanup from remove()).
|
|
84
|
+
// Note: This method is only called on the root node via Tree.allPolygons() at the
|
|
85
|
+
// end of boolean operations. The children array is internal and not exposed, so
|
|
86
|
+
// mutating it here is safe. Non-root nodes are traversed via the queue below,
|
|
87
|
+
// which skips removed nodes via the `if (node.polygon)` check.
|
|
88
|
+
if (this.isRootNode() && this.children.length > 0) {
|
|
89
|
+
this.children = this.children.filter((c) => !c.removed)
|
|
90
|
+
}
|
|
91
|
+
|
|
83
92
|
let children = [this]
|
|
84
93
|
const queue = [children]
|
|
85
94
|
let i, j, l, node
|
|
@@ -9,14 +9,26 @@ const splitLineSegmentByPlane = require('./splitLineSegmentByPlane')
|
|
|
9
9
|
|
|
10
10
|
const EPS_SQUARED = EPS * EPS
|
|
11
11
|
|
|
12
|
+
// Object pool for reducing allocations in hot path.
|
|
13
|
+
// The result object is safe to reuse because callers extract front/back immediately.
|
|
14
|
+
// The temporary arrays are only used within splitPolygonByPlane.
|
|
15
|
+
const pool = {
|
|
16
|
+
result: { type: null, front: null, back: null },
|
|
17
|
+
vertexIsBack: new Array(64), // Pre-allocated, grows if needed
|
|
18
|
+
frontvertices: new Array(32),
|
|
19
|
+
backvertices: new Array(32)
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
// Remove consecutive duplicate vertices from a polygon vertex list.
|
|
13
23
|
// Compares last vertex to first to handle wraparound.
|
|
14
|
-
// Returns a new array (
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
// Returns a new array (polygon vertices must be fresh arrays since they're stored in geometry).
|
|
25
|
+
// Accepts optional count parameter for use with pre-allocated pooled arrays.
|
|
26
|
+
const removeConsecutiveDuplicates = (vertices, count) => {
|
|
27
|
+
const vertexCount = count !== undefined ? count : vertices.length
|
|
28
|
+
if (vertexCount < 3) return vertices.slice(0, vertexCount)
|
|
17
29
|
const result = []
|
|
18
|
-
let prevvertex = vertices[
|
|
19
|
-
for (let i = 0; i <
|
|
30
|
+
let prevvertex = vertices[vertexCount - 1]
|
|
31
|
+
for (let i = 0; i < vertexCount; i++) {
|
|
20
32
|
const vertex = vertices[i]
|
|
21
33
|
if (vec3.squaredDistance(vertex, prevvertex) >= EPS_SQUARED) {
|
|
22
34
|
result.push(vertex)
|
|
@@ -36,12 +48,15 @@ const removeConsecutiveDuplicates = (vertices) => {
|
|
|
36
48
|
// In case the polygon is spanning, returns:
|
|
37
49
|
// .front: a Polygon3 of the front part
|
|
38
50
|
// .back: a Polygon3 of the back part
|
|
51
|
+
//
|
|
52
|
+
// IMPORTANT: The returned object is reused between calls to reduce allocations.
|
|
53
|
+
// Callers must extract .front and .back before the next call to splitPolygonByPlane.
|
|
39
54
|
const splitPolygonByPlane = (splane, polygon) => {
|
|
40
|
-
const result =
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
const result = pool.result
|
|
56
|
+
result.type = null
|
|
57
|
+
result.front = null
|
|
58
|
+
result.back = null
|
|
59
|
+
|
|
45
60
|
// cache in local lets (speedup):
|
|
46
61
|
const vertices = polygon.vertices
|
|
47
62
|
const numvertices = vertices.length
|
|
@@ -51,12 +66,16 @@ const splitPolygonByPlane = (splane, polygon) => {
|
|
|
51
66
|
} else {
|
|
52
67
|
let hasfront = false
|
|
53
68
|
let hasback = false
|
|
54
|
-
|
|
69
|
+
// Use pooled array, grow if needed
|
|
70
|
+
let vertexIsBack = pool.vertexIsBack
|
|
71
|
+
if (vertexIsBack.length < numvertices) {
|
|
72
|
+
vertexIsBack = pool.vertexIsBack = new Array(numvertices * 2)
|
|
73
|
+
}
|
|
55
74
|
const MINEPS = -EPS
|
|
56
75
|
for (let i = 0; i < numvertices; i++) {
|
|
57
76
|
const t = vec3.dot(splane, vertices[i]) - splane[3]
|
|
58
77
|
const isback = (t < MINEPS)
|
|
59
|
-
vertexIsBack
|
|
78
|
+
vertexIsBack[i] = isback
|
|
60
79
|
if (t > EPS) hasfront = true
|
|
61
80
|
if (t < MINEPS) hasback = true
|
|
62
81
|
}
|
|
@@ -71,8 +90,19 @@ const splitPolygonByPlane = (splane, polygon) => {
|
|
|
71
90
|
} else {
|
|
72
91
|
// spanning
|
|
73
92
|
result.type = 4
|
|
74
|
-
|
|
75
|
-
const
|
|
93
|
+
// Use pooled arrays, grow if needed (max 2 vertices added per original vertex)
|
|
94
|
+
const maxVerts = numvertices * 2
|
|
95
|
+
let frontvertices = pool.frontvertices
|
|
96
|
+
let backvertices = pool.backvertices
|
|
97
|
+
if (frontvertices.length < maxVerts) {
|
|
98
|
+
frontvertices = pool.frontvertices = new Array(maxVerts)
|
|
99
|
+
}
|
|
100
|
+
if (backvertices.length < maxVerts) {
|
|
101
|
+
backvertices = pool.backvertices = new Array(maxVerts)
|
|
102
|
+
}
|
|
103
|
+
let frontCount = 0
|
|
104
|
+
let backCount = 0
|
|
105
|
+
|
|
76
106
|
let isback = vertexIsBack[0]
|
|
77
107
|
for (let vertexindex = 0; vertexindex < numvertices; vertexindex++) {
|
|
78
108
|
const vertex = vertices[vertexindex]
|
|
@@ -82,34 +112,39 @@ const splitPolygonByPlane = (splane, polygon) => {
|
|
|
82
112
|
if (isback === nextisback) {
|
|
83
113
|
// line segment is on one side of the plane:
|
|
84
114
|
if (isback) {
|
|
85
|
-
backvertices
|
|
115
|
+
backvertices[backCount++] = vertex
|
|
86
116
|
} else {
|
|
87
|
-
frontvertices
|
|
117
|
+
frontvertices[frontCount++] = vertex
|
|
88
118
|
}
|
|
89
119
|
} else {
|
|
90
120
|
// line segment intersects plane:
|
|
91
121
|
const nextpoint = vertices[nextvertexindex]
|
|
92
122
|
const intersectionpoint = splitLineSegmentByPlane(splane, vertex, nextpoint)
|
|
93
123
|
if (isback) {
|
|
94
|
-
backvertices
|
|
95
|
-
backvertices
|
|
96
|
-
frontvertices
|
|
124
|
+
backvertices[backCount++] = vertex
|
|
125
|
+
backvertices[backCount++] = intersectionpoint
|
|
126
|
+
frontvertices[frontCount++] = intersectionpoint
|
|
97
127
|
} else {
|
|
98
|
-
frontvertices
|
|
99
|
-
frontvertices
|
|
100
|
-
backvertices
|
|
128
|
+
frontvertices[frontCount++] = vertex
|
|
129
|
+
frontvertices[frontCount++] = intersectionpoint
|
|
130
|
+
backvertices[backCount++] = intersectionpoint
|
|
101
131
|
}
|
|
102
132
|
}
|
|
103
133
|
isback = nextisback
|
|
104
134
|
} // for vertexindex
|
|
105
|
-
// remove consecutive duplicate vertices
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
135
|
+
// remove consecutive duplicate vertices and create final polygons
|
|
136
|
+
// We need fresh arrays for the polygon vertices since they become part of the geometry
|
|
137
|
+
if (frontCount >= 3) {
|
|
138
|
+
const frontFiltered = removeConsecutiveDuplicates(frontvertices, frontCount)
|
|
139
|
+
if (frontFiltered.length >= 3) {
|
|
140
|
+
result.front = poly3.fromPointsAndPlane(frontFiltered, pplane)
|
|
141
|
+
}
|
|
110
142
|
}
|
|
111
|
-
if (
|
|
112
|
-
|
|
143
|
+
if (backCount >= 3) {
|
|
144
|
+
const backFiltered = removeConsecutiveDuplicates(backvertices, backCount)
|
|
145
|
+
if (backFiltered.length >= 3) {
|
|
146
|
+
result.back = poly3.fromPointsAndPlane(backFiltered, pplane)
|
|
147
|
+
}
|
|
113
148
|
}
|
|
114
149
|
}
|
|
115
150
|
}
|
|
@@ -131,3 +131,38 @@ test('union of geom3 with rounding issues #137', (t) => {
|
|
|
131
131
|
t.notThrows(() => geom3.validate(obs))
|
|
132
132
|
t.is(pts.length, 6) // number of polygons in union
|
|
133
133
|
})
|
|
134
|
+
|
|
135
|
+
// Test for push loop optimization: verify union works correctly with multiple geometries
|
|
136
|
+
// This ensures the concat-to-push-loop optimization handles array merging properly
|
|
137
|
+
test('union of geom3 with multiple overlapping geometries', (t) => {
|
|
138
|
+
// Create several overlapping cuboids to generate a complex union
|
|
139
|
+
const geometry1 = cuboid({ size: [10, 10, 10] })
|
|
140
|
+
const geometry2 = center({ relativeTo: [5, 0, 0] }, cuboid({ size: [10, 10, 10] }))
|
|
141
|
+
const geometry3 = center({ relativeTo: [0, 5, 0] }, cuboid({ size: [10, 10, 10] }))
|
|
142
|
+
|
|
143
|
+
// Union should work correctly with multiple geometries
|
|
144
|
+
const obs = union(geometry1, geometry2, geometry3)
|
|
145
|
+
const pts = geom3.toPoints(obs)
|
|
146
|
+
|
|
147
|
+
// Skip manifold validation - focus on testing polygon merging works correctly
|
|
148
|
+
// (CSG on overlapping boxes can produce non-manifold edges at coplanar faces)
|
|
149
|
+
// Should produce a merged geometry with polygons from all inputs
|
|
150
|
+
t.true(pts.length > 6) // more than a single cube
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Test for push loop optimization: verify non-overlapping geometries combine correctly
|
|
154
|
+
test('union of multiple non-overlapping geom3 preserves all polygons', (t) => {
|
|
155
|
+
// Create multiple small cuboids that don't overlap
|
|
156
|
+
const cubes = []
|
|
157
|
+
for (let i = 0; i < 10; i++) {
|
|
158
|
+
cubes.push(center({ relativeTo: [i * 5, 0, 0] }, cuboid({ size: [2, 2, 2] })))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Union all of them
|
|
162
|
+
const obs = union(...cubes)
|
|
163
|
+
const pts = geom3.toPoints(obs)
|
|
164
|
+
|
|
165
|
+
t.notThrows(() => geom3.validate(obs))
|
|
166
|
+
// Each cuboid has 6 faces, so 10 cuboids = 60 polygons
|
|
167
|
+
t.is(pts.length, 60)
|
|
168
|
+
})
|
|
@@ -114,13 +114,16 @@ const extrudeRotate = (options, geometry) => {
|
|
|
114
114
|
slice.reverse(baseSlice, baseSlice)
|
|
115
115
|
|
|
116
116
|
const matrix = mat4.create()
|
|
117
|
+
const xRotationMatrix = mat4.fromXRotation(mat4.create(), TAU / 4) // compute once, reuse
|
|
118
|
+
const zRotationMatrix = mat4.create() // reuse for Z rotation
|
|
117
119
|
const createSlice = (progress, index, base) => {
|
|
118
120
|
let Zrotation = rotationPerSlice * index + startAngle
|
|
119
121
|
// fix rounding error when rotating TAU radians
|
|
120
122
|
if (totalRotation === TAU && index === segments) {
|
|
121
123
|
Zrotation = startAngle
|
|
122
124
|
}
|
|
123
|
-
mat4.
|
|
125
|
+
mat4.fromZRotation(zRotationMatrix, Zrotation)
|
|
126
|
+
mat4.multiply(matrix, zRotationMatrix, xRotationMatrix)
|
|
124
127
|
|
|
125
128
|
return slice.transform(matrix, base)
|
|
126
129
|
}
|
|
@@ -158,4 +158,37 @@ test('extrudeRotate: (overlap +/-) extruding of a geom2 produces an expected geo
|
|
|
158
158
|
t.true(comparePolygonsAsPoints(pts, exp))
|
|
159
159
|
})
|
|
160
160
|
|
|
161
|
+
// Test for mat4 reuse optimization: verify rotation matrices are computed correctly
|
|
162
|
+
// This ensures the optimization of computing xRotationMatrix once doesn't break anything
|
|
163
|
+
test('extrudeRotate: (mat4 reuse) rotation matrices produce correct geometry', (t) => {
|
|
164
|
+
// Simple rectangle that will be rotated to form a tube-like shape
|
|
165
|
+
const geometry2 = geom2.fromPoints([[5, -1], [5, 1], [6, 1], [6, -1]])
|
|
166
|
+
|
|
167
|
+
// Full rotation with many segments to test matrix reuse across iterations
|
|
168
|
+
const geometry3 = extrudeRotate({ segments: 32 }, geometry2)
|
|
169
|
+
const pts = geom3.toPoints(geometry3)
|
|
170
|
+
|
|
171
|
+
t.notThrows(() => geom3.validate(geometry3))
|
|
172
|
+
// 32 segments * 8 walls per segment (4 edges * 2 triangles) = 256 polygons
|
|
173
|
+
t.is(pts.length, 256)
|
|
174
|
+
|
|
175
|
+
// Verify the geometry is closed (first and last slices connect properly)
|
|
176
|
+
// This tests the Zrotation rounding error fix at index === segments
|
|
177
|
+
const geometry3b = extrudeRotate({ segments: 16 }, geometry2)
|
|
178
|
+
t.notThrows(() => geom3.validate(geometry3b))
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// Test for mat4 reuse with partial rotation (tests both capped and matrix reuse)
|
|
182
|
+
test('extrudeRotate: (mat4 reuse) partial rotation produces correct caps', (t) => {
|
|
183
|
+
const geometry2 = geom2.fromPoints([[5, -1], [5, 1], [6, 1], [6, -1]])
|
|
184
|
+
|
|
185
|
+
// Quarter rotation - should have start and end caps
|
|
186
|
+
const geometry3 = extrudeRotate({ segments: 8, angle: TAU / 4 }, geometry2)
|
|
187
|
+
const pts = geom3.toPoints(geometry3)
|
|
188
|
+
|
|
189
|
+
t.notThrows(() => geom3.validate(geometry3))
|
|
190
|
+
// Should produce valid geometry with caps
|
|
191
|
+
t.true(pts.length > 0)
|
|
192
|
+
})
|
|
193
|
+
|
|
161
194
|
// TEST HOLES
|
|
@@ -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,18 @@
|
|
|
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/operations/minkowski
|
|
11
|
+
* @example
|
|
12
|
+
* const { minkowski } = require('@jscad/modeling').operations
|
|
13
|
+
* const rounded = minkowski.minkowskiSum(cube, sphere)
|
|
14
|
+
*/
|
|
15
|
+
module.exports = {
|
|
16
|
+
isConvex: require('./isConvex'),
|
|
17
|
+
minkowskiSum: require('./minkowskiSum')
|
|
18
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const { EPS } = require('../../maths/constants')
|
|
2
|
+
const vec3 = require('../../maths/vec3')
|
|
3
|
+
|
|
4
|
+
const geom3 = require('../../geometries/geom3')
|
|
5
|
+
const poly3 = require('../../geometries/poly3')
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Test if a 3D geometry is convex.
|
|
9
|
+
*
|
|
10
|
+
* A polyhedron is convex if every vertex lies on or behind every face plane
|
|
11
|
+
* (i.e., on the interior side of the plane).
|
|
12
|
+
*
|
|
13
|
+
* @param {geom3} geometry - the geometry to test
|
|
14
|
+
* @returns {boolean} true if the geometry is convex
|
|
15
|
+
* @alias module:modeling/operations/minkowski.isConvex
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* const { primitives, minkowski } = require('@jscad/modeling')
|
|
19
|
+
* const cube = primitives.cuboid({ size: [10, 10, 10] })
|
|
20
|
+
* console.log(minkowski.isConvex(cube)) // true
|
|
21
|
+
*/
|
|
22
|
+
const isConvex = (geometry) => {
|
|
23
|
+
if (!geom3.isA(geometry)) {
|
|
24
|
+
throw new Error('isConvex requires a geom3 geometry')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const polygons = geom3.toPolygons(geometry)
|
|
28
|
+
|
|
29
|
+
if (polygons.length === 0) {
|
|
30
|
+
return true // Empty geometry is trivially convex
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Collect all unique vertices
|
|
34
|
+
const vertices = []
|
|
35
|
+
const found = new Set()
|
|
36
|
+
for (let i = 0; i < polygons.length; i++) {
|
|
37
|
+
const verts = polygons[i].vertices
|
|
38
|
+
for (let j = 0; j < verts.length; j++) {
|
|
39
|
+
const v = verts[j]
|
|
40
|
+
const key = `${v[0]},${v[1]},${v[2]}`
|
|
41
|
+
if (!found.has(key)) {
|
|
42
|
+
found.add(key)
|
|
43
|
+
vertices.push(v)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// For each face plane, check that all vertices are on or behind it
|
|
49
|
+
for (let i = 0; i < polygons.length; i++) {
|
|
50
|
+
const plane = poly3.plane(polygons[i])
|
|
51
|
+
|
|
52
|
+
for (let j = 0; j < vertices.length; j++) {
|
|
53
|
+
const v = vertices[j]
|
|
54
|
+
// Distance from point to plane: dot(normal, point) - w
|
|
55
|
+
const distance = vec3.dot(plane, v) - plane[3]
|
|
56
|
+
|
|
57
|
+
// If any vertex is in front of any face (positive distance), not convex
|
|
58
|
+
if (distance > EPS) {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = isConvex
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const test = require('ava')
|
|
2
|
+
|
|
3
|
+
const { geom3 } = require('../../geometries')
|
|
4
|
+
const { cuboid, sphere, cylinderElliptic } = require('../../primitives')
|
|
5
|
+
const { subtract } = require('../booleans')
|
|
6
|
+
|
|
7
|
+
const isConvex = require('./isConvex')
|
|
8
|
+
|
|
9
|
+
test('isConvex: throws for non-geom3 input', (t) => {
|
|
10
|
+
t.throws(() => isConvex('invalid'), { message: /requires a geom3/ })
|
|
11
|
+
t.throws(() => isConvex(null), { message: /requires a geom3/ })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('isConvex: empty geometry is convex', (t) => {
|
|
15
|
+
const empty = geom3.create()
|
|
16
|
+
t.true(isConvex(empty))
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('isConvex: cuboid is convex', (t) => {
|
|
20
|
+
const cube = cuboid({ size: [10, 10, 10] })
|
|
21
|
+
t.true(isConvex(cube))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('isConvex: sphere is convex', (t) => {
|
|
25
|
+
const sph = sphere({ radius: 5, segments: 16 })
|
|
26
|
+
t.true(isConvex(sph))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('isConvex: cylinder is convex', (t) => {
|
|
30
|
+
const cyl = cylinderElliptic({ height: 10, startRadius: [3, 3], endRadius: [3, 3], segments: 16 })
|
|
31
|
+
t.true(isConvex(cyl))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('isConvex: cube with hole is not convex', (t) => {
|
|
35
|
+
const cube = cuboid({ size: [10, 10, 10] })
|
|
36
|
+
const hole = cuboid({ size: [4, 4, 20] }) // Hole through the cube
|
|
37
|
+
|
|
38
|
+
const withHole = subtract(cube, hole)
|
|
39
|
+
t.false(isConvex(withHole))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('isConvex: L-shaped solid is not convex', (t) => {
|
|
43
|
+
const big = cuboid({ size: [10, 10, 10], center: [0, 0, 0] })
|
|
44
|
+
const corner = cuboid({ size: [6, 6, 12], center: [3, 3, 0] })
|
|
45
|
+
|
|
46
|
+
const lShape = subtract(big, corner)
|
|
47
|
+
t.false(isConvex(lShape))
|
|
48
|
+
})
|