sm::bezcurve

A general-order Bezier curve

import sm.bezcurve;

Module file: sm/bezcurve.cppm. Test and example code: examples/bezcurve tests/bez1 tests/bezcurves tests/bezfit tests/bezsplit tests/twocurves tests/bezmatrix

Table of Contents

Summary

sm::bezcurve<F, order> represents a single Bezier curve of a fixed polynomial degree, order (1 for a line, 2 for a quadratic, 3 for a cubic, and so on) - order has no default value and must always be given explicitly. Its order + 1 control points are stored as an sm::mat<F, order + 1, 2> (one row per control point, columns for x and y) rather than as an array of sm::vecs.

The class implements Cohen & Riesenfeld’s (1982) general matrix representation of Bezier curves: it precomputes a basis-conversion matrix M and caches M * C (C being the control-point matrix) so that evaluating a point at parameter t is a single small matrix multiply, valid for any order. It also contains an implementation of the classical direct Bernstein-polynomial summation as a cross-check. There’s a compile-time ceiling on order of around 19 (tied to a Pascal’s-triangle lookup table sized for orders up to 20) - plenty for any practical use.

sm::bezcurve isn’t constexpr-capable (it uses std::cout, std::format, exceptions, and - for one curve-fitting overload - an internal sm::nm_simplex optimization).

Creating a curve

The most general constructor takes all order + 1 control points (including both endpoints) as an sm::vvec:

sm::vvec<sm::vec<float,2>> c = { {1,1}, {2,8}, {9,8}, {10,1} };
sm::bezcurve<float, 3> cv (c); // order 3 -> needs exactly 4 points

You can also pass the control points directly as an sm::mat<F, order+1, 2>, or use one of the fixed-order convenience constructors:

sm::vec<float,2> ip = {1,1}, fp = {10,1}, c1 = {5,5}, c2 = {2,-4};
sm::bezcurve<float, 3> cubic  (ip, fp, c1, c2); // order == 3 only: initial point, final point, 2 control points
sm::bezcurve<float, 2> quad   (ip, fp, c1);     // order == 2 only: initial point, final point, 1 control point
sm::bezcurve<float, 1> line   (ip, fp);         // order == 1 only: just the two endpoints

Or, for any order, give the endpoints separately from the interior control points:

sm::vvec<sm::vec<float,2>> interior = { c1, c2 }; // order-1 interior points
sm::bezcurve<float, 3> cv (ip, fp, interior);

All the point-count-based constructors throw std::runtime_error if you supply the wrong number of points for the curve’s order.

To replace all of a curve’s control points after construction, use update_controls (also throws on a size mismatch):

cv.update_controls (c);

Evaluating the curve

compute_point(t) evaluates the curve at parameter t in [0, 1], throwing std::runtime_error outside that range:

sm::bezcoord<float> pt = cv.compute_point (0.4f);

Internally, this selects different methods based on the order; closed-form evaluation for first to third order curves, or the general matrix-multiply method for order >= 4.

compute_point(t, l) starts at parameter t and moves a further Euclidean distance l along the curve, via a binary search over t (the search’s tolerance is set as a percentage of l with set_lthresh(F), default 1). If there isn’t l worth of curve left after t, or the search doesn’t converge, it returns a null bezcoord (is_null() == true) whose remaining field holds the actual distance left. This convention lets sm::bezcurvepath stitch samples smoothly across a sequence of curves.

For sampling many points at once:

std::vector<sm::bezcoord<float>> pts    = cv.compute_points (40u);         // 40 points, evenly spaced in t
std::vector<sm::bezcoord<float>> pts_l  = cv.compute_points (1.0f);        // points spaced by Euclidean arc length 1.0
std::vector<sm::bezcoord<float>> pts_x  = cv.compute_points_horz (1.0f);   // points spaced by horizontal (x) distance

For the arc-length and horizontal-distance overloads, the last element of the returned vector is a null bezcoord carrying whatever distance was left over past the last full step - again, the mechanism bezcurvepath relies on.

compute_tangent_normal(t) returns a std::pair of unit bezcoords, {tangent, normal}:

auto [tangent, normal] = cv.compute_tangent_normal (0.4f);

For order > 1 this is built from derivative<F>(), which returns the order control points of a curve one degree lower (the derivative curve).

Fitting a curve to points

cv.fit (points); // points.size() must equal order + 1, exactly

This is an exact interpolation through the given points (parameterized by their estimated arc-length positions along the curve), not a least-squares fit over more points than the curve has degrees of freedom - fit throws std::runtime_error if points.size() != order + 1. Internally, the fit is computed in double precision even when F is float, because it was found that single precision only gives reliable fits up to around order 4 or 5.

sm::vvec<sm::vec<float,2>> c = { {-0.28f,0.0f}, {0.28f,0.0f}, {0.28f,0.45f}, {-0.28f,0.45f} };
sm::bezcurve<float, 3> cv;
cv.fit (c);
std::cout << cv.get_order() << std::endl; // 3

There’s also a three-argument overload, fit (points, preceding, optimize = false), which additionally smooths the tangent direction across the join with a preceding curve, and - if optimize is true - refines the interior control points with an sm::nm_simplex search that minimizes compute_objective(points).

compute_objective(points) is itself public: it returns the sum of squared distances between arc-length-sampled points on the curve and points (or F{-1} - a sentinel, not an exception - if the sizes don’t line up), and is the quantity the optimizing fit overload minimizes.

Splitting a curve

You can split a curve into two curves at any point t in the range [0, 1]. The function split returns two new control point matrices for the two shorter curves:

auto [c1, c2] = cv.split (0.5f); // control-point matrices of the two halves, split at t=0.5
// explicitly: std::pair<sm::mat<float, 4, 2>, sm::mat<float, 4, 2>> splitpair = cv.split (0.5f);
sm::bezcurve<float, 3> cv1 (c1);
sm::bezcurve<float, 3> cv2 (c2);

Scaling the output

bezcurve has a scaling factor for the output coordinates. This was implemented to apply scaling factors from a SVG file, where curves are defined along with a scale to change into a desired set of units (such cm or mm). The scale defaults to 1, but may be changed wtih set_scale.

cv.set_scale (2.0f); // see caveat below
float len_x2 = cv.get_initial_point_scaled().length(); // scale applied
std::uint32_t ord = cv.get_order();
sm::vvec<sm::vec<float,2>> ctrls = cv.get_controls();  // control points are unscaled

String output

Output the curve

Output functions return a string containing a newline-separated list of coordinates on the curve. You can pass either an unsigned integer argument num_points to output a fixed number of points spaced evenly on the t parameter line, or a floating point argument step to obtain points spaced by a fixed arc length.

std::cout << cv.output (40u);  // 40 evenly-spaced-in-t sample points
std::cout << cv.output (0.1f); // sample points spaced by arc-length step 0.1

These produce output like:

6.48923,6.1053
7.40207,5.69326
8.18007,5.07463
...

Output the control matrix

The control matrix is formatted by sm::mat:

std::cout << cv.output_control();

Gives:

|    -0.28        ~0       |
|   0.533498   -0.663498   |
|   0.533498     1.1135    |
|    -0.28        0.45     |

This page was authored with AI, based on human written code in bezcurve.cppm. Reviewed by Seb James


This site uses Just the Docs, a documentation theme for Jekyll.