@jscad/modeling 2.12.6 → 2.12.7

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.
@@ -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
- polygons = polygons.concat(extrudeWalls(prevSlice, currentSlice))
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
- polygons = polygons.concat(endPolygons)
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
- polygons = polygons.concat(startPolygons)
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
- polygons = polygons.concat(extrudeWalls(endSlice, startSlice))
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)
@@ -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.multiply(matrix, mat4.fromZRotation(matrix, Zrotation), mat4.fromXRotation(mat4.create(), TAU / 4))
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
@@ -26,10 +26,11 @@ const repartitionEdges = (newlength, edges) => {
26
26
  }
27
27
 
28
28
  const divisor = vec3.fromValues(multiple, multiple, multiple)
29
+ const increment = vec3.create() // reuse across all edge iterations
29
30
 
30
31
  const newEdges = []
31
32
  edges.forEach((edge) => {
32
- const increment = vec3.subtract(vec3.create(), edge[1], edge[0])
33
+ vec3.subtract(increment, edge[1], edge[0])
33
34
  vec3.divide(increment, increment, divisor)
34
35
 
35
36
  // repartition the edge
@@ -80,3 +80,75 @@ test('extrudeWalls (different shapes)', (t) => {
80
80
  walls = extrudeWalls(slice3, slice.transform(matrix, slice2))
81
81
  t.is(walls.length, 24)
82
82
  })
83
+
84
+ // Test for vec3 reuse optimization in repartitionEdges
85
+ // When shapes have different edge counts, edges are repartitioned using vec3 operations
86
+ test('extrudeWalls (repartitionEdges vec3 reuse)', (t) => {
87
+ const matrix = mat4.fromTranslation(mat4.create(), [0, 0, 5])
88
+
89
+ // Triangle (3 edges)
90
+ const triangle = [
91
+ [[0, 10], [-8.66, -5]],
92
+ [[-8.66, -5], [8.66, -5]],
93
+ [[8.66, -5], [0, 10]]
94
+ ]
95
+
96
+ // Hexagon (6 edges) - LCM with triangle is 6, so triangle edges get split
97
+ const hexagon = [
98
+ [[0, 10], [-8.66, 5]],
99
+ [[-8.66, 5], [-8.66, -5]],
100
+ [[-8.66, -5], [0, -10]],
101
+ [[0, -10], [8.66, -5]],
102
+ [[8.66, -5], [8.66, 5]],
103
+ [[8.66, 5], [0, 10]]
104
+ ]
105
+
106
+ const sliceTriangle = slice.fromSides(triangle)
107
+ const sliceHexagon = slice.fromSides(hexagon)
108
+
109
+ // Triangle to hexagon requires repartitioning (3 -> 6 edges)
110
+ // This exercises the vec3 reuse optimization in repartitionEdges
111
+ const walls = extrudeWalls(sliceTriangle, slice.transform(matrix, sliceHexagon))
112
+
113
+ // 6 edges * 2 triangles per edge = 12 wall polygons
114
+ t.is(walls.length, 12)
115
+
116
+ // Verify all walls are valid triangles
117
+ walls.forEach((wall) => {
118
+ t.is(wall.vertices.length, 3)
119
+ })
120
+ })
121
+
122
+ // Test for vec3 reuse with higher repartition multiple
123
+ test('extrudeWalls (repartitionEdges with high multiple)', (t) => {
124
+ const matrix = mat4.fromTranslation(mat4.create(), [0, 0, 10])
125
+
126
+ // Square (4 edges)
127
+ const square = [
128
+ [[-5, 5], [-5, -5]],
129
+ [[-5, -5], [5, -5]],
130
+ [[5, -5], [5, 5]],
131
+ [[5, 5], [-5, 5]]
132
+ ]
133
+
134
+ // Octagon (8 edges) - LCM with square is 8, so square edges get doubled
135
+ const octagon = [
136
+ [[0, 5], [-3.54, 3.54]],
137
+ [[-3.54, 3.54], [-5, 0]],
138
+ [[-5, 0], [-3.54, -3.54]],
139
+ [[-3.54, -3.54], [0, -5]],
140
+ [[0, -5], [3.54, -3.54]],
141
+ [[3.54, -3.54], [5, 0]],
142
+ [[5, 0], [3.54, 3.54]],
143
+ [[3.54, 3.54], [0, 5]]
144
+ ]
145
+
146
+ const sliceSquare = slice.fromSides(square)
147
+ const sliceOctagon = slice.fromSides(octagon)
148
+
149
+ // Square to octagon requires repartitioning (4 -> 8 edges)
150
+ const walls = extrudeWalls(sliceSquare, slice.transform(matrix, sliceOctagon))
151
+
152
+ // 8 edges * 2 triangles per edge = 16 wall polygons
153
+ t.is(walls.length, 16)
154
+ })
@@ -120,6 +120,7 @@ const reTesselateCoplanarPolygons = (sourcepolygons) => {
120
120
  // at the the left and right side of the polygon
121
121
  // Iterate over all polygons that have a corner at this y coordinate:
122
122
  const polygonindexeswithcorner = ycoordinatetopolygonindexes.get(ycoordinate)
123
+ let removeCount = 0 // track removals to filter at end (avoids O(n²) splice)
123
124
  for (let activepolygonindex = 0; activepolygonindex < activepolygons.length; ++activepolygonindex) {
124
125
  const activepolygon = activepolygons[activepolygonindex]
125
126
  const polygonindex = activepolygon.polygonindex
@@ -143,9 +144,9 @@ const reTesselateCoplanarPolygons = (sourcepolygons) => {
143
144
  }
144
145
  if ((newleftvertexindex !== activepolygon.leftvertexindex) && (newleftvertexindex === newrightvertexindex)) {
145
146
  // We have increased leftvertexindex or decreased rightvertexindex, and now they point to the same vertex
146
- // This means that this is the bottom point of the polygon. We'll remove it:
147
- activepolygons.splice(activepolygonindex, 1)
148
- --activepolygonindex
147
+ // This means that this is the bottom point of the polygon. Mark it for removal:
148
+ activepolygon._remove = true
149
+ removeCount++
149
150
  } else {
150
151
  activepolygon.leftvertexindex = newleftvertexindex
151
152
  activepolygon.rightvertexindex = newrightvertexindex
@@ -160,6 +161,10 @@ const reTesselateCoplanarPolygons = (sourcepolygons) => {
160
161
  }
161
162
  } // if polygon has corner here
162
163
  } // for activepolygonindex
164
+ // Filter out marked polygons in single pass (O(n) instead of O(n²) splice)
165
+ if (removeCount > 0) {
166
+ activepolygons = activepolygons.filter((p) => !p._remove)
167
+ }
163
168
  let nextycoordinate
164
169
  if (yindex >= ycoordinates.length - 1) {
165
170
  // last row, all polygons must be finished here:
@@ -16,7 +16,7 @@ const rotatePoly3 = (angles, polygon) => {
16
16
  return poly3.transform(matrix, polygon)
17
17
  }
18
18
 
19
- test.only('retessellateCoplanarPolygons: should merge coplanar polygons', (t) => {
19
+ test('retessellateCoplanarPolygons: should merge coplanar polygons', (t) => {
20
20
  const polyA = poly3.create([[-5, -5, 0], [5, -5, 0], [5, 5, 0], [-5, 5, 0]])
21
21
  const polyB = poly3.create([[5, -5, 0], [8, 0, 0], [5, 5, 0]])
22
22
  const polyC = poly3.create([[-5, 5, 0], [-8, 0, 0], [-5, -5, 0]])
@@ -68,3 +68,38 @@ test.only('retessellateCoplanarPolygons: should merge coplanar polygons', (t) =>
68
68
  obs = reTesselateCoplanarPolygons([polyH, polyI, polyJ, polyK, polyL])
69
69
  t.is(obs.length, 1)
70
70
  })
71
+
72
+ // Test for mark-and-filter optimization: multiple polygons that reach their
73
+ // bottom point at the same y-coordinate (triggering the removal code path)
74
+ test('retessellateCoplanarPolygons: should correctly handle multiple polygon removals', (t) => {
75
+ // Create multiple triangular polygons that all end at the same y-coordinate
76
+ // This exercises the mark-and-filter removal optimization
77
+ const poly1 = poly3.create([[0, 0, 0], [2, 0, 0], [1, 3, 0]]) // triangle pointing up
78
+ const poly2 = poly3.create([[3, 0, 0], [5, 0, 0], [4, 3, 0]]) // triangle pointing up
79
+ const poly3a = poly3.create([[6, 0, 0], [8, 0, 0], [7, 3, 0]]) // triangle pointing up
80
+
81
+ // These polygons share the same plane and have vertices at y=0 and y=3
82
+ // During retessellation, all three will be active and then removed at y=3
83
+ const obs = reTesselateCoplanarPolygons([poly1, poly2, poly3a])
84
+
85
+ // Each triangle should be preserved (they don't overlap)
86
+ t.is(obs.length, 3)
87
+
88
+ // Verify each polygon has 3 vertices (triangles)
89
+ obs.forEach((polygon) => {
90
+ t.is(polygon.vertices.length, 3)
91
+ })
92
+ })
93
+
94
+ // Test for mark-and-filter with overlapping polygons that get merged
95
+ test('retessellateCoplanarPolygons: should merge adjacent polygons with shared edges', (t) => {
96
+ // Two adjacent squares sharing an edge at x=5
97
+ const poly1 = poly3.create([[0, 0, 0], [5, 0, 0], [5, 5, 0], [0, 5, 0]])
98
+ const poly2 = poly3.create([[5, 0, 0], [10, 0, 0], [10, 5, 0], [5, 5, 0]])
99
+
100
+ const obs = reTesselateCoplanarPolygons([poly1, poly2])
101
+
102
+ // Should merge into a single rectangle
103
+ t.is(obs.length, 1)
104
+ t.is(obs[0].vertices.length, 4) // rectangle has 4 vertices
105
+ })
@@ -22,8 +22,11 @@ const retessellate = (geometry) => {
22
22
  const destPolygons = []
23
23
  classified.forEach((group) => {
24
24
  if (Array.isArray(group)) {
25
- const reTessellateCoplanarPolygons = reTesselateCoplanarPolygons(group)
26
- destPolygons.push(...reTessellateCoplanarPolygons)
25
+ const coplanarPolygons = reTesselateCoplanarPolygons(group)
26
+ // Use loop instead of spread to avoid stack overflow with large arrays
27
+ for (let i = 0; i < coplanarPolygons.length; i++) {
28
+ destPolygons.push(coplanarPolygons[i])
29
+ }
27
30
  } else {
28
31
  destPolygons.push(group)
29
32
  }
@@ -5,6 +5,6 @@
5
5
  * @returns {Array} a flat list of arguments
6
6
  * @alias module:modeling/utils.flatten
7
7
  */
8
- const flatten = (arr) => arr.reduce((acc, val) => Array.isArray(val) ? acc.concat(flatten(val)) : acc.concat(val), [])
8
+ const flatten = (arr) => arr.flat(Infinity)
9
9
 
10
10
  module.exports = flatten
@@ -0,0 +1,94 @@
1
+ const test = require('ava')
2
+
3
+ const flatten = require('./flatten')
4
+
5
+ test('flatten: test an empty array returns empty.', (t) => {
6
+ t.deepEqual(flatten([]), [])
7
+ })
8
+
9
+ test('flatten: test a flat array is unchanged.', (t) => {
10
+ t.deepEqual(flatten([1, 2, 3]), [1, 2, 3])
11
+ })
12
+
13
+ test('flatten: test single level nesting is flattened.', (t) => {
14
+ t.deepEqual(flatten([1, [2, 3], 4]), [1, 2, 3, 4])
15
+ t.deepEqual(flatten([[1, 2], [3, 4]]), [1, 2, 3, 4])
16
+ t.deepEqual(flatten([[1], [2], [3]]), [1, 2, 3])
17
+ })
18
+
19
+ test('flatten: test deep nesting is flattened.', (t) => {
20
+ t.deepEqual(flatten([1, [2, [3, [4]]]]), [1, 2, 3, 4])
21
+ t.deepEqual(flatten([[[[1]]]]), [1])
22
+ t.deepEqual(flatten([1, [2, [3, [4, [5]]]]]), [1, 2, 3, 4, 5])
23
+ })
24
+
25
+ test('flatten: test mixed nesting depths are flattened.', (t) => {
26
+ t.deepEqual(flatten([1, [2, 3], [[4, 5]], [[[6]]]]), [1, 2, 3, 4, 5, 6])
27
+ })
28
+
29
+ test('flatten: test empty nested arrays are removed.', (t) => {
30
+ t.deepEqual(flatten([[]]), [])
31
+ t.deepEqual(flatten([[], []]), [])
32
+ t.deepEqual(flatten([1, [], 2]), [1, 2])
33
+ t.deepEqual(flatten([[], [1], []]), [1])
34
+ })
35
+
36
+ test('flatten: test single element arrays are flattened.', (t) => {
37
+ t.deepEqual(flatten([1]), [1])
38
+ t.deepEqual(flatten([[1]]), [1])
39
+ t.deepEqual(flatten([[[1]]]), [1])
40
+ })
41
+
42
+ test('flatten: test element order is preserved.', (t) => {
43
+ t.deepEqual(flatten([1, [2, 3], 4, [5, 6]]), [1, 2, 3, 4, 5, 6])
44
+ t.deepEqual(flatten([[1, 2], 3, [4, [5, 6]]]), [1, 2, 3, 4, 5, 6])
45
+ })
46
+
47
+ test('flatten: test object references are preserved.', (t) => {
48
+ const obj1 = { id: 1 }
49
+ const obj2 = { id: 2 }
50
+ const obj3 = { id: 3 }
51
+ const result = flatten([obj1, [obj2, obj3]])
52
+ t.is(result[0], obj1)
53
+ t.is(result[1], obj2)
54
+ t.is(result[2], obj3)
55
+ })
56
+
57
+ test('flatten: test various types are preserved.', (t) => {
58
+ const obj = { a: 1 }
59
+ const fn = () => {}
60
+ t.deepEqual(flatten([1, 'string', null, undefined, true]), [1, 'string', null, undefined, true])
61
+
62
+ const result = flatten([obj, [fn]])
63
+ t.is(result[0], obj)
64
+ t.is(result[1], fn)
65
+ })
66
+
67
+ test('flatten: test large flat array is unchanged.', (t) => {
68
+ const large = []
69
+ for (let i = 0; i < 1000; i++) {
70
+ large.push(i)
71
+ }
72
+ const result = flatten(large)
73
+ t.is(result.length, 1000)
74
+ t.is(result[0], 0)
75
+ t.is(result[999], 999)
76
+ })
77
+
78
+ test('flatten: test large nested array is flattened.', (t) => {
79
+ const nested = []
80
+ for (let i = 0; i < 100; i++) {
81
+ nested.push([i * 10, i * 10 + 1, i * 10 + 2])
82
+ }
83
+ const result = flatten(nested)
84
+ t.is(result.length, 300)
85
+ t.is(result[0], 0)
86
+ t.is(result[3], 10)
87
+ })
88
+
89
+ test('flatten: test input array is not modified.', (t) => {
90
+ const input = [1, [2, 3], 4]
91
+ const inputCopy = JSON.stringify(input)
92
+ flatten(input)
93
+ t.is(JSON.stringify(input), inputCopy)
94
+ })