diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 74e7663c7d..792b34363d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/psf/black - rev: 22.12.0 # IF VERSION CHANGES --> MODIFY "blacken-docs" MANUALLY AS WELL!! + rev: 23.1.0 # IF VERSION CHANGES --> MODIFY "blacken-docs" MANUALLY AS WELL!! hooks: - id: black @@ -9,7 +9,7 @@ repos: rev: 1.13.0 hooks: - id: blacken-docs - additional_dependencies: [black==22.12.0] + additional_dependencies: [black==23.1.0] - repo: https://github.com/pycqa/isort rev: 5.12.0 diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 0000000000..4991fc293c --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,16 @@ +# Authors + +## Project Lead + +* [Roberto Pastor](https://github.com/RobPasMue) + +## Contributors + +* [Jonah Boling](https://github.com/jonahrb) +* [Matteo Bini](https://github.com/b-matteo) +* [Chad Queen](https://github.com/chadqueen) +* [Revathy Venugopal](https://github.com/Revathyvenugopal162) +* [Maxime Rey](https://github.com/MaxJPRey) +* [Alexander Kaszynski](https://github.com/akaszynski) +* [Jorge Martínez](https://github.com/jorgepiloto) +* [Alejandro Fernández](https://github.com/AlejandroFernandezLuces) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index deebbad380..37b6a6e9a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,2 +1,16 @@ -# Contributing +# Contributing +We absolutely welcome any code contributions and we hope that this +guide will facilitate an understanding of the PyGeometry code +repository. It is important to note that while the PyGeometry software +package is maintained by ANSYS and any submissions will be reviewed +thoroughly before merging, we still seek to foster a community that can +support user questions and develop new features to make this software +a useful tool for all users. As such, we welcome and encourage any +questions or submissions to this repository. + +For contributing to this project, please refer to the [PyAnsys Developer's Guide]. +Further information about contributing to PyGeometry can be found in [Contributing]. + +[PyAnsys Developer's Guide]: https://dev.docs.pyansys.com/index.html +[Contributing]: https://geometry.docs.pyansys.com/dev/contributing.html diff --git a/README.rst b/README.rst index 4ffa81a183..06891bc8de 100644 --- a/README.rst +++ b/README.rst @@ -119,7 +119,14 @@ If you want to change the defaults, modify the following environment variables: export ANSRV_GEO_HOST=127.0.0.1 export ANSRV_GEO_PORT=50051 -**On Windows** +**On Windows Powershell** + +.. code:: + + $env:ANSRV_GEO_HOST="127.0.0.1" + $env:ANSRV_GEO_PORT=50051 + +**On Windows CMD** .. code:: @@ -182,6 +189,12 @@ To install PyGeometry in developer mode, perform these steps: git clone https://github.com/pyansys/pygeometry +#. Access the ``pygeometry`` directory where the repository has been cloned: + + .. code:: bash + + cd pygeometry + #. Create a clean Python virtual environment and activate it: .. code:: bash @@ -204,18 +217,21 @@ To install PyGeometry in developer mode, perform these steps: python -m pip install -U pip tox - #. Install the project in editable mode: .. code:: bash - - python -m pip install ansys-geometry-core - -#. Verify your development installation by running: + + # Install the minimum requirements + python -m pip install -e . - .. code:: bash - - tox + # Install the minimum + tests requirements + python -m pip install -e .[tests] + + # Install the minimum + doc requirements + python -m pip install -e .[doc] + + # Install the all requirements + python -m pip install -e .[tests,doc] Install in offline mode ^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/doc/source/conf.py b/doc/source/conf.py index 86973dc044..90b0760061 100755 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -22,7 +22,7 @@ project = "ansys-geometry-core" copyright = f"(c) {datetime.now().year} ANSYS, Inc. All rights reserved" author = "ANSYS, Inc." -release = version = "0.2.0" +release = version = __version__ cname = os.getenv("DOCUMENTATION_CNAME", default="nocname.com") # Select desired logo, theme, and declare the html title diff --git a/doc/source/contributing.rst b/doc/source/contributing.rst index 5c2ae5567e..bcf5ba7677 100644 --- a/doc/source/contributing.rst +++ b/doc/source/contributing.rst @@ -43,7 +43,7 @@ Documentation for the latest stable release of PyGeometry is hosted at `PyGeometry Documentation `_. Documentation for the latest development version, which tracks the -``main`` branch, is hosted at `Development PyGeometry Documentation `_. +``main`` branch, is hosted at `Development PyGeometry Documentation `_. This version is automatically kept up to date via GitHub actions. Code style diff --git a/doc/source/getting_started/docker.rst b/doc/source/getting_started/docker.rst index c41d0998c2..b75d6e3821 100644 --- a/doc/source/getting_started/docker.rst +++ b/doc/source/getting_started/docker.rst @@ -1,29 +1,89 @@ +.. _ref_docker: + Geometry service using Docker ============================= +Docker +------ + +Ensure that the machine in which the Geometry service should run has Docker installed. Otherwise, +please install `Docker Engine `_ from the previous link. + +.. caution:: + At the moment, the Geometry service backend is only delivered as a Windows Docker container. + As such, this container only runs on a Windows machine. Furthermore, it has also been observed + that certain Docker Desktop versions for Windows are not properly configured for running Windows + Docker containers. Refer to our section + :ref:`Running the Geometry service Windows Docker container ` for further details. + +.. _ref_docker_windows: + +Running the Geometry service Windows Docker container +----------------------------------------------------- + +For running the Windows Docker container of the Geometry service, please ensure that +you follow the upcoming steps when installing Docker: + +#. Install `Docker Desktop 4.13.1 `_ **or below**. + It has been observed that newer versions present problems when running Windows Docker containers. + +#. When prompted for ``Use WSL2 instead of Hyper-V (recommended)``, **deselect this option**. + +#. Once the installation process finishes, open up Docker Desktop. + +#. On ``Settings >> Software updates``, deselect ``Automatically check for updates``. Then, ``Apply & restart``. + +#. On the Windows taskbar, go to the ``Show hidden icons`` section, right click on the Docker Desktop app and + select ``Switch to Windows containers...``. + +At this point, your Docker engine will support running Windows Docker containers. Next step will involve downloading +the Geometry service Windows Docker image. + Install the PyGeometry image ---------------------------- +Once you have Docker installed on your machine, the next steps involve pulling down the Geometry service +Docker container. + #. Using your GitHub credentials, download the Docker image from the `pygeometry `_ repository. + #. If you have Docker installed, use a GitHub personal access token (PAT) with packages read permission to authorize Docker to access this repository. For more information, see `creating a personal access token `_. #. Save the token to a file: - .. code:: bash + .. code-block:: bash - echo XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX > GH_TOKEN.txt + echo XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX > GH_TOKEN.txt #. Authorize Docker to access the repository: - .. code:: bash +.. tab-set:: - GH_USERNAME= - cat GH_TOKEN.txt | docker login docker.pkg.github.com -u $GH_USERNAME --password-stdin + .. tab-item:: Linux/Mac + + .. code-block:: bash + + GH_USERNAME= + cat GH_TOKEN.txt | docker login ghcr.io -u $GH_USERNAME --password-stdin + + .. tab-item:: Powershell + + .. code-block:: bash + + $env:GH_USERNAME= + cat GH_TOKEN.txt | docker login ghcr.io -u $env:GH_USERNAME --password-stdin + + .. tab-item:: Windows CMD + + .. code-block:: bash + + SET GH_USERNAME= + type GH_TOKEN.txt | docker login ghcr.io -u %GH_USERNAME% --password-stdin -#. Pull the Geometry service locally using Docker with: +#. Pull the Geometry service locally using Docker with: .. code:: bash @@ -72,7 +132,15 @@ Depending on the mechanism chosen to launch the Geometry service, you can set th export ANSRV_GEO_ENABLE_TRACE=0 export ANSRV_GEO_LOG_LEVEL=2 - .. tab-item:: Windows + .. tab-item:: Powershell + + .. code-block:: bash + + $env:ANSRV_GEO_LICENSE_SERVER="127.0.0.1" + $env:ANSRV_GEO_ENABLE_TRACE=0 + $env:ANSRV_GEO_LOG_LEVEL=2 + + .. tab-item:: Windows CMD .. code-block:: bash @@ -150,7 +218,14 @@ If you want to change the defaults, modify environment variables and the export ANSRV_GEO_HOST=127.0.0.1 export ANSRV_GEO_PORT=50051 - .. tab-item:: Windows + .. tab-item:: Powershell + + .. code-block:: bash + + $env:ANSRV_GEO_HOST="127.0.0.1" + $env:ANSRV_GEO_PORT=50051 + + .. tab-item:: Windows CMD .. code-block:: bash diff --git a/doc/source/getting_started/index.rst b/doc/source/getting_started/index.rst index 0761951733..764da9f802 100644 --- a/doc/source/getting_started/index.rst +++ b/doc/source/getting_started/index.rst @@ -7,6 +7,11 @@ To use PyGeometry, you must have a local installation of `Docker `_. +.. caution:: + PyGeometry is a client library that works with a Geometry service backend. This service is distributed + as a Docker container. At the moment, there is only a Windows Docker container version available for this + service. For more information please refer to the :ref:`Geometry service using Docker ` section. + .. toctree:: docker diff --git a/pyproject.toml b/pyproject.toml index 9ef8a16124..c6238be6c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] dependencies = [ - "ansys-api-geometry==0.2.0", + "ansys-api-geometry==0.2.1", "beartype>=0.11.0", "google-api-python-client>=1.7.11", "googleapis-common-protos>=1.52.0", @@ -43,11 +43,11 @@ dependencies = [ [project.optional-dependencies] tests = [ "beartype==0.12.0", - "google-api-python-client==2.75.0", + "google-api-python-client==2.77.0", "googleapis-common-protos==1.58.0", "grpcio==1.50.0", "grpcio-health-checking==1.48.2", - "numpy==1.24.1", + "numpy==1.24.2", "Pint==0.20.1", "protobuf==3.20.3", "pyvista==0.37.0", @@ -57,10 +57,10 @@ tests = [ "docker==6.0.1", "pytest==7.2.1", "pytest-cov==4.0.0", - "pytest-pyvista==0.1.5", + "pytest-pyvista==0.1.6", ] doc = [ - "ansys-sphinx-theme==0.8.1", + "ansys-sphinx-theme==0.8.2", "ipyvtklink==0.2.3", "jupyter_sphinx==0.4.0", "jupytext==1.14.4", diff --git a/src/ansys/geometry/core/connection/launcher.py b/src/ansys/geometry/core/connection/launcher.py index aae839f641..1ac6f75e82 100644 --- a/src/ansys/geometry/core/connection/launcher.py +++ b/src/ansys/geometry/core/connection/launcher.py @@ -2,7 +2,11 @@ from beartype.typing import TYPE_CHECKING, Optional from ansys.geometry.core.connection.defaults import DEFAULT_PORT -from ansys.geometry.core.connection.local_instance import GeometryContainers, LocalDockerInstance +from ansys.geometry.core.connection.local_instance import ( + _HAS_DOCKER, + GeometryContainers, + LocalDockerInstance, +) from ansys.geometry.core.logger import LOG as logger from ansys.geometry.core.misc import check_type @@ -41,12 +45,12 @@ def launch_modeler() -> "Modeler": # Start PyGeometry with PyPIM if the environment is configured for it # and a directive on how to launch it was not passed. - if pypim.is_configured(): + if _HAS_PIM and pypim.is_configured(): logger.info("Starting Geometry service remotely. The startup configuration is ignored.") return launch_remote_modeler() # Otherwise, we are in the "local Docker Container" scenario - if LocalDockerInstance.is_docker_installed(): + if _HAS_DOCKER and LocalDockerInstance.is_docker_installed(): logger.info("Starting Geometry service locally from Docker container.") return launch_local_modeler() @@ -142,6 +146,9 @@ def launch_local_modeler( from ansys.geometry.core.modeler import Modeler + if not _HAS_DOCKER: # pragma: no cover + raise ModuleNotFoundError("The package 'docker' is required to use this function.") + # Call the LocalDockerInstance ctor. local_instance = LocalDockerInstance( port=port, diff --git a/src/ansys/geometry/core/connection/local_instance.py b/src/ansys/geometry/core/connection/local_instance.py index 48273e6a5e..04d5941cdd 100644 --- a/src/ansys/geometry/core/connection/local_instance.py +++ b/src/ansys/geometry/core/connection/local_instance.py @@ -176,7 +176,7 @@ def _check_port_availability(self, port: int) -> Tuple[bool, Optional["Container """ # First, check if there is a container already running at that port for cont in self.docker_client().containers.list(): - for (_, ports_shared) in cont.attrs["NetworkSettings"]["Ports"].items(): + for _, ports_shared in cont.attrs["NetworkSettings"]["Ports"].items(): for port_shared in ports_shared: if int(port_shared["HostPort"]) == port: logger.warning(f"Service already running at port {port}...") diff --git a/src/ansys/geometry/core/designer/body.py b/src/ansys/geometry/core/designer/body.py index 2971c8721d..a6c9f19825 100644 --- a/src/ansys/geometry/core/designer/body.py +++ b/src/ansys/geometry/core/designer/body.py @@ -1,7 +1,11 @@ """Provides the ``Body`` class module.""" from enum import Enum -from ansys.api.geometry.v0.bodies_pb2 import SetAssignedMaterialRequest, TranslateRequest +from ansys.api.geometry.v0.bodies_pb2 import ( + CopyRequest, + SetAssignedMaterialRequest, + TranslateRequest, +) from ansys.api.geometry.v0.bodies_pb2_grpc import BodiesStub from ansys.api.geometry.v0.commands_pb2 import ( AssignMidSurfaceOffsetTypeRequest, @@ -400,6 +404,46 @@ def translate(self, direction: UnitVector3D, distance: Union[Quantity, Distance] ) ) + @protect_grpc + def copy(self, parent: "Component", name: str = None) -> "Body": + """Creates a copy of the geometry body and places it under the specified parent. + + Parameters + ---------- + parent: Component + The parent component that the new body should live under. + name: str + The name to give the new body. + + Returns + ------- + Body + Copy of the body. + """ + from ansys.geometry.core.designer.component import Component + + # Check input types + check_type(parent, Component) + check_type(name, (type(None), str)) + copy_name = self.name if name is None else name + + self._grpc_client.log.debug(f"Copying body {self.id}.") + + # Perform copy request to server + response = self._bodies_stub.Copy( + CopyRequest( + id=self.id, + parent=parent.id, + name=copy_name, + ) + ) + + # Assign the new body to its specified parent (and return the new body) + parent._bodies.append( + Body(response.id, copy_name, parent, self._grpc_client, is_surface=False) + ) + return parent._bodies[-1] + @protect_grpc def tessellate(self, merge: Optional[bool] = False) -> Union["PolyData", "MultiBlock"]: """Tessellate the body and return the geometry as triangles. diff --git a/src/ansys/geometry/core/designer/design.py b/src/ansys/geometry/core/designer/design.py index 25b1c1ec13..8281581c85 100644 --- a/src/ansys/geometry/core/designer/design.py +++ b/src/ansys/geometry/core/designer/design.py @@ -52,6 +52,7 @@ class DesignFileFormat(Enum): SCDOCX = "SCDOCX", None PARASOLID_TEXT = "PARASOLID_TEXT", PartExportFormat.PARTEXPORTFORMAT_PARASOLID_TEXT PARASOLID_BIN = "PARASOLID_BIN", PartExportFormat.PARTEXPORTFORMAT_PARASOLID_BINARY + FMD = "FMD", PartExportFormat.PARTEXPORTFORMAT_FMD INVALID = "INVALID", None @@ -184,9 +185,11 @@ def download( if format is DesignFileFormat.SCDOCX: response = self._commands_stub.DownloadFile(Empty()) received_bytes += response.data - elif (format is DesignFileFormat.PARASOLID_TEXT) or ( - format is DesignFileFormat.PARASOLID_BIN - ): + elif format in [ + DesignFileFormat.PARASOLID_TEXT, + DesignFileFormat.PARASOLID_BIN, + DesignFileFormat.FMD, + ]: response = self._design_stub.Export(ExportRequest(format=format.value[1])) received_bytes += response.data else: diff --git a/src/ansys/geometry/core/designer/face.py b/src/ansys/geometry/core/designer/face.py index 905bc7877c..70b1690160 100644 --- a/src/ansys/geometry/core/designer/face.py +++ b/src/ansys/geometry/core/designer/face.py @@ -72,7 +72,6 @@ def __init__( max_bbox: Point3D, edges: List[Edge], ): - self._type = type self._length = length self._min_bbox = min_bbox diff --git a/src/ansys/geometry/core/plotting/plotter.py b/src/ansys/geometry/core/plotting/plotter.py index 32629d8938..57e0a32d2d 100644 --- a/src/ansys/geometry/core/plotting/plotter.py +++ b/src/ansys/geometry/core/plotting/plotter.py @@ -170,7 +170,7 @@ def plot_sketch( sketch: Sketch, show_plane: bool = False, show_frame: bool = False, - **plotting_options: Optional[dict] + **plotting_options: Optional[dict], ) -> None: """Plot a sketch in the scene. @@ -222,7 +222,7 @@ def add_component( component: Component, merge_component: bool = False, merge_bodies: bool = False, - **plotting_options + **plotting_options, ) -> None: """Add a component to the scene. @@ -267,7 +267,7 @@ def show( show_axes_at_origin: bool = True, show_plane: bool = True, jupyter_backend: Optional[str] = None, - **kwargs: Optional[dict] + **kwargs: Optional[dict], ) -> None: """Show the rendered scene on the screen. diff --git a/src/ansys/geometry/core/primitives/cylinder.py b/src/ansys/geometry/core/primitives/cylinder.py index 715914a81c..ed555976ef 100644 --- a/src/ansys/geometry/core/primitives/cylinder.py +++ b/src/ansys/geometry/core/primitives/cylinder.py @@ -1,12 +1,15 @@ """ Provides the ``Cylinder`` class.""" from beartype import beartype as check_input_types -from beartype.typing import Optional, Union +from beartype.typing import Union import numpy as np -from pint import Unit +from pint import Quantity -from ansys.geometry.core.math import Point3D, UnitVector3D, Vector3D -from ansys.geometry.core.misc import UNIT_LENGTH, UNITS, check_pint_unit_compatibility +from ansys.geometry.core.math import UNITVECTOR3D_X, UNITVECTOR3D_Z, Point3D, UnitVector3D, Vector3D +from ansys.geometry.core.misc import Distance +from ansys.geometry.core.primitives.circle import Circle +from ansys.geometry.core.primitives.line import Line +from ansys.geometry.core.primitives.surface_evaluation import ParamUV, SurfaceEvaluation from ansys.geometry.core.typing import Real, RealSequence @@ -18,101 +21,188 @@ class Cylinder: ---------- origin : Union[~numpy.ndarray, RealSequence, Point3D] Origin of the cylinder. - direction_x : Union[~numpy.ndarray, RealSequence, UnitVector3D, Vector3D] - X-plane direction. - direction_y : Union[~numpy.ndarray, RealSequence, UnitVector3D, Vector3D] - Y-plane direction. - radius : Real + radius : Union[Quantity, Distance, Real] Radius of the cylinder. - height : Real - Height of the cylinder. - unit : Unit, default: UNIT_LENGTH - Units for defining the radius and height. + reference : Union[~numpy.ndarray, RealSequence, UnitVector3D, Vector3D] + X-axis direction. + axis : Union[~numpy.ndarray, RealSequence, UnitVector3D, Vector3D] + Z-axis direction. """ @check_input_types def __init__( self, origin: Union[np.ndarray, RealSequence, Point3D], - direction_x: Union[np.ndarray, RealSequence, UnitVector3D, Vector3D], - direction_y: Union[np.ndarray, RealSequence, UnitVector3D, Vector3D], - radius: Real, - height: Real, - unit: Optional[Unit] = UNIT_LENGTH, + radius: Union[Quantity, Distance, Real], + reference: Union[np.ndarray, RealSequence, UnitVector3D, Vector3D] = UNITVECTOR3D_X, + axis: Union[np.ndarray, RealSequence, UnitVector3D, Vector3D] = UNITVECTOR3D_Z, ): """Constructor method for the ``Cylinder`` class.""" - check_pint_unit_compatibility(unit, UNIT_LENGTH) - self._unit = unit - _, self._base_unit = UNITS.get_base_units(unit) - self._origin = Point3D(origin) if not isinstance(origin, Point3D) else origin - self._direction_x = ( - UnitVector3D(direction_x) if not isinstance(direction_x, UnitVector3D) else direction_x - ) - self._direction_y = ( - UnitVector3D(direction_y) if not isinstance(direction_y, UnitVector3D) else direction_y + self._reference = ( + UnitVector3D(reference) if not isinstance(reference, UnitVector3D) else reference ) + self._axis = UnitVector3D(axis) if not isinstance(axis, UnitVector3D) else axis + self._axis = UnitVector3D(axis) if not isinstance(axis, UnitVector3D) else axis + if not self._reference.is_perpendicular_to(self._axis): + raise ValueError("Cylinder reference (dir_x) and axis (dir_z) must be perpendicular.") - # Store values in base unit - self._radius = UNITS.convert(radius, self._unit, self._base_unit) - self._height = UNITS.convert(height, self._unit, self._base_unit) + self._radius = radius if isinstance(radius, Distance) else Distance(radius) + if self._radius.value <= 0: + raise ValueError("Radius must be a real positive value.") @property def origin(self) -> Point3D: """Origin of the cylinder.""" return self._origin - @origin.setter - @check_input_types - def origin(self, origin: Point3D) -> None: - self._origin = origin - @property - def radius(self) -> Real: + def radius(self) -> Quantity: """Radius of the cylinder.""" - return UNITS.convert(self._radius, self._base_unit, self._unit) - - @radius.setter - @check_input_types - def radius(self, radius: Real) -> None: - """Set the radius of the cylinder.""" - self._radius = UNITS.convert(radius, self._unit, self._base_unit) + return self._radius.value @property - def height(self) -> Real: - """Height of the cylinder.""" - return UNITS.convert(self._height, self._base_unit, self._unit) + def dir_x(self) -> UnitVector3D: + """X-direction of the cylinder.""" + return self._reference - @height.setter - @check_input_types - def height(self, height: Real) -> None: - """Set the height of the cylinder.""" - self._height = UNITS.convert(height, self._unit, self._base_unit) + @property + def dir_y(self) -> UnitVector3D: + """Y-direction of the cylinder.""" + return self.dir_z.cross(self.dir_x) @property - def unit(self) -> Unit: - """Unit of the radius and height.""" - return self._unit + def dir_z(self) -> UnitVector3D: + """Z-direction of the cylinder.""" + return self._axis - @unit.setter - @check_input_types - def unit(self, unit: Unit) -> None: - """Set the unit of the object.""" - check_pint_unit_compatibility(unit, UNIT_LENGTH) - self._unit = unit + def surface_area(self, height: Union[Quantity, Distance, Real]) -> Quantity: + """Surface area of the cylinder.""" + height = height if isinstance(height, Distance) else Distance(height) + if height.value <= 0: + raise ValueError("Height must be a real positive value.") + + return 2 * np.pi * self.radius * height.value + 2 * np.pi * self.radius**2 + + def volume(self, height: Union[Quantity, Distance, Real]) -> Quantity: + """Volume of the cylinder.""" + height = height if isinstance(height, Distance) else Distance(height) + if height.value <= 0: + raise ValueError("Height must be a real positive value.") + + return np.pi * self.radius**2 * height.value @check_input_types - def __eq__(self, other: object) -> bool: + def __eq__(self, other: "Cylinder") -> bool: """Equals operator for the ``Cylinder`` class.""" return ( - self._origin == other.origin - and self._radius == other.radius - and self._height == other.height - and self._direction_x == other._direction_x - and self._direction_y == other._direction_y + self._origin == other._origin + and self._radius == other._radius + and self._reference == other._reference + and self._axis == other._axis + ) + + def evaluate(self, parameter: ParamUV) -> "CylinderEvaluation": + """Evaluate the cylinder at the given parameters.""" + return CylinderEvaluation(self, parameter) + + def project_point(self, point: Point3D) -> "CylinderEvaluation": + """Project a point onto the cylinder and return its ``CylinderEvaluation``.""" + circle = Circle(self.origin, self.radius, self.dir_x, self.dir_z) + u = circle.project_point(point).parameter + + line = Line(self.origin, self.dir_z) + v = line.project_point(point).parameter + + return CylinderEvaluation(self, ParamUV(u, v)) + + +class CylinderEvaluation(SurfaceEvaluation): + """ + Provides ``Cylinder`` evaluation at certain parameters. + + Parameters + ---------- + cylinder: ~ansys.geometry.core.primitives.cylinder.Cylinder + The ``Cylinder`` object to be evaluated. + parameter: ParamUV + The parameters (u, v) at which the ``Cylinder`` evaluation is requested. + """ + + def __init__(self, cylinder: Cylinder, parameter: ParamUV) -> None: + """``CylinderEvaluation`` class constructor.""" + self._cylinder = cylinder + self._parameter = parameter + + @property + def cylinder(self) -> Cylinder: + """The cylinder being evaluated.""" + return self._cylinder + + @property + def parameter(self) -> ParamUV: + """The parameter that the evaluation is based upon.""" + return self._parameter + + def position(self) -> Point3D: + """The point on the cylinder, based on the evaluation.""" + return ( + self.cylinder.origin + + self.cylinder.radius.m * self.__cylinder_normal() + + self.parameter.v * self.cylinder.dir_z + ) + + def normal(self) -> UnitVector3D: + """The normal to the surface.""" + return UnitVector3D(self.__cylinder_normal()) + + def __cylinder_normal(self) -> Vector3D: + """The normal to the cylinder.""" + return ( + np.cos(self.parameter.u) * self.cylinder.dir_x + + np.sin(self.parameter.u) * self.cylinder.dir_y ) - def __ne__(self, other) -> bool: - """Not equals operator for the ``Cylinder`` class.""" - return not self == other + def __cylinder_tangent(self) -> Vector3D: + """The tangent to the cylinder.""" + return ( + -np.sin(self.parameter.u) * self.cylinder.dir_x + + np.cos(self.parameter.u) * self.cylinder.dir_y + ) + + def u_derivative(self) -> Vector3D: + """The first derivative with respect to u.""" + return self.cylinder.radius.m * self.__cylinder_tangent() + + def v_derivative(self) -> Vector3D: + """The first derivative with respect to v.""" + return self.cylinder.dir_z + + def uu_derivative(self) -> Vector3D: + """The second derivative with respect to u.""" + return -self.cylinder.radius.m * self.__cylinder_normal() + + def uv_derivative(self) -> Vector3D: + """The second derivative with respect to u and v.""" + return Vector3D([0, 0, 0]) + + def vv_derivative(self) -> Vector3D: + """The second derivative with respect to v.""" + return Vector3D([0, 0, 0]) + + def min_curvature(self) -> Real: + """The minimum curvature.""" + return 0 + + def min_curvature_direction(self) -> UnitVector3D: + """The minimum curvature direction.""" + return UnitVector3D(self.cylinder.dir_z) + + def max_curvature(self) -> Real: + """The maximum curvature.""" + return 1.0 / self.cylinder.radius.m + + def max_curvature_direction(self) -> UnitVector3D: + """The maximum curvature direction.""" + return UnitVector3D(self.u_derivative()) diff --git a/src/ansys/geometry/core/primitives/line.py b/src/ansys/geometry/core/primitives/line.py index 50e092c9d5..d976ea887f 100644 --- a/src/ansys/geometry/core/primitives/line.py +++ b/src/ansys/geometry/core/primitives/line.py @@ -19,7 +19,6 @@ def __init__( origin: Union[np.ndarray, RealSequence, Point3D], direction: Union[np.ndarray, RealSequence, UnitVector3D, Vector3D], ): - self._origin = Point3D(origin) if not isinstance(origin, Point3D) else origin self._direction = ( UnitVector3D(direction) if not isinstance(direction, UnitVector3D) else direction diff --git a/src/ansys/geometry/core/sketch/gears.py b/src/ansys/geometry/core/sketch/gears.py index 8480c022d5..a6eda7705d 100644 --- a/src/ansys/geometry/core/sketch/gears.py +++ b/src/ansys/geometry/core/sketch/gears.py @@ -460,7 +460,6 @@ def _generate_arcs( ] if not closing_involute: - # Initiate preliminary arc object - lives outside the scope of the loop preliminary_arc = None diff --git a/tests/integration/test_design.py b/tests/integration/test_design.py index 19732b6335..eb17585877 100644 --- a/tests/integration/test_design.py +++ b/tests/integration/test_design.py @@ -787,13 +787,18 @@ def test_download_file(modeler: Modeler, tmp_path_factory: pytest.TempPathFactor file_save = tmp_path_factory.mktemp("scdoc_files_save") / "cylinder.scdocx" design.save(file_location=file_save) - # Check for parasolid exports + # Check for other exports binary_parasolid_file = tmp_path_factory.mktemp("scdoc_files_download") / "cylinder.x_b" text_parasolid_file = tmp_path_factory.mktemp("scdoc_files_download") / "cylinder.x_t" + fmd_file = tmp_path_factory.mktemp("scdoc_files_download") / "cylinder.fmd" + design.download(binary_parasolid_file, format=DesignFileFormat.PARASOLID_BIN) design.download(text_parasolid_file, format=DesignFileFormat.PARASOLID_TEXT) + design.download(fmd_file, format=DesignFileFormat.FMD) + assert binary_parasolid_file.exists() assert text_parasolid_file.exists() + assert fmd_file.exists() def test_slot_extrusion(modeler: Modeler): @@ -870,6 +875,41 @@ def test_project_and_imprint_curves(modeler: Modeler): assert len(body.faces) == 8 +def test_copy_body(modeler: Modeler): + """Test copying a body.""" + + # Create your design on the server side + design = modeler.create_design("Design") + + sketch_1 = Sketch().circle(Point2D([10, 10], UNITS.mm), Quantity(10, UNITS.mm)) + body = design.extrude_sketch("Original", sketch_1, Distance(1, UNITS.mm)) + + # Copy body at same design level + copy = body.copy(design, "Copy") + assert len(design.bodies) == 2 + assert design.bodies[-1] == copy + + # Bodies should be distinct + assert body != copy + + # Copy body into sub-component + comp1 = design.add_component("comp1") + copy2 = body.copy(comp1, "Subcopy") + assert len(comp1.bodies) == 1 + assert comp1.bodies[-1] == copy2 + + # Copy a copy + comp2 = comp1.add_component("comp2") + copy3 = copy2.copy(comp2, "Copy3") + assert len(comp2.bodies) == 1 + assert comp2.bodies[-1] == copy3 + + # Ensure deleting original doesn't affect the copies + design.delete_body(body) + assert not body.is_alive + assert copy.is_alive + + def test_beams(modeler: Modeler): """Test beam creation.""" # Create your design on the server side diff --git a/tests/test_metadata.py b/tests/test_metadata.py index aaa5b57d7a..206afb25cb 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -2,7 +2,6 @@ def test_pkg_version(): - try: import importlib.metadata as importlib_metadata except ModuleNotFoundError: # pragma: no cover diff --git a/tests/test_primitives.py b/tests/test_primitives.py index 2ee04a211d..d0477f7123 100644 --- a/tests/test_primitives.py +++ b/tests/test_primitives.py @@ -28,60 +28,45 @@ def test_cylinder(): """``Cylinder`` construction and equivalency.""" # Create two Cylinder objects - origin = Point3D([42, 99, 13]) - radius = 100 - height = 200 - c_1 = Cylinder(origin, UnitVector3D([12, 31, 99]), UnitVector3D([25, 39, 82]), radius, height) - c_1_duplicate = Cylinder( - origin, UnitVector3D([12, 31, 99]), UnitVector3D([25, 39, 82]), radius, height - ) - c_2 = Cylinder( - Point3D([5, 8, 9]), UnitVector3D([55, 16, 73]), UnitVector3D([23, 67, 45]), 88, 76 - ) - c_with_array_definitions = Cylinder([5, 8, 9], [55, 16, 73], [23, 67, 45], 88, 76) + origin = Point3D([0, 0, 0]) + radius = 1 + c_1 = Cylinder(origin, radius) + duplicate = Cylinder(origin, radius) + c_2 = Cylinder(origin, 2) # Check that the equals operator works - assert c_1 == c_1_duplicate + assert c_1 == duplicate assert c_1 != c_2 - assert c_2 == c_with_array_definitions # Check cylinder definition assert c_1.origin.x == origin.x assert c_1.origin.y == origin.y assert c_1.origin.z == origin.z - assert c_1.radius == radius - assert c_1.height == height - - c_1.origin = new_origin = Point3D([42, 88, 99]) - c_1.radius = new_radius = 1000 - c_1.height = new_height = 2000 - - assert c_1.origin.x == new_origin.x - assert c_1.origin.y == new_origin.y - assert c_1.origin.z == new_origin.z - assert c_1.radius == new_radius - assert c_1.height == new_height + assert c_1.radius.m == radius + assert c_1.radius.u == "meter" + assert isinstance(c_1.radius, Quantity) + assert np.allclose(c_1.dir_x, UNITVECTOR3D_X) + assert np.allclose(c_1.dir_y, UNITVECTOR3D_Y) + assert np.allclose(c_1.dir_z, UNITVECTOR3D_Z) + + assert Accuracy.length_is_equal(c_1.surface_area(1).m, 12.5663706) + assert c_1.surface_area(1).u == "meter ** 2" + assert isinstance(c_1.surface_area(1), Quantity) + assert Accuracy.length_is_equal(c_1.volume(1).m, 3.14159265) + assert c_1.volume(1).u == "meter ** 3" + assert isinstance(c_1.volume(1), Quantity) with pytest.raises(BeartypeCallHintParamViolation): - Cylinder(origin, UnitVector3D([12, 31, 99]), UnitVector3D([25, 39, 82]), "A", 200) + Cylinder(origin, "A") with pytest.raises(BeartypeCallHintParamViolation): - Cylinder(origin, UnitVector3D([12, 31, 99]), UnitVector3D([25, 39, 82]), 100, "A") + Cylinder(origin, 100, "A", UnitVector3D([25, 39, 82])) with pytest.raises(BeartypeCallHintParamViolation): - c_1.radius = "A" + Cylinder(origin, 100, UnitVector3D([12, 31, 99]), "A") - with pytest.raises(BeartypeCallHintParamViolation): - c_1.height = "A" - - with pytest.raises(BeartypeCallHintParamViolation): - c_1.origin = "A" - - with pytest.raises(BeartypeCallHintParamViolation): - Cylinder(origin, "A", UnitVector3D([25, 39, 82]), 100, 200) - - with pytest.raises(BeartypeCallHintParamViolation): - Cylinder(origin, UnitVector3D([12, 31, 99]), "A", 100, 200) + with pytest.raises(ValueError): + Cylinder(origin, 1, UnitVector3D([1, 0, 0]), UnitVector3D([1, 1, 1])) def test_cylinder_units(): @@ -89,48 +74,65 @@ def test_cylinder_units(): origin = Point3D([42, 99, 13]) radius = 100 - height = 200 unit = UNITS.mm - # Verify rejection of invalid base unit type - with pytest.raises( - TypeError, - match=r"The pint.Unit provided as an input should be a \[length\] quantity.", - ): - Cylinder( - origin, - UnitVector3D([12, 31, 99]), - UnitVector3D([25, 39, 82]), - radius, - height, - UNITS.celsius, - ) - c_1 = Cylinder( - origin, UnitVector3D([12, 31, 99]), UnitVector3D([25, 39, 82]), radius, height, unit - ) + c_1 = Cylinder(origin, Quantity(radius, unit)) # Verify rejection of invalid base unit type with pytest.raises( TypeError, match=r"The pint.Unit provided as an input should be a \[length\] quantity.", ): - c_1.unit = UNITS.celsius + Cylinder(origin, Quantity(radius, UNITS.celsius)) # Check that the units are correctly in place - assert c_1.unit == unit + assert c_1.radius.u == unit # Request for radius/height and ensure they are in mm - assert c_1.radius == radius - assert c_1.height == height - - # Check that the actual values are in base units (i.e. UNIT_LENGTH) - assert c_1._radius == (c_1.radius * c_1.unit).to_base_units().magnitude - assert c_1._height == (c_1.height * c_1.unit).to_base_units().magnitude + assert c_1.radius == Quantity(radius, unit) # Set unit to cm now... and check if the values changed - c_1.unit = new_unit = UNITS.cm - assert c_1.radius == UNITS.convert(radius, unit, new_unit) - assert c_1.height == UNITS.convert(height, unit, new_unit) + c_1._radius.unit = new_unit = UNITS.cm + assert c_1.radius.m == UNITS.convert(radius, unit, new_unit) + + +def test_cylinder_evaluation(): + origin = Point3D([0, 0, 0]) + radius = 1 + cylinder = Cylinder(origin, radius) + + eval = cylinder.evaluate(ParamUV(0, 0)) + + # Test base evaluation at (0, 0) + assert eval.cylinder == cylinder + assert np.allclose(eval.position(), Point3D([1, 0, 0])) + assert isinstance(eval.position(), Point3D) + assert np.allclose(eval.normal(), UnitVector3D([1, 0, 0])) + assert isinstance(eval.normal(), UnitVector3D) + assert np.allclose(eval.u_derivative(), Vector3D([0, 1, 0])) + assert isinstance(eval.u_derivative(), Vector3D) + assert np.allclose(eval.v_derivative(), Vector3D([0, 0, 1])) + assert isinstance(eval.v_derivative(), Vector3D) + assert np.allclose(eval.uu_derivative(), Vector3D([-1, 0, 0])) + assert isinstance(eval.uu_derivative(), Vector3D) + assert np.allclose(eval.uv_derivative(), Vector3D([0, 0, 0])) + assert isinstance(eval.uv_derivative(), Vector3D) + assert np.allclose(eval.vv_derivative(), Vector3D([0, 0, 0])) + assert isinstance(eval.vv_derivative(), Vector3D) + assert eval.min_curvature() == 0 + assert np.allclose(eval.min_curvature_direction(), UnitVector3D([0, 0, 1])) + assert isinstance(eval.min_curvature_direction(), UnitVector3D) + assert eval.max_curvature() == 1.0 + assert np.allclose(eval.max_curvature_direction(), UnitVector3D([0, 1, 0])) + assert isinstance(eval.max_curvature_direction(), UnitVector3D) + + # # Test evaluation by projecting a point onto the cylinder + eval2 = cylinder.project_point(Point3D([3, 3, 3])) + assert eval2.cylinder == cylinder + assert np.allclose(eval2.position(), Point3D([0.70710678, 0.70710678, 3])) + assert np.allclose(eval2.normal(), UnitVector3D([1, 1, 0])) + assert np.allclose(eval2.u_derivative().normalize(), UnitVector3D([-1, 1, 0])) + assert np.allclose(eval2.v_derivative(), Vector3D([0, 0, 1])) def test_sphere(): @@ -365,7 +367,7 @@ def test_cone_evaluation(): assert np.allclose(eval.max_curvature_direction(), UnitVector3D([0, 1, 0])) assert isinstance(eval.max_curvature_direction(), UnitVector3D) - # # Test evaluation by projecting a point onto the sphere + # # Test evaluation by projecting a point onto the cone eval2 = cone.project_point(Point3D([1, 1, 1])) assert eval2.cone == cone assert np.allclose(eval2.position(), Point3D([1.20710678, 1.20710678, 0.70710678]))