@jscad/modeling 2.12.6 → 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 +12 -299
- package/bench/booleans.bench.js +103 -0
- package/bench/primitives.bench.js +108 -0
- package/dist/jscad-modeling.min.js +404 -395
- 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/booleans/trees/PolygonTreeNode.js +18 -5
- package/src/operations/booleans/trees/splitPolygonByPlane.js +27 -25
- package/src/operations/booleans/trees/splitPolygonByPlane.test.js +132 -0
- package/src/operations/booleans/unionGeom3.test.js +35 -0
- package/src/operations/extrusions/extrudeFromSlices.js +14 -4
- package/src/operations/extrusions/extrudeRectangular.test.js +3 -3
- package/src/operations/extrusions/extrudeRotate.js +4 -1
- package/src/operations/extrusions/extrudeRotate.test.js +33 -0
- package/src/operations/extrusions/extrudeWalls.js +2 -1
- package/src/operations/extrusions/extrudeWalls.test.js +72 -0
- package/src/operations/minkowski/index.d.ts +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/reTesselateCoplanarPolygons.js +8 -3
- package/src/operations/modifiers/reTesselateCoplanarPolygons.test.js +36 -1
- package/src/operations/modifiers/retessellate.js +5 -2
- 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/src/utils/flatten.js +1 -1
- package/src/utils/flatten.test.js +94 -0
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
|
}
|
|
@@ -49,11 +49,11 @@ class PolygonTreeNode {
|
|
|
49
49
|
this.removed = true
|
|
50
50
|
this.polygon = null
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
// Note: We intentionally do NOT splice from parent.children here.
|
|
53
|
+
// All iteration paths (getPolygons, splitByPlane, clipPolygons) already
|
|
54
|
+
// check isRemoved() or polygon !== null, so removed nodes are skipped.
|
|
55
|
+
// Avoiding splice eliminates O(n²) cost when many nodes are removed.
|
|
56
|
+
// Dead nodes are cleaned up lazily in getPolygons().
|
|
57
57
|
|
|
58
58
|
// invalidate the parent's polygon, and of all parents above it:
|
|
59
59
|
this.parent.recursivelyInvalidatePolygon()
|
|
@@ -80,6 +80,19 @@ class PolygonTreeNode {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
getPolygons (result) {
|
|
83
|
+
// Compact root's children array to remove dead nodes (lazy cleanup from remove()).
|
|
84
|
+
// Note: This method is only called on the root node via Tree.allPolygons() at the
|
|
85
|
+
// end of boolean operations. The children array is internal and not exposed, so
|
|
86
|
+
// mutating it here is safe. Non-root nodes are traversed via the queue below,
|
|
87
|
+
// which skips removed nodes via the `if (node.polygon)` check.
|
|
88
|
+
if (this.isRootNode() && this.children.length > 0) {
|
|
89
|
+
const compacted = []
|
|
90
|
+
for (let i = 0; i < this.children.length; i++) {
|
|
91
|
+
if (!this.children[i].removed) compacted.push(this.children[i])
|
|
92
|
+
}
|
|
93
|
+
this.children = compacted
|
|
94
|
+
}
|
|
95
|
+
|
|
83
96
|
let children = [this]
|
|
84
97
|
const queue = [children]
|
|
85
98
|
let i, j, l, node
|
|
@@ -7,6 +7,25 @@ const poly3 = require('../../../geometries/poly3')
|
|
|
7
7
|
|
|
8
8
|
const splitLineSegmentByPlane = require('./splitLineSegmentByPlane')
|
|
9
9
|
|
|
10
|
+
const EPS_SQUARED = EPS * EPS
|
|
11
|
+
|
|
12
|
+
// Remove consecutive duplicate vertices from a polygon vertex list.
|
|
13
|
+
// Compares last vertex to first to handle wraparound.
|
|
14
|
+
// Returns a new array (does not modify input).
|
|
15
|
+
// IMPORTANT: Caller must ensure vertices.length >= 3 before calling.
|
|
16
|
+
const removeConsecutiveDuplicates = (vertices) => {
|
|
17
|
+
const result = []
|
|
18
|
+
let prevvertex = vertices[vertices.length - 1]
|
|
19
|
+
for (let i = 0; i < vertices.length; i++) {
|
|
20
|
+
const vertex = vertices[i]
|
|
21
|
+
if (vec3.squaredDistance(vertex, prevvertex) >= EPS_SQUARED) {
|
|
22
|
+
result.push(vertex)
|
|
23
|
+
}
|
|
24
|
+
prevvertex = vertex
|
|
25
|
+
}
|
|
26
|
+
return result
|
|
27
|
+
}
|
|
28
|
+
|
|
10
29
|
// Returns object:
|
|
11
30
|
// .type:
|
|
12
31
|
// 0: coplanar-front
|
|
@@ -83,35 +102,18 @@ const splitPolygonByPlane = (splane, polygon) => {
|
|
|
83
102
|
}
|
|
84
103
|
isback = nextisback
|
|
85
104
|
} // for vertexindex
|
|
86
|
-
// remove duplicate vertices
|
|
87
|
-
const EPS_SQUARED = EPS * EPS
|
|
88
|
-
if (backvertices.length >= 3) {
|
|
89
|
-
let prevvertex = backvertices[backvertices.length - 1]
|
|
90
|
-
for (let vertexindex = 0; vertexindex < backvertices.length; vertexindex++) {
|
|
91
|
-
const vertex = backvertices[vertexindex]
|
|
92
|
-
if (vec3.squaredDistance(vertex, prevvertex) < EPS_SQUARED) {
|
|
93
|
-
backvertices.splice(vertexindex, 1)
|
|
94
|
-
vertexindex--
|
|
95
|
-
}
|
|
96
|
-
prevvertex = vertex
|
|
97
|
-
}
|
|
98
|
-
}
|
|
105
|
+
// remove consecutive duplicate vertices (check length before calling to avoid function overhead)
|
|
99
106
|
if (frontvertices.length >= 3) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (vec3.squaredDistance(vertex, prevvertex) < EPS_SQUARED) {
|
|
104
|
-
frontvertices.splice(vertexindex, 1)
|
|
105
|
-
vertexindex--
|
|
106
|
-
}
|
|
107
|
-
prevvertex = vertex
|
|
107
|
+
const frontFiltered = removeConsecutiveDuplicates(frontvertices)
|
|
108
|
+
if (frontFiltered.length >= 3) {
|
|
109
|
+
result.front = poly3.fromPointsAndPlane(frontFiltered, pplane)
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
|
-
if (frontvertices.length >= 3) {
|
|
111
|
-
result.front = poly3.fromPointsAndPlane(frontvertices, pplane)
|
|
112
|
-
}
|
|
113
112
|
if (backvertices.length >= 3) {
|
|
114
|
-
|
|
113
|
+
const backFiltered = removeConsecutiveDuplicates(backvertices)
|
|
114
|
+
if (backFiltered.length >= 3) {
|
|
115
|
+
result.back = poly3.fromPointsAndPlane(backFiltered, pplane)
|
|
116
|
+
}
|
|
115
117
|
}
|
|
116
118
|
}
|
|
117
119
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const test = require('ava')
|
|
2
|
+
|
|
3
|
+
const { poly3 } = require('../../../geometries')
|
|
4
|
+
const plane = require('../../../maths/plane')
|
|
5
|
+
|
|
6
|
+
const splitPolygonByPlane = require('./splitPolygonByPlane')
|
|
7
|
+
|
|
8
|
+
test('splitPolygonByPlane: test coplanar-front polygon returns type 0.', (t) => {
|
|
9
|
+
// Polygon in XY plane at z=0
|
|
10
|
+
const polygon = poly3.create([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]])
|
|
11
|
+
// Plane is also XY plane at z=0, normal pointing up
|
|
12
|
+
const splane = plane.fromPoints(plane.create(), [0, 0, 0], [1, 0, 0], [1, 1, 0])
|
|
13
|
+
|
|
14
|
+
const result = splitPolygonByPlane(splane, polygon)
|
|
15
|
+
t.is(result.type, 0) // coplanar-front
|
|
16
|
+
t.is(result.front, null)
|
|
17
|
+
t.is(result.back, null)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('splitPolygonByPlane: test polygon entirely in front returns type 2.', (t) => {
|
|
21
|
+
// Polygon at z=5
|
|
22
|
+
const polygon = poly3.create([[0, 0, 5], [1, 0, 5], [1, 1, 5], [0, 1, 5]])
|
|
23
|
+
// Plane at z=0
|
|
24
|
+
const splane = [0, 0, 1, 0] // normal (0,0,1), w=0
|
|
25
|
+
|
|
26
|
+
const result = splitPolygonByPlane(splane, polygon)
|
|
27
|
+
t.is(result.type, 2) // front
|
|
28
|
+
t.is(result.front, null)
|
|
29
|
+
t.is(result.back, null)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('splitPolygonByPlane: test polygon entirely in back returns type 3.', (t) => {
|
|
33
|
+
// Polygon at z=-5
|
|
34
|
+
const polygon = poly3.create([[0, 0, -5], [1, 0, -5], [1, 1, -5], [0, 1, -5]])
|
|
35
|
+
// Plane at z=0
|
|
36
|
+
const splane = [0, 0, 1, 0] // normal (0,0,1), w=0
|
|
37
|
+
|
|
38
|
+
const result = splitPolygonByPlane(splane, polygon)
|
|
39
|
+
t.is(result.type, 3) // back
|
|
40
|
+
t.is(result.front, null)
|
|
41
|
+
t.is(result.back, null)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('splitPolygonByPlane: test spanning polygon returns type 4 with front and back.', (t) => {
|
|
45
|
+
// Polygon spanning z=0 plane (from z=-1 to z=1)
|
|
46
|
+
const polygon = poly3.create([[0, 0, -1], [1, 0, -1], [1, 0, 1], [0, 0, 1]])
|
|
47
|
+
// Plane at z=0
|
|
48
|
+
const splane = [0, 0, 1, 0] // normal (0,0,1), w=0
|
|
49
|
+
|
|
50
|
+
const result = splitPolygonByPlane(splane, polygon)
|
|
51
|
+
t.is(result.type, 4) // spanning
|
|
52
|
+
t.not(result.front, null)
|
|
53
|
+
t.not(result.back, null)
|
|
54
|
+
|
|
55
|
+
// Front polygon should have z >= 0
|
|
56
|
+
const frontPoints = poly3.toPoints(result.front)
|
|
57
|
+
t.true(frontPoints.length >= 3)
|
|
58
|
+
frontPoints.forEach((p) => {
|
|
59
|
+
t.true(p[2] >= -1e-5, `front point z=${p[2]} should be >= 0`)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Back polygon should have z <= 0
|
|
63
|
+
const backPoints = poly3.toPoints(result.back)
|
|
64
|
+
t.true(backPoints.length >= 3)
|
|
65
|
+
backPoints.forEach((p) => {
|
|
66
|
+
t.true(p[2] <= 1e-5, `back point z=${p[2]} should be <= 0`)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('splitPolygonByPlane: test duplicate vertices are removed from split result.', (t) => {
|
|
71
|
+
// Create a polygon that when split would produce duplicate vertices
|
|
72
|
+
// Triangle with one vertex on the plane
|
|
73
|
+
const polygon = poly3.create([[0, 0, 0], [1, 0, 1], [1, 0, -1]])
|
|
74
|
+
// Plane at z=0
|
|
75
|
+
const splane = [0, 0, 1, 0]
|
|
76
|
+
|
|
77
|
+
const result = splitPolygonByPlane(splane, polygon)
|
|
78
|
+
t.is(result.type, 4) // spanning
|
|
79
|
+
|
|
80
|
+
// Verify no consecutive duplicate vertices in front
|
|
81
|
+
if (result.front) {
|
|
82
|
+
const frontPoints = poly3.toPoints(result.front)
|
|
83
|
+
for (let i = 0; i < frontPoints.length; i++) {
|
|
84
|
+
const curr = frontPoints[i]
|
|
85
|
+
const next = frontPoints[(i + 1) % frontPoints.length]
|
|
86
|
+
const dx = curr[0] - next[0]
|
|
87
|
+
const dy = curr[1] - next[1]
|
|
88
|
+
const dz = curr[2] - next[2]
|
|
89
|
+
const distSq = dx * dx + dy * dy + dz * dz
|
|
90
|
+
t.true(distSq > 1e-10, 'front polygon should not have duplicate consecutive vertices')
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Verify no consecutive duplicate vertices in back
|
|
95
|
+
if (result.back) {
|
|
96
|
+
const backPoints = poly3.toPoints(result.back)
|
|
97
|
+
for (let i = 0; i < backPoints.length; i++) {
|
|
98
|
+
const curr = backPoints[i]
|
|
99
|
+
const next = backPoints[(i + 1) % backPoints.length]
|
|
100
|
+
const dx = curr[0] - next[0]
|
|
101
|
+
const dy = curr[1] - next[1]
|
|
102
|
+
const dz = curr[2] - next[2]
|
|
103
|
+
const distSq = dx * dx + dy * dy + dz * dz
|
|
104
|
+
t.true(distSq > 1e-10, 'back polygon should not have duplicate consecutive vertices')
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('splitPolygonByPlane: test complex spanning polygon splits correctly.', (t) => {
|
|
110
|
+
// Hexagon spanning the XY plane
|
|
111
|
+
const polygon = poly3.create([
|
|
112
|
+
[1, 0, -1],
|
|
113
|
+
[0.5, 0.866, -1],
|
|
114
|
+
[-0.5, 0.866, 1],
|
|
115
|
+
[-1, 0, 1],
|
|
116
|
+
[-0.5, -0.866, 1],
|
|
117
|
+
[0.5, -0.866, -1]
|
|
118
|
+
])
|
|
119
|
+
// Plane at z=0
|
|
120
|
+
const splane = [0, 0, 1, 0]
|
|
121
|
+
|
|
122
|
+
const result = splitPolygonByPlane(splane, polygon)
|
|
123
|
+
t.is(result.type, 4) // spanning
|
|
124
|
+
t.not(result.front, null)
|
|
125
|
+
t.not(result.back, null)
|
|
126
|
+
|
|
127
|
+
// Both resulting polygons should be valid (at least 3 vertices)
|
|
128
|
+
const frontPoints = poly3.toPoints(result.front)
|
|
129
|
+
const backPoints = poly3.toPoints(result.back)
|
|
130
|
+
t.true(frontPoints.length >= 3, 'front polygon should have at least 3 vertices')
|
|
131
|
+
t.true(backPoints.length >= 3, 'back polygon should have at least 3 vertices')
|
|
132
|
+
})
|
|
@@ -131,3 +131,38 @@ test('union of geom3 with rounding issues #137', (t) => {
|
|
|
131
131
|
t.notThrows(() => geom3.validate(obs))
|
|
132
132
|
t.is(pts.length, 6) // number of polygons in union
|
|
133
133
|
})
|
|
134
|
+
|
|
135
|
+
// Test for push loop optimization: verify union works correctly with multiple geometries
|
|
136
|
+
// This ensures the concat-to-push-loop optimization handles array merging properly
|
|
137
|
+
test('union of geom3 with multiple overlapping geometries', (t) => {
|
|
138
|
+
// Create several overlapping cuboids to generate a complex union
|
|
139
|
+
const geometry1 = cuboid({ size: [10, 10, 10] })
|
|
140
|
+
const geometry2 = center({ relativeTo: [5, 0, 0] }, cuboid({ size: [10, 10, 10] }))
|
|
141
|
+
const geometry3 = center({ relativeTo: [0, 5, 0] }, cuboid({ size: [10, 10, 10] }))
|
|
142
|
+
|
|
143
|
+
// Union should work correctly with multiple geometries
|
|
144
|
+
const obs = union(geometry1, geometry2, geometry3)
|
|
145
|
+
const pts = geom3.toPoints(obs)
|
|
146
|
+
|
|
147
|
+
// Skip manifold validation - focus on testing polygon merging works correctly
|
|
148
|
+
// (CSG on overlapping boxes can produce non-manifold edges at coplanar faces)
|
|
149
|
+
// Should produce a merged geometry with polygons from all inputs
|
|
150
|
+
t.true(pts.length > 6) // more than a single cube
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Test for push loop optimization: verify non-overlapping geometries combine correctly
|
|
154
|
+
test('union of multiple non-overlapping geom3 preserves all polygons', (t) => {
|
|
155
|
+
// Create multiple small cuboids that don't overlap
|
|
156
|
+
const cubes = []
|
|
157
|
+
for (let i = 0; i < 10; i++) {
|
|
158
|
+
cubes.push(center({ relativeTo: [i * 5, 0, 0] }, cuboid({ size: [2, 2, 2] })))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Union all of them
|
|
162
|
+
const obs = union(...cubes)
|
|
163
|
+
const pts = geom3.toPoints(obs)
|
|
164
|
+
|
|
165
|
+
t.notThrows(() => geom3.validate(obs))
|
|
166
|
+
// Each cuboid has 6 faces, so 10 cuboids = 60 polygons
|
|
167
|
+
t.is(pts.length, 60)
|
|
168
|
+
})
|
|
@@ -81,7 +81,10 @@ const extrudeFromSlices = (options, base) => {
|
|
|
81
81
|
if (edges.length === 0) throw new Error('the callback function must return slices with one or more edges')
|
|
82
82
|
|
|
83
83
|
if (prevSlice) {
|
|
84
|
-
|
|
84
|
+
const walls = extrudeWalls(prevSlice, currentSlice)
|
|
85
|
+
for (let i = 0; i < walls.length; i++) {
|
|
86
|
+
polygons.push(walls[i])
|
|
87
|
+
}
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
// save start and end slices for caps if necessary
|
|
@@ -95,17 +98,24 @@ const extrudeFromSlices = (options, base) => {
|
|
|
95
98
|
if (capEnd) {
|
|
96
99
|
// create a cap at the end
|
|
97
100
|
const endPolygons = slice.toPolygons(endSlice)
|
|
98
|
-
|
|
101
|
+
for (let i = 0; i < endPolygons.length; i++) {
|
|
102
|
+
polygons.push(endPolygons[i])
|
|
103
|
+
}
|
|
99
104
|
}
|
|
100
105
|
if (capStart) {
|
|
101
106
|
// create a cap at the start
|
|
102
107
|
const startPolygons = slice.toPolygons(startSlice).map(poly3.invert)
|
|
103
|
-
|
|
108
|
+
for (let i = 0; i < startPolygons.length; i++) {
|
|
109
|
+
polygons.push(startPolygons[i])
|
|
110
|
+
}
|
|
104
111
|
}
|
|
105
112
|
if (!capStart && !capEnd) {
|
|
106
113
|
// create walls between end and start slices
|
|
107
114
|
if (close && !slice.equals(endSlice, startSlice)) {
|
|
108
|
-
|
|
115
|
+
const walls = extrudeWalls(endSlice, startSlice)
|
|
116
|
+
for (let i = 0; i < walls.length; i++) {
|
|
117
|
+
polygons.push(walls[i])
|
|
118
|
+
}
|
|
109
119
|
}
|
|
110
120
|
}
|
|
111
121
|
return geom3.create(polygons)
|
|
@@ -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)
|
|
@@ -114,13 +114,16 @@ const extrudeRotate = (options, geometry) => {
|
|
|
114
114
|
slice.reverse(baseSlice, baseSlice)
|
|
115
115
|
|
|
116
116
|
const matrix = mat4.create()
|
|
117
|
+
const xRotationMatrix = mat4.fromXRotation(mat4.create(), TAU / 4) // compute once, reuse
|
|
118
|
+
const zRotationMatrix = mat4.create() // reuse for Z rotation
|
|
117
119
|
const createSlice = (progress, index, base) => {
|
|
118
120
|
let Zrotation = rotationPerSlice * index + startAngle
|
|
119
121
|
// fix rounding error when rotating TAU radians
|
|
120
122
|
if (totalRotation === TAU && index === segments) {
|
|
121
123
|
Zrotation = startAngle
|
|
122
124
|
}
|
|
123
|
-
mat4.
|
|
125
|
+
mat4.fromZRotation(zRotationMatrix, Zrotation)
|
|
126
|
+
mat4.multiply(matrix, zRotationMatrix, xRotationMatrix)
|
|
124
127
|
|
|
125
128
|
return slice.transform(matrix, base)
|
|
126
129
|
}
|
|
@@ -158,4 +158,37 @@ test('extrudeRotate: (overlap +/-) extruding of a geom2 produces an expected geo
|
|
|
158
158
|
t.true(comparePolygonsAsPoints(pts, exp))
|
|
159
159
|
})
|
|
160
160
|
|
|
161
|
+
// Test for mat4 reuse optimization: verify rotation matrices are computed correctly
|
|
162
|
+
// This ensures the optimization of computing xRotationMatrix once doesn't break anything
|
|
163
|
+
test('extrudeRotate: (mat4 reuse) rotation matrices produce correct geometry', (t) => {
|
|
164
|
+
// Simple rectangle that will be rotated to form a tube-like shape
|
|
165
|
+
const geometry2 = geom2.fromPoints([[5, -1], [5, 1], [6, 1], [6, -1]])
|
|
166
|
+
|
|
167
|
+
// Full rotation with many segments to test matrix reuse across iterations
|
|
168
|
+
const geometry3 = extrudeRotate({ segments: 32 }, geometry2)
|
|
169
|
+
const pts = geom3.toPoints(geometry3)
|
|
170
|
+
|
|
171
|
+
t.notThrows(() => geom3.validate(geometry3))
|
|
172
|
+
// 32 segments * 8 walls per segment (4 edges * 2 triangles) = 256 polygons
|
|
173
|
+
t.is(pts.length, 256)
|
|
174
|
+
|
|
175
|
+
// Verify the geometry is closed (first and last slices connect properly)
|
|
176
|
+
// This tests the Zrotation rounding error fix at index === segments
|
|
177
|
+
const geometry3b = extrudeRotate({ segments: 16 }, geometry2)
|
|
178
|
+
t.notThrows(() => geom3.validate(geometry3b))
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// Test for mat4 reuse with partial rotation (tests both capped and matrix reuse)
|
|
182
|
+
test('extrudeRotate: (mat4 reuse) partial rotation produces correct caps', (t) => {
|
|
183
|
+
const geometry2 = geom2.fromPoints([[5, -1], [5, 1], [6, 1], [6, -1]])
|
|
184
|
+
|
|
185
|
+
// Quarter rotation - should have start and end caps
|
|
186
|
+
const geometry3 = extrudeRotate({ segments: 8, angle: TAU / 4 }, geometry2)
|
|
187
|
+
const pts = geom3.toPoints(geometry3)
|
|
188
|
+
|
|
189
|
+
t.notThrows(() => geom3.validate(geometry3))
|
|
190
|
+
// Should produce valid geometry with caps
|
|
191
|
+
t.true(pts.length > 0)
|
|
192
|
+
})
|
|
193
|
+
|
|
161
194
|
// TEST HOLES
|