diff --git a/doc/changelog.d/2383.added.md b/doc/changelog.d/2383.added.md new file mode 100644 index 0000000000..c13400b524 --- /dev/null +++ b/doc/changelog.d/2383.added.md @@ -0,0 +1 @@ +Expose enclosure methods diff --git a/src/ansys/geometry/core/_grpc/_services/base/prepare_tools.py b/src/ansys/geometry/core/_grpc/_services/base/prepare_tools.py index 04d31db962..da1b5fdfb7 100644 --- a/src/ansys/geometry/core/_grpc/_services/base/prepare_tools.py +++ b/src/ansys/geometry/core/_grpc/_services/base/prepare_tools.py @@ -83,3 +83,18 @@ def remove_logo(self, **kwargs) -> dict: def detect_helixes(self, **kwargs) -> dict: """Detect helixes in geometry.""" pass + + @abstractmethod + def create_box_enclosure(self, **kwargs) -> dict: + """Create a box enclosure around bodies.""" + pass + + @abstractmethod + def create_cylinder_enclosure(self, **kwargs) -> dict: + """Create a cylinder enclosure around bodies.""" + pass + + @abstractmethod + def create_sphere_enclosure(self, **kwargs) -> dict: + """Create a sphere enclosure around bodies.""" + pass diff --git a/src/ansys/geometry/core/_grpc/_services/v0/conversions.py b/src/ansys/geometry/core/_grpc/_services/v0/conversions.py index 2d7f8fd82c..601f564696 100644 --- a/src/ansys/geometry/core/_grpc/_services/v0/conversions.py +++ b/src/ansys/geometry/core/_grpc/_services/v0/conversions.py @@ -56,6 +56,7 @@ TrimmedCurve as GRPCTrimmedCurve, TrimmedSurface as GRPCTrimmedSurface, ) +from ansys.api.geometry.v0.preparetools_pb2 import EnclosureOptions as GRPCEnclosureOptions import pint from ansys.geometry.core.errors import GeometryRuntimeError @@ -95,6 +96,7 @@ from ansys.geometry.core.sketch.nurbs import SketchNurbs from ansys.geometry.core.sketch.polygon import Polygon from ansys.geometry.core.sketch.segment import SketchSegment + from ansys.geometry.core.tools.prepare_tools import EnclosureOptions def from_point3d_to_grpc_point(point: "Point3D") -> GRPCPoint: @@ -1410,3 +1412,27 @@ def serialize_entity_identifier(entity): for entity in getattr(response, "deleted_bodies", []) ], } + + +def from_enclosure_options_to_grpc_enclosure_options( + enclosure_options: "EnclosureOptions", +) -> GRPCEnclosureOptions: + """Convert enclosure_options to grpc definition. + + Parameters + ---------- + enclosure_options : EnclosureOptions + Definition of the enclosure options. + + Returns + ------- + GRPCEnclosureOptions + Grpc converted definition. + """ + frame = enclosure_options.frame + return GRPCEnclosureOptions( + create_shared_topology=enclosure_options.create_shared_topology, + subtract_bodies=enclosure_options.subtract_bodies, + frame=from_frame_to_grpc_frame(frame) if frame is not None else None, + cushion_proportion=enclosure_options.cushion_proportion, + ) diff --git a/src/ansys/geometry/core/_grpc/_services/v0/prepare_tools.py b/src/ansys/geometry/core/_grpc/_services/v0/prepare_tools.py index b783b952c4..38435f85b5 100644 --- a/src/ansys/geometry/core/_grpc/_services/v0/prepare_tools.py +++ b/src/ansys/geometry/core/_grpc/_services/v0/prepare_tools.py @@ -27,7 +27,11 @@ from ..base.conversions import from_measurement_to_server_length from ..base.prepare_tools import GRPCPrepareToolsService -from .conversions import build_grpc_id +from .conversions import ( + build_grpc_id, + from_enclosure_options_to_grpc_enclosure_options, + serialize_tracker_command_response, +) class GRPCPrepareToolsServiceV0(GRPCPrepareToolsService): @@ -295,3 +299,97 @@ def detect_helixes(self, **kwargs) -> dict: # noqa: D102 for helix in response.helixes ] } + + @protect_grpc + def create_box_enclosure(self, **kwargs) -> dict: # noqa: D102 + from ansys.api.geometry.v0.models_pb2 import Body as GRPCBody + from ansys.api.geometry.v0.preparetools_pb2 import CreateEnclosureBoxRequest + + grpc_enclosure_options = from_enclosure_options_to_grpc_enclosure_options( + kwargs["enclosure_options"] + ) + + # Create the request - assumes all inputs are valid and of the proper type + request = CreateEnclosureBoxRequest( + bodies=[GRPCBody(id=body.id) for body in kwargs["bodies"]], + x_low=from_measurement_to_server_length(kwargs["x_low"]), + x_high=from_measurement_to_server_length(kwargs["x_high"]), + y_low=from_measurement_to_server_length(kwargs["y_low"]), + y_high=from_measurement_to_server_length(kwargs["y_high"]), + z_low=from_measurement_to_server_length(kwargs["z_low"]), + z_high=from_measurement_to_server_length(kwargs["z_high"]), + enclosure_options=grpc_enclosure_options, + ) + + # Call the gRPC service + response = self.stub.CreateEnclosureBox(request) + + # Return the response - formatted as a dictionary + serialized_tracker_response = serialize_tracker_command_response( + response=response.command_response + ) + return { + "success": response.success, + "created_bodies": [body.id for body in response.created_bodies], + "tracker_response": serialized_tracker_response, + } + + @protect_grpc + def create_cylinder_enclosure(self, **kwargs) -> dict: # noqa: D102 + from ansys.api.geometry.v0.models_pb2 import Body as GRPCBody + from ansys.api.geometry.v0.preparetools_pb2 import CreateEnclosureCylinderRequest + + grpc_enclosure_options = from_enclosure_options_to_grpc_enclosure_options( + kwargs["enclosure_options"] + ) + + # Create the request - assumes all inputs are valid and of the proper type + request = CreateEnclosureCylinderRequest( + bodies=[GRPCBody(id=body.id) for body in kwargs["bodies"]], + axial_distance_low=from_measurement_to_server_length(kwargs["axial_distance_low"]), + axial_distance_high=from_measurement_to_server_length(kwargs["axial_distance_high"]), + radial_distance=from_measurement_to_server_length(kwargs["radial_distance"]), + enclosure_options=grpc_enclosure_options, + ) + + # Call the gRPC service + response = self.stub.CreateEnclosureCylinder(request) + + # Return the response - formatted as a dictionary + serialized_tracker_response = serialize_tracker_command_response( + response=response.command_response + ) + return { + "success": response.success, + "created_bodies": [body.id for body in response.created_bodies], + "tracker_response": serialized_tracker_response, + } + + @protect_grpc + def create_sphere_enclosure(self, **kwargs) -> dict: # noqa: D102 + from ansys.api.geometry.v0.models_pb2 import Body as GRPCBody + from ansys.api.geometry.v0.preparetools_pb2 import CreateEnclosureSphereRequest + + grpc_enclosure_options = from_enclosure_options_to_grpc_enclosure_options( + kwargs["enclosure_options"] + ) + + # Create the request - assumes all inputs are valid and of the proper type + request = CreateEnclosureSphereRequest( + bodies=[GRPCBody(id=body.id) for body in kwargs["bodies"]], + radial_distance=from_measurement_to_server_length(kwargs["radial_distance"]), + enclosure_options=grpc_enclosure_options, + ) + + # Call the gRPC service + response = self.stub.CreateEnclosureSphere(request) + + # Return the response - formatted as a dictionary + serialized_tracker_response = serialize_tracker_command_response( + response=response.command_response + ) + return { + "success": response.success, + "created_bodies": [body.id for body in response.created_bodies], + "tracker_response": serialized_tracker_response, + } diff --git a/src/ansys/geometry/core/_grpc/_services/v1/prepare_tools.py b/src/ansys/geometry/core/_grpc/_services/v1/prepare_tools.py index cadd8c3984..3ad0170b45 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/prepare_tools.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/prepare_tools.py @@ -82,3 +82,15 @@ def remove_logo(self, **kwargs) -> dict: # noqa: D102 @protect_grpc def detect_helixes(self, **kwargs) -> dict: # noqa: D102 raise NotImplementedError + + @protect_grpc + def create_box_enclosure(self, **kwargs) -> dict: # noqa: D102 + raise NotImplementedError + + @protect_grpc + def create_cylinder_enclosure(self, **kwargs) -> dict: # noqa: D102 + raise NotImplementedError + + @protect_grpc + def create_sphere_enclosure(self, **kwargs) -> dict: # noqa: D102 + raise NotImplementedError diff --git a/src/ansys/geometry/core/tools/prepare_tools.py b/src/ansys/geometry/core/tools/prepare_tools.py index b6723641fe..2b712205e1 100644 --- a/src/ansys/geometry/core/tools/prepare_tools.py +++ b/src/ansys/geometry/core/tools/prepare_tools.py @@ -21,15 +21,18 @@ # SOFTWARE. """Provides tools for preparing geometry for use with simulation.""" +from dataclasses import dataclass from typing import TYPE_CHECKING from beartype import beartype as check_input_types from pint import Quantity +import ansys.geometry.core as pyansys_geom from ansys.geometry.core.connection import GrpcClient from ansys.geometry.core.connection.backend import BackendType from ansys.geometry.core.errors import GeometryRuntimeError from ansys.geometry.core.logger import LOG +from ansys.geometry.core.math.frame import Frame from ansys.geometry.core.misc.auxiliary import ( get_bodies_from_ids, get_design_from_body, @@ -49,6 +52,32 @@ from ansys.geometry.core.designer.face import Face +@dataclass +class EnclosureOptions: + """Provides options related to enclosure creation. + + Options allow control on how the enclosure is inserted in the design. + + Parameters + ---------- + create_shared_topology : bool, default: False + Whether shared topology should be applied after enclosure creation. + subtract_bodies : bool, default: True + Whether the specified bodies for enclosure creation should be subtracted from the enclosure. + frame : Frame, default: None + Frame used to orient the enclosure. + cushion_proportion : Real, default: 0.25 + A percentage of the minimum enclosure size. + Determines the initial distance between the enclosed objects + and the closest point of the enclosure to the objects. + """ + + create_shared_topology: bool = False + subtract_bodies: bool = True + frame: Frame = None + cushion_proportion: Real = 0.25 + + class PrepareTools: """Prepare tools for PyAnsys Geometry. @@ -527,3 +556,226 @@ def detect_helixes( for helix in response.get("helixes") ] } + + @min_backend_version(26, 1, 0) + def create_box_enclosure( + self, + bodies: list["Body"], + x_low: Distance | Quantity | Real, + x_high: Distance | Quantity | Real, + y_low: Distance | Quantity | Real, + y_high: Distance | Quantity | Real, + z_low: Distance | Quantity | Real, + z_high: Distance | Quantity | Real, + enclosure_options: EnclosureOptions, + ) -> list["Body"]: + """Create box enclosure around the given bodies. + + Parameters + ---------- + bodies : list[Body] + List of bodies to create enclosure around. + x_low : Distance | Quantity | Real + The lowest distance from the bodies in the x direction. + x_high : Distance | Quantity | Real + The highest distance from the bodies in the x direction. + y_low : Distance | Quantity | Real + The lowest distance from the bodies in the y direction. + y_high : Distance | Quantity | Real + The highest distance from the bodies in the y direction. + z_low : Distance | Quantity | Real + The lowest distance from the bodies in the z direction. + z_high : Distance | Quantity | Real + The highest distance from the bodies in the z direction. + enclosure_options : EnclosureOptions + Options that define how the enclosure is included in the design. + + Returns + ------- + list[Body] + List of created bodies. + + Warnings + -------- + This method is only available starting on Ansys release 26R1. + """ + from ansys.geometry.core.designer.body import Body + + if not bodies: + self._grpc_client.log.info("No bodies provided for enclosure...") + return [] + + # Verify inputs + check_type_all_elements_in_iterable(bodies, Body) + + x_low = x_low if isinstance(x_low, Distance) else Distance(x_low) + x_high = x_high if isinstance(x_high, Distance) else Distance(x_high) + y_low = x_low if isinstance(y_low, Distance) else Distance(y_low) + y_high = y_high if isinstance(y_high, Distance) else Distance(y_high) + z_low = z_low if isinstance(z_low, Distance) else Distance(z_low) + z_high = z_high if isinstance(z_high, Distance) else Distance(z_high) + + parent_design = get_design_from_body(bodies[0]) + + response = self._grpc_client._services.prepare_tools.create_box_enclosure( + bodies=bodies, + x_low=x_low, + x_high=x_high, + y_low=y_low, + y_high=y_high, + z_low=z_low, + z_high=z_high, + enclosure_options=enclosure_options, + ) + + if response.get("success"): + bodies_ids = response.get("created_bodies") + if len(bodies_ids) > 0: + if not pyansys_geom.USE_TRACKER_TO_UPDATE_DESIGN: + parent_design._update_design_inplace() + else: + parent_design._update_from_tracker(response.get("tracker_response")) + return get_bodies_from_ids(parent_design, bodies_ids) + else: + self._grpc_client.log.info("Failed to create enclosure...") + return [] + + @min_backend_version(26, 1, 0) + def create_cylinder_enclosure( + self, + bodies: list["Body"], + axial_distance_low: Distance | Quantity | Real, + axial_distance_high: Distance | Quantity | Real, + radial_distance: Distance | Quantity | Real, + enclosure_options: EnclosureOptions, + ) -> list["Body"]: + """Create cylinder enclosure around the given bodies. + + Parameters + ---------- + bodies : list[Body] + List of bodies to create enclosure around. + axial_distance_low : Distance | Quantity | Real + The lowest axial distance from the bodies. + axial_distance_high : Distance | Quantity | Real + The highest axial distance from the bodies. + radial_distance : Distance | Quantity | Real + The radial distance from the bodies. + enclosure_options : EnclosureOptions + Options that define how the enclosure is included in the design. + + Returns + ------- + list[Body] + List of created bodies. + + Warnings + -------- + This method is only available starting on Ansys release 26R1. + """ + from ansys.geometry.core.designer.body import Body + + if not bodies: + self._grpc_client.log.info("No bodies provided for enclosure...") + return [] + + # Verify inputs + check_type_all_elements_in_iterable(bodies, Body) + + axial_distance_low = ( + axial_distance_low + if isinstance(axial_distance_low, Distance) + else Distance(axial_distance_low) + ) + axial_distance_high = ( + axial_distance_high + if isinstance(axial_distance_high, Distance) + else Distance(axial_distance_high) + ) + radial_distance = ( + axial_distance_low + if isinstance(radial_distance, Distance) + else Distance(radial_distance) + ) + + parent_design = get_design_from_body(bodies[0]) + + response = self._grpc_client._services.prepare_tools.create_cylinder_enclosure( + bodies=bodies, + axial_distance_low=axial_distance_low, + axial_distance_high=axial_distance_high, + radial_distance=radial_distance, + enclosure_options=enclosure_options, + ) + + if response.get("success"): + bodies_ids = response.get("created_bodies") + if len(bodies_ids) > 0: + if not pyansys_geom.USE_TRACKER_TO_UPDATE_DESIGN: + parent_design._update_design_inplace() + else: + parent_design._update_from_tracker(response.get("tracker_response")) + return get_bodies_from_ids(parent_design, bodies_ids) + else: + self._grpc_client.log.info("Failed to create enclosure...") + return [] + + @min_backend_version(26, 1, 0) + def create_sphere_enclosure( + self, + bodies: list["Body"], + radial_distance: Distance | Quantity | Real, + enclosure_options: EnclosureOptions, + ) -> list["Body"]: + """Create sphere enclosure around the given bodies. + + Parameters + ---------- + bodies : list[Body] + List of bodies to create enclosure around. + radial_distance : Distance | Quantity | Real + The radial distance from the bodies. + enclosure_options : EnclosureOptions + Options that define how the enclosure is included in the design. + + Returns + ------- + list[Body] + List of created bodies. + + Warnings + -------- + This method is only available starting on Ansys release 26R1. + """ + from ansys.geometry.core.designer.body import Body + + if not bodies: + self._grpc_client.log.info("No bodies provided for enclosure...") + return [] + + # Verify inputs + check_type_all_elements_in_iterable(bodies, Body) + + parent_design = get_design_from_body(bodies[0]) + + radial_distance = ( + radial_distance if isinstance(radial_distance, Distance) else Distance(radial_distance) + ) + + response = self._grpc_client._services.prepare_tools.create_sphere_enclosure( + bodies=bodies, + radial_distance=radial_distance, + enclosure_options=enclosure_options, + ) + + if response.get("success"): + bodies_ids = response.get("created_bodies") + if len(bodies_ids) > 0: + if not pyansys_geom.USE_TRACKER_TO_UPDATE_DESIGN: + parent_design._update_design_inplace() + else: + parent_design._update_from_tracker(response.get("tracker_response")) + return get_bodies_from_ids(parent_design, bodies_ids) + else: + self._grpc_client.log.info("Failed to create enclosure...") + return [] diff --git a/tests/_incompatible_tests.yml b/tests/_incompatible_tests.yml index 79b761f236..621f543296 100644 --- a/tests/_incompatible_tests.yml +++ b/tests/_incompatible_tests.yml @@ -29,6 +29,9 @@ backends: - tests/integration/test_prepare_tools.py::test_volume_extract_bad_edges - tests/integration/test_prepare_tools.py::test_volume_extract_bad_edges - tests/integration/test_prepare_tools.py::test_helix_detection + - tests/integration/test_prepare_tools.py::test_box_enclosure + - tests/integration/test_prepare_tools.py::test_cylinder_enclosure + - tests/integration/test_prepare_tools.py::test_sphere_enclosure - tests/integration/test_repair_tools.py::test_fix_small_face - tests/integration/test_repair_tools.py::test_find_interference - tests/integration/test_repair_tools.py::test_fix_interference @@ -150,6 +153,9 @@ backends: - tests/integration/test_prepare_tools.py::test_volume_extract_bad_edges - tests/integration/test_prepare_tools.py::test_volume_extract_bad_edges - tests/integration/test_prepare_tools.py::test_helix_detection + - tests/integration/test_prepare_tools.py::test_box_enclosure + - tests/integration/test_prepare_tools.py::test_cylinder_enclosure + - tests/integration/test_prepare_tools.py::test_sphere_enclosure - tests/integration/test_repair_tools.py::test_fix_small_face - tests/integration/test_repair_tools.py::test_find_interference - tests/integration/test_repair_tools.py::test_fix_interference diff --git a/tests/integration/test_prepare_tools.py b/tests/integration/test_prepare_tools.py index d7aebe6de6..05cbadd04f 100644 --- a/tests/integration/test_prepare_tools.py +++ b/tests/integration/test_prepare_tools.py @@ -21,12 +21,16 @@ # SOFTWARE. """Testing of prepare tools.""" +import numpy as np from pint import Quantity -from ansys.geometry.core.math.point import Point2D +from ansys.geometry.core.math.frame import Frame +from ansys.geometry.core.math.point import Point2D, Point3D +from ansys.geometry.core.math.vector import UnitVector3D, Vector3D from ansys.geometry.core.misc.measurements import UNITS from ansys.geometry.core.modeler import Modeler from ansys.geometry.core.sketch import Sketch +from ansys.geometry.core.tools.prepare_tools import EnclosureOptions from .conftest import FILES_DIR @@ -234,3 +238,59 @@ def test_helix_detection(modeler: Modeler): # Test with multiple bodies result = modeler.prepare_tools.detect_helixes(bodies) assert len(result["helixes"]) == 2 + + +def test_box_enclosure(modeler): + """Tests creation of a box enclosure.""" + design = modeler.open_file(FILES_DIR / "BoxWithRound.scdocx") + bodies = [design.bodies[0]] + enclosure_options = EnclosureOptions() + modeler.prepare_tools.create_box_enclosure( + bodies, 0.005, 0.01, 0.01, 0.005, 0.10, 0.10, enclosure_options + ) + assert len(design.components) == 1 + assert len(design.components[0].bodies) == 1 + # verify that a body is created in a new component + volume_when_subtracting = design.components[0].bodies[0].volume + enclosure_options = EnclosureOptions(subtract_bodies=False) + modeler.prepare_tools.create_box_enclosure( + bodies, 0.005, 0.01, 0.01, 0.005, 0.10, 0.10, enclosure_options + ) + assert len(design.components) == 2 + assert len(design.components[1].bodies) == 1 + volume_without_subtracting = design.components[1].bodies[0].volume + # verify that the volume without subtracting is greater than the one with subtraction + assert volume_without_subtracting > volume_when_subtracting + # verify that an enclosure can be created with zero cushion + modeler.prepare_tools.create_box_enclosure( + bodies, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, enclosure_options + ) + assert len(design.components) == 3 + assert len(design.components[2].bodies) == 1 + + +def test_cylinder_enclosure(modeler): + """Tests creation of a cylinder enclosure.""" + design = modeler.open_file(FILES_DIR / "BoxWithRound.scdocx") + bodies = [design.bodies[0]] + origin = Vector3D([0.0, 0.0, 0.0]) + direction_x = UnitVector3D([0, 1, 0]) + direction_y = UnitVector3D([0, 0, 1]) + frame = Frame(origin, direction_x, direction_y) + enclosure_options = EnclosureOptions(frame=frame) + modeler.prepare_tools.create_cylinder_enclosure(bodies, 0.1, 0.1, 0.1, enclosure_options) + assert len(design.components) == 1 + assert len(design.components[0].bodies) == 1 + bounding_box = design.components[0].bodies[0].bounding_box + # check that the cylinder has been placed in the appropriate position based upon the frame + assert np.allclose(bounding_box.center, Point3D([0.0, 0.0, 0.01])) + + +def test_sphere_enclosure(modeler): + """Tests creation of a sphere enclosure.""" + design = modeler.open_file(FILES_DIR / "BoxWithRound.scdocx") + bodies = [design.bodies[0]] + enclosure_options = EnclosureOptions() + modeler.prepare_tools.create_sphere_enclosure(bodies, 0.1, enclosure_options) + assert len(design.components) == 1 + assert len(design.components[0].bodies) == 1