diff --git a/.github/workflows/nightly_docker_test.yml b/.github/workflows/nightly_docker_test.yml index f891eedcc8..399aa1e59d 100644 --- a/.github/workflows/nightly_docker_test.yml +++ b/.github/workflows/nightly_docker_test.yml @@ -44,8 +44,6 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ env.MAIN_PYTHON_VERSION }} - cache: 'pip' - cache-dependency-path: 'pyproject.toml' - name: Set up headless display uses: pyvista/setup-headless-display-action@v2 diff --git a/doc/changelog.d/1248.added.md b/doc/changelog.d/1248.added.md new file mode 100644 index 0000000000..fb51e57853 --- /dev/null +++ b/doc/changelog.d/1248.added.md @@ -0,0 +1 @@ +feat: revolve a sketch given an axis and an origin \ No newline at end of file diff --git a/doc/source/_static/thumbnails/revolving.png b/doc/source/_static/thumbnails/revolving.png new file mode 100644 index 0000000000..c9b0081304 Binary files /dev/null and b/doc/source/_static/thumbnails/revolving.png differ diff --git a/doc/source/conf.py b/doc/source/conf.py index 5c3dd8bbe3..197c4f45d7 100755 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -269,6 +269,7 @@ def intersphinx_pyansys_geometry(switcher_version: str): "examples/03_modeling/boolean_operations": "_static/thumbnails/boolean_operations.png", "examples/03_modeling/scale_map_mirror_bodies": "_static/thumbnails/scale_map_mirror_bodies.png", # noqa: E501 "examples/03_modeling/sweep_chain_profile": "_static/thumbnails/sweep_chain_profile.png", + "examples/03_modeling/revolving": "_static/thumbnails/revolving.png", "examples/03_modeling/export_design": "_static/thumbnails/export_design.png", "examples/04_applied/01_naca_airfoils": "_static/thumbnails/naca_airfoils.png", "examples/04_applied/02_naca_fluent": "_static/thumbnails/naca_fluent.png", diff --git a/doc/source/examples.rst b/doc/source/examples.rst index 2b7d5b5964..4e8a1bda8a 100644 --- a/doc/source/examples.rst +++ b/doc/source/examples.rst @@ -40,6 +40,7 @@ These examples demonstrate service-based modeling operations. examples/03_modeling/boolean_operations.mystnb examples/03_modeling/scale_map_mirror_bodies.mystnb examples/03_modeling/sweep_chain_profile.mystnb + examples/03_modeling/revolving.mystnb examples/03_modeling/export_design.mystnb Applied examples diff --git a/doc/source/examples/03_modeling/revolving.mystnb b/doc/source/examples/03_modeling/revolving.mystnb new file mode 100644 index 0000000000..f542627030 --- /dev/null +++ b/doc/source/examples/03_modeling/revolving.mystnb @@ -0,0 +1,126 @@ +--- +jupytext: + text_representation: + extension: .mystnb + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.1 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# Modeling: Revolving a sketch + +This example shows how to use the ``revolve_sketch()`` method to +revolve a sketch around an axis to create a 3D body. You can also +specify the angle of revolution to create a partial body. + +```{code-cell} ipython3 +# Imports +from ansys.geometry.core import Modeler +from ansys.geometry.core.math import ( + Plane, + Point2D, + Point3D, + UNITVECTOR3D_X, + UNITVECTOR3D_Z, +) +from ansys.geometry.core.misc import UNITS, Angle +from ansys.geometry.core.sketch import Sketch + +``` + +```{code-cell} ipython3 +# Initialize the modeler for this example notebook +m = Modeler() +``` + +## Example: Creating a quarter of a donut + +The following code snippets show how to use the ``revolve_sketch()`` function to create a +quarter of a 3D donut. The process involves defining a quarter of a circle as a profile +and then revolving it around the Z-axis to create a 3D body. + +### Initialize the sketch design + +Create a design sketch named ``quarter-donut``. + +```{code-cell} ipython3 +# Initialize the donut sketch design +design = m.create_design("quarter-donut") +``` + +### Define circle parameters + +Set ``path_radius``, which represents the radius of the circular path that the profile +circle sweeps along, to ``5`` units. +Set ``profile_radius``, which represents the radius of the profile circle that sweeps +along the path to create the donut body, to ``2`` units. + +```{code-cell} ipython3 +# Donut parameters +path_radius = 5 +profile_radius = 2 +``` + +### Create the profile circle + +Create a circle on the XZ plane centered at the coordinates ``(5, 0, 0)`` +and use``profile_radius`` to define the radius. This circle serves as the +profile or cross-sectional shape of the donut. + +```{code-cell} ipython3 +# Create the circular profile on the XZ-plane centered at (5, 0, 0) +# with a radius of 2 +plane_profile = Plane( + origin=Point3D([path_radius, 0, 0]), + direction_x=UNITVECTOR3D_X, + direction_y=UNITVECTOR3D_Z, +) +profile = Sketch(plane=plane_profile) +profile.circle(Point2D([0, 0]), profile_radius) + +profile.plot() +``` + +### Perform the revolve operation + +Revolve the profile circle around the Z axis to create a quarter of a donut body. +Set the angle of revolution to 90 degrees in the default direction, which is counterclockwise. + +```{code-cell} ipython3 +# Revolve the profile around the Z axis and center in the absolute origin +# for an angle of 90 degrees +design.revolve_sketch( + "quarter-donut-body", + sketch=profile, + axis=UNITVECTOR3D_Z, + angle=Angle(90, unit=UNITS.degrees), + rotation_origin=Point3D([0, 0, 0]), +) + +design.plot() +``` + +### Perform a revolve operation with a negative angle of revolution + +You can use a negative angle of revolution to create a quarter of a donut in the opposite direction. The following code snippet shows how to create a quarter of a donut in the clockwise direction. The same profile circle is used, but the angle of revolution is set to -90 degrees. + +```{code-cell} ipython3 +# Initialize the donut sketch design +design = m.create_design("quarter-donut-negative") + +# Revolve the profile around the Z axis and center in the absolute origin +# for an angle of -90 degrees (clockwise) +design.revolve_sketch( + "quarter-donut-body-negative", + sketch=profile, + axis=UNITVECTOR3D_Z, + angle=Angle(-90, unit=UNITS.degrees), + rotation_origin=Point3D([0, 0, 0]), +) + +design.plot() +``` diff --git a/doc/source/examples/03_modeling/sweep_chain_profile.mystnb b/doc/source/examples/03_modeling/sweep_chain_profile.mystnb index dc8cb4d6e9..f6bb50066a 100644 --- a/doc/source/examples/03_modeling/sweep_chain_profile.mystnb +++ b/doc/source/examples/03_modeling/sweep_chain_profile.mystnb @@ -75,7 +75,7 @@ defined by ``profile_radius``. This circle serves as the profile or cross-sectio shape of the donut. ```{code-cell} ipython3 -# Create the circlular profile on the XZ-plane centered at (5, 0, 0) +# Create the circular profile on the XZ plane centered at (5, 0, 0) # with a radius of 2 plane_profile = Plane(direction_x=UNITVECTOR3D_X, direction_y=UNITVECTOR3D_Z) profile = Sketch(plane=plane_profile) @@ -90,7 +90,7 @@ Another circle, representing the path along which the profile circle is swept, i the XY-plane centered at (0, 0, 0). The radius of this circle is defined by ``path_radius``. ```{code-cell} ipython3 -# Create the circlular path on the XY-plane centered at (0, 0, 0) with radius 5 +# Create the circular path on the XY plane centered at (0, 0, 0) with radius 5 path = [Circle(Point3D([0, 0, 0]), path_radius).trim(Interval(0, 2 * np.pi))] ``` diff --git a/src/ansys/geometry/core/designer/component.py b/src/ansys/geometry/core/designer/component.py index 72645963a2..90ac7beaf9 100644 --- a/src/ansys/geometry/core/designer/component.py +++ b/src/ansys/geometry/core/designer/component.py @@ -73,7 +73,9 @@ from ansys.geometry.core.math.vector import UnitVector3D, Vector3D from ansys.geometry.core.misc.checks import ensure_design_is_active, min_backend_version from ansys.geometry.core.misc.measurements import DEFAULT_UNITS, Angle, Distance +from ansys.geometry.core.shapes.curves.circle import Circle from ansys.geometry.core.shapes.curves.trimmed_curve import TrimmedCurve +from ansys.geometry.core.shapes.parameterization import Interval from ansys.geometry.core.sketch.sketch import Sketch from ansys.geometry.core.typing import Real @@ -588,6 +590,76 @@ def sweep_chain( self._master_component.part.bodies.append(tb) return Body(response.id, response.name, self, tb) + @min_backend_version(24, 2, 0) + @check_input_types + def revolve_sketch( + self, + name: str, + sketch: Sketch, + axis: Vector3D, + angle: Union[Quantity, Angle, Real], + rotation_origin: Point3D, + ) -> Body: + """ + Create a solid body by revolving a sketch profile around an axis. + + Notes + ----- + It is important that the sketch plane origin is not coincident with the rotation + origin. If the sketch plane origin is coincident with the rotation origin, the + distance between the two points is zero, and the revolve operation fails. + + Parameters + ---------- + name : str + User-defined label for the new solid body. + sketch : Sketch + Two-dimensional sketch source for the revolve. + axis : Vector3D + Axis of rotation for the revolve. + angle : Union[~pint.Quantity, Angle, Real] + Angle to revolve the solid body around the axis. The angle can be positive or negative. + rotation_origin : Point3D + Origin of the axis of rotation. + + Returns + ------- + Body + Revolved body from the given sketch. + """ + # Check that the sketch plane origin is not coincident with the rotation origin + if sketch.plane.origin == rotation_origin: + raise ValueError( + "The sketch plane origin is coincident with the rotation origin. " + + "The distance between the points is zero, and the revolve operation will fail." + ) + + # Compute the distance between the rotation origin and the sketch plane + rotation_origin_to_sketch = sketch.plane.origin - rotation_origin + rotation_origin_to_sketch_as_vector = Vector3D(rotation_origin_to_sketch) + distance = Distance( + rotation_origin_to_sketch_as_vector.norm, + unit=rotation_origin_to_sketch.base_unit, + ) + + # Define the revolve path + circle = Circle( + rotation_origin, + radius=distance, + reference=rotation_origin_to_sketch_as_vector, + axis=axis, + ) + angle = angle if isinstance(angle, Angle) else Angle(angle) + interval = ( + Interval(0, angle.value.m_as(DEFAULT_UNITS.SERVER_ANGLE)) + if angle.value.m >= 0 + else Interval(angle.value.m_as(DEFAULT_UNITS.SERVER_ANGLE), 0) + ) + path = circle.trim(interval) + + # Create the revolved body by delegating to the sweep method + return self.sweep_sketch(name, sketch, [path]) + @protect_grpc @check_input_types @ensure_design_is_active diff --git a/tests/integration/image_cache/results/.gitignore b/tests/integration/image_cache/results/.gitignore deleted file mode 100644 index d6b7ef32c8..0000000000 --- a/tests/integration/image_cache/results/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/integration/test_design.py b/tests/integration/test_design.py index 3e9db54d23..25c89c8255 100644 --- a/tests/integration/test_design.py +++ b/tests/integration/test_design.py @@ -54,10 +54,8 @@ UnitVector3D, Vector3D, ) -from ansys.geometry.core.misc import DEFAULT_UNITS, UNITS, Accuracy, Distance -from ansys.geometry.core.shapes.curves.circle import Circle -from ansys.geometry.core.shapes.curves.ellipse import Ellipse -from ansys.geometry.core.shapes.parameterization import Interval, ParamUV +from ansys.geometry.core.misc import DEFAULT_UNITS, UNITS, Accuracy, Angle, Distance +from ansys.geometry.core.shapes import Circle, Ellipse, Interval, ParamUV from ansys.geometry.core.sketch import Sketch from .conftest import FILES_DIR, skip_if_linux @@ -2286,3 +2284,60 @@ def test_create_body_from_loft_profile(modeler: Modeler): # check volume of body # expected is 0 since it's not a closed surface assert result.volume.m == 0 + + +def test_revolve_sketch(modeler: Modeler): + """Test revolving a circular profile for a quarter donut.""" + # Initialize the donut sketch design + design = modeler.create_design("quarter-donut") + + # Donut parameters + path_radius = 5 + profile_radius = 2 + + # Create the circular profile on the XZ plane centered at (5, 0, 0) + # with a radius of 2 + plane_profile = Plane( + origin=Point3D([path_radius, 0, 0]), direction_x=UNITVECTOR3D_X, direction_y=UNITVECTOR3D_Z + ) + profile = Sketch(plane=plane_profile) + profile.circle(Point2D([0, 0]), profile_radius) + + # Revolve the profile around the Z axis and center in the absolute origin + # for an angle of 90 degrees + body = design.revolve_sketch( + "donut-body", + sketch=profile, + axis=UNITVECTOR3D_Z, + angle=Angle(90, unit=UNITS.degrees), + rotation_origin=Point3D([0, 0, 0]), + ) + + assert body.is_surface == False + assert body.name == "donut-body" + assert np.isclose(body.volume.m, np.pi**2 * 2 * 5, rtol=1e-3) # quarter of a torus volume + + +def test_revolve_sketch_fail(modeler: Modeler): + """Test demonstrating the failure of revolving a sketch when it is located in the + same origin.""" + # Initialize the donut sketch design + design = modeler.create_design("revolve-fail") + + # Create an XZ plane centered at (0, 0, 0) + plane_profile = Plane( + origin=Point3D([0, 0, 0]), direction_x=UNITVECTOR3D_X, direction_y=UNITVECTOR3D_Z + ) + profile = Sketch(plane=plane_profile) + + # Try revolving the profile... + with pytest.raises( + ValueError, match="The sketch plane origin is coincident with the rotation origin." + ): + design.revolve_sketch( + "donut-body", + sketch=profile, + axis=UNITVECTOR3D_Z, + angle=Angle(90, unit=UNITS.degrees), + rotation_origin=Point3D([0, 0, 0]), + ) diff --git a/tests/integration/test_plotter.py b/tests/integration/test_plotter.py index 6a9770b989..0b0f9b7a4b 100644 --- a/tests/integration/test_plotter.py +++ b/tests/integration/test_plotter.py @@ -30,7 +30,9 @@ from ansys.geometry.core import Modeler from ansys.geometry.core.math import UNITVECTOR3D_Y, UNITVECTOR3D_Z, Plane, Point2D, Point3D +from ansys.geometry.core.math.constants import UNITVECTOR3D_X from ansys.geometry.core.misc import DEFAULT_UNITS, UNITS, Distance +from ansys.geometry.core.misc.measurements import Angle from ansys.geometry.core.plotting import GeometryPlotter from ansys.geometry.core.sketch import ( Arc, @@ -688,3 +690,73 @@ def test_plot_design_point(modeler: Modeler, verify_image_cache): pl.show( screenshot=Path(IMAGE_RESULTS_DIR, "test_plot_design_point.png"), ) + + +def test_plot_revolve_sketch_normal(modeler: Modeler): + """Test plotting of a sketch revolved around an axis.""" + # Initialize the donut sketch design + design = modeler.create_design("quarter-donut") + + # Donut parameters + path_radius = 5 + profile_radius = 2 + + # Create the circular profile on the XZ plane centered at (5, 0, 0) + # with a radius of 2 + plane_profile = Plane( + origin=Point3D([path_radius, 0, 0]), direction_x=UNITVECTOR3D_X, direction_y=UNITVECTOR3D_Z + ) + profile = Sketch(plane=plane_profile) + profile.circle(Point2D([0, 0]), profile_radius) + + # Revolve the profile around the Z axis and center in the absolute origin + # for an angle of 90 degrees + design.revolve_sketch( + "donut-body", + sketch=profile, + axis=UNITVECTOR3D_Z, + angle=Angle(90, unit=UNITS.degrees), + rotation_origin=Point3D([0, 0, 0]), + ) + + # plot + pl = GeometryPlotter() + pl.plot(design) + pl.show( + screenshot=Path(IMAGE_RESULTS_DIR, "test_plot_revolve_sketch_normal.png"), + ) + + +def test_plot_revolve_sketch_negative_angle(modeler: Modeler): + """Test plotting of a sketch revolved around an axis with a negative angle.""" + # Initialize the donut sketch design + design = modeler.create_design("quarter-donut") + + # Specify donut parameters + path_radius = 5 + profile_radius = 2 + + # Create the circular profile on the XZ plane centered at (5, 0, 0) + # with a radius of 2 + plane_profile = Plane( + origin=Point3D([path_radius, 0, 0]), direction_x=UNITVECTOR3D_X, direction_y=UNITVECTOR3D_Z + ) + profile = Sketch(plane=plane_profile) + profile.circle(Point2D([0, 0]), profile_radius) + + # Revolve the profile around the Z axis and centered in the absolute origin + # for an angle of 90 degrees + design.revolve_sketch( + "donut-body-negative", + sketch=profile, + axis=UNITVECTOR3D_Z, + angle=Angle(-90, unit=UNITS.degrees), + rotation_origin=Point3D([0, 0, 0]), + ) + + # plot + pl = GeometryPlotter() + pl.plot(design) + pl.show( + screenshot=Path(IMAGE_RESULTS_DIR, "test_plot_revolve_sketch_negative_angle.png"), + )