The Curve
The Curve (NURBS / Spline)
A Curve is a 1D path in 3D space that isn't straight. In computational geometry, we almost always use NURBS(Non-Uniform Rational B-Splines). NURBS can define any shape, from a circle to the complex organic body of a car.
Unlike a polyline, a NURBS curve is mathematically smooth at every single point.
Control Points & Knots:
Control Hull: The line connecting the control points.
Knots: A sequence of numbers that determines the influence of each CP.
1class Curve:2 """3 Represents a NURBS or Spline curve.4 """5 def __init__(self, control_points: list, degree: int = 3):6 self.ControlPoints = control_points7 self.Degree = degree8 self.Knots = [] # Mathematical weighting
PointAt(t):
t, which usually ranges from 0.0 (start) to 1.0 (end).1def PointAt(self, t: float) -> Point3d:2 """3 Evaluates a cubic Bezier curve at parameter t (0.0 to 1.0).4 """5 p0, p1, p2, p3 = self.control_points6 mt = 1.0 - t78 # Bernstein polynomials9 x = mt**3 * p0.X + 3 * mt**2 * t * p1.X + 3 * mt * t**2 * p2.X + t**3 * p3.X10 y = mt**3 * p0.Y + 3 * mt**2 * t * p1.Y + 3 * mt * t**2 * p2.Y + t**3 * p3.Y11 z = mt**3 * p0.Z + 3 * mt**2 * t * p1.Z + 3 * mt * t**2 * p2.Z + t**3 * p3.Z1213 return Point3d(x, y, z)
TangentAt(t):
t.1def TangentAt(self, t: float) -> Vector3d:2 """3 Returns a unit vector representing local direction of curve at t.4 Calculated as first derivative of cubic Bezier curve.5 """6 p0, p1, p2, p3 = self.control_points7 mt = 1.0 - t89 # First derivative equation10 dx = 3 * mt**2 * (p1.X - p0.X) + 6 * mt * t * (p2.X - p1.X) + 3 * t**2 * (p3.X - p2.X)11 dy = 3 * mt**2 * (p1.Y - p0.Y) + 6 * mt * t * (p2.Y - p1.Y) + 3 * t**2 * (p3.Y - p2.Y)12 dz = 3 * mt**2 * (p1.Z - p0.Z) + 6 * mt * t * (p2.Z - p1.Z) + 3 * t**2 * (p3.Z - p2.Z)1314 tangent = Vector3d(dx, dy, dz)15 tangent.Unitize()16 return tangent
Translate(vector):
1def Translate(self, vector: 'Vector3d'):2 """Shifts all control points by the vector."""3 for cp in self.ControlPoints:4 cp += vector
Scale(factor):
1def ScaleCurve(curve: Curve, factor: float, center: Point3d) -> Curve:2 """Scales a curve from a center point by a uniform factor."""3 # A factor > 1.0 enlarges the curve, < 1.0 shrinks it4 xform = Transform.Scale(center, factor)56 curve.Transform(xform)7 return curve
Rotate(angle, axis):
1def RotateCurve(curve: Curve, angle_deg: float, axis: Vector3d, center: Point3d) -> Curve:2 """Rotates a curve around a specific center point and axis."""3 import math4 rad = math.radians(angle_deg)56 xform = Transform.Rotation(rad, axis, center)7 curve.Transform(xform)8 return curve
Arc Length:
1def CurveLength(curve: 'Curve', domain: 'Interval' = None) -> float:2 """Calculates the arc length of a NURBS curve."""34 # Can calculate total length, or the length of a sub-segment5 if domain:6 return curve.GetLength(domain)7 else:8 return curve.GetLength()
Divide by Count / Length:
1def DivideCurve(curve: 'Curve', count: int) -> list['Point3d']:2 """Divides a curve into equal length segments."""34 # Returns the division points.5 # Note: DivideByCount ensures segments have equal ARC length,6 # which is very different from dividing the t-parameter domain evenly.78 t_params = curve.DivideByCount(count, True)9 return [curve.PointAt(t) for t in t_params]
CurvatureAt(t):
1def CurvatureAt(curve: Curve, t: float) -> dict:2 """Calculates curvature using first and second derivatives."""3 d1 = curve.DerivativeAt(t, 1) # Velocity (Tangent)4 d2 = curve.DerivativeAt(t, 2) # Acceleration56 # k = |d1 x d2| / |d1|^37 cross_prod = d1.CrossProduct(d2)8 k = cross_prod.Length / (d1.Length ** 3)910 r = 1.0 / k if k > 1e-6 else float('inf')1112 return {13 "curvature": k,14 "radius": r15 }
Offset(plane, distance):
1def OffsetPoint(curve: Curve, t: float, distance: float) -> Point3d:23 """Naive implementation."""4 """Calculates a single offset point mathematically.5 An offset curve is formed by moving each point along its normal vector.67 O(t) = C(t) + d * N(t)8 """9 pt = curve.PointAt(t)10 tangent = curve.DerivativeAt(t, 1)1112 # In 2D (XY plane), normal is (-y, x)13 normal = Vector3d(-tangent.Y, tangent.X, 0)14 normal.Unitize()1516 offset_pt = pt + normal * distance17 return offset_pt
Reverse():
1def EvaluateReversed(curve: Curve, t: float) -> Point3d:2 """Mathematically, reversing a curve flips its parameter space.3 Assuming a normalized domain [0, 1]:45 C_rev(t) = C(1 - t)67 To construct a new reversed NURBS curve from scratch:8 1. Reverse the list of Control Points9 2. Reverse the Knot vector and adjust its bounds10 3. Reverse the weights11 """12 reversed_t = 1.0 - t13 return curve.PointAt(reversed_t)
Curve Intersection:
1def IntersectCurves(crvA: Curve, crvB: Curve, tol: float) -> list[Point3d]:2 """Finds intersection points using recursive subdivision."""34 # 1. Broad Phase: check bounding boxes5 if not BoundingBoxesIntersect(crvA.BoundingBox, crvB.BoundingBox, tol):6 return []78 # 2. Base case: curves are flat/small enough to approximate as lines9 if crvA.Length < tol and crvB.Length < tol:10 ptA = crvA.PointAt(0.5)11 ptB = crvB.PointAt(0.5)12 if ptA.DistanceTo(ptB) <= tol:13 return [ptA]14 return []1516 # 3. Recursive step: split curves and test sub-segments17 A1, A2 = crvA.Split(0.5)18 B1, B2 = crvB.Split(0.5)1920 pts = []21 pts.extend(IntersectCurves(A1, B1, tol))22 pts.extend(IntersectCurves(A1, B2, tol))23 pts.extend(IntersectCurves(A2, B1, tol))24 pts.extend(IntersectCurves(A2, B2, tol))2526 return pts
Trim:
1def TrimCurveDomain(curve: Curve, t0: float, t1: float) -> Curve:2 """Extracts a sub-curve between two t-parameters."""34 # Trim cuts away everything outside the domain [t0, t1]5 # It returns a new Curve object representing just that segment.6 trimmed = curve.Trim(t0, t1)78 # Split, on the other hand, breaks the curve at t and returns both pieces9 # pieces = curve.Split(t)1011 return trimmed
Because curves are parameterized from `[0, 1]` (or some start `t` to end `t`), we can extract any sub-segment by specifying a domain `[t0, t1]`.
Trimming relies on parameters, not lengths. Cutting from `t=0` to `t=0.5` does not guarantee you will get exactly 50% of the physical length of a NURBS curve, because parameterization is often non-uniform.
JoinCurves:
1def JoinTwoCurves(crvA: Curve, crvB: Curve, tol: float) -> PolyCurve:2 """Mathematically joins two curves if their endpoints touch.34 This illustrates the logic under the hood of Curve.JoinCurves().5 A PolyCurve acts as a container that sequentially routes parameter6 evaluations to the correct sub-curve.7 """8 endA = crvA.PointAtEnd9 startB = crvB.PointAtStart1011 # Check if ends meet within tolerance12 if endA.DistanceTo(startB) <= tol:13 poly = PolyCurve()14 poly.Append(crvA)15 poly.Append(crvB)16 return poly1718 return None # Gap exceeds tolerance, cannot join
When a Line, an Arc, and a NURBS spline are joined end-to-end, they don't mathematically merge into one uniform spline. Instead, they are wrapped into a container called a PolyCurve.
To the user, a PolyCurve behaves exactly like a single curve (you can extract its length, or evaluate `PointAt()`). But under the hood, it routes evaluation requests to the correct internal sub-segment. The `tolerance` parameter dictates how close endpoints must be to successfully fuse together.
IncreaseDegree:
1def ElevateBezierDegree(points: list[Point3d]) -> list[Point3d]:2 """Elevates a Bezier curve of degree n to degree n+1.34 The physical shape remains identical, but a new control point is added,5 providing more flexibility for future deformations.67 Formula: Q_i = (i / (n+1)) * P_{i-1} + (1 - i / (n+1)) * P_i8 """9 n = len(points) - 110 new_degree = n + 111 new_points = []1213 for i in range(new_degree + 1):14 # Ratio of interpolation between adjacent old control points15 ratio = i / new_degree1617 p_prev = points[i - 1] if i > 0 else points[0]18 p_curr = points[i] if i < new_degree else points[-1]1920 # Q_i is a linear blend of P_i and P_{i-1}21 q_i = p_curr * (1.0 - ratio) + p_prev * ratio22 new_points.append(q_i)2324 return new_points
A Polyline is a Degree 1 curve. It is rigid, sharp, and has no concept of curvature. If you want to smooth a polyline, you can't just bend it—you have to upgrade its mathematics.
IncreaseDegree() takes a lower-degree curve and upgrades its math to a higher degree (like from 1 to 3). The amazing part? The physical shape of the curve does not change. A degree 3 curve can perfectly replicate sharp corners by stacking control points on top of each other. Once elevated, you have more control points to smoothly bend the lines into complex shapes (like S-curves).
ClosestPoint(pt):
1def ClosestPoint(curve: Curve, test_pt: Point3d, steps: int = 100) -> Point3d:2 """Finds the closest point on a curve to a test point.34 Naive but robust implementation: brute-force sampling5 across the curve's domain to find the closest approach.6 """7 best_t = 0.08 min_dist = float('inf')910 # 1. Broad Phase: Sample the curve at low resolution11 for i in range(steps + 1):12 t = i / steps13 pt = curve.PointAt(t)1415 dist = pt.DistanceTo(test_pt)16 if dist < min_dist:17 min_dist = dist18 best_t = t1920 # 2. Narrow Phase (Skipped here): Newton-Raphson refinement21 # best_t = RefineWithDerivatives(curve, test_pt, best_t)2223 return curve.PointAt(best_t)
t where the distance is minimized. Production systems first use brute-force sampling to find a good starting guess.BoundingBox:
1def ComputeBoundingBox(curve: Curve, samples: int = 100) -> tuple[Point3d, Point3d]:2 """Computes a tight axis-aligned bounding box by sampling the curve.34 Note: A fast 'loose' box can be found by just finding the bounding box5 of the Control Points (thanks to the convex hull property of NURBS),6 but sampling yields the precise physical boundaries.7 """8 min_pt = Point3d(float('inf'), float('inf'), float('inf'))9 max_pt = Point3d(float('-inf'), float('-inf'), float('-inf'))1011 for i in range(samples + 1):12 t = i / samples13 pt = curve.PointAt(t)1415 min_pt.X, min_pt.Y, min_pt.Z = min(min_pt.X, pt.X), min(min_pt.Y, pt.Y), min(min_pt.Z, pt.Z)16 max_pt.X, max_pt.Y, max_pt.Z = max(max_pt.X, pt.X), max(max_pt.Y, pt.Y), max(max_pt.Z, pt.Z)1718 return min_pt, max_pt
Rebuild(pt_count, degree):
1def RebuildCurve(curve: Curve, pt_count: int) -> list[Point3d]:2 """Rebuilds a curve by sampling it at evenly spaced intervals.34 A naive rebuild algorithm evaluates the original curve at uniform5 parameter steps to generate a new, simplified set of control points.6 Advanced rebuilds use least-squares fitting to minimize deviation.7 """8 if pt_count < 2:9 pt_count = 21011 new_points = []12 for i in range(pt_count):13 # Calculate parameter t from 0.0 to 1.014 t = i / (pt_count - 1)1516 # Sample the original curve17 pt = curve.PointAt(t)18 new_points.append(pt)1920 return new_points
Extend(length, style):
1def ExtendCurve(curve: Curve, length: float, style: str) -> list[Point3d]:2 """Extends a curve by a specified length at its end."""3 end_pt = curve.PointAtEnd4 tangent = curve.TangentAtEnd56 if style == 'linear':7 # Extends purely along the tangent vector (G1)8 ext_pt = end_pt + tangent * length9 return [end_pt, ext_pt]1011 elif style == 'arc':12 # Extends by fitting a circular arc to match curvature13 # 1. Compute curvature vector (k) and radius (1/|k|)14 curvature_vec = curve.CurvatureAtEnd15 radius = 1.0 / curvature_vec.Length1617 # 2. Find circle center, then sample points along the arc18 center = end_pt + curvature_vec * (radius ** 2)19 return SampleArc(center, radius, length)2021 elif style == 'smooth':22 # Extends by extrapolating the curve's control points (maintains C2 continuity)23 # We project the last 3 control points forward using Taylor expansion24 p0, p1, p2 = curve.ControlPoints[-3:]2526 velocity = p2 - p127 acceleration = velocity - (p1 - p0)2829 ext_pt = p2 + velocity * length + acceleration * (length ** 2) * 0.530 return [end_pt, ext_pt]
PerpendicularFrameAt(t):
1def PerpendicularFrameAt(curve: Curve, t: float) -> Plane:2 """Computes the Frenet-Serret perpendicular frame at parameter t."""3 origin = curve.PointAt(t)45 # First derivative (Velocity) gives the Tangent6 velocity = curve.DerivativeAt(t, 1)7 tangent = velocity.Normalize()89 # Second derivative (Acceleration) captures the curvature10 acceleration = curve.DerivativeAt(t, 2)1112 # Binormal is perpendicular to both velocity and acceleration13 binormal = CrossProduct(velocity, acceleration).Normalize()1415 # Normal completes the orthogonal triad16 normal = CrossProduct(binormal, tangent)1718 # Rhino Plane: (Origin, X-Axis, Y-Axis). Its Z-Axis becomes the Tangent.19 return Plane(origin, normal, binormal)
IsClosed:
1def IsCurveClosed(curve: Curve, tolerance: float = 1e-6) -> bool:2 """Checks if a curve is geometrically closed.34 Under the hood, this simply verifies whether the start and end5 points of the curve are coincident within a specific tolerance.6 """7 start_pt = curve.PointAtStart8 end_pt = curve.PointAtEnd910 # Calculate Euclidean distance between start and end11 distance = start_pt.DistanceTo(end_pt)1213 return distance <= tolerance