Geometry Library in C# — Composite Curves
Saturday, April 4, 2026In 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:
- A horizontal segment
- A fillet arc
- A vertical segment
- Another fillet arc
- 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:
- Find sub-curve \(i\) such that \(S_i \leq t < S_{i+1}\).
- Convert the global parameter to a local one: \(t_{\text{local}} = t - S_i\).
- 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:
- Both parameters fall on the same sub-curve → delegate directly.
- 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.