From e977ae202fc4aca2b18b97f618a86ed5da65d8b9 Mon Sep 17 00:00:00 2001
From: Pablo Vasconez
Date: Tue, 14 Nov 2023 11:21:36 +0100
Subject: [PATCH] feat(#27): add class ConvexHull3D.
---
pyproject.toml | 1 +
.../geometry_3d/convex_hull_3d.py | 110 ++++++++++++++++++
.../geometry_3d/operations_3d.py | 16 +++
tests/test_geometry_3d/test_convex_hull_3d.py | 57 +++++++++
4 files changed, 184 insertions(+)
create mode 100644 src/plxcontroller/geometry_3d/convex_hull_3d.py
create mode 100644 tests/test_geometry_3d/test_convex_hull_3d.py
diff --git a/pyproject.toml b/pyproject.toml
index b14023c..23b4616 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -78,6 +78,7 @@ module = [
"plxscripting.*",
"skspatial.*",
"numpy.*",
+ "scipy.*",
"matplotlib.*",
"requests.*",
"nuclei.*",
diff --git a/src/plxcontroller/geometry_3d/convex_hull_3d.py b/src/plxcontroller/geometry_3d/convex_hull_3d.py
new file mode 100644
index 0000000..5ef891c
--- /dev/null
+++ b/src/plxcontroller/geometry_3d/convex_hull_3d.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+from typing import List, Sequence, Tuple
+
+import numpy as np
+from scipy import spatial
+
+from plxcontroller.geometry_3d.bounding_box_3d import BoundingBox3D
+from plxcontroller.geometry_3d.point_3d import Point3D
+
+
+class ConvexHull3D:
+ """
+ A class with information about a convex hull in the 3D space.
+ """
+
+ def __init__(self, points: Sequence[Point3D | Tuple[float, float, float]]):
+ """Initializes a ConvexHull3D instance.
+
+ Parameters
+ ----------
+ points : List[Point3D | Tuple[float, float, float]]
+ a list of the points from which the convex hull will be created.
+
+ Raises
+ ------
+ TypeError
+ if points of its (sub)items are not of the expected type.
+ ValueError
+ - if length of points is not >= 4.
+ - if item of points is tuple but its length of any tuple is not exactly 3.
+ - if a convex hull cannot be computed from the given points.
+ """
+ # Validate input
+ # points
+ if not isinstance(points, list):
+ raise TypeError(
+ f"Unexpected type for points. Expected list, but got: {type(points)}"
+ )
+ # point items and subtimes
+ for i, point in enumerate(points):
+ if not isinstance(point, (Point3D, tuple)):
+ raise TypeError(
+ f"Unexpected type for item of points at index {i}. Expected Point3D or tuple, but got: {type(point)}"
+ )
+ if isinstance(point, tuple):
+ if not len(point) == 3:
+ raise ValueError(
+ f"Unexpected tuple length for item of points at index {i}. Expected tuple length = 3, but got: {len(point)}"
+ )
+ if not all(isinstance(a, (float, int)) for a in point):
+ raise TypeError(
+ f"Unexpected type for tuple items of point at index {i}. Expected float, but got: {tuple(isinstance(a, float) for a in point)}"
+ )
+
+ # Validate length of list
+ if not len(points) >= 4:
+ raise ValueError(
+ f"Unexpected length of points. Expected length >= 4, but got: {len(points)}"
+ )
+
+ # Cast tuples to points
+ casted_points: List[Point3D] = []
+ for point in points:
+ if isinstance(point, Point3D):
+ casted_points.append(point)
+ else:
+ casted_points.append(Point3D(x=point[0], y=point[1], z=point[2]))
+
+ # Compute the convex hull of all the points
+ try:
+ convex_hull = spatial.ConvexHull(
+ points=np.vstack([point.coordinates for point in casted_points])
+ )
+ except spatial.QhullError as e:
+ raise ValueError(
+ "Cannot create ConvexHull3D from given points, the followin error "
+ + f"was raised when computing the convex hull: {e}"
+ )
+
+ # Store the points and the indices of a
+ self._points = casted_points
+ self._vertex_indices = list(convex_hull.vertices)
+
+ @property
+ def points(self) -> List[Point3D]:
+ """Returns all the points of the convex hull."""
+ return self._points
+
+ @property
+ def vertex_indices(self) -> List[int]:
+ """Returns the indices of the vertices of the convex hull."""
+ return self._vertex_indices
+
+ @property
+ def vertices(self) -> List[Point3D]:
+ """Returns the vertices of the convex hull as points."""
+ return [self._points[i] for i in self._vertex_indices]
+
+ @property
+ def bounding_box(self) -> BoundingBox3D:
+ """Returns the bounding box of the convex hull."""
+ return BoundingBox3D(
+ x_min=min([vertex.x for vertex in self.vertices]),
+ y_min=min([vertex.y for vertex in self.vertices]),
+ z_min=min([vertex.z for vertex in self.vertices]),
+ x_max=max([vertex.x for vertex in self.vertices]),
+ y_max=max([vertex.y for vertex in self.vertices]),
+ z_max=max([vertex.z for vertex in self.vertices]),
+ )
diff --git a/src/plxcontroller/geometry_3d/operations_3d.py b/src/plxcontroller/geometry_3d/operations_3d.py
index 3e6a03f..35f9535 100644
--- a/src/plxcontroller/geometry_3d/operations_3d.py
+++ b/src/plxcontroller/geometry_3d/operations_3d.py
@@ -1,6 +1,9 @@
from __future__ import annotations
+from typing import List
+
import numpy as np
+from scipy import spatial
from skspatial.objects import Plane as ScikitSpatialPlane
from plxcontroller.geometry_3d.point_3d import Point3D
@@ -65,3 +68,16 @@ def project_vertically_point_onto_polygon_3d(
z = point.z
return Point3D(x=point.x, y=point.y, z=z)
+
+
+def get_convex_hull_vertices_3d(points: List[Point3D]) -> List[Point3D]:
+ convex_hull = spatial.ConvexHull(
+ points=np.vstack([point.coordinates for point in points])
+ )
+
+ vertices = []
+ for vertix_index in convex_hull.vertices:
+ vertix = convex_hull.points[vertix_index]
+ vertices.append(Point3D(x=vertix[0], y=vertix[1], z=vertix[2]))
+
+ return vertices
diff --git a/tests/test_geometry_3d/test_convex_hull_3d.py b/tests/test_geometry_3d/test_convex_hull_3d.py
new file mode 100644
index 0000000..cc9363b
--- /dev/null
+++ b/tests/test_geometry_3d/test_convex_hull_3d.py
@@ -0,0 +1,57 @@
+import pytest
+
+from plxcontroller.geometry_3d.convex_hull_3d import ConvexHull3D
+from plxcontroller.geometry_3d.point_3d import Point3D
+
+
+def test_convex_hull_3d() -> None:
+ """
+ Tests the methods of the class ConvexHull3D.
+ """
+
+ # Assert invalid input
+ with pytest.raises(TypeError, match="Expected list"):
+ ConvexHull3D(points="invalid input")
+
+ with pytest.raises(TypeError, match="Expected Point3D or tuple"):
+ ConvexHull3D(points=["invalid input"])
+
+ with pytest.raises(ValueError, match="Expected tuple length = 3"):
+ ConvexHull3D(points=[(1.0, 2.0)])
+
+ with pytest.raises(TypeError, match="Expected float"):
+ ConvexHull3D(points=[(1.0, 2.0, "invalid input")])
+
+ with pytest.raises(ValueError, match="Expected length >= 4"):
+ ConvexHull3D(points=[])
+
+ with pytest.raises(
+ ValueError, match="Cannot create ConvexHull3D from given points"
+ ):
+ ConvexHull3D(
+ points=[
+ Point3D(-5.0, -5.0, -5.0),
+ Point3D(5.0, -5.0, -5.0),
+ Point3D(5.0, 5.0, -5.0),
+ Point3D(-5.0, 5.0, -5.0),
+ ]
+ )
+
+ # Assert instance is correctly created with valid input
+ points = [
+ Point3D(-5.0, -5.0, -5.0),
+ Point3D(5.0, -5.0, -5.0),
+ Point3D(5.0, 5.0, -5.0),
+ Point3D(-5.0, 5.0, -5.0),
+ Point3D(-5.0, -5.0, 5.0),
+ Point3D(5.0, -5.0, 5.0),
+ Point3D(5.0, 5.0, 5.0),
+ Point3D(-5.0, 5.0, 5.0),
+ Point3D(0.0, 0.0, 0.0),
+ ]
+
+ convex_hull = ConvexHull3D(points=points)
+
+ assert convex_hull.points == points
+ assert convex_hull.vertex_indices == list(range(0, 8))
+ assert convex_hull.vertices == points[:-1]