From f61d460994d3de386a23333b0ccfb93036a1b9b4 Mon Sep 17 00:00:00 2001
From: Pablo Vasconez
Date: Fri, 10 Nov 2023 10:59:52 +0100
Subject: [PATCH 1/2] feat(#15): add method to project point3d onto polygon3d.
---
.../geometry_3d/bounding_box_3d.py | 18 ++---
.../geometry_3d/operations_3d.py | 67 +++++++++++++++++++
src/plxcontroller/geometry_3d/point_3d.py | 7 ++
src/plxcontroller/geometry_3d/polygon_3d.py | 25 ++++++-
.../test_geometry_3d/test_bounding_box_3d.py | 7 +-
tests/test_geometry_3d/test_operations_3d.py | 47 +++++++++++++
6 files changed, 155 insertions(+), 16 deletions(-)
create mode 100644 src/plxcontroller/geometry_3d/operations_3d.py
create mode 100644 tests/test_geometry_3d/test_operations_3d.py
diff --git a/src/plxcontroller/geometry_3d/bounding_box_3d.py b/src/plxcontroller/geometry_3d/bounding_box_3d.py
index 9cc6e7b..8a015d7 100644
--- a/src/plxcontroller/geometry_3d/bounding_box_3d.py
+++ b/src/plxcontroller/geometry_3d/bounding_box_3d.py
@@ -39,9 +39,9 @@ def __init__(
TypeError
if any parameter is not of the expected type.
ValueError
- if x_max is not > x_min
- if y_max is not > y_max
- if z_max is not > z_min
+ if x_max is not >= x_min
+ if y_max is not >= y_max
+ if z_max is not >= z_min
"""
# Validate types
if not isinstance(x_min, (float, int)):
@@ -71,14 +71,14 @@ def __init__(
)
# Validate values
- if not x_max > x_min:
- raise ValueError("x_max must be > x_min")
+ if not x_max >= x_min:
+ raise ValueError("x_max must be >= x_min")
- if not y_max > y_min:
- raise ValueError("y_max must be > y_min")
+ if not y_max >= y_min:
+ raise ValueError("y_max must be >= y_min")
- if not z_max > z_min:
- raise ValueError("z_max must be > z_min")
+ if not z_max >= z_min:
+ raise ValueError("z_max must be >= z_min")
self._x_min = x_min
self._y_min = y_min
diff --git a/src/plxcontroller/geometry_3d/operations_3d.py b/src/plxcontroller/geometry_3d/operations_3d.py
new file mode 100644
index 0000000..4cc7f36
--- /dev/null
+++ b/src/plxcontroller/geometry_3d/operations_3d.py
@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+import numpy as np
+from skspatial.objects import Plane as ScikitSpatialPlane
+
+from plxcontroller.geometry_3d.point_3d import Point3D
+from plxcontroller.geometry_3d.polygon_3d import Polygon3D
+
+
+def project_vertically_point_onto_polygon_3d(
+ point: Point3D, polygon: Polygon3D
+) -> Point3D | None:
+ """Returns the vertical projection of the Point3D onto Polygon3D.
+ If the point is not within the boundaries of the projection of the polygon
+ in the xy plane, then None is returned.
+
+ Parameters
+ ----------
+ point : Point3D
+ the point to project.
+ polygon : Polygon3D
+ the polygon to project onto.
+
+ Raises
+ ------
+ TypeError
+ if any parameter is not of the expected type.
+
+ Returns
+ -------
+ Point3D | None
+ the projected point onto the polygon.
+ """
+ # Validate input
+ if not isinstance(point, Point3D):
+ raise TypeError(
+ f"Unexpected type for point. Expected Point3D, but got {type(point)}."
+ )
+
+ if not isinstance(polygon, Polygon3D):
+ raise TypeError(
+ f"Unexpected type for polygon. Expected Polygon3D, but got {type(point)}."
+ )
+
+ # Check whether the point is inside the projection of the polygon in the xy plane
+ if not polygon.shapely_polygon_xy_plane.contains(point.shapely_point_xy_plane):
+ return None
+
+ # Make plane using first three points of the polygon.
+ # Note that all points in Polygon3D are coplanar, so it does not matter which
+ # poitns are taken.
+ plane = ScikitSpatialPlane.from_points(
+ *[coord for coord in polygon.coordinates[:3]]
+ )
+
+ # Calculate the z-coordinate using the equation of the plane with the
+ # point x and y coordinates input
+ a, b, c, d = plane.cartesian()
+ if not np.isclose(c, 0.0):
+ z = (d - a * point.x - b * point.y) / c
+ else:
+ # if the coefficient c is close to zero, it means that
+ # the plane is parallel to the z-axis, so the z-coordinate
+ # is the same as the point itself.
+ z = point.z
+
+ return Point3D(x=point.x, y=point.y, z=z)
diff --git a/src/plxcontroller/geometry_3d/point_3d.py b/src/plxcontroller/geometry_3d/point_3d.py
index c215d39..19defaf 100644
--- a/src/plxcontroller/geometry_3d/point_3d.py
+++ b/src/plxcontroller/geometry_3d/point_3d.py
@@ -2,6 +2,8 @@
from typing import Tuple
+import shapely
+
class Point3D:
"""
@@ -62,3 +64,8 @@ def z(self) -> float:
def coordinates(self) -> Tuple[float, float, float]:
"""Returns a tuple with the (x,y,z) coordinates of the point."""
return (self.x, self.y, self.z)
+
+ @property
+ def shapely_point_xy_plane(self) -> shapely.Point:
+ """Returns the projection of the point in the xy plane as a shapely.Point object."""
+ return shapely.Point((self.x, self.y))
diff --git a/src/plxcontroller/geometry_3d/polygon_3d.py b/src/plxcontroller/geometry_3d/polygon_3d.py
index 3cd55db..b95cda5 100644
--- a/src/plxcontroller/geometry_3d/polygon_3d.py
+++ b/src/plxcontroller/geometry_3d/polygon_3d.py
@@ -3,8 +3,10 @@
from copy import copy
from typing import List, Tuple
+import shapely
from skspatial.objects import Points as ScikitSpatialPoints
+from plxcontroller.geometry_3d.bounding_box_3d import BoundingBox3D
from plxcontroller.geometry_3d.point_3d import Point3D
@@ -69,15 +71,32 @@ def __init__(self, coordinates: List[Tuple[float, float, float]]):
@property
def coordinates(self) -> List[Tuple[float, float, float]]:
- """Return the coordinates of the polygon a list of (x,y,z) tuples."""
+ """Returns the coordinates of the polygon a list of (x,y,z) tuples."""
return copy(self._coordinates)
@property
def points(self) -> List[Point3D]:
- """Return the coordinates of the polygon a list of Point3D."""
+ """Returns the coordinates of the polygon a list of Point3D."""
return [Point3D(*a) for a in self._coordinates]
@property
def scikit_spatial_points(self) -> ScikitSpatialPoints:
- """Return the coordinates of the polygon a skspatial.objects.Points object."""
+ """Returns the coordinates of the polygon a skspatial.objects.Points object."""
return ScikitSpatialPoints(self._coordinates)
+
+ @property
+ def shapely_polygon_xy_plane(self) -> shapely.Polygon:
+ """Returns the projection in the xy plane as a shapely polygon"""
+ return shapely.Polygon([(point.x, point.y) for point in self.points])
+
+ @property
+ def bounding_box(self) -> BoundingBox3D:
+ """Returns the bounding box of the polygon."""
+ return BoundingBox3D(
+ x_min=min([point.x for point in self.points]),
+ y_min=min([point.y for point in self.points]),
+ z_min=min([point.z for point in self.points]),
+ x_max=max([point.x for point in self.points]),
+ y_max=max([point.y for point in self.points]),
+ z_max=max([point.z for point in self.points]),
+ )
diff --git a/tests/test_geometry_3d/test_bounding_box_3d.py b/tests/test_geometry_3d/test_bounding_box_3d.py
index 695de5f..15d23d1 100644
--- a/tests/test_geometry_3d/test_bounding_box_3d.py
+++ b/tests/test_geometry_3d/test_bounding_box_3d.py
@@ -1,7 +1,6 @@
import pytest
from plxcontroller.geometry_3d.bounding_box_3d import BoundingBox3D
-from plxcontroller.geometry_3d.point_3d import Point3D
def test_bounding_box_3d() -> None:
@@ -41,13 +40,13 @@ def test_bounding_box_3d() -> None:
)
# Assert invalid input (values)
- with pytest.raises(ValueError, match="x_max must be > x_min"):
+ with pytest.raises(ValueError, match="x_max must be >= x_min"):
BoundingBox3D(x_min=3.0, y_min=2.0, z_min=3.0, x_max=2.0, y_max=3.0, z_max=4.0)
- with pytest.raises(ValueError, match="y_max must be > y_min"):
+ with pytest.raises(ValueError, match="y_max must be >= y_min"):
BoundingBox3D(x_min=1.0, y_min=4.0, z_min=3.0, x_max=2.0, y_max=3.0, z_max=4.0)
- with pytest.raises(ValueError, match="z_max must be > z_min"):
+ with pytest.raises(ValueError, match="z_max must be >= z_min"):
BoundingBox3D(x_min=1.0, y_min=2.0, z_min=5.0, x_max=2.0, y_max=3.0, z_max=4.0)
# Assert correct instance with valid input
diff --git a/tests/test_geometry_3d/test_operations_3d.py b/tests/test_geometry_3d/test_operations_3d.py
new file mode 100644
index 0000000..fee8ed4
--- /dev/null
+++ b/tests/test_geometry_3d/test_operations_3d.py
@@ -0,0 +1,47 @@
+import pytest
+
+from plxcontroller.geometry_3d.operations_3d import (
+ project_vertically_point_onto_polygon_3d,
+)
+from plxcontroller.geometry_3d.point_3d import Point3D
+from plxcontroller.geometry_3d.polygon_3d import Polygon3D
+
+
+def test_project_vertically_point_onto_polygon_3d() -> None:
+ """
+ Tests the method project_vertically_point_onto_polygon_3d.
+ """
+
+ # Assert invalid input (types)
+ with pytest.raises(TypeError, match="Expected Point3D"):
+ project_vertically_point_onto_polygon_3d(
+ point="invalid input",
+ polygon=Polygon3D(coordinates=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)]),
+ )
+
+ with pytest.raises(TypeError, match="Expected Polygon3D"):
+ project_vertically_point_onto_polygon_3d(
+ point=Point3D(x=0.5, y=0.5, z=5.0),
+ polygon="invalid input",
+ )
+
+ # Assert it returns None if projection falls out the boundaries of the polygon
+ assert (
+ project_vertically_point_onto_polygon_3d(
+ point=Point3D(x=-0.5, y=-0.5, z=5.0),
+ polygon=Polygon3D(coordinates=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)]),
+ )
+ is None
+ )
+
+ # Assert it returns the expected point (polygon3d parallel to xy plane)
+ assert project_vertically_point_onto_polygon_3d(
+ point=Point3D(x=0.5, y=0.5, z=5.0),
+ polygon=Polygon3D(coordinates=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)]),
+ ).coordinates == (0.5, 0.5, 0)
+
+ # Assert it returns the expected point (inclined polygon3d)
+ assert project_vertically_point_onto_polygon_3d(
+ point=Point3D(x=0.5, y=0.5, z=5.0),
+ polygon=Polygon3D(coordinates=[(0, 0, 0), (1, 0, 1), (1, 1, 1), (0, 1, 0)]),
+ ).coordinates == (0.5, 0.5, 0.5)
From 58bb8402eac1a80e4ec8a30410bb69562af62677 Mon Sep 17 00:00:00 2001
From: Pablo Vasconez
Date: Fri, 10 Nov 2023 11:07:57 +0100
Subject: [PATCH 2/2] lint(#15): exclude numpy missing imports from mypy.
---
pyproject.toml | 1 +
1 file changed, 1 insertion(+)
diff --git a/pyproject.toml b/pyproject.toml
index 2b5db19..05415b7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -77,6 +77,7 @@ install_types = true
module = [
"plxscripting.*",
"skspatial.*",
+ "numpy.*",
"matplotlib.*",
"requests.*",
"nuclei.*",