@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jscad/modeling",
3
- "version": "2.12.7",
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": "138ee568542545f27629166bd93fff653cc3c26d"
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,3 @@
1
+ import type Geom3 from './type'
2
+
3
+ export function isConvex(geometry: Geom3): boolean
@@ -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.ceil(Math.abs(deltatheta) / TAU * segments) + 1
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, 7)
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, 19)
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
- [27, -22],
37
- [27, -3],
38
- [26.086451657912605, -8.941047736250177],
39
- [23.87938869625451, -14.243872270248309],
40
- [20.58174906029909, -18.420882475791835],
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, 7)
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, 16)
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
- [27, -22],
57
- [27, -3],
58
- [21.830323320631795, -4.401628923214028],
59
- [17.364704977487236, -6.805886946199115],
60
- [13.940501387124588, -10.031143708098092],
61
- [11.816394990371812, -13.833746263211978],
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, 8)
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
  }
@@ -1,4 +1,5 @@
1
1
  export { default as intersect } from './intersect'
2
+ export { minkowskiSum as minkowski } from '../minkowski/minkowskiSum'
2
3
  export { default as subtract } from './subtract'
3
4
  export { default as union } from './union'
4
5
  export { default as scission } from './scission'
@@ -8,6 +8,7 @@
8
8
  */
9
9
  module.exports = {
10
10
  intersect: require('./intersect'),
11
+ minkowski: require('../minkowski/minkowskiSum'),
11
12
  scission: require('./scission'),
12
13
  subtract: require('./subtract'),
13
14
  union: require('./union')
@@ -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, 44)
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, 60)
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, 84)
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,4 @@
1
+ import type { Geom3 } from '../../geometries/types'
2
+
3
+ export function minkowskiSum(geometryA: Geom3, geometryB: Geom3): Geom3
4
+ export function minkowskiSum(...geometries: Geom3[]): Geom3
@@ -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