diff --git a/doc/changelog.d/2104.added.md b/doc/changelog.d/2104.added.md new file mode 100644 index 0000000000..7817f90c31 --- /dev/null +++ b/doc/changelog.d/2104.added.md @@ -0,0 +1 @@ +Nurbs sketching and surface support \ No newline at end of file diff --git a/src/ansys/geometry/core/_grpc/_services/v0/conversions.py b/src/ansys/geometry/core/_grpc/_services/v0/conversions.py index cecd103382..07c9d2a7aa 100644 --- a/src/ansys/geometry/core/_grpc/_services/v0/conversions.py +++ b/src/ansys/geometry/core/_grpc/_services/v0/conversions.py @@ -84,6 +84,7 @@ from ansys.geometry.core.sketch.edge import SketchEdge from ansys.geometry.core.sketch.ellipse import SketchEllipse from ansys.geometry.core.sketch.face import SketchFace + from ansys.geometry.core.sketch.nurbs import SketchNurbs from ansys.geometry.core.sketch.polygon import Polygon from ansys.geometry.core.sketch.segment import SketchSegment @@ -404,6 +405,7 @@ def from_sketch_shapes_to_grpc_geometries( converted_sketch_edges = from_sketch_edges_to_grpc_geometries(edges, plane) geometries.lines.extend(converted_sketch_edges[0]) geometries.arcs.extend(converted_sketch_edges[1]) + geometries.nurbs_curves.extend(converted_sketch_edges[2]) for face in faces: if isinstance(face, SketchCircle): @@ -429,6 +431,8 @@ def from_sketch_shapes_to_grpc_geometries( one_curve_geometry.ellipses.append(geometries.ellipses[0]) elif len(geometries.polygons) > 0: one_curve_geometry.polygons.append(geometries.polygons[0]) + elif len(geometries.nurbs_curves) > 0: + one_curve_geometry.nurbs_curves.append(geometries.nurbs_curves[0]) return one_curve_geometry else: @@ -438,7 +442,7 @@ def from_sketch_shapes_to_grpc_geometries( def from_sketch_edges_to_grpc_geometries( edges: list["SketchEdge"], plane: "Plane", -) -> tuple[list[GRPCLine], list[GRPCArc]]: +) -> tuple[list[GRPCLine], list[GRPCArc], list[GRPCNurbsCurve]]: """Convert a list of ``SketchEdge`` to a gRPC message. Parameters @@ -450,21 +454,25 @@ def from_sketch_edges_to_grpc_geometries( Returns ------- - tuple[list[GRPCLine], list[GRPCArc]] - Geometry service gRPC line and arc messages. The unit is meters. + tuple[list[GRPCLine], list[GRPCArc], list[GRPCNurbsCurve]] + Geometry service gRPC line, arc, and NURBS curve messages. The unit is meters. """ from ansys.geometry.core.sketch.arc import Arc + from ansys.geometry.core.sketch.nurbs import SketchNurbs from ansys.geometry.core.sketch.segment import SketchSegment arcs = [] segments = [] + nurbs_curves = [] for edge in edges: if isinstance(edge, SketchSegment): segments.append(from_sketch_segment_to_grpc_line(edge, plane)) elif isinstance(edge, Arc): arcs.append(from_sketch_arc_to_grpc_arc(edge, plane)) + elif isinstance(edge, SketchNurbs): + nurbs_curves.append(from_sketch_nurbs_to_grpc_nurbs_curve(edge, plane)) - return (segments, arcs) + return (segments, arcs, nurbs_curves) def from_sketch_arc_to_grpc_arc(arc: "Arc", plane: "Plane") -> GRPCArc: @@ -496,6 +504,48 @@ def from_sketch_arc_to_grpc_arc(arc: "Arc", plane: "Plane") -> GRPCArc: ) +def from_sketch_nurbs_to_grpc_nurbs_curve(curve: "SketchNurbs", plane: "Plane") -> GRPCNurbsCurve: + """Convert a ``SketchNurbs`` class to a NURBS curve gRPC message. + + Parameters + ---------- + nurbs : SketchNurbs + Source NURBS data. + plane : Plane + Plane for positioning the NURBS curve. + + Returns + ------- + GRPCNurbsCurve + Geometry service gRPC NURBS curve message. The unit is meters. + """ + from ansys.api.geometry.v0.models_pb2 import ( + ControlPoint as GRPCControlPoint, + NurbsData as GRPCNurbsData, + ) + + # Convert control points + control_points = [ + GRPCControlPoint( + position=from_point2d_to_grpc_point(plane, pt), + weight=curve.weights[i], + ) + for i, pt in enumerate(curve.control_points) + ] + + # Convert nurbs data + nurbs_data = GRPCNurbsData( + degree=curve.degree, + knots=from_knots_to_grpc_knots(curve.knots), + order=curve.degree + 1, + ) + + return GRPCNurbsCurve( + control_points=control_points, + nurbs_data=nurbs_data, + ) + + def from_sketch_ellipse_to_grpc_ellipse(ellipse: "SketchEllipse", plane: "Plane") -> GRPCEllipse: """Convert a ``SketchEllipse`` class to an ellipse gRPC message. diff --git a/src/ansys/geometry/core/connection/conversions.py b/src/ansys/geometry/core/connection/conversions.py index 0166ac830b..9e30e6a850 100644 --- a/src/ansys/geometry/core/connection/conversions.py +++ b/src/ansys/geometry/core/connection/conversions.py @@ -160,6 +160,10 @@ def sketch_shapes_to_grpc_geometries( GRPCGeometries Geometry service gRPC geometries message. The unit is meters. """ + from ansys.geometry.core.sketch.circle import SketchCircle + from ansys.geometry.core.sketch.ellipse import SketchEllipse + from ansys.geometry.core.sketch.polygon import Polygon + geometries = GRPCGeometries() converted_sketch_edges = sketch_edges_to_grpc_geometries(edges, plane) @@ -197,8 +201,8 @@ def sketch_shapes_to_grpc_geometries( def sketch_edges_to_grpc_geometries( - edges: list[SketchEdge], - plane: Plane, + edges: list["SketchEdge"], + plane: "Plane", ) -> tuple[list[GRPCLine], list[GRPCArc]]: """Convert a list of ``SketchEdge`` to a gRPC message. @@ -214,6 +218,9 @@ def sketch_edges_to_grpc_geometries( tuple[list[GRPCLine], list[GRPCArc]] Geometry service gRPC line and arc messages. The unit is meters. """ + from ansys.geometry.core.sketch.arc import Arc + from ansys.geometry.core.sketch.segment import SketchSegment + arcs = [] segments = [] for edge in edges: diff --git a/src/ansys/geometry/core/designer/geometry_commands.py b/src/ansys/geometry/core/designer/geometry_commands.py index cce3e8bd62..7622750026 100644 --- a/src/ansys/geometry/core/designer/geometry_commands.py +++ b/src/ansys/geometry/core/designer/geometry_commands.py @@ -62,6 +62,7 @@ point3d_to_grpc_point, unit_vector_to_grpc_direction, ) +from ansys.geometry.core.designer.body import Body from ansys.geometry.core.designer.component import Component from ansys.geometry.core.designer.mating_conditions import ( AlignCondition, diff --git a/src/ansys/geometry/core/shapes/curves/nurbs.py b/src/ansys/geometry/core/shapes/curves/nurbs.py index ab7baafc83..f1aa11a205 100644 --- a/src/ansys/geometry/core/shapes/curves/nurbs.py +++ b/src/ansys/geometry/core/shapes/curves/nurbs.py @@ -167,10 +167,7 @@ def fit_curve_from_points( from geomdl import fitting # Convert points to a format suitable for the fitting function - converted_points = [] - for pt in points: - pt_raw = [*pt] - converted_points.append(pt_raw) + converted_points = [[*pt] for pt in points] # Fit the curve to the points curve = fitting.interpolate_curve(converted_points, degree) diff --git a/src/ansys/geometry/core/sketch/edge.py b/src/ansys/geometry/core/sketch/edge.py index 7da77235a8..39baa3a310 100644 --- a/src/ansys/geometry/core/sketch/edge.py +++ b/src/ansys/geometry/core/sketch/edge.py @@ -51,6 +51,10 @@ def length(self) -> Quantity: """Length of the edge.""" raise NotImplementedError("Each edge must provide the length definition.") + def contains_point(self, point: Point2D, tol: float = 1e-6) -> bool: + """Check if the edge contains the given point within a tolerance.""" + raise NotImplementedError("Each edge must provide the contains_point method.") + @property def visualization_polydata(self) -> "pv.PolyData": """VTK polydata representation for PyVista visualization. @@ -76,7 +80,7 @@ def plane_change(self, plane: "Plane") -> None: Notes ----- This implies that their 3D definition might suffer changes. By default, this - metho does nothing. It is required to be implemented in child ``SketchEdge`` + method does nothing. It is required to be implemented in child ``SketchEdge`` classes. """ pass diff --git a/src/ansys/geometry/core/sketch/nurbs.py b/src/ansys/geometry/core/sketch/nurbs.py new file mode 100644 index 0000000000..2a97178975 --- /dev/null +++ b/src/ansys/geometry/core/sketch/nurbs.py @@ -0,0 +1,213 @@ +# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Provides for creating and managing a nurbs sketch curve.""" + +from typing import TYPE_CHECKING + +from beartype import beartype as check_input_types +import numpy as np + +from ansys.geometry.core.math.point import Point2D +from ansys.geometry.core.misc.checks import graphics_required +from ansys.geometry.core.sketch.edge import SketchEdge +from ansys.geometry.core.typing import Real + +if TYPE_CHECKING: # pragma: no cover + import geomdl.NURBS as geomdl_nurbs # noqa: N811 + import pyvista as pv + + +class SketchNurbs(SketchEdge): + """Represents a NURBS sketch curve. + + Notes + ----- + This class is a wrapper around the NURBS curve class from the `geomdl` library. + By leveraging the `geomdl` library, this class provides a high-level interface + to create and manipulate NURBS curves. The `geomdl` library is a powerful + library for working with NURBS curves and surfaces. For more information, see + https://pypi.org/project/geomdl/. + + """ + + def __init__(self): + """Initialize the NURBS sketch curve.""" + super().__init__() + try: + import geomdl.NURBS as geomdl_nurbs # noqa: N811 + except ImportError as e: # pragma: no cover + raise ImportError( + "The `geomdl` library is required to use the NURBSCurve class. " + "Please install it using `pip install geomdl`." + ) from e + + self._nurbs_curve = geomdl_nurbs.Curve() + + @property + def geomdl_nurbs_curve(self) -> "geomdl_nurbs.Curve": + """Get the underlying NURBS curve. + + Notes + ----- + This property gives access to the full functionality of the NURBS curve + coming from the `geomdl` library. Use with caution. + """ + return self._nurbs_curve + + @property + def control_points(self) -> list[Point2D]: + """Get the control points of the curve.""" + return [Point2D(point) for point in self._nurbs_curve.ctrlpts] + + @property + def degree(self) -> int: + """Get the degree of the curve.""" + return self._nurbs_curve.degree + + @property + def knots(self) -> list[Real]: + """Get the knot vector of the curve.""" + return self._nurbs_curve.knotvector + + @property + def weights(self) -> list[Real]: + """Get the weights of the control points.""" + return self._nurbs_curve.weights + + @property + def start(self) -> Point2D: + """Get the start point of the curve.""" + return Point2D(self._nurbs_curve.evaluate_single(0.0)) + + @property + def end(self) -> Point2D: + """Get the end point of the curve.""" + return Point2D(self._nurbs_curve.evaluate_single(1.0)) + + @property + @graphics_required + def visualization_polydata(self) -> "pv.PolyData": + """Get the VTK polydata representation for PyVista visualization. + + Returns + ------- + pyvista.PolyData + VTK pyvista.Polydata configuration. + + Notes + ----- + The representation lies in the X/Y plane within + the standard global Cartesian coordinate system. + """ + # from geomdl.exchange_vtk import export_polydata + import numpy as np + import pyvista as pv + + # Sample points along the curve + params = np.linspace(0, 1, 100) + points = [self._nurbs_curve.evaluate_single(u) for u in params] # For 2D: [x, y] + + # Add a zero z-coordinate for PyVista (only supports 3D points) + points = [(*pt, 0.0) for pt in points] + + # Create PolyData and add the line + polydata = pv.PolyData(points) + polydata.lines = [len(points)] + list(range(len(points))) + + return polydata + + def contains_point(self, point: Point2D, tolerance: Real = 1e-6) -> bool: + """Check if the curve contains a given point within a specified tolerance. + + Parameters + ---------- + point : Point2D + The point to check. + tolerance : Real, optional + The tolerance for the containment check, by default 1e-6. + + Returns + ------- + bool + True if the curve contains the point within the tolerance, False otherwise. + """ + # Sample points along the curve + params = np.linspace(0, 1, 200) + sampled = [self._nurbs_curve.evaluate_single(u) for u in params] + + # Check if any sampled point is close to the target point + return any(np.linalg.norm(np.array(pt) - np.array(point)) < tolerance for pt in sampled) + + @classmethod + @check_input_types + def fit_curve_from_points( + cls, + points: list[Point2D], + degree: int = 3, + ) -> "SketchNurbs": + """Fit a NURBS curve to a set of points. + + Parameters + ---------- + points : list[Point2D] + The points to fit the curve to. + degree : int, optional + The degree of the NURBS curve, by default 3. + + Returns + ------- + SketchNurbs + A new instance of SketchNurbs fitted to the given points. + """ + from geomdl import fitting + + # Check degree compared to number of points provided + if degree < 1: + raise ValueError("Degree must be at least 1.") + if len(points) == 2: + degree = 1 # Force linear interpolation for two points + if len(points) == 3: + degree = 2 # Force quadratic interpolation for three points + if degree >= len(points): + raise ValueError( + f"Degree {degree} is too high for the number of points provided: {len(points)}." + ) + + curve = fitting.interpolate_curve( + [[*pt] for pt in points], # Convert Point2D to list of coordinates + degree=degree, + ) + + # Construct the NURBSCurve object + nurbs_curve = cls() + nurbs_curve._nurbs_curve.degree = curve.degree + nurbs_curve._nurbs_curve.ctrlpts = [Point2D(entry) for entry in curve.ctrlpts] + nurbs_curve._nurbs_curve.knotvector = curve.knotvector + nurbs_curve._nurbs_curve.weights = curve.weights + + # Verify the curve is valid + try: + nurbs_curve._nurbs_curve._check_variables() + except ValueError as e: + raise ValueError(f"Invalid NURBS curve: {e}") + + return nurbs_curve diff --git a/src/ansys/geometry/core/sketch/sketch.py b/src/ansys/geometry/core/sketch/sketch.py index 36f3e0f1c3..9ff19e50cb 100644 --- a/src/ansys/geometry/core/sketch/sketch.py +++ b/src/ansys/geometry/core/sketch/sketch.py @@ -39,6 +39,7 @@ from ansys.geometry.core.sketch.ellipse import SketchEllipse from ansys.geometry.core.sketch.face import SketchFace from ansys.geometry.core.sketch.gears import DummyGear, SpurGear +from ansys.geometry.core.sketch.nurbs import SketchNurbs from ansys.geometry.core.sketch.polygon import Polygon from ansys.geometry.core.sketch.segment import SketchSegment from ansys.geometry.core.sketch.slot import Slot @@ -561,6 +562,28 @@ def arc_from_start_center_and_angle( ) return self.edge(arc, tag) + def nurbs_from_2d_points( + self, + points: list[Point2D], + tag: str | None = None, + ) -> "Sketch": + """Add a NURBS curve from a list of 2D points. + + Parameters + ---------- + points : list[Point2D] + List of 2D points to define the NURBS curve. + tag : str | None, default: None + User-defined label for identifying the curve. + + Returns + ------- + Sketch + Revised sketch state ready for further sketch actions. + """ + nurbs_curve = SketchNurbs.fit_curve_from_points(points) + return self.edge(nurbs_curve, tag) + def triangle( self, point1: Point2D, diff --git a/tests/integration/test_design.py b/tests/integration/test_design.py index 4e9e4229fa..0dd11a3f91 100644 --- a/tests/integration/test_design.py +++ b/tests/integration/test_design.py @@ -3082,6 +3082,40 @@ def test_surface_body_creation(modeler: Modeler): assert body.faces[0].area.m == pytest.approx(39.4784176044 * 2) +def test_create_surface_from_nurbs_sketch(modeler: Modeler): + """Test creating a surface from a NURBS sketch.""" + design = modeler.create_design("NURBS_Sketch_Surface") + + # Create a NURBS sketch + sketch = Sketch() + sketch.nurbs_from_2d_points( + points=[ + Point2D([0, 0]), + Point2D([1, 0]), + Point2D([1, 1]), + Point2D([0, 1]), + ], + tag="nurbs_sketch", + ) + sketch.segment( + start=Point2D([0, -1]), + end=Point2D([0, 2]), + tag="segment_1", + ) + + # Create a surface from the NURBS sketch + surface_body = design.create_surface( + name="nurbs_surface", + sketch=sketch, + ) + + assert len(design.bodies) == 1 + assert surface_body.is_surface + assert surface_body.faces[0].area.m > 0 + + design.plot(screenshot="C:\\Users\\jkerstet\\Downloads\\nurbs_surface.png") + + def test_design_parameters(modeler: Modeler): """Test the design parameter's functionality.""" design = modeler.open_file(FILES_DIR / "blockswithparameters.dsco") diff --git a/tests/integration/test_plotter.py b/tests/integration/test_plotter.py index 688d2e4841..dc48283756 100644 --- a/tests/integration/test_plotter.py +++ b/tests/integration/test_plotter.py @@ -124,6 +124,23 @@ def test_plot_sketch(verify_image_cache): sketch.plot(view_2d=True, screenshot=Path(IMAGE_RESULTS_DIR, "plot_sketch.png")) +@skip_no_xserver +def test_plot_nurbs_sketch(verify_image_cache): + # Create a NURBS sketch instance + sketch = Sketch() + sketch.nurbs_from_2d_points( + [ + Point2D([0, 0]), + Point2D([2, 2]), + Point2D([3, 6]), + Point2D([4, 7]), + ] + ) + + # Plot the NURBS sketch + sketch.plot(view_2d=True, screenshot=Path(IMAGE_RESULTS_DIR, "plot_nurbs_sketch.png")) + + @skip_no_xserver def test_plot_geometryplotter_sketch_pyvista(verify_image_cache): # define sketch diff --git a/tests/test_sketch.py b/tests/test_sketch.py index ae3c33c9af..e661323c60 100644 --- a/tests/test_sketch.py +++ b/tests/test_sketch.py @@ -329,6 +329,46 @@ def test_sketch_arc_edge(): assert arc3_retrieved[0] == sketch.edges[2] +def test_sketch_nurbs(): + """Test NURBS SketchEdge sketching.""" + # Create a Sketch instance + sketch = Sketch() + + # Create a NURBS curve through 4 points + points = [ + Point2D([0, 0]), + Point2D([2, 2]), + Point2D([3, 6]), + Point2D([4, 7]), + ] + sketch.nurbs_from_2d_points(points, tag="Nurbs1") + assert len(sketch.edges) == 1 + + # Create another curve through 3 points + sketch.nurbs_from_2d_points( + [ + Point2D([0, 0]), + Point2D([1, 1]), + Point2D([2, 2]), + ], + tag="Nurbs2", + ) + + curve = sketch.edges[0] + assert curve.start == Point2D([0, 0]) + assert curve.end == Point2D([4, 7]) + assert curve.degree == 3 + + # Check retrieving the NURBS curve by tag + nurbs1_retrieved = sketch.get("Nurbs1") + assert len(nurbs1_retrieved) == 1 + assert nurbs1_retrieved[0] == sketch.edges[0] + + # Check if the curve contains a point + assert sketch.edges[0].contains_point(Point2D([4, 7])) + assert not sketch.edges[0].contains_point(Point2D([5, 5])) + + def test_sketch_triangle_face(): """Test Triangle SketchFace sketching.""" # Create a Sketch instance