Bounds3DTest.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.commons.geometry.euclidean.threed;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.ToDoubleFunction;
import java.util.regex.Pattern;

import org.apache.commons.geometry.core.GeometryTestUtils;
import org.apache.commons.geometry.core.RegionLocation;
import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
import org.apache.commons.geometry.euclidean.threed.line.Line3D;
import org.apache.commons.geometry.euclidean.threed.line.LineConvexSubset3D;
import org.apache.commons.geometry.euclidean.threed.line.LinecastPoint3D;
import org.apache.commons.geometry.euclidean.threed.line.Lines3D;
import org.apache.commons.geometry.euclidean.threed.line.Segment3D;
import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
import org.apache.commons.geometry.euclidean.threed.shape.Parallelepiped;
import org.apache.commons.numbers.angle.Angle;
import org.apache.commons.numbers.core.Precision;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

class Bounds3DTest {

    private static final double TEST_EPS = 1e-10;

    private static final Precision.DoubleEquivalence TEST_PRECISION =
            Precision.doubleEquivalenceOfEpsilon(TEST_EPS);

    private static final String NO_POINTS_MESSAGE = "Cannot construct bounds: no points given";

    private static final Pattern INVALID_BOUNDS_PATTERN =
            Pattern.compile("^Invalid bounds: min= \\([^\\)]+\\), max= \\([^\\)]+\\)");

    @Test
    void testFrom_varargs_singlePoint() {
        // arrange
        final Vector3D p1 = Vector3D.of(-1, 2, -3);

        // act
        final Bounds3D b = Bounds3D.from(p1);

        // assert
        EuclideanTestUtils.assertCoordinatesEqual(p1, b.getMin(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(p1, b.getMax(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, b.getDiagonal(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(p1, b.getCentroid(), TEST_EPS);
    }

    @Test
    void testFrom_varargs_multiplePoints() {
        // arrange
        final Vector3D p1 = Vector3D.of(1, 6, 7);
        final Vector3D p2 = Vector3D.of(0, 5, 11);
        final Vector3D p3 = Vector3D.of(3, 6, 8);

        // act
        final Bounds3D b = Bounds3D.from(p1, p2, p3);

        // assert
        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 5, 7), b.getMin(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 6, 11), b.getMax(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 1, 4), b.getDiagonal(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1.5, 5.5, 9), b.getCentroid(), TEST_EPS);
    }

    @Test
    void testFrom_iterable_singlePoint() {
        // arrange
        final Vector3D p1 = Vector3D.of(-1, 2, -3);

        // act
        final Bounds3D b = Bounds3D.from(Collections.singletonList(p1));

        // assert
        EuclideanTestUtils.assertCoordinatesEqual(p1, b.getMin(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(p1, b.getMax(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, b.getDiagonal(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(p1, b.getCentroid(), TEST_EPS);
    }

    @Test
    void testFrom_iterable_multiplePoints() {
        // arrange
        final Vector3D p1 = Vector3D.of(1, 6, 7);
        final Vector3D p2 = Vector3D.of(2, 5, 9);
        final Vector3D p3 = Vector3D.of(3, 4, 8);

        // act
        final Bounds3D b = Bounds3D.from(Arrays.asList(p1, p2, p3));

        // assert
        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 4, 7), b.getMin(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 6, 9), b.getMax(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(2, 2, 2), b.getDiagonal(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(2, 5, 8), b.getCentroid(), TEST_EPS);
    }

    @Test
    void testFrom_iterable_noPoints() {
        // act/assert
        GeometryTestUtils.assertThrowsWithMessage(() -> {
            Bounds3D.from(new ArrayList<>());
        }, IllegalStateException.class, NO_POINTS_MESSAGE);
    }

    @Test
    void testFrom_invalidBounds() {
        // arrange
        final Vector3D good = Vector3D.of(1, 1, 1);

        final Vector3D nan = Vector3D.of(Double.NaN, 1, 1);
        final Vector3D posInf = Vector3D.of(1, Double.POSITIVE_INFINITY, 1);
        final Vector3D negInf = Vector3D.of(1, 1, Double.NEGATIVE_INFINITY);

        // act/assert
        GeometryTestUtils.assertThrowsWithMessage(() -> {
            Bounds3D.from(Vector3D.NaN);
        }, IllegalStateException.class, INVALID_BOUNDS_PATTERN);

        GeometryTestUtils.assertThrowsWithMessage(() -> {
            Bounds3D.from(Vector3D.POSITIVE_INFINITY);
        }, IllegalStateException.class, INVALID_BOUNDS_PATTERN);

        GeometryTestUtils.assertThrowsWithMessage(() -> {
            Bounds3D.from(Vector3D.NEGATIVE_INFINITY);
        }, IllegalStateException.class, INVALID_BOUNDS_PATTERN);

        GeometryTestUtils.assertThrowsWithMessage(() -> {
            Bounds3D.from(good, nan);
        }, IllegalStateException.class, INVALID_BOUNDS_PATTERN);

        GeometryTestUtils.assertThrowsWithMessage(() -> {
            Bounds3D.from(posInf, good);
        }, IllegalStateException.class, INVALID_BOUNDS_PATTERN);

        GeometryTestUtils.assertThrowsWithMessage(() -> {
            Bounds3D.from(good, negInf, good);
        }, IllegalStateException.class, INVALID_BOUNDS_PATTERN);
    }

    @Test
    void testHasSize() {
        // arrange
        final Precision.DoubleEquivalence low = Precision.doubleEquivalenceOfEpsilon(1e-2);
        final Precision.DoubleEquivalence high = Precision.doubleEquivalenceOfEpsilon(1e-10);

        final Vector3D p1 = Vector3D.ZERO;

        final Vector3D p2 = Vector3D.of(1e-5, 1, 1);
        final Vector3D p3 = Vector3D.of(1, 1e-5, 1);
        final Vector3D p4 = Vector3D.of(1, 1, 1e-5);

        final Vector3D p5 = Vector3D.of(1, 1, 1);

        // act/assert
        Assertions.assertFalse(Bounds3D.from(p1).hasSize(high));
        Assertions.assertFalse(Bounds3D.from(p1).hasSize(low));

        Assertions.assertTrue(Bounds3D.from(p1, p2).hasSize(high));
        Assertions.assertFalse(Bounds3D.from(p1, p2).hasSize(low));

        Assertions.assertTrue(Bounds3D.from(p1, p3).hasSize(high));
        Assertions.assertFalse(Bounds3D.from(p1, p3).hasSize(low));

        Assertions.assertTrue(Bounds3D.from(p1, p4).hasSize(high));
        Assertions.assertFalse(Bounds3D.from(p1, p4).hasSize(low));

        Assertions.assertTrue(Bounds3D.from(p1, p5).hasSize(high));
        Assertions.assertTrue(Bounds3D.from(p1, p5).hasSize(low));
    }

    @Test
    void testContains_strict() {
        // arrange
        final Bounds3D b = Bounds3D.from(
                Vector3D.of(0, 4, 8),
                Vector3D.of(2, 6, 10));

        // act/assert
        assertContainsStrict(b, true,
                b.getCentroid(),
                Vector3D.of(0, 4, 8), Vector3D.of(2, 6, 10),
                Vector3D.of(1, 5, 9),
                Vector3D.of(0, 5, 9), Vector3D.of(2, 5, 9),
                Vector3D.of(1, 4, 9), Vector3D.of(1, 6, 9),
                Vector3D.of(1, 5, 8), Vector3D.of(1, 5, 10));

        assertContainsStrict(b, false,
                Vector3D.ZERO,
                Vector3D.of(-1, 5, 9), Vector3D.of(3, 5, 9),
                Vector3D.of(1, 3, 9), Vector3D.of(1, 7, 9),
                Vector3D.of(1, 5, 7), Vector3D.of(1, 5, 11),
                Vector3D.of(-1e-15, 4, 8), Vector3D.of(2, 6 + 1e-15, 10), Vector3D.of(0, 4, 10 + 1e-15));
    }

    @Test
    void testContains_precision() {
        // arrange
        final Bounds3D b = Bounds3D.from(
                Vector3D.of(0, 4, 8),
                Vector3D.of(2, 6, 10));

        // act/assert
        assertContainsWithPrecision(b, true,
                b.getCentroid(),
                Vector3D.of(0, 4, 8), Vector3D.of(2, 6, 10),
                Vector3D.of(1, 5, 9),
                Vector3D.of(0, 5, 9), Vector3D.of(2, 5, 9),
                Vector3D.of(1, 4, 9), Vector3D.of(1, 6, 9),
                Vector3D.of(1, 5, 8), Vector3D.of(1, 5, 10),
                Vector3D.of(-1e-15, 4, 8), Vector3D.of(2, 6 + 1e-15, 10), Vector3D.of(0, 4, 10 + 1e-15));

        assertContainsWithPrecision(b, false,
                Vector3D.ZERO,
                Vector3D.of(-1, 5, 9), Vector3D.of(3, 5, 9),
                Vector3D.of(1, 3, 9), Vector3D.of(1, 7, 9),
                Vector3D.of(1, 5, 7), Vector3D.of(1, 5, 11));
    }

    @Test
    void testIntersects() {
        // arrange
        final Bounds3D b = Bounds3D.from(Vector3D.ZERO, Vector3D.of(1, 1, 1));

        // act/assert
        checkIntersects(b, Vector3D::getX, (v, x) -> Vector3D.of(x, v.getY(), v.getZ()));
        checkIntersects(b, Vector3D::getY, (v, y) -> Vector3D.of(v.getX(), y, v.getZ()));
        checkIntersects(b, Vector3D::getZ, (v, z) -> Vector3D.of(v.getX(), v.getY(), z));
    }

    private void checkIntersects(final Bounds3D b, final ToDoubleFunction<? super Vector3D> getter,
                                 final BiFunction<? super Vector3D, Double, ? extends Vector3D> setter) {

        final Vector3D min = b.getMin();
        final Vector3D max = b.getMax();

        final double minValue = getter.applyAsDouble(min);
        final double maxValue = getter.applyAsDouble(max);
        final double midValue = (0.5 * (maxValue - minValue)) + minValue;

        // check all possible interval relationships

        // start below minValue
        Assertions.assertFalse(b.intersects(Bounds3D.from(
                setter.apply(min, minValue - 2), setter.apply(max, minValue - 1))));

        Assertions.assertTrue(b.intersects(Bounds3D.from(
                setter.apply(min, minValue - 2), setter.apply(max, minValue))));
        Assertions.assertTrue(b.intersects(Bounds3D.from(
                setter.apply(min, minValue - 2), setter.apply(max, midValue))));
        Assertions.assertTrue(b.intersects(Bounds3D.from(
                setter.apply(min, minValue - 2), setter.apply(max, maxValue))));
        Assertions.assertTrue(b.intersects(Bounds3D.from(
                setter.apply(min, minValue - 2), setter.apply(max, maxValue + 1))));

        // start on minValue
        Assertions.assertTrue(b.intersects(Bounds3D.from(
                setter.apply(min, minValue), setter.apply(max, minValue))));
        Assertions.assertTrue(b.intersects(Bounds3D.from(
                setter.apply(min, minValue), setter.apply(max, midValue))));
        Assertions.assertTrue(b.intersects(Bounds3D.from(
                setter.apply(min, minValue), setter.apply(max, maxValue))));
        Assertions.assertTrue(b.intersects(Bounds3D.from(
                setter.apply(min, minValue), setter.apply(max, maxValue + 1))));

        // start on midValue
        Assertions.assertTrue(b.intersects(Bounds3D.from(
                setter.apply(min, midValue), setter.apply(max, midValue))));
        Assertions.assertTrue(b.intersects(Bounds3D.from(
                setter.apply(min, midValue), setter.apply(max, maxValue))));
        Assertions.assertTrue(b.intersects(Bounds3D.from(
                setter.apply(min, midValue), setter.apply(max, maxValue + 1))));

        // start on maxValue
        Assertions.assertTrue(b.intersects(Bounds3D.from(
                setter.apply(min, maxValue), setter.apply(max, maxValue))));
        Assertions.assertTrue(b.intersects(Bounds3D.from(
                setter.apply(min, maxValue), setter.apply(max, maxValue + 1))));

        // start above maxValue
        Assertions.assertFalse(b.intersects(Bounds3D.from(
                setter.apply(min, maxValue + 1), setter.apply(max, maxValue + 2))));
    }

    @Test
    void testIntersection() {
        // -- arrange
        final Bounds3D b = Bounds3D.from(Vector3D.ZERO, Vector3D.of(1, 1, 1));

        // -- act/assert

        // move along x-axis
        Assertions.assertNull(b.intersection(Bounds3D.from(Vector3D.of(-2, 0, 0), Vector3D.of(-1, 1, 1))));
        checkIntersection(b, Vector3D.of(-1, 0, 0), Vector3D.of(0, 1, 1),
                Vector3D.of(0, 0, 0), Vector3D.of(0, 1, 1));
        checkIntersection(b, Vector3D.of(-1, 0, 0), Vector3D.of(0.5, 1, 1),
                Vector3D.of(0, 0, 0), Vector3D.of(0.5, 1, 1));
        checkIntersection(b, Vector3D.of(-1, 0, 0), Vector3D.of(1, 1, 1),
                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
        checkIntersection(b, Vector3D.of(-1, 0, 0), Vector3D.of(2, 1, 1),
                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
        checkIntersection(b, Vector3D.of(0, 0, 0), Vector3D.of(2, 1, 1),
                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
        checkIntersection(b, Vector3D.of(0.5, 0, 0), Vector3D.of(2, 1, 1),
                Vector3D.of(0.5, 0, 0), Vector3D.of(1, 1, 1));
        checkIntersection(b, Vector3D.of(1, 0, 0), Vector3D.of(2, 1, 1),
                Vector3D.of(1, 0, 0), Vector3D.of(1, 1, 1));
        Assertions.assertNull(b.intersection(Bounds3D.from(Vector3D.of(2, 0, 0), Vector3D.of(3, 1, 1))));

        // move along y-axis
        Assertions.assertNull(b.intersection(Bounds3D.from(Vector3D.of(0, -2, 0), Vector3D.of(1, -1, 1))));
        checkIntersection(b, Vector3D.of(0, -1, 0), Vector3D.of(1, 0, 1),
                Vector3D.of(0, 0, 0), Vector3D.of(1, 0, 1));
        checkIntersection(b, Vector3D.of(0, -1, 0), Vector3D.of(1, 0.5, 1),
                Vector3D.of(0, 0, 0), Vector3D.of(1, 0.5, 1));
        checkIntersection(b, Vector3D.of(0, -1, 0), Vector3D.of(1, 1, 1),
                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
        checkIntersection(b, Vector3D.of(0, -1, 0), Vector3D.of(1, 2, 1),
                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
        checkIntersection(b, Vector3D.of(0, 0, 0), Vector3D.of(1, 2, 1),
                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
        checkIntersection(b, Vector3D.of(0, 0.5, 0), Vector3D.of(1, 2, 1),
                Vector3D.of(0, 0.5, 0), Vector3D.of(1, 1, 1));
        checkIntersection(b, Vector3D.of(0, 1, 0), Vector3D.of(1, 2, 1),
                Vector3D.of(0, 1, 0), Vector3D.of(1, 1, 1));
        Assertions.assertNull(b.intersection(Bounds3D.from(Vector3D.of(0, 2, 0), Vector3D.of(1, 3, 1))));

        // move along z-axis
        Assertions.assertNull(b.intersection(Bounds3D.from(Vector3D.of(0, 0, -2), Vector3D.of(1, 1, -1))));
        checkIntersection(b, Vector3D.of(0, 0, -1), Vector3D.of(1, 1, 0),
                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 0));
        checkIntersection(b, Vector3D.of(0, 0, -1), Vector3D.of(1, 1, 0.5),
                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 0.5));
        checkIntersection(b, Vector3D.of(0, 0, -1), Vector3D.of(1, 1, 1),
                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
        checkIntersection(b, Vector3D.of(0, 0, -1), Vector3D.of(1, 1, 2),
                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
        checkIntersection(b, Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 2),
                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
        checkIntersection(b, Vector3D.of(0, 0, 0.5), Vector3D.of(1, 1, 2),
                Vector3D.of(0, 0, 0.5), Vector3D.of(1, 1, 1));
        checkIntersection(b, Vector3D.of(0, 0, 1), Vector3D.of(1, 1, 2),
                Vector3D.of(0, 0, 1), Vector3D.of(1, 1, 1));
        Assertions.assertNull(b.intersection(Bounds3D.from(Vector3D.of(0, 0, 2), Vector3D.of(1, 1, 3))));
    }

    private void checkIntersection(final Bounds3D b, final Vector3D a1, final Vector3D a2, final Vector3D r1, final Vector3D r2) {
        final Bounds3D a = Bounds3D.from(a1, a2);
        final Bounds3D result = b.intersection(a);

        checkBounds(result, r1, r2);
    }

    @Test
    void toRegion() {
        // arrange
        final Bounds3D b = Bounds3D.from(
                Vector3D.of(0, 4, 8),
                Vector3D.of(2, 6, 10));

        // act
        final Parallelepiped p = b.toRegion(TEST_PRECISION);

        // assert
        Assertions.assertEquals(8, p.getSize(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 5, 9), p.getCentroid(), TEST_EPS);
    }

    @Test
    void toRegion_boundingBoxTooSmall() {
        // act/assert
        Assertions.assertThrows(IllegalArgumentException.class, () -> Bounds3D.from(Vector3D.ZERO, Vector3D.of(1e-12, 1e-12, 1e-12))
                .toRegion(TEST_PRECISION));
    }

    @Test
    void testEq() {
        // arrange
        final Precision.DoubleEquivalence low = Precision.doubleEquivalenceOfEpsilon(1e-2);
        final Precision.DoubleEquivalence high = Precision.doubleEquivalenceOfEpsilon(1e-10);

        final Bounds3D b1 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2, 2, 2));

        final Bounds3D b2 = Bounds3D.from(Vector3D.of(1.1, 1, 1), Vector3D.of(2, 2, 2));
        final Bounds3D b3 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(1.9, 2, 2));

        final Bounds3D b4 = Bounds3D.from(Vector3D.of(1.001, 1.001, 1.001), Vector3D.of(2.001, 2.001, 2.001));

        // act/assert
        Assertions.assertTrue(b1.eq(b1, low));

        Assertions.assertFalse(b1.eq(b2, low));
        Assertions.assertFalse(b1.eq(b3, low));

        Assertions.assertTrue(b1.eq(b4, low));
        Assertions.assertTrue(b4.eq(b1, low));

        Assertions.assertFalse(b1.eq(b4, high));
        Assertions.assertFalse(b4.eq(b1, high));
    }

    @Test
    void testLinecast_intersectsFace() {
        // -- arrange
        // use unequal face sizes so that our test lines do not end up passing through
        // a vertex on the opposite side
        final Bounds3D bounds = Bounds3D.from(Vector3D.of(-0.9, -2, -3), Vector3D.of(0.9, 2, 3));

        // -- act/assert
        checkLinecastIntersectingFace(bounds, Vector3D.of(0.9, 0, 0), Vector3D.Unit.PLUS_X);
        checkLinecastIntersectingFace(bounds, Vector3D.of(-0.9, 0, 0), Vector3D.Unit.MINUS_X);

        checkLinecastIntersectingFace(bounds, Vector3D.of(0, 2, 0), Vector3D.Unit.PLUS_Y);
        checkLinecastIntersectingFace(bounds, Vector3D.of(0, -2, 0), Vector3D.Unit.MINUS_Y);

        checkLinecastIntersectingFace(bounds, Vector3D.of(0, 0, 3), Vector3D.Unit.PLUS_Z);
        checkLinecastIntersectingFace(bounds, Vector3D.of(0, 0, -3), Vector3D.Unit.MINUS_Z);
    }

    private void checkLinecastIntersectingFace(
            final Bounds3D bounds,
            final Vector3D facePt,
            final Vector3D normal) {

        // -- arrange
        final Vector3D offset = normal.multiply(1.2);
        final Parallelepiped region = bounds.toRegion(TEST_PRECISION);

        EuclideanTestUtils.permute(-1, 1, 0.5, (x, y, z) -> {
            final Vector3D otherPt = facePt
                    .add(Vector3D.of(x, y, z))
                    .add(offset);
            final Line3D line = Lines3D.fromPoints(otherPt, facePt, TEST_PRECISION);

            final LinecastPoint3D reversePt = region.linecastFirst(line.reverse());

            // -- act/assert
            linecastChecker(bounds)
                .expect(facePt, normal)
                .and(reversePt.getPoint(), reversePt.getNormal())
                .whenGiven(line);

            linecastChecker(bounds)
                .and(reversePt.getPoint(), reversePt.getNormal())
                .expect(facePt, normal)
                .whenGiven(line.reverse());
        });
    }

    @Test
    void testLinecast_intersectsSingleVertex() {
        // -- arrange
        final Bounds3D bounds = Bounds3D.from(Vector3D.ZERO, Vector3D.of(1, 1, 1));

        // -- act/assert
        checkLinecastIntersectingSingleVertex(bounds, Vector3D.ZERO);
        checkLinecastIntersectingSingleVertex(bounds, Vector3D.of(0, 0, 1));
        checkLinecastIntersectingSingleVertex(bounds, Vector3D.of(0, 1, 0));
        checkLinecastIntersectingSingleVertex(bounds, Vector3D.of(0, 1, 1));
        checkLinecastIntersectingSingleVertex(bounds, Vector3D.of(1, 0, 0));
        checkLinecastIntersectingSingleVertex(bounds, Vector3D.of(1, 0, 1));
        checkLinecastIntersectingSingleVertex(bounds, Vector3D.of(1, 1, 0));
        checkLinecastIntersectingSingleVertex(bounds, Vector3D.of(1, 1, 1));
    }

    private void checkLinecastIntersectingSingleVertex(
            final Bounds3D bounds,
            final Vector3D vertex) {

        // -- arrange
        final Vector3D centerToVertex = vertex.subtract(bounds.getCentroid()).normalize();
        final Vector3D baseLineDir = centerToVertex.orthogonal();

        final int runCnt = 10;
        for (double a = 0; a < Angle.TWO_PI; a += Angle.TWO_PI / runCnt) {

            // construct a line orthogonal to the vector from the bounds center to the vertex and passing
            // through the vertex
            final Vector3D lineDir = QuaternionRotation.fromAxisAngle(centerToVertex, a).apply(baseLineDir);
            final Line3D line = Lines3D.fromPointAndDirection(vertex, lineDir, TEST_PRECISION);

            // construct possible normals for this vertex
            final List<Vector3D> normals = new ArrayList<>();
            normals.add(centerToVertex.project(Vector3D.Unit.PLUS_X).normalize());
            normals.add(centerToVertex.project(Vector3D.Unit.PLUS_Y).normalize());
            normals.add(centerToVertex.project(Vector3D.Unit.PLUS_Z).normalize());

            normals.sort(Vector3D.COORDINATE_ASCENDING_ORDER);

            // create the checker and populate it with the normals of faces that are not parallel to
            // the line
            final BoundsLinecastChecker3D checker = linecastChecker(bounds);
            for (final Vector3D normal : normals) {
                if (!TEST_PRECISION.eqZero(normal.dot(lineDir))) {
                    checker.expect(vertex, normal);
                }
            }

            // -- act/assert
            checker.whenGiven(line)
                .whenGiven(line.reverse());
        }
    }

    @Test
    void testLinecast_vertexToVertex() {
        // -- arrange
        final Vector3D min = Vector3D.ZERO;
        final Vector3D max = Vector3D.of(1, 1, 1);

        final Bounds3D bounds = Bounds3D.from(min, max);
        final Line3D line = Lines3D.fromPoints(min, max, TEST_PRECISION);

        // -- act/assert
        linecastChecker(bounds)
            .expect(min, Vector3D.Unit.MINUS_X)
            .and(min, Vector3D.Unit.MINUS_Y)
            .and(min, Vector3D.Unit.MINUS_Z)
            .and(max, Vector3D.Unit.PLUS_Z)
            .and(max, Vector3D.Unit.PLUS_Y)
            .and(max, Vector3D.Unit.PLUS_X)
            .whenGiven(line);
    }

    @Test
    void testLinecast_edgeToEdge() {
        // -- arrange
        final Vector3D min = Vector3D.of(0, 0, 0);
        final Vector3D max = Vector3D.of(1, 1, 1);

        final Bounds3D bounds = Bounds3D.from(min, max);

        final Vector3D start = Vector3D.of(0, 0, 0.5);
        final Vector3D end = Vector3D.of(1, 1, 0.5);

        final Line3D line = Lines3D.fromPoints(start, end, TEST_PRECISION);

        // -- act/assert
        linecastChecker(bounds)
            .expect(start, Vector3D.Unit.MINUS_X)
            .and(start, Vector3D.Unit.MINUS_Y)
            .and(end, Vector3D.Unit.PLUS_Y)
            .and(end, Vector3D.Unit.PLUS_X)
            .whenGiven(line);
    }

    @Test
    void testLinecast_alongFace() {
        // -- arrange
        final Vector3D min = Vector3D.ZERO;
        final Vector3D max = Vector3D.of(1, 1, 1);

        final Bounds3D bounds = Bounds3D.from(min, max);

        final int cnt = 10;
        for (double x = min.getX();
                x <= max.getX();
                x += bounds.getDiagonal().getX() / cnt) {

            final Vector3D start = Vector3D.of(x, min.getY(), max.getZ());
            final Vector3D end = Vector3D.of(x, max.getY(), max.getZ());

            final Line3D line = Lines3D.fromPoints(start, end, TEST_PRECISION);

            // -- act/assert
            linecastChecker(bounds)
                .expect(start, Vector3D.Unit.MINUS_Y)
                .and(end, Vector3D.Unit.PLUS_Y)
                .whenGiven(line);
        }
    }

    @Test
    void testLinecast_noIntersection() {
        // -- arrange
        final Bounds3D bounds = Bounds3D.from(Vector3D.ZERO, Vector3D.of(1, 1, 1));

        // -- act/assert
        checkLinecastNoIntersection(bounds, Vector3D.ZERO);
        checkLinecastNoIntersection(bounds, Vector3D.of(0, 0, 1));
        checkLinecastNoIntersection(bounds, Vector3D.of(0, 1, 0));
        checkLinecastNoIntersection(bounds, Vector3D.of(0, 1, 1));
        checkLinecastNoIntersection(bounds, Vector3D.of(1, 0, 0));
        checkLinecastNoIntersection(bounds, Vector3D.of(1, 0, 1));
        checkLinecastNoIntersection(bounds, Vector3D.of(1, 1, 0));
        checkLinecastNoIntersection(bounds, Vector3D.of(1, 1, 1));
    }

    private void checkLinecastNoIntersection(
            final Bounds3D bounds,
            final Vector3D vertex) {

        // -- arrange
        final Vector3D toVertex = bounds.getCentroid().directionTo(vertex);
        final Vector3D baseLineDir = toVertex.orthogonal();

        final Vector3D offsetVertex = vertex.add(toVertex);

        final Line3D plusXLine = Lines3D.fromPointAndDirection(offsetVertex, Vector3D.Unit.PLUS_X, TEST_PRECISION);
        final Line3D plusYLine = Lines3D.fromPointAndDirection(offsetVertex, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
        final Line3D plusZLine = Lines3D.fromPointAndDirection(offsetVertex, Vector3D.Unit.PLUS_Z, TEST_PRECISION);

        final BoundsLinecastChecker3D emptyChecker = linecastChecker(bounds)
                .expectNothing();

        // -- act/assert
        // check axis-aligned lines
        emptyChecker
            .whenGiven(plusXLine)
            .whenGiven(plusYLine)
            .whenGiven(plusZLine);

        // check lines orthogonal to the axis
        final int runCnt = 10;
        for (double a = 0; a < Angle.TWO_PI; a += Angle.TWO_PI / runCnt) {
            final Vector3D lineDir = QuaternionRotation.fromAxisAngle(toVertex, a).apply(baseLineDir);
            final Line3D line = Lines3D.fromPointAndDirection(offsetVertex, lineDir, TEST_PRECISION);

            emptyChecker.whenGiven(line);
        }
    }

    @Test
    void testLinecast_nonSpan() {
        // -- arrange
        final Vector3D min = Vector3D.ZERO;
        final Vector3D max = Vector3D.of(1, 1, 1);

        final Bounds3D bounds = Bounds3D.from(min, max);

        final Vector3D centroid = bounds.getCentroid();

        final Vector3D start = Vector3D.of(max.getX(), centroid.getY(), centroid.getZ());
        final Vector3D end = Vector3D.of(min.getX(), centroid.getY(), centroid.getZ());

        final Line3D line = Lines3D.fromPoints(start, end, TEST_PRECISION);

        // -- act/assert
        linecastChecker(bounds)
            .expect(end, Vector3D.Unit.MINUS_X)
            .whenGiven(line.rayFrom(-0.5));

        linecastChecker(bounds)
            .expect(start, Vector3D.Unit.PLUS_X)
            .whenGiven(line.reverseRayTo(-0.5));

        linecastChecker(bounds)
            .expectNothing()
            .whenGiven(line.segment(-0.9, -0.1));

        linecastChecker(bounds)
            .expect(end, Vector3D.Unit.MINUS_X)
            .whenGiven(line.segment(-0.9, 0.1));

        linecastChecker(bounds)
            .expect(start, Vector3D.Unit.PLUS_X)
            .whenGiven(line.segment(-1.1, -0.1));

        linecastChecker(bounds)
            .expect(start, Vector3D.Unit.PLUS_X)
            .expect(end, Vector3D.Unit.MINUS_X)
            .whenGiven(line.segment(-1.1, 0.1));
    }

    @Test
    void testLinecast_subsetEndpointOnBounds() {
        // -- arrange
        final Vector3D min = Vector3D.ZERO;
        final Vector3D max = Vector3D.of(1, 1, 1);

        final Bounds3D bounds = Bounds3D.from(min, max);

        final Vector3D centroid = bounds.getCentroid();

        final Vector3D start = Vector3D.of(max.getX(), centroid.getY(), centroid.getZ());
        final Vector3D end = Vector3D.of(min.getX(), centroid.getY(), centroid.getZ());

        final Line3D line = Lines3D.fromPoints(start, end, TEST_PRECISION);

        // -- act/assert
        linecastChecker(bounds)
            .expect(end, Vector3D.Unit.MINUS_X)
            .whenGiven(line.rayFrom(0));

        linecastChecker(bounds)
            .expect(end, Vector3D.Unit.MINUS_X)
            .whenGiven(line.segment(0, 1));

        linecastChecker(bounds)
            .expect(start, Vector3D.Unit.PLUS_X)
            .whenGiven(line.reverseRayTo(-1));

        linecastChecker(bounds)
            .expect(start, Vector3D.Unit.PLUS_X)
            .whenGiven(line.segment(-2, -1));
    }

    @Test
    void testLinecast_usesLinePrecision() {
        // -- arrange
        final double withinEps = 0.9 * TEST_EPS;
        final double outsideEps = 1.1 * TEST_EPS;

        final Vector3D min = Vector3D.ZERO;
        final Vector3D max = Vector3D.of(1, 1, 1);

        final Bounds3D bounds = Bounds3D.from(min, max);

        final Vector3D centroid = bounds.getCentroid();

        final Vector3D centerStart = Vector3D.of(max.getX(), centroid.getY(), centroid.getZ());
        final Vector3D centerEnd = Vector3D.of(min.getX(), centroid.getY(), centroid.getZ());

        final Line3D centerLine = Lines3D.fromPoints(centerStart, centerEnd, TEST_PRECISION);

        final Vector3D faceStart = Vector3D.of(max.getX() + withinEps, max.getY() + withinEps, min.getZ());
        final Vector3D faceEnd = Vector3D.of(max.getX() + withinEps, max.getY() + withinEps, max.getZ());

        final Line3D faceLine = Lines3D.fromPoints(faceStart, faceEnd, TEST_PRECISION);

        // -- act/assert
        linecastChecker(bounds)
            .expect(centerEnd, Vector3D.Unit.MINUS_X)
            .whenGiven(centerLine.rayFrom(withinEps));

        linecastChecker(bounds)
            .expectNothing()
            .whenGiven(centerLine.rayFrom(outsideEps));

        linecastChecker(bounds)
            .expect(centerStart, Vector3D.Unit.PLUS_X)
            .expect(centerEnd, Vector3D.Unit.MINUS_X)
            .whenGiven(centerLine.segment(-1 + withinEps, -withinEps));

        linecastChecker(bounds)
            .expectNothing()
            .whenGiven(centerLine.segment(-1 + outsideEps, -outsideEps));

        linecastChecker(bounds)
            .expect(faceStart, Vector3D.Unit.MINUS_Z)
            .expect(faceEnd, Vector3D.Unit.PLUS_Z)
            .whenGiven(faceLine.segment(withinEps, 1 - withinEps));

        linecastChecker(bounds)
            .expectNothing()
            .whenGiven(faceLine.segment(outsideEps, 1 - outsideEps));
    }

    @Test
    void testLinecast_boundsHasNoSize() {
        // -- arrange
        final Vector3D pt = Vector3D.of(1, 2, 3);

        final Bounds3D bounds = Bounds3D.from(pt, pt);

        final Line3D diagonalLine = Lines3D.fromPointAndDirection(pt, Vector3D.of(1, 1, 1), TEST_PRECISION);

        final Line3D plusXLine = Lines3D.fromPointAndDirection(pt, Vector3D.Unit.PLUS_X, TEST_PRECISION);

        // -- act/assert
        linecastChecker(bounds)
            .expect(pt, Vector3D.Unit.MINUS_X)
            .expect(pt, Vector3D.Unit.MINUS_Y)
            .expect(pt, Vector3D.Unit.MINUS_Z)
            .expect(pt, Vector3D.Unit.PLUS_Z)
            .expect(pt, Vector3D.Unit.PLUS_Y)
            .expect(pt, Vector3D.Unit.PLUS_X)
            .whenGiven(diagonalLine);

        linecastChecker(bounds)
            .expect(pt, Vector3D.Unit.MINUS_X)
            .expect(pt, Vector3D.Unit.PLUS_X)
            .whenGiven(plusXLine);
    }

    @Test
    void testLineIntersection() {
        // -- arrange
        final Vector3D min = Vector3D.ZERO;
        final Vector3D max = Vector3D.of(1, 1, 1);

        final Vector3D insideMin = Vector3D.of(0.1, 0.1, 0.1);
        final Vector3D insideMax = Vector3D.of(0.9, 0.9, 0.9);

        final Vector3D outsideMin = Vector3D.of(-0.1, -0.1, -0.1);
        final Vector3D outsideMax = Vector3D.of(1.1, 1.1, 1.1);

        final Bounds3D bounds = Bounds3D.from(min, max);

        final Line3D diagonal = Lines3D.fromPoints(min, max, TEST_PRECISION);

        // -- act/assert
        assertLineIntersection(bounds, diagonal, min, max);

        assertLineIntersection(bounds, diagonal.segment(outsideMin, outsideMax), min, max);
        assertLineIntersection(bounds, diagonal.segment(outsideMin, insideMax), min, insideMax);
        assertLineIntersection(bounds, diagonal.segment(insideMin, outsideMax), insideMin, max);
        assertLineIntersection(bounds, diagonal.segment(insideMin, insideMax), insideMin, insideMax);

        assertLineIntersection(bounds, diagonal.rayFrom(min), min, max);
        assertLineIntersection(bounds, diagonal.reverseRayTo(min), min, min);

        assertLineIntersection(bounds, diagonal.rayFrom(max), max, max);
        assertLineIntersection(bounds, diagonal.reverseRayTo(max), min, max);

        assertLineIntersection(bounds, diagonal.rayFrom(insideMax), insideMax, max);
        assertLineIntersection(bounds, diagonal.reverseRayTo(insideMax), min, insideMax);
    }

    @Test
    void testLineIntersection_noIntersection() {
        // -- arrange
        final Bounds3D bounds = Bounds3D.from(Vector3D.ZERO, Vector3D.of(1, 1, 1));

        final Line3D plusXLine =
                Lines3D.fromPointAndDirection(bounds.getCentroid(), Vector3D.Unit.PLUS_X, TEST_PRECISION);

        // -- act/assert
        checkLineNoIntersection(bounds, Vector3D.ZERO);
        checkLineNoIntersection(bounds, Vector3D.of(0, 0, 1));
        checkLineNoIntersection(bounds, Vector3D.of(0, 1, 0));
        checkLineNoIntersection(bounds, Vector3D.of(0, 1, 1));
        checkLineNoIntersection(bounds, Vector3D.of(1, 0, 0));
        checkLineNoIntersection(bounds, Vector3D.of(1, 0, 1));
        checkLineNoIntersection(bounds, Vector3D.of(1, 1, 0));
        checkLineNoIntersection(bounds, Vector3D.of(1, 1, 1));

        assertNoLineIntersection(bounds, plusXLine.segment(-0.2, -0.1));
        assertNoLineIntersection(bounds, plusXLine.reverseRayTo(-0.1));

        assertNoLineIntersection(bounds, plusXLine.segment(1.1, 1.2));
        assertNoLineIntersection(bounds, plusXLine.rayFrom(1.1));
    }

    private void checkLineNoIntersection(
            final Bounds3D bounds,
            final Vector3D vertex) {

        // -- arrange
        final Vector3D toVertex = bounds.getCentroid().directionTo(vertex);
        final Vector3D baseLineDir = toVertex.orthogonal();

        final Vector3D offsetVertex = vertex.add(toVertex);

        final Line3D plusXLine = Lines3D.fromPointAndDirection(offsetVertex, Vector3D.Unit.PLUS_X, TEST_PRECISION);
        final Line3D plusYLine = Lines3D.fromPointAndDirection(offsetVertex, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
        final Line3D plusZLine = Lines3D.fromPointAndDirection(offsetVertex, Vector3D.Unit.PLUS_Z, TEST_PRECISION);

        // -- act/assert
        // check axis-aligned lines
        assertNoLineIntersection(bounds, plusXLine);
        assertNoLineIntersection(bounds, plusYLine);
        assertNoLineIntersection(bounds, plusZLine);

        // check lines orthogonal to the axis
        final int runCnt = 10;
        for (double a = 0; a < Angle.TWO_PI; a += Angle.TWO_PI / runCnt) {
            final Vector3D lineDir = QuaternionRotation.fromAxisAngle(toVertex, a).apply(baseLineDir);
            final Line3D line = Lines3D.fromPointAndDirection(offsetVertex, lineDir, TEST_PRECISION);

            assertNoLineIntersection(bounds, line);
        }
    }

    @Test
    void testLineIntersection_boundsHasNoSize() {
        // -- arrange
        final Vector3D pt = Vector3D.of(1, 2, 3);

        final Bounds3D bounds = Bounds3D.from(pt, pt);

        final Line3D plusXLine = Lines3D.fromPointAndDirection(pt, Vector3D.Unit.PLUS_X, TEST_PRECISION);

        // -- act/assert
        assertLineIntersection(bounds, plusXLine, pt, pt);
        assertLineIntersection(bounds, plusXLine.rayFrom(pt), pt, pt);
    }

    @Test
    void testLineIntersection_lineAlmostParallel() {
        // -- arrange
        final Vector3D min = Vector3D.of(1e150, -1, -1);
        final Vector3D max = Vector3D.of(1.1e150, 1, 1);

        final Bounds3D bounds = Bounds3D.from(min, max);

        final Vector3D lineDir = Vector3D.of(1, -5e-11, 0);
        final Line3D line = Lines3D.fromPointAndDirection(Vector3D.ZERO, lineDir, TEST_PRECISION);

        // -- act
        assertNoLineIntersection(bounds, line);
    }

    @Test
    void testHashCode() {
        // arrange
        final Bounds3D b1 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2, 2, 2));

        final Bounds3D b2 = Bounds3D.from(Vector3D.of(-2, 1, 1), Vector3D.of(2, 2, 2));
        final Bounds3D b3 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(3, 2, 2));
        final Bounds3D b4 = Bounds3D.from(Vector3D.of(1 + 1e-15, 1, 1), Vector3D.of(2, 2, 2));
        final Bounds3D b5 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2 + 1e-15, 2, 2));

        final Bounds3D b6 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2, 2, 2));

        // act
        final int hash = b1.hashCode();

        // assert
        Assertions.assertEquals(hash, b1.hashCode());

        Assertions.assertNotEquals(hash, b2.hashCode());
        Assertions.assertNotEquals(hash, b3.hashCode());
        Assertions.assertNotEquals(hash, b4.hashCode());
        Assertions.assertNotEquals(hash, b5.hashCode());

        Assertions.assertEquals(hash, b6.hashCode());
    }

    @Test
    void testEquals() {
        // arrange
        final Bounds3D b1 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2, 2, 2));

        final Bounds3D b2 = Bounds3D.from(Vector3D.of(-1, 1, 1), Vector3D.of(2, 2, 2));
        final Bounds3D b3 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(3, 2, 2));
        final Bounds3D b4 = Bounds3D.from(Vector3D.of(1 + 1e-15, 1, 1), Vector3D.of(2, 2, 2));
        final Bounds3D b5 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2 + 1e-15, 2, 2));

        final Bounds3D b6 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2, 2, 2));

        // act/assert
        GeometryTestUtils.assertSimpleEqualsCases(b1);

        Assertions.assertNotEquals(b1, b2);
        Assertions.assertNotEquals(b1, b3);
        Assertions.assertNotEquals(b1, b4);
        Assertions.assertNotEquals(b1, b5);

        Assertions.assertEquals(b1, b6);
    }

    @Test
    void testToString() {
        // arrange
        final Bounds3D b = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2, 2, 2));

        // act
        final String str = b.toString();

        // assert
        GeometryTestUtils.assertContains("Bounds3D[min= (1", str);
        GeometryTestUtils.assertContains(", max= (2", str);
    }

    @Test
    void testBuilder_addMethods() {
        // arrange
        final Vector3D p1 = Vector3D.of(1, 10, 11);
        final Vector3D p2 = Vector3D.of(2, 9, 12);
        final Vector3D p3 = Vector3D.of(3, 8, 13);
        final Vector3D p4 = Vector3D.of(4, 7, 14);
        final Vector3D p5 = Vector3D.of(5, 6, 15);

        // act
        final Bounds3D b = Bounds3D.builder()
                .add(p1)
                .addAll(Arrays.asList(p2, p3))
                .add(Bounds3D.from(p4, p5))
                .build();

        // assert
        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 6, 11), b.getMin(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(5, 10, 15), b.getMax(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 8, 13), b.getCentroid(), TEST_EPS);
    }

    @Test
    void testBuilder_hasBounds() {
        // act/assert
        Assertions.assertFalse(Bounds3D.builder().hasBounds());

        Assertions.assertFalse(Bounds3D.builder().add(Vector3D.of(Double.NaN, 1, 1)).hasBounds());
        Assertions.assertFalse(Bounds3D.builder().add(Vector3D.of(1, Double.NaN, 1)).hasBounds());
        Assertions.assertFalse(Bounds3D.builder().add(Vector3D.of(1, 1, Double.NaN)).hasBounds());

        Assertions.assertFalse(Bounds3D.builder().add(Vector3D.of(Double.POSITIVE_INFINITY, 1, 1)).hasBounds());
        Assertions.assertFalse(Bounds3D.builder().add(Vector3D.of(1, Double.POSITIVE_INFINITY, 1)).hasBounds());
        Assertions.assertFalse(Bounds3D.builder().add(Vector3D.of(1, 1, Double.POSITIVE_INFINITY)).hasBounds());

        Assertions.assertFalse(Bounds3D.builder().add(Vector3D.of(Double.NEGATIVE_INFINITY, 1, 1)).hasBounds());
        Assertions.assertFalse(Bounds3D.builder().add(Vector3D.of(1, Double.NEGATIVE_INFINITY, 1)).hasBounds());
        Assertions.assertFalse(Bounds3D.builder().add(Vector3D.of(1, 1, Double.NEGATIVE_INFINITY)).hasBounds());

        Assertions.assertTrue(Bounds3D.builder().add(Vector3D.ZERO).hasBounds());
    }

    private static void checkBounds(final Bounds3D b, final Vector3D min, final Vector3D max) {
        EuclideanTestUtils.assertCoordinatesEqual(min, b.getMin(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(max, b.getMax(), TEST_EPS);
    }

    private static void assertContainsStrict(
            final Bounds3D bounds,
            final boolean contains,
            final Vector3D... pts) {
        for (final Vector3D pt : pts) {
            Assertions.assertEquals(contains, bounds.contains(pt), "Unexpected location for point " + pt);
        }
    }

    private static void assertContainsWithPrecision(
            final Bounds3D bounds,
            final boolean contains,
            final Vector3D... pts) {
        for (final Vector3D pt : pts) {
            Assertions.assertEquals(contains, bounds.contains(pt, TEST_PRECISION), "Unexpected location for point " + pt);
        }
    }

    private static void assertLineIntersection(
            final Bounds3D bounds,
            final Line3D line,
            final Vector3D start,
            final Vector3D end) {
        final Segment3D segment = bounds.intersection(line);

        Assertions.assertSame(line, segment.getLine());
        assertSegment(segment, start, end);

        Assertions.assertTrue(bounds.intersects(line));
    }

    private static void assertLineIntersection(
            final Bounds3D bounds,
            final LineConvexSubset3D subset,
            final Vector3D start,
            final Vector3D end) {
        final Segment3D segment = bounds.intersection(subset);

        Assertions.assertSame(subset.getLine(), segment.getLine());
        assertSegment(segment, start, end);

        Assertions.assertTrue(bounds.intersects(subset));
    }

    private static void assertNoLineIntersection(
            final Bounds3D bounds,
            final Line3D line) {
        Assertions.assertNull(bounds.intersection(line));
        Assertions.assertFalse(bounds.intersects(line));
    }

    private static void assertNoLineIntersection(
            final Bounds3D bounds,
            final LineConvexSubset3D subset) {
        Assertions.assertNull(bounds.intersection(subset));
        Assertions.assertFalse(bounds.intersects(subset));
    }

    private static void assertSegment(final Segment3D segment, final Vector3D start, final Vector3D end) {
        EuclideanTestUtils.assertCoordinatesEqual(start, segment.getStartPoint(), TEST_EPS);
        EuclideanTestUtils.assertCoordinatesEqual(end, segment.getEndPoint(), TEST_EPS);
    }

    private static BoundsLinecastChecker3D linecastChecker(final Bounds3D bounds) {
        return new BoundsLinecastChecker3D(bounds);
    }

    /**
     * Internal test class used to perform and verify linecast operations.
     */
    private static final class BoundsLinecastChecker3D {

        private final Bounds3D bounds;

        private final Parallelepiped region;

        private final LinecastChecker3D checker;

        BoundsLinecastChecker3D(final Bounds3D bounds) {
            this.bounds = bounds;
            this.region = bounds.hasSize(TEST_PRECISION) ?
                    bounds.toRegion(TEST_PRECISION) :
                    null;
            this.checker = LinecastChecker3D.with(bounds);
        }

        public BoundsLinecastChecker3D expectNothing() {
            checker.expectNothing();
            return this;
        }

        public BoundsLinecastChecker3D expect(final Vector3D pt, final Vector3D normal) {
            checker.expect(pt, normal);
            return this;
        }

        public BoundsLinecastChecker3D and(final Vector3D pt, final Vector3D normal) {
            return expect(pt, normal);
        }

        public BoundsLinecastChecker3D whenGiven(final Line3D line) {
            // perform the standard checks
            checker.whenGiven(line);

            // check that the returned points are equivalent to those returned by linecasting against
            // the region
            final List<LinecastPoint3D> boundsResults = bounds.linecast(line);

            if (region != null) {
                assertLinecastElements(region.linecast(line), bounds.linecast(line));
            }

            // check consistency with the intersects method; having linecast results guarantees
            // that we intersect the bounds but not vice versa
            if (!boundsResults.isEmpty()) {
                Assertions.assertTrue(bounds.intersects(line),
                        () -> "Linecast result is inconsistent with intersects method: line= " + line);

                assertLinecastResultsConsistentWithSegment(boundsResults, bounds.intersection(line));
            }

            return this;
        }

        public BoundsLinecastChecker3D whenGiven(final LineConvexSubset3D subset) {
            // perform the standard checks
            checker.whenGiven(subset);

            // check that the returned points are equivalent to those returned by linecasting against
            // the region
            final List<LinecastPoint3D> boundsResults = bounds.linecast(subset);

            if (region != null) {
                assertLinecastElements(region.linecast(subset), boundsResults);
            }

            // check consistency with the intersects methods; having linecast results guarantees
            // that we intersect the bounds but not vice versa
            if (!boundsResults.isEmpty()) {
                Assertions.assertTrue(bounds.intersects(subset),
                        () -> "Linecast result is inconsistent with intersects method: line subset= " + subset);

                assertLinecastResultsConsistentWithSegment(boundsResults, bounds.intersection(subset));
            }

            return this;
        }

        /** Assert that the two collections contain the same linecast points and that the elements
         * of {@code actual} are arranged in ascending abscissa order. Note that this does <em>not</em>
         * assert that {@code expected} and {@code actual} have the same exact ordering, since the
         * specific ordering is sensitive to floating point errors.
         * @param expected expected collection
         * @param actual actual collection
         */
        private void assertLinecastElements(
                final Collection<LinecastPoint3D> expected,
                final Collection<LinecastPoint3D> actual) {
            Assertions.assertEquals(expected.size(), actual.size(), "Unexpected list size");

            // create a sorted copy
            final List<LinecastPoint3D> sortedList = new ArrayList<>(actual);
            sortedList.sort(LinecastPoint3D.ABSCISSA_ORDER);

            // check element membership
            for (final LinecastPoint3D expectedPt : expected) {
                final Iterator<LinecastPoint3D> sortedIt = sortedList.iterator();

                boolean found = false;
                while (sortedIt.hasNext()) {
                    if (expectedPt.eq(sortedIt.next(), TEST_PRECISION)) {
                        found = true;
                        break;
                    }
                }

                if (!found) {
                    Assertions.fail("Missing expected linecast point " + expectedPt);
                }
            }

            // check the order
            Assertions.assertEquals(sortedList, actual);
        }

        /** Assert that the linecast results are consistent with the given segment, which is taken
         * to be the intersection of a line or line convex subset with the bounding box.
         * @param linecastResults
         * @param segment
         */
        private void assertLinecastResultsConsistentWithSegment(
                final List<LinecastPoint3D> linecastResults,
                final Segment3D segment) {

            for (final LinecastPoint3D pt : linecastResults) {
                Assertions.assertEquals(RegionLocation.BOUNDARY, segment.classifyAbscissa(pt.getAbscissa()),
                        () -> "Expected linecast point to lie on segment boundary");
            }
        }
    }
}