Geometry Library in C# — Composite Curves

Saturday, April 4, 2026

In the previous articles, we implemented individual curves: lines, segments and NURBS. Each of these curves implements IBoundedCurve2d. But in a real CAD model, a contour is rarely a single curve — it's a sequence of segments, arcs, and free-form curves, joined end to end.

That's the role of the composite curve.

The concept

A composite curve is a curve made up of several sub-curves (or segments) joined end to end. The EndPoint of each sub-curve coincides with the StartPoint of the next.

For example, the profile of a mechanical part might be:

  1. A horizontal segment
  2. A fillet arc
  3. A vertical segment
  4. Another fillet arc
  5. A segment that returns to the start → closed contour

The composite curve implements IBoundedCurve2d: from the outside, it's a curve like any other, with a parametric domain, a length, and endpoints. All the complexity of delegating to sub-curves is encapsulated.

Parameter management

The main challenge is distributing the global parametric domain among sub-curves. Each sub-curve \(i\) has its own local domain \([0, L_i]\) (where \(L_i\) is its length). The global domain goes from \(0\) to the total length \(L\).

Specifically, sub-curve \(i\) covers the global interval \([S_i, S_{i+1}]\), where \(S_i\) is the sum of the lengths of the preceding sub-curves:

\(S_0 = 0, \quad S_{i+1} = S_i + L_i\)

To evaluate a global parameter \(t\), we must:

  1. Find sub-curve \(i\) such that \(S_i \leq t < S_{i+1}\).
  2. Convert the global parameter to a local one: \(t_{\text{local}} = t - S_i\).
  3. Delegate evaluation to the sub-curve.

This lookup can be done by binary search on the cumulative bounds, giving \(O(\log n)\) complexity instead of \(O(n)\) for a linear scan.

The implementation

public sealed class CompositeCurve2d : IBoundedCurve2d
{
    readonly IBoundedCurve2d[] _segments;
    readonly double[] _cumulativeLengths;

    public Point2d StartPoint => _segments[0].StartPoint;

    public Point2d EndPoint => _segments[^1].EndPoint;

    public double Length { get; }

    public bool IsClosed => StartPoint.IsAlmostEqualTo(EndPoint);

    public double StartParameter => 0;

    public double EndParameter => Length;

    public IReadOnlyList<IBoundedCurve2d> Segments => _segments;

    public CompositeCurve2d(IBoundedCurve2d[] segments, double tolerance = 1e-10)
    {
        ArgumentNullException.ThrowIfNull(segments);

        if (segments.Length == 0)
            throw new ArgumentException("The composite curve must contain at least one segment.", nameof(segments));

        for (int i = 0; i < segments.Length - 1; i++)
        {
            if (!segments[i].EndPoint.IsAlmostEqualTo(segments[i + 1].StartPoint, tolerance))
                throw new ArgumentException(
                    $"Segment {i} does not connect to segment {i + 1}.", nameof(segments));
        }

        _segments = segments;
        _cumulativeLengths = new double[segments.Length + 1];
        _cumulativeLengths[0] = 0;

        for (int i = 0; i < segments.Length; i++)
            _cumulativeLengths[i + 1] = _cumulativeLengths[i] + segments[i].Length;

        Length = _cumulativeLengths[^1];
    }
}

The constructor verifies continuity: each sub-curve must connect to the next, within tolerance. The _cumulativeLengths array stores cumulative bounds for fast lookup.

Finding the sub-curve

The private method that identifies the correct sub-curve from a global parameter:

(int index, double localParameter) FindSegment(double parameter)
{
    if (parameter <= 0)
        return (0, 0);

    if (parameter >= Length)
        return (_segments.Length - 1, _segments[^1].Length);

    int index = Array.BinarySearch(_cumulativeLengths, parameter);

    if (index < 0)
        index = ~index - 1;

    if (index >= _segments.Length)
        index = _segments.Length - 1;

    double localParameter = parameter - _cumulativeLengths[index];
    return (index, localParameter);
}

Array.BinarySearch returns the exact index if the value is found, or the bitwise complement of the index of the first greater element. We subtract 1 to get the interval containing the parameter.

Delegating operations

Each interface method delegates to the appropriate sub-curve:

public Point2d GetPointAtParameter(double parameter)
{
    (int index, double localParameter) = FindSegment(parameter);
    return _segments[index].GetPointAtParameter(localParameter);
}

public double GetParameterAtPoint(Point2d point, double tolerance = 1e-10)
{
    for (int i = 0; i < _segments.Length; i++)
    {
        double distance = _segments[i].GetDistanceTo(point);

        if (distance <= tolerance)
        {
            double localParameter = _segments[i].GetParameterAtPoint(point, tolerance);
            return _cumulativeLengths[i] + localParameter;
        }
    }

    throw new ArgumentException("The point does not lie on the composite curve.", nameof(point));
}

public UnitVector2d GetTangentAtParameter(double parameter)
{
    (int index, double localParameter) = FindSegment(parameter);
    return _segments[index].GetTangentAtParameter(localParameter);
}

public Point2d GetClosestPointTo(Point2d point)
{
    Point2d closest = _segments[0].GetClosestPointTo(point);
    double bestDistance = point.GetDistanceTo(closest);

    for (int i = 1; i < _segments.Length; i++)
    {
        Point2d candidate = _segments[i].GetClosestPointTo(point);
        double distance = point.GetDistanceTo(candidate);

        if (distance < bestDistance)
        {
            closest = candidate;
            bestDistance = distance;
        }
    }

    return closest;
}

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

Note that GetClosestPointTo traverses all sub-curves, because the closest point could lie on any of them. GetParameterAtPoint, on the other hand, stops as soon as it finds the sub-curve containing the point.

Trimming and reversal

public IBoundedCurve2d GetSubCurve(double startParameter, double endParameter)
{
    (int startIndex, double startLocal) = FindSegment(startParameter);
    (int endIndex, double endLocal) = FindSegment(endParameter);

    if (startIndex == endIndex)
        return _segments[startIndex].GetSubCurve(startLocal, endLocal);

    var subSegments = new List<IBoundedCurve2d>();

    subSegments.Add(_segments[startIndex].GetSubCurve(startLocal, _segments[startIndex].EndParameter));

    for (int i = startIndex + 1; i < endIndex; i++)
        subSegments.Add(_segments[i]);

    subSegments.Add(_segments[endIndex].GetSubCurve(_segments[endIndex].StartParameter, endLocal));

    return new CompositeCurve2d(subSegments.ToArray());
}

public IBoundedCurve2d CreateReversed()
{
    var reversed = new IBoundedCurve2d[_segments.Length];

    for (int i = 0; i < _segments.Length; i++)
        reversed[i] = _segments[_segments.Length - 1 - i].CreateReversed();

    return new CompositeCurve2d(reversed);
}

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

GetSubCurve handles three cases:

  1. Both parameters fall on the same sub-curve → delegate directly.
  2. Otherwise, trim the first sub-curve (from the start parameter to its end), keep intermediate sub-curves intact, and trim the last (from its start to the end parameter).

CreateReversed reverses the sub-curve order and reverses each sub-curve individually.

Example: an L-shaped profile

var bottom = new LineSegment2d(new Point2d(0, 0), new Point2d(4, 0));
var right = new LineSegment2d(new Point2d(4, 0), new Point2d(4, 2));
var top = new LineSegment2d(new Point2d(4, 2), new Point2d(0, 2));
var left = new LineSegment2d(new Point2d(0, 2), new Point2d(0, 0));

var contour = new CompositeCurve2d(new IBoundedCurve2d[] { bottom, right, top, left });

contour.IsClosed;                       // True
contour.Length;                          // 12.0
contour.GetPointAtParameter(4.0);       // (4, 0) — end of first segment
contour.GetPointAtParameter(5.0);       // (4, 1) — middle of second segment
contour.GetTangentAtParameter(5.0);     // (0, 1) — upward direction

What's next

With composite curves, our library can represent any contour, no matter how complex. In the next article, we'll see how to compute intersections between curves — with a lazy computation system that only pays the cost of detailed information when actually needed.

On the same topic