@jbroll/jscad-modeling 2.12.8 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbroll/jscad-modeling",
3
- "version": "2.12.8",
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
- return { vertices }
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
@@ -4,6 +4,6 @@ const { create } = require('./index')
4
4
 
5
5
  test('poly3: create() should return a poly3 with initial values', (t) => {
6
6
  const obs = create()
7
- const exp = { vertices: [] }
7
+ const exp = { vertices: [], plane: null, boundingSphere: null }
8
8
  t.deepEqual(obs, exp)
9
9
  })
@@ -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
- const poly = create(vertices)
13
- poly.plane = plane // retain the plane for later use
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
- const vertices = polygon.vertices.slice().reverse()
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
- const boundingSphere = cache.get(polygon)
13
- if (boundingSphere) return boundingSphere
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
- vertices.forEach((v) => {
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
- cache.set(polygon, out)
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 (does not modify input).
15
- const removeConsecutiveDuplicates = (vertices) => {
16
- if (vertices.length < 3) return vertices
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[vertices.length - 1]
19
- for (let i = 0; i < vertices.length; 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
- type: null,
42
- front: null,
43
- back: null
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
- const vertexIsBack = []
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.push(isback)
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
- const frontvertices = []
75
- const backvertices = []
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.push(vertex)
115
+ backvertices[backCount++] = vertex
86
116
  } else {
87
- frontvertices.push(vertex)
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.push(vertex)
95
- backvertices.push(intersectionpoint)
96
- frontvertices.push(intersectionpoint)
124
+ backvertices[backCount++] = vertex
125
+ backvertices[backCount++] = intersectionpoint
126
+ frontvertices[frontCount++] = intersectionpoint
97
127
  } else {
98
- frontvertices.push(vertex)
99
- frontvertices.push(intersectionpoint)
100
- backvertices.push(intersectionpoint)
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
- const backFiltered = removeConsecutiveDuplicates(backvertices)
107
- const frontFiltered = removeConsecutiveDuplicates(frontvertices)
108
- if (frontFiltered.length >= 3) {
109
- result.front = poly3.fromPointsAndPlane(frontFiltered, pplane)
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 (backFiltered.length >= 3) {
112
- result.back = poly3.fromPointsAndPlane(backFiltered, pplane)
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,2 @@
1
+ export { default as isConvex } from './isConvex'
2
+ export { default as minkowskiSum } from './minkowskiSum'
@@ -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,5 @@
1
+ import { Geom3 } from '../../geometries/types'
2
+
3
+ export default isConvex
4
+
5
+ declare function isConvex(geometry: Geom3): boolean
@@ -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,6 @@
1
+ import { Geom3 } from '../../geometries/types'
2
+
3
+ export default minkowskiSum
4
+
5
+ declare function minkowskiSum(geometryA: Geom3, geometryB: Geom3): Geom3
6
+ declare function minkowskiSum(...geometries: Geom3[]): Geom3
@@ -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