diff --git a/doc/changelog.d/1953.added.md b/doc/changelog.d/1953.added.md new file mode 100644 index 0000000000..3b08510ec1 --- /dev/null +++ b/doc/changelog.d/1953.added.md @@ -0,0 +1 @@ +Find and fix stitch/missing/small faces enhancements \ No newline at end of file diff --git a/src/ansys/geometry/core/_grpc/_services/base/conversions.py b/src/ansys/geometry/core/_grpc/_services/base/conversions.py index 81892a0a00..16913fd193 100644 --- a/src/ansys/geometry/core/_grpc/_services/base/conversions.py +++ b/src/ansys/geometry/core/_grpc/_services/base/conversions.py @@ -58,6 +58,22 @@ def from_measurement_to_server_angle(input: Measurement) -> float: return input.value.m_as(DEFAULT_UNITS.SERVER_ANGLE) +def from_measurement_to_server_area(input: Measurement) -> float: + """Convert a measurement to an area value. + + Parameters + ---------- + value : Measurement + Measurement value. + + Returns + ------- + float + Area value in server-defined units. By default, square meters. + """ + return input.value.m_as(DEFAULT_UNITS.SERVER_AREA) + + def to_distance(value: float | int) -> Distance: """Convert a server value to a Distance object. diff --git a/src/ansys/geometry/core/_grpc/_services/base/repair_tools.py b/src/ansys/geometry/core/_grpc/_services/base/repair_tools.py index cbbf7b90d3..4d2ac4cfde 100644 --- a/src/ansys/geometry/core/_grpc/_services/base/repair_tools.py +++ b/src/ansys/geometry/core/_grpc/_services/base/repair_tools.py @@ -42,6 +42,7 @@ class GRPCRepairToolsService(ABC): # pragma: no cover def __init__(self, channel: grpc.Channel): """Initialize the gRPC repair tools service.""" + pass @abstractmethod def find_split_edges(self, **kwargs) -> dict: @@ -113,6 +114,11 @@ def find_and_fix_simplify(self, **kwargs) -> dict: """Identify and simplify areas in the geometry.""" pass + @abstractmethod + def find_and_fix_stitch_faces(self, **kwargs) -> dict: + """Identify and stitch faces in the geometry.""" + pass + @abstractmethod def inspect_geometry(self, **kwargs) -> dict: """Inspect the geometry for issues.""" diff --git a/src/ansys/geometry/core/_grpc/_services/v0/repair_tools.py b/src/ansys/geometry/core/_grpc/_services/v0/repair_tools.py index 5b7000e990..372d0868be 100644 --- a/src/ansys/geometry/core/_grpc/_services/v0/repair_tools.py +++ b/src/ansys/geometry/core/_grpc/_services/v0/repair_tools.py @@ -58,7 +58,7 @@ def find_split_edges(self, **kwargs) -> dict: # noqa: D102 from ansys.api.geometry.v0.repairtools_pb2 import FindSplitEdgesRequest - # Create the gRPC request + # Create the request - assumes all inputs are valid and of the proper type request = FindSplitEdgesRequest( bodies_or_faces=kwargs["bodies_or_faces"], angle=DoubleValue(value=float(kwargs["angle"])), @@ -104,6 +104,7 @@ def find_extra_edges(self, **kwargs) -> dict: # noqa: D102 def find_inexact_edges(self, **kwargs) -> dict: # noqa: D102 from ansys.api.geometry.v0.repairtools_pb2 import FindInexactEdgesRequest + # Create the request - assumes all inputs are valid and of the proper type request = FindInexactEdgesRequest(selection=kwargs["selection"]) # Call the gRPC service @@ -150,6 +151,7 @@ def find_short_edges(self, **kwargs) -> dict: # noqa: D102 def find_duplicate_faces(self, **kwargs) -> dict: # noqa: D102 from ansys.api.geometry.v0.repairtools_pb2 import FindDuplicateFacesRequest + # Create the request - assumes all inputs are valid and of the proper type request = FindDuplicateFacesRequest(faces=kwargs["faces"]) # Call the gRPC service @@ -168,9 +170,41 @@ def find_duplicate_faces(self, **kwargs) -> dict: # noqa: D102 @protect_grpc def find_missing_faces(self, **kwargs) -> dict: # noqa: D102 + from google.protobuf.wrappers_pb2 import DoubleValue + from ansys.api.geometry.v0.repairtools_pb2 import FindMissingFacesRequest + from ansys.geometry.core.logger import LOG + + from ..base.conversions import ( + from_measurement_to_server_angle, + from_measurement_to_server_length, + ) + + # Check the backend version to set optional parameters + if kwargs["backend_version"] < (26, 1, 0) and ( + kwargs["angle"] is not None or kwargs["distance"] is not None + ): + # If the backend version is less than 26.1.0, set angle and distance to None + kwargs["angle"] = None + kwargs["distance"] = None + + # Log a warning + LOG.warning( + "The backend version is less than 26.1.0, so angle and distance parameters will be" + "ignored. Please update the backend to use these parameters." + ) + + # Create the request - assumes all inputs are valid and of the proper type + request = FindMissingFacesRequest( + faces=kwargs["faces"], + angle=DoubleValue(value=from_measurement_to_server_angle(kwargs["angle"])) + if kwargs["angle"] is not None + else None, + distance=DoubleValue(value=from_measurement_to_server_length(kwargs["distance"])) + if kwargs["distance"] is not None + else None, + ) - request = FindMissingFacesRequest(faces=kwargs["faces"]) # Call the gRPC service response = self.stub.FindMissingFaces(request) @@ -187,9 +221,41 @@ def find_missing_faces(self, **kwargs) -> dict: # noqa: D102 @protect_grpc def find_small_faces(self, **kwargs) -> dict: # noqa: D102 + from google.protobuf.wrappers_pb2 import DoubleValue + from ansys.api.geometry.v0.repairtools_pb2 import FindSmallFacesRequest + from ansys.geometry.core.logger import LOG + + from ..base.conversions import ( + from_measurement_to_server_area, + from_measurement_to_server_length, + ) + + # Check the backend version to set optional parameters + if kwargs["backend_version"] < (26, 1, 0) and ( + kwargs["area"] is not None or kwargs["width"] is not None + ): + # If the backend version is less than 26.1.0, set area and width to None + kwargs["area"] = None + kwargs["width"] = None + + # Log a warning + LOG.warning( + "The backend version is less than 26.1.0, so area and width parameters will be" + "ignored. Please update the backend to use these parameters." + ) + + # Create the request - assumes all inputs are valid and of the proper type + request = FindSmallFacesRequest( + selection=kwargs["selection"], + area=DoubleValue(value=from_measurement_to_server_area(kwargs["area"])) + if kwargs["area"] is not None + else None, + width=DoubleValue(value=from_measurement_to_server_length(kwargs["width"])) + if kwargs["width"] is not None + else None, + ) - request = FindSmallFacesRequest(selection=kwargs["selection"]) # Call the gRPC service response = self.stub.FindSmallFaces(request) @@ -206,11 +272,33 @@ def find_small_faces(self, **kwargs) -> dict: # noqa: D102 @protect_grpc def find_stitch_faces(self, **kwargs) -> dict: # noqa: D102 + from google.protobuf.wrappers_pb2 import DoubleValue + from ansys.api.geometry.v0.repairtools_pb2 import FindStitchFacesRequest + from ansys.geometry.core.logger import LOG + + from ..base.conversions import from_measurement_to_server_length + + if kwargs["backend_version"] < (26, 1, 0) and kwargs["distance"] is not None: + # If the backend version is less than 26.1.0, set distance to None and log warning + kwargs["distance"] = None + LOG.warning( + "The backend version is less than 26.1.0, so distance parameter will be ignored. " + ) + + # Create the request - assumes all inputs are valid and of the proper type + request = FindStitchFacesRequest( + faces=kwargs["faces"], + maximum_distance=DoubleValue( + value=from_measurement_to_server_length(kwargs["distance"]) + ) + if kwargs["distance"] is not None + else None, + ) - request = FindStitchFacesRequest(faces=kwargs["faces"]) # Call the gRPC service response = self.stub.FindStitchFaces(request) + # Return the response - formatted as a dictionary return { "problems": [ @@ -226,10 +314,12 @@ def find_stitch_faces(self, **kwargs) -> dict: # noqa: D102 def find_simplify(self, **kwargs) -> dict: # noqa: D102 from ansys.api.geometry.v0.repairtools_pb2 import FindAdjustSimplifyRequest + # Create the request - assumes all inputs are valid and of the proper type request = FindAdjustSimplifyRequest(selection=kwargs["selection"]) # Call the gRPC service response = self.stub.FindAdjustSimplify(request) + # Return the response - formatted as a dictionary return { "problems": [ @@ -245,12 +335,15 @@ def find_simplify(self, **kwargs) -> dict: # noqa: D102 def find_and_fix_simplify(self, **kwargs) -> dict: # noqa: D102 from ansys.api.geometry.v0.repairtools_pb2 import FindAdjustSimplifyRequest + # Create the request - assumes all inputs are valid and of the proper type request = FindAdjustSimplifyRequest( selection=kwargs["selection"], comprehensive=kwargs["comprehensive_result"], ) + # Call the gRPC service response = self.stub.FindAndSimplify(request) + # Return the response - formatted as a dictionary return { "success": response.success, @@ -260,23 +353,54 @@ def find_and_fix_simplify(self, **kwargs) -> dict: # noqa: D102 "modified_bodies_monikers": [], } + @protect_grpc + def find_and_fix_stitch_faces(self, **kwargs) -> dict: # noqa: D102 + from google.protobuf.wrappers_pb2 import BoolValue, DoubleValue + + from ansys.api.geometry.v0.repairtools_pb2 import FindStitchFacesRequest + + # Create the request - assumes all inputs are valid and of the proper type + request = FindStitchFacesRequest( + faces=kwargs["body_ids"], + maximum_distance=DoubleValue(value=kwargs["max_distance"]) + if kwargs["max_distance"] is not None + else None, + allow_multiple_bodies=BoolValue(value=kwargs["allow_multiple_bodies"]), + maintain_components=BoolValue(value=kwargs["maintain_components"]), + check_for_coincidence=BoolValue(value=kwargs["check_for_coincidence"]), + comprehensive=kwargs["comprehensive_result"], + ) + + # Call the gRPC service + response = self.stub.FindAndFixStitchFaces(request) + + # Return the response - formatted as a dictionary + return { + "success": response.success, + "created_bodies_monikers": response.created_bodies_monikers, + "modified_bodies_monikers": response.modified_bodies_monikers, + "found": response.found, + "repaired": response.repaired, + } + @protect_grpc def inspect_geometry(self, **kwargs) -> dict: # noqa: D102 from ansys.api.geometry.v0.repairtools_pb2 import InspectGeometryRequest - # Create the gRPC request + # Create the request - assumes all inputs are valid and of the proper type request = InspectGeometryRequest(bodies=kwargs.get("bodies", [])) # Call the gRPC service inspect_result_response = self.stub.InspectGeometry(request) # Serialize and return the response - return self.serialize_inspect_result_response(inspect_result_response) + return self.__serialize_inspect_result_response(inspect_result_response) @protect_grpc def repair_geometry(self, **kwargs) -> dict: # noqa: D102 from ansys.api.geometry.v0.repairtools_pb2 import RepairGeometryRequest + # Create the request - assumes all inputs are valid and of the proper type request = RepairGeometryRequest(bodies=kwargs.get("bodies", [])) # Call the gRPC service @@ -293,6 +417,7 @@ def find_interferences(self, **kwargs) -> dict: # noqa: D102 from ansys.api.geometry.v0.repairtools_pb2 import FindInterferenceRequest + # Create the request - assumes all inputs are valid and of the proper type request = FindInterferenceRequest( bodies=kwargs["bodies"], cut_smaller_body=BoolValue(value=kwargs["cut_smaller_body"]), @@ -318,11 +443,13 @@ def find_and_fix_short_edges(self, **kwargs): # noqa: D102 from ansys.api.geometry.v0.repairtools_pb2 import FindShortEdgesRequest + # Create the request - assumes all inputs are valid and of the proper type request = FindShortEdgesRequest( selection=kwargs["selection"], max_edge_length=DoubleValue(value=kwargs["length"]), comprehensive=kwargs["comprehensive_result"], ) + # Call the gRPC service response = self.stub.FindAndFixShortEdges(request) @@ -339,10 +466,12 @@ def find_and_fix_short_edges(self, **kwargs): # noqa: D102 def find_and_fix_extra_edges(self, **kwargs) -> dict: # noqa: D102 from ansys.api.geometry.v0.repairtools_pb2 import FindExtraEdgesRequest + # Create the request - assumes all inputs are valid and of the proper type request = FindExtraEdgesRequest( selection=kwargs["selection"], comprehensive=kwargs["comprehensive_result"], ) + # Call the gRPC service response = self.stub.FindAndFixExtraEdges(request) @@ -361,6 +490,7 @@ def find_and_fix_split_edges(self, **kwargs) -> dict: # noqa: D102 from ansys.api.geometry.v0.repairtools_pb2 import FindSplitEdgesRequest + # Create the request - assumes all inputs are valid and of the proper type request = FindSplitEdgesRequest( bodies_or_faces=kwargs["bodies_or_faces"], angle=DoubleValue(value=float(kwargs["angle"])), @@ -380,8 +510,7 @@ def find_and_fix_split_edges(self, **kwargs) -> dict: # noqa: D102 "modified_bodies_monikers": [], } - @staticmethod - def serialize_inspect_result_response(response) -> dict: # noqa: D102 + def __serialize_inspect_result_response(self, response) -> dict: # noqa: D102 def serialize_body(body): return { "id": body.id, diff --git a/src/ansys/geometry/core/_grpc/_services/v1/repair_tools.py b/src/ansys/geometry/core/_grpc/_services/v1/repair_tools.py index 503f70529b..3a53f3c2f5 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/repair_tools.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/repair_tools.py @@ -19,7 +19,7 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Module containing the repair tools service implementation.""" +"""Module containing the repair tools service implementation for v1.""" import grpc @@ -99,6 +99,10 @@ def find_and_fix_split_edges(self, **kwargs) -> dict: # noqa: D102 def find_and_fix_simplify(self, **kwargs) -> dict: # noqa: D102 raise NotImplementedError + @protect_grpc + def find_and_fix_stitch_faces(self, **kwargs) -> dict: # noqa: D102 + raise NotImplementedError + @protect_grpc def inspect_geometry(self, **kwargs) -> dict: # noqa: D102 raise NotImplementedError diff --git a/src/ansys/geometry/core/misc/measurements.py b/src/ansys/geometry/core/misc/measurements.py index d3182b1753..ca39d7b3da 100644 --- a/src/ansys/geometry/core/misc/measurements.py +++ b/src/ansys/geometry/core/misc/measurements.py @@ -98,6 +98,11 @@ def ANGLE(self, value: Unit) -> None: # noqa: N802 check_pint_unit_compatibility(value, self._angle) self._angle = value + @property + def AREA(self) -> Unit: # noqa: N802 + """Default area unit for PyAnsys Geometry.""" + return self._length * self._length + @property def SERVER_LENGTH(self) -> Unit: # noqa: N802 """Default length unit for gRPC messages. @@ -228,3 +233,21 @@ def __init__(self, value: Real | Quantity, unit: Unit | None = None): # Delegates in Measurement ctor. forcing expected dimensions. unit = unit if unit else DEFAULT_UNITS.ANGLE super().__init__(value, unit, DEFAULT_UNITS.ANGLE) + + +class Area(Measurement): + """Provides the ``Measurement`` subclass for holding an area. + + Parameters + ---------- + value : Real | ~pint.Quantity + Value of the area. + unit : ~pint.Unit, default: DEFAULT_UNITS.AREA + Units for the area. + """ + + def __init__(self, value: Real | Quantity, unit: Unit | None = None): + """Initialize the ``Area`` class.""" + # Delegates in Measurement ctor. forcing expected dimensions. + unit = unit if unit else DEFAULT_UNITS.AREA + super().__init__(value, unit, DEFAULT_UNITS.AREA) diff --git a/src/ansys/geometry/core/tools/repair_tools.py b/src/ansys/geometry/core/tools/repair_tools.py index a0d468a70b..d1245569bc 100644 --- a/src/ansys/geometry/core/tools/repair_tools.py +++ b/src/ansys/geometry/core/tools/repair_tools.py @@ -23,8 +23,9 @@ from typing import TYPE_CHECKING +import pint + from ansys.geometry.core.connection import GrpcClient -from ansys.geometry.core.errors import protect_grpc from ansys.geometry.core.misc.auxiliary import ( get_bodies_from_ids, get_design_from_body, @@ -36,6 +37,7 @@ check_type_all_elements_in_iterable, min_backend_version, ) +from ansys.geometry.core.misc.measurements import Angle, Area, Distance from ansys.geometry.core.tools.check_geometry import GeometryIssue, InspectResult from ansys.geometry.core.tools.problem_areas import ( DuplicateFaceProblemAreas, @@ -65,7 +67,6 @@ def __init__(self, grpc_client: GrpcClient, modeler: "Modeler"): self._modeler = modeler self._grpc_client = grpc_client - @protect_grpc def find_split_edges( self, bodies: list["Body"], angle: Real = 0.0, length: Real = 0.0 ) -> list[SplitEdgeProblemAreas]: @@ -93,21 +94,20 @@ def find_split_edges( body_ids = [body.id for body in bodies] - problem_areas_response = self._grpc_client.services.repair_tools.find_split_edges( + response = self._grpc_client.services.repair_tools.find_split_edges( bodies_or_faces=body_ids, angle=angle, distance=length ) parent_design = get_design_from_body(bodies[0]) return [ SplitEdgeProblemAreas( - f"{res['id']}", + f"{res.get('id')}", self._grpc_client, - get_edges_from_ids(parent_design, res["edges"]), + get_edges_from_ids(parent_design, res.get("edges")), ) - for res in problem_areas_response["problems"] + for res in response.get("problems") ] - @protect_grpc def find_extra_edges(self, bodies: list["Body"]) -> list[ExtraEdgeProblemAreas]: """Find the extra edges in the given list of bodies. @@ -128,21 +128,18 @@ def find_extra_edges(self, bodies: list["Body"]) -> list[ExtraEdgeProblemAreas]: return [] body_ids = [body.id for body in bodies] - problem_areas_response = self._grpc_client.services.repair_tools.find_extra_edges( - selection=body_ids - ) + response = self._grpc_client.services.repair_tools.find_extra_edges(selection=body_ids) parent_design = get_design_from_body(bodies[0]) return [ ExtraEdgeProblemAreas( - f"{res['id']}", + f"{res.get('id')}", self._grpc_client, - get_edges_from_ids(parent_design, res["edges"]), + get_edges_from_ids(parent_design, res.get("edges")), ) - for res in problem_areas_response["problems"] + for res in response.get("problems") ] - @protect_grpc def find_inexact_edges(self, bodies: list["Body"]) -> list[InexactEdgeProblemAreas]: """Find inexact edges in the given list of bodies. @@ -163,22 +160,19 @@ def find_inexact_edges(self, bodies: list["Body"]) -> list[InexactEdgeProblemAre return [] body_ids = [body.id for body in bodies] - problem_areas_response = self._grpc_client.services.repair_tools.find_inexact_edges( - selection=body_ids - ) + response = self._grpc_client.services.repair_tools.find_inexact_edges(selection=body_ids) parent_design = get_design_from_body(bodies[0]) return [ InexactEdgeProblemAreas( - f"{res['id']}", + f"{res.get('id')}", self._grpc_client, - get_edges_from_ids(parent_design, res["edges"]), + get_edges_from_ids(parent_design, res.get("edges")), ) - for res in problem_areas_response["problems"] + for res in response.get("problems") ] - @protect_grpc def find_short_edges( self, bodies: list["Body"], length: Real = 0.0 ) -> list[ShortEdgeProblemAreas]: @@ -202,21 +196,20 @@ def find_short_edges( body_ids = [body.id for body in bodies] - problem_areas_response = self._grpc_client.services.repair_tools.find_short_edges( + response = self._grpc_client.services.repair_tools.find_short_edges( selection=body_ids, length=length ) parent_design = get_design_from_body(bodies[0]) return [ ShortEdgeProblemAreas( - f"{res['id']}", + f"{res.get('id')}", self._grpc_client, - get_edges_from_ids(parent_design, res["edges"]), + get_edges_from_ids(parent_design, res.get("edges")), ) - for res in problem_areas_response["problems"] + for res in response.get("problems") ] - @protect_grpc def find_duplicate_faces(self, bodies: list["Body"]) -> list[DuplicateFaceProblemAreas]: """Find the duplicate face problem areas. @@ -237,22 +230,24 @@ def find_duplicate_faces(self, bodies: list["Body"]) -> list[DuplicateFaceProble return [] body_ids = [body.id for body in bodies] - problem_areas_response = self._grpc_client.services.repair_tools.find_duplicate_faces( - faces=body_ids - ) + response = self._grpc_client.services.repair_tools.find_duplicate_faces(faces=body_ids) parent_design = get_design_from_body(bodies[0]) return [ DuplicateFaceProblemAreas( - f"{res['id']}", + f"{res.get('id')}", self._grpc_client, - get_faces_from_ids(parent_design, res["faces"]), + get_faces_from_ids(parent_design, res.get("faces")), ) - for res in problem_areas_response["problems"] + for res in response.get("problems") ] - @protect_grpc - def find_missing_faces(self, bodies: list["Body"]) -> list[MissingFaceProblemAreas]: + def find_missing_faces( + self, + bodies: list["Body"], + angle: Angle | pint.Quantity | Real | None = None, + distance: Distance | pint.Quantity | Real | None = None, + ) -> list[MissingFaceProblemAreas]: """Find the missing faces. This method find the missing face problem areas and returns a list of missing @@ -262,6 +257,12 @@ def find_missing_faces(self, bodies: list["Body"]) -> list[MissingFaceProblemAre ---------- bodies : list[Body] List of bodies that missing faces are investigated on. + angle : Angle | ~pint.Quantity | Real, optional + The minimum angle between faces. By default, None. + This option is only used if the backend version is 26.1 or higher. + distance : Distance | ~pint.Quantity | Real, optional + The minimum distance between faces. By default, None. + This option is only used if the backend version is 26.1 or higher. Returns ------- @@ -270,23 +271,37 @@ def find_missing_faces(self, bodies: list["Body"]) -> list[MissingFaceProblemAre """ if not bodies: return [] + + # Perform sanity check + if angle is not None: + angle = angle if isinstance(angle, Angle) else Angle(angle) + if distance is not None: + distance = distance if isinstance(distance, Distance) else Distance(distance) + body_ids = [body.id for body in bodies] - problem_areas_response = self._grpc_client.services.repair_tools.find_missing_faces( - faces=body_ids + response = self._grpc_client.services.repair_tools.find_missing_faces( + faces=body_ids, + angle=angle, + distance=distance, + backend_version=self._grpc_client.backend_version, ) parent_design = get_design_from_body(bodies[0]) return [ MissingFaceProblemAreas( - f"{res['id']}", + f"{res.get('id')}", self._grpc_client, - get_edges_from_ids(parent_design, res["edges"]), + get_edges_from_ids(parent_design, res.get("edges")), ) - for res in problem_areas_response["problems"] + for res in response.get("problems") ] - @protect_grpc - def find_small_faces(self, bodies: list["Body"]) -> list[SmallFaceProblemAreas]: + def find_small_faces( + self, + bodies: list["Body"], + area: Area | pint.Quantity | Real | None = None, + width: Distance | pint.Quantity | Real | None = None, + ) -> list[SmallFaceProblemAreas]: """Find the small face problem areas. This method finds and returns a list of ids of small face problem areas @@ -296,6 +311,12 @@ def find_small_faces(self, bodies: list["Body"]) -> list[SmallFaceProblemAreas]: ---------- bodies : list[Body] List of bodies that small faces are investigated on. + area : Area | ~pint.Quantity | Real, optional + Maximum area of the faces. By default, None. + This option is only used if the backend version is 26.1 or higher. + width : Distance | ~pint.Quantity | Real, optional + Maximum width of the faces. By default, None. + This option is only used if the backend version is 26.1 or higher. Returns ------- @@ -306,22 +327,34 @@ def find_small_faces(self, bodies: list["Body"]) -> list[SmallFaceProblemAreas]: return [] body_ids = [body.id for body in bodies] - problem_areas_response = self._grpc_client.services.repair_tools.find_small_faces( - selection=body_ids + + if area is not None: + area = area if isinstance(area, Area) else Area(area) + if width is not None: + width = width if isinstance(width, Distance) else Distance(width) + + response = self._grpc_client.services.repair_tools.find_small_faces( + selection=body_ids, + area=area, + width=width, + backend_version=self._grpc_client.backend_version, ) parent_design = get_design_from_body(bodies[0]) return [ SmallFaceProblemAreas( - f"{res['id']}", + f"{res.get('id')}", self._grpc_client, - get_faces_from_ids(parent_design, res["faces"]), + get_faces_from_ids(parent_design, res.get("faces")), ) - for res in problem_areas_response["problems"] + for res in response.get("problems") ] - @protect_grpc - def find_stitch_faces(self, bodies: list["Body"]) -> list[StitchFaceProblemAreas]: + def find_stitch_faces( + self, + bodies: list["Body"], + max_distance: Distance | pint.Quantity | Real | None = None, + ) -> list[StitchFaceProblemAreas]: """Return the list of stitch face problem areas. This method find the stitch face problem areas and returns a list of ids of stitch face @@ -331,27 +364,40 @@ def find_stitch_faces(self, bodies: list["Body"]) -> list[StitchFaceProblemAreas ---------- bodies : list[Body] List of bodies that stitchable faces are investigated on. + max_distance : Distance | ~pint.Quantity | Real, optional + Maximum distance between faces. By default, None. + This option is only used if the backend version is 26.1 or higher. Returns ------- list[StitchFaceProblemAreas] List of objects representing stitch face problem areas. """ + from ansys.geometry.core.designer.body import Body + + # Perform sanity check + check_type_all_elements_in_iterable(bodies, Body) + if max_distance is not None: + max_distance = ( + max_distance if isinstance(max_distance, Distance) else Distance(max_distance) + ) + body_ids = [body.id for body in bodies] - problem_areas_response = self._grpc_client.services.repair_tools.find_stitch_faces( - faces=body_ids + response = self._grpc_client.services.repair_tools.find_stitch_faces( + faces=body_ids, + distance=max_distance, + backend_version=self._grpc_client.backend_version, ) parent_design = get_design_from_body(bodies[0]) return [ StitchFaceProblemAreas( - f"{res['id']}", + f"{res.get('id')}", self._grpc_client, - get_bodies_from_ids(parent_design, res["bodies"]), + get_bodies_from_ids(parent_design, res.get("bodies")), ) - for res in problem_areas_response["problems"] + for res in response.get("problems") ] - @protect_grpc @min_backend_version(25, 2, 0) def find_simplify(self, bodies: list["Body"]) -> list[UnsimplifiedFaceProblemAreas]: """Detect faces in a body that can be simplified. @@ -372,20 +418,17 @@ def find_simplify(self, bodies: list["Body"]) -> list[UnsimplifiedFaceProblemAre body_ids = [body.id for body in bodies] parent_design = get_design_from_body(bodies[0]) - problem_areas_response = self._grpc_client.services.repair_tools.find_simplify( - selection=body_ids - ) + response = self._grpc_client.services.repair_tools.find_simplify(selection=body_ids) return [ UnsimplifiedFaceProblemAreas( - f"{res['id']}", + f"{res.get('id')}", self._grpc_client, - get_faces_from_ids(parent_design, res["bodies"]), + get_faces_from_ids(parent_design, res.get("bodies")), ) - for res in problem_areas_response["problems"] + for res in response.get("problems") ] - @protect_grpc @min_backend_version(25, 2, 0) def find_interferences( self, bodies: list["Body"], cut_smaller_body: bool = False @@ -421,21 +464,19 @@ def find_interferences( parent_design = get_design_from_body(bodies[0]) body_ids = [body.id for body in bodies] - # cut_smaller_body_bool = BoolValue(value=cut_smaller_body) - problem_areas_response = self._grpc_client.services.repair_tools.find_interferences( + response = self._grpc_client.services.repair_tools.find_interferences( bodies=body_ids, cut_smaller_body=cut_smaller_body ) return [ InterferenceProblemAreas( - f"{res['id']}", + f"{res.get('id')}", self._grpc_client, - get_bodies_from_ids(parent_design, res["bodies"]), + get_bodies_from_ids(parent_design, res.get("bodies")), ) - for res in problem_areas_response["problems"] + for res in response.get("problems") ] - @protect_grpc @min_backend_version(25, 2, 0) def find_and_fix_short_edges( self, bodies: list["Body"], length: Real = 0.0, comprehensive_result: bool = False @@ -478,18 +519,13 @@ def find_and_fix_short_edges( comprehensive_result=comprehensive_result, ) + # Update existing design parent_design = get_design_from_body(bodies[0]) parent_design._update_design_inplace() - message = RepairToolMessage( - success=response["success"], - found=response["found"], - repaired=response["repaired"], - created_bodies=[], - modified_bodies=[], - ) - return message - @protect_grpc + # Build the response message + return self.__build_repair_tool_message(response) + @min_backend_version(25, 2, 0) def find_and_fix_extra_edges( self, bodies: list["Body"], comprehensive_result: bool = False @@ -529,18 +565,13 @@ def find_and_fix_extra_edges( comprehensive_result=comprehensive_result, ) + # Update existing design parent_design = get_design_from_body(bodies[0]) parent_design._update_design_inplace() - message = RepairToolMessage( - response["success"], - response["created_bodies_monikers"], - response["modified_bodies_monikers"], - response["found"], - response["repaired"], - ) - return message - @protect_grpc + # Build the response message + return self.__build_repair_tool_message(response) + @min_backend_version(25, 2, 0) def find_and_fix_split_edges( self, @@ -591,18 +622,13 @@ def find_and_fix_split_edges( comprehensive_result=comprehensive_result, ) + # Update existing design parent_design = get_design_from_body(bodies[0]) parent_design._update_design_inplace() - message = RepairToolMessage( - response["success"], - response["created_bodies_monikers"], - response["modified_bodies_monikers"], - response["found"], - response["repaired"], - ) - return message - @protect_grpc + # Build the response message + return self.__build_repair_tool_message(response) + @min_backend_version(25, 2, 0) def find_and_fix_simplify( self, bodies: list["Body"], comprehensive_result: bool = False @@ -641,16 +667,80 @@ def find_and_fix_simplify( comprehensive_result=comprehensive_result, ) + # Update existing design parent_design = get_design_from_body(bodies[0]) parent_design._update_design_inplace() - message = RepairToolMessage( - response["success"], - response["created_bodies_monikers"], - response["modified_bodies_monikers"], - response["found"], - response["repaired"], + + # Build the response message + return self.__build_repair_tool_message(response) + + @min_backend_version(25, 2, 0) + def find_and_fix_stitch_faces( + self, + bodies: list["Body"], + max_distance: Distance | pint.Quantity | Real | None = None, + allow_multiple_bodies: bool = False, + maintain_components: bool = True, + check_for_coincidence: bool = False, + comprehensive_result: bool = False, + ) -> RepairToolMessage: + """Find and fix the stitch face problem areas. + + Parameters + ---------- + bodies : list[Body] + List of bodies that stitchable faces are investigated on. + max_distance : Distance | ~pint.Quantity | Real, optional + The maximum distance between faces to be stitched. + By default, 0.0001. + allow_multiple_bodies : bool, optional + Whether to allow multiple bodies in the result. + By default, False. + maintain_components : bool, optional + Whether to stitch bodies within the components. + By default, True. + check_for_coincidence : bool, optional + Whether coincidence surfaces are searched. + By default, False. + comprehensive_result : bool, optional + Whether to fix all problem areas individually. + By default, False. + + Returns + ------- + RepairToolMessage + Message containing number of problem areas found/fixed, created and/or modified bodies. + + Notes + ----- + This method finds the stitchable faces and fixes them. + """ + from ansys.geometry.core.designer.body import Body + + # Perform sanity check + check_type_all_elements_in_iterable(bodies, Body) + if max_distance is not None: + max_distance = ( + max_distance if isinstance(max_distance, Distance) else Distance(max_distance) + ) + + body_ids = [body.id for body in bodies] + + response = self._grpc_client.services.repair_tools.find_and_fix_stitch_faces( + body_ids=body_ids, + max_distance=max_distance, + allow_multiple_bodies=allow_multiple_bodies, + maintain_components=maintain_components, + check_for_coincidence=check_for_coincidence, + comprehensive_result=comprehensive_result, ) - return message + + # Update existing design + parent_design = get_design_from_body(bodies[0]) + parent_design._update_design_inplace() + + # Build the response message + return self.__build_repair_tool_message(response) def inspect_geometry(self, bodies: list["Body"] = None) -> list[InspectResult]: """Return a list of geometry issues organized by body. @@ -675,7 +765,7 @@ def inspect_geometry(self, bodies: list["Body"] = None) -> list[InspectResult]: parent_design=parent_design, bodies=body_ids ) return self.__create_inspect_result_from_response( - parent_design, inspect_result_response_dict["issues_by_body"] + parent_design, inspect_result_response_dict.get("issues_by_body") ) def __create_inspect_result_from_response( @@ -684,7 +774,7 @@ def __create_inspect_result_from_response( inspect_results = [] for inspect_geometry_result in inspect_geometry_results: body = get_bodies_from_ids(design, [inspect_geometry_result["body"]["id"]]) - issues = self.__create_issues_from_response(inspect_geometry_result["issues"]) + issues = self.__create_issues_from_response(inspect_geometry_result.get("issues")) inspect_result = InspectResult( grpc_client=self._grpc_client, body=body[0], issues=issues ) @@ -698,9 +788,9 @@ def __create_issues_from_response( ) -> list[GeometryIssue]: issues = [] for issue in inspect_geometry_result_issues: - message_type = issue["message_type"] - message_id = issue["message_id"] - message = issue["message"] + message_type = issue.get("message_type") + message_id = issue.get("message_id") + message = issue.get("message") faces = [face["id"] for face in issue.get("faces", [])] edges = [edge["id"] for edge in issue.get("edges", [])] @@ -715,7 +805,6 @@ def __create_issues_from_response( issues.append(geometry_issue) return issues - @protect_grpc @min_backend_version(25, 2, 0) def repair_geometry(self, bodies: list["Body"] = None) -> RepairToolMessage: """Attempt to repair the geometry for the given bodies. @@ -738,5 +827,26 @@ def repair_geometry(self, bodies: list["Body"] = None) -> RepairToolMessage: bodies=body_ids ) - message = RepairToolMessage(repair_result_response["success"], [], []) - return message + return self.__build_repair_tool_message(repair_result_response) + + def __build_repair_tool_message(self, response: dict) -> RepairToolMessage: + """Build a repair tool message from the service response. + + Parameters + ---------- + response : dict + The response from the service containing information about the repair operation. + + Returns + ------- + RepairToolMessage + A message containing the success status, created bodies, modified bodies, + number of found problem areas, and number of repaired problem areas. + """ + return RepairToolMessage( + success=response.get("success"), + created_bodies=response.get("created_bodies_monikers", []), + modified_bodies=response.get("modified_bodies_monikers", []), + found=response.get("found", -1), + repaired=response.get("repaired", -1), + ) diff --git a/tests/integration/files/MissingFaces_AngleDistance.scdocx b/tests/integration/files/MissingFaces_AngleDistance.scdocx new file mode 100644 index 0000000000..5ed58d9995 Binary files /dev/null and b/tests/integration/files/MissingFaces_AngleDistance.scdocx differ diff --git a/tests/integration/files/stitch_1200_bodies.dsco b/tests/integration/files/stitch_1200_bodies.dsco new file mode 100644 index 0000000000..08b757fed9 Binary files /dev/null and b/tests/integration/files/stitch_1200_bodies.dsco differ diff --git a/tests/integration/test_repair_tools.py b/tests/integration/test_repair_tools.py index 4317691149..1e063d1549 100644 --- a/tests/integration/test_repair_tools.py +++ b/tests/integration/test_repair_tools.py @@ -23,7 +23,7 @@ from ansys.geometry.core.modeler import Modeler -from .conftest import FILES_DIR, skip_if_core_service +from .conftest import FILES_DIR def test_find_split_edges(modeler: Modeler): @@ -183,8 +183,6 @@ def test_fix_duplicate_face(modeler: Modeler): def test_find_small_faces(modeler: Modeler): """Test to read geometry and find it's small face problem areas.""" - # Skip test on CoreService - skip_if_core_service(modeler, test_find_small_faces.__name__, "repair_tools") design = modeler.open_file(FILES_DIR / "SmallFacesBefore.scdocx") problem_areas = modeler.repair_tools.find_small_faces(design.bodies) assert len(problem_areas) == 4 @@ -192,8 +190,6 @@ def test_find_small_faces(modeler: Modeler): def test_find_small_face_id(modeler: Modeler): """Test whether problem area has the id.""" - # Skip test on CoreService - skip_if_core_service(modeler, test_find_small_face_id.__name__, "repair_tools") design = modeler.open_file(FILES_DIR / "SmallFacesBefore.scdocx") problem_areas = modeler.repair_tools.find_small_faces(design.bodies) assert problem_areas[0].id != "0" @@ -203,9 +199,6 @@ def test_find_small_face_faces(modeler: Modeler): """Test to read geometry, find it's small face problem area and return connected faces. """ - skip_if_core_service( - modeler, test_find_small_face_faces.__name__, "repair_tools" - ) # Skip test on CoreService design = modeler.open_file(FILES_DIR / "SmallFacesBefore.scdocx") problem_areas = modeler.repair_tools.find_small_faces(design.bodies) assert len(problem_areas[0].faces) > 0 @@ -213,10 +206,13 @@ def test_find_small_face_faces(modeler: Modeler): def test_fix_small_face(modeler: Modeler): """Test to read geometry and find and fix it's small face problem areas.""" - # Skip test on CoreService - skip_if_core_service(modeler, test_fix_small_face.__name__, "repair_tools") - design = modeler.open_file(FILES_DIR / "SmallFacesBefore.scdocx") + design = modeler.open_file(FILES_DIR / "SmallFaces.scdocx") + problem_areas = modeler.repair_tools.find_small_faces(design.bodies, 2.84e-8, None) + assert len(problem_areas) == 2 + problem_areas = modeler.repair_tools.find_small_faces(design.bodies, None, 0.00036) + assert len(problem_areas) == 9 problem_areas = modeler.repair_tools.find_small_faces(design.bodies) + assert len(problem_areas) == 4 assert problem_areas[0].fix().success is True @@ -285,6 +281,32 @@ def test_fix_interference(modeler: Modeler): assert result.success is True +def test_find_and_fix_stitch_faces(modeler: Modeler): + """Test to find and fix stitch faces and validate that we get a solid.""" + design = modeler.open_file(FILES_DIR / "stitch_1200_bodies.dsco") + assert len(design.bodies) == 3600 + + stitch_faces = modeler.repair_tools.find_and_fix_stitch_faces(design.bodies) + assert stitch_faces.found == 1 + assert stitch_faces.repaired == 1 + + assert len(design.bodies) == 1200 + + +def test_find_and_fix_stitch_faces_comprehensive(modeler: Modeler): + """Test to find and fix stitch faces and validate that we get a solid.""" + design = modeler.open_file(FILES_DIR / "stitch_1200_bodies.dsco") + assert len(design.bodies) == 3600 + + stitch_faces = modeler.repair_tools.find_and_fix_stitch_faces( + design.bodies, comprehensive_result=True + ) + assert stitch_faces.found == 1200 + assert stitch_faces.repaired == 1200 + + assert len(design.bodies) == 1200 + + def test_find_and_fix_duplicate_faces(modeler: Modeler): """Test to read geometry, find and fix duplicate faces and validate they are removed.""" design = modeler.open_file(FILES_DIR / "DuplicateFaces.scdocx") @@ -368,6 +390,19 @@ def test_find_and_fix_missing_faces(modeler: Modeler): assert not comp.bodies[0].is_surface +def test_find_and_fix_missing_faces_angle_distance(modeler: Modeler): + """Test to read geometry, find and fix missing faces specify angle and distance.""" + design = modeler.open_file(FILES_DIR / "MissingFaces_AngleDistance.scdocx") + assert len(design.bodies) == 1 + assert len(design.bodies[0].faces) == 11 + missing_faces = modeler.repair_tools.find_missing_faces(design.bodies, 0.785398, 0.0005) + assert len(missing_faces) == 4 + for face in missing_faces: + face.fix() + assert len(design.bodies) == 1 + assert len(design.bodies[0].faces) == 15 + + def test_find_and_fix_short_edges_problem_areas(modeler: Modeler): """Test to read geometry, find and fix short edges and validate they are fixed removed.""" design = modeler.open_file(FILES_DIR / "ShortEdges.scdocx")