@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/CHANGELOG.md +10 -0
- package/dist/jscad-modeling.min.js +396 -387
- 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/extrusions/extrudeRectangular.test.js +3 -3
- 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/snap.test.js +24 -15
- package/src/primitives/arc.js +2 -2
- package/src/primitives/arc.test.js +122 -111
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jscad/modeling",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"description": "Constructive Solid Geometry (CSG) Library for JSCAD",
|
|
5
5
|
"homepage": "https://openjscad.xyz/",
|
|
6
6
|
"repository": "https://github.com/jscad/OpenJSCAD.org",
|
|
@@ -61,5 +61,5 @@
|
|
|
61
61
|
"nyc": "15.1.0",
|
|
62
62
|
"uglifyify": "5.0.2"
|
|
63
63
|
},
|
|
64
|
-
"gitHead": "
|
|
64
|
+
"gitHead": "602bb2a3a2c2c7c21315e2801804dd1ec0cbd3f8"
|
|
65
65
|
}
|
|
@@ -5,6 +5,7 @@ export { default as fromPoints } from './fromPoints'
|
|
|
5
5
|
export { default as fromCompactBinary } from './fromCompactBinary'
|
|
6
6
|
export { default as invert } from './invert'
|
|
7
7
|
export { default as isA } from './isA'
|
|
8
|
+
export { isConvex } from './isConvex'
|
|
8
9
|
export { default as toPoints } from './toPoints'
|
|
9
10
|
export { default as toPolygons } from './toPolygons'
|
|
10
11
|
export { default as toString } from './toString'
|
|
@@ -28,6 +28,7 @@ module.exports = {
|
|
|
28
28
|
fromCompactBinary: require('./fromCompactBinary'),
|
|
29
29
|
invert: require('./invert'),
|
|
30
30
|
isA: require('./isA'),
|
|
31
|
+
isConvex: require('./isConvex'),
|
|
31
32
|
toPoints: require('./toPoints'),
|
|
32
33
|
toPolygons: require('./toPolygons'),
|
|
33
34
|
toString: require('./toString'),
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const { EPS } = require('../../maths/constants')
|
|
2
|
+
const vec3 = require('../../maths/vec3')
|
|
3
|
+
|
|
4
|
+
const geom3 = require('./isA')
|
|
5
|
+
const toPolygons = require('./toPolygons')
|
|
6
|
+
const poly3 = require('../poly3')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Test if a 3D geometry is convex.
|
|
10
|
+
*
|
|
11
|
+
* A polyhedron is convex if every vertex lies on or behind every face plane
|
|
12
|
+
* (i.e., on the interior side of the plane).
|
|
13
|
+
*
|
|
14
|
+
* @param {geom3} geometry - the geometry to test
|
|
15
|
+
* @returns {boolean} true if the geometry is convex
|
|
16
|
+
* @alias module:modeling/geometries/geom3.isConvex
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const { geom3, primitives } = require('@jscad/modeling')
|
|
20
|
+
* const cube = primitives.cuboid({ size: [10, 10, 10] })
|
|
21
|
+
* console.log(geom3.isConvex(cube)) // true
|
|
22
|
+
*/
|
|
23
|
+
const isConvex = (geometry) => {
|
|
24
|
+
if (!geom3(geometry)) {
|
|
25
|
+
throw new Error('isConvex requires a geom3 geometry')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const polygons = toPolygons(geometry)
|
|
29
|
+
|
|
30
|
+
if (polygons.length === 0) {
|
|
31
|
+
return true // Empty geometry is trivially convex
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Collect all unique vertices
|
|
35
|
+
const vertices = []
|
|
36
|
+
const found = new Set()
|
|
37
|
+
for (let i = 0; i < polygons.length; i++) {
|
|
38
|
+
const verts = polygons[i].vertices
|
|
39
|
+
for (let j = 0; j < verts.length; j++) {
|
|
40
|
+
const v = verts[j]
|
|
41
|
+
const key = `${v[0]},${v[1]},${v[2]}`
|
|
42
|
+
if (!found.has(key)) {
|
|
43
|
+
found.add(key)
|
|
44
|
+
vertices.push(v)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// For each face plane, check that all vertices are on or behind it
|
|
50
|
+
for (let i = 0; i < polygons.length; i++) {
|
|
51
|
+
const plane = poly3.plane(polygons[i])
|
|
52
|
+
|
|
53
|
+
for (let j = 0; j < vertices.length; j++) {
|
|
54
|
+
const v = vertices[j]
|
|
55
|
+
// Distance from point to plane: dot(normal, point) - w
|
|
56
|
+
const distance = vec3.dot(plane, v) - plane[3]
|
|
57
|
+
|
|
58
|
+
// If any vertex is in front of any face (positive distance), not convex
|
|
59
|
+
if (distance > EPS) {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = isConvex
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const test = require('ava')
|
|
2
|
+
|
|
3
|
+
const { geometries, primitives, booleans } = require('../../index')
|
|
4
|
+
const { geom3 } = geometries
|
|
5
|
+
|
|
6
|
+
test('isConvex: throws for non-geom3 input', (t) => {
|
|
7
|
+
t.throws(() => geom3.isConvex('invalid'), { message: /requires a geom3/ })
|
|
8
|
+
t.throws(() => geom3.isConvex(null), { message: /requires a geom3/ })
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('isConvex: empty geometry is convex', (t) => {
|
|
12
|
+
const empty = geom3.create()
|
|
13
|
+
t.true(geom3.isConvex(empty))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('isConvex: cuboid is convex', (t) => {
|
|
17
|
+
const cube = primitives.cuboid({ size: [10, 10, 10] })
|
|
18
|
+
t.true(geom3.isConvex(cube))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('isConvex: sphere is convex', (t) => {
|
|
22
|
+
const sph = primitives.sphere({ radius: 5, segments: 16 })
|
|
23
|
+
t.true(geom3.isConvex(sph))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('isConvex: cylinder is convex', (t) => {
|
|
27
|
+
const cyl = primitives.cylinderElliptic({ height: 10, startRadius: [3, 3], endRadius: [3, 3], segments: 16 })
|
|
28
|
+
t.true(geom3.isConvex(cyl))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('isConvex: cube with hole is not convex', (t) => {
|
|
32
|
+
const cube = primitives.cuboid({ size: [10, 10, 10] })
|
|
33
|
+
const hole = primitives.cuboid({ size: [4, 4, 20] }) // Hole through the cube
|
|
34
|
+
|
|
35
|
+
const withHole = booleans.subtract(cube, hole)
|
|
36
|
+
t.false(geom3.isConvex(withHole))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('isConvex: L-shaped solid is not convex', (t) => {
|
|
40
|
+
const big = primitives.cuboid({ size: [10, 10, 10], center: [0, 0, 0] })
|
|
41
|
+
const corner = primitives.cuboid({ size: [6, 6, 12], center: [3, 3, 0] })
|
|
42
|
+
|
|
43
|
+
const lShape = booleans.subtract(big, corner)
|
|
44
|
+
t.false(geom3.isConvex(lShape))
|
|
45
|
+
})
|
|
@@ -120,7 +120,7 @@ const appendArc = (options, geometry) => {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
// Ok, we have the center point and angle range (from theta1, deltatheta radians) so we can create the ellipse
|
|
123
|
-
let numsteps = Math.
|
|
123
|
+
let numsteps = Math.floor(segments * (Math.abs(deltatheta) / TAU))
|
|
124
124
|
if (numsteps < 1) numsteps = 1
|
|
125
125
|
for (let step = 1; step < numsteps; step++) {
|
|
126
126
|
const theta = theta1 + step / numsteps * deltatheta
|
|
@@ -22,47 +22,43 @@ test('appendArc: appending to a path produces a new path', (t) => {
|
|
|
22
22
|
const p2 = fromPoints({}, [[27, -22], [27, -3]])
|
|
23
23
|
obs = appendArc({ endpoint: [12, -22], radius: [15, -20] }, p2)
|
|
24
24
|
pts = toPoints(obs)
|
|
25
|
-
t.is(pts.length,
|
|
25
|
+
t.is(pts.length, 5)
|
|
26
26
|
|
|
27
27
|
// test segments
|
|
28
28
|
obs = appendArc({ endpoint: [12, -22], radius: [15, -20], segments: 64 }, p2)
|
|
29
29
|
pts = toPoints(obs)
|
|
30
|
-
t.is(pts.length,
|
|
30
|
+
t.is(pts.length, 17)
|
|
31
31
|
|
|
32
32
|
// test clockwise
|
|
33
33
|
obs = appendArc({ endpoint: [12, -22], radius: [15, -20], clockwise: true }, p2)
|
|
34
34
|
pts = toPoints(obs)
|
|
35
35
|
let exp = [
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
[16.49674848226545, -21.0880050920699],
|
|
42
|
-
[11.999999999999998, -22]
|
|
36
|
+
[ 27, -22 ],
|
|
37
|
+
[ 27, -3 ],
|
|
38
|
+
[ 24.7485593841743, -12.579008396887021 ],
|
|
39
|
+
[ 19.29019838402471, -19.492932330409836 ],
|
|
40
|
+
[ 12, -22 ]
|
|
43
41
|
]
|
|
44
|
-
t.is(pts.length,
|
|
42
|
+
t.is(pts.length, 5)
|
|
45
43
|
t.true(comparePoints(pts, exp))
|
|
46
44
|
|
|
47
45
|
// test large
|
|
48
46
|
obs = appendArc({ endpoint: [12, -22], radius: [15, -20], large: true }, p2)
|
|
49
47
|
pts = toPoints(obs)
|
|
50
|
-
t.is(pts.length,
|
|
48
|
+
t.is(pts.length, 14)
|
|
51
49
|
|
|
52
50
|
// test xaxisrotation
|
|
53
51
|
obs = appendArc({ endpoint: [12, -22], radius: [15, -20], xaxisrotation: TAU / 4 }, p2)
|
|
54
52
|
pts = toPoints(obs)
|
|
55
53
|
exp = [
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
[11.15285201325494, -17.926425912558045],
|
|
63
|
-
[12, -22.000000000000004]
|
|
54
|
+
[ 27, -22 ],
|
|
55
|
+
[ 27, -3 ],
|
|
56
|
+
[ 19.486852090983938, -5.488140907400943 ],
|
|
57
|
+
[ 13.940501387124588, -10.031143708098092 ],
|
|
58
|
+
[ 11.296247566821858, -15.862906638006239 ],
|
|
59
|
+
[ 12, -22 ]
|
|
64
60
|
]
|
|
65
|
-
t.is(pts.length,
|
|
61
|
+
t.is(pts.length, 6)
|
|
66
62
|
t.true(comparePoints(pts, exp))
|
|
67
63
|
|
|
68
64
|
// test small arc between far points
|
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
|
}
|
|
@@ -15,7 +15,7 @@ test('extrudeRectangular (defaults)', (t) => {
|
|
|
15
15
|
let obs = extrudeRectangular({ }, geometry1)
|
|
16
16
|
let pts = geom3.toPoints(obs)
|
|
17
17
|
t.notThrows(() => geom3.validate(obs))
|
|
18
|
-
t.is(pts.length,
|
|
18
|
+
t.is(pts.length, 36)
|
|
19
19
|
|
|
20
20
|
obs = extrudeRectangular({ }, geometry2)
|
|
21
21
|
pts = geom3.toPoints(obs)
|
|
@@ -30,7 +30,7 @@ test('extrudeRectangular (chamfer)', (t) => {
|
|
|
30
30
|
let obs = extrudeRectangular({ corners: 'chamfer' }, geometry1)
|
|
31
31
|
let pts = geom3.toPoints(obs)
|
|
32
32
|
t.notThrows(() => geom3.validate(obs))
|
|
33
|
-
t.is(pts.length,
|
|
33
|
+
t.is(pts.length, 48)
|
|
34
34
|
|
|
35
35
|
obs = extrudeRectangular({ corners: 'chamfer' }, geometry2)
|
|
36
36
|
pts = geom3.toPoints(obs)
|
|
@@ -45,7 +45,7 @@ test('extrudeRectangular (segments = 8, round)', (t) => {
|
|
|
45
45
|
let obs = extrudeRectangular({ segments: 8, corners: 'round' }, geometry1)
|
|
46
46
|
let pts = geom3.toPoints(obs)
|
|
47
47
|
t.notThrows(() => geom3.validate(obs))
|
|
48
|
-
t.is(pts.length,
|
|
48
|
+
t.is(pts.length, 72)
|
|
49
49
|
|
|
50
50
|
obs = extrudeRectangular({ segments: 8, corners: 'round' }, geometry2)
|
|
51
51
|
pts = geom3.toPoints(obs)
|
|
@@ -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
|