@jscad/modeling 3.0.3-alpha.0 → 3.0.5-alpha.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.
Files changed (214) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/jscad-modeling.es.js +2 -7
  3. package/dist/jscad-modeling.min.js +2 -7
  4. package/package.json +8 -9
  5. package/rollup.config.js +8 -4
  6. package/src/colors/colorize.js +17 -1
  7. package/src/curves/bezier/arcLengthToT.js +1 -1
  8. package/src/curves/bezier/create.js +1 -1
  9. package/src/curves/bezier/index.js +7 -7
  10. package/src/curves/bezier/length.js +1 -1
  11. package/src/curves/bezier/lengths.js +2 -1
  12. package/src/curves/bezier/tangentAt.js +1 -1
  13. package/src/curves/bezier/valueAt.js +1 -1
  14. package/src/curves/index.js +3 -3
  15. package/src/geometries/geom2/applyTransforms.js +3 -1
  16. package/src/geometries/geom2/clone.js +5 -1
  17. package/src/geometries/geom2/create.js +4 -14
  18. package/src/geometries/geom2/fromPoints.d.ts +4 -0
  19. package/src/geometries/geom2/fromPoints.js +28 -0
  20. package/src/geometries/geom2/fromPoints.test.js +22 -0
  21. package/src/geometries/geom2/fromSides.js +4 -2
  22. package/src/geometries/geom2/index.d.ts +1 -0
  23. package/src/geometries/geom2/index.js +22 -5
  24. package/src/geometries/geom2/isA.js +5 -1
  25. package/src/geometries/geom2/reverse.js +4 -2
  26. package/src/geometries/geom2/toOutlines.js +2 -1
  27. package/src/geometries/geom2/toPoints.js +5 -2
  28. package/src/geometries/geom2/toSides.js +4 -3
  29. package/src/geometries/geom2/toString.js +3 -2
  30. package/src/geometries/geom2/transform.js +4 -2
  31. package/src/geometries/geom2/validate.js +6 -2
  32. package/src/geometries/geom3/clone.js +5 -1
  33. package/src/geometries/geom3/create.js +5 -19
  34. package/src/geometries/geom3/fromVertices.js +13 -1
  35. package/src/geometries/geom3/fromVerticesConvex.js +1 -1
  36. package/src/geometries/geom3/index.d.ts +1 -0
  37. package/src/geometries/geom3/index.js +26 -4
  38. package/src/geometries/geom3/invert.js +5 -1
  39. package/src/geometries/geom3/isA.js +5 -1
  40. package/src/geometries/geom3/isConvex.d.ts +3 -0
  41. package/src/geometries/geom3/isConvex.js +65 -0
  42. package/src/geometries/geom3/isConvex.test.js +44 -0
  43. package/src/geometries/geom3/toPolygons.js +4 -2
  44. package/src/geometries/geom3/toString.js +3 -2
  45. package/src/geometries/geom3/toVertices.js +8 -4
  46. package/src/geometries/geom3/transform.js +5 -2
  47. package/src/geometries/geom3/validate.js +6 -2
  48. package/src/geometries/index.js +9 -7
  49. package/src/geometries/path2/appendArc.js +7 -5
  50. package/src/geometries/path2/appendArc.test.js +11 -15
  51. package/src/geometries/path2/appendBezier.js +6 -4
  52. package/src/geometries/path2/appendPoints.js +4 -2
  53. package/src/geometries/path2/applyTransforms.js +3 -0
  54. package/src/geometries/path2/clone.js +5 -1
  55. package/src/geometries/path2/close.js +5 -1
  56. package/src/geometries/path2/concat.js +3 -2
  57. package/src/geometries/path2/create.js +4 -15
  58. package/src/geometries/path2/equals.js +12 -7
  59. package/src/geometries/path2/fromPoints.js +5 -3
  60. package/src/geometries/path2/index.js +21 -4
  61. package/src/geometries/path2/isA.js +5 -1
  62. package/src/geometries/path2/reverse.js +4 -2
  63. package/src/geometries/path2/toPoints.js +5 -3
  64. package/src/geometries/path2/toString.js +3 -2
  65. package/src/geometries/path2/transform.js +4 -2
  66. package/src/geometries/path2/validate.js +5 -1
  67. package/src/geometries/path3/applyTransforms.js +1 -1
  68. package/src/geometries/path3/clone.d.ts +3 -0
  69. package/src/geometries/path3/clone.js +11 -0
  70. package/src/geometries/path3/close.js +4 -2
  71. package/src/geometries/path3/concat.js +2 -3
  72. package/src/geometries/path3/create.js +4 -20
  73. package/src/geometries/path3/equals.js +4 -2
  74. package/src/geometries/path3/fromVertices.js +2 -3
  75. package/src/geometries/path3/index.d.ts +1 -0
  76. package/src/geometries/path3/index.js +18 -1
  77. package/src/geometries/path3/isA.js +4 -2
  78. package/src/geometries/path3/reverse.js +2 -3
  79. package/src/geometries/path3/toString.js +2 -3
  80. package/src/geometries/path3/toVertices.js +2 -3
  81. package/src/geometries/path3/transform.js +2 -3
  82. package/src/geometries/path3/validate.js +6 -3
  83. package/src/geometries/poly2/arePointsInside.js +4 -1
  84. package/src/geometries/poly2/clone.js +4 -1
  85. package/src/geometries/poly2/create.js +2 -9
  86. package/src/geometries/poly2/index.js +16 -4
  87. package/src/geometries/poly2/isA.js +5 -1
  88. package/src/geometries/poly2/isConvex.js +5 -1
  89. package/src/geometries/poly2/isSimple.js +5 -1
  90. package/src/geometries/poly2/measureArea.js +4 -1
  91. package/src/geometries/poly2/measureBoundingBox.js +6 -1
  92. package/src/geometries/poly2/reverse.js +4 -1
  93. package/src/geometries/poly2/toPoints.js +6 -1
  94. package/src/geometries/poly2/toString.js +5 -1
  95. package/src/geometries/poly2/transform.js +5 -1
  96. package/src/geometries/poly2/type.d.ts +1 -5
  97. package/src/geometries/poly2/validate.js +6 -2
  98. package/src/geometries/poly3/clone.js +4 -1
  99. package/src/geometries/poly3/create.js +3 -11
  100. package/src/geometries/poly3/fromVerticesAndPlane.js +3 -1
  101. package/src/geometries/poly3/index.js +19 -4
  102. package/src/geometries/poly3/invert.js +4 -1
  103. package/src/geometries/poly3/isA.js +5 -1
  104. package/src/geometries/poly3/isConvex.js +5 -1
  105. package/src/geometries/poly3/measureArea.js +5 -1
  106. package/src/geometries/poly3/measureBoundingBox.js +4 -1
  107. package/src/geometries/poly3/measureBoundingSphere.js +4 -3
  108. package/src/geometries/poly3/measureSignedVolume.js +6 -1
  109. package/src/geometries/poly3/plane.js +6 -0
  110. package/src/geometries/poly3/toString.js +5 -1
  111. package/src/geometries/poly3/toVertices.js +6 -1
  112. package/src/geometries/poly3/transform.js +5 -1
  113. package/src/geometries/poly3/validate.js +6 -2
  114. package/src/geometries/slice/calculatePlane.js +3 -3
  115. package/src/geometries/slice/clone.js +4 -1
  116. package/src/geometries/slice/create.js +5 -10
  117. package/src/geometries/slice/equals.js +5 -1
  118. package/src/geometries/slice/fromOutlines.d.ts +5 -0
  119. package/src/geometries/slice/fromOutlines.js +16 -0
  120. package/src/geometries/slice/fromOutlines.test.js +17 -0
  121. package/src/geometries/slice/fromVertices.js +3 -3
  122. package/src/geometries/slice/index.d.ts +1 -1
  123. package/src/geometries/slice/index.js +20 -5
  124. package/src/geometries/slice/isA.js +5 -1
  125. package/src/geometries/slice/reverse.js +5 -2
  126. package/src/geometries/slice/toEdges.js +5 -3
  127. package/src/geometries/slice/toPolygons.js +5 -1
  128. package/src/geometries/slice/toString.js +5 -1
  129. package/src/geometries/slice/toVertices.js +5 -3
  130. package/src/geometries/slice/transform.js +4 -3
  131. package/src/geometries/slice/validate.js +3 -2
  132. package/src/index.d.ts +1 -0
  133. package/src/index.js +4 -0
  134. package/src/maths/constants.js +11 -7
  135. package/src/maths/index.js +2 -1
  136. package/src/maths/mat4/isOnlyTransformScale.js +1 -1
  137. package/src/maths/plane/fromNormalAndPoint.js +4 -6
  138. package/src/maths/plane/fromPoints.js +8 -7
  139. package/src/maths/plane/fromPointsRandom.js +13 -13
  140. package/src/measurements/measureAggregateEpsilon.js +3 -1
  141. package/src/measurements/measureAggregateEpsilon.test.js +1 -1
  142. package/src/measurements/measureArea.js +6 -4
  143. package/src/measurements/measureArea.test.js +4 -1
  144. package/src/measurements/measureBoundingBox.js +16 -2
  145. package/src/measurements/measureBoundingBox.test.js +4 -1
  146. package/src/measurements/measureBoundingSphere.js +38 -29
  147. package/src/measurements/measureBoundingSphere.test.js +4 -1
  148. package/src/measurements/measureCenterOfMass.js +3 -2
  149. package/src/measurements/measureEpsilon.js +4 -2
  150. package/src/operations/booleans/index.js +2 -0
  151. package/src/operations/booleans/intersect.js +0 -1
  152. package/src/operations/booleans/scission.js +0 -1
  153. package/src/operations/booleans/trees/splitLineSegmentByPlane.js +1 -4
  154. package/src/operations/booleans/trees/splitPolygonByPlane.d.ts +1 -3
  155. package/src/operations/booleans/trees/splitPolygonByPlane.test.js +138 -0
  156. package/src/operations/booleans/union.js +1 -1
  157. package/src/operations/booleans/unionGeom3.test.js +35 -0
  158. package/src/operations/extrusions/extrudeFromSlices.js +16 -6
  159. package/src/operations/extrusions/extrudeFromSlices.test.js +1 -1
  160. package/src/operations/extrusions/extrudeHelical.js +2 -1
  161. package/src/operations/extrusions/extrudeLinear.js +1 -1
  162. package/src/operations/extrusions/extrudeLinearGeom2.js +2 -1
  163. package/src/operations/extrusions/extrudeRotate.js +3 -2
  164. package/src/operations/extrusions/extrudeRotate.test.js +34 -0
  165. package/src/operations/extrusions/extrudeWalls.test.js +60 -0
  166. package/src/operations/hulls/hull.js +3 -2
  167. package/src/operations/hulls/toUniquePoints.js +3 -0
  168. package/src/operations/minkowski/index.d.ts +1 -0
  169. package/src/operations/minkowski/index.js +15 -0
  170. package/src/operations/minkowski/minkowskiSum.d.ts +4 -0
  171. package/src/operations/minkowski/minkowskiSum.js +223 -0
  172. package/src/operations/minkowski/minkowskiSum.test.js +199 -0
  173. package/src/operations/modifiers/generalize.js +9 -2
  174. package/src/operations/modifiers/reTesselateCoplanarPolygons.js +10 -3
  175. package/src/operations/modifiers/reTesselateCoplanarPolygons.test.js +36 -1
  176. package/src/operations/modifiers/retessellate.js +4 -2
  177. package/src/operations/modifiers/snap.js +22 -3
  178. package/src/operations/modifiers/snap.test.js +24 -15
  179. package/src/operations/offsets/offsetGeom3.test.js +5 -7
  180. package/src/operations/transforms/align.js +2 -1
  181. package/src/operations/transforms/align.test.js +1 -1
  182. package/src/operations/transforms/mirror.js +6 -2
  183. package/src/operations/transforms/rotate.js +6 -2
  184. package/src/operations/transforms/scale.js +6 -2
  185. package/src/operations/transforms/transform.js +6 -2
  186. package/src/operations/transforms/transform.test.js +16 -5
  187. package/src/operations/transforms/translate.js +6 -2
  188. package/src/primitives/arc.js +13 -12
  189. package/src/primitives/arc.test.js +104 -113
  190. package/src/primitives/circle.js +10 -9
  191. package/src/primitives/cube.js +5 -6
  192. package/src/primitives/cuboid.js +6 -6
  193. package/src/primitives/cylinder.js +8 -8
  194. package/src/primitives/cylinderElliptic.js +11 -11
  195. package/src/primitives/ellipse.js +10 -9
  196. package/src/primitives/ellipsoid.js +8 -8
  197. package/src/primitives/geodesicSphere.js +6 -6
  198. package/src/primitives/line.js +2 -0
  199. package/src/primitives/polygon.js +6 -7
  200. package/src/primitives/polyhedron.js +7 -8
  201. package/src/primitives/rectangle.js +6 -6
  202. package/src/primitives/roundedCuboid.js +8 -8
  203. package/src/primitives/roundedCylinder.js +9 -9
  204. package/src/primitives/roundedRectangle.js +8 -8
  205. package/src/primitives/sphere.js +7 -8
  206. package/src/primitives/square.js +6 -6
  207. package/src/primitives/star.js +10 -10
  208. package/src/primitives/torus.js +11 -11
  209. package/src/primitives/triangle.js +7 -6
  210. package/src/utils/areAllShapesTheSameType.js +4 -0
  211. package/src/utils/flatten.js +1 -1
  212. package/src/utils/flatten.test.js +94 -0
  213. package/src/geometries/slice/fromGeom2.d.ts +0 -5
  214. package/src/geometries/slice/fromGeom2.js +0 -17
@@ -0,0 +1,199 @@
1
+ import test from 'ava'
2
+
3
+ import { geom3 } from '../../geometries/index.js'
4
+ import { cuboid, sphere, torus } from '../../primitives/index.js'
5
+ import { subtract } from '../booleans/index.js'
6
+ import { measureBoundingBox } from '../../measurements/index.js'
7
+
8
+ import { minkowskiSum } from './index.js'
9
+
10
+ test('minkowskiSum: throws for non-geom3 inputs', (t) => {
11
+ t.throws(() => minkowskiSum('invalid', cuboid()), { message: /requires geom3/ })
12
+ t.throws(() => minkowskiSum(cuboid(), 'invalid'), { message: /requires geom3/ })
13
+ })
14
+
15
+ test('minkowskiSum: throws for wrong number of geometries', (t) => {
16
+ t.throws(() => minkowskiSum(), { message: /exactly two/ })
17
+ t.throws(() => minkowskiSum(cuboid()), { message: /exactly two/ })
18
+ t.throws(() => minkowskiSum(cuboid(), cuboid(), cuboid()), { message: /exactly two/ })
19
+ })
20
+
21
+ test('minkowskiSum: cube + cube produces correct bounds', (t) => {
22
+ // Cube1: size 10 (±5 from origin)
23
+ // Cube2: size 4 (±2 from origin)
24
+ // Minkowski sum should be size 14 (±7 from origin)
25
+ const cube1 = cuboid({ size: [10, 10, 10] })
26
+ const cube2 = cuboid({ size: [4, 4, 4] })
27
+
28
+ const result = minkowskiSum(cube1, cube2)
29
+
30
+ t.notThrows(() => geom3.validate(result))
31
+
32
+ const bounds = measureBoundingBox(result)
33
+ // Allow small tolerance for floating point
34
+ t.true(Math.abs(bounds[0][0] - (-7)) < 0.001)
35
+ t.true(Math.abs(bounds[0][1] - (-7)) < 0.001)
36
+ t.true(Math.abs(bounds[0][2] - (-7)) < 0.001)
37
+ t.true(Math.abs(bounds[1][0] - 7) < 0.001)
38
+ t.true(Math.abs(bounds[1][1] - 7) < 0.001)
39
+ t.true(Math.abs(bounds[1][2] - 7) < 0.001)
40
+ })
41
+
42
+ test('minkowskiSum: cube + sphere produces correct bounds', (t) => {
43
+ // Cube: size 10 (±5 from origin)
44
+ // Sphere: radius 2
45
+ // Minkowski sum should be ±7 from origin
46
+ const cube = cuboid({ size: [10, 10, 10] })
47
+ const sph = sphere({ radius: 2, segments: 16 })
48
+
49
+ const result = minkowskiSum(cube, sph)
50
+
51
+ t.notThrows(() => geom3.validate(result))
52
+
53
+ const bounds = measureBoundingBox(result)
54
+ // Allow small tolerance
55
+ t.true(Math.abs(bounds[0][0] - (-7)) < 0.1)
56
+ t.true(Math.abs(bounds[1][0] - 7) < 0.1)
57
+ })
58
+
59
+ test('minkowskiSum: sphere + sphere produces correct bounds', (t) => {
60
+ // Sphere1: radius 3
61
+ // Sphere2: radius 2
62
+ // Minkowski sum should be a sphere-like shape with radius ~5
63
+ const sph1 = sphere({ radius: 3, segments: 16 })
64
+ const sph2 = sphere({ radius: 2, segments: 16 })
65
+
66
+ const result = minkowskiSum(sph1, sph2)
67
+
68
+ t.notThrows(() => geom3.validate(result))
69
+
70
+ const bounds = measureBoundingBox(result)
71
+ // Should be approximately ±5
72
+ t.true(Math.abs(bounds[0][0] - (-5)) < 0.2)
73
+ t.true(Math.abs(bounds[1][0] - 5) < 0.2)
74
+ })
75
+
76
+ test('minkowskiSum: empty geometry returns empty', (t) => {
77
+ const empty = geom3.create()
78
+ const cube = cuboid({ size: [10, 10, 10] })
79
+
80
+ const result = minkowskiSum(empty, cube)
81
+
82
+ t.notThrows(() => geom3.validate(result))
83
+ t.is(geom3.toPolygons(result).length, 0)
84
+ })
85
+
86
+ test('minkowskiSum: result is convex', (t) => {
87
+ const cube = cuboid({ size: [10, 10, 10] })
88
+ const sph = sphere({ radius: 2, segments: 12 })
89
+
90
+ const result = minkowskiSum(cube, sph)
91
+
92
+ t.notThrows(() => geom3.validate(result))
93
+ t.true(geom3.isConvex(result))
94
+ })
95
+
96
+ // Non-convex tests
97
+
98
+ test('minkowskiSum: non-convex + convex produces valid geometry', (t) => {
99
+ // Create L-shaped non-convex geometry
100
+ const big = cuboid({ size: [10, 10, 10] })
101
+ const corner = cuboid({ size: [6, 6, 12], center: [3, 3, 0] })
102
+ const lShape = subtract(big, corner)
103
+
104
+ t.false(geom3.isConvex(lShape))
105
+
106
+ const sph = sphere({ radius: 1, segments: 8 })
107
+
108
+ const result = minkowskiSum(lShape, sph)
109
+
110
+ t.true(geom3.toPolygons(result).length > 0)
111
+ t.true(geom3.isA(result))
112
+ })
113
+
114
+ test('minkowskiSum: non-convex + convex produces correct bounds', (t) => {
115
+ // Cube with hole through it
116
+ const cube = cuboid({ size: [10, 10, 10] })
117
+ const hole = cuboid({ size: [4, 4, 20] })
118
+ const cubeWithHole = subtract(cube, hole)
119
+
120
+ t.false(geom3.isConvex(cubeWithHole))
121
+
122
+ // Offset by sphere of radius 1
123
+ const sph = sphere({ radius: 1, segments: 8 })
124
+ const result = minkowskiSum(cubeWithHole, sph)
125
+
126
+ t.true(geom3.isA(result))
127
+
128
+ const bounds = measureBoundingBox(result)
129
+
130
+ // Original cube is ±5, plus sphere radius 1 = ±6
131
+ t.true(Math.abs(bounds[0][0] - (-6)) < 0.2)
132
+ t.true(Math.abs(bounds[1][0] - 6) < 0.2)
133
+ })
134
+
135
+ test('minkowskiSum: convex + non-convex swaps operands', (t) => {
136
+ // Minkowski sum is commutative, so A⊕B = B⊕A
137
+ const cube = cuboid({ size: [10, 10, 10] })
138
+ const hole = cuboid({ size: [4, 4, 20] })
139
+ const cubeWithHole = subtract(cube, hole)
140
+
141
+ const sph = sphere({ radius: 1, segments: 8 })
142
+
143
+ // convex + non-convex should work (swaps internally)
144
+ const result = minkowskiSum(sph, cubeWithHole)
145
+
146
+ t.true(geom3.isA(result))
147
+ t.true(geom3.toPolygons(result).length > 0)
148
+ })
149
+
150
+ test('minkowskiSum: throws for two non-convex geometries', (t) => {
151
+ const cube1 = cuboid({ size: [10, 10, 10] })
152
+ const hole1 = cuboid({ size: [4, 4, 20] })
153
+ const nonConvex1 = subtract(cube1, hole1)
154
+
155
+ const cube2 = cuboid({ size: [8, 8, 8] })
156
+ const hole2 = cuboid({ size: [3, 3, 16] })
157
+ const nonConvex2 = subtract(cube2, hole2)
158
+
159
+ t.throws(() => minkowskiSum(nonConvex1, nonConvex2), { message: /two non-convex/ })
160
+ })
161
+
162
+ test('minkowskiSum: torus + sphere preserves hole (face-local apex)', (t) => {
163
+ // Torus with innerRadius=3 (tube radius) and outerRadius=8 (distance to tube center)
164
+ // At z=0, the torus extends from radius 5 to 11 (8-3 to 8+3)
165
+ // Adding sphere of radius 1 should give 4 to 12
166
+ const torusShape = torus({
167
+ innerRadius: 3,
168
+ outerRadius: 8,
169
+ innerSegments: 16,
170
+ outerSegments: 24
171
+ })
172
+
173
+ const sph = sphere({ radius: 1, segments: 8 })
174
+
175
+ t.false(geom3.isConvex(torusShape))
176
+
177
+ const result = minkowskiSum(torusShape, sph)
178
+
179
+ t.true(geom3.isA(result))
180
+ t.true(geom3.toPolygons(result).length > 0)
181
+
182
+ // Check that the hole is preserved by examining vertices at z≈0
183
+ const polygons = geom3.toPolygons(result)
184
+ let minRadius = Infinity
185
+
186
+ for (const poly of polygons) {
187
+ for (const v of poly.vertices) {
188
+ if (Math.abs(v[2]) < 0.5) {
189
+ const r = Math.sqrt(v[0] * v[0] + v[1] * v[1])
190
+ if (r < minRadius) minRadius = r
191
+ }
192
+ }
193
+ }
194
+
195
+ // With face-local apex, hole should be preserved
196
+ // Inner radius should be around 4 (8-3-1 = 4)
197
+ // If centroid-based (buggy), hole would be filled and minRadius would be ~0
198
+ t.true(minRadius > 3, `hole should be preserved, got minRadius=${minRadius}`)
199
+ })
@@ -3,6 +3,8 @@ import { measureEpsilon } from '../../measurements/measureEpsilon.js'
3
3
  import * as geom2 from '../../geometries/geom2/index.js'
4
4
  import * as geom3 from '../../geometries/geom3/index.js'
5
5
  import * as path2 from '../../geometries/path2/index.js'
6
+ import * as path3 from '../../geometries/path3/index.js'
7
+ import * as slice from '../../geometries/slice/index.js'
6
8
 
7
9
  import { snapPolygons } from './snapPolygons.js'
8
10
  import { mergePolygons } from './mergePolygons.js'
@@ -13,6 +15,10 @@ import { triangulatePolygons } from './triangulatePolygons.js'
13
15
  */
14
16
  const generalizePath2 = (options, geometry) => geometry
15
17
 
18
+ /*
19
+ */
20
+ const generalizePath3 = (options, geometry) => geometry
21
+
16
22
  /*
17
23
  */
18
24
  const generalizeGeom2 = (options, geometry) => geometry
@@ -66,9 +72,10 @@ const generalizeGeom3 = (options, geometry) => {
66
72
  */
67
73
  export const generalize = (options, ...geometries) => {
68
74
  const results = geometries.map((geometry) => {
69
- if (path2.isA(geometry)) return generalizePath2(options, geometry)
70
- if (geom2.isA(geometry)) return generalizeGeom2(options, geometry)
71
75
  if (geom3.isA(geometry)) return generalizeGeom3(options, geometry)
76
+ if (geom2.isA(geometry)) return generalizeGeom2(options, geometry)
77
+ if (path2.isA(geometry)) return generalizePath2(options, geometry)
78
+ if (path3.isA(geometry)) return generalizePath3(options, geometry)
72
79
  if (Array.isArray(geometry)) return generalize(options, ...geometry)
73
80
  return geometry
74
81
  })
@@ -121,6 +121,7 @@ export const reTesselateCoplanarPolygons = (sourcePolygons) => {
121
121
  // at the left and right side of the polygon
122
122
  // Iterate over all polygons that have a corner at this y coordinate:
123
123
  const polygonIndexesWithCorner = yCoordinateToPolygonIndexes.get(yCoordinate)
124
+ let removeCount = 0 // track removals to filter at end (avoids O(n²) splice)
124
125
  for (let activePolygonIndex = 0; activePolygonIndex < activePolygons.length; ++activePolygonIndex) {
125
126
  const activePolygon = activePolygons[activePolygonIndex]
126
127
  const polygonIndex = activePolygon.polygonIndex
@@ -144,9 +145,9 @@ export const reTesselateCoplanarPolygons = (sourcePolygons) => {
144
145
  }
145
146
  if ((newLeftVertexIndex !== activePolygon.leftVertexIndex) && (newLeftVertexIndex === newRightVertexIndex)) {
146
147
  // We have increased leftVertexIndex or decreased rightVertexIndex, and now they point to the same vertex
147
- // This means that this is the bottom point of the polygon. We'll remove it:
148
- activePolygons.splice(activePolygonIndex, 1)
149
- --activePolygonIndex
148
+ // This means that this is the bottom point of the polygon, so Mark it for removal
149
+ activePolygon.remove = true
150
+ removeCount++
150
151
  } else {
151
152
  activePolygon.leftVertexIndex = newLeftVertexIndex
152
153
  activePolygon.rightVertexIndex = newRightVertexIndex
@@ -161,6 +162,12 @@ export const reTesselateCoplanarPolygons = (sourcePolygons) => {
161
162
  }
162
163
  } // if polygon has corner here
163
164
  } // for activePolygonIndex
165
+
166
+ // Filter out marked polygons in single pass (O(n) instead of O(n²) splice)
167
+ if (removeCount > 0) {
168
+ activePolygons = activePolygons.filter((p) => !p.remove)
169
+ }
170
+
164
171
  let nextYcoordinate
165
172
  if (yIndex >= yCoordinates.length - 1) {
166
173
  // 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
+ })
@@ -24,8 +24,10 @@ export const retessellate = (geometry) => {
24
24
  const destPolygons = []
25
25
  classified.forEach((group) => {
26
26
  if (Array.isArray(group)) {
27
- const reTessellateCoplanarPolygons = reTesselateCoplanarPolygons(group)
28
- destPolygons.push(...reTessellateCoplanarPolygons)
27
+ const coplanarPolygons = reTesselateCoplanarPolygons(group)
28
+ for (let i = 0; i < coplanarPolygons.length; i++) {
29
+ destPolygons.push(coplanarPolygons[i])
30
+ }
29
31
  } else {
30
32
  destPolygons.push(group)
31
33
  }
@@ -1,22 +1,38 @@
1
1
  import * as vec2 from '../../maths/vec2/index.js'
2
+ import * as vec3 from '../../maths/vec2/index.js'
2
3
 
3
4
  import * as geom2 from '../../geometries/geom2/index.js'
4
5
  import * as geom3 from '../../geometries/geom3/index.js'
5
6
  import * as path2 from '../../geometries/path2/index.js'
7
+ import * as path3 from '../../geometries/path3/index.js'
6
8
  import * as poly2 from '../../geometries/poly2/index.js'
7
9
 
8
10
  import { measureEpsilon } from '../../measurements/measureEpsilon.js'
9
11
 
10
12
  import { snapPolygons } from './snapPolygons.js'
11
13
 
14
+ /*
15
+ */
12
16
  const snapPath2 = (geometry) => {
13
17
  const epsilon = measureEpsilon(geometry)
14
18
  const points = path2.toPoints(geometry)
15
19
  const newPoints = points.map((point) => vec2.snap(vec2.create(), point, epsilon))
16
20
  // snap can produce duplicate points, remove those
17
- return path2.create(newPoints)
21
+ return path2.fromPoints({}, newPoints)
22
+ }
23
+
24
+ /*
25
+ */
26
+ const snapPath3 = (geometry) => {
27
+ const epsilon = measureEpsilon(geometry)
28
+ const vertices = path3.toVertices(geometry)
29
+ const newVertices = vertices.map((vertice) => vec3.snap(vec3.create(), vertice, epsilon))
30
+ // snap can produce duplicate points, remove those
31
+ return path3.fromVertices({}, newVertices)
18
32
  }
19
33
 
34
+ /*
35
+ */
20
36
  const snapGeom2 = (geometry) => {
21
37
  const epsilon = measureEpsilon(geometry)
22
38
  const outlines = geom2.toOutlines(geometry)
@@ -38,6 +54,8 @@ const snapGeom2 = (geometry) => {
38
54
  return geom2.create(newOutlines)
39
55
  }
40
56
 
57
+ /*
58
+ */
41
59
  const snapGeom3 = (geometry) => {
42
60
  const epsilon = measureEpsilon(geometry)
43
61
  const polygons = geom3.toPolygons(geometry)
@@ -54,9 +72,10 @@ const snapGeom3 = (geometry) => {
54
72
  */
55
73
  export const snap = (...geometries) => {
56
74
  const results = geometries.map((geometry) => {
57
- if (path2.isA(geometry)) return snapPath2(geometry)
58
- if (geom2.isA(geometry)) return snapGeom2(geometry)
59
75
  if (geom3.isA(geometry)) return snapGeom3(geometry)
76
+ if (geom2.isA(geometry)) return snapGeom2(geometry)
77
+ if (path2.isA(geometry)) return snapPath2(geometry)
78
+ if (path3.isA(geometry)) return snapPath3(geometry)
60
79
  if (Array.isArray(geometry)) return snap(...geometry)
61
80
  return geometry
62
81
  })
@@ -25,31 +25,40 @@ test('snap: snap of a path2 produces an expected path2', (t) => {
25
25
 
26
26
  pts = path2.toPoints(results[1])
27
27
  exp = [
28
- [0.5, 0], [0.383022221559489, 0.3213938048432696],
29
- [0.08682408883346521, 0.492403876506104], [-0.2499999999999999, 0.43301270189221935],
30
- [-0.46984631039295416, 0.17101007166283444], [-0.4698463103929542, -0.17101007166283433],
31
- [-0.2500000000000002, -0.43301270189221924], [0.08682408883346499, -0.49240387650610407],
32
- [0.3830222215594889, -0.3213938048432698]
28
+ [0.5, 0],
29
+ [0.35355000000000003, 0.35355000000000003],
30
+ [0, 0.5],
31
+ [-0.35355000000000003, 0.35355000000000003],
32
+ [-0.5, 0],
33
+ [-0.35355000000000003, -0.35355000000000003],
34
+ [0, -0.5],
35
+ [0.35355000000000003, -0.35355000000000003]
33
36
  ]
34
37
  t.true(comparePoints(pts, exp))
35
38
 
36
39
  pts = path2.toPoints(results[2])
37
40
  exp = [
38
- [0.6666666666666666, 0], [0.5106962954126519, 0.4285250731243595],
39
- [0.11576545177795361, 0.6565385020081387], [-0.33333333333333315, 0.5773502691896257],
40
- [-0.6264617471906055, 0.22801342888377923], [-0.6264617471906055, -0.2280134288837791],
41
- [-0.3333333333333336, -0.5773502691896256], [0.1157654517779533, -0.6565385020081387],
42
- [0.5106962954126518, -0.4285250731243597]
41
+ [0.6666666666666666, 0],
42
+ [0.4714, 0.4714],
43
+ [0, 0.6666666666666666],
44
+ [-0.4714, 0.4714],
45
+ [-0.6666666666666666, 0],
46
+ [-0.4714, -0.4714],
47
+ [0, -0.6666666666666666],
48
+ [0.4714, -0.4714]
43
49
  ]
44
50
  t.true(comparePoints(pts, exp))
45
51
 
46
52
  pts = path2.toPoints(results[3])
47
53
  exp = [
48
- [1570.7979271820118, 0], [1203.3061290889411, 1009.6890116376164],
49
- [272.7710864950155, 1546.9412033856784], [-785.3989635910059, 1360.3552181729126],
50
- [-1476.0772155839566, 537.2521917480618], [-1476.0772155839566, -537.2521917480618],
51
- [-785.3989635910059, -1360.3552181729126], [272.7710864950155, -1546.9412033856784],
52
- [1203.3061290889411, -1009.6890116376164]
54
+ [1570.7963267948967, 0],
55
+ [1110.7100826766714, 1110.7100826766714],
56
+ [0, 1570.7963267948967],
57
+ [-1110.7100826766714, 1110.7100826766714],
58
+ [-1570.7963267948967, 0],
59
+ [-1110.7100826766714, -1110.7100826766714],
60
+ [0, -1570.7963267948967],
61
+ [1110.7100826766714, -1110.7100826766714]
53
62
  ]
54
63
  t.true(comparePoints(pts, exp))
55
64
  })
@@ -69,8 +69,7 @@ test('offset: offset of a geom3 produces expected changes to polygons', (t) => {
69
69
  t.is(pts2.length, 864)
70
70
  })
71
71
 
72
- test('offsetGeom3: offset completes properly, issue 876', async (t) => {
73
- setTimeout(() => t.fail(), 1000)
72
+ test('offsetGeom3: offset completes properly, issue 876', (t) => {
74
73
  const polies = [
75
74
  poly3.create([[-19.61, -0.7999999999999986, 11.855], [-19.61, -0.8000000000000015, -11.855], [-19.61, -2.7500000000000018, -11.855], [-19.61, -2.7499999999999982, 11.855]]),
76
75
  poly3.create([[-17.32, -2.75, 10], [-17.32, -2.7500000000000013, -10], [-17.32, -0.8000000000000014, -10], [-17.32, -0.7999999999999987, 10]]),
@@ -90,9 +89,8 @@ test('offsetGeom3: offset completes properly, issue 876', async (t) => {
90
89
 
91
90
  const sub = geom3.create(polies)
92
91
 
93
- return new Promise((resolve, reject) => {
94
- offset({ delta: 1.3, corners: 'round', segments: 12 }, sub)
95
- t.pass()
96
- resolve()
97
- })
92
+ const obs = offset({ delta: 1.3, corners: 'round', segments: 12 }, sub)
93
+ t.notThrows.skip(() => geom3.validate(obs))
94
+ t.is(measureArea(obs), 524.9674760547548)
95
+ t.is(measureVolume(obs), 604.0599465573156)
98
96
  })
@@ -76,12 +76,13 @@ export const align = (options, ...geometries) => {
76
76
  options = validateOptions(options)
77
77
  let { modes, relativeTo, grouped } = options
78
78
 
79
+ geometries = coalesce(geometries)
80
+
79
81
  if (relativeTo.filter((val) => val == null).length) {
80
82
  const bounds = measureAggregateBoundingBox(geometries)
81
83
  relativeTo = populateRelativeToFromBounds(relativeTo, modes, bounds)
82
84
  }
83
85
  if (grouped) {
84
- geometries = coalesce(geometries)
85
86
  geometries = alignGeometries(geometries, modes, relativeTo)
86
87
  } else {
87
88
  geometries = geometries.map((geometry) => alignGeometries(geometry, modes, relativeTo))
@@ -79,7 +79,7 @@ test('align: multiple objects ungrouped, relativeTo is nulls, returns geometry a
79
79
  cube({ size: 2, center: [4, 4, 4] }),
80
80
  cube({ size: 4, center: [10, 10, 10] })
81
81
  ]
82
- const aligned = align({ modes: ['center', 'min', 'max'], relativeTo: [null, null, null], grouped: false }, ...original)
82
+ const aligned = align({ modes: ['center', 'min', 'max'], relativeTo: [null, null, null], grouped: false }, original)
83
83
  const bounds = measureAggregateBoundingBox(aligned)
84
84
  const expectedBounds = [[5.5, 3, 8], [9.5, 7, 12]]
85
85
  t.notThrows(() => geom3.validate(aligned[0]))
@@ -4,6 +4,8 @@ import * as plane from '../../maths/plane/index.js'
4
4
  import * as geom2 from '../../geometries/geom2/index.js'
5
5
  import * as geom3 from '../../geometries/geom3/index.js'
6
6
  import * as path2 from '../../geometries/path2/index.js'
7
+ import * as path3 from '../../geometries/path3/index.js'
8
+ import * as slice from '../../geometries/slice/index.js'
7
9
 
8
10
  /**
9
11
  * Mirror the given objects using the given options.
@@ -33,9 +35,11 @@ export const mirror = (options, ...objects) => {
33
35
  const matrix = mat4.mirrorByPlane(mat4.create(), planeOfMirror)
34
36
 
35
37
  const results = objects.map((object) => {
36
- if (path2.isA(object)) return path2.transform(matrix, object)
37
- if (geom2.isA(object)) return geom2.transform(matrix, object)
38
38
  if (geom3.isA(object)) return geom3.transform(matrix, object)
39
+ if (geom2.isA(object)) return geom2.transform(matrix, object)
40
+ if (path2.isA(object)) return path2.transform(matrix, object)
41
+ if (path3.isA(object)) return path3.transform(matrix, object)
42
+ if (slice.isA(object)) return slice.transform(matrix, object)
39
43
  // handle recursive arrays
40
44
  if (Array.isArray(object)) return mirror(options, ...object)
41
45
  return object
@@ -3,6 +3,8 @@ import * as mat4 from '../../maths/mat4/index.js'
3
3
  import * as geom2 from '../../geometries/geom2/index.js'
4
4
  import * as geom3 from '../../geometries/geom3/index.js'
5
5
  import * as path2 from '../../geometries/path2/index.js'
6
+ import * as path3 from '../../geometries/path3/index.js'
7
+ import * as slice from '../../geometries/slice/index.js'
6
8
 
7
9
  /**
8
10
  * Rotate the given objects using the given options.
@@ -28,9 +30,11 @@ export const rotate = (angles, ...objects) => {
28
30
  const matrix = mat4.fromTaitBryanRotation(mat4.create(), yaw, pitch, roll)
29
31
 
30
32
  const results = objects.map((object) => {
31
- if (path2.isA(object)) return path2.transform(matrix, object)
32
- if (geom2.isA(object)) return geom2.transform(matrix, object)
33
33
  if (geom3.isA(object)) return geom3.transform(matrix, object)
34
+ if (geom2.isA(object)) return geom2.transform(matrix, object)
35
+ if (path2.isA(object)) return path2.transform(matrix, object)
36
+ if (path3.isA(object)) return path3.transform(matrix, object)
37
+ if (slice.isA(object)) return slice.transform(matrix, object)
34
38
  // handle recursive arrays
35
39
  if (Array.isArray(object)) return rotate(angles, ...object)
36
40
  return object
@@ -3,6 +3,8 @@ import * as mat4 from '../../maths/mat4/index.js'
3
3
  import * as geom2 from '../../geometries/geom2/index.js'
4
4
  import * as geom3 from '../../geometries/geom3/index.js'
5
5
  import * as path2 from '../../geometries/path2/index.js'
6
+ import * as path3 from '../../geometries/path3/index.js'
7
+ import * as slice from '../../geometries/slice/index.js'
6
8
 
7
9
  /**
8
10
  * Scale the given objects using the given options.
@@ -26,9 +28,11 @@ export const scale = (factors, ...objects) => {
26
28
  const matrix = mat4.fromScaling(mat4.create(), factors)
27
29
 
28
30
  const results = objects.map((object) => {
29
- if (path2.isA(object)) return path2.transform(matrix, object)
30
- if (geom2.isA(object)) return geom2.transform(matrix, object)
31
31
  if (geom3.isA(object)) return geom3.transform(matrix, object)
32
+ if (geom2.isA(object)) return geom2.transform(matrix, object)
33
+ if (path2.isA(object)) return path2.transform(matrix, object)
34
+ if (path3.isA(object)) return path3.transform(matrix, object)
35
+ if (slice.isA(object)) return slice.transform(matrix, object)
32
36
  // handle recursive arrays
33
37
  if (Array.isArray(object)) return scale(factors, ...object)
34
38
  return object
@@ -1,6 +1,8 @@
1
1
  import * as geom2 from '../../geometries/geom2/index.js'
2
2
  import * as geom3 from '../../geometries/geom3/index.js'
3
3
  import * as path2 from '../../geometries/path2/index.js'
4
+ import * as path3 from '../../geometries/path3/index.js'
5
+ import * as slice from '../../geometries/slice/index.js'
4
6
 
5
7
  /**
6
8
  * Transform the given objects using the given matrix.
@@ -16,9 +18,11 @@ export const transform = (matrix, ...objects) => {
16
18
  // TODO how to check that the matrix is REAL?
17
19
 
18
20
  const results = objects.map((object) => {
19
- if (path2.isA(object)) return path2.transform(matrix, object)
20
- if (geom2.isA(object)) return geom2.transform(matrix, object)
21
21
  if (geom3.isA(object)) return geom3.transform(matrix, object)
22
+ if (geom2.isA(object)) return geom2.transform(matrix, object)
23
+ if (path2.isA(object)) return path2.transform(matrix, object)
24
+ if (path3.isA(object)) return path3.transform(matrix, object)
25
+ if (slice.isA(object)) return slice.transform(matrix, object)
22
26
  // handle recursive arrays
23
27
  if (Array.isArray(object)) return transform(matrix, ...object)
24
28
  return object
@@ -4,11 +4,11 @@ import { comparePoints, comparePolygonsAsPoints } from '../../../test/helpers/in
4
4
 
5
5
  import { mat4 } from '../../maths/index.js'
6
6
 
7
- import { geom2, geom3, path2 } from '../../geometries/index.js'
7
+ import { geom2, geom3, path2, path3 } from '../../geometries/index.js'
8
8
 
9
9
  import { transform } from './index.js'
10
10
 
11
- test('transform: transforming of a path2 produces expected changes to points', (t) => {
11
+ test('transform: (path2)', (t) => {
12
12
  const matrix = mat4.fromTranslation(mat4.create(), [2, 2, 0])
13
13
  let geometry = path2.fromPoints({}, [[0, 0], [1, 0]])
14
14
 
@@ -19,7 +19,18 @@ test('transform: transforming of a path2 produces expected changes to points', (
19
19
  t.true(comparePoints(obs, exp))
20
20
  })
21
21
 
22
- test('transform: transforming of a geom2 produces expected changes to sides', (t) => {
22
+ test('transform: (path3)', (t) => {
23
+ const matrix = mat4.fromTranslation(mat4.create(), [2, 2, 2])
24
+ let geometry = path3.fromVertices({ closed: true }, [[0, 0, 0], [1, 0, 1], [3, 2, 1]])
25
+
26
+ geometry = transform(matrix, geometry)
27
+ const obs = path3.toVertices(geometry)
28
+ const exp = [[2, 2, 2], [3, 2, 3], [5, 4, 3]]
29
+ t.notThrows(() => path3.validate(geometry))
30
+ t.true(comparePoints(obs, exp))
31
+ })
32
+
33
+ test('transform: (geom2)', (t) => {
23
34
  const matrix = mat4.fromScaling(mat4.create(), [5, 5, 5])
24
35
  let geometry = geom2.create([[[0, 0], [1, 0], [0, 1]]])
25
36
 
@@ -30,7 +41,7 @@ test('transform: transforming of a geom2 produces expected changes to sides', (t
30
41
  t.true(comparePoints(obs, exp))
31
42
  })
32
43
 
33
- test('transform: transforming of a geom3 produces expected changes to polygons', (t) => {
44
+ test('transform: (geom3)', (t) => {
34
45
  const matrix = mat4.fromTranslation(mat4.create(), [-3, -3, -3])
35
46
  const points = [
36
47
  [[-2, -7, -12], [-2, -7, 18], [-2, 13, 18], [-2, 13, -12]],
@@ -55,7 +66,7 @@ test('transform: transforming of a geom3 produces expected changes to polygons',
55
66
  t.true(comparePolygonsAsPoints(obs, exp))
56
67
  })
57
68
 
58
- test('transform: transforming of multiple objects produces expected changes', (t) => {
69
+ test('transform: (multiple objects)', (t) => {
59
70
  const junk = 'hello'
60
71
  const geometry1 = path2.fromPoints({}, [[-5, 5], [5, 5], [-5, -5], [10, -5]])
61
72
  const geometry2 = geom2.create([[[-5, -5], [0, 5], [10, -5]]])
@@ -3,6 +3,8 @@ import * as mat4 from '../../maths/mat4/index.js'
3
3
  import * as geom2 from '../../geometries/geom2/index.js'
4
4
  import * as geom3 from '../../geometries/geom3/index.js'
5
5
  import * as path2 from '../../geometries/path2/index.js'
6
+ import * as path3 from '../../geometries/path3/index.js'
7
+ import * as slice from '../../geometries/slice/index.js'
6
8
 
7
9
  /**
8
10
  * Translate the given objects using the given options.
@@ -24,9 +26,11 @@ export const translate = (offset, ...objects) => {
24
26
  const matrix = mat4.fromTranslation(mat4.create(), offset)
25
27
 
26
28
  const results = objects.map((object) => {
27
- if (path2.isA(object)) return path2.transform(matrix, object)
28
- if (geom2.isA(object)) return geom2.transform(matrix, object)
29
29
  if (geom3.isA(object)) return geom3.transform(matrix, object)
30
+ if (geom2.isA(object)) return geom2.transform(matrix, object)
31
+ if (path2.isA(object)) return path2.transform(matrix, object)
32
+ if (path3.isA(object)) return path3.transform(matrix, object)
33
+ if (slice.isA(object)) return slice.transform(matrix, object)
30
34
  // handle recursive arrays
31
35
  if (Array.isArray(object)) return translate(offset, ...object)
32
36
  return object