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.*",