diff --git a/doc/changelog.d/1653.added.md b/doc/changelog.d/1653.added.md new file mode 100644 index 0000000000..fa342e2c78 --- /dev/null +++ b/doc/changelog.d/1653.added.md @@ -0,0 +1 @@ +create circular and fill patterns \ No newline at end of file diff --git a/src/ansys/geometry/core/designer/body.py b/src/ansys/geometry/core/designer/body.py index fb0a846ee9..585b7a7213 100644 --- a/src/ansys/geometry/core/designer/body.py +++ b/src/ansys/geometry/core/designer/body.py @@ -1250,6 +1250,18 @@ def wrapper(self: "Body", *args, **kwargs): def _reset_tessellation_cache(self): # noqa: N805 """Reset the cached tessellation for a body.""" self._template._tessellation = None + # if this reference is stale, reset the real cache in the part + # this gets the matching id master body in the part + master_in_part = next( + ( + b + for b in self.parent_component._master_component.part.bodies + if b.id == self._template.id + ), + None, + ) + if master_in_part is not None: + master_in_part._tessellation = None @property def id(self) -> str: # noqa: D102 diff --git a/src/ansys/geometry/core/designer/face.py b/src/ansys/geometry/core/designer/face.py index 1ff0ba768a..b437a49123 100644 --- a/src/ansys/geometry/core/designer/face.py +++ b/src/ansys/geometry/core/designer/face.py @@ -182,8 +182,6 @@ def __init__( self._is_reversed = is_reversed self._shape = None - self._grpc_client.log.debug("Requesting surface properties from server.") - @property def id(self) -> str: """Face ID.""" diff --git a/src/ansys/geometry/core/designer/geometry_commands.py b/src/ansys/geometry/core/designer/geometry_commands.py index e4892d100a..230ccd6d27 100644 --- a/src/ansys/geometry/core/designer/geometry_commands.py +++ b/src/ansys/geometry/core/designer/geometry_commands.py @@ -26,6 +26,8 @@ from ansys.api.geometry.v0.commands_pb2 import ( ChamferRequest, + CreateCircularPatternRequest, + CreateFillPatternRequest, CreateLinearPatternRequest, ExtrudeEdgesRequest, ExtrudeEdgesUpToRequest, @@ -34,6 +36,7 @@ FilletRequest, FullFilletRequest, ModifyLinearPatternRequest, + PatternRequest, ) from ansys.api.geometry.v0.commands_pb2_grpc import CommandsStub from ansys.geometry.core.connection import GrpcClient @@ -83,6 +86,15 @@ class OffsetMode(Enum): MOVE_FACES_APART = 2 +@unique +class FillPatternType(Enum): + """Provides values for types of fill patterns.""" + + GRID = 0 + OFFSET = 1 + SKEWED = 2 + + class GeometryCommands: """Provides geometry commands for PyAnsys Geometry. @@ -611,3 +623,193 @@ def modify_linear_pattern( ) return result.result.success + + @protect_grpc + @min_backend_version(25, 2, 0) + def create_circular_pattern( + self, + selection: Union["Face", List["Face"]], + circular_axis: "Edge", + circular_count: int, + circular_angle: Real, + two_dimensional: bool = False, + linear_count: int = None, + linear_pitch: Real = None, + radial_direction: UnitVector3D = None, + ) -> bool: + """Create a circular pattern. The pattern can be one or two dimensions. + + Parameters + ---------- + selection : Face | List[Face] + Faces to create the pattern out of. + circular_axis : Edge + The axis of the circular pattern, determined by the direction of an edge. + circular_count : int + How many members are in the circular pattern. + circular_angle : Real + The angular range of the pattern. + two_dimensional : bool, default: False + If ``True``, create a two-dimensional pattern. + linear_count : int, default: None + How many times the circular pattern repeats along the radial lines for a + two-dimensional pattern. + linear_pitch : Real, default: None + The spacing along the radial lines for a two-dimensional pattern. + radial_direction : UnitVector3D, default: None + The direction from the center out for a two-dimensional pattern. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + from ansys.geometry.core.designer.face import Face + + selection: list[Face] = selection if isinstance(selection, list) else [selection] + + check_type_all_elements_in_iterable(selection, Face) + + for object in selection: + object.body._reset_tessellation_cache() + + if two_dimensional and None in (linear_count, linear_pitch): + raise ValueError( + "If the pattern is two-dimensional, linear_count and linear_pitch must be provided." + ) + if not two_dimensional and None not in ( + linear_count, + linear_pitch, + ): + raise ValueError( + ( + "You provided linear_count and linear_pitch. Ensure two_dimensional is True if " + "a two-dimensional pattern is desired." + ) + ) + + result = self._commands_stub.CreateCircularPattern( + CreateCircularPatternRequest( + selection=[object._grpc_id for object in selection], + circular_axis=circular_axis._grpc_id, + circular_count=circular_count, + circular_angle=circular_angle, + two_dimensional=two_dimensional, + linear_count=linear_count, + linear_pitch=linear_pitch, + radial_direction=None + if radial_direction is None + else unit_vector_to_grpc_direction(radial_direction), + ) + ) + + return result.result.success + + @protect_grpc + @min_backend_version(25, 2, 0) + def create_fill_pattern( + self, + selection: Union["Face", List["Face"]], + linear_direction: Union["Edge", "Face"], + fill_pattern_type: FillPatternType, + margin: Real, + x_spacing: Real, + y_spacing: Real, + row_x_offset: Real = 0, + row_y_offset: Real = 0, + column_x_offset: Real = 0, + column_y_offset: Real = 0, + ) -> bool: + """Create a fill pattern. + + Parameters + ---------- + selection : Face | List[Face] + Faces to create the pattern out of. + linear_direction : Edge + Direction of the linear pattern, determined by the direction of an edge. + fill_pattern_type : FillPatternType + The type of fill pattern. + margin : Real + Margin defining the border of the fill pattern. + x_spacing : Real + Spacing between the pattern members in the x direction. + y_spacing : Real + Spacing between the pattern members in the x direction. + row_x_offset : Real, default: 0 + Offset for the rows in the x direction. Only used with ``FillPattern.SKEWED``. + row_y_offset : Real, default: 0 + Offset for the rows in the y direction. Only used with ``FillPattern.SKEWED``. + column_x_offset : Real, default: 0 + Offset for the columns in the x direction. Only used with ``FillPattern.SKEWED``. + column_y_offset : Real, default: 0 + Offset for the columns in the y direction. Only used with ``FillPattern.SKEWED``. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + from ansys.geometry.core.designer.face import Face + + selection: list[Face] = selection if isinstance(selection, list) else [selection] + + check_type_all_elements_in_iterable(selection, Face) + + for object in selection: + object.body._reset_tessellation_cache() + + result = self._commands_stub.CreateFillPattern( + CreateFillPatternRequest( + selection=[object._grpc_id for object in selection], + linear_direction=linear_direction._grpc_id, + fill_pattern_type=fill_pattern_type.value, + margin=margin, + x_spacing=x_spacing, + y_spacing=y_spacing, + row_x_offset=row_x_offset, + row_y_offset=row_y_offset, + column_x_offset=column_x_offset, + column_y_offset=column_y_offset, + ) + ) + + return result.result.success + + @protect_grpc + @min_backend_version(25, 2, 0) + def update_fill_pattern( + self, + selection: Union["Face", List["Face"]], + ) -> bool: + """Update a fill pattern. + + When the face that a fill pattern exists upon changes in size, the + fill pattern can be updated to fill the new space. + + Parameters + ---------- + selection : Face | List[Face] + Face(s) that are part of a fill pattern. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + from ansys.geometry.core.designer.face import Face + + selection: list[Face] = selection if isinstance(selection, list) else [selection] + + check_type_all_elements_in_iterable(selection, Face) + + for object in selection: + object.body._reset_tessellation_cache() + + result = self._commands_stub.UpdateFillPattern( + PatternRequest( + selection=[object._grpc_id for object in selection], + ) + ) + + return result.result.success diff --git a/tests/integration/test_geometry_commands.py b/tests/integration/test_geometry_commands.py index 725cf6a9ac..732d925e37 100644 --- a/tests/integration/test_geometry_commands.py +++ b/tests/integration/test_geometry_commands.py @@ -21,10 +21,15 @@ # SOFTWARE. """Testing of geometry commands.""" +import numpy as np from pint import Quantity import pytest -from ansys.geometry.core.designer.geometry_commands import ExtrudeType, OffsetMode +from ansys.geometry.core.designer.geometry_commands import ( + ExtrudeType, + FillPatternType, + OffsetMode, +) from ansys.geometry.core.math import Point3D, UnitVector3D from ansys.geometry.core.math.point import Point2D from ansys.geometry.core.misc import UNITS @@ -349,3 +354,154 @@ def test_linear_pattern(modeler: Modeler): modeler.geometry_commands.create_linear_pattern( body.faces[-1], body.edges[0], 5, 0.2, False, 5, 0.2 ) + + +def test_circular_pattern(modeler: Modeler): + design = modeler.create_design("d1") + base = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + axis = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 0.01, 0.01), 1) + base.subtract(axis) + axis = base.edges[-4] + + cutout = design.extrude_sketch("cylinder", Sketch().circle(Point2D([-0.2, 0]), 0.005), 1) + base.subtract(cutout) + + assert base.volume.m == pytest.approx( + Quantity(0.999821460184, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 11 + + # full two-dimensional test - creates 3 rings around the center + success = modeler.geometry_commands.create_circular_pattern( + base.faces[-1], axis, 12, np.pi * 2, True, 3, 0.05, UnitVector3D([1, 0, 0]) + ) + assert success + assert base.volume.m == pytest.approx( + Quantity(0.997072566612, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 46 + + # input validation test + with pytest.raises( + ValueError, + match="If the pattern is two-dimensional, linear_count and linear_pitch must be provided.", + ): + modeler.geometry_commands.create_circular_pattern(base.faces[-1], axis, 12, np.pi * 2, True) + with pytest.raises( + ValueError, + match=( + "You provided linear_count and linear_pitch. Ensure two_dimensional is True if a " + "two-dimensional pattern is desired." + ), + ): + modeler.geometry_commands.create_circular_pattern( + base.faces[-1], axis, 12, np.pi * 2, False, 3, 0.05 + ) + + +def test_fill_pattern(modeler: Modeler): + design = modeler.create_design("d1") + + # grid fill pattern + base = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + cutout = design.extrude_sketch("cylinder", Sketch().circle(Point2D([-0.4, -0.4]), 0.05), 1) + base.subtract(cutout) + assert base.volume.m == pytest.approx( + Quantity(0.992146018366, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 7 + + success = modeler.geometry_commands.create_fill_pattern( + base.faces[-1], + base.edges[2], + FillPatternType.GRID, + 0.01, + 0.1, + 0.1, + ) + assert success + assert base.volume.m == pytest.approx( + Quantity(0.803650459151, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 31 + + # offset fill pattern + base = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + cutout = design.extrude_sketch("cylinder", Sketch().circle(Point2D([-0.4, -0.4]), 0.05), 1) + base.subtract(cutout) + assert base.volume.m == pytest.approx( + Quantity(0.992146018366, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 7 + + success = modeler.geometry_commands.create_fill_pattern( + base.faces[-1], + base.edges[2], + FillPatternType.OFFSET, + 0.01, + 0.05, + 0.05, + ) + assert success + assert base.volume.m == pytest.approx( + Quantity(0.670132771373, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 48 + + # skewed fill pattern + base = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + cutout = design.extrude_sketch("cylinder", Sketch().circle(Point2D([-0.4, -0.4]), 0.05), 1) + base.subtract(cutout) + assert base.volume.m == pytest.approx( + Quantity(0.992146018366, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 7 + + success = modeler.geometry_commands.create_fill_pattern( + base.faces[-1], + base.edges[2], + FillPatternType.SKEWED, + 0.01, + 0.1, + 0.1, + 0.1, + 0.2, + 0.2, + 0.1, + ) + assert success + assert base.volume.m == pytest.approx( + Quantity(0.787942495883, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 33 + + # update fill pattern + base = design.extrude_sketch("update_fill", Sketch().box(Point2D([0, 0]), 1, 1), 1) + cutout = design.extrude_sketch("cylinder", Sketch().circle(Point2D([-0.4, -0.4]), 0.05), 1) + base.subtract(cutout) + base.translate(UnitVector3D([1, 0, 0]), 5) + assert base.volume.m == pytest.approx( + Quantity(0.992146018366, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 7 + + success = modeler.geometry_commands.create_fill_pattern( + base.faces[-1], + base.edges[2], + FillPatternType.GRID, + 0.01, + 0.1, + 0.1, + ) + assert success + assert base.volume.m == pytest.approx( + Quantity(0.803650459151, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 31 + + face = base.faces[3] + modeler.geometry_commands.extrude_faces(face, 1, face.normal(0, 0)) + success = modeler.geometry_commands.update_fill_pattern(base.faces[-1]) + assert success + assert base.volume.m == pytest.approx(Quantity(1.60730091830, UNITS.m**3).m, rel=1e-6, abs=1e-8) + assert len(base.faces) == 56