diff --git a/doc/changelog.d/1056.added.md b/doc/changelog.d/1056.added.md new file mode 100644 index 0000000000..d49517557b --- /dev/null +++ b/doc/changelog.d/1056.added.md @@ -0,0 +1 @@ +feat: sweeping chains and profiles \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 109a3bc4c7..6838161756 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ - "ansys-api-geometry==0.3.12", + "ansys-api-geometry==0.3.13", "ansys-tools-path>=0.3,<1", "beartype>=0.11.0,<1", "google-api-python-client>=1.7.11,<3", diff --git a/src/ansys/geometry/core/connection/conversions.py b/src/ansys/geometry/core/connection/conversions.py index cda75e9a9f..2eed3988a1 100644 --- a/src/ansys/geometry/core/connection/conversions.py +++ b/src/ansys/geometry/core/connection/conversions.py @@ -583,16 +583,11 @@ def trimmed_curve_to_grpc_trimmed_curve(curve: "TrimmedCurve") -> GRPCTrimmedCur Geometry service gRPC ``TrimmedCurve`` message. """ curve_geometry = curve_to_grpc_curve(curve.geometry) - start = point3d_to_grpc_point(curve.start) - end = point3d_to_grpc_point(curve.end) i_start = curve.interval.start i_end = curve.interval.end return GRPCTrimmedCurve( curve=curve_geometry, - start=start, - end=end, interval_start=i_start, interval_end=i_end, - length=curve.length.m, ) diff --git a/src/ansys/geometry/core/designer/component.py b/src/ansys/geometry/core/designer/component.py index 4ae1d21978..ba7933025f 100644 --- a/src/ansys/geometry/core/designer/component.py +++ b/src/ansys/geometry/core/designer/component.py @@ -31,6 +31,8 @@ CreateExtrudedBodyRequest, CreatePlanarBodyRequest, CreateSphereBodyRequest, + CreateSweepingChainRequest, + CreateSweepingProfileRequest, TranslateRequest, ) from ansys.api.geometry.v0.bodies_pb2_grpc import BodiesStub @@ -53,6 +55,7 @@ plane_to_grpc_plane, point3d_to_grpc_point, sketch_shapes_to_grpc_geometries, + trimmed_curve_to_grpc_trimmed_curve, unit_vector_to_grpc_direction, ) from ansys.geometry.core.designer.beam import Beam, BeamProfile @@ -69,6 +72,7 @@ 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.trimmed_curve import TrimmedCurve from ansys.geometry.core.sketch.sketch import Sketch from ansys.geometry.core.typing import Real @@ -485,6 +489,106 @@ def extrude_sketch( self._master_component.part.bodies.append(tb) return Body(response.id, response.name, self, tb) + @min_backend_version(24, 2, 0) + @protect_grpc + @check_input_types + @ensure_design_is_active + def sweep_sketch( + self, + name: str, + sketch: Sketch, + path: List[TrimmedCurve], + ) -> Body: + """ + Create a body by sweeping a planar profile along a path. + + Notes + ----- + The newly created body is placed under this component within the design assembly. + + Parameters + ---------- + name : str + User-defined label for the new solid body. + sketch : Sketch + Two-dimensional sketch source for the extrusion. + path : List[TrimmedCurve] + The path to sweep the profile along. + + Returns + ------- + Body + Created body from the given sketch. + """ + # Convert each ``TrimmedCurve`` in path to equivalent gRPC type + path_grpc = [] + for tc in path: + path_grpc.append(trimmed_curve_to_grpc_trimmed_curve(tc)) + + request = CreateSweepingProfileRequest( + name=name, + parent=self.id, + plane=plane_to_grpc_plane(sketch._plane), + geometries=sketch_shapes_to_grpc_geometries(sketch._plane, sketch.edges, sketch.faces), + path=path_grpc, + ) + + self._grpc_client.log.debug(f"Creating a sweeping profile on {self.id}. Creating body...") + response = self._bodies_stub.CreateSweepingProfile(request) + tb = MasterBody(response.master_id, name, self._grpc_client, is_surface=False) + self._master_component.part.bodies.append(tb) + return Body(response.id, response.name, self, tb) + + @min_backend_version(24, 2, 0) + @protect_grpc + @check_input_types + @ensure_design_is_active + def sweep_chain( + self, + name: str, + path: List[TrimmedCurve], + chain: List[TrimmedCurve], + ) -> Body: + """ + Create a body by sweeping a chain of curves along a path. + + Notes + ----- + The newly created body is placed under this component within the design assembly. + + Parameters + ---------- + name : str + User-defined label for the new solid body. + sketch : Sketch + Two-dimensional sketch source for the extrusion. + path : List[TrimmedCurve] + The path to sweep the chain along. + chain : List[TrimmedCurve] + A chain of trimmed curves. + + Returns + ------- + Body + Created body from the given sketch. + """ + # Convert each ``TrimmedCurve`` in path and chain to equivalent gRPC types + path_grpc = [trimmed_curve_to_grpc_trimmed_curve(tc) for tc in path] + chain_grpc = [trimmed_curve_to_grpc_trimmed_curve(tc) for tc in chain] + + request = CreateSweepingChainRequest( + name=name, + parent=self.id, + path=path_grpc, + chain=chain_grpc, + ) + + self._grpc_client.log.debug(f"Creating a sweeping chain on {self.id}. Creating body...") + response = self._bodies_stub.CreateSweepingChain(request) + tb = MasterBody(response.master_id, name, self._grpc_client, is_surface=True) + self._master_component.part.bodies.append(tb) + return Body(response.id, response.name, self, tb) + @protect_grpc @check_input_types @ensure_design_is_active diff --git a/src/ansys/geometry/core/designer/face.py b/src/ansys/geometry/core/designer/face.py index 2e87bc2bd2..e76ddd3f7e 100644 --- a/src/ansys/geometry/core/designer/face.py +++ b/src/ansys/geometry/core/designer/face.py @@ -39,6 +39,7 @@ from ansys.geometry.core.math.vector import UnitVector3D from ansys.geometry.core.misc.checks import ensure_design_is_active from ansys.geometry.core.misc.measurements import DEFAULT_UNITS +from ansys.geometry.core.shapes.box_uv import BoxUV from ansys.geometry.core.shapes.curves.trimmed_curve import TrimmedCurve from ansys.geometry.core.shapes.parameterization import Interval from ansys.geometry.core.shapes.surfaces.trimmed_surface import ( @@ -203,12 +204,17 @@ def shape(self) -> TrimmedSurface: direction of the normal vector to ensure it is always facing outward. """ if self._shape is None: + self._grpc_client.log.debug("Requesting face properties from server.") + surface_response = self._faces_stub.GetSurface(self._grpc_id) geometry = grpc_surface_to_surface(surface_response, self._surface_type) + box = self._faces_stub.GetBoxUV(self._grpc_id) + box_uv = BoxUV(Interval(box.start_u, box.end_u), Interval(box.start_v, box.end_v)) + self._shape = ( - ReversedTrimmedSurface(self, geometry) + ReversedTrimmedSurface(geometry, box_uv) if self.is_reversed - else TrimmedSurface(self, geometry) + else TrimmedSurface(geometry, box_uv) ) return self._shape diff --git a/src/ansys/geometry/core/shapes/curves/curve.py b/src/ansys/geometry/core/shapes/curves/curve.py index e15e4eee7f..e9222f5d85 100644 --- a/src/ansys/geometry/core/shapes/curves/curve.py +++ b/src/ansys/geometry/core/shapes/curves/curve.py @@ -22,12 +22,17 @@ """Provides the ``Curve`` class.""" from abc import ABC, abstractmethod +from beartype.typing import TYPE_CHECKING + from ansys.geometry.core.math.matrix import Matrix44 from ansys.geometry.core.math.point import Point3D from ansys.geometry.core.shapes.curves.curve_evaluation import CurveEvaluation -from ansys.geometry.core.shapes.parameterization import Parameterization +from ansys.geometry.core.shapes.parameterization import Interval, Parameterization from ansys.geometry.core.typing import Real +if TYPE_CHECKING: # pragma: no cover + from ansys.geometry.core.shapes.curves.trimmed_curve import TrimmedCurve + class Curve(ABC): """Provides the abstract base class representing a 3D curve.""" @@ -74,3 +79,22 @@ def project_point(self, point: Point3D) -> CurveEvaluation: This method returns the evaluation at the closest point. """ return + + def trim(self, interval: Interval) -> "TrimmedCurve": + """ + Trim this curve by bounding it with an interval. + + Returns + ------- + TrimmedCurve + The resulting bounded curve. + """ + from ansys.geometry.core.shapes.curves.trimmed_curve import TrimmedCurve + + return TrimmedCurve( + self, + self.evaluate(interval.start).position, + self.evaluate(interval.end).position, + interval, + None, # TODO: calculate length on client? + ) diff --git a/src/ansys/geometry/core/shapes/surfaces/surface.py b/src/ansys/geometry/core/shapes/surfaces/surface.py index fae48d4556..042b019c3a 100644 --- a/src/ansys/geometry/core/shapes/surfaces/surface.py +++ b/src/ansys/geometry/core/shapes/surfaces/surface.py @@ -22,13 +22,17 @@ """Provides the ``Surface`` class.""" from abc import ABC, abstractmethod -from beartype.typing import Tuple +from beartype.typing import TYPE_CHECKING, Tuple from ansys.geometry.core.math.matrix import Matrix44 from ansys.geometry.core.math.point import Point3D +from ansys.geometry.core.shapes.box_uv import BoxUV from ansys.geometry.core.shapes.parameterization import Parameterization, ParamUV from ansys.geometry.core.shapes.surfaces.surface_evaluation import SurfaceEvaluation +if TYPE_CHECKING: # pragma: no cover + from ansys.geometry.core.shapes.surfaces.trimmed_surface import TrimmedSurface + class Surface(ABC): """Provides the abstract base class for a 3D surface.""" @@ -75,3 +79,16 @@ def project_point(self, point: Point3D) -> SurfaceEvaluation: This method returns the evaluation at the closest point. """ return + + def trim(self, box_uv: BoxUV) -> "TrimmedSurface": + """ + Trim this surface by bounding it with a BoxUV. + + Returns + ------- + TrimmedSurface + The resulting bounded surface. + """ + from ansys.geometry.core.shapes.surfaces.trimmed_surface import TrimmedSurface + + return TrimmedSurface(self, box_uv) diff --git a/src/ansys/geometry/core/shapes/surfaces/trimmed_surface.py b/src/ansys/geometry/core/shapes/surfaces/trimmed_surface.py index 66978e0b4e..9b418e8536 100644 --- a/src/ansys/geometry/core/shapes/surfaces/trimmed_surface.py +++ b/src/ansys/geometry/core/shapes/surfaces/trimmed_surface.py @@ -21,19 +21,14 @@ # SOFTWARE. """Provides the ``TrimmedSurface`` class.""" -from beartype.typing import TYPE_CHECKING - from ansys.geometry.core.math.point import Point3D from ansys.geometry.core.math.vector import UnitVector3D from ansys.geometry.core.shapes.box_uv import BoxUV -from ansys.geometry.core.shapes.parameterization import Interval, ParamUV +from ansys.geometry.core.shapes.parameterization import ParamUV from ansys.geometry.core.shapes.surfaces.surface import Surface from ansys.geometry.core.shapes.surfaces.surface_evaluation import SurfaceEvaluation from ansys.geometry.core.typing import Real -if TYPE_CHECKING: - from ansys.geometry.core.designer.face import Face - class TrimmedSurface: """ @@ -50,15 +45,10 @@ class TrimmedSurface: Underlying mathematical representation of the surface. """ - def __init__(self, face: "Face", geometry: Surface): + def __init__(self, geometry: Surface, box_uv: BoxUV): """Initialize an instance of a trimmed surface.""" - self._face = face self._geometry = geometry - - @property - def face(self) -> "Face": - """Face the trimmed surface belongs to.""" - return self._face + self._box_uv = box_uv @property def geometry(self) -> Surface: @@ -68,9 +58,7 @@ def geometry(self) -> Surface: @property def box_uv(self) -> BoxUV: """Bounding BoxUV of the surface.""" - self._face._grpc_client.log.debug("Requesting box UV from server.") - box = self._face._faces_stub.GetBoxUV(self.face._grpc_id) - return BoxUV(Interval(box.start_u, box.end_u), Interval(box.start_v, box.end_v)) + return self._box_uv def get_proportional_parameters(self, param_uv: ParamUV) -> ParamUV: """ @@ -154,7 +142,7 @@ def evaluate_proportion(self, u: Real, v: Real) -> SurfaceEvaluation: ) ) - # TODO: perimeter + # TODO: perimeter, area? class ReversedTrimmedSurface(TrimmedSurface): @@ -172,9 +160,9 @@ class ReversedTrimmedSurface(TrimmedSurface): Underlying mathematical representation of the surface. """ - def __init__(self, face: "Face", geometry: Surface): + def __init__(self, geometry: Surface, box_uv: BoxUV): """Initialize an instance of a reversed trimmed surface.""" - super().__init__(face, geometry) + super().__init__(geometry, box_uv) def normal(self, u: Real, v: Real) -> UnitVector3D: # noqa: D102 return -self.evaluate_proportion(u, v).normal diff --git a/tests/integration/test_design.py b/tests/integration/test_design.py index b25c5a5983..a2b77a8a54 100644 --- a/tests/integration/test_design.py +++ b/tests/integration/test_design.py @@ -55,7 +55,9 @@ Vector3D, ) from ansys.geometry.core.misc import DEFAULT_UNITS, UNITS, Accuracy, Distance -from ansys.geometry.core.shapes.parameterization import ParamUV +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.sketch import Sketch @@ -2167,3 +2169,90 @@ def test_body_mirror(modeler: Modeler): if edge.shape.start not in copy_vertices: copy_vertices.append(edge.shape.start) assert np.allclose(expected_vertices, copy_vertices) + + +def test_sweep_sketch(modeler: Modeler): + """Test revolving a circle profile around a circular axis to make a donut.""" + + skip_if_linux(modeler) + design_sketch = modeler.create_design("donut") + + path_radius = 5 + profile_radius = 2 + + # create a circle on the XZ-plane centered at (5, 0, 0) with radius 2 + profile = Sketch(plane=Plane(direction_x=[1, 0, 0], direction_y=[0, 0, 1])).circle( + Point2D([path_radius, 0]), profile_radius + ) + + # create a circle 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))] + + body = design_sketch.sweep_sketch("donutsweep", profile, path) + + assert body.is_surface == False + + # check edges + assert len(body.edges) == 0 + + # check faces + assert len(body.faces) == 1 + + # check area of face + # compute expected area (torus with r < R) where r2 is inner radius and r1 is outer radius + r1 = path_radius + profile_radius + r2 = path_radius - profile_radius + expected_face_area = (np.pi**2) * (r1**2 - r2**2) + assert body.faces[0].area.m == pytest.approx(expected_face_area) + + assert Accuracy.length_is_equal(body.volume.m, 394.7841760435743) + + +def test_sweep_chain(modeler: Modeler): + """Test revolving a semi-elliptical profile around a circular axis to make a + bowl.""" + + skip_if_linux(modeler) + design_chain = modeler.create_design("bowl") + + radius = 10 + + # create quarter-ellipse profile with major radius = 10, minor radius = 5 + profile = [ + Ellipse( + Point3D([0, 0, radius / 2]), radius, radius / 2, reference=[1, 0, 0], axis=[0, 1, 0] + ).trim(Interval(0, np.pi / 2)) + ] + + # create circle on the plane parallel to the XY-plane but moved up by 5 units with radius 10 + path = [Circle(Point3D([0, 0, radius / 2]), radius).trim(Interval(0, 2 * np.pi))] + + # create the bowl body + body = design_chain.sweep_chain("bowlsweep", path, profile) + + assert body.is_surface == True + + # check edges + assert len(body.edges) == 1 + + # check length of edge + # compute expected circumference (circle with radius 10) + expected_edge_cirumference = 2 * np.pi * 10 + assert body.edges[0].length.m == pytest.approx(expected_edge_cirumference) + + # check faces + assert len(body.faces) == 1 + + # check area of face + # compute expected area (half a spheroid) + minor_rad = radius / 2 + e_squared = 1 - (minor_rad**2 / radius**2) + e = np.sqrt(e_squared) + expected_face_area = ( + 2 * np.pi * radius**2 + (minor_rad**2 / e) * np.pi * np.log((1 + e) / (1 - e)) + ) / 2 + assert body.faces[0].area.m == pytest.approx(expected_face_area) + + # check volume of body + # expected is 0 since it's not a closed surface + assert body.volume.m == 0