diff --git a/doc/source/api/enum_types.rst b/doc/source/api/enum_types.rst index 62019e0e84..e609b287cc 100644 --- a/doc/source/api/enum_types.rst +++ b/doc/source/api/enum_types.rst @@ -7,6 +7,7 @@ Enumeration data types :toctree: _autosummary :template: autosummary/no_methods_doc/class.rst.jinja2 + ArrowType BooleanOperationType CutoffMaterialType CutoffRuleType @@ -23,7 +24,9 @@ Enumeration data types LookUpTable3DInterpolationAlgorithm LookUpTableColumnValueType NodalDataType + OffsetType PlyCutoffType + PlyGeometryExportFormat PlyType RosetteSelectionMethod RosetteType diff --git a/doc/source/api/internal.rst b/doc/source/api/internal.rst index c202c1d5a8..100101d121 100644 --- a/doc/source/api/internal.rst +++ b/doc/source/api/internal.rst @@ -31,6 +31,7 @@ Internal objects _tree_objects.base.CreatableTreeObject _tree_objects.base.TreeObject _tree_objects.base.TreeObjectBase + _tree_objects.base.ServerWrapper _tree_objects.material.property_sets.wrapper.TC _tree_objects.material.property_sets.wrapper.TV _workflow._LocalWorkingDir diff --git a/poetry.lock b/poetry.lock index c52d49eab8..3e32e55c18 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4939,4 +4939,4 @@ examples = ["ansys-dpf-composites", "ansys-mapdl-core", "ansys-mechanical-core", [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.13" -content-hash = "fcb8512081f19b8f731dd14d146c9e7810694055b2f089ac18c708e774a95828" +content-hash = "8528b628233a1c28d985edc6e5e09888e8ae62c32bdf7941bbf60b8f73d684a2" diff --git a/pyproject.toml b/pyproject.toml index 989ae4be06..76c8194958 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ numpy = ">=1.22" grpcio-health-checking = ">=1.43" packaging = ">=15.0" typing-extensions = ">=4.5.0" -ansys-api-acp = "^0.1.dev7" +ansys-api-acp = "^0.1.dev9" ansys-tools-path = ">=0" ansys-tools-local-product-launcher = ">=0.1" ansys-tools-filetransfer = ">=0.1" @@ -46,7 +46,7 @@ pyvista = ">=0.42.0" # Dependencies for the examples. Update also the 'dev' group when # these are updated. -ansys-mapdl-core = { version = ">=0.62.1", optional = true } +ansys-mapdl-core = { version = ">=0.68.3", optional = true } ansys-dpf-composites = { version = ">=0.3", optional = true } ansys-dpf-core = { version = ">=0.8", optional = true} ansys-mechanical-core = { version = ">=0.10.0", optional = true, python = "<3.12" } @@ -78,7 +78,7 @@ pyvista = { version = ">=0.42.0", extras = ["jupyter", "trame"] } # Repeat optional dependencies from the main group which are # included in the 'examples' extra. This is done s.t. the install # flag '--with=dev,test' will install all dependencies. -ansys-mapdl-core = ">=0.62.1" +ansys-mapdl-core = ">=0.68.3" ansys-dpf-composites = { version = ">=0.3"} ansys-dpf-core = { version = ">=0.8"} ansys-mechanical-core = { version = ">=0.10.0", python = "<3.12" } diff --git a/src/ansys/acp/core/__init__.py b/src/ansys/acp/core/__init__.py index 75ac0aa472..1a7291dbf4 100644 --- a/src/ansys/acp/core/__init__.py +++ b/src/ansys/acp/core/__init__.py @@ -42,6 +42,7 @@ AnalysisPly, AnalysisPlyElementalData, AnalysisPlyNodalData, + ArrowType, BooleanOperationType, BooleanSelectionRule, BooleanSelectionRuleElementalData, @@ -92,6 +93,7 @@ ModelingPlyNodalData, ModelNodalData, NodalDataType, + OffsetType, OrientedSelectionSet, OrientedSelectionSetElementalData, OrientedSelectionSetNodalData, @@ -99,6 +101,7 @@ ParallelSelectionRuleElementalData, ParallelSelectionRuleNodalData, PlyCutoffType, + PlyGeometryExportFormat, PlyType, ProductionPly, ProductionPlyElementalData, @@ -144,6 +147,7 @@ "AnalysisPly", "AnalysisPlyElementalData", "AnalysisPlyNodalData", + "ArrowType", "BooleanOperationType", "BooleanSelectionRule", "BooleanSelectionRuleElementalData", @@ -180,6 +184,7 @@ "GeometricalSelectionRuleElementalData", "GeometricalSelectionRuleNodalData", "get_composite_post_processing_files", + "get_directions_plotter", "get_dpf_unit_system", "get_model_tree", "IgnorableEntity", @@ -204,6 +209,7 @@ "ModelingPlyNodalData", "ModelNodalData", "NodalDataType", + "OffsetType", "OrientedSelectionSet", "OrientedSelectionSetElementalData", "OrientedSelectionSetNodalData", @@ -211,6 +217,7 @@ "ParallelSelectionRuleElementalData", "ParallelSelectionRuleNodalData", "PlyCutoffType", + "PlyGeometryExportFormat", "PlyType", "print_model", "ProductionPly", @@ -244,5 +251,4 @@ "VectorData", "VirtualGeometry", "VirtualGeometryDimension", - "get_directions_plotter", ] diff --git a/src/ansys/acp/core/_server/acp_instance.py b/src/ansys/acp/core/_server/acp_instance.py index bafb455d78..e790dd3aa4 100644 --- a/src/ansys/acp/core/_server/acp_instance.py +++ b/src/ansys/acp/core/_server/acp_instance.py @@ -35,6 +35,7 @@ from .._tree_objects import Model from .._tree_objects._grpc_helpers.exceptions import wrap_grpc_errors +from .._tree_objects.base import ServerWrapper from .._typing_helper import PATH as _PATH from .common import ServerProtocol @@ -149,24 +150,38 @@ def import_model( Format of the file to be loaded. Can be one of ``"acp:h5"``, ``"ansys:h5"``, ``"ansys:cdb"``, ``"ansys:dat"``, ``"abaqus:inp"``, or ``"nastran:bdf"``. - kwargs : - Additional parameters to be passed to :meth:`Model.from_fe_file`. - Not available when ``format`` is "acp:h5". + ignored_entities: + Entities to ignore when loading the FE file. Can be a subset of + the following values: + ``"coordinate_systems"``, ``"element_sets"``, ``"materials"``, + ``"mesh"``, or ``"shell_sections"``. + Available only when the format is not ``"acp:h5"``. + convert_section_data: + Whether to import the section data of a shell model and convert it + into ACP composite definitions. + Available only when the format is not ``"acp:h5"``. + unit_system: + Set the unit system of the model to the given value. Ignored + if the unit system is already set in the FE file. + Available only when the format is not ``"acp:h5"``. Returns ------- : The loaded ``Model`` instance. """ + server_wrapper = ServerWrapper.from_acp_instance(self) if format == "acp:h5": if kwargs: raise ValueError( f"Parameters '{kwargs.keys()}' cannot be passed when " f"loading a model with format '{format}'." ) - model = Model.from_file(path=path, channel=self._channel) + model = Model._from_file(path=path, server_wrapper=server_wrapper) else: - model = Model.from_fe_file(path=path, channel=self._channel, format=format, **kwargs) + model = Model._from_fe_file( + path=path, server_wrapper=server_wrapper, format=format, **kwargs + ) if name is not None: model.name = name return model diff --git a/src/ansys/acp/core/_tree_objects/__init__.py b/src/ansys/acp/core/_tree_objects/__init__.py index cbf47b2ce8..852459051f 100644 --- a/src/ansys/acp/core/_tree_objects/__init__.py +++ b/src/ansys/acp/core/_tree_objects/__init__.py @@ -42,6 +42,7 @@ from .edge_set import EdgeSet from .element_set import ElementSet, ElementSetElementalData, ElementSetNodalData from .enums import ( + ArrowType, BooleanOperationType, CutoffMaterialType, CutoffRuleType, @@ -55,7 +56,9 @@ LookUpTable3DInterpolationAlgorithm, LookUpTableColumnValueType, NodalDataType, + OffsetType, PlyCutoffType, + PlyGeometryExportFormat, PlyType, RosetteSelectionMethod, RosetteType, @@ -118,6 +121,7 @@ "AnalysisPly", "AnalysisPlyElementalData", "AnalysisPlyNodalData", + "ArrowType", "BooleanOperationType", "BooleanSelectionRule", "BooleanSelectionRuleElementalData", @@ -170,6 +174,7 @@ "ModelingPlyNodalData", "ModelNodalData", "NodalDataType", + "OffsetType", "OrientedSelectionSet", "OrientedSelectionSetElementalData", "OrientedSelectionSetNodalData", @@ -177,6 +182,7 @@ "ParallelSelectionRuleElementalData", "ParallelSelectionRuleNodalData", "PlyCutoffType", + "PlyGeometryExportFormat", "PlyType", "ProductionPly", "ProductionPlyElementalData", diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/linked_object_list.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/linked_object_list.py index 0dd402736d..3bad490829 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/linked_object_list.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/linked_object_list.py @@ -111,7 +111,7 @@ def set_resourcepath_list(value: list[ResourcePath]) -> None: self._set_resourcepath_list = set_resourcepath_list self._object_constructor: Callable[[ResourcePath], ValueT] = ( - lambda resource_path: _object_constructor(resource_path, _parent_object._channel) + lambda resource_path: _object_constructor(resource_path, _parent_object._server_wrapper) ) def __len__(self) -> int: diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/mapping.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/mapping.py index ebb9c18959..c5590d27a9 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/mapping.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/mapping.py @@ -33,7 +33,7 @@ from ..._utils.property_protocols import ReadOnlyProperty from ..._utils.resource_paths import join as _rp_join from .._object_cache import ObjectCacheMixin, constructor_with_cache -from ..base import CreatableTreeObject, TreeObject, TreeObjectBase +from ..base import CreatableTreeObject, ServerWrapper, TreeObject, TreeObjectBase from ..enums import StatusType from .exceptions import wrap_grpc_errors from .property_helper import _exposed_grpc_property, _wrap_doc @@ -60,13 +60,13 @@ class Mapping(ObjectCacheMixin, Generic[ValueT]): def _initialize_with_cache( cls, *, - channel: Channel, + server_wrapper: ServerWrapper, collection_path: CollectionPath, stub: ReadableResourceStub, object_constructor: Callable[[ObjectInfo, Channel | None], ValueT], ) -> Self: return cls( - _channel=channel, + _server_wrapper=server_wrapper, _collection_path=collection_path, _stub=stub, _object_constructor=object_constructor, @@ -75,7 +75,7 @@ def _initialize_with_cache( def __init__( self, *, - _channel: Channel, + _server_wrapper: ServerWrapper, _collection_path: CollectionPath, _stub: ReadableResourceStub, _object_constructor: Callable[[ObjectInfo, Channel | None], ValueT], @@ -83,7 +83,7 @@ def __init__( self._collection_path = _collection_path self._stub = _stub - self._channel = _channel + self._server_wrapper = _server_wrapper self._object_constructor = _object_constructor @staticmethod @@ -97,7 +97,7 @@ def __iter__(self) -> Iterator[str]: def __getitem__(self, key: str) -> ValueT: obj_info = self._get_objectinfo_by_id(key) - return self._object_constructor(obj_info, self._channel) + return self._object_constructor(obj_info, self._server_wrapper) def _get_objectinfo_list(self) -> list[ObjectInfo]: with wrap_grpc_errors(): @@ -124,7 +124,7 @@ def _get_objectinfo_by_id(self, key: str) -> ObjectInfo: def values(self) -> Iterator[ValueT]: """Return an iterator over the values of the mapping.""" return ( - self._object_constructor(obj_info, self._channel) + self._object_constructor(obj_info, self._server_wrapper) for obj_info in self._get_objectinfo_list() ) @@ -133,7 +133,7 @@ def items(self) -> Iterator[tuple[str, ValueT]]: return ( ( obj_info.info.id, - self._object_constructor(obj_info, self._channel), + self._object_constructor(obj_info, self._server_wrapper), ) for obj_info in self._get_objectinfo_list() ) @@ -179,13 +179,13 @@ class MutableMapping(Mapping[CreatableValueT]): def _initialize_with_cache( cls, *, - channel: Channel, + server_wrapper: ServerWrapper, collection_path: CollectionPath, stub: EditableAndReadableResourceStub, # type: ignore # violates Liskov substitution object_constructor: Callable[[ObjectInfo, Channel | None], CreatableValueT], ) -> Self: return cls( - _channel=channel, + _server_wrapper=server_wrapper, _collection_path=collection_path, _stub=stub, _object_constructor=object_constructor, @@ -194,14 +194,14 @@ def _initialize_with_cache( def __init__( self, *, - _channel: Channel, + _server_wrapper: ServerWrapper, _collection_path: CollectionPath, _stub: EditableAndReadableResourceStub, - _object_constructor: Callable[[ObjectInfo, Channel | None], CreatableValueT], + _object_constructor: Callable[[ObjectInfo, ServerWrapper | None], CreatableValueT], ) -> None: self._collection_path = _collection_path self._stub: EditableAndReadableResourceStub = _stub - self._channel = _channel + self._server_wrapper = _server_wrapper self._object_constructor = _object_constructor def __delitem__(self, key: str) -> None: @@ -234,7 +234,7 @@ def popitem(self) -> CreatableValueT: return self._pop_from_info(obj_info) def _pop_from_info(self, object_info: ObjectInfo) -> CreatableValueT: - obj = self._object_constructor(object_info, self._channel) + obj = self._object_constructor(object_info, self._server_wrapper) new_obj = obj.clone() obj.delete() return new_obj @@ -257,12 +257,12 @@ def collection_property(self: ParentT) -> Mapping[ValueT]: f"The object {self.name} must be up-to-date to access {object_class.__name__}." ) return Mapping._initialize_with_cache( - channel=self._channel, + server_wrapper=self._server_wrapper, collection_path=CollectionPath( value=_rp_join(self._resource_path.value, object_class._COLLECTION_LABEL) ), object_constructor=object_class._from_object_info, - stub=stub_class(channel=self._channel), + stub=stub_class(channel=self._server_wrapper.channel), ) return _wrap_doc(_exposed_grpc_property(collection_property), doc=doc) @@ -303,12 +303,12 @@ def define_mutable_mapping( def collection_property(self: ParentT) -> MutableMapping[CreatableValueT]: return MutableMapping._initialize_with_cache( - channel=self._channel, + server_wrapper=self._server_wrapper, collection_path=CollectionPath( value=_rp_join(self._resource_path.value, object_class._COLLECTION_LABEL) ), object_constructor=object_class._from_object_info, - stub=stub_class(channel=self._channel), + stub=stub_class(channel=self._server_wrapper.channel), ) return _wrap_doc(_exposed_grpc_property(collection_property), doc=doc) diff --git a/src/ansys/acp/core/_tree_objects/base.py b/src/ansys/acp/core/_tree_objects/base.py index 0dea1c2f77..21e495c5a6 100644 --- a/src/ansys/acp/core/_tree_objects/base.py +++ b/src/ansys/acp/core/_tree_objects/base.py @@ -25,11 +25,15 @@ from abc import abstractmethod from collections.abc import Iterable +from dataclasses import dataclass +from functools import wraps import typing from typing import Any, Callable, Generic, TypeVar, cast from grpc import Channel -from typing_extensions import Self +from packaging.version import Version +from packaging.version import parse as parse_version +from typing_extensions import Concatenate, ParamSpec, Self, TypeAlias from ansys.api.acp.v0.base_pb2 import CollectionPath, DeleteRequest, GetRequest, ResourcePath @@ -57,12 +61,15 @@ ) from ._object_cache import ObjectCacheMixin, constructor_with_cache +if typing.TYPE_CHECKING: + from .._server import ACP + @mark_grpc_properties class TreeObjectBase(ObjectCacheMixin, GrpcObjectBase): """Base class for ACP tree objects.""" - __slots__: Iterable[str] = ("_channel_store", "_pb_object") + __slots__: Iterable[str] = ("_server_wrapper_store", "_pb_object") _COLLECTION_LABEL: str _OBJECT_INFO_TYPE: type[ObjectInfo] @@ -71,7 +78,7 @@ class TreeObjectBase(ObjectCacheMixin, GrpcObjectBase): name: ReadOnlyProperty[str] def __init__(self: TreeObjectBase, name: str = "") -> None: - self._channel_store: Channel | None = None + self._server_wrapper_store: ServerWrapper | None = None self._pb_object: ObjectInfo = self._OBJECT_INFO_TYPE() # We don't want to invoke gRPC requests for setting the name # during object construction, so we set the name directly on @@ -98,11 +105,11 @@ def __eq__(self: Self, other: Any) -> bool: raise_on_invalid_key=False, ) def _from_object_info( - cls: type[Self], /, object_info: ObjectInfo, channel: Channel | None = None + cls: type[Self], /, object_info: ObjectInfo, server_wrapper: ServerWrapper | None = None ) -> Self: instance = cls() instance._pb_object = object_info - instance._channel_store = channel + instance._server_wrapper_store = server_wrapper return instance @classmethod @@ -110,10 +117,12 @@ def _from_object_info( key_getter=lambda resource_path, *args, **kwargs: resource_path.value, raise_on_invalid_key=True, ) - def _from_resource_path(cls, /, resource_path: ResourcePath, channel: Channel) -> Self: + def _from_resource_path( + cls, /, resource_path: ResourcePath, server_wrapper: ServerWrapper + ) -> Self: instance = cls() instance._pb_object.info.resource_path.CopyFrom(resource_path) - instance._channel_store = channel + instance._server_wrapper_store = server_wrapper return instance @property @@ -122,13 +131,18 @@ def _resource_path(self) -> ResourcePath: @property def _channel(self) -> Channel: + return self._server_wrapper.channel + + @property + def _server_wrapper(self) -> ServerWrapper: if not self._is_stored: raise RuntimeError("The server connection is uninitialized.") - return self._channel_store + assert self._server_wrapper_store is not None + return self._server_wrapper_store @property def _is_stored(self) -> bool: - return self._channel_store is not None + return self._server_wrapper_store is not None def __repr__(self) -> str: return f"<{type(self).__name__} with name '{self.name}'>" @@ -139,6 +153,25 @@ def __repr__(self) -> str: StubT = TypeVar("StubT") +@dataclass(frozen=True) +class ServerWrapper: + """Wrapper for the connection to an ACP instance. + + This class contains the representation of the ACP instance needed by tree objects. + Its purpose is to minimize the dependency of tree objects on the ACP class. + """ + + channel: Channel + version: Version + + @classmethod + def from_acp_instance(cls, acp_instance: ACP[Any]) -> ServerWrapper: + """Convert an ACP instance into the wrapper needed by tree objects.""" + return cls( + channel=acp_instance._channel, version=parse_version(acp_instance.server_version) + ) + + class StubStore(Generic[StubT]): """Stores a gRPC stub, and creates it on demand.""" @@ -262,7 +295,7 @@ def store(self: Self, parent: TreeObject) -> None: parent : Parent object to store the object under. """ - self._channel_store = parent._channel + self._server_wrapper_store = parent._server_wrapper collection_path = CollectionPath( value=_rp_join(parent._resource_path.value, self._COLLECTION_LABEL) @@ -415,6 +448,34 @@ def _put_if_stored(self) -> None: self._put() +T = TypeVar("T", bound=TreeObjectBase) +P = ParamSpec("P") +R = TypeVar("R") +_WRAPPED_T: TypeAlias = Callable[Concatenate[T, P], R] + + +def supported_since(version: str) -> Callable[[_WRAPPED_T[T, P, R]], _WRAPPED_T[T, P, R]]: + """Mark a TreeObjectBase method as supported since a specific server version. + + Raises an exception if the current server version does not match the required version. + """ + required_version = parse_version(version) + + def decorator(func: _WRAPPED_T[T, P, R]) -> _WRAPPED_T[T, P, R]: + @wraps(func) + def inner(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R: + if self._server_wrapper.version < required_version: + raise RuntimeError( + f"The method '{func.__name__}' is only supported since version {version} of the ACP " + f"gRPC server. The current server version is {self._server_wrapper.version}." + ) + return func(self, *args, **kwargs) + + return inner + + return decorator + + if typing.TYPE_CHECKING: # Ensure that the ReadOnlyTreeObject satisfies the Gettable interface _x: Readable = typing.cast(ReadOnlyTreeObject, None) diff --git a/src/ansys/acp/core/_tree_objects/enums.py b/src/ansys/acp/core/_tree_objects/enums.py index 4298c4df24..90b7fbeae8 100644 --- a/src/ansys/acp/core/_tree_objects/enums.py +++ b/src/ansys/acp/core/_tree_objects/enums.py @@ -31,6 +31,7 @@ lookup_table_column_type_pb2, mesh_query_pb2, modeling_ply_pb2, + ply_geometry_export_pb2, ply_material_pb2, rosette_pb2, sensor_pb2, @@ -41,30 +42,33 @@ from ._grpc_helpers.enum_wrapper import wrap_to_string_enum __all__ = [ - "StatusType", - "RosetteSelectionMethod", + "ArrowType", + "BooleanOperationType", "CutoffMaterialType", - "DropoffMaterialType", - "DrapingType", + "CutoffRuleType", + "DimensionType", "DrapingMaterialType", - "SymmetryType", + "DrapingType", + "DropoffMaterialType", "EdgeSetType", - "PlyType", - "BooleanOperationType", - "UnitSystemType", - "DimensionType", "ElementalDataType", - "NodalDataType", - "LookUpTableColumnValueType", + "GeometricalRuleType", "LookUpTable3DInterpolationAlgorithm", + "LookUpTableColumnValueType", + "NodalDataType", + "OffsetType", + "PlyCutoffType", + "PlyGeometryExportFormat", + "PlyType", + "RosetteSelectionMethod", "RosetteType", "SensorType", - "VirtualGeometryDimension", - "CutoffRuleType", - "PlyCutoffType", - "GeometricalRuleType", - "ThicknessType", + "StatusType", + "SymmetryType", "ThicknessFieldType", + "ThicknessType", + "UnitSystemType", + "VirtualGeometryDimension", ] (StatusType, status_type_to_pb, status_type_from_pb) = wrap_to_string_enum( @@ -184,6 +188,20 @@ doc="Options for combining selection rules.", ) +(OffsetType, offset_type_to_pb, _) = wrap_to_string_enum( + "OffsetType", + enum_types_pb2.OffsetType, + module=__name__, + doc="Options for the ply offset type.", +) + +(ArrowType, arrow_type_to_pb, _) = wrap_to_string_enum( + "ArrowType", + enum_types_pb2.ArrowType, + module=__name__, + doc="Options for the type of arrow to be created for directions in the ply geometry export.", +) + ( UnitSystemType, unit_system_type_to_pb, @@ -336,3 +354,10 @@ module=__name__, doc="Options for how thickness from a table is defined.", ) + +(PlyGeometryExportFormat, ply_geometry_export_format_to_pb, _) = wrap_to_string_enum( + "PlyGeometryExportFormat", + ply_geometry_export_pb2.ExportFormat, + module=__name__, + doc="Options for the file format of the ply geometry export.", +) diff --git a/src/ansys/acp/core/_tree_objects/model.py b/src/ansys/acp/core/_tree_objects/model.py index 73062885ba..5c7e4fd573 100644 --- a/src/ansys/acp/core/_tree_objects/model.py +++ b/src/ansys/acp/core/_tree_objects/model.py @@ -24,9 +24,9 @@ from collections.abc import Iterable import dataclasses +import typing from typing import Any, cast -from grpc import Channel import numpy as np import numpy.typing as npt from pyvista.core.pointset import UnstructuredGrid @@ -49,8 +49,10 @@ model_pb2, model_pb2_grpc, modeling_group_pb2_grpc, + modeling_ply_pb2_grpc, oriented_selection_set_pb2_grpc, parallel_selection_rule_pb2_grpc, + ply_geometry_export_pb2, rosette_pb2_grpc, sensor_pb2_grpc, spherical_selection_rule_pb2_grpc, @@ -84,20 +86,31 @@ elemental_data_property, nodal_data_property, ) -from .base import TreeObject +from .base import ServerWrapper, TreeObject, supported_since from .boolean_selection_rule import BooleanSelectionRule from .cad_geometry import CADGeometry from .cutoff_selection_rule import CutoffSelectionRule from .cylindrical_selection_rule import CylindricalSelectionRule from .edge_set import EdgeSet from .element_set import ElementSet -from .enums import UnitSystemType, unit_system_type_from_pb, unit_system_type_to_pb +from .enums import ( + ArrowType, + OffsetType, + PlyGeometryExportFormat, + UnitSystemType, + arrow_type_to_pb, + offset_type_to_pb, + ply_geometry_export_format_to_pb, + unit_system_type_from_pb, + unit_system_type_to_pb, +) from .fabric import Fabric from .geometrical_selection_rule import GeometricalSelectionRule from .lookup_table_1d import LookUpTable1D from .lookup_table_3d import LookUpTable3D from .material import Material from .modeling_group import ModelingGroup +from .modeling_ply import ModelingPly from .oriented_selection_set import OrientedSelectionSet from .parallel_selection_rule import ParallelSelectionRule from .rosette import Rosette @@ -255,29 +268,29 @@ def _create_stub(self) -> model_pb2_grpc.ObjectServiceStub: ) @classmethod - def from_file(cls, *, path: _PATH, channel: Channel) -> Model: + def _from_file(cls, *, path: _PATH, server_wrapper: ServerWrapper) -> Model: """Instantiate a Model from an ACPH5 file. Parameters ---------- path: File path, on the server. - channel: - gRPC channel to the server. + server_wrapper: + Representation of the ACP instance. """ # Send absolute paths to the server, since its CWD may not match # the Python CWD. request = model_pb2.LoadFromFileRequest(path=path_to_str_checked(path)) with wrap_grpc_errors(): - reply = model_pb2_grpc.ObjectServiceStub(channel).LoadFromFile(request) - return cls._from_object_info(object_info=reply, channel=channel) + reply = model_pb2_grpc.ObjectServiceStub(server_wrapper.channel).LoadFromFile(request) + return cls._from_object_info(object_info=reply, server_wrapper=server_wrapper) @classmethod - def from_fe_file( + def _from_fe_file( cls, *, path: _PATH, - channel: Channel, + server_wrapper: ServerWrapper, format: FeFormat, # type: ignore ignored_entities: Iterable[IgnorableEntity] = (), # type: ignore convert_section_data: bool = False, @@ -289,8 +302,8 @@ def from_fe_file( ---------- path: File path, on the server. - channel: - gRPC channel to the server. + server_wrapper: + Representation of the ACP instance. format: Format of the FE file. Can be one of ``"ansys:h5"``, ``"ansys:cdb"``, ``"ansys:dat"``, ``"abaqus:inp"``, or ``"nastran:bdf"``. @@ -317,8 +330,8 @@ def from_fe_file( unit_system=cast(Any, unit_system_type_to_pb(unit_system)), ) with wrap_grpc_errors(): - reply = model_pb2_grpc.ObjectServiceStub(channel).LoadFromFEFile(request) - return cls._from_object_info(object_info=reply, channel=channel) + reply = model_pb2_grpc.ObjectServiceStub(server_wrapper.channel).LoadFromFEFile(request) + return cls._from_object_info(object_info=reply, server_wrapper=server_wrapper) def update(self, *, relations_only: bool = False) -> None: """Update the model. @@ -412,6 +425,84 @@ def export_materials(self, path: _PATH) -> None: ) ) + @supported_since("25.1") + def export_modeling_ply_geometries( + self, + path: _PATH, + *, + modeling_plies: Iterable[ModelingPly] | None = None, + format: PlyGeometryExportFormat = PlyGeometryExportFormat.STEP, + offset_type: OffsetType = OffsetType.MIDDLE_OFFSET, + include_surface: bool = True, + include_boundary: bool = True, + include_first_material_direction: bool = True, + include_second_material_direction: bool = True, + arrow_length: float | None = None, + arrow_type: ArrowType = ArrowType.NO_ARROW, + ) -> None: + """ + Write ply geometries to a STEP, IGES, or STL file. + + Parameters + ---------- + path : + File path to save the geometries to. + modeling_plies : + List of modeling plies whose geometries should be exported. If not + provided, the geometries of all modeling plies in the model are exported. + format : + Format of the created file. Can be one of ``"STEP"``, ``"IGES"``, + or ``"STL"``. + offset_type : + Determines how the ply offset is calculated. Can be one of + ``"NO_OFFSET"``, ``"BOTTOM_OFFSET"``, ``"MIDDLE_OFFSET"``, or + ``"TOP_OFFSET"``. + include_surface : + Whether to include the ply surface in the exported geometry. + include_boundary : + Whether to include the ply boundary in the exported geometry. + include_first_material_direction : + Whether to include the first material direction in the exported geometry. + include_second_material_direction : + Whether to include the second material direction in the exported geometry. + arrow_length : + Size of the arrow used to represent the material directions. By default, the + square root of the average element area is used. + arrow_type : + Type of the arrow used to represent the material directions. Can be + one of ``"NO_ARROW"``, ``"HALF_ARROW"``, or ``"STANDARD_ARROW"``. + """ + if modeling_plies is None: + modeling_plies = [ + ply + for modeling_group in self.modeling_groups.values() + for ply in modeling_group.modeling_plies.values() + ] + mp_resource_paths = [ply._resource_path for ply in modeling_plies] + + modeling_ply_stub = modeling_ply_pb2_grpc.ObjectServiceStub(self._channel) + + if arrow_length is None: + arrow_length = np.sqrt(self.average_element_size) + + with wrap_grpc_errors(): + modeling_ply_stub.ExportGeometries( + ply_geometry_export_pb2.ExportGeometriesRequest( + path=path_to_str_checked(path), + plies=mp_resource_paths, + options=ply_geometry_export_pb2.ExportOptions( + format=typing.cast(typing.Any, ply_geometry_export_format_to_pb(format)), + offset_type=typing.cast(typing.Any, offset_type_to_pb(offset_type)), + include_surface=include_surface, + include_boundary=include_boundary, + include_first_material_direction=include_first_material_direction, + include_second_material_direction=include_second_material_direction, + arrow_length=arrow_length, + arrow_type=typing.cast(typing.Any, arrow_type_to_pb(arrow_type)), + ), + ) + ) + create_material = define_create_method( Material, func_name="create_material", parent_class_name="Model", module_name=__module__ ) diff --git a/src/ansys/acp/core/_tree_objects/modeling_ply.py b/src/ansys/acp/core/_tree_objects/modeling_ply.py index 8b885bb205..97d9160e65 100644 --- a/src/ansys/acp/core/_tree_objects/modeling_ply.py +++ b/src/ansys/acp/core/_tree_objects/modeling_ply.py @@ -185,7 +185,7 @@ def _from_pb_object( apply_changes: Callable[[], None], ) -> Self: edge_set = EdgeSet._from_resource_path( - resource_path=message.edge_set, channel=parent_object._channel + resource_path=message.edge_set, server_wrapper=parent_object._server_wrapper ) new_obj = cls( diff --git a/src/ansys/acp/core/_tree_objects/virtual_geometry.py b/src/ansys/acp/core/_tree_objects/virtual_geometry.py index 9c09906bd8..9ad4a02ff4 100644 --- a/src/ansys/acp/core/_tree_objects/virtual_geometry.py +++ b/src/ansys/acp/core/_tree_objects/virtual_geometry.py @@ -87,7 +87,7 @@ def _from_pb_object( ) -> SubShape: new_obj = cls( cad_geometry=CADGeometry._from_resource_path( - message.cad_geometry, channel=parent_object._channel + message.cad_geometry, server_wrapper=parent_object._server_wrapper ), path=message.path, ) @@ -196,7 +196,7 @@ def set_cad_components(self, cad_components: Iterable[CADComponent]) -> None: def _get_parent(cad_component: CADComponent) -> CADGeometry: rp = "/".join(cad_component._resource_path.value.split("/")[:-2]) return CADGeometry._from_resource_path( - base_pb2.ResourcePath(value=rp), channel=cad_component._channel + base_pb2.ResourcePath(value=rp), server_wrapper=cad_component._server_wrapper ) sub_shapes = [ diff --git a/tests/unittests/test_model.py b/tests/unittests/test_model.py index 4f047ac025..70b3f7bbf2 100644 --- a/tests/unittests/test_model.py +++ b/tests/unittests/test_model.py @@ -88,7 +88,7 @@ def test_unittest(acp_instance, model_data_dir): else: with tempfile.TemporaryDirectory() as local_working_dir: save_path = pathlib.Path(local_working_dir) / "test_model_serialization.acph5" - acp_instance.save(save_path, save_cache=True) + model.save(save_path, save_cache=True) acp_instance.clear() model = acp_instance.import_model(path=save_path) @@ -249,3 +249,21 @@ def test_regression_454(minimal_complete_model): """ assert not hasattr(minimal_complete_model, "clone") assert not hasattr(minimal_complete_model, "store") + + +def test_modeling_ply_export(acp_instance, minimal_complete_model): + """ + Test that the 'export_modeling_ply_geometries' method produces a file. + The contents of the file are not checked. + """ + out_filename = "modeling_ply_export.step" + + with tempfile.TemporaryDirectory() as tmp_dir: + local_file_path = pathlib.Path(tmp_dir) / out_filename + if acp_instance.is_remote: + out_file_path = pathlib.Path(out_filename) + else: + out_file_path = local_file_path + minimal_complete_model.export_modeling_ply_geometries(out_file_path) + acp_instance.download_file(out_file_path, local_file_path) + assert local_file_path.exists()