diff --git a/doc/changelog.d/2443.maintenance.md b/doc/changelog.d/2443.maintenance.md new file mode 100644 index 0000000000..c07e008acb --- /dev/null +++ b/doc/changelog.d/2443.maintenance.md @@ -0,0 +1 @@ +Chore: v1 implementation of file/designs stub diff --git a/src/ansys/geometry/core/_grpc/_services/v1/bodies.py b/src/ansys/geometry/core/_grpc/_services/v1/bodies.py index e00b65d1f5..973462902e 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/bodies.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/bodies.py @@ -31,6 +31,8 @@ from .conversions import ( build_grpc_id, from_frame_to_grpc_frame, + from_grpc_edge_tess_to_pd, + from_grpc_edge_tess_to_raw_data, from_grpc_point_to_point3d, from_grpc_tess_to_pd, from_grpc_tess_to_raw_data, @@ -1359,4 +1361,19 @@ def get_full_tessellation(self, **kwargs): # noqa: D102 resp = self.stub.GetTessellationStream(request=request) # Return the response - formatted as a dictionary - return {"tessellation": from_grpc_tess_to_pd(resp)} + tess_map = {} + for elem in resp: + for face_id, face_tess in elem.response_data[0].face_tessellation.items(): + tess_map[face_id] = ( + from_grpc_tess_to_raw_data(face_tess) + if kwargs["raw_data"] + else from_grpc_tess_to_pd(face_tess) + ) + for edge_id, edge_tess in elem.response_data[0].edge_tessellation.items(): + tess_map[edge_id] = ( + from_grpc_edge_tess_to_raw_data(edge_tess) + if kwargs["raw_data"] + else from_grpc_edge_tess_to_pd(edge_tess) + ) + + return {"tessellation": tess_map} diff --git a/src/ansys/geometry/core/_grpc/_services/v1/commands_script.py b/src/ansys/geometry/core/_grpc/_services/v1/commands_script.py index 1e482cbeb6..c060fd79c6 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/commands_script.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/commands_script.py @@ -43,13 +43,13 @@ class GRPCCommandsScriptServiceV1(GRPCCommandsScriptService): # pragma: no cove @protect_grpc def __init__(self, channel: grpc.Channel): # noqa: D102 - from ansys.api.discovery.v1.commands.script_pb2 import ScriptStub + from ansys.api.discovery.v1.commands.script_pb2_grpc import ScriptStub self.stub = ScriptStub(channel) @protect_grpc def run_script_file(self, **kwargs) -> dict: # noqa: D102 - from aansys.api.discovery.v1.commands.script_pb2 import RunScriptFileRequest + from ansys.api.discovery.v1.commands.script_pb2 import RunScriptFileRequest # Create the request - assumes all inputs are valid and of the proper type request = RunScriptFileRequest( @@ -63,7 +63,7 @@ def run_script_file(self, **kwargs) -> dict: # noqa: D102 # Return the response - formatted as a dictionary return { - "success": response.success, - "message": response.message, + "success": response.command_response.success, + "message": response.command_response.message, "values": None if not response.values else dict(response.values), } diff --git a/src/ansys/geometry/core/_grpc/_services/v1/components.py b/src/ansys/geometry/core/_grpc/_services/v1/components.py index 7592ce0a8b..b01f4a8476 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/components.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/components.py @@ -26,9 +26,9 @@ from ansys.geometry.core.errors import protect_grpc from ..base.components import GRPCComponentsService -from ..base.conversions import from_measurement_to_server_angle from .conversions import ( build_grpc_id, + from_angle_to_grpc_quantity, from_grpc_matrix_to_matrix, from_point3d_to_grpc_point, from_unit_vector_to_grpc_direction, @@ -80,7 +80,7 @@ def create(self, **kwargs) -> dict: # noqa: D102 # Note: response.components is a repeated field, we return the first one component = response.components[0] return { - "id": component.id, + "id": component.id.id, "name": component.name, "instance_name": component.instance_name, "template": kwargs["template_id"], # template_id from input @@ -132,7 +132,7 @@ def set_placement(self, **kwargs) -> dict: # noqa: D102 translation=translation, rotation_axis_origin=origin, rotation_axis_direction=direction, - rotation_angle=from_measurement_to_server_angle(kwargs["rotation_angle"]), + rotation_angle=from_angle_to_grpc_quantity(kwargs["rotation_angle"]), ) ], ) @@ -143,7 +143,7 @@ def set_placement(self, **kwargs) -> dict: # noqa: D102 # Return the response - formatted as a dictionary # Note: response.matrices is a map # Get the matrix for our component ID - matrix_value = response.matrices.get(kwargs["id"].id) + matrix_value = response.matrices.get(kwargs["id"]) return {"matrix": from_grpc_matrix_to_matrix(matrix_value) if matrix_value else None} @protect_grpc diff --git a/src/ansys/geometry/core/_grpc/_services/v1/conversions.py b/src/ansys/geometry/core/_grpc/_services/v1/conversions.py index da135b6822..3dcf779609 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/conversions.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/conversions.py @@ -23,6 +23,9 @@ from typing import TYPE_CHECKING +from ansys.api.discovery.v1.commands.file_pb2 import ( + ImportOptionDefinition as GRPCImportOptionDefinition, +) from ansys.api.discovery.v1.commonenums_pb2 import ( BackendType as GRPCBackendType, FileFormat as GRPCFileFormat, @@ -42,6 +45,7 @@ ) from ansys.api.discovery.v1.design.designmessages_pb2 import ( CurveGeometry as GRPCCurveGeometry, + DatumPointEntity as GRPCDesignPoint, DrivingDimensionEntity as GRPCDrivingDimension, EdgeTessellation as GRPCEdgeTessellation, EnhancedRepairToolMessage as GRPCEnhancedRepairToolResponse, @@ -90,7 +94,7 @@ from ansys.geometry.core.math.point import Point2D, Point3D from ansys.geometry.core.math.vector import UnitVector3D from ansys.geometry.core.misc.measurements import Measurement - from ansys.geometry.core.misc.options import TessellationOptions + from ansys.geometry.core.misc.options import ImportOptionsDefinitions, TessellationOptions from ansys.geometry.core.parameters.parameter import ( Parameter, ParameterUpdateStatus, @@ -237,6 +241,24 @@ def from_point2d_to_grpc_point(plane: "Plane", point2d: "Point2D") -> GRPCPoint: ) +def from_point3d_to_grpc_design_point(point: "Point3D") -> GRPCDesignPoint: + """Convert a ``Point3D`` class to a design point gRPC message. + + Parameters + ---------- + point : Point3D + Source point data. + + Returns + ------- + GRPCDesignPoint + Geometry service gRPC design point message. The unit is meters. + """ + return GRPCDesignPoint( + position=from_point3d_to_grpc_point(point), + ) + + def from_unit_vector_to_grpc_direction(unit_vector: "UnitVector3D") -> GRPCDirection: """Convert a ``UnitVector3D`` class to a unit vector gRPC message. @@ -376,9 +398,9 @@ def from_grpc_frame_to_frame(frame: GRPCFrame) -> "Frame": return Frame( Point3D( input=[ - frame.origin.x, - frame.origin.y, - frame.origin.z, + frame.origin.x.value_in_geometry_units, + frame.origin.y.value_in_geometry_units, + frame.origin.z.value_in_geometry_units, ], unit=DEFAULT_UNITS.SERVER_LENGTH, ), @@ -1271,6 +1293,45 @@ def from_grpc_update_status_to_parameter_update_status( return status_mapping.get(update_status, ParameterUpdateStatus.UNKNOWN) +def from_design_file_format_to_grpc_file_export_format( + design_file_format: "DesignFileFormat", +) -> GRPCFileFormat: + """Convert from a DesignFileFormat object to a gRPC FileExportFormat one. + + Parameters + ---------- + design_file_format : DesignFileFormat + The file format desired + + Returns + ------- + GRPCFileExportFormat + Converted gRPC File format + """ + from ansys.geometry.core.designer.design import DesignFileFormat + + if design_file_format == DesignFileFormat.SCDOCX: + return GRPCFileFormat.FILEFORMAT_SCDOCX + elif design_file_format == DesignFileFormat.PARASOLID_TEXT: + return GRPCFileFormat.FILEFORMAT_PARASOLID_TEXT + elif design_file_format == DesignFileFormat.PARASOLID_BIN: + return GRPCFileFormat.FILEFORMAT_PARASOLID_BINARY + elif design_file_format == DesignFileFormat.FMD: + return GRPCFileFormat.FILEFORMAT_FMD + elif design_file_format == DesignFileFormat.STEP: + return GRPCFileFormat.FILEFORMAT_STEP + elif design_file_format == DesignFileFormat.IGES: + return GRPCFileFormat.FILEFORMAT_IGES + elif design_file_format == DesignFileFormat.PMDB: + return GRPCFileFormat.FILEFORMAT_PMDB + elif design_file_format == DesignFileFormat.STRIDE: + return GRPCFileFormat.FILEFORMAT_STRIDE + elif design_file_format == DesignFileFormat.DISCO: + return GRPCFileFormat.FILEFORMAT_DISCO + else: + return None + + def from_material_to_grpc_material( material: "Material", ) -> GRPCMaterial: @@ -1447,6 +1508,28 @@ def from_parameter_to_grpc_quantity(value: float) -> GRPCQuantity: return GRPCQuantity(value_in_geometry_units=value) +def from_import_options_definitions_to_grpc_import_options_definition( + import_options_definitions: "ImportOptionsDefinitions", +) -> GRPCImportOptionDefinition: + """Convert an ``ImportOptionsDefinitions`` to import options definition gRPC message. + + Parameters + ---------- + import_options_definitions : ImportOptionsDefinitions + Definition of the import options. + + Returns + ------- + GRPCImportOptionDefinition + Geometry service gRPC import options definition message. + """ + definitions = {} + for key, definition in import_options_definitions.to_dict().items(): + definitions[key] = GRPCImportOptionDefinition(string_option=str(definition)) + + return definitions + + def _nurbs_curves_compatibility(backend_version: "semver.Version", grpc_geometries: GRPCGeometries): """Check if the backend version is compatible with NURBS curves in sketches. @@ -1514,45 +1597,6 @@ def from_enclosure_options_to_grpc_enclosure_options( ) -def from_design_file_format_to_grpc_file_format( - design_file_format: "DesignFileFormat", -) -> GRPCFileFormat: - """Convert from a ``DesignFileFormat`` object to a gRPC file format. - - Parameters - ---------- - design_file_format : DesignFileFormat - The file format desired - - Returns - ------- - GRPCFileFormat - Converted gRPC FileFormat. - """ - from ansys.geometry.core.designer.design import DesignFileFormat - - if design_file_format == DesignFileFormat.SCDOCX: - return GRPCFileFormat.FILEFORMAT_SCDOCX - elif design_file_format == DesignFileFormat.PARASOLID_TEXT: - return GRPCFileFormat.FILEFORMAT_PARASOLID_TEXT - elif design_file_format == DesignFileFormat.PARASOLID_BIN: - return GRPCFileFormat.FILEFORMAT_PARASOLID_BINARY - elif design_file_format == DesignFileFormat.FMD: - return GRPCFileFormat.FILEFORMAT_FMD - elif design_file_format == DesignFileFormat.STEP: - return GRPCFileFormat.FILEFORMAT_STEP - elif design_file_format == DesignFileFormat.IGES: - return GRPCFileFormat.FILEFORMAT_IGES - elif design_file_format == DesignFileFormat.PMDB: - return GRPCFileFormat.FILEFORMAT_PMDB - elif design_file_format == DesignFileFormat.STRIDE: - return GRPCFileFormat.FILEFORMAT_STRIDE - elif design_file_format == DesignFileFormat.DISCO: - return GRPCFileFormat.FILEFORMAT_DISCO - else: - return None - - def serialize_tracked_command_response(response: GRPCTrackedCommandResponse) -> dict: """Serialize a TrackedCommandResponse object into a dictionary. diff --git a/src/ansys/geometry/core/_grpc/_services/v1/designs.py b/src/ansys/geometry/core/_grpc/_services/v1/designs.py index 3c83edc289..0fb9c5c9c6 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/designs.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/designs.py @@ -26,6 +26,14 @@ 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_frame_to_frame, + from_grpc_material_to_material, + from_grpc_matrix_to_matrix, + from_grpc_point_to_point3d, +) class GRPCDesignsServiceV1(GRPCDesignsService): # pragma: no cover @@ -43,25 +51,115 @@ 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.discovery.v1.commands.file_pb2_grpc import FileStub + from ansys.api.discovery.v1.design.designdoc_pb2_grpc import DesignDocStub - self.stub = DesignsStub(channel) + self.file_stub = FileStub(channel) + self.designdoc_stub = DesignDocStub(channel) @protect_grpc def open(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 + + import ansys.geometry.core.connection.defaults as pygeom_defaults + + from .conversions import from_import_options_definitions_to_grpc_import_options_definition + + if TYPE_CHECKING: # pragma: no cover + from ansys.geometry.core.misc.options import ImportOptions, ImportOptionsDefinitions + + def request_generator( + file_path: Path, + file_name: str, + import_options: "ImportOptions", + import_options_definitions: "ImportOptionsDefinitions", + open_mode: OpenMode, + ) -> Generator[OpenRequest, None, None]: + """Generate requests for streaming file upload.""" + msg_buffer = 5 * 1024 # 5KB - for additional message data + if pygeom_defaults.MAX_MESSAGE_LENGTH - msg_buffer < 0: # pragma: no cover + raise ValueError("MAX_MESSAGE_LENGTH is too small for file upload.") + + chunk_size = pygeom_defaults.MAX_MESSAGE_LENGTH - msg_buffer + with Path.open(file_path, "rb") as file: + while chunk := file.read(chunk_size): + test_req = OpenRequest( + data=chunk, + file_name=file_name, + open_mode=open_mode, + import_options=import_options.to_dict(), + import_options_definitions=from_import_options_definitions_to_grpc_import_options_definition( + import_options_definitions + ), + ) + yield test_req + + # Get the open mode + open_mode = kwargs["open_mode"] + if open_mode == "new": + open_mode = OpenMode.OPENMODE_NEW + elif open_mode == "insert": + open_mode = OpenMode.OPENMODE_INSERT + + # Call the gRPC service + response = self.file_stub.Open( + request_generator( + file_path=kwargs["filepath"], + file_name=kwargs["original_file_name"], + import_options=kwargs["import_options"], + import_options_definitions=kwargs["import_options_definitions"], + open_mode=open_mode, + ) + ) + + # Return the response - formatted as a dictionary + return {"file_path": response.design.path} @protect_grpc def new(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + from ansys.api.discovery.v1.commands.file_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.file_stub.New(request) + + # Return the response - formatted as a dictionary + return { + "design_id": response.design.id.id, + "main_part_id": response.design.main_part_id.id, + } @protect_grpc def get_assembly(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + from ansys.api.discovery.v1.design.designdoc_pb2 import GetAssemblyRequest + + # Create the request - assumes all inputs are valid and of the proper type + request = GetAssemblyRequest(id=build_grpc_id(kwargs["active_design"].get("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 + from ansys.api.discovery.v1.commands.file_pb2 import CloseRequest + + # Create the request - assumes all inputs are valid and of the proper type + request = CloseRequest(design_id=build_grpc_id(kwargs["design_id"])) + + # Call the gRPC service + _ = self.file_stub.Close(request) + + # Return the response - formatted as a dictionary + return {} @protect_grpc def put_active(self, **kwargs) -> dict: # noqa: D102 @@ -69,23 +167,60 @@ def put_active(self, **kwargs) -> dict: # noqa: D102 @protect_grpc def save_as(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + from ansys.api.discovery.v1.commands.file_pb2 import SaveRequest + + from .conversions import ( + _check_write_body_facets_input, + from_design_file_format_to_grpc_file_export_format, + ) + + _check_write_body_facets_input(kwargs["backend_version"], kwargs["write_body_facets"]) + + # Create the request - assumes all inputs are valid and of the proper type + request = SaveRequest( + format=from_design_file_format_to_grpc_file_export_format(kwargs["format"]), + write_body_facets=kwargs["write_body_facets"], + ) + + # Call the gRPC service + response_stream = self.file_stub.Save(request) + + # Return the response - formatted as a dictionary + data = bytes() + for response in response_stream: + data += response.data + + return { + "data": data, + } @protect_grpc def download_export(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + return self.save_as(**kwargs) @protect_grpc def stream_download_export(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + return self.save_as(**kwargs) @protect_grpc def insert(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + # Route to open method + return self.open(**kwargs, open_mode="insert") @protect_grpc def get_active(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + from ansys.api.discovery.v1.commonmessages_pb2 import EntityRequest + + # Call the gRPC service + response = self.designdoc_stub.Get(request=EntityRequest(id=build_grpc_id(""))) + + # Return the response - formatted as a dictionary + if response.design: + return { + "design_id": response.design.id.id, + "main_part_id": response.design.main_part_id.id, + "name": response.design.name, + } @protect_grpc def upload_file(self, **kwargs) -> dict: # noqa: D102 @@ -97,8 +232,228 @@ def upload_file_stream(self, **kwargs) -> dict: # noqa: D102 @protect_grpc def stream_design_tessellation(self, **kwargs) -> dict: # noqa: D102 - raise NotImplementedError + from ansys.api.discovery.v1.design.designdoc_pb2 import DesignTessellationRequest + + from .conversions import ( + from_grpc_edge_tess_to_raw_data, + from_grpc_tess_to_raw_data, + from_tess_options_to_grpc_tess_options, + ) + + # 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["include_faces"], + include_edges=kwargs["include_edges"], + ) + + # Call the gRPC service + response_stream = self.designdoc_stub.StreamDesignTessellation(request) + + # Return the response - formatted as a dictionary + tess_map = {} + for elem in response_stream: + 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 + return self.save_as(**kwargs) + + def _serialize_assembly_response(self, response): + 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.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.value_in_geometry_units, + "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.value_in_geometry_units, + "centroid_x": properties.centroid_x.value_in_geometry_units, + "centroid_y": properties.centroid_y.value_in_geometry_units, + "warping_constant": properties.warping_constant.value_in_geometry_units, + "ixx": properties.ixx.value_in_geometry_units, + "ixy": properties.ixy.value_in_geometry_units, + "iyy": properties.iyy.value_in_geometry_units, + "shear_center_x": properties.shear_center_x.value_in_geometry_units, + "shear_center_y": properties.shear_center_y.value_in_geometry_units, + "torsional_constant": properties.torsional_constant.value_in_geometry_units, + } + + 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/_grpc/_services/v1/materials.py b/src/ansys/geometry/core/_grpc/_services/v1/materials.py index 9078d9edb6..3e10d459c2 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/materials.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/materials.py @@ -44,17 +44,17 @@ class GRPCMaterialsServiceV1(GRPCMaterialsService): @protect_grpc def __init__(self, channel: grpc.Channel): # noqa: D102 - from ansys.api.discovery.v1.design.data.cadmaterial_pb2_grpc import MaterialsStub + from ansys.api.discovery.v1.design.data.cadmaterial_pb2_grpc import CADMaterialStub - self.stub = MaterialsStub(channel) + self.stub = CADMaterialStub(channel) @protect_grpc def add_material(self, **kwargs) -> dict: # noqa: D102 - from ansys.api.discovery.v1.design.data.cadmaterial_pb2_grpc import CreateRequest + from ansys.api.discovery.v1.design.data.cadmaterial_pb2 import CreateRequest # Create the request - assumes all inputs are valid and of the proper type request = CreateRequest( - request_data=from_material_to_grpc_material(kwargs["material"]), + request_data=[from_material_to_grpc_material(kwargs["material"])], ) # Call the gRPC service @@ -65,7 +65,7 @@ def add_material(self, **kwargs) -> dict: # noqa: D102 @protect_grpc def remove_material(self, **kwargs) -> dict: # noqa: D102 - from ansys.api.discovery.v1.design.data.cadmaterial_pb2_grpc import DeleteRequest + from ansys.api.discovery.v1.design.data.cadmaterial_pb2 import DeleteRequest # Create the request - assumes all inputs are valid and of the proper type request = DeleteRequest( diff --git a/src/ansys/geometry/core/_grpc/_services/v1/points.py b/src/ansys/geometry/core/_grpc/_services/v1/points.py index 0ef2a863b7..0ce1e0a927 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/points.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/points.py @@ -26,6 +26,7 @@ from ansys.geometry.core.errors import protect_grpc from ..base.points import GRPCPointsService +from .conversions import build_grpc_id, from_point3d_to_grpc_design_point class GRPCPointsServiceV1(GRPCPointsService): # pragma: no cover @@ -49,19 +50,17 @@ def __init__(self, channel: grpc.Channel): # noqa: D102 @protect_grpc def create_design_points(self, **kwargs) -> dict: # noqa: D102 - from ansys.api.discovery.v1.design.constructs.datumpoint_pb2_grpc import ( + from ansys.api.discovery.v1.design.constructs.datumpoint_pb2 import ( DatumPointCreationRequest, DatumPointCreationRequestData, ) - from .conversions import from_point3d_to_grpc_point - # Create the request - assumes all inputs are valid and of the proper type request = DatumPointCreationRequest( - requestData=[ + request_data=[ DatumPointCreationRequestData( - points=[from_point3d_to_grpc_point(point) for point in kwargs["points"]], - parent=kwargs["parent_id"], + points=[from_point3d_to_grpc_design_point(point) for point in kwargs["points"]], + parent_id=build_grpc_id(kwargs["parent_id"]), ) ] ) diff --git a/src/ansys/geometry/core/_grpc/_services/v1/unsupported.py b/src/ansys/geometry/core/_grpc/_services/v1/unsupported.py index d96f968a57..6d6afe90db 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/unsupported.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/unsupported.py @@ -21,7 +21,7 @@ # SOFTWARE. """Module containing the unsupported service implementation for v1.""" -from ansys.api.discovery.v1.commands.unsupported_pb2 import SetExportIdData +from ansys.api.discovery.v1.commands.unsupported_pb2 import SetExportIdData, SetExportIdRequest import grpc from ansys.geometry.core.errors import protect_grpc @@ -62,13 +62,11 @@ def get_import_id_map(self, **kwargs) -> dict: # noqa: D102 @protect_grpc def set_export_ids(self, **kwargs) -> dict: # noqa: D102 - from ansys.api.discovery.v1.commands.unsupported_pb2 import SetExportIdRequest - # Create the request - assumes all inputs are valid and of the proper type request = SetExportIdRequest( export_data=[ SetExportIdData( - moniker=build_grpc_id(data.moniker), + moniker_id=build_grpc_id(data.moniker), id=data.value, type=data.id_type.value, ) @@ -77,7 +75,7 @@ def set_export_ids(self, **kwargs) -> dict: # noqa: D102 ) # Call the gRPC service - _ = self.stub.SetExportIds(request) + _ = self.stub.SetExportId(request) # Return the response - formatted as a dictionary return {} @@ -85,10 +83,14 @@ def set_export_ids(self, **kwargs) -> dict: # noqa: D102 @protect_grpc def set_single_export_id(self, **kwargs) -> dict: # noqa: D102 # Create the request - assumes all inputs are valid and of the proper type - request = SetExportIdData( - moniker=build_grpc_id(kwargs["export_data"].moniker), - id=kwargs["export_data"].value, - type=kwargs["export_data"].id_type.value, + request = SetExportIdRequest( + export_data=[ + SetExportIdData( + moniker_id=build_grpc_id(kwargs["export_data"].moniker), + id=kwargs["export_data"].value, + type=kwargs["export_data"].id_type.value, + ) + ] ) # Call the gRPC service diff --git a/src/ansys/geometry/core/designer/design.py b/src/ansys/geometry/core/designer/design.py index cb7baa278d..b958f7056d 100644 --- a/src/ansys/geometry/core/designer/design.py +++ b/src/ansys/geometry/core/designer/design.py @@ -29,6 +29,7 @@ import numpy as np from pint import Quantity, UndefinedUnitError +from ansys.geometry.core._grpc._version import GeometryApiProtos from ansys.geometry.core.connection.backend import BackendType from ansys.geometry.core.designer.beam import ( Beam, @@ -53,12 +54,17 @@ from ansys.geometry.core.math.plane import Plane from ansys.geometry.core.math.point import Point3D from ansys.geometry.core.math.vector import UnitVector3D, Vector3D +from ansys.geometry.core.misc.auxiliary import prepare_file_for_server_upload from ansys.geometry.core.misc.checks import ( ensure_design_is_active, min_backend_version, ) from ansys.geometry.core.misc.measurements import Distance -from ansys.geometry.core.misc.options import ImportOptions, TessellationOptions +from ansys.geometry.core.misc.options import ( + ImportOptions, + ImportOptionsDefinitions, + TessellationOptions, +) from ansys.geometry.core.modeler import Modeler from ansys.geometry.core.parameters.parameter import Parameter, ParameterUpdateStatus from ansys.geometry.core.shapes.curves.trimmed_curve import TrimmedCurve @@ -247,6 +253,7 @@ def save(self, file_location: Path | str, write_body_facets: bool = False) -> No filepath=file_location, write_body_facets=write_body_facets, backend_version=self._grpc_client.backend_version, + format=DesignFileFormat.SCDOCX, ) self._grpc_client.log.debug(f"Design successfully saved at location {file_location}.") @@ -988,6 +995,7 @@ def insert_file( self, file_location: Path | str, import_options: ImportOptions = ImportOptions(), + import_options_definitions: ImportOptionsDefinitions = ImportOptionsDefinitions(), ) -> Component: """Insert a file into the design. @@ -995,8 +1003,11 @@ def insert_file( ---------- file_location : ~pathlib.Path | str Location on disk where the file is located. - import_options : ImportOptions - The options to pass into upload file + import_options : ImportOptions, optional + The options to pass into upload file. If none are provided, default options are used. + import_options_definitions : ImportOptionsDefinitions, optional + Additional options to pass into insert file. If none are provided, default options + are used. Returns ------- @@ -1007,13 +1018,37 @@ def insert_file( -------- This method is only available starting on Ansys release 24R2. """ - # Upload the file to the server - filepath_server = self._modeler._upload_file(file_location, import_options=import_options) + # Upload the file to the server if using v0 protos + if self._grpc_client.services.version == GeometryApiProtos.V0: + filepath_server = self._modeler._upload_file( + file_location, import_options=import_options + ) + + # Insert the file into the design + self._grpc_client.services.designs.insert( + filepath=filepath_server, + import_named_selections=import_options.import_named_selections, + ) + else: + # Zip file and pass filepath to service to open + fp_path = Path(file_location).resolve() + + try: + temp_zip_path = prepare_file_for_server_upload(fp_path) + + # Pass the zip file path to the service + self._grpc_client.services.designs.insert( + filepath=temp_zip_path, + original_file_name=fp_path.name, + import_options=import_options, + import_options_definitions=import_options_definitions, + ) + + finally: + # Clean up the temporary zip file + if temp_zip_path.exists(): + temp_zip_path.unlink() - # Insert the file into the design - self._grpc_client.services.designs.insert( - filepath=filepath_server, import_named_selections=import_options.import_named_selections - ) self._grpc_client.log.debug(f"File {file_location} successfully inserted into design.") self._update_design_inplace() diff --git a/src/ansys/geometry/core/misc/auxiliary.py b/src/ansys/geometry/core/misc/auxiliary.py index 2ddfbf73c0..25e2390206 100644 --- a/src/ansys/geometry/core/misc/auxiliary.py +++ b/src/ansys/geometry/core/misc/auxiliary.py @@ -21,6 +21,7 @@ # SOFTWARE. """Auxiliary functions for the PyAnsys Geometry library.""" +from pathlib import Path from typing import TYPE_CHECKING, Union if TYPE_CHECKING: # pragma: no cover @@ -399,3 +400,39 @@ def convert_opacity_to_hex(opacity: float) -> str: raise ValueError("Opacity value must be between 0 and 1.") except ValueError as err: raise ValueError(f"Invalid color value: {err}") + + +def prepare_file_for_server_upload(file_path: Path) -> Path: + """Create a zip file from the given file path. + + Parameters + ---------- + file_path : str + The path to the file to be zipped. + + Returns + ------- + Path + The path to the created zip file. + """ + import tempfile + from zipfile import ZipFile + + # Create a temporary zip file with the same name as the original file + temp_dir = Path(tempfile.gettempdir()) + temp_zip_path = temp_dir / f"{file_path.stem}.zip" + + # Create zip archive + with ZipFile(temp_zip_path, "w") as zipf: + # Add the main file + zipf.write(file_path, file_path.name) + + # If it's an assembly format, add all files from the same directory + assembly_extensions = [".CATProduct", ".asm", ".solution", ".sldasm"] + if any(ext in str(file_path) for ext in assembly_extensions): + dir_path = file_path.parent + for file in dir_path.iterdir(): + if file.is_file() and file != file_path: + zipf.write(file, file.name) + + return temp_zip_path diff --git a/src/ansys/geometry/core/misc/options.py b/src/ansys/geometry/core/misc/options.py index a0e43427dc..4f88dc780c 100644 --- a/src/ansys/geometry/core/misc/options.py +++ b/src/ansys/geometry/core/misc/options.py @@ -66,6 +66,23 @@ def to_dict(self): return {k: bool(v) for k, v in asdict(self).items()} +@dataclass +class ImportOptionsDefinitions: + """Import options definitions when opening a file. + + Parameters + ---------- + import_named_selections_keys : string = None + Import the named selections keys associated with the root component being inserted. + """ + + import_named_selections_keys: str = None + + def to_dict(self): + """Provide the dictionary representation of the ImportOptionsDefinitions class.""" + return {k: str(v) for k, v in asdict(self).items()} + + class TessellationOptions: """Provides options for getting tessellation. diff --git a/src/ansys/geometry/core/modeler.py b/src/ansys/geometry/core/modeler.py index f0fc490719..0e9a1a5e01 100644 --- a/src/ansys/geometry/core/modeler.py +++ b/src/ansys/geometry/core/modeler.py @@ -27,12 +27,14 @@ from grpc import Channel +from ansys.geometry.core._grpc._version import GeometryApiProtos from ansys.geometry.core.connection.backend import ApiVersions, BackendType from ansys.geometry.core.connection.client import GrpcClient import ansys.geometry.core.connection.defaults as pygeom_defaults from ansys.geometry.core.errors import GeometryRuntimeError +from ansys.geometry.core.misc.auxiliary import prepare_file_for_server_upload from ansys.geometry.core.misc.checks import check_type, min_backend_version -from ansys.geometry.core.misc.options import ImportOptions +from ansys.geometry.core.misc.options import ImportOptions, ImportOptionsDefinitions from ansys.geometry.core.tools.measurement_tools import MeasurementTools from ansys.geometry.core.tools.prepare_tools import PrepareTools from ansys.geometry.core.tools.repair_tools import RepairTools @@ -293,28 +295,35 @@ def _upload_file( This method creates a file on the server that has the same name and extension as the file on the client. """ - from pathlib import Path + if self.client.services.version == GeometryApiProtos.V0: + from pathlib import Path - fp_path = Path(file_path).resolve() + fp_path = Path(file_path).resolve() - if not fp_path.exists(): - raise ValueError(f"Could not find file: {file_path}") - if fp_path.is_dir(): - raise ValueError("File path must lead to a file, not a directory.") + if not fp_path.exists(): + raise ValueError(f"Could not find file: {file_path}") + if fp_path.is_dir(): + raise ValueError("File path must lead to a file, not a directory.") - file_name = fp_path.name + file_name = fp_path.name - with fp_path.open(mode="rb") as file: - data = file.read() + with fp_path.open(mode="rb") as file: + data = file.read() - response = self.client.services.designs.upload_file( - data=data, - file_name=file_name, - open_file=open_file, - import_options=import_options, - ) + response = self.client.services.designs.upload_file( + data=data, + file_name=file_name, + open_file=open_file, + import_options=import_options, + ) - return response.get("file_path") + return response.get("file_path") + else: + raise GeometryRuntimeError( + "The '_upload_file' method is not supported in protos v1 and beyond. " + "Use 'modeler.open_file()' to open files or 'design.insert_file()' " + "to insert files into an existing design." + ) def _upload_file_stream( self, @@ -343,26 +352,34 @@ def _upload_file_stream( This method creates a file on the server that has the same name and extension as the file on the client. """ - from pathlib import Path + if self.client.services.version == GeometryApiProtos.V0: + from pathlib import Path - fp_path = Path(file_path).resolve() + fp_path = Path(file_path).resolve() - if not fp_path.exists(): - raise ValueError(f"Could not find file: {file_path}") - if fp_path.is_dir(): - raise ValueError("File path must lead to a file, not a directory.") + if not fp_path.exists(): + raise ValueError(f"Could not find file: {file_path}") + if fp_path.is_dir(): + raise ValueError("File path must lead to a file, not a directory.") - response = self.client.services.designs.upload_file_stream( - file_path=fp_path, open_file=open_file, import_options=import_options - ) + response = self.client.services.designs.upload_file_stream( + file_path=fp_path, open_file=open_file, import_options=import_options + ) - return response.get("file_path") + return response.get("file_path") + else: + raise GeometryRuntimeError( + "The '_upload_file_stream' method is not supported with protos v1 and beyond. " + "Use 'modeler.open_file()' to open files or 'design.insert_file()' " + "to insert files into an existing design." + ) def open_file( self, file_path: str | Path, upload_to_server: bool = True, import_options: ImportOptions = ImportOptions(), + import_options_definitions: ImportOptionsDefinitions = ImportOptionsDefinitions(), ) -> "Design": """Open a file. @@ -411,8 +428,10 @@ def open_file( if self._design is not None and self._design.is_active: self._design.close() - # Format-specific logic - upload the whole containing folder for assemblies - if upload_to_server: + # Format-specific logic - upload the whole containing folder for assemblies. If backend's + # version is > 26.1.0 we're going to upload the file no matter what, as streaming is + # supported. + if upload_to_server and self.client.services.version == GeometryApiProtos.V0: fp_path = Path(file_path) file_size_kb = fp_path.stat().st_size if any( @@ -441,10 +460,25 @@ def open_file( "File is too large to upload. Service versions above 25R2 support streaming." ) else: - self.client.services.designs.open( - filepath=file_path, - import_options=import_options, - ) + # Zip file and pass filepath to service to open + fp_path = Path(file_path).resolve() + + try: + temp_zip_path = prepare_file_for_server_upload(fp_path) + + # Pass the zip file path to the service + self.client.services.designs.open( + filepath=temp_zip_path, + original_file_name=fp_path.name, + import_options=import_options, + import_options_definitions=import_options_definitions, + open_mode="new", + ) + + finally: + # Clean up the temporary zip file + if temp_zip_path.exists(): + temp_zip_path.unlink() return self.read_existing_design() @@ -547,7 +581,11 @@ def run_discovery_script_file( # but this method has been tested independently api_version = ApiVersions.parse_input(api_version) - serv_path = self._upload_file(file_path) + # Prepare the script path + if self.client.services.version == GeometryApiProtos.V0: + serv_path = self._upload_file(file_path) + else: + serv_path = file_path self.client.log.debug(f"Running Discovery script file at {file_path}...") response = self.client.services.commands_script.run_script_file( diff --git a/tests/integration/test_design.py b/tests/integration/test_design.py index 53d17c3971..fe4c7e342d 100644 --- a/tests/integration/test_design.py +++ b/tests/integration/test_design.py @@ -31,6 +31,7 @@ import pytest from ansys.geometry.core import Modeler +from ansys.geometry.core._grpc._version import GeometryApiProtos from ansys.geometry.core.connection import BackendType import ansys.geometry.core.connection.defaults as pygeom_defaults from ansys.geometry.core.designer import ( @@ -86,18 +87,31 @@ def test_error_opening_file(modeler: Modeler, tmp_path_factory: pytest.TempPathFactory): """Validating error messages when opening up files""" - fake_file_path = Path("C:\\Users\\FakeUser\\Documents\\FakeProject\\FakeFile.scdocx") - with pytest.raises(ValueError, match="Could not find file:"): - modeler._upload_file(fake_file_path) - file = tmp_path_factory.mktemp("test_design") - with pytest.raises(ValueError, match="File path must lead to a file, not a directory"): - modeler._upload_file(file) - fake_file_path = Path("C:\\Users\\FakeUser\\Documents\\FakeProject\\FakeFile.scdocx") - with pytest.raises(ValueError, match="Could not find file:"): - modeler._upload_file_stream(fake_file_path) - file = tmp_path_factory.mktemp("test_design") - with pytest.raises(ValueError, match="File path must lead to a file, not a directory"): - modeler._upload_file_stream(file) + # If the protos version is v1 or higher, uploading files is not supported + if modeler.client.services.version != GeometryApiProtos.V0: + fake_path = Path("C:\\Users\\FakeUser\\Documents\\FakeProject\\FakeFile.scdocx") + with pytest.raises( + GeometryRuntimeError, + match="The '_upload_file' method is not supported in backend v1 and beyond.", + ): + modeler._upload_file(fake_path) + with pytest.raises( + GeometryRuntimeError, + match=("The '_upload_file_stream' method is not supported with backend v1 and beyond."), + ): + modeler._upload_file_stream(fake_path) + else: + fake_path = Path("C:\\Users\\FakeUser\\Documents\\FakeProject\\FakeFile.scdocx") + temp_dir = tmp_path_factory.mktemp("test_design") + + with pytest.raises(ValueError, match="Could not find file:"): + modeler._upload_file(fake_path) + with pytest.raises(ValueError, match="File path must lead to a file, not a directory"): + modeler._upload_file(temp_dir) + with pytest.raises(ValueError, match="Could not find file:"): + modeler._upload_file_stream(fake_path) + with pytest.raises(ValueError, match="File path must lead to a file, not a directory"): + modeler._upload_file_stream(temp_dir) def test_modeler_open_files(modeler: Modeler): @@ -1334,7 +1348,14 @@ def test_stream_upload_file(tmp_path_factory: pytest.TempPathFactory, transport_ from ansys.geometry.core import Modeler modeler = Modeler(transport_mode=transport_mode) - path_on_server = modeler._upload_file_stream(file) + if modeler.client.services.version == GeometryApiProtos.V0: + path_on_server = modeler._upload_file_stream(file) + else: + with pytest.raises( + GeometryRuntimeError, + match="The '_upload_file_stream' method is not supported with backend v1 and beyond.", # noqa: E501 + ): + modeler._upload_file_stream(file) assert path_on_server is not None finally: pygeom_defaults.MAX_MESSAGE_LENGTH = old_value @@ -4059,6 +4080,9 @@ def test_legacy_export_download( modeler: Modeler, tmp_path_factory: pytest.TempPathFactory, use_grpc_client_old_backend: Modeler ): # Test is meant to add test coverage for using an old backend to export and download + if modeler.client.services.version != GeometryApiProtos.V0: + pytest.skip("Test only applies to v0 backend") + # Creating the directory and file to export working_directory = tmp_path_factory.mktemp("test_import_export_reimport") original_file = Path(FILES_DIR, "reactorWNS.scdocx") diff --git a/tests/integration/test_runscript.py b/tests/integration/test_runscript.py index 5c63f2ee15..84d03e1426 100644 --- a/tests/integration/test_runscript.py +++ b/tests/integration/test_runscript.py @@ -94,6 +94,7 @@ def test_python_integrated_script(modeler: Modeler): # SpaceClaim (.scscript) def test_scscript_simple_script(modeler: Modeler): + # Testing running a simple scscript file result, _ = modeler.run_discovery_script_file(DSCOSCRIPTS_FILES_DIR / "simple_script.scscript") assert len(result) == 2 pattern_db = re.compile(r"SpaceClaim\.Api\.[A-Za-z0-9]+\.DesignBody", re.IGNORECASE) @@ -105,6 +106,7 @@ def test_scscript_simple_script(modeler: Modeler): # Discovery (.dscript) def test_dscript_simple_script(modeler: Modeler): + # Testing running a simple dscript file result, _ = modeler.run_discovery_script_file(DSCOSCRIPTS_FILES_DIR / "simple_script.dscript") assert len(result) == 2 pattern_db = re.compile(r"SpaceClaim\.Api\.[A-Za-z0-9]+\.DesignBody", re.IGNORECASE)