@jscad/modeling 2.11.1 → 2.12.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 (45) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/jscad-modeling.min.js +25 -25
  3. package/package.json +2 -2
  4. package/src/curves/bezier/arcLengthToT.js +6 -6
  5. package/src/curves/bezier/arcLengthToT.test.js +25 -25
  6. package/src/curves/bezier/length.js +3 -5
  7. package/src/curves/bezier/length.test.js +2 -2
  8. package/src/curves/bezier/lengths.js +10 -10
  9. package/src/curves/bezier/lengths.test.js +3 -3
  10. package/src/geometries/poly2/arePointsInside.js +0 -7
  11. package/src/geometries/poly3/measureBoundingSphere.js +1 -2
  12. package/src/operations/booleans/trees/PolygonTreeNode.js +1 -1
  13. package/src/operations/expansions/expand.test.js +1 -1
  14. package/src/operations/extrusions/extrudeHelical.js +6 -7
  15. package/src/operations/extrusions/extrudeHelical.test.js +35 -37
  16. package/src/operations/extrusions/index.d.ts +1 -0
  17. package/src/operations/hulls/hullPoints2.js +3 -2
  18. package/src/operations/modifiers/index.js +1 -1
  19. package/src/operations/modifiers/retessellate.js +66 -27
  20. package/src/primitives/circle.js +2 -2
  21. package/src/primitives/circle.test.js +7 -0
  22. package/src/primitives/cube.js +2 -2
  23. package/src/primitives/cube.test.js +7 -0
  24. package/src/primitives/cuboid.js +4 -1
  25. package/src/primitives/cuboid.test.js +7 -0
  26. package/src/primitives/cylinder.js +7 -2
  27. package/src/primitives/cylinder.test.js +14 -0
  28. package/src/primitives/ellipse.js +4 -1
  29. package/src/primitives/ellipse.test.js +7 -0
  30. package/src/primitives/ellipsoid.js +4 -1
  31. package/src/primitives/ellipsoid.test.js +7 -0
  32. package/src/primitives/geodesicSphere.js +5 -2
  33. package/src/primitives/geodesicSphere.test.js +7 -0
  34. package/src/primitives/rectangle.js +4 -1
  35. package/src/primitives/rectangle.test.js +7 -0
  36. package/src/primitives/roundedCuboid.js +10 -3
  37. package/src/primitives/roundedCuboid.test.js +14 -0
  38. package/src/primitives/roundedCylinder.js +12 -5
  39. package/src/primitives/roundedCylinder.test.js +21 -0
  40. package/src/primitives/roundedRectangle.js +10 -3
  41. package/src/primitives/roundedRectangle.test.js +14 -0
  42. package/src/primitives/sphere.js +2 -2
  43. package/src/primitives/sphere.test.js +7 -0
  44. package/src/primitives/square.js +2 -2
  45. package/src/primitives/square.test.js +7 -0
@@ -25,7 +25,7 @@ class PolygonTreeNode {
25
25
  this.parent = parent
26
26
  this.children = []
27
27
  this.polygon = polygon
28
- this.removed = false // state of branch or leaf
28
+ this.removed = false // state of branch or leaf
29
29
  }
30
30
 
31
31
  // fill the tree with polygons. Should be called on the root node only; child nodes must
@@ -53,7 +53,7 @@ test('expand: round-expanding a bent line produces expected geometry', (t) => {
53
53
  const expandedPoints = geom2.toPoints(expandedPathGeom2)
54
54
 
55
55
  t.notThrows(() => geom2.validate(expandedPathGeom2))
56
- const expectedArea = 56 + TAU * delta * 1.25 // shape will have 1 and 1/4 circles
56
+ const expectedArea = 56 + TAU * delta * 1.25 // shape will have 1 and 1/4 circles
57
57
  nearlyEqual(t, area(expandedPoints), expectedArea, 0.01, 'Measured area should be pretty close')
58
58
  const boundingBox = measureBoundingBox(expandedPathGeom2)
59
59
  t.true(comparePoints(boundingBox, [[-7, -2, 0], [2, 12, 0]]), 'Unexpected bounding box: ' + JSON.stringify(boundingBox))
@@ -40,7 +40,7 @@ const extrudeHelical = (options, geometry) => {
40
40
 
41
41
  let pitch
42
42
  // ignore height if pitch is set
43
- if(!options.pitch && options.height) {
43
+ if (!options.pitch && options.height) {
44
44
  pitch = options.height / (angle / TAU)
45
45
  } else {
46
46
  pitch = options.pitch ? options.pitch : defaults.pitch
@@ -49,18 +49,17 @@ const extrudeHelical = (options, geometry) => {
49
49
  // needs at least 3 segments for each revolution
50
50
  const minNumberOfSegments = 3
51
51
 
52
- if (segmentsPerRotation < minNumberOfSegments)
53
- throw new Error(`The number of segments per rotation needs to be at least 3.`)
52
+ if (segmentsPerRotation < minNumberOfSegments) { throw new Error('The number of segments per rotation needs to be at least 3.') }
54
53
 
55
- let shapeSides = geom2.toSides(geometry)
54
+ const shapeSides = geom2.toSides(geometry)
56
55
  if (shapeSides.length === 0) throw new Error('the given geometry cannot be empty')
57
56
 
58
57
  // const pointsWithNegativeX = shapeSides.filter((s) => (s[0][0] < 0))
59
58
  const pointsWithPositiveX = shapeSides.filter((s) => (s[0][0] >= 0))
60
-
59
+
61
60
  let baseSlice = slice.fromSides(shapeSides)
62
-
63
- if(pointsWithPositiveX.length === 0) {
61
+
62
+ if (pointsWithPositiveX.length === 0) {
64
63
  // only points in negative x plane, reverse
65
64
  baseSlice = slice.reverse(baseSlice)
66
65
  }
@@ -1,5 +1,4 @@
1
1
  const test = require('ava')
2
- const { comparePoints, comparePolygonsAsPoints } = require('../../../test/helpers')
3
2
  const { TAU } = require('../../maths/constants')
4
3
  const { geom2, geom3 } = require('../../geometries')
5
4
  const { circle } = require('../../primitives')
@@ -7,56 +6,55 @@ const { circle } = require('../../primitives')
7
6
  const { extrudeHelical } = require('./index')
8
7
 
9
8
  test('extrudeHelical: (defaults) extruding of a geom2 produces an expected geom3', (t) => {
10
- const geometry2 = geom2.fromPoints([[10, 8], [10, -8], [26, -8], [26, 8]])
9
+ const geometry2 = geom2.fromPoints([[10, 8], [10, -8], [26, -8], [26, 8]])
11
10
 
12
- const geometry3 = extrudeHelical({}, geometry2)
13
- const pts = geom3.toPoints(geometry3)
14
- t.notThrows(() => geom3.validate(geometry3))
11
+ const geometry3 = extrudeHelical({}, geometry2)
12
+ t.notThrows(() => geom3.validate(geometry3))
15
13
  })
16
14
 
17
15
  test('extrudeHelical: (defaults) extruding of a circle produces an expected geom3', (t) => {
18
- const geometry2 = circle({ size: 3, center: [10, 0] })
16
+ const geometry2 = circle({ size: 3, center: [10, 0] })
19
17
 
20
- const geometry3 = extrudeHelical({}, geometry2)
21
- t.notThrows(() => geom3.validate(geometry3))
18
+ const geometry3 = extrudeHelical({}, geometry2)
19
+ t.notThrows(() => geom3.validate(geometry3))
22
20
  })
23
21
 
24
22
  test('extrudeHelical: (angle) extruding of a circle produces an expected geom3', (t) => {
25
- const maxRevolutions = 10
26
- const geometry2 = circle({ size: 3, center: [10, 0] })
27
- for (const index of [...Array(maxRevolutions).keys()]) {
28
- // also test negative angles
29
- const geometry3 = extrudeHelical({angle: TAU * (index - maxRevolutions / 2)}, geometry2)
30
- t.notThrows(() => geom3.validate(geometry3))
31
- }
23
+ const maxRevolutions = 10
24
+ const geometry2 = circle({ size: 3, center: [10, 0] })
25
+ for (const index of [...Array(maxRevolutions).keys()]) {
26
+ // also test negative angles
27
+ const geometry3 = extrudeHelical({ angle: TAU * (index - maxRevolutions / 2) }, geometry2)
28
+ t.notThrows(() => geom3.validate(geometry3))
29
+ }
32
30
  })
33
31
 
34
32
  test('extrudeHelical: (pitch) extruding of a circle produces an expected geom3', (t) => {
35
- const startPitch = -10
36
- const geometry2 = circle({ size: 3, center: [10, 0] })
37
- for (const index of [...Array(20).keys()]) {
38
- // also test negative pitches
39
- const geometry3 = extrudeHelical({pitch: startPitch + index}, geometry2)
40
- t.notThrows(() => geom3.validate(geometry3))
41
- }
33
+ const startPitch = -10
34
+ const geometry2 = circle({ size: 3, center: [10, 0] })
35
+ for (const index of [...Array(20).keys()]) {
36
+ // also test negative pitches
37
+ const geometry3 = extrudeHelical({ pitch: startPitch + index }, geometry2)
38
+ t.notThrows(() => geom3.validate(geometry3))
39
+ }
42
40
  })
43
41
 
44
42
  test('extrudeHelical: (endRadiusOffset) extruding of a circle produces an expected geom3', (t) => {
45
- const startOffset = -5
46
- const geometry2 = circle({ size: 3, center: [10, 0] })
47
- for (const index of [...Array(10).keys()]) {
48
- // also test negative pitches
49
- const geometry3 = extrudeHelical({endRadiusOffset: startOffset + index}, geometry2)
50
- t.notThrows(() => geom3.validate(geometry3))
51
- }
43
+ const startOffset = -5
44
+ const geometry2 = circle({ size: 3, center: [10, 0] })
45
+ for (const index of [...Array(10).keys()]) {
46
+ // also test negative pitches
47
+ const geometry3 = extrudeHelical({ endRadiusOffset: startOffset + index }, geometry2)
48
+ t.notThrows(() => geom3.validate(geometry3))
49
+ }
52
50
  })
53
51
 
54
52
  test('extrudeHelical: (segments) extruding of a circle produces an expected geom3', (t) => {
55
- const startSegments = 3
56
- const geometry2 = circle({ size: 3, center: [10, 0] })
57
- for (const index of [...Array(30).keys()]) {
58
- // also test negative pitches
59
- const geometry3 = extrudeHelical({segments: startSegments + index}, geometry2)
60
- t.notThrows(() => geom3.validate(geometry3))
61
- }
62
- })
53
+ const startSegments = 3
54
+ const geometry2 = circle({ size: 3, center: [10, 0] })
55
+ for (const index of [...Array(30).keys()]) {
56
+ // also test negative pitches
57
+ const geometry3 = extrudeHelical({ segments: startSegments + index }, geometry2)
58
+ t.notThrows(() => geom3.validate(geometry3))
59
+ }
60
+ })
@@ -2,6 +2,7 @@ export { default as extrudeFromSlices, ExtrudeFromSlicesOptions } from './extrud
2
2
  export { default as extrudeLinear, ExtrudeLinearOptions } from './extrudeLinear'
3
3
  export { default as extrudeRectangular, ExtrudeRectangularOptions } from './extrudeRectangular'
4
4
  export { default as extrudeRotate, ExtrudeRotateOptions } from './extrudeRotate'
5
+ export { default as extrudeHelical, ExtrudeHelicalOptions } from './extrudeHelical'
5
6
  export { default as project, ProjectOptions } from './project'
6
7
  export * as slice from './slice'
7
8
 
@@ -25,8 +25,9 @@ const hullPoints2 = (uniquePoints) => {
25
25
  })
26
26
 
27
27
  // sort by polar coordinates
28
- points.sort((pt1, pt2) => pt1.angle < pt2.angle ? -1 : pt1.angle > pt2.angle ? 1 :
29
- pt1.distSq < pt2.distSq ? -1 : pt1.distSq > pt2.distSq ? 1 : 0)
28
+ points.sort((pt1, pt2) => pt1.angle !== pt2.angle
29
+ ? pt1.angle - pt2.angle
30
+ : pt1.distSq - pt2.distSq)
30
31
 
31
32
  const stack = [] // start with empty stack
32
33
  points.forEach((point) => {
@@ -8,5 +8,5 @@
8
8
  module.exports = {
9
9
  generalize: require('./generalize'),
10
10
  snap: require('./snap'),
11
- retessellate: require('./retessellate'),
11
+ retessellate: require('./retessellate')
12
12
  }
@@ -1,18 +1,8 @@
1
1
  const geom3 = require('../../geometries/geom3')
2
2
  const poly3 = require('../../geometries/poly3')
3
-
4
- const aboutEqualNormals = require('../../maths/utils/aboutEqualNormals')
5
-
3
+ const { NEPS } = require('../../maths/constants')
6
4
  const reTesselateCoplanarPolygons = require('./reTesselateCoplanarPolygons')
7
5
 
8
- const coplanar = (plane1, plane2) => {
9
- // expect the same distance from the origin, within tolerance
10
- if (Math.abs(plane1[3] - plane2[3]) < 0.00000015) {
11
- return aboutEqualNormals(plane1, plane2)
12
- }
13
- return false
14
- }
15
-
16
6
  /*
17
7
  After boolean operations all coplanar polygon fragments are joined by a retesselating
18
8
  operation. geom3.reTesselate(geom).
@@ -26,29 +16,78 @@ const retessellate = (geometry) => {
26
16
  return geometry
27
17
  }
28
18
 
29
- const polygons = geom3.toPolygons(geometry)
30
- const polygonsPerPlane = [] // elements: [plane, [poly3...]]
31
- polygons.forEach((polygon) => {
32
- const mapping = polygonsPerPlane.find((element) => coplanar(element[0], poly3.plane(polygon)))
33
- if (mapping) {
34
- const polygons = mapping[1]
35
- polygons.push(polygon)
19
+ const polygons = geom3.toPolygons(geometry).map((polygon, index) => ({ vertices: polygon.vertices, plane: poly3.plane(polygon), index: index }))
20
+ const classified = classifyPolygons(polygons)
21
+
22
+ const destPolygons = []
23
+ classified.forEach((group) => {
24
+ if (Array.isArray(group)) {
25
+ const reTessellateCoplanarPolygons = reTesselateCoplanarPolygons(group)
26
+ destPolygons.push(...reTessellateCoplanarPolygons)
36
27
  } else {
37
- polygonsPerPlane.push([poly3.plane(polygon), [polygon]])
28
+ destPolygons.push(group)
38
29
  }
39
30
  })
40
31
 
41
- let destpolygons = []
42
- polygonsPerPlane.forEach((mapping) => {
43
- const sourcepolygons = mapping[1]
44
- const retesselayedpolygons = reTesselateCoplanarPolygons(sourcepolygons)
45
- destpolygons = destpolygons.concat(retesselayedpolygons)
46
- })
47
-
48
- const result = geom3.create(destpolygons)
32
+ const result = geom3.create(destPolygons)
49
33
  result.isRetesselated = true
50
34
 
51
35
  return result
52
36
  }
53
37
 
38
+ const classifyPolygons = (polygons) => {
39
+ let clusters = [polygons] // a cluster is an array of potentially coplanar polygons
40
+ const nonCoplanar = [] // polygons that are known to be non-coplanar
41
+ // go through each component of the plane starting with the last one (the distance from origin)
42
+ for (let component = 3; component >= 0; component--) {
43
+ const maybeCoplanar = []
44
+ const tolerance = component === 3 ? 0.000000015 : NEPS
45
+ clusters.forEach((cluster) => {
46
+ // sort the cluster by the current component
47
+ cluster.sort(byPlaneComponent(component, tolerance))
48
+ // iterate through the cluster and check if there are polygons which are not coplanar with the others
49
+ // or if there are sub-clusters of coplanar polygons
50
+ let startIndex = 0
51
+ for (let i = 1; i < cluster.length; i++) {
52
+ // if there's a difference larger than the tolerance, split the cluster
53
+ if (cluster[i].plane[component] - cluster[startIndex].plane[component] > tolerance) {
54
+ // if there's a single polygon it's definitely not coplanar with any others
55
+ if (i - startIndex === 1) {
56
+ nonCoplanar.push(cluster[startIndex])
57
+ } else { // we have a new sub cluster of potentially coplanar polygons
58
+ maybeCoplanar.push(cluster.slice(startIndex, i))
59
+ }
60
+ startIndex = i
61
+ }
62
+ }
63
+ // handle the last elements of the cluster
64
+ if (cluster.length - startIndex === 1) {
65
+ nonCoplanar.push(cluster[startIndex])
66
+ } else {
67
+ maybeCoplanar.push(cluster.slice(startIndex))
68
+ }
69
+ })
70
+ // replace previous clusters with the new ones
71
+ clusters = maybeCoplanar
72
+ }
73
+ // restore the original order of the polygons
74
+ const result = []
75
+ // polygons inside the cluster should already be sorted by index
76
+ clusters.forEach((cluster) => {
77
+ if (cluster[0]) result[cluster[0].index] = cluster
78
+ })
79
+ nonCoplanar.forEach((polygon) => { result[polygon.index] = polygon })
80
+
81
+ return result
82
+ }
83
+
84
+ const byPlaneComponent = (component, tolerance) => (a, b) => {
85
+ if (a.plane[component] - b.plane[component] > tolerance) {
86
+ return 1
87
+ } else if (b.plane[component] - a.plane[component] > tolerance) {
88
+ return -1
89
+ }
90
+ return 0
91
+ }
92
+
54
93
  module.exports = retessellate
@@ -2,7 +2,7 @@ const { TAU } = require('../maths/constants')
2
2
 
3
3
  const ellipse = require('./ellipse')
4
4
 
5
- const { isGT } = require('./commonChecks')
5
+ const { isGTE } = require('./commonChecks')
6
6
 
7
7
  /**
8
8
  * Construct a circle in two dimensional space where all points are at the same distance from the center.
@@ -28,7 +28,7 @@ const circle = (options) => {
28
28
  }
29
29
  let { center, radius, startAngle, endAngle, segments } = Object.assign({}, defaults, options)
30
30
 
31
- if (!isGT(radius, 0)) throw new Error('radius must be greater than zero')
31
+ if (!isGTE(radius, 0)) throw new Error('radius must be positive')
32
32
 
33
33
  radius = [radius, radius]
34
34
 
@@ -146,3 +146,10 @@ test('circle (options)', (t) => {
146
146
  t.deepEqual(pts.length, 5)
147
147
  t.true(comparePoints(pts, exp))
148
148
  })
149
+
150
+ test('circle (radius zero)', (t) => {
151
+ const geometry = circle({ radius: 0 })
152
+ const pts = geom2.toPoints(geometry)
153
+ t.notThrows(() => geom2.validate(geometry))
154
+ t.is(pts.length, 0)
155
+ })
@@ -1,6 +1,6 @@
1
1
  const cuboid = require('./cuboid')
2
2
 
3
- const { isGT } = require('./commonChecks')
3
+ const { isGTE } = require('./commonChecks')
4
4
 
5
5
  /**
6
6
  * Construct an axis-aligned solid cube in three dimensional space with six square faces.
@@ -20,7 +20,7 @@ const cube = (options) => {
20
20
  }
21
21
  let { center, size } = Object.assign({}, defaults, options)
22
22
 
23
- if (!isGT(size, 0)) throw new Error('size must be greater than zero')
23
+ if (!isGTE(size, 0)) throw new Error('size must be positive')
24
24
 
25
25
  size = [size, size, size]
26
26
 
@@ -46,3 +46,10 @@ test('cube (options)', (t) => {
46
46
  t.is(pts.length, 6)
47
47
  t.true(comparePolygonsAsPoints(pts, exp))
48
48
  })
49
+
50
+ test('cube (zero size)', (t) => {
51
+ const obs = cube({ size: 0 })
52
+ const pts = geom3.toPoints(obs)
53
+ t.notThrows(() => geom3.validate(obs))
54
+ t.is(pts.length, 0)
55
+ })
@@ -23,7 +23,10 @@ const cuboid = (options) => {
23
23
 
24
24
  if (!isNumberArray(center, 3)) throw new Error('center must be an array of X, Y and Z values')
25
25
  if (!isNumberArray(size, 3)) throw new Error('size must be an array of width, depth and height values')
26
- if (!size.every((n) => n > 0)) throw new Error('size values must be greater than zero')
26
+ if (!size.every((n) => n >= 0)) throw new Error('size values must be positive')
27
+
28
+ // if any size is zero return empty geometry
29
+ if (size[0] === 0 || size[1] === 0 || size[2] === 0) return geom3.create()
27
30
 
28
31
  const result = geom3.create(
29
32
  // adjust a basic shape to size
@@ -55,3 +55,10 @@ test('cuboid (options)', (t) => {
55
55
  t.is(pts.length, 6)
56
56
  t.true(comparePolygonsAsPoints(pts, exp))
57
57
  })
58
+
59
+ test('cuboid (zero size)', (t) => {
60
+ const obs = cuboid({ size: [1, 1, 0] })
61
+ const pts = geom3.toPoints(obs)
62
+ t.notThrows(() => geom3.validate(obs))
63
+ t.is(pts.length, 0)
64
+ })
@@ -1,6 +1,8 @@
1
+ const geom3 = require('../geometries/geom3')
2
+
1
3
  const cylinderElliptic = require('./cylinderElliptic')
2
4
 
3
- const { isGT } = require('./commonChecks')
5
+ const { isGTE } = require('./commonChecks')
4
6
 
5
7
  /**
6
8
  * Construct a Z axis-aligned cylinder in three dimensional space.
@@ -25,7 +27,10 @@ const cylinder = (options) => {
25
27
  }
26
28
  const { center, height, radius, segments } = Object.assign({}, defaults, options)
27
29
 
28
- if (!isGT(radius, 0)) throw new Error('radius must be greater than zero')
30
+ if (!isGTE(radius, 0)) throw new Error('radius must be positive')
31
+
32
+ // if size is zero return empty geometry
33
+ if (height === 0 || radius === 0) return geom3.create()
29
34
 
30
35
  const newoptions = {
31
36
  center,
@@ -14,6 +14,20 @@ test('cylinder (defaults)', (t) => {
14
14
  t.is(pts.length, 96)
15
15
  })
16
16
 
17
+ test('cylinder (zero height)', (t) => {
18
+ const obs = cylinder({ height: 0 })
19
+ const pts = geom3.toPoints(obs)
20
+ t.notThrows(() => geom3.validate(obs))
21
+ t.is(pts.length, 0)
22
+ })
23
+
24
+ test('cylinder (zero radius)', (t) => {
25
+ const obs = cylinder({ radius: 0 })
26
+ const pts = geom3.toPoints(obs)
27
+ t.notThrows(() => geom3.validate(obs))
28
+ t.is(pts.length, 0)
29
+ })
30
+
17
31
  test('cylinder (options)', (t) => {
18
32
  let obs = cylinder({ height: 10, radius: 4, segments: 5 })
19
33
  let pts = geom3.toPoints(obs)
@@ -34,11 +34,14 @@ const ellipse = (options) => {
34
34
 
35
35
  if (!isNumberArray(center, 2)) throw new Error('center must be an array of X and Y values')
36
36
  if (!isNumberArray(radius, 2)) throw new Error('radius must be an array of X and Y values')
37
- if (!radius.every((n) => n > 0)) throw new Error('radius values must be greater than zero')
37
+ if (!radius.every((n) => n >= 0)) throw new Error('radius values must be positive')
38
38
  if (!isGTE(startAngle, 0)) throw new Error('startAngle must be positive')
39
39
  if (!isGTE(endAngle, 0)) throw new Error('endAngle must be positive')
40
40
  if (!isGTE(segments, 3)) throw new Error('segments must be three or more')
41
41
 
42
+ // if any radius is zero return empty geometry
43
+ if (radius[0] === 0 || radius[1] === 0) return geom2.create()
44
+
42
45
  startAngle = startAngle % TAU
43
46
  endAngle = endAngle % TAU
44
47
 
@@ -137,3 +137,10 @@ test('ellipse (options)', (t) => {
137
137
  t.notThrows(() => geom2.validate(geometry))
138
138
  t.deepEqual(obs.length, 72)
139
139
  })
140
+
141
+ test('ellipse (zero radius)', (t) => {
142
+ const geometry = ellipse({ radius: [1, 0] })
143
+ const obs = geom2.toPoints(geometry)
144
+ t.notThrows(() => geom2.validate(geometry))
145
+ t.is(obs.length, 0)
146
+ })
@@ -32,9 +32,12 @@ const ellipsoid = (options) => {
32
32
 
33
33
  if (!isNumberArray(center, 3)) throw new Error('center must be an array of X, Y and Z values')
34
34
  if (!isNumberArray(radius, 3)) throw new Error('radius must be an array of X, Y and Z values')
35
- if (!radius.every((n) => n > 0)) throw new Error('radius values must be greater than zero')
35
+ if (!radius.every((n) => n >= 0)) throw new Error('radius values must be positive')
36
36
  if (!isGTE(segments, 4)) throw new Error('segments must be four or more')
37
37
 
38
+ // if any radius is zero return empty geometry
39
+ if (radius[0] === 0 || radius[1] === 0 || radius[2] === 0) return geom3.create()
40
+
38
41
  const xvector = vec3.scale(vec3.create(), vec3.normalize(vec3.create(), axes[0]), radius[0])
39
42
  const yvector = vec3.scale(vec3.create(), vec3.normalize(vec3.create(), axes[1]), radius[1])
40
43
  const zvector = vec3.scale(vec3.create(), vec3.normalize(vec3.create(), axes[2]), radius[2])
@@ -207,3 +207,10 @@ test('ellipsoid (options)', (t) => {
207
207
  t.is(pts.length, 32)
208
208
  t.true(comparePolygonsAsPoints(pts, exp))
209
209
  })
210
+
211
+ test('ellipsoid (zero radius)', (t) => {
212
+ const obs = ellipsoid({ radius: [1, 1, 0] })
213
+ const pts = geom3.toPoints(obs)
214
+ t.notThrows(() => geom3.validate(obs))
215
+ t.is(pts.length, 0)
216
+ })
@@ -5,7 +5,7 @@ const geom3 = require('../geometries/geom3')
5
5
 
6
6
  const polyhedron = require('./polyhedron')
7
7
 
8
- const { isGT, isGTE } = require('./commonChecks')
8
+ const { isGTE } = require('./commonChecks')
9
9
 
10
10
  /**
11
11
  * Construct a geodesic sphere based on icosahedron symmetry.
@@ -25,9 +25,12 @@ const geodesicSphere = (options) => {
25
25
  }
26
26
  let { radius, frequency } = Object.assign({}, defaults, options)
27
27
 
28
- if (!isGT(radius, 0)) throw new Error('radius must be greater than zero')
28
+ if (!isGTE(radius, 0)) throw new Error('radius must be positive')
29
29
  if (!isGTE(frequency, 6)) throw new Error('frequency must be six or more')
30
30
 
31
+ // if radius is zero return empty geometry
32
+ if (radius === 0) return geom3.create()
33
+
31
34
  // adjust the frequency to base 6
32
35
  frequency = Math.floor(frequency / 6)
33
36
 
@@ -51,3 +51,10 @@ test('geodesicSphere (options)', (t) => {
51
51
  t.notThrows.skip(() => geom3.validate(obs))
52
52
  t.is(pts.length, 180)
53
53
  })
54
+
55
+ test('geodesicSphere (zero radius)', (t) => {
56
+ const obs = geodesicSphere({ radius: 0 })
57
+ const pts = geom3.toPoints(obs)
58
+ t.notThrows(() => geom3.validate(obs))
59
+ t.is(pts.length, 0)
60
+ })
@@ -24,7 +24,10 @@ const rectangle = (options) => {
24
24
 
25
25
  if (!isNumberArray(center, 2)) throw new Error('center must be an array of X and Y values')
26
26
  if (!isNumberArray(size, 2)) throw new Error('size must be an array of X and Y values')
27
- if (!size.every((n) => n > 0)) throw new Error('size values must be greater than zero')
27
+ if (!size.every((n) => n >= 0)) throw new Error('size values must be positive')
28
+
29
+ // if any size is zero return empty geometry
30
+ if (size[0] === 0 || size[1] === 0) return geom2.create()
28
31
 
29
32
  const point = [size[0] / 2, size[1] / 2]
30
33
  const pswap = [point[0], -point[1]]
@@ -50,3 +50,10 @@ test('rectangle (options)', (t) => {
50
50
  t.deepEqual(obs.length, 4)
51
51
  t.true(comparePoints(obs, exp))
52
52
  })
53
+
54
+ test('rectangle (zero size)', (t) => {
55
+ const geometry = rectangle({ size: [1, 0] })
56
+ const obs = geom2.toPoints(geometry)
57
+ t.notThrows(() => geom2.validate(geometry))
58
+ t.is(obs.length, 0)
59
+ })
@@ -8,7 +8,8 @@ const poly3 = require('../geometries/poly3')
8
8
 
9
9
  const { sin, cos } = require('../maths/utils/trigonometry')
10
10
 
11
- const { isGT, isGTE, isNumberArray } = require('./commonChecks')
11
+ const { isGTE, isNumberArray } = require('./commonChecks')
12
+ const cuboid = require('./cuboid')
12
13
 
13
14
  const createCorners = (center, size, radius, segments, slice, positive) => {
14
15
  const pitch = (TAU / 4) * slice / segments
@@ -135,10 +136,16 @@ const roundedCuboid = (options) => {
135
136
 
136
137
  if (!isNumberArray(center, 3)) throw new Error('center must be an array of X, Y and Z values')
137
138
  if (!isNumberArray(size, 3)) throw new Error('size must be an array of X, Y and Z values')
138
- if (!size.every((n) => n > 0)) throw new Error('size values must be greater than zero')
139
- if (!isGT(roundRadius, 0)) throw new Error('roundRadius must be greater than zero')
139
+ if (!size.every((n) => n >= 0)) throw new Error('size values must be positive')
140
+ if (!isGTE(roundRadius, 0)) throw new Error('roundRadius must be positive')
140
141
  if (!isGTE(segments, 4)) throw new Error('segments must be four or more')
141
142
 
143
+ // if any size is zero return empty geometry
144
+ if (size[0] === 0 || size[1] === 0 || size[2] === 0) return geom3.create()
145
+
146
+ // if roundRadius is zero, return cuboid
147
+ if (roundRadius === 0) return cuboid({ center, size })
148
+
142
149
  size = size.map((v) => v / 2) // convert to radius
143
150
 
144
151
  if (roundRadius > (size[0] - EPS) ||
@@ -14,6 +14,20 @@ test('roundedCuboid (defaults)', (t) => {
14
14
  t.deepEqual(pts.length, 614)
15
15
  })
16
16
 
17
+ test('roundedCuboid (zero size)', (t) => {
18
+ const obs = roundedCuboid({ size: [1, 1, 0] })
19
+ const pts = geom3.toPoints(obs)
20
+ t.notThrows(() => geom3.validate(obs))
21
+ t.is(pts.length, 0)
22
+ })
23
+
24
+ test('roundedCuboid (zero radius)', (t) => {
25
+ const obs = roundedCuboid({ roundRadius: 0 })
26
+ const pts = geom3.toPoints(obs)
27
+ t.notThrows(() => geom3.validate(obs))
28
+ t.deepEqual(pts.length, 6)
29
+ })
30
+
17
31
  test('roundedCuboid (options)', (t) => {
18
32
  // test segments
19
33
  let obs = roundedCuboid({ segments: 8 })
@@ -7,7 +7,8 @@ const poly3 = require('../geometries/poly3')
7
7
 
8
8
  const { sin, cos } = require('../maths/utils/trigonometry')
9
9
 
10
- const { isGT, isGTE, isNumberArray } = require('./commonChecks')
10
+ const { isGTE, isNumberArray } = require('./commonChecks')
11
+ const cylinder = require('./cylinder')
11
12
 
12
13
  /**
13
14
  * Construct a Z axis-aligned solid cylinder in three dimensional space with rounded ends.
@@ -34,12 +35,18 @@ const roundedCylinder = (options) => {
34
35
  const { center, height, radius, roundRadius, segments } = Object.assign({}, defaults, options)
35
36
 
36
37
  if (!isNumberArray(center, 3)) throw new Error('center must be an array of X, Y and Z values')
37
- if (!isGT(height, 0)) throw new Error('height must be greater then zero')
38
- if (!isGT(radius, 0)) throw new Error('radius must be greater then zero')
39
- if (!isGT(roundRadius, 0)) throw new Error('roundRadius must be greater then zero')
40
- if (roundRadius > (radius - EPS)) throw new Error('roundRadius must be smaller then the radius')
38
+ if (!isGTE(height, 0)) throw new Error('height must be positive')
39
+ if (!isGTE(radius, 0)) throw new Error('radius must be positive')
40
+ if (!isGTE(roundRadius, 0)) throw new Error('roundRadius must be positive')
41
+ if (roundRadius > radius) throw new Error('roundRadius must be smaller then the radius')
41
42
  if (!isGTE(segments, 4)) throw new Error('segments must be four or more')
42
43
 
44
+ // if size is zero return empty geometry
45
+ if (height === 0 || radius === 0) return geom3.create()
46
+
47
+ // if roundRadius is zero, return cylinder
48
+ if (roundRadius === 0) return cylinder({ center, height, radius })
49
+
43
50
  const start = [0, 0, -(height / 2)]
44
51
  const end = [0, 0, height / 2]
45
52
  const direction = vec3.subtract(vec3.create(), end, start)