Geometry Library in C# — Curves

Saturday, April 4, 2026

In the previous articles, we built the fundamental building blocks of our library: points, vectors, operators, unit vectors, and transformations. All these types are immutable structs that represent simple values.

We're now moving to a higher level of abstraction: curves. A line, a segment, a circular arc — all of these are curves. They share common behaviors (evaluating a point, computing a tangent, measuring a distance), but differ in nature: some are infinite, others are bounded, some are open, others closed.

The central question is: how do we model this hierarchy in C#?

From structs to classes

Until now, we've used readonly struct for all our types. A point, a vector, a matrix — these are values: small, immutable, copied on every assignment.

Curves are different:

  • They are polymorphic: we want to manipulate a line and an arc through the same ICurve2d interface.
  • They are more complex: a curve carries more state than a simple pair of coordinates.
  • They have identity: two segments connecting the same points may be distinct objects in a CAD model, each carrying its own attributes.

For all these reasons, our curves will be classes (reference types), not structs. Interfaces will define the common contract.

The parametric curve

The concept

All our curves will be parametric curves. The idea is simple: a curve is a function that maps a parameter \(t\) (a real number) to a point in space:

\(C(t) = \text{Point2d}\)

As \(t\) varies, we traverse the curve. The parameter \(t\) isn't necessarily the arc length — it's an abstract coordinate that identifies each point on the curve.

Each curve defines a parametric domain: the interval \([t_{\min}, t_{\max}]\) over which the parameter is valid.

Unbounded and bounded curves

This is where the fundamental distinction appears:

Curve type Parametric domain Length Examples
Unbounded \((-\infty, +\infty)\) Infinite Line
Bounded open \([t_{\min}, t_{\max}]\) Finite Segment, arc
Bounded closed \([t_{\min}, t_{\max}]\) (periodic) Finite Circle, ellipse

A line extends to infinity in both directions. You can't talk about its "start point" or its length.

A segment is a piece of a line between two points. It has a length, a start, and an end.

A circle is a special case: it's bounded (finite length) but closed — its start and end points coincide, and the curve is periodic.

This distinction naturally dictates our interface hierarchy.

The ICurve2d interface

The base interface defines the minimal contract every curve must satisfy:

public interface ICurve2d
{
    double StartParameter { get; }

    double EndParameter { get; }

    Point2d GetPointAtParameter(double parameter);

    double GetParameterAtPoint(Point2d point, double tolerance = 1e-10);

    UnitVector2d GetTangentAtParameter(double parameter);

    Point2d GetClosestPointTo(Point2d point);

    double GetDistanceTo(Point2d point);

    ICurve2d CreateReversed();
}

Let's detail each member:

  • StartParameter / EndParameter define the parametric domain. For a line, these will be double.NegativeInfinity and double.PositiveInfinity. For a segment, 0 and the segment's length.

  • GetPointAtParameter evaluates the curve: given a parameter \(t\), it returns the corresponding point on the curve. This is the function \(C(t)\).

  • GetParameterAtPoint is the inverse operation: given a point on the curve, it returns the corresponding parameter \(t\). If the point isn't on the curve (within tolerance), an exception is thrown. This method is essential for composite curves, where a point must be converted to a global parameter.

  • GetTangentAtParameter returns the curve's direction at a given parameter. The tangent is a unit vector: only the direction matters, not the magnitude.

  • GetClosestPointTo returns the point on the curve closest to a given point. This is the core operation for snapping and proximity detection.

  • GetDistanceTo computes the minimum distance between a point and the curve. In practice, it delegates to GetClosestPointTo and then measures the distance between the found point and the given point.

  • CreateReversed returns a new curve traversed in the opposite direction. We return a new instance rather than modifying the existing object, to stay consistent with the immutable approach adopted throughout the library.

Why GetTangentAtParameter and not GetTangentAtPoint?

We could have defined the tangent as a function of a point rather than a parameter. But the same point can appear multiple times on a curve (think of a figure eight, or a cusp point). The parameter, however, unambiguously identifies a specific location on the curve.

Moreover, the caller often already has the parameter at hand (because they traversed the curve or solved an intersection), which avoids a costly reverse lookup.

The IBoundedCurve2d interface

For curves with finite length, we extend the base interface:

public interface IBoundedCurve2d : ICurve2d
{
    Point2d StartPoint { get; }

    Point2d EndPoint { get; }

    double Length { get; }

    bool IsClosed { get; }

    IBoundedCurve2d GetSubCurve(double startParameter, double endParameter);

    new IBoundedCurve2d CreateReversed();
}
  • StartPoint / EndPoint are the points at the curve's extremities. For a closed curve (circle), they are identical.

  • Length is the curve's total length.

  • IsClosed indicates whether the curve is closed. This tells you whether the parameter is periodic (a circle) or the endpoints are distinct (a segment).

  • GetSubCurve extracts a portion of the curve between two parameters. This is the trimming operation: we "cut" the curve to keep only a piece. The result is always a bounded curve. This method is essential for boolean operations, clipping, and building composite curves from pieces of existing curves.

  • CreateReversed is redeclared with a more precise return type (IBoundedCurve2d instead of ICurve2d). This is return type covariance — reversing a bounded curve always gives a bounded curve.

Aren't StartPoint and EndPoint redundant?

One could argue that StartPoint is just GetPointAtParameter(StartParameter). That's true. But these properties exist for two reasons:

  1. Readability: segment.StartPoint is clearer than segment.GetPointAtParameter(segment.StartParameter).
  2. Performance: for some curves, the start point is stored directly and doesn't need to be recomputed.

Example: the line segment

To illustrate these interfaces, let's implement a segment — the simplest bounded curve:

public sealed class LineSegment2d : IBoundedCurve2d
{
    public Point2d StartPoint { get; }

    public Point2d EndPoint { get; }

    public UnitVector2d Direction { get; }

    public double Length { get; }

    public bool IsClosed => false;

    public double StartParameter => 0;

    public double EndParameter => Length;

    public LineSegment2d(Point2d startPoint, Point2d endPoint, double tolerance = 1e-10)
    {
        double length = startPoint.GetDistanceTo(endPoint);

        if (length < tolerance)
            throw new ArgumentException("The two points are too close to define a segment.");

        StartPoint = startPoint;
        EndPoint = endPoint;
        Length = length;
        Direction = UnitVector2d.CreateFromVector(endPoint - startPoint);
    }

    public Point2d GetPointAtParameter(double parameter)
    {
        return StartPoint + Direction * parameter;
    }

    public double GetParameterAtPoint(Point2d point, double tolerance = 1e-10)
    {
        double parameter = (point - StartPoint).DotProduct(Direction);

        if (GetPointAtParameter(parameter).GetDistanceTo(point) > tolerance)
            throw new ArgumentException("The point does not lie on the segment.", nameof(point));

        return parameter;
    }

    public UnitVector2d GetTangentAtParameter(double parameter)
    {
        return Direction;
    }

    public Point2d GetClosestPointTo(Point2d point)
    {
        Vector2d toPoint = point - StartPoint;
        double projection = toPoint.DotProduct(Direction);

        if (projection <= 0)
            return StartPoint;

        if (projection >= Length)
            return EndPoint;

        return StartPoint + Direction * projection;
    }

    public double GetDistanceTo(Point2d point)
    {
        return point.GetDistanceTo(GetClosestPointTo(point));
    }

    public IBoundedCurve2d GetSubCurve(double startParameter, double endParameter)
    {
        return new LineSegment2d(
            GetPointAtParameter(startParameter),
            GetPointAtParameter(endParameter));
    }

    public IBoundedCurve2d CreateReversed()
    {
        return new LineSegment2d(EndPoint, StartPoint);
    }

    ICurve2d ICurve2d.CreateReversed()
    {
        return CreateReversed();
    }
}

A few observations:

  • The parametric domain goes from 0 to Length. The parameter directly corresponds to the distance traveled from the start point.
  • The tangent is constant across the entire segment: it's the segment's direction.
  • GetClosestPointTo projects the point onto the segment's supporting line. If the projection falls before the start or after the end, it returns the nearest endpoint. GetDistanceTo then simply measures the distance between the given point and the closest point.
  • CreateReversed returns a new segment with swapped points. The direction, tangent — everything is recomputed automatically by the constructor.

Example: the line

The line illustrates the unbounded case:

public sealed class Line2d : ICurve2d
{
    public Point2d Origin { get; }

    public UnitVector2d Direction { get; }

    public double StartParameter => double.NegativeInfinity;

    public double EndParameter => double.PositiveInfinity;

    public Line2d(Point2d origin, UnitVector2d direction)
    {
        Origin = origin;
        Direction = direction;
    }

    public Point2d GetPointAtParameter(double parameter)
    {
        return Origin + Direction * parameter;
    }

    public double GetParameterAtPoint(Point2d point, double tolerance = 1e-10)
    {
        double parameter = (point - Origin).DotProduct(Direction);

        if (GetPointAtParameter(parameter).GetDistanceTo(point) > tolerance)
            throw new ArgumentException("The point does not lie on the line.", nameof(point));

        return parameter;
    }

    public UnitVector2d GetTangentAtParameter(double parameter)
    {
        return Direction;
    }

    public Point2d GetClosestPointTo(Point2d point)
    {
        Vector2d toPoint = point - Origin;
        double projection = toPoint.DotProduct(Direction);
        return Origin + Direction * projection;
    }

    public double GetDistanceTo(Point2d point)
    {
        return point.GetDistanceTo(GetClosestPointTo(point));
    }

    public ICurve2d CreateReversed()
    {
        return new Line2d(Origin, -Direction);
    }
}

Compared to the segment:

  • The parametric bounds are infinite.
  • GetClosestPointTo has no clamping at endpoints — the projection is always valid.
  • CreateReversed reverses the direction, not the points.
  • Line2d does not implement IBoundedCurve2d: it has no length, no start or end point.

Hierarchy overview

ICurve2d
├── Line2d                 (unbounded)
└── IBoundedCurve2d
    ├── LineSegment2d      (bounded, open)
    ├── Arc2d              (bounded, open)
    └── Circle2d           (bounded, closed)

All curves share parametric evaluation, tangent, distance, and reversal. Bounded curves add endpoints, length, trimming, and the notion of closure.

What about 3D?

The 3D interfaces follow exactly the same pattern:

public interface ICurve3d
{
    double StartParameter { get; }

    double EndParameter { get; }

    Point3d GetPointAtParameter(double parameter);

    double GetParameterAtPoint(Point3d point, double tolerance = 1e-10);

    UnitVector3d GetTangentAtParameter(double parameter);

    Point3d GetClosestPointTo(Point3d point);

    double GetDistanceTo(Point3d point);

    ICurve3d CreateReversed();
}

public interface IBoundedCurve3d : ICurve3d
{
    Point3d StartPoint { get; }

    Point3d EndPoint { get; }

    double Length { get; }

    bool IsClosed { get; }

    IBoundedCurve3d GetSubCurve(double startParameter, double endParameter);

    new IBoundedCurve3d CreateReversed();
}

The 3D implementations (lines, segments, arcs, circles in a 3D plane) are more complex in their calculations, but the architecture remains identical.

What's next

Our interfaces are in place. In the next articles, we'll implement the concrete geometric entities: the circle and the arc, then we'll see how these interfaces enable building NURBS curves and composite curves.

On the same topic