diff --git a/doc/changelog.d/1805.added.md b/doc/changelog.d/1805.added.md new file mode 100644 index 0000000000..bb98482b7a --- /dev/null +++ b/doc/changelog.d/1805.added.md @@ -0,0 +1 @@ +enhanced 3D bounding box implementation \ No newline at end of file diff --git a/src/ansys/geometry/core/designer/body.py b/src/ansys/geometry/core/designer/body.py index e6495fc285..9d421494ac 100644 --- a/src/ansys/geometry/core/designer/body.py +++ b/src/ansys/geometry/core/designer/body.py @@ -63,6 +63,7 @@ from ansys.geometry.core.connection.conversions import ( frame_to_grpc_frame, grpc_material_to_material, + grpc_point_to_point3d, plane_to_grpc_plane, point3d_to_grpc_point, sketch_shapes_to_grpc_geometries, @@ -74,6 +75,7 @@ from ansys.geometry.core.designer.face import Face, SurfaceType from ansys.geometry.core.errors import protect_grpc from ansys.geometry.core.materials.material import Material +from ansys.geometry.core.math.bbox import BoundingBox from ansys.geometry.core.math.constants import IDENTITY_MATRIX44 from ansys.geometry.core.math.frame import Frame from ansys.geometry.core.math.matrix import Matrix44 @@ -282,6 +284,17 @@ def material(self) -> Material: """ return + @abstractmethod + def bounding_box(self) -> BoundingBox: + """Get the bounding box of the body. + + Returns + ------- + BoundingBox + Bounding box of the body. + """ + return + @abstractmethod def assign_material(self, material: Material) -> None: """Assign a material against the active design. @@ -969,6 +982,18 @@ def material(self) -> Material: # noqa: D102 def material(self, value: Material): # noqa: D102 self.assign_material(value) + @property + @protect_grpc + @min_backend_version(25, 2, 0) + def bounding_box(self) -> BoundingBox: # noqa: D102 + self._grpc_client.log.debug(f"Retrieving bounding box for body {self.id} from server.") + result = self._bodies_stub.GetBoundingBox(self._grpc_id).box + + min_corner = grpc_point_to_point3d(result.min) + max_corner = grpc_point_to_point3d(result.max) + center = grpc_point_to_point3d(result.center) + return BoundingBox(min_corner, max_corner, center) + @protect_grpc @check_input_types def assign_material(self, material: Material) -> None: # noqa: D102 @@ -1555,6 +1580,10 @@ def material(self) -> Material: # noqa: D102 def material(self, value: Material): # noqa: D102 self._template.material = value + @property + def bounding_box(self) -> BoundingBox: # noqa: D102 + return self._template.bounding_box + @ensure_design_is_active def assign_material(self, material: Material) -> None: # noqa: D102 self._template.assign_material(material) diff --git a/src/ansys/geometry/core/designer/edge.py b/src/ansys/geometry/core/designer/edge.py index 3b67b9c06d..cea88bb063 100644 --- a/src/ansys/geometry/core/designer/edge.py +++ b/src/ansys/geometry/core/designer/edge.py @@ -29,8 +29,9 @@ from ansys.api.dbu.v0.dbumodels_pb2 import EntityIdentifier from ansys.api.geometry.v0.edges_pb2_grpc import EdgesStub from ansys.geometry.core.connection.client import GrpcClient -from ansys.geometry.core.connection.conversions import grpc_curve_to_curve +from ansys.geometry.core.connection.conversions import grpc_curve_to_curve, grpc_point_to_point3d from ansys.geometry.core.errors import GeometryRuntimeError, protect_grpc +from ansys.geometry.core.math.bbox import BoundingBox from ansys.geometry.core.math.point import Point3D from ansys.geometry.core.misc.checks import ensure_design_is_active, min_backend_version from ansys.geometry.core.misc.measurements import DEFAULT_UNITS @@ -206,3 +207,18 @@ def end(self) -> Point3D: self._grpc_client.log.debug("Requesting edge end point from server.") response = self._edges_stub.GetStartAndEndPoints(self._grpc_id) return Point3D([response.end.x, response.end.y, response.end.z]) + + @property + @protect_grpc + @ensure_design_is_active + @min_backend_version(25, 2, 0) + def bounding_box(self) -> BoundingBox: + """Bounding box of the edge.""" + self._grpc_client.log.debug("Requesting bounding box from server.") + + result = self._edges_stub.GetBoundingBox(self._grpc_id) + + min_corner = grpc_point_to_point3d(result.min) + max_corner = grpc_point_to_point3d(result.max) + center = grpc_point_to_point3d(result.center) + return BoundingBox(min_corner, max_corner, center) diff --git a/src/ansys/geometry/core/designer/face.py b/src/ansys/geometry/core/designer/face.py index b182f80c29..38a77f09b1 100644 --- a/src/ansys/geometry/core/designer/face.py +++ b/src/ansys/geometry/core/designer/face.py @@ -22,7 +22,6 @@ """Module for managing a face.""" from enum import Enum, unique -from functools import cached_property from typing import TYPE_CHECKING from beartype import beartype as check_input_types @@ -42,10 +41,14 @@ from ansys.api.geometry.v0.faces_pb2_grpc import FacesStub from ansys.api.geometry.v0.models_pb2 import Edge as GRPCEdge from ansys.geometry.core.connection.client import GrpcClient -from ansys.geometry.core.connection.conversions import grpc_curve_to_curve, grpc_surface_to_surface +from ansys.geometry.core.connection.conversions import ( + grpc_curve_to_curve, + grpc_point_to_point3d, + grpc_surface_to_surface, +) from ansys.geometry.core.designer.edge import Edge from ansys.geometry.core.errors import GeometryRuntimeError, protect_grpc -from ansys.geometry.core.math.bbox import BoundingBox2D +from ansys.geometry.core.math.bbox import BoundingBox from ansys.geometry.core.math.point import Point3D from ansys.geometry.core.math.vector import UnitVector3D from ansys.geometry.core.misc.auxiliary import ( @@ -335,16 +338,19 @@ def color(self, color: str | tuple[float, float, float]) -> None: def opacity(self, opacity: float) -> None: self.set_opacity(opacity) - @cached_property + @property @protect_grpc @min_backend_version(25, 2, 0) - def bounding_box(self) -> BoundingBox2D: + def bounding_box(self) -> BoundingBox: """Get the bounding box for the face.""" self._grpc_client.log.debug(f"Getting bounding box for {self.id}.") result = self._faces_stub.GetBoundingBox(request=self._grpc_id) + min_point = grpc_point_to_point3d(result.min) + max_point = grpc_point_to_point3d(result.max) + center = grpc_point_to_point3d(result.center) - return BoundingBox2D(result.min.x, result.max.x, result.min.y, result.max.y) + return BoundingBox(min_point, max_point, center) @protect_grpc @check_input_types diff --git a/src/ansys/geometry/core/math/__init__.py b/src/ansys/geometry/core/math/__init__.py index 4dde6e0ad7..13ef6c8b78 100644 --- a/src/ansys/geometry/core/math/__init__.py +++ b/src/ansys/geometry/core/math/__init__.py @@ -21,7 +21,7 @@ # SOFTWARE. """PyAnsys Geometry math subpackage.""" -from ansys.geometry.core.math.bbox import BoundingBox2D +from ansys.geometry.core.math.bbox import BoundingBox, BoundingBox2D from ansys.geometry.core.math.constants import ( DEFAULT_POINT2D, DEFAULT_POINT3D, diff --git a/src/ansys/geometry/core/math/bbox.py b/src/ansys/geometry/core/math/bbox.py index 422bc6ee34..2d304f735d 100644 --- a/src/ansys/geometry/core/math/bbox.py +++ b/src/ansys/geometry/core/math/bbox.py @@ -27,8 +27,9 @@ from beartype import beartype as check_input_types from ansys.geometry.core.math.misc import intersect_interval -from ansys.geometry.core.math.point import Point2D +from ansys.geometry.core.math.point import Point2D, Point3D from ansys.geometry.core.misc.accuracy import Accuracy +from ansys.geometry.core.misc.checks import deprecated_method from ansys.geometry.core.misc.measurements import DEFAULT_UNITS from ansys.geometry.core.typing import Real @@ -49,6 +50,7 @@ class BoundingBox2D: """ @check_input_types + @deprecated_method(alternative="BoundingBox", version="0.10.0", remove="0.11.0") def __init__( self, x_min: Real = sys.float_info.max, @@ -244,3 +246,157 @@ def intersect_bboxes( return None return BoundingBox2D(min_x, max_x, min_y, max_y) + + +class BoundingBox: + """Maintains the box structure for Bounding Boxes. + + Parameters + ---------- + min_corner : Point3D + Minimum corner for the box. + max_corner : Point3D + Maximum corner for the box. + center : Point3D + Center of the box. + """ + + @check_input_types + def __init__(self, min_corner: Point3D, max_corner: Point3D, center: Point3D = None): + """Initialize the ``BoundingBox`` class.""" + self._min_corner = min_corner + self._max_corner = max_corner + + if center is not None: + self._center = center + else: + mid_x = (max_corner.x.m - min_corner.x.m) / 2 + mid_y = (max_corner.y.m - min_corner.y.m) / 2 + mid_z = (max_corner.z.m - min_corner.z.m) / 2 + self._center = Point3D([mid_x, mid_y, mid_z]) + + @property + def min_corner(self) -> Point3D: + """Minimum corner of the bounding box.""" + return self._min_corner + + @property + def max_corner(self) -> Point3D: + """Maximum corner of the bounding box.""" + return self._max_corner + + @property + def center(self) -> Point3D: + """Center of the bounding box.""" + return self._center + + @check_input_types + def contains_point(self, point: Point3D) -> bool: + """Evaluate whether a point lies within the box. + + Parameters + ---------- + point : Point3D + Point to compare against the bounds. + + Returns + ------- + bool + ``True`` if the point is contained in the bounding box. Otherwise, ``False``. + """ + return self.contains_point_components( + point.x.m_as(DEFAULT_UNITS.LENGTH), + point.y.m_as(DEFAULT_UNITS.LENGTH), + point.z.m_as(DEFAULT_UNITS.LENGTH), + ) + + @check_input_types + def contains_point_components(self, x: Real, y: Real, z: Real) -> bool: + """Check if point components are within box. + + Parameters + ---------- + x : Real + Point X component to compare against the bounds. + y : Real + Point Y component to compare against the bounds. + z : Real + Point Z component to compare against the bounds. + + Returns + ------- + bool + ``True`` if the components are contained in the bounding box. Otherwise, ``False``. + """ + return ( + Accuracy.length_is_greater_than_or_equal(x, self._min_corner.x.m) + and Accuracy.length_is_greater_than_or_equal(y, self._min_corner.y.m) + and Accuracy.length_is_greater_than_or_equal(z, self._min_corner.z.m) + and Accuracy.length_is_less_than_or_equal(x, self._max_corner.x.m) + and Accuracy.length_is_less_than_or_equal(y, self._max_corner.y.m) + and Accuracy.length_is_less_than_or_equal(z, self._max_corner.z.m) + ) + + @check_input_types + def __eq__(self, other: "BoundingBox") -> bool: + """Equals operator for the ``BoundingBox`` class.""" + return ( + self._min_corner.x == other._min_corner.x + and self._max_corner.x == other._max_corner.x + and self._min_corner.y == other._min_corner.y + and self._max_corner.y == other._max_corner.y + and self._min_corner.z == other._min_corner.z + and self._max_corner.z == other._max_corner.z + ) + + @check_input_types + def __ne__(self, other: "BoundingBox") -> bool: + """Not equals operator for the ``BoundingBox`` class.""" + return not self == other + + @staticmethod + def intersect_bboxes(box_1: "BoundingBox", box_2: "BoundingBox") -> Union[None, "BoundingBox"]: + """Find the intersection of 2 BoundingBox objects. + + Parameters + ---------- + box_1: BoundingBox + The box to consider the intersection of with respect to box_2. + box_2: BoundingBox + The box to consider the intersection of with respect to box_1. + + Returns + ------- + BoundingBox: + The box representing the intersection of the two passed in boxes. + """ + intersect, min_x, max_x = intersect_interval( + box_1._min_corner.x.m, + box_2._min_corner.x.m, + box_1._max_corner.x.m, + box_2._max_corner.x.m, + ) + if not intersect: + return None + + intersect, min_y, max_y = intersect_interval( + box_1._min_corner.y.m, + box_2._min_corner.y.m, + box_1._max_corner.y.m, + box_2._max_corner.y.m, + ) + if not intersect: + return None + + intersect, min_z, max_z = intersect_interval( + box_1._min_corner.z.m, + box_2._min_corner.z.m, + box_1._max_corner.z.m, + box_2._max_corner.z.m, + ) + if not intersect: + return None + + min_point = Point3D([min_x, min_y, min_z]) + max_point = Point3D([max_x, max_y, max_z]) + return BoundingBox(min_point, max_point) diff --git a/tests/integration/test_design.py b/tests/integration/test_design.py index 3b7f830603..849ec7cbd1 100644 --- a/tests/integration/test_design.py +++ b/tests/integration/test_design.py @@ -3190,3 +3190,55 @@ def test_set_face_color(modeler: Modeler): ValueError, match="Invalid color value: Opacity value must be between 0 and 1." ): faces[3].opacity = 255 + + +def test_get_face_bounding_box(modeler: Modeler): + """Test getting the bounding box of a face.""" + design = modeler.create_design("face_bounding_box") + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + + bounding_box = body.faces[0].bounding_box + assert bounding_box.min_corner.x.m == bounding_box.min_corner.y.m == -0.5 + assert bounding_box.max_corner.x.m == bounding_box.max_corner.y.m == 0.5 + + bounding_box = body.faces[1].bounding_box + assert bounding_box.min_corner.x.m == bounding_box.min_corner.y.m == -0.5 + assert bounding_box.max_corner.x.m == bounding_box.max_corner.y.m == 0.5 + + +def test_get_edge_bounding_box(modeler: Modeler): + """Test getting the bounding box of an edge.""" + design = modeler.create_design("edge_bounding_box") + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + + # Edge 0 goes from (-0.5, -0.5, 1) to (0.5, -0.5, 1) + bounding_box = body.edges[0].bounding_box + assert bounding_box.min_corner.x.m == bounding_box.min_corner.y.m == -0.5 + assert bounding_box.min_corner.z.m == 1 + assert bounding_box.max_corner.x.m == 0.5 + assert bounding_box.max_corner.y.m == -0.5 + assert bounding_box.max_corner.z.m == 1 + + # Test center + center = bounding_box.center + assert center.x.m == 0 + assert center.y.m == -0.5 + assert center.z.m == 1 + + +def test_get_body_bounding_box(modeler: Modeler): + """Test getting the bounding box of a body.""" + design = modeler.create_design("body_bounding_box") + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + + bounding_box = body.bounding_box + assert bounding_box.min_corner.x.m == bounding_box.min_corner.y.m == -0.5 + assert bounding_box.min_corner.z.m == 0 + assert bounding_box.max_corner.x.m == bounding_box.max_corner.y.m == 0.5 + assert bounding_box.max_corner.z.m == 1 + + # Test center + center = bounding_box.center + assert center.x.m == 0 + assert center.y.m == 0 + assert center.z.m == 0.5 diff --git a/tests/integration/test_geometry_commands.py b/tests/integration/test_geometry_commands.py index a1ce658dc5..8856860bdd 100644 --- a/tests/integration/test_geometry_commands.py +++ b/tests/integration/test_geometry_commands.py @@ -788,20 +788,6 @@ def test_get_empty_round_info(modeler: Modeler): assert radius == 0.0 -def test_get_face_bounding_box(modeler: Modeler): - """Test getting the bounding box of a face.""" - design = modeler.create_design("face_bounding_box") - body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) - - bounding_box = body.faces[0].bounding_box - assert bounding_box.x_min == bounding_box.y_min == -0.5 - assert bounding_box.x_max == bounding_box.y_max == 0.5 - - bounding_box = body.faces[1].bounding_box - assert bounding_box.x_min == bounding_box.y_min == -0.5 - assert bounding_box.x_max == bounding_box.y_max == 0.5 - - def test_linear_pattern_on_imported_geometry_faces(modeler: Modeler): """Test create a linear pattern on imported geometry""" design = modeler.open_file(FILES_DIR / "LinearPatterns.scdocx") diff --git a/tests/test_math.py b/tests/test_math.py index 779845e003..c00df8c91d 100644 --- a/tests/test_math.py +++ b/tests/test_math.py @@ -34,6 +34,7 @@ UNITVECTOR3D_Z, ZERO_VECTOR2D, ZERO_VECTOR3D, + BoundingBox, BoundingBox2D, Frame, Matrix, @@ -1221,7 +1222,7 @@ def test_circle_intersections_coincident(): assert intersections is None -def test_bounding_box_intersection(): +def test_bounding_box2d_intersection(): """Test the intersection of two bounding boxes""" # Create the two boxes box1 = BoundingBox2D(0, 1, 0, 1) @@ -1233,7 +1234,7 @@ def test_bounding_box_intersection(): assert intersection == BoundingBox2D(0.5, 1, 0, 1) -def test_bounding_box_no_intersection(): +def test_bounding_box2d_no_intersection(): """Test that the bounding box intersection returns None in the case of no overlap""" # Create the two boxes box1 = BoundingBox2D(0, 1, 0, 1) @@ -1242,3 +1243,41 @@ def test_bounding_box_no_intersection(): # Get intersection and check intersection = BoundingBox2D.intersect_bboxes(box1, box2) assert intersection is None + + +def test_bounding_box_evaluates_bounds_comparisons(): + min_point = Point3D([0, 0, 0]) + max_point = Point3D([10, 10, 0]) + bounding_box = BoundingBox(min_point, max_point) + assert bounding_box.contains_point_components(5, 5, 0) + assert not bounding_box.contains_point_components(100, 100, 0) + assert bounding_box.contains_point(Point3D([3, 4, 0])) + assert not bounding_box.contains_point(Point3D([3, 14, 0])) + + copy_bbox_1 = BoundingBox(min_point, max_point) + copy_bbox_2 = BoundingBox(Point3D([5, 5, 5]), Point3D([10, 10, 10])) + assert copy_bbox_1 == bounding_box + assert copy_bbox_2 != bounding_box + + +def test_bounding_box_intersection(): + """Test the intersection of two bounding boxes""" + # Create the two boxes + box1 = BoundingBox(Point3D([0, 0, 0]), Point3D([1, 1, 0])) + box2 = BoundingBox(Point3D([0.5, 0, 0]), Point3D([1.5, 1, 0])) + + # Get intersection and check + intersection = BoundingBox.intersect_bboxes(box1, box2) + assert intersection is not None + assert intersection == BoundingBox(Point3D([0.5, 0, 0]), Point3D([1, 1, 0])) + + +def test_bounding_box_no_intersection(): + """Test that the bounding box intersection returns None in the case of no overlap""" + # Create the two boxes + box1 = BoundingBox(Point3D([0, 0, 0]), Point3D([1, 1, 0])) + box2 = BoundingBox(Point3D([2, 0, 0]), Point3D([3, 1, 0])) + + # Get intersection and check + intersection = BoundingBox.intersect_bboxes(box1, box2) + assert intersection is None