This is an automated email from the ASF dual-hosted git repository.
mattjuntunen pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-geometry.git
The following commit(s) were added to refs/heads/master by this push:
new 2f4e86b7 GEOMETRY-110: add ConvexHull3D using Quickhull algorithm
2f4e86b7 is described below
commit 2f4e86b792693d4c2c0e5fb2c4f198541e1fecb6
Author: Andreas Goß <[email protected]>
AuthorDate: Sun Apr 28 02:14:00 2024 +0200
GEOMETRY-110: add ConvexHull3D using Quickhull algorithm
* GEOMETRY-110
Add ConvexHull3D class and tests.
* GEOMETRY-110
Return only an unmodifiable collection of facets.
* GEOMETRY-110
Test for a large number of points.
* Checkstyle Fix.
* GEOMETRY-110
Points are added to candidates again instead of a new point set.
* GEOMETRY-110
Only candidates points are distributed.
* GEOMETRY-110
Add Simplex type
* GEOMETRY-110
Create simplex while appending and cache it for later.
* GEOMETRY-110
Build simplex incrementially while appending points.
* GEOMETRY-110
Return facets as list and filter out duplicates in vertices.
* GEOMETRY-110
Returned collections have to be unmodifiable in public methods.
* GEOMETRY-110
Rename method and calculate maximum in a loop.
* GEOMETRY-110
Declare variable outside of loop.
* GEOMETRY-110
Rename methods.
* GEOMETRY-110
Replace bounds by array.
* GEOMETRY-110
Add check for appending a collection of points
* [GEOMETRY-110]
Delete hasOusidePoints and add exclusion for facets to spotbugs.
* [GEOMETRY-110]
Move isInside method into Facet class and reverse and rename it to isOutside
* [GEOMETRY-110]
Replace vertexToFacetMap with edgeMap
* [Geometry-110]
Change loop for creating edges.
* [GEOMETRY-110]
Facets are all located through edge operations. The edges are oriented in
such a way, that neighbors share an edge in inverse orientation.
* [GEOMETRY-110]
Delete unnecessary debug code.
* [GEOMETRY-110]
Edges are oriented and lookup is done via an edgeMap. Each oriented edge is
unique in association with a facet. Each facet stores the maximum offset and
the associated point of outside points.
* [GEOMETRY-110]
- Rename Edge fields.
- Improve testing and assert all outputs of the algorithm.
* [GEOMETRY-110]
Check isDegenerate for all non-degenerate hulls.
* [GEOMETRY-110]
Even though the defined region is the same the number of facets can vary
depending on the execution order of the algorithm.
---------
Co-authored-by: agoß <agoß@Goß>
Co-authored-by: Andreas Goss <[email protected]>
---
.../euclidean/threed/hull/ConvexHull3D.java | 728 +++++++++++++++++++++
.../euclidean/threed/hull/package-info.java | 25 +
.../euclidean/threed/hull/ConvexHull3DTest.java | 276 ++++++++
.../resources/spotbugs/spotbugs-exclude-filter.xml | 5 +
4 files changed, 1034 insertions(+)
diff --git
a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/hull/ConvexHull3D.java
b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/hull/ConvexHull3D.java
new file mode 100644
index 00000000..aec0a5ee
--- /dev/null
+++
b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/hull/ConvexHull3D.java
@@ -0,0 +1,728 @@
+/*
+ * 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
+ *
+ * http://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.hull;
+
+import org.apache.commons.geometry.core.ConvexHull;
+import org.apache.commons.geometry.core.collection.PointSet;
+import org.apache.commons.geometry.euclidean.EuclideanCollections;
+import org.apache.commons.geometry.euclidean.threed.Bounds3D;
+import org.apache.commons.geometry.euclidean.threed.ConvexPolygon3D;
+import org.apache.commons.geometry.euclidean.threed.ConvexVolume;
+import org.apache.commons.geometry.euclidean.threed.Plane;
+import org.apache.commons.geometry.euclidean.threed.Planes;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.geometry.euclidean.threed.line.Line3D;
+import org.apache.commons.geometry.euclidean.threed.line.Lines3D;
+import org.apache.commons.numbers.core.Precision.DoubleEquivalence;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * This class represents a convex hull in three-dimensional Euclidean space.
+ */
+public class ConvexHull3D implements ConvexHull<Vector3D> {
+
+ /**
+ * The vertices of the convex hull.
+ */
+ private final List<Vector3D> vertices;
+
+ /**
+ * The region defined by the hull.
+ */
+ private final ConvexVolume region;
+
+ /**
+ * A collection of all facets that form the convex volume of the hull.
+ */
+ private final List<ConvexPolygon3D> facets;
+
+ /**
+ * Flag for when the hull is degenerate.
+ */
+ private final boolean isDegenerate;
+
+ /**
+ * Simple constructor no validation performed. This constructor is called
if the
+ * hull is well-formed and non-degenerative.
+ *
+ * @param facets the facets of the hull.
+ */
+ ConvexHull3D(Collection<? extends ConvexPolygon3D> facets) {
+ vertices = Collections.unmodifiableList(new
ArrayList<>(facets.stream().flatMap(f -> f.getVertices().stream())
+ .collect(Collectors.toSet())));
+ region = ConvexVolume.fromBounds(() ->
facets.stream().map(ConvexPolygon3D::getPlane).iterator());
+ this.facets = Collections.unmodifiableList(new ArrayList<>(facets));
+ this.isDegenerate = false;
+ }
+
+ /**
+ * Simple constructor no validation performed. No Region is formed as it is
+ * assumed that the hull is degenerate.
+ *
+ * @param points the given vertices of the hull.
+ * @param isDegenerate boolean flag
+ */
+ ConvexHull3D(Collection<Vector3D> points, boolean isDegenerate) {
+ vertices = Collections.unmodifiableList(new ArrayList<>(points));
+ region = null;
+ this.facets = Collections.emptyList();
+ this.isDegenerate = isDegenerate;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public List<Vector3D> getVertices() {
+ return vertices;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ConvexVolume getRegion() {
+ return region;
+ }
+
+ /**
+ * Return a collection of all two-dimensional faces (called facets) of the
+ * convex hull.
+ *
+ * @return a collection of all two-dimensional faces.
+ */
+ public List<ConvexPolygon3D> getFacets() {
+ return facets;
+ }
+
+ /**
+ * Return {@code true} if the hull is degenerate.
+ *
+ * @return the isDegenerate
+ */
+ public boolean isDegenerate() {
+ return isDegenerate;
+ }
+
+ /**
+ * Implementation of quick-hull algorithm by Barber, Dobkin and Huhdanpaa.
The
+ * algorithm constructs the convex hull for a given finite set of points.
+ * Empirically, the number of points processed by quickhull is
proportional to
+ * the number of vertices in the output. The algorithm runs on an input of
size
+ * n with r processed points in time O(n log r). We define a point of the
given
+ * set to be extreme, if and only if the point is part of the final hull.
The
+ * algorithm runs in multiple stages:
+ * <ol>
+ * <li>First we construct a simplex with extreme properties from the given
point
+ * set to maximize the possibility of choosing extreme points as initial
simplex
+ * vertices.</li>
+ * <li>We partition all the remaining points into outside sets. Each
polygon
+ * face of the simplex defines a positive and negative half-space. A point
can
+ * be assigned to the outside set of the polygon if it is an element of the
+ * positive half space.</li>
+ * <li>For each polygon-face (facet) with a non empty outside set we
choose a
+ * point with maximal distance to the given facet.</li>
+ * <li>We determine all the visible facets from the given outside point
and find
+ * a path around the horizon.</li>
+ * <li>We construct a new cone of polygons from the edges of the horizon
to the
+ * outside point. All visible facets are removed and the points in the
outside
+ * sets of the visible facets are redistributed.</li>
+ * <li>We repeat step 3-5 until each outside set is empty.</li>
+ * </ol>
+ */
+ public static class Builder {
+
+ /**
+ * Set of possible candidates.
+ */
+ private final PointSet<Vector3D> candidates;
+
+ /**
+ * Precision context used to compare floating point numbers.
+ */
+ private final DoubleEquivalence precision;
+ /**
+ * Map containing all edges as keys and the associated facets as
values.
+ */
+ private final Map<Edge, Facet> edgeMap;
+ /**
+ * Simplex for testing new points and starting the algorithm.
+ */
+ private Simplex simplex;
+ /**
+ * The minX, maxX, minY, maxY, minZ, maxZ points.
+ */
+ private Bounds3D box;
+
+ /**
+ * Constructor for a builder with the given precision.
+ *
+ * @param precision the given precision.
+ */
+ public Builder(DoubleEquivalence precision) {
+ candidates = EuclideanCollections.pointSet3D(precision);
+ this.precision = precision;
+ simplex = new Simplex(Collections.emptySet());
+ edgeMap = new HashMap<>();
+ }
+
+ /**
+ * Associates the given point with an outside set if possible.
+ *
+ * @param p the given point.
+ * @param facets the facets to check against.
+ */
+ private static void distributePoint(Vector3D p, Iterable<Facet>
facets) {
+ for (Facet facet : facets) {
+ if (facet.addPoint(p)) {
+ return;
+ }
+ }
+ }
+
+ /**
+ * Appends to the point to the set of possible candidates.
+ *
+ * @param point the given point.
+ * @return this instance.
+ */
+ public Builder append(Vector3D point) {
+ boolean recomputeSimplex = false;
+ if (box == null) {
+ box = Bounds3D.from(point);
+ recomputeSimplex = true;
+ } else if (!box.contains(point)) {
+ box = Bounds3D.from(box.getMin(), box.getMax(), point);
+ recomputeSimplex = true;
+ }
+ candidates.add(point);
+ if (recomputeSimplex) {
+ // Remove all outside Points and add all vertices again.
+ removeFacets(simplex.getFacets());
+ simplex.getFacets().stream().map(Facet::getPolygon).forEach(p
-> candidates.addAll(p.getVertices()));
+ simplex = createSimplex(candidates);
+ }
+ distributePoints(simplex.getFacets());
+ return this;
+ }
+
+ /**
+ * Appends the given collection of points to the set of possible
candidates.
+ *
+ * @param points the given collection of points.
+ * @return this instance.
+ */
+ public Builder append(Collection<Vector3D> points) {
+ boolean recomputeSimplex = false;
+ if (box == null) {
+ box = Bounds3D.from(points);
+ recomputeSimplex = true;
+ } else if (points.stream().anyMatch(p -> !box.contains(p))) {
+ box = Bounds3D.builder().add(box).addAll(points).build();
+ recomputeSimplex = true;
+ }
+
+ candidates.addAll(points);
+ if (recomputeSimplex) {
+ // Remove all outside Points and add all vertices again.
+ removeFacets(simplex.getFacets());
+ simplex.getFacets().stream().map(Facet::getPolygon).forEach(p
-> candidates.addAll(p.getVertices()));
+ simplex = createSimplex(candidates);
+ }
+ distributePoints(simplex.getFacets());
+ return this;
+ }
+
+ /**
+ * Builds a convex hull containing all appended points.
+ *
+ * @return a convex hull containing all appended points.
+ */
+ public ConvexHull3D build() {
+ if (simplex == null) {
+ return new ConvexHull3D(candidates, true);
+ }
+
+ // The simplex is degenerate.
+ if (simplex.isDegenerate()) {
+ return new ConvexHull3D(candidates, true);
+ }
+
+
+ simplex.getFacets().forEach(this::addFacet);
+ distributePoints(simplex.getFacets());
+ Facet conflictFacet = getConflictFacet();
+ while (conflictFacet != null) {
+ Vector3D conflictPoint = conflictFacet.getOutsidePoint();
+ Set<Facet> visibleFacets = new HashSet<>();
+ visibleFacets.add(conflictFacet);
+ Set<Edge> horizon = new HashSet<>();
+ getVisibleFacets(conflictFacet, conflictPoint, visibleFacets,
horizon);
+ Vector3D referencePoint =
conflictFacet.getPolygon().getCentroid();
+ Set<Facet> cone = constructCone(conflictPoint, horizon,
referencePoint);
+ removeFacets(visibleFacets);
+ cone.forEach(this::addFacet);
+ distributePoints(cone);
+ conflictFacet = getConflictFacet();
+ }
+ Collection<ConvexPolygon3D> hull = edgeMap.values().stream()
+ .map(Facet::getPolygon).collect(Collectors.toSet());
+ return new ConvexHull3D(hull);
+ }
+
+ /**
+ * Constructs a new cone with conflict point and the given edges. The
reference
+ * point is used for orientation in such a way, that the reference
point lies in
+ * the negative half-space of all newly constructed facets.
+ *
+ * @param conflictPoint the given conflict point.
+ * @param horizon the given set of edges.
+ * @param referencePoint a reference point for orientation.
+ * @return a set of newly constructed facets.
+ */
+ private Set<Facet> constructCone(Vector3D conflictPoint, Set<Edge>
horizon, Vector3D referencePoint) {
+ Set<Facet> newFacets = new HashSet<>();
+ for (Edge edge : horizon) {
+ ConvexPolygon3D newPolygon =
Planes.convexPolygonFromVertices(Arrays.asList(edge.getStart(),
+ edge.getEnd(), conflictPoint), precision);
+ newFacets.add(new Facet(newPolygon, referencePoint,
precision));
+ }
+ return newFacets;
+ }
+
+ /**
+ * Create an initial simplex for the given point set. If no non-zero
simplex can
+ * be formed the point set is degenerate and an empty Collection is
returned.
+ * Each vertex of the simplex must be inside the given point set.
+ *
+ * @param points the given point set.
+ * @return an initial simplex.
+ */
+ private Simplex createSimplex(Collection<Vector3D> points) {
+
+ // First vertex of the simplex
+ Vector3D vertex1 =
points.stream().min(Vector3D.COORDINATE_ASCENDING_ORDER).get();
+
+ // Find a point with maximal distance to the second.
+ Vector3D vertex2 =
points.stream().max(Comparator.comparingDouble(vertex1::distance)).get();
+
+ // The point is degenerate if all points are equivalent.
+ if (vertex1.eq(vertex2, precision)) {
+ return new Simplex(Collections.emptyList());
+ }
+
+ // First and second vertex form a line.
+ Line3D line = Lines3D.fromPoints(vertex1, vertex2, precision);
+
+ // Find a point with maximal distance from the line.
+ Vector3D vertex3 =
points.stream().max(Comparator.comparingDouble(line::distance)).get();
+
+ // The point set is degenerate because all points are collinear.
+ if (line.contains(vertex3)) {
+ return new Simplex(Collections.emptyList());
+ }
+
+ // Form a triangle with the first three vertices.
+ ConvexPolygon3D facet1 = Planes.triangleFromVertices(vertex1,
vertex2, vertex3, precision);
+
+ // Find a point with maximal distance to the plane formed by the
triangle.
+ Plane plane = facet1.getPlane();
+ Vector3D vertex4 =
points.stream().max(Comparator.comparingDouble(d ->
Math.abs(plane.offset(d)))).get();
+
+ // The point set is degenerate, because all points are coplanar.
+ if (plane.contains(vertex4)) {
+ return new Simplex(Collections.emptyList());
+ }
+
+ // Construct the other three facets.
+ ConvexPolygon3D facet2 =
Planes.convexPolygonFromVertices(Arrays.asList(vertex1, vertex2, vertex4),
+ precision);
+ ConvexPolygon3D facet3 =
Planes.convexPolygonFromVertices(Arrays.asList(vertex1, vertex3, vertex4),
+ precision);
+ ConvexPolygon3D facet4 =
Planes.convexPolygonFromVertices(Arrays.asList(vertex2, vertex3, vertex4),
+ precision);
+
+ List<Facet> facets = new ArrayList<>();
+
+ // Choose the right orientation for all facets.
+ facets.add(new Facet(facet1, vertex4, precision));
+ facets.add(new Facet(facet2, vertex3, precision));
+ facets.add(new Facet(facet3, vertex2, precision));
+ facets.add(new Facet(facet4, vertex1, precision));
+
+ return new Simplex(facets);
+ }
+
+ /**
+ * Adds the facet for the quickhull algorithm.
+ *
+ * @param facet the given facet.
+ */
+ private void addFacet(Facet facet) {
+ for (Edge e : facet.getEdges()) {
+ edgeMap.put(e, facet);
+ }
+ }
+
+ /**
+ * Associates each point of the candidates set with an outside set of
the given
+ * facets. Afterward the candidates set is cleared.
+ *
+ * @param facets the facets to check against.
+ */
+ private void distributePoints(Collection<Facet> facets) {
+ if (!facets.isEmpty()) {
+ candidates.forEach(p -> distributePoint(p, facets));
+ candidates.clear();
+ }
+ }
+
+ /**
+ * Returns any facet, which is currently in conflict e.g. has a
non-empty outside
+ * set.
+ *
+ * @return any facet, which is currently in conflict e.g. has a
non-empty outside
+ * set.
+ */
+ private Facet getConflictFacet() {
+ return
edgeMap.values().stream().filter(Facet::hasOutsidePoints).findFirst()
+ .orElse(null);
+ }
+
+ /**
+ * Adds all visible facets to the provided set.
+ *
+ * @param facet the given conflictFacet.
+ * @param outsidePoint the given outside point.
+ * @param visibleFacets visible facets are collected in this set.
+ * @param horizon horizon edges.
+ */
+ private void getVisibleFacets(Facet facet, Vector3D outsidePoint,
Set<Facet> visibleFacets, Set<Edge> horizon) {
+ for (Edge e : facet.getEdges()) {
+ Facet neighbor = edgeMap.get(e.getInverse());
+ if (precision.gt(neighbor.offset(outsidePoint), 0.0)) {
+ if (visibleFacets.add(neighbor)) {
+ getVisibleFacets(neighbor, outsidePoint,
visibleFacets, horizon);
+ }
+ } else {
+ horizon.add(e);
+ }
+ }
+ }
+
+ /**
+ * Removes the facets from vertexToFacets map and returns a set of all
+ * associated outside points. All outside set associated with the
visible facets
+ * are added to the possible candidates again.
+ *
+ * @param visibleFacets a set of facets.
+ */
+ private void removeFacets(Set<Facet> visibleFacets) {
+ visibleFacets.forEach(f -> candidates.addAll(f.getOutsideSet()));
+ if (!edgeMap.isEmpty()) {
+ removeFacetsFromVertexMap(visibleFacets);
+ }
+ }
+
+ /**
+ * Removes the given facets from the vertexToFacetMap.
+ *
+ * @param visibleFacets the facets to be removed.
+ */
+ private void removeFacetsFromVertexMap(Set<Facet> visibleFacets) {
+ // Remove facets from edgeMap
+ for (Facet facet : visibleFacets) {
+ for (Edge e : facet.getEdges()) {
+ edgeMap.remove(e);
+ }
+ }
+ }
+
+ }
+
+ /**
+ * A facet is a convex polygon with an associated outside set.
+ */
+ static class Facet {
+
+ /**
+ * The polygon of the facet.
+ */
+ private final ConvexPolygon3D polygon;
+
+ /**
+ * The edges of the facet.
+ */
+ private final List<Edge> edges;
+
+ /**
+ * The outside set of the facet.
+ */
+ private final Set<Vector3D> outsideSet;
+
+ /**
+ * Precision context used to compare floating point numbers.
+ */
+ private final DoubleEquivalence precision;
+
+ /**
+ * Store the offset with the biggest distance to the plane.
+ */
+ private double maximumOffset;
+
+ /**
+ * Store the point with the biggest distance.
+ */
+ private Vector3D maximumPoint;
+
+ /**
+ * Constructs a new facet with the given polygon and an associated
empty
+ * outside set in such a way, that the reference point is in the
negative half-space of the associated oriented
+ * polygon.
+ *
+ * @param polygon the given polygon.
+ * @param referencePoint reference point for construction.
+ * @param precision context used to compare floating point
numbers.
+ */
+ Facet(ConvexPolygon3D polygon, Vector3D referencePoint,
DoubleEquivalence precision) {
+ this.polygon =
precision.lte(polygon.getPlane().offset(referencePoint), 0) ? polygon :
polygon.reverse();
+ outsideSet = EuclideanCollections.pointSet3D(precision);
+ this.precision = precision;
+ List<Edge> edgesCol = new ArrayList<>();
+ List<Vector3D> vertices = this.polygon.getVertices();
+ maximumOffset = 0.0;
+
+ for (int i = 0; i < vertices.size(); i++) {
+ Vector3D start = vertices.get(i);
+ Vector3D end = vertices.get(i + 1 == vertices.size() ? 0 : i +
1);
+ edgesCol.add(new Edge(start, end));
+ }
+ edges = Collections.unmodifiableList(edgesCol);
+ }
+
+ /**
+ * Return {@code true} if the facet is in conflict e.g. the outside
set is
+ * non-empty.
+ *
+ * @return {@code true} if the facet is in conflict e.g. the outside
set is
+ * non-empty.
+ */
+ boolean hasOutsidePoints() {
+ return !outsideSet.isEmpty();
+ }
+
+ /**
+ * Returns the associated polygon.
+ *
+ * @return the associated polygon.
+ */
+ public ConvexPolygon3D getPolygon() {
+ return polygon;
+ }
+
+ /**
+ * Returns an unmodifiable view of the associated outside set.
+ *
+ * @return an unmodifiable view of the associated outside set.
+ */
+ public Set<Vector3D> getOutsideSet() {
+ return outsideSet;
+ }
+
+ /**
+ * Returns a list of all edges.
+ *
+ * @return a list of all edges.
+ */
+ public List<Edge> getEdges() {
+ return edges;
+ }
+
+ /**
+ * Returns {@code true} if the point resides in the positive
half-space defined
+ * by the associated hyperplane of the polygon and {@code false}
otherwise. If
+ * {@code true} the point is added to the associated outside set.
+ *
+ * @param p the given point.
+ * @return {@code true} if the point is added to the outside set,
{@code false}
+ * otherwise.
+ */
+ public boolean addPoint(Vector3D p) {
+ double offset = offset(p);
+ if (precision.gt(offset, 0.0)) {
+ outsideSet.add(p);
+ if (precision.gt(offset, maximumOffset)) {
+ maximumOffset = offset;
+ maximumPoint = p;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Returns the offset of the given point to the plane defined by this
instance.
+ *
+ * @param point a reference point.
+ * @return the offset of the given point to the plane defined by this
instance.
+ */
+ public double offset(Vector3D point) {
+ return polygon.getPlane().offset(point);
+ }
+
+ /**
+ * Returns the outside point with the greatest offset distance to the
hyperplane
+ * defined by the associated polygon.
+ *
+ * @return the outside point with the greatest offset distance to the
hyperplane
+ * defined by the associated polygon.
+ */
+ public Vector3D getOutsidePoint() {
+ return maximumPoint;
+ }
+ }
+
+ /**
+ * This class represents an edge consisting of two vertices. The order of
the vertices is not relevant so two edges
+ * are equivalent if the edges are equivalent irrespectively of the order.
+ */
+ static class Edge {
+
+ /**
+ * The first vertex.
+ */
+ private final Vector3D start;
+
+ /**
+ * The second vertex.
+ */
+ private final Vector3D end;
+
+ /**
+ * Simple Constructor.
+ *
+ * @param start the start of the edge.
+ * @param end the end of the edge.
+ */
+ Edge(Vector3D start, Vector3D end) {
+ this.start = start;
+ this.end = end;
+ }
+
+ /**
+ * Getter for the start vertex.
+ *
+ * @return the start vertex.
+ */
+ public Vector3D getStart() {
+ return start;
+ }
+
+ /**
+ * Getter for the end vertex.
+ *
+ * @return the end vertex.
+ */
+ public Vector3D getEnd() {
+ return end;
+ }
+
+ /**
+ * Returns the inverse of this given edge.
+ *
+ * @return the inverse of this given edge.
+ */
+ public Edge getInverse() {
+ return new Edge(end, start);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Edge edge = (Edge) o;
+ return Objects.equals(start, edge.start) && Objects.equals(end,
edge.end);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int hashCode() {
+ return Objects.hash(start, end);
+ }
+ }
+
+ /**
+ * This class represents a simple simplex with four facets.
+ */
+ private static class Simplex {
+
+
+ /**
+ * The facets of the simplex.
+ */
+ private final Set<Facet> facets;
+
+ /**
+ * Constructs a new simplex with the given facets.
+ *
+ * @param facets the given facets.
+ */
+ Simplex(Collection<Facet> facets) {
+ this.facets = new HashSet<>(facets);
+ }
+
+ /**
+ * Returns {@code true} if the collection of facets is empty.
+ *
+ * @return {@code true} if the collection of facets is empty.
+ */
+ public boolean isDegenerate() {
+ return facets.isEmpty();
+ }
+
+ /**
+ * Returns the facets of the simplex as set.
+ *
+ * @return the facets of the simplex as set.
+ */
+ public Set<Facet> getFacets() {
+ return facets;
+ }
+ }
+}
diff --git
a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/hull/package-info.java
b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/hull/package-info.java
new file mode 100644
index 00000000..05c972b9
--- /dev/null
+++
b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/hull/package-info.java
@@ -0,0 +1,25 @@
+/*
+ * 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
+ *
+ * http://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.
+ */
+/**
+ *
+ * <p>
+ * This package provides algorithms to generate the convex hull
+ * for a set of points in an three-dimensional Euclidean space.
+ * </p>
+ *
+ */
+package org.apache.commons.geometry.euclidean.threed.hull;
diff --git
a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/hull/ConvexHull3DTest.java
b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/hull/ConvexHull3DTest.java
new file mode 100644
index 00000000..015669d2
--- /dev/null
+++
b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/hull/ConvexHull3DTest.java
@@ -0,0 +1,276 @@
+/*
+ * 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
+ *
+ * http://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.hull;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.commons.geometry.euclidean.EuclideanCollections;
+import org.apache.commons.geometry.euclidean.threed.ConvexPolygon3D;
+import org.apache.commons.geometry.euclidean.threed.ConvexVolume;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.numbers.core.Precision;
+import org.apache.commons.rng.UniformRandomProvider;
+import org.apache.commons.rng.simple.RandomSource;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class ConvexHull3DTest {
+
+ private static final double TEST_EPS = 1e-10;
+
+ private static final Precision.DoubleEquivalence TEST_PRECISION =
Precision.doubleEquivalenceOfEpsilon(TEST_EPS);
+
+ private ConvexHull3D.Builder builder;
+
+ private UniformRandomProvider random;
+
+ @BeforeEach
+ public void setUp() {
+ builder = new ConvexHull3D.Builder(TEST_PRECISION);
+ random = RandomSource.XO_SHI_RO_256_PP.create(10);
+ }
+
+ /**
+ * A hull with less than four points is degenerate.
+ */
+ @Test
+ void lessThanFourPoints() {
+ List<Vector3D> vertices = Arrays.asList(Vector3D.of(0, 0, 0),
Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0));
+ builder.append(vertices);
+ ConvexHull3D hull = builder.build();
+ checkDegenerateHull(hull, vertices);
+ }
+
+ /**
+ * A Hull with less than four points is degenerate.
+ */
+ @Test
+ void samePoints() {
+ List<Vector3D> vertices = Arrays.asList(Vector3D.ZERO, Vector3D.ZERO,
Vector3D.ZERO, Vector3D.ZERO);
+ builder.append(vertices);
+ ConvexHull3D hull = builder.build();
+ checkDegenerateHull(hull, vertices);
+ }
+
+ @Test
+ void collinearPoints() {
+ List<Vector3D> vertices = Arrays.asList(Vector3D.ZERO, Vector3D.of(1,
0, 0), Vector3D.of(2, 0, 0),
+ Vector3D.of(3, 0, 0));
+ builder.append(vertices);
+ ConvexHull3D hull = builder.build();
+ checkDegenerateHull(hull, vertices);
+ }
+
+ @Test
+ void coplanarPoints() {
+ List<Vector3D> vertices = Arrays.asList(Vector3D.ZERO, Vector3D.of(1,
0, 0), Vector3D.of(0, 1, 0),
+ Vector3D.of(3, 0, 0));
+ builder.append(vertices);
+ ConvexHull3D hull = builder.build();
+ checkDegenerateHull(hull, vertices);
+ }
+
+ @Test
+ void simplex() {
+ List<Vector3D> vertices = Arrays.asList(Vector3D.ZERO, Vector3D.of(1,
0, 0), Vector3D.of(0, 1, 0),
+ Vector3D.of(0, 0, 1));
+ builder.append(vertices);
+ ConvexHull3D hull = builder.build();
+ checkHull(hull, vertices);
+ assertTrue(TEST_PRECISION.eq(1.0 / 6.0, hull.getRegion().getSize()));
+ assertEquals(4, hull.getFacets().size());
+ }
+
+ @Test
+ void simplexPlusPoint() {
+ List<Vector3D> vertices = Arrays.asList(Vector3D.ZERO, Vector3D.of(1,
0, 0), Vector3D.of(0, 1, 0),
+ Vector3D.of(0, 0, 1), Vector3D.of(1, 1, 1));
+ builder.append(vertices);
+ ConvexHull3D hull = builder.build();
+ checkHull(hull, vertices);
+ assertTrue(TEST_PRECISION.eq(1.0 / 2.0, hull.getRegion().getSize()));
+ assertEquals(6, hull.getFacets().size());
+ }
+
+ @Test
+ void unitCube() {
+ List<Vector3D> vertices = Arrays.asList(Vector3D.ZERO, Vector3D.of(1,
0, 0), Vector3D.of(0, 1, 0),
+ Vector3D.of(0, 0, 1), Vector3D.of(1, 1, 0), Vector3D.of(1, 0,
1), Vector3D.of(0, 1, 1),
+ Vector3D.of(1, 1, 1));
+ builder.append(vertices);
+ ConvexHull3D hull = builder.build();
+ checkHull(hull, vertices);
+ assertTrue(TEST_PRECISION.eq(1.0, hull.getRegion().getSize()));
+ assertEquals(12, hull.getFacets().size());
+ }
+
+ @Test
+ void unitCubeSequentially() {
+ List<Vector3D> vertices = Arrays.asList(Vector3D.ZERO, Vector3D.of(1,
0, 0), Vector3D.of(0, 1, 0),
+ Vector3D.of(0, 0, 1), Vector3D.of(1, 1, 0), Vector3D.of(1, 0,
1), Vector3D.of(0, 1, 1),
+ Vector3D.of(1, 1, 1));
+ vertices.forEach(builder::append);
+ ConvexHull3D hull = builder.build();
+ checkHull(hull, vertices);
+ assertTrue(TEST_PRECISION.eq(1.0, hull.getRegion().getSize()));
+ assertEquals(12, hull.getFacets().size());
+ }
+
+ @Test
+ void multiplePoints() {
+ List<Vector3D> vertices = Arrays.asList(Vector3D.ZERO, Vector3D.of(1,
0, 0), Vector3D.of(0, 1, 0),
+ Vector3D.of(0, 0, 1), Vector3D.of(1, 1, 0), Vector3D.of(1, 0,
1), Vector3D.of(0, 1, 1),
+ Vector3D.of(1, 1, 1), Vector3D.of(10, 20, 30),
Vector3D.of(-0.5, 0, 5));
+ builder.append(vertices);
+ ConvexHull3D hull = builder.build();
+ checkHull(hull, vertices);
+ assertTrue(TEST_PRECISION.eq(42.58333333333329,
hull.getRegion().getSize()));
+ }
+
+ /**
+ * Create 1000 points on a unit sphere. Then every point of the set must
be a
+ * vertex of the hull.
+ */
+ @Test
+ void randomUnitPoints() {
+ // All points in the set must be on the hull. This is a worst case
scenario.
+ Set<Vector3D> set = createRandomPoints(1000, true);
+ builder.append(set);
+ ConvexHull3D hull = builder.build();
+ ConvexVolume region = hull.getRegion();
+ assertNotNull(region);
+ List<Vector3D> vertices = hull.getVertices();
+ for (Vector3D p : set) {
+ assertTrue(vertices.contains(p));
+ }
+ checkHull(hull, vertices);
+ assertEquals(1000, hull.getVertices().size());
+ }
+
+ @Test
+ void randomPoints() {
+ Set<Vector3D> set = createRandomPoints(100000, false);
+ builder.append(set);
+ ConvexHull3D hull = builder.build();
+ checkHull(hull, set);
+ }
+
+ @Test
+ void randomPointsInTwoSets() {
+ Set<Vector3D> set1 = createRandomPoints(50000, false);
+ Set<Vector3D> set2 = createRandomPoints(50000, false);
+ builder.append(set1);
+ builder.append(set2);
+ ConvexHull3D hull = builder.build();
+ checkHull(hull, set1);
+ checkHull(hull, set2);
+ }
+
+ @Test
+ void randomPointsSequentially() {
+ // Points are added sequentially
+ List<Vector3D> list = new ArrayList<>(createRandomPoints(100, false));
+ list.forEach(builder::append);
+ ConvexHull3D hull = builder.build();
+ checkHull(hull, list);
+ }
+
+ /**
+ * Create a specified number of random points on the unit sphere.
+ *
+ * @param number the given number.
+ * @param normalize normalize the output points.
+ * @return a specified number of random points on the unit sphere.
+ */
+ private Set<Vector3D> createRandomPoints(int number, boolean normalize) {
+ Set<Vector3D> set = EuclideanCollections.pointSet3D(TEST_PRECISION);
+ for (int i = 0; i < number; i++) {
+ if (normalize) {
+ set.add(Vector3D.Unit.from(random.nextDouble(),
random.nextDouble(), random.nextDouble()));
+ } else {
+ set.add(Vector3D.of(random.nextDouble(), random.nextDouble(),
random.nextDouble()));
+ }
+ }
+ return set;
+ }
+
+ /**
+ * Check if the hull contains all the points in the given collection and
checks if the volume is finite and
+ * non-zero.
+ */
+ private void checkHull(ConvexHull3D hull, Collection<Vector3D> points) {
+ ConvexVolume region = hull.getRegion();
+ assertNotNull(region);
+ assertTrue(region.isFinite());
+ assertFalse(region.isEmpty());
+ assertFalse(hull.isDegenerate());
+ for (Vector3D p : points) {
+ assertTrue(region.contains(p));
+ }
+ checkFacets(hull);
+ }
+
+ private void checkFacets(ConvexHull3D hull) {
+ List<ConvexPolygon3D> polygons = hull.getFacets();
+ assertFalse(polygons.isEmpty());
+
+ // Build an edge map to check if every facet has a neighbor and all
edges share two facets.
+ Vector3D centroid = hull.getRegion().getCentroid();
+ Set<ConvexHull3D.Facet> facets = polygons.stream().map(p -> new
ConvexHull3D.Facet(p, centroid, TEST_PRECISION))
+ .collect(Collectors.toSet());
+
+ //Populate edgeMap.
+ Map<ConvexHull3D.Edge, ConvexHull3D.Facet> edgeMap = new HashMap<>();
+ for (ConvexHull3D.Facet f : facets) {
+ for (ConvexHull3D.Edge e : f.getEdges()) {
+ edgeMap.put(e, f);
+ }
+ }
+
+ //Check if all edges are shared by two facets.
+ for (ConvexHull3D.Facet f : facets) {
+ for (ConvexHull3D.Edge e : f.getEdges()) {
+ assertTrue(edgeMap.containsKey(e.getInverse()));
+ }
+ }
+ }
+
+ private void checkDegenerateHull(ConvexHull3D hull, Collection<Vector3D>
points) {
+ assertTrue(hull.isDegenerate());
+ assertNull(hull.getRegion());
+ assertTrue(hull.getFacets().isEmpty());
+ List<Vector3D> vertices = hull.getVertices();
+ for (Vector3D p : points) {
+ assertTrue(vertices.contains(p));
+ }
+ }
+
+}
diff --git a/src/main/resources/spotbugs/spotbugs-exclude-filter.xml
b/src/main/resources/spotbugs/spotbugs-exclude-filter.xml
index 3c953c8e..a6415a84 100644
--- a/src/main/resources/spotbugs/spotbugs-exclude-filter.xml
+++ b/src/main/resources/spotbugs/spotbugs-exclude-filter.xml
@@ -95,5 +95,10 @@
<Field name="entrySetInstance" />
<BugPattern name="EI_EXPOSE_REP"/>
</Match>
+ <Match>
+ <Class
name="org.apache.commons.geometry.euclidean.threed.hull.ConvexHull3D" />
+ <Field name="facets" />
+ <BugPattern name="EI_EXPOSE_REP"/>
+ </Match>
</FindBugsFilter>