Skip to content

Commit 2b95f88

Browse files
jacobrkerstetterpre-commit-ci[bot]pyansys-ci-bot
authored
feat: nurbs sketching and surface support (#2104)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: pyansys-ci-bot <[email protected]>
1 parent ac2c39f commit 2b95f88

File tree

9 files changed

+387
-9
lines changed

9 files changed

+387
-9
lines changed

doc/changelog.d/2104.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Nurbs sketching and surface support

src/ansys/geometry/core/_grpc/_services/v0/conversions.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
from ansys.geometry.core.sketch.edge import SketchEdge
8585
from ansys.geometry.core.sketch.ellipse import SketchEllipse
8686
from ansys.geometry.core.sketch.face import SketchFace
87+
from ansys.geometry.core.sketch.nurbs import SketchNurbs
8788
from ansys.geometry.core.sketch.polygon import Polygon
8889
from ansys.geometry.core.sketch.segment import SketchSegment
8990

@@ -404,6 +405,7 @@ def from_sketch_shapes_to_grpc_geometries(
404405
converted_sketch_edges = from_sketch_edges_to_grpc_geometries(edges, plane)
405406
geometries.lines.extend(converted_sketch_edges[0])
406407
geometries.arcs.extend(converted_sketch_edges[1])
408+
geometries.nurbs_curves.extend(converted_sketch_edges[2])
407409

408410
for face in faces:
409411
if isinstance(face, SketchCircle):
@@ -429,6 +431,8 @@ def from_sketch_shapes_to_grpc_geometries(
429431
one_curve_geometry.ellipses.append(geometries.ellipses[0])
430432
elif len(geometries.polygons) > 0:
431433
one_curve_geometry.polygons.append(geometries.polygons[0])
434+
elif len(geometries.nurbs_curves) > 0:
435+
one_curve_geometry.nurbs_curves.append(geometries.nurbs_curves[0])
432436
return one_curve_geometry
433437

434438
else:
@@ -438,7 +442,7 @@ def from_sketch_shapes_to_grpc_geometries(
438442
def from_sketch_edges_to_grpc_geometries(
439443
edges: list["SketchEdge"],
440444
plane: "Plane",
441-
) -> tuple[list[GRPCLine], list[GRPCArc]]:
445+
) -> tuple[list[GRPCLine], list[GRPCArc], list[GRPCNurbsCurve]]:
442446
"""Convert a list of ``SketchEdge`` to a gRPC message.
443447
444448
Parameters
@@ -450,21 +454,25 @@ def from_sketch_edges_to_grpc_geometries(
450454
451455
Returns
452456
-------
453-
tuple[list[GRPCLine], list[GRPCArc]]
454-
Geometry service gRPC line and arc messages. The unit is meters.
457+
tuple[list[GRPCLine], list[GRPCArc], list[GRPCNurbsCurve]]
458+
Geometry service gRPC line, arc, and NURBS curve messages. The unit is meters.
455459
"""
456460
from ansys.geometry.core.sketch.arc import Arc
461+
from ansys.geometry.core.sketch.nurbs import SketchNurbs
457462
from ansys.geometry.core.sketch.segment import SketchSegment
458463

459464
arcs = []
460465
segments = []
466+
nurbs_curves = []
461467
for edge in edges:
462468
if isinstance(edge, SketchSegment):
463469
segments.append(from_sketch_segment_to_grpc_line(edge, plane))
464470
elif isinstance(edge, Arc):
465471
arcs.append(from_sketch_arc_to_grpc_arc(edge, plane))
472+
elif isinstance(edge, SketchNurbs):
473+
nurbs_curves.append(from_sketch_nurbs_to_grpc_nurbs_curve(edge, plane))
466474

467-
return (segments, arcs)
475+
return (segments, arcs, nurbs_curves)
468476

469477

470478
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:
496504
)
497505

498506

507+
def from_sketch_nurbs_to_grpc_nurbs_curve(curve: "SketchNurbs", plane: "Plane") -> GRPCNurbsCurve:
508+
"""Convert a ``SketchNurbs`` class to a NURBS curve gRPC message.
509+
510+
Parameters
511+
----------
512+
nurbs : SketchNurbs
513+
Source NURBS data.
514+
plane : Plane
515+
Plane for positioning the NURBS curve.
516+
517+
Returns
518+
-------
519+
GRPCNurbsCurve
520+
Geometry service gRPC NURBS curve message. The unit is meters.
521+
"""
522+
from ansys.api.geometry.v0.models_pb2 import (
523+
ControlPoint as GRPCControlPoint,
524+
NurbsData as GRPCNurbsData,
525+
)
526+
527+
# Convert control points
528+
control_points = [
529+
GRPCControlPoint(
530+
position=from_point2d_to_grpc_point(plane, pt),
531+
weight=curve.weights[i],
532+
)
533+
for i, pt in enumerate(curve.control_points)
534+
]
535+
536+
# Convert nurbs data
537+
nurbs_data = GRPCNurbsData(
538+
degree=curve.degree,
539+
knots=from_knots_to_grpc_knots(curve.knots),
540+
order=curve.degree + 1,
541+
)
542+
543+
return GRPCNurbsCurve(
544+
control_points=control_points,
545+
nurbs_data=nurbs_data,
546+
)
547+
548+
499549
def from_sketch_ellipse_to_grpc_ellipse(ellipse: "SketchEllipse", plane: "Plane") -> GRPCEllipse:
500550
"""Convert a ``SketchEllipse`` class to an ellipse gRPC message.
501551

src/ansys/geometry/core/shapes/curves/nurbs.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,7 @@ def fit_curve_from_points(
167167
from geomdl import fitting
168168

169169
# Convert points to a format suitable for the fitting function
170-
converted_points = []
171-
for pt in points:
172-
pt_raw = [*pt]
173-
converted_points.append(pt_raw)
170+
converted_points = [[*pt] for pt in points]
174171

175172
# Fit the curve to the points
176173
curve = fitting.interpolate_curve(converted_points, degree)

src/ansys/geometry/core/sketch/edge.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ def length(self) -> Quantity:
5151
"""Length of the edge."""
5252
raise NotImplementedError("Each edge must provide the length definition.")
5353

54+
def contains_point(self, point: Point2D, tol: float = 1e-6) -> bool:
55+
"""Check if the edge contains the given point within a tolerance."""
56+
raise NotImplementedError("Each edge must provide the contains_point method.")
57+
5458
@property
5559
def visualization_polydata(self) -> "pv.PolyData":
5660
"""VTK polydata representation for PyVista visualization.
@@ -76,7 +80,7 @@ def plane_change(self, plane: "Plane") -> None:
7680
Notes
7781
-----
7882
This implies that their 3D definition might suffer changes. By default, this
79-
metho does nothing. It is required to be implemented in child ``SketchEdge``
83+
method does nothing. It is required to be implemented in child ``SketchEdge``
8084
classes.
8185
"""
8286
pass
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates.
2+
# SPDX-License-Identifier: MIT
3+
#
4+
#
5+
# Permission is hereby granted, free of charge, to any person obtaining a copy
6+
# of this software and associated documentation files (the "Software"), to deal
7+
# in the Software without restriction, including without limitation the rights
8+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
# copies of the Software, and to permit persons to whom the Software is
10+
# furnished to do so, subject to the following conditions:
11+
#
12+
# The above copyright notice and this permission notice shall be included in all
13+
# copies or substantial portions of the Software.
14+
#
15+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
# SOFTWARE.
22+
"""Provides for creating and managing a nurbs sketch curve."""
23+
24+
from typing import TYPE_CHECKING
25+
26+
from beartype import beartype as check_input_types
27+
import numpy as np
28+
29+
from ansys.geometry.core.math.point import Point2D
30+
from ansys.geometry.core.misc.checks import graphics_required
31+
from ansys.geometry.core.sketch.edge import SketchEdge
32+
from ansys.geometry.core.typing import Real
33+
34+
if TYPE_CHECKING: # pragma: no cover
35+
import geomdl.NURBS as geomdl_nurbs # noqa: N811
36+
import pyvista as pv
37+
38+
39+
class SketchNurbs(SketchEdge):
40+
"""Represents a NURBS sketch curve.
41+
42+
Warnings
43+
--------
44+
NURBS sketching is only supported in 26R1 and later versions of Ansys.
45+
46+
Notes
47+
-----
48+
This class is a wrapper around the NURBS curve class from the `geomdl` library.
49+
By leveraging the `geomdl` library, this class provides a high-level interface
50+
to create and manipulate NURBS curves. The `geomdl` library is a powerful
51+
library for working with NURBS curves and surfaces. For more information, see
52+
https://pypi.org/project/geomdl/.
53+
"""
54+
55+
def __init__(self):
56+
"""Initialize the NURBS sketch curve."""
57+
super().__init__()
58+
try:
59+
import geomdl.NURBS as geomdl_nurbs # noqa: N811
60+
except ImportError as e: # pragma: no cover
61+
raise ImportError(
62+
"The `geomdl` library is required to use the NURBSCurve class. "
63+
"Please install it using `pip install geomdl`."
64+
) from e
65+
66+
self._nurbs_curve = geomdl_nurbs.Curve()
67+
68+
@property
69+
def geomdl_nurbs_curve(self) -> "geomdl_nurbs.Curve":
70+
"""Get the underlying NURBS curve.
71+
72+
Notes
73+
-----
74+
This property gives access to the full functionality of the NURBS curve
75+
coming from the `geomdl` library. Use with caution.
76+
"""
77+
return self._nurbs_curve
78+
79+
@property
80+
def control_points(self) -> list[Point2D]:
81+
"""Get the control points of the curve."""
82+
return [Point2D(point) for point in self._nurbs_curve.ctrlpts]
83+
84+
@property
85+
def degree(self) -> int:
86+
"""Get the degree of the curve."""
87+
return self._nurbs_curve.degree
88+
89+
@property
90+
def knots(self) -> list[Real]:
91+
"""Get the knot vector of the curve."""
92+
return self._nurbs_curve.knotvector
93+
94+
@property
95+
def weights(self) -> list[Real]:
96+
"""Get the weights of the control points."""
97+
return self._nurbs_curve.weights
98+
99+
@property
100+
def start(self) -> Point2D:
101+
"""Get the start point of the curve."""
102+
return Point2D(self._nurbs_curve.evaluate_single(0.0))
103+
104+
@property
105+
def end(self) -> Point2D:
106+
"""Get the end point of the curve."""
107+
return Point2D(self._nurbs_curve.evaluate_single(1.0))
108+
109+
@property
110+
@graphics_required
111+
def visualization_polydata(self) -> "pv.PolyData":
112+
"""Get the VTK polydata representation for PyVista visualization.
113+
114+
Returns
115+
-------
116+
pyvista.PolyData
117+
VTK pyvista.Polydata configuration.
118+
119+
Notes
120+
-----
121+
The representation lies in the X/Y plane within
122+
the standard global Cartesian coordinate system.
123+
"""
124+
import pyvista as pv
125+
126+
# Sample points along the curve
127+
params = np.linspace(0, 1, 100)
128+
points = [self._nurbs_curve.evaluate_single(u) for u in params] # For 2D: [x, y]
129+
130+
# Add a zero z-coordinate for PyVista (only supports 3D points)
131+
points = [(*pt, 0.0) for pt in points]
132+
133+
# Create PolyData and add the line
134+
polydata = pv.PolyData(points)
135+
polydata.lines = [len(points)] + list(range(len(points)))
136+
137+
return polydata
138+
139+
def contains_point(self, point: Point2D, tolerance: Real = 1e-6) -> bool:
140+
"""Check if the curve contains a given point within a specified tolerance.
141+
142+
Parameters
143+
----------
144+
point : Point2D
145+
The point to check.
146+
tolerance : Real, optional
147+
The tolerance for the containment check, by default 1e-6.
148+
149+
Returns
150+
-------
151+
bool
152+
True if the curve contains the point within the tolerance, False otherwise.
153+
"""
154+
# Sample points along the curve
155+
params = np.linspace(0, 1, 200)
156+
sampled = [self._nurbs_curve.evaluate_single(u) for u in params]
157+
158+
# Check if any sampled point is close to the target point
159+
return any(np.linalg.norm(np.array(pt) - np.array(point)) < tolerance for pt in sampled)
160+
161+
@classmethod
162+
@check_input_types
163+
def fit_curve_from_points(
164+
cls,
165+
points: list[Point2D],
166+
degree: int = 3,
167+
) -> "SketchNurbs":
168+
"""Fit a NURBS curve to a set of points.
169+
170+
Parameters
171+
----------
172+
points : list[Point2D]
173+
The points to fit the curve to.
174+
degree : int, optional
175+
The degree of the NURBS curve, by default 3.
176+
177+
Returns
178+
-------
179+
SketchNurbs
180+
A new instance of SketchNurbs fitted to the given points.
181+
"""
182+
from geomdl import fitting
183+
184+
# Check degree compared to number of points provided
185+
if degree < 1:
186+
raise ValueError("Degree must be at least 1.")
187+
if len(points) == 2:
188+
degree = 1 # Force linear interpolation for two points
189+
if len(points) == 3:
190+
degree = 2 # Force quadratic interpolation for three points
191+
if degree >= len(points):
192+
raise ValueError(
193+
f"Degree {degree} is too high for the number of points provided: {len(points)}."
194+
)
195+
196+
curve = fitting.interpolate_curve(
197+
[[*pt] for pt in points], # Convert Point2D to list of coordinates
198+
degree=degree,
199+
)
200+
201+
# Construct the NURBSCurve object
202+
nurbs_curve = cls()
203+
nurbs_curve._nurbs_curve.degree = curve.degree
204+
nurbs_curve._nurbs_curve.ctrlpts = [Point2D(entry) for entry in curve.ctrlpts]
205+
nurbs_curve._nurbs_curve.knotvector = curve.knotvector
206+
nurbs_curve._nurbs_curve.weights = curve.weights
207+
208+
# Verify the curve is valid
209+
try:
210+
nurbs_curve._nurbs_curve._check_variables()
211+
except ValueError as e:
212+
raise ValueError(f"Invalid NURBS curve: {e}")
213+
214+
return nurbs_curve

src/ansys/geometry/core/sketch/sketch.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from ansys.geometry.core.sketch.ellipse import SketchEllipse
4040
from ansys.geometry.core.sketch.face import SketchFace
4141
from ansys.geometry.core.sketch.gears import DummyGear, SpurGear
42+
from ansys.geometry.core.sketch.nurbs import SketchNurbs
4243
from ansys.geometry.core.sketch.polygon import Polygon
4344
from ansys.geometry.core.sketch.segment import SketchSegment
4445
from ansys.geometry.core.sketch.slot import Slot
@@ -561,6 +562,28 @@ def arc_from_start_center_and_angle(
561562
)
562563
return self.edge(arc, tag)
563564

565+
def nurbs_from_2d_points(
566+
self,
567+
points: list[Point2D],
568+
tag: str | None = None,
569+
) -> "Sketch":
570+
"""Add a NURBS curve from a list of 2D points.
571+
572+
Parameters
573+
----------
574+
points : list[Point2D]
575+
List of 2D points to define the NURBS curve.
576+
tag : str | None, default: None
577+
User-defined label for identifying the curve.
578+
579+
Returns
580+
-------
581+
Sketch
582+
Revised sketch state ready for further sketch actions.
583+
"""
584+
nurbs_curve = SketchNurbs.fit_curve_from_points(points)
585+
return self.edge(nurbs_curve, tag)
586+
564587
def triangle(
565588
self,
566589
point1: Point2D,

0 commit comments

Comments
 (0)