@jbroll/jscad-modeling 2.12.8 → 2.13.1
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/.claude/settings.local.json +8 -12
- package/bench/splitPolygon.bench.js +143 -0
- package/benchmarks/compare.js +673 -0
- package/dist/jscad-modeling.min.js +110 -101
- 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/splitPolygonByPlane.js +64 -29
- package/src/operations/minkowski/index.d.ts +4 -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 +8 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jbroll/jscad-modeling",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.1",
|
|
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
|
}
|
|
@@ -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
|
}
|
|
@@ -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
|
+
})
|
|
@@ -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
|