View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      http://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.apache.commons.geometry.euclidean.threed.shape;
18  
19  import java.io.IOException;
20  import java.util.List;
21  import java.util.function.DoubleSupplier;
22  import java.util.regex.Pattern;
23  import java.util.stream.Collectors;
24  
25  import org.apache.commons.geometry.core.GeometryTestUtils;
26  import org.apache.commons.geometry.core.RegionLocation;
27  import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
28  import org.apache.commons.geometry.euclidean.threed.Bounds3D;
29  import org.apache.commons.geometry.euclidean.threed.PlaneConvexSubset;
30  import org.apache.commons.geometry.euclidean.threed.RegionBSPTree3D;
31  import org.apache.commons.geometry.euclidean.threed.SphericalCoordinates;
32  import org.apache.commons.geometry.euclidean.threed.Triangle3D;
33  import org.apache.commons.geometry.euclidean.threed.Vector3D;
34  import org.apache.commons.geometry.euclidean.threed.line.Line3D;
35  import org.apache.commons.geometry.euclidean.threed.line.LineConvexSubset3D;
36  import org.apache.commons.geometry.euclidean.threed.line.LinecastPoint3D;
37  import org.apache.commons.geometry.euclidean.threed.line.Lines3D;
38  import org.apache.commons.geometry.euclidean.threed.mesh.TriangleMesh;
39  import org.apache.commons.numbers.angle.Angle;
40  import org.apache.commons.numbers.core.Precision;
41  import org.apache.commons.rng.UniformRandomProvider;
42  import org.apache.commons.rng.simple.RandomSource;
43  import org.junit.jupiter.api.Assertions;
44  import org.junit.jupiter.api.Test;
45  
46  class SphereTest {
47  
48      private static final double TEST_EPS = 1e-10;
49  
50      private static final Precision.DoubleEquivalence TEST_PRECISION =
51              Precision.doubleEquivalenceOfEpsilon(TEST_EPS);
52  
53      @Test
54      void testFrom() {
55          // arrange
56          final Vector3D center = Vector3D.of(1, 2, 3);
57  
58          // act
59          final Sphere s = Sphere.from(center, 3, TEST_PRECISION);
60  
61          // act/assert
62          Assertions.assertFalse(s.isFull());
63          Assertions.assertFalse(s.isEmpty());
64  
65          Assertions.assertSame(center, s.getCenter());
66          Assertions.assertSame(center, s.getCentroid());
67  
68          Assertions.assertEquals(3, s.getRadius(), 0.0);
69  
70          Assertions.assertSame(TEST_PRECISION, s.getPrecision());
71      }
72  
73      @Test
74      void testFrom_illegalCenter() {
75          // act/assert
76          Assertions.assertThrows(IllegalArgumentException.class, () -> Sphere.from(Vector3D.of(Double.POSITIVE_INFINITY, 1, 2), 1, TEST_PRECISION));
77          Assertions.assertThrows(IllegalArgumentException.class, () -> Sphere.from(Vector3D.of(Double.NaN, 1, 2), 1, TEST_PRECISION));
78      }
79  
80      @Test
81      void testFrom_illegalRadius() {
82          // arrange
83          final Precision.DoubleEquivalence precision = Precision.doubleEquivalenceOfEpsilon(1e-2);
84  
85          // act/assert
86          Assertions.assertThrows(IllegalArgumentException.class, () -> Sphere.from(Vector3D.ZERO, -1, TEST_PRECISION));
87          Assertions.assertThrows(IllegalArgumentException.class, () -> Sphere.from(Vector3D.ZERO, 0, TEST_PRECISION));
88          Assertions.assertThrows(IllegalArgumentException.class, () -> Sphere.from(Vector3D.ZERO, Double.POSITIVE_INFINITY, TEST_PRECISION));
89          Assertions.assertThrows(IllegalArgumentException.class, () -> Sphere.from(Vector3D.ZERO, Double.NaN, TEST_PRECISION));
90          Assertions.assertThrows(IllegalArgumentException.class, () -> Sphere.from(Vector3D.ZERO, 1e-3, precision));
91      }
92  
93      @Test
94      void testGeometricProperties() {
95          // arrange
96          final double r = 2;
97          final Sphere s = Sphere.from(Vector3D.of(1, 2, 3), r, TEST_PRECISION);
98  
99          // act/assert
100         Assertions.assertEquals(4 * Math.PI * r * r, s.getBoundarySize(), TEST_EPS);
101         Assertions.assertEquals((4.0 * Math.PI * r * r * r) / 3.0, s.getSize(), TEST_EPS);
102     }
103 
104     @Test
105     void testClassify() {
106         // arrange
107         final Vector3D center = Vector3D.of(1, 2, 3);
108         final double radius = 4;
109         final Sphere s = Sphere.from(center, radius, TEST_PRECISION);
110 
111         EuclideanTestUtils.permute(0, Angle.TWO_PI, 0.2, (azimuth, polar) -> {
112             // act/assert
113             EuclideanTestUtils.assertRegionLocation(s, RegionLocation.OUTSIDE,
114                     SphericalCoordinates.of(radius + 1, azimuth, polar)
115                         .toVector()
116                         .add(center));
117 
118             EuclideanTestUtils.assertRegionLocation(s, RegionLocation.BOUNDARY,
119                     SphericalCoordinates.of(radius + 1e-12, azimuth, polar)
120                         .toVector()
121                         .add(center));
122 
123             EuclideanTestUtils.assertRegionLocation(s, RegionLocation.INSIDE,
124                     SphericalCoordinates.of(radius - 1, azimuth, polar)
125                         .toVector()
126                         .add(center));
127         });
128     }
129 
130     @Test
131     void testContains() {
132      // arrange
133         final Vector3D center = Vector3D.of(1, 2, 3);
134         final double radius = 4;
135         final Sphere s = Sphere.from(center, radius, TEST_PRECISION);
136 
137         EuclideanTestUtils.permute(0, Angle.TWO_PI, 0.2, (azimuth, polar) -> {
138             // act/assert
139             checkContains(s, false,
140                     SphericalCoordinates.of(radius + 1, azimuth, polar)
141                         .toVector()
142                         .add(center));
143 
144             checkContains(s, true,
145                     SphericalCoordinates.of(radius - 1, azimuth, polar)
146                         .toVector()
147                         .add(center),
148                     SphericalCoordinates.of(radius + 1e-12, azimuth, polar)
149                         .toVector()
150                         .add(center));
151         });
152     }
153 
154     @Test
155     void testProject() {
156         // arrange
157         final Vector3D center = Vector3D.of(1.5, 2.5, 3.5);
158         final double radius = 3;
159         final Sphere s = Sphere.from(center, radius, TEST_PRECISION);
160 
161         EuclideanTestUtils.permute(-4, 4, 1, (x, y, z) -> {
162             final Vector3D pt = Vector3D.of(x, y, z);
163 
164             // act
165             final Vector3D projection = s.project(pt);
166 
167             // assert
168             Assertions.assertEquals(radius, center.distance(projection), TEST_EPS);
169             EuclideanTestUtils.assertCoordinatesEqual(center.directionTo(pt),
170                     center.directionTo(projection), TEST_EPS);
171         });
172     }
173 
174     @Test
175     void testProject_argumentEqualsCenter() {
176         // arrange
177         final Sphere c = Sphere.from(Vector3D.of(1, 2, 3), 2, TEST_PRECISION);
178 
179         // act
180         final Vector3D projection = c.project(Vector3D.of(1, 2, 3));
181 
182         // assert
183         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 2, 3), projection, TEST_EPS);
184     }
185 
186     @Test
187     void testIntersections() {
188         // --- arrange
189         final Sphere s = Sphere.from(Vector3D.of(2, 1, 3), 2, TEST_PRECISION);
190         final double sqrt3 = Math.sqrt(3);
191 
192         // --- act/assert
193         // descending along y in x-y plane
194         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(-1, 4, 3), Vector3D.of(5, 4, 3), TEST_PRECISION));
195         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(-1, 3, 3), Vector3D.of(5, 3, 3), TEST_PRECISION),
196                 Vector3D.of(2, 3, 3));
197         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(-1, 2, 3), Vector3D.of(5, 2, 3), TEST_PRECISION),
198                 Vector3D.of(2 - sqrt3, 2, 3), Vector3D.of(2 + sqrt3, 2, 3));
199         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(-1, 1, 3), Vector3D.of(5, 1, 3), TEST_PRECISION),
200                 Vector3D.of(0, 1, 3), Vector3D.of(4, 1, 3));
201         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(-1, 0, 3), Vector3D.of(5, 0, 3), TEST_PRECISION),
202                 Vector3D.of(2 - sqrt3, 0, 3), Vector3D.of(2 + sqrt3, 0, 3));
203         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(-1, -1, 3), Vector3D.of(5, -1, 3), TEST_PRECISION),
204                 Vector3D.of(2, -1, 3));
205         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(-1, -2, 3), Vector3D.of(5, -2, 3), TEST_PRECISION));
206 
207         // ascending along x in x-y plane
208         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(-1, -2, 3), Vector3D.of(-1, 5, 3), TEST_PRECISION));
209         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(0, -2, 3), Vector3D.of(0, 5, 3), TEST_PRECISION),
210                 Vector3D.of(0, 1, 3));
211         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(1, -2, 3), Vector3D.of(1, 5, 3), TEST_PRECISION),
212                 Vector3D.of(1, 1 - sqrt3, 3), Vector3D.of(1, 1 + sqrt3, 3));
213         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(2, -2, 3), Vector3D.of(2, 5, 3), TEST_PRECISION),
214                 Vector3D.of(2, -1, 3), Vector3D.of(2, 3, 3));
215         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(3, -2, 3), Vector3D.of(3, 5, 3), TEST_PRECISION),
216                 Vector3D.of(3, 1 - sqrt3, 3), Vector3D.of(3, 1 + sqrt3, 3));
217         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(4, -2, 3), Vector3D.of(4, 5, 3), TEST_PRECISION),
218                 Vector3D.of(4, 1, 3));
219         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(5, -2, 3), Vector3D.of(5, 5, 3), TEST_PRECISION));
220 
221         // descending along z in y-z plane
222         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(2, -2, 6), Vector3D.of(2, 4, 6), TEST_PRECISION));
223         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(2, -2, 5), Vector3D.of(2, 4, 5), TEST_PRECISION),
224                 Vector3D.of(2, 1, 5));
225         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(2, -2, 4), Vector3D.of(2, 4, 4), TEST_PRECISION),
226                 Vector3D.of(2, 1 - sqrt3, 4), Vector3D.of(2, 1 + sqrt3, 4));
227         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(2, -2, 3), Vector3D.of(2, 4, 3), TEST_PRECISION),
228                 Vector3D.of(2, -1, 3), Vector3D.of(2, 3, 3));
229         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(2, -2, 2), Vector3D.of(2, 4, 2), TEST_PRECISION),
230                 Vector3D.of(2, 1 - sqrt3, 2), Vector3D.of(2, 1 + sqrt3, 2));
231         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(2, -2, 1), Vector3D.of(2, 4, 1), TEST_PRECISION),
232                 Vector3D.of(2, 1, 1));
233         checkIntersections(s, Lines3D.fromPoints(Vector3D.of(2, -2, 0), Vector3D.of(2, 4, 0), TEST_PRECISION));
234 
235         // diagonal from origin
236         final Vector3D center = s.getCenter();
237         checkIntersections(s, Lines3D.fromPoints(Vector3D.ZERO, s.getCenter(), TEST_PRECISION),
238                 center.withNorm(center.norm() - s.getRadius()), center.withNorm(center.norm() + s.getRadius()));
239     }
240 
241     @Test
242     void testLinecast() {
243         // arrange
244         final Sphere s = Sphere.from(Vector3D.of(2, 1, 3), 2, TEST_PRECISION);
245         final double sqrt3 = Math.sqrt(3);
246 
247         // act/assert
248         checkLinecast(s, Lines3D.segmentFromPoints(Vector3D.of(-1, 0, 3), Vector3D.of(5, 0, 3), TEST_PRECISION),
249                 Vector3D.of(2 - sqrt3, 0, 3), Vector3D.of(2 + sqrt3, 0, 3));
250         checkLinecast(s, Lines3D.segmentFromPoints(Vector3D.of(-1, 3, 3), Vector3D.of(5, 3, 3), TEST_PRECISION),
251                 Vector3D.of(2, 3, 3));
252         checkLinecast(s, Lines3D.segmentFromPoints(Vector3D.of(-1, -2, 3), Vector3D.of(5, -2, 3), TEST_PRECISION));
253     }
254 
255     @Test
256     void testLinecast_intersectionsNotInSegment() {
257         // arrange
258         final Sphere s = Sphere.from(Vector3D.of(2, 1, 3), 2, TEST_PRECISION);
259         final Line3D line = Lines3D.fromPointAndDirection(Vector3D.of(0, 0, 3), Vector3D.Unit.PLUS_X, TEST_PRECISION);
260 
261         // act/assert
262         checkLinecast(s, line.segment(-1, 0));
263         checkLinecast(s, line.segment(1.5, 2.5));
264         checkLinecast(s, line.segment(1.5, 2.5));
265         checkLinecast(s, line.segment(4, 5));
266     }
267 
268     @Test
269     void testLinecast_segmentPointOnBoundary() {
270         // arrange
271         final Sphere s = Sphere.from(Vector3D.of(2, 1, 3), 2, TEST_PRECISION);
272         final Line3D line = Lines3D.fromPointAndDirection(Vector3D.of(0, 0, 3), Vector3D.Unit.PLUS_X, TEST_PRECISION);
273         final double sqrt3 = Math.sqrt(3);
274         final double start = 2 - sqrt3;
275         final double end = 2 + sqrt3;
276 
277         // act/assert
278         checkLinecast(s, line.segment(start, 2), Vector3D.of(start, 0, 3));
279         checkLinecast(s, line.segment(start, end), Vector3D.of(start, 0, 3), Vector3D.of(end, 0, 3));
280         checkLinecast(s, line.segment(end, 5), Vector3D.of(end, 0, 3));
281     }
282 
283     @Test
284     void testToTree_zeroSubdivisions() throws IOException {
285         // arrange
286         final double r = 2;
287         final Sphere s = Sphere.from(Vector3D.of(2, 1, 3), r, TEST_PRECISION);
288 
289         // act
290         final RegionBSPTree3D tree = s.toTree(0);
291 
292         // assert
293         checkBasicApproximationProperties(s, tree);
294 
295         final List<PlaneConvexSubset> boundaries = tree.getBoundaries();
296         Assertions.assertEquals(8, boundaries.size());
297 
298         final List<Triangle3D> triangles = tree.triangleStream().collect(Collectors.toList());
299         Assertions.assertEquals(8, triangles.size());
300 
301         final double expectedSize = (4.0 / 3.0) * r * r * r;
302         Assertions.assertEquals(expectedSize, tree.getSize(), TEST_EPS);
303     }
304 
305     @Test
306     void testToTree_oneSubdivision() throws IOException {
307         // arrange
308         final double r = 2;
309         final Sphere s = Sphere.from(Vector3D.of(2, 1, 3), r, TEST_PRECISION);
310 
311         // act
312         final RegionBSPTree3D tree = s.toTree(1);
313 
314         // assert
315         checkBasicApproximationProperties(s, tree);
316 
317         final List<PlaneConvexSubset> boundaries = tree.getBoundaries();
318         Assertions.assertEquals(32, boundaries.size());
319 
320         final List<Triangle3D> triangles = tree.triangleStream().collect(Collectors.toList());
321         Assertions.assertEquals(32, triangles.size());
322 
323         Assertions.assertTrue(tree.getSize() <= s.getSize());
324     }
325 
326     @Test
327     void testToTree_multipleSubdivisionCounts() {
328         // -- arrange
329         final Sphere s = Sphere.from(Vector3D.of(-3, 5, 1), 10, TEST_PRECISION);
330 
331         final int min = 0;
332         final int max = 5;
333 
334         RegionBSPTree3D tree;
335 
336         double sizeDiff;
337         double prevSizeDiff = Double.POSITIVE_INFINITY;
338 
339         for (int n = min; n <= max; ++n) {
340             // -- act
341             tree = s.toTree(n);
342 
343             // -- assert
344             checkBasicApproximationProperties(s, tree);
345 
346             final int expectedTriangles = (int) (8 * Math.pow(4, n));
347             final List<PlaneConvexSubset> boundaries = tree.getBoundaries();
348             Assertions.assertEquals(expectedTriangles, boundaries.size());
349 
350             final List<Triangle3D> triangles = tree.triangleStream().collect(Collectors.toList());
351             Assertions.assertEquals(expectedTriangles, triangles.size());
352 
353             // check that we get closer and closer to the correct size as we add more segments
354             sizeDiff = s.getSize() - tree.getSize();
355             Assertions.assertTrue(sizeDiff < prevSizeDiff, "Expected size difference to decrease: n= " +
356                     n + ", prevSizeDiff= " + prevSizeDiff + ", sizeDiff= " + sizeDiff);
357 
358             prevSizeDiff = sizeDiff;
359         }
360     }
361 
362     @Test
363     void testToTree_randomSpheres() {
364         // arrange
365         final UniformRandomProvider rand = RandomSource.create(RandomSource.XO_RO_SHI_RO_128_PP, 1L);
366         final Precision.DoubleEquivalence precision = Precision.doubleEquivalenceOfEpsilon(1e-10);
367         final double min = 1e-1;
368         final double max = 1e2;
369 
370         final DoubleSupplier randDouble = () -> (rand.nextDouble() * (max - min)) + min;
371 
372         final int count = 10;
373         for (int i = 0; i < count; ++i) {
374             final Vector3D center = Vector3D.of(
375                     randDouble.getAsDouble(),
376                     randDouble.getAsDouble(),
377                     randDouble.getAsDouble());
378 
379             final double radius = randDouble.getAsDouble();
380             final Sphere sphere = Sphere.from(center, radius, precision);
381 
382             for (int s = 0; s < 7; ++s) {
383                 // act
384                 final RegionBSPTree3D tree = sphere.toTree(s);
385 
386                 // assert
387                 Assertions.assertEquals((int) (8 * Math.pow(4, s)), tree.getBoundaries().size());
388                 Assertions.assertTrue(tree.isFinite());
389                 Assertions.assertFalse(tree.isEmpty());
390                 Assertions.assertTrue(tree.getSize() < sphere.getSize());
391             }
392         }
393     }
394 
395     @Test
396     void testToTree_closeApproximation() throws IOException {
397         // arrange
398         final Sphere s = Sphere.from(Vector3D.ZERO, 1, TEST_PRECISION);
399 
400         // act
401         final RegionBSPTree3D tree = s.toTree(8);
402 
403         // assert
404         checkBasicApproximationProperties(s, tree);
405 
406         final double eps = 1e-3;
407         Assertions.assertTrue(tree.isFinite());
408         Assertions.assertEquals(s.getSize(), tree.getSize(), eps);
409         Assertions.assertEquals(s.getBoundarySize(), tree.getBoundarySize(), eps);
410         EuclideanTestUtils.assertCoordinatesEqual(s.getCentroid(), tree.getCentroid(), eps);
411     }
412 
413     @Test
414     void testToTree_subdivideFails() {
415         // arrange
416         final Precision.DoubleEquivalence precision = Precision.doubleEquivalenceOfEpsilon(1e-5);
417         final Sphere s = Sphere.from(Vector3D.ZERO, 1, precision);
418 
419         // act/assert
420         GeometryTestUtils.assertThrowsWithMessage(() -> {
421             s.toTree(6);
422         }, IllegalStateException.class,
423                 Pattern.compile("^Failed to construct sphere approximation with subdivision count 6:.*"));
424     }
425 
426     @Test
427     void testToTree_invalidArgs() {
428         // arrange
429         final Sphere s = Sphere.from(Vector3D.of(2, 1, 3), 2, TEST_PRECISION);
430 
431         // act/assert
432         GeometryTestUtils.assertThrowsWithMessage(() -> {
433             s.toTree(-1);
434         }, IllegalArgumentException.class,
435                 "Number of sphere approximation subdivisions must be greater than or equal to zero; was -1");
436     }
437 
438     @Test
439     void testToMesh_zeroSubdivisions() {
440         // arrange
441         final Sphere s = Sphere.from(Vector3D.of(1, 2, 3), 2, TEST_PRECISION);
442 
443         // act
444         final TriangleMesh mesh = s.toTriangleMesh(0);
445 
446         // assert
447         Assertions.assertEquals(6, mesh.getVertexCount());
448         Assertions.assertEquals(8, mesh.getFaceCount());
449 
450         final Bounds3D bounds = mesh.getBounds();
451         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-1, 0, 1), bounds.getMin(), TEST_EPS);
452         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 4, 5), bounds.getMax(), TEST_EPS);
453 
454         Assertions.assertTrue(mesh.toTree().isFinite());
455     }
456 
457     @Test
458     void testToMesh_manySubdivisions() {
459         // arrange
460         final Sphere s = Sphere.from(Vector3D.of(1, 2, 3), 2, TEST_PRECISION);
461         final int subdivisions = 5;
462 
463         // act
464         final TriangleMesh mesh = s.toTriangleMesh(subdivisions);
465 
466         // assert
467         Assertions.assertEquals((int) (8 * Math.pow(4, subdivisions)), mesh.getFaceCount());
468 
469         final Bounds3D bounds = mesh.getBounds();
470         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-1, 0, 1), bounds.getMin(), TEST_EPS);
471         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 4, 5), bounds.getMax(), TEST_EPS);
472 
473         final RegionBSPTree3D tree = RegionBSPTree3D.partitionedRegionBuilder()
474                 .insertAxisAlignedGrid(bounds, 3, TEST_PRECISION)
475                 .insertBoundaries(mesh)
476                 .build();
477 
478         Assertions.assertTrue(tree.isFinite());
479 
480         final double approximationEps = 0.1;
481         Assertions.assertEquals(s.getSize(), tree.getSize(), approximationEps);
482         Assertions.assertEquals(s.getBoundarySize(), tree.getBoundarySize(), approximationEps);
483 
484         EuclideanTestUtils.assertCoordinatesEqual(s.getCentroid(), tree.getCentroid(), TEST_EPS);
485     }
486 
487     @Test
488     void testToMesh_invalidArgs() {
489         // arrange
490         final Sphere s = Sphere.from(Vector3D.of(2, 1, 3), 2, TEST_PRECISION);
491 
492         // act/assert
493         GeometryTestUtils.assertThrowsWithMessage(() -> {
494             s.toTriangleMesh(-1);
495         }, IllegalArgumentException.class,
496                 "Number of sphere approximation subdivisions must be greater than or equal to zero; was -1");
497     }
498 
499     @Test
500     void testHashCode() {
501         // arrange
502         final Precision.DoubleEquivalence otherPrecision = Precision.doubleEquivalenceOfEpsilon(1e-2);
503 
504         final Sphere a = Sphere.from(Vector3D.of(1, 2, 3), 3, TEST_PRECISION);
505         final Sphere b = Sphere.from(Vector3D.of(1, 1, 3), 3, TEST_PRECISION);
506         final Sphere c = Sphere.from(Vector3D.of(1, 2, 3), 4, TEST_PRECISION);
507         final Sphere d = Sphere.from(Vector3D.of(1, 2, 3), 3, otherPrecision);
508         final Sphere e = Sphere.from(Vector3D.of(1, 2, 3), 3, TEST_PRECISION);
509 
510         // act
511         final int hash = a.hashCode();
512 
513         // act/assert
514         Assertions.assertEquals(hash, a.hashCode());
515 
516         Assertions.assertNotEquals(hash, b.hashCode());
517         Assertions.assertNotEquals(hash, c.hashCode());
518         Assertions.assertNotEquals(hash, d.hashCode());
519 
520         Assertions.assertEquals(hash, e.hashCode());
521     }
522 
523     @Test
524     void testEquals() {
525         // arrange
526         final Precision.DoubleEquivalence precision = Precision.doubleEquivalenceOfEpsilon(1e-2);
527 
528         final Sphere a = Sphere.from(Vector3D.of(1, 2, 3), 3, TEST_PRECISION);
529         final Sphere b = Sphere.from(Vector3D.of(1, 1, 3), 3, TEST_PRECISION);
530         final Sphere c = Sphere.from(Vector3D.of(1, 2, 3), 4, TEST_PRECISION);
531         final Sphere d = Sphere.from(Vector3D.of(1, 2, 3), 3, precision);
532         final Sphere e = Sphere.from(Vector3D.of(1, 2, 3), 3, TEST_PRECISION);
533 
534         // act/assert
535         GeometryTestUtils.assertSimpleEqualsCases(a);
536 
537         Assertions.assertNotEquals(a, b);
538         Assertions.assertNotEquals(a, c);
539         Assertions.assertNotEquals(a, d);
540 
541         Assertions.assertEquals(a, e);
542     }
543 
544     @Test
545     void testToString() {
546         // arrange
547         final Sphere c = Sphere.from(Vector3D.of(1, 2, 3), 3, TEST_PRECISION);
548 
549         // act
550         final String str = c.toString();
551 
552         // assert
553         Assertions.assertEquals("Sphere[center= (1.0, 2.0, 3.0), radius= 3.0]", str);
554     }
555 
556     private static void checkContains(final Sphere sphere, final boolean contains, final Vector3D... pts) {
557         for (final Vector3D pt : pts) {
558             Assertions.assertEquals(contains, sphere.contains(pt),
559                     "Expected circle to " + (contains ? "" : "not") + "contain point " + pt);
560         }
561     }
562 
563     private static void checkIntersections(final Sphere sphere, final Line3D line, final Vector3D... expectedPts) {
564         // --- act
565         // compute the intersections forward and reverse
566         final List<Vector3D> actualPtsForward = sphere.intersections(line);
567         final List<Vector3D> actualPtsReverse = sphere.intersections(line.reverse());
568 
569         final Vector3D actualFirstForward = sphere.firstIntersection(line);
570         final Vector3D actualFirstReverse = sphere.firstIntersection(line.reverse());
571 
572         // --- assert
573         final int len = expectedPts.length;
574 
575         // check the lists
576         Assertions.assertEquals(len, actualPtsForward.size());
577         Assertions.assertEquals(len, actualPtsReverse.size());
578 
579         for (int i = 0; i < len; ++i) {
580             EuclideanTestUtils.assertCoordinatesEqual(expectedPts[i], actualPtsForward.get(i), TEST_EPS);
581             Assertions.assertEquals(sphere.getRadius(), sphere.getCenter().distance(actualPtsForward.get(i)), TEST_EPS);
582 
583             EuclideanTestUtils.assertCoordinatesEqual(expectedPts[len - i - 1], actualPtsReverse.get(i), TEST_EPS);
584             Assertions.assertEquals(sphere.getRadius(), sphere.getCenter().distance(actualPtsReverse.get(i)), TEST_EPS);
585         }
586 
587         // check the single intersection points
588         if (len > 0) {
589             Assertions.assertNotNull(actualFirstForward);
590             Assertions.assertNotNull(actualFirstReverse);
591 
592             EuclideanTestUtils.assertCoordinatesEqual(expectedPts[0], actualFirstForward, TEST_EPS);
593             EuclideanTestUtils.assertCoordinatesEqual(expectedPts[len - 1], actualFirstReverse, TEST_EPS);
594         } else {
595             Assertions.assertNull(actualFirstForward);
596             Assertions.assertNull(actualFirstReverse);
597         }
598     }
599 
600     private static void checkLinecast(final Sphere s, final LineConvexSubset3D segment, final Vector3D... expectedPts) {
601         // check linecast
602         final List<LinecastPoint3D> results = s.linecast(segment);
603         Assertions.assertEquals(expectedPts.length, results.size());
604 
605         LinecastPoint3D actual;
606         Vector3D expected;
607         for (int i = 0; i < expectedPts.length; ++i) {
608             expected = expectedPts[i];
609             actual = results.get(i);
610 
611             EuclideanTestUtils.assertCoordinatesEqual(expected, actual.getPoint(), TEST_EPS);
612             EuclideanTestUtils.assertCoordinatesEqual(s.getCenter().directionTo(expected), actual.getNormal(), TEST_EPS);
613             Assertions.assertSame(segment.getLine(), actual.getLine());
614         }
615 
616         // check linecastFirst
617         final LinecastPoint3D firstResult = s.linecastFirst(segment);
618         if (expectedPts.length > 0) {
619             Assertions.assertEquals(results.get(0), firstResult);
620         } else {
621             Assertions.assertNull(firstResult);
622         }
623     }
624 
625     /**
626      * Check a number of standard properties for bsp trees generated as sphere approximations.
627      */
628     private static void checkBasicApproximationProperties(final Sphere s, final RegionBSPTree3D tree) {
629         Assertions.assertFalse(tree.isFull());
630         Assertions.assertFalse(tree.isEmpty());
631         Assertions.assertTrue(tree.isFinite());
632         Assertions.assertFalse(tree.isInfinite());
633 
634         // volume must be less than the sphere
635         Assertions.assertTrue(tree.getSize() < s.getSize(), "Expected approximation volume to be less than circle");
636 
637         // all vertices must be inside the sphere or on the boundary
638         for (final PlaneConvexSubset boundary : tree.getBoundaries()) {
639             Assertions.assertTrue(boundary.isFinite());
640 
641             for (final Vector3D vertex : boundary.getVertices()) {
642                 Assertions.assertTrue(s.contains(vertex), "Expected vertex to be contained in sphere: " + vertex);
643             }
644         }
645 
646         // sphere must contain centroid
647         EuclideanTestUtils.assertRegionLocation(s, RegionLocation.INSIDE, tree.getCentroid());
648     }
649 }