diff --git a/doc/changelog.d/2396.added.md b/doc/changelog.d/2396.added.md new file mode 100644 index 0000000000..c423c463b6 --- /dev/null +++ b/doc/changelog.d/2396.added.md @@ -0,0 +1 @@ +V1 implementation of design stub diff --git a/src/ansys/geometry/core/_grpc/_services/v1/admin.py b/src/ansys/geometry/core/_grpc/_services/v1/admin.py index 911bda2b50..24223085ad 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/admin.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/admin.py @@ -32,6 +32,11 @@ from .conversions import from_grpc_backend_type_to_backend_type +# Define BackendType if not already imported +class BackendType: + DISCOVERY = "DISCOVERY" + + class GRPCAdminServiceV1(GRPCAdminService): # pragma: no cover """Admin service for gRPC communication with the Geometry server. diff --git a/src/ansys/geometry/core/_grpc/_services/v1/designs.py b/src/ansys/geometry/core/_grpc/_services/v1/designs.py index 3c83edc289..f46f21cf56 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/designs.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/designs.py @@ -21,11 +21,23 @@ # SOFTWARE. """Module containing the designs service implementation for v1.""" +from google.protobuf.empty_pb2 import Empty import grpc from ansys.geometry.core.errors import protect_grpc from ..base.designs import GRPCDesignsService +from .conversions import ( + build_grpc_id, + from_grpc_curve_to_curve, + from_grpc_edge_tess_to_raw_data, + from_grpc_frame_to_frame, + from_grpc_material_to_material, + from_grpc_matrix_to_matrix, + from_grpc_point_to_point3d, + from_grpc_tess_to_raw_data, + from_tess_options_to_grpc_tess_options, +) class GRPCDesignsServiceV1(GRPCDesignsService): # pragma: no cover @@ -43,62 +55,546 @@ class GRPCDesignsServiceV1(GRPCDesignsService): # pragma: no cover @protect_grpc def __init__(self, channel: grpc.Channel): # noqa: D102 - from ansys.api.dbu.v1.designs_pb2_grpc import DesignsStub + from ansys.api.dbu.v0.designs_pb2_grpc import DesignsStub + from ansys.api.discovery.v1.commands.file_pb2_grpc import FileStub + from ansys.api.discovery.v1.design.designdoc_pb2_grpc import DesignDocStub - self.stub = DesignsStub(channel) + self.designdoc_stub = DesignDocStub(channel) + self.file_stub = FileStub(channel) + self.designs_stub = DesignsStub(channel) @protect_grpc def open(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + from ansys.api.dbu.v0.designs_pb2 import OpenRequest + + # Create the request - assumes all inputs are valid and of the proper type + request = OpenRequest( + filepath=kwargs["filepath"], + import_options=kwargs["import_options"].to_dict(), + ) + + # Call the gRPC service + _ = self.designs_stub.Open(request) + + # Return the response - formatted as a dictionary + return {} @protect_grpc def new(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + from ansys.api.dbu.v0.designs_pb2 import NewRequest + + # Create the request - assumes all inputs are valid and of the proper type + request = NewRequest(name=kwargs["name"]) + + # Call the gRPC service + response = self.designs_stub.New(request) + + # Return the response - formatted as a dictionary + return { + "design_id": response.id, + "main_part_id": response.main_part.id, + } @protect_grpc def get_assembly(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + from ansys.api.discovery.v1.design.designdoc_pb2 import GetAssemblyRequest + + # Return the information needed to fill a design. + active_design = kwargs["active_design"] + design_id = active_design.get("design_id") + + # Create the request - assumes all inputs are valid and of the proper type + request = GetAssemblyRequest(id=build_grpc_id(id=design_id)) + + # Call the gRPC service + response = self.designdoc_stub.GetAssembly(request) + + # Return the response - formatted as a dictionary + serialized_response = self._serialize_assembly_response(response) + return serialized_response @protect_grpc def close(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + # Create the request - assumes all inputs are valid and of the proper type + request = build_grpc_id(id=kwargs["design_id"]) + + # Call the gRPC service + _ = self.designs_stub.Close(request) + + # Return the response - formatted as a dictionary + return {} @protect_grpc def put_active(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + # Create the request - assumes all inputs are valid and of the proper type + request = build_grpc_id(id=kwargs["design_id"]) + + # Call the gRPC service + _ = self.designs_stub.PutActive(request) + + # Return the response - formatted as a dictionary + return {} @protect_grpc def save_as(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + from ansys.api.dbu.v0.designs_pb2 import SaveAsRequest + + # Create the request - assumes all inputs are valid and of the proper type + request = SaveAsRequest( + filepath=kwargs["filepath"], write_body_facets=kwargs.get("write_body_facets", False) + ) + + # Call the gRPC service + _ = self.designs_stub.SaveAs(request) + + # Return the response - formatted as a dictionary + return {} @protect_grpc def download_export(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + from ansys.api.dbu.v0.designs_pb2 import DownloadExportFileRequest + + # Import file format conversion - need to add this to conversions.py + from ..v0.conversions import from_design_file_format_to_grpc_part_export_format + + # Create the request - assumes all inputs are valid and of the proper type + request = DownloadExportFileRequest( + format=from_design_file_format_to_grpc_part_export_format(kwargs["format"]), + write_body_facets=kwargs.get("write_body_facets", False), + ) + + # Call the gRPC service + response = self.designs_stub.DownloadExportFile(request) + + # Return the response - formatted as a dictionary + data = bytes() + data += response.data + return {"data": data} @protect_grpc def stream_download_export(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + from ansys.api.dbu.v0.designs_pb2 import DownloadExportFileRequest + + # Import file format conversion - need to add this to conversions.py + from ..v0.conversions import from_design_file_format_to_grpc_part_export_format + + # Create the request - assumes all inputs are valid and of the proper type + request = DownloadExportFileRequest( + format=from_design_file_format_to_grpc_part_export_format(kwargs["format"]), + write_body_facets=kwargs.get("write_body_facets", False), + ) + + # Call the gRPC service + response = self.designs_stub.StreamDownloadExportFile(request) + + # Return the response - formatted as a dictionary + data = bytes() + for elem in response: + data += elem.data + + return {"data": data} @protect_grpc def insert(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + from ansys.api.dbu.v0.designs_pb2 import InsertRequest + + # Create the request - assumes all inputs are valid and of the proper type + request = InsertRequest( + filepath=kwargs["filepath"], + import_named_selections=kwargs.get("import_named_selections", False), + ) + + # Call the gRPC service + _ = self.designs_stub.Insert(request) + + # Return the response - formatted as a dictionary + return {} @protect_grpc def get_active(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + # Call the gRPC service + response = self.designs_stub.GetActive(request=Empty()) + + # Return the response - formatted as a dictionary + if response: + return { + "design_id": response.id, + "main_part_id": response.main_part.id, + "name": response.name, + } @protect_grpc def upload_file(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + from pathlib import Path + from typing import TYPE_CHECKING, Generator + + from ansys.api.discovery.v1.commands.file_pb2 import OpenMode, OpenRequest + from ansys.api.discovery.v1.commonenums_pb2 import FileFormat + + import ansys.geometry.core.connection.defaults as pygeom_defaults + + if TYPE_CHECKING: # pragma: no cover + from ansys.geometry.core.misc.options import ImportOptions + + # ---- 1) Extract and log kwargs ---- + raw_file_path = kwargs.get("file_path") + open_file = kwargs.get("open_file", False) + import_options = kwargs.get("import_options") # may be None + + print("[upload_file] raw_file_path =", raw_file_path) + print("[upload_file] open_file =", open_file) + print("[upload_file] import_opts =", type(import_options)) + + if raw_file_path is None: + raise ValueError("[upload_file] 'file_path' kwarg is required") + + file_path = Path(raw_file_path) + + # ---- 2) Validate file existence & readability ---- + if not file_path.exists(): + raise FileNotFoundError(f"[upload_file] File does not exist: {file_path!r}") + if not file_path.is_file(): + raise ValueError(f"[upload_file] Path is not a file: {file_path!r}") + + try: + size = file_path.stat().st_size + except Exception as e: + print("[upload_file] Error stating file:", e) + raise + + print(f"[upload_file] File exists, size = {size} bytes") + + # ---- 3) Safe helper for import_options.to_dict() ---- + def import_options_to_dict(import_opts: "ImportOptions | None"): + if import_opts is None: + print("[upload_file] import_options is None → using empty dict") + return {} + try: + d = import_opts.to_dict() + print("[upload_file] import_options.to_dict() succeeded") + return d + except Exception as e: + print("[upload_file] ERROR in import_options.to_dict():", repr(e)) + import traceback + + traceback.print_exc() + # Re-raise so we see the real cause instead of UNKNOWN / iterating requests + raise + + # Precompute once so we don't repeat this in each chunk + import_options_dict = import_options_to_dict(import_options) + + # ---- 4) Request generator with strong logging & exception surfacing ---- + def request_generator( + file_path: Path, open_file: bool, import_options_dict: dict + ) -> Generator[OpenRequest, None, None]: + """Generate requests for streaming file upload.""" + import traceback + + msg_buffer = 5 * 1024 # 5KB - for additional message data + if pygeom_defaults.MAX_MESSAGE_LENGTH - msg_buffer < 0: # pragma: no cover + raise ValueError("[upload_file] MAX_MESSAGE_LENGTH is too small for file upload") + + chunk_size = pygeom_defaults.MAX_MESSAGE_LENGTH - msg_buffer + print(f"[upload_file] Using chunk_size = {chunk_size} bytes") + + try: + with file_path.open("rb") as file: + chunk_index = 0 + while True: + chunk = file.read(chunk_size) + if not chunk: + print("[upload_file] No more data to read, stopping generator") + break + + print(f"[upload_file] Yielding chunk {chunk_index}, len={len(chunk)}") + + yield OpenRequest( + data=chunk, + open_mode=OpenMode.OPENMODE_NEW, + file_format=FileFormat.FILEFORMAT_DISCO, + import_options=import_options_dict, + ) + chunk_index += 1 + except Exception: + print("[upload_file] EXCEPTION inside request_generator:") + traceback.print_exc() + # Re-raise so gRPC wraps it, but you still see the root traceback + raise + + # ---- 5) Call the gRPC service, with extra logging ---- + gen = request_generator( + file_path=file_path, + open_file=open_file, + import_options_dict=import_options_dict, + ) + + from grpc import RpcError + + try: + print("[upload_file] Calling file_stub.Open(...)") + response = self.file_stub.Open(gen) + print("[upload_file] file_stub.Open(...) returned successfully") + except RpcError as rpc_exc: + # This is what you currently see as UNKNOWN / Exception iterating requests + print("[upload_file] RpcError caught in upload_file:") + print(" code :", rpc_exc.code()) + print(" details:", rpc_exc.details()) + import traceback + + traceback.print_exc() + # Re-raise so the higher-level wrapper (protect_grpc) can do its thing + raise + + # ---- 6) Return the response - formatted as a dictionary ---- + return {"file_path": response.design.path} @protect_grpc def upload_file_stream(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + from ansys.api.discovery.v1.commands.file_pb2 import UploadFileRequest + + # Create the request - assumes all inputs are valid and of the proper type + request = UploadFileRequest( + data=kwargs["data"], + file_name=kwargs["file_name"], + open=kwargs["open_file"], + import_options=kwargs["import_options"].to_dict(), + ) + + # Call the gRPC service + response = self.commands_stub.UploadFile(request) + + # Return the response - formatted as a dictionary + return {"file_path": response.file_path} @protect_grpc def stream_design_tessellation(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + from ansys.api.discovery.v1.design.designdoc_pb2 import DesignTessellationRequest + + # If there are options, convert to gRPC options + options = ( + from_tess_options_to_grpc_tess_options(kwargs["options"]) + if kwargs["options"] is not None + else None + ) + + # Create the request - assumes all inputs are valid and of the proper type + request = DesignTessellationRequest( + options=options, + include_faces=kwargs.get("include_faces", True), + include_edges=kwargs.get("include_edges", True), + ) + + # Call the gRPC service + response = self.designdoc_stub.StreamDesignTessellation(request) + + # Return the response - formatted as a dictionary + tess_map = {} + for elem in response: + for body_id, body_tess in elem.body_tessellation.items(): + tess = {} + for face_id, face_tess in body_tess.face_tessellation.items(): + tess[face_id] = from_grpc_tess_to_raw_data(face_tess) + for edge_id, edge_tess in body_tess.edge_tessellation.items(): + tess[edge_id] = from_grpc_edge_tess_to_raw_data(edge_tess) + tess_map[body_id] = tess + + return { + "tessellation": tess_map, + } @protect_grpc def download_file(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + # TODO: find correct v1 stub for download_file (may be FileStub or Commands) + # https://github.com/ansys/pyansys-geometry/issues/2396 + # For now, raise NotImplementedError until the correct service is identified + raise NotImplementedError("download_file not yet implemented for v1") + + def _serialize_assembly_response(self, response): + """Serialize the GetAssembly response from gRPC format to dictionary format. + + This method converts the v1 proto GetAssemblyResponse message into a dictionary + structure that matches the expected format used throughout the codebase. + Note that v1 uses EntityIdentifier objects, so we need to access the .id field. + """ + + def serialize_body(body): + return { + "id": body.id.id, + "name": body.name, + "master_id": body.master_id.id, + "parent_id": body.parent_id.id, + "is_surface": body.is_surface, + } + + def serialize_component(component): + return { + "id": component.id.id, + "parent_id": component.parent_id.id, + "master_id": component.master_id.id, + "name": component.name, + "placement": component.placement, + "part_master": serialize_part(component.part_master), + } + + def serialize_transformed_part(transformed_part): + return { + "id": transformed_part.id.id, + "name": transformed_part.name, + "placement": from_grpc_matrix_to_matrix(transformed_part.placement), + "part_master": serialize_part(transformed_part.part_master), + } + + def serialize_part(part): + return { + "id": part.id.id, + "name": part.name, + } + + def serialize_material_properties(material_property): + return { + "id": material_property.id, + "display_name": material_property.display_name, + "value": material_property.value, + "units": material_property.units, + } + + def serialize_material(material): + material_properties = getattr(material, "material_properties", []) + return { + "name": material.name, + "material_properties": [ + serialize_material_properties(property) for property in material_properties + ], + } + + def serialize_named_selection(named_selection): + return {"id": named_selection.id.id, "name": named_selection.name} + + def serialize_coordinate_systems(coordinate_systems): + serialized_cs = [] + for cs in coordinate_systems.coordinate_systems: + serialized_cs.append( + { + "id": cs.id.id, + "name": cs.name, + "frame": from_grpc_frame_to_frame(cs.frame), + } + ) + + return serialized_cs + + def serialize_component_coordinate_systems(component_coordinate_system): + serialized_component_coordinate_systems = [] + for ( + component_coordinate_system_id, + coordinate_systems, + ) in component_coordinate_system.items(): + serialized_component_coordinate_systems.append( + { + "component_id": component_coordinate_system_id, + "coordinate_systems": serialize_coordinate_systems(coordinate_systems), + } + ) + + return serialized_component_coordinate_systems + + def serialize_component_shared_topologies(component_share_topology): + serialized_share_topology = [] + for component_shared_topology_id, shared_topology in component_share_topology.items(): + serialized_share_topology.append( + { + "component_id": component_shared_topology_id, + "shared_topology_type": shared_topology, + } + ) + return serialized_share_topology + + def serialize_beam_curve(curve): + return { + "curve": from_grpc_curve_to_curve(curve.curve), + "start": from_grpc_point_to_point3d(curve.start), + "end": from_grpc_point_to_point3d(curve.end), + "interval_start": curve.interval_start, + "interval_end": curve.interval_end, + "length": curve.length, + } + + def serialize_beam_curve_list(curve_list): + return {"curves": [serialize_beam_curve(curve) for curve in curve_list.curves]} + + def serialize_beam_cross_section(cross_section): + return { + "section_anchor": cross_section.section_anchor, + "section_angle": cross_section.section_angle, + "section_frame": from_grpc_frame_to_frame(cross_section.section_frame), + "section_profile": [ + serialize_beam_curve_list(curve_list) + for curve_list in cross_section.section_profile + ], + } + + def serialize_beam_properties(properties): + return { + "area": properties.area, + "centroid_x": properties.centroid_x, + "centroid_y": properties.centroid_y, + "warping_constant": properties.warping_constant, + "ixx": properties.ixx, + "ixy": properties.ixy, + "iyy": properties.iyy, + "shear_center_x": properties.shear_center_x, + "shear_center_y": properties.shear_center_y, + "torsional_constant": properties.torsional_constant, + } + + def serialize_beam(beam): + return { + "id": beam.id.id, + "parent_id": beam.parent.id, + "start": from_grpc_point_to_point3d(beam.shape.start), + "end": from_grpc_point_to_point3d(beam.shape.end), + "name": beam.name, + "is_deleted": beam.is_deleted, + "is_reversed": beam.is_reversed, + "is_rigid": beam.is_rigid, + "material": from_grpc_material_to_material(beam.material), + "type": beam.type, + "properties": serialize_beam_properties(beam.properties), + "cross_section": serialize_beam_cross_section(beam.cross_section), + } + + def serialize_design_point(design_point): + return { + "id": design_point.id.id, + "name": design_point.owner_name, + "point": from_grpc_point_to_point3d(design_point.points[0]), + "parent_id": design_point.parent_id.id, + } + + parts = getattr(response, "parts", []) + transformed_parts = getattr(response, "transformed_parts", []) + bodies = getattr(response, "bodies", []) + components = getattr(response, "components", []) + materials = getattr(response, "materials", []) + named_selections = getattr(response, "named_selections", []) + component_coordinate_systems = getattr(response, "component_coord_systems", []) + component_shared_topologies = getattr(response, "component_shared_topologies", []) + beams = getattr(response, "beams", []) + design_points = getattr(response, "design_points", []) + return { + "parts": [serialize_part(part) for part in parts] if len(parts) > 0 else [], + "transformed_parts": [serialize_transformed_part(tp) for tp in transformed_parts], + "bodies": [serialize_body(body) for body in bodies] if len(bodies) > 0 else [], + "components": [serialize_component(component) for component in components], + "materials": [serialize_material(material) for material in materials], + "named_selections": [serialize_named_selection(ns) for ns in named_selections], + "component_coordinate_systems": serialize_component_coordinate_systems( + component_coordinate_systems + ), + "component_shared_topologies": serialize_component_shared_topologies( + component_shared_topologies + ), + "beams": [serialize_beam(beam) for beam in beams], + "design_points": [serialize_design_point(dp) for dp in design_points], + } diff --git a/src/ansys/geometry/core/modeler.py b/src/ansys/geometry/core/modeler.py index a604935708..5853a8e535 100644 --- a/src/ansys/geometry/core/modeler.py +++ b/src/ansys/geometry/core/modeler.py @@ -297,6 +297,7 @@ def _upload_file( response = self.client.services.designs.upload_file( data=data, + file_path=str(fp_path), file_name=file_name, open_file=open_file, import_options=import_options,