Resources Numerical accuracy Calculation policy

NURBS knot insertion and removal, degree change, split and join

Changes how a curve or surface is described without meaning to change its shape: adds or removes knots, raises or lowers the degree, splits, reverses, rescales the parameter and joins curves.

How the precision is kept

Knot insertion, splitting, reversal and reparameterization keep the shape up to 64-bit rounding. Knot removal, degree reduction and joining move the shape by up to a stated tolerance, and degree elevation ends with a knot removal at 1e-10 of the curve's size. Surfaces apply the curve operation to every row of the control net.

Calculations under this policy

15 calculations in 3 source files. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.

nurbs/curve.js ModelCraft 0.8.1 64-bit 10 calculations Reviewed

Tests tools/nurbs/test.mjs

Knot insertion

64-bit

Adds a knot to a curve r times without changing its shape.

α = (u − U[L + i]) ÷ (U[i + k + 1] − U[L + i]); Q = α·R[i + 1] + (1 − α)·R[i]

Each new control point is a convex combination of two old ones in homogeneous coordinates, so the points stay within the old ones' range. The knot must lie inside the domain by more than 1e-12 of its length.

  • nurbs/curve.js
  • lines 131 to 160
  • ModelCraft 0.8.1
export function insertKnot(c, u, r = 1) {
  const p = c.deg, U = c.knots, n = c.cvs.length - 1;
  const [a, b] = domain(c);
  // inside the domain by more than rounding (a knot within 1e-12 of an end would be read as that end's knot)
  if (!(u > a + 1e-12 * (b - a) && u < b - 1e-12 * (b - a))) fail('nurbs.err.knot_outside');
  const s = multiplicity(U, u);
  if (r <= 0) return { deg: p, knots: U.slice(), cvs: c.cvs.map((q) => q.slice()), w: c.w ? c.w.slice() : null };
  if (r + s > p) fail('nurbs.err.knot_mult');
  const k = findSpan(n, p, u, U), Pw = hcvs(c), mp = n + p + 1, nq = n + r;
  const UQ = new Array(mp + r + 1), Qw = new Array(nq + 1);
  for (let i = 0; i <= k; i++) UQ[i] = U[i];
  for (let i = 1; i <= r; i++) UQ[k + i] = u;
  for (let i = k + 1; i <= mp; i++) UQ[i + r] = U[i];
  for (let i = 0; i <= k - p; i++) Qw[i] = Pw[i];
  for (let i = k - s; i <= n; i++) Qw[i + r] = Pw[i];
  const Rw = [];
  for (let i = 0; i <= p - s; i++) Rw[i] = Pw[k - p + i].slice();
  let L = 0;
  for (let j = 1; j <= r; j++) {
    L = k - p + j;
    for (let i = 0; i <= p - j - s; i++) {
      const al = (u - U[L + i]) / (U[i + k + 1] - U[L + i]);
      Rw[i] = V.comb(Rw[i + 1], al, Rw[i], 1 - al);
    }
    Qw[L] = Rw[0];
    Qw[k + r - j - s] = Rw[p - j - s];
  }
  for (let i = L + 1; i < k - s; i++) Qw[i] = Rw[i - L];
  return fromH(p, UQ, Qw);
}

Tolerance in homogeneous space

64-bit

Converts a distance tolerance into one for weighted control points.

TOL = tol·w_min ÷ (1 + max |P|)

For a polynomial curve the tolerance is used as given. For a rational one it is scaled by the smallest weight and the largest control point distance from the origin, so it tightens for a curve far from the origin.

  • nurbs/curve.js
  • lines 168 to 175
  • ModelCraft 0.8.1
/** Homogeneous-space tolerance for a geometric tolerance (The NURBS Book § 5.4, eq. 5.30). */
function hTol(c, tol) {
  if (!c.w) return tol;
  const wmin = Math.min(...c.w);
  let pmax = 0;
  for (const q of c.cvs) pmax = Math.max(pmax, V.len(q));
  return tol * wmin / (1 + pmax);
}

Knot removal

64-bit

Removes copies of an interior knot while the curve moves no more than a tolerance.

candidates: (P[i] − (1 − α)·T[i − 1]) ÷ α and (P[j] − α·T[j + 1]) ÷ (1 − α); removed when they agree within TOL

The candidates divide by α and by 1 − α, which amplifies rounding when either is small. A copy is removed only when the test distance is within the tolerance (default 1e-9, absolute), so the curve may move by up to that much. The shift in fout halves an integer index, not a measured value.

  • nurbs/curve.js
  • lines 177 to 219
  • ModelCraft 0.8.1
/** Remove up to `num` copies of the interior knot u while the curve moves no more than tol. */
export function removeKnot(c, u, num = 1, tol = 1e-9) {
  const p = c.deg, n = c.cvs.length - 1, ord = p + 1;
  const U = c.knots.slice(), Pw = hcvs(c), m = U.length - 1;
  const s = multiplicity(U, u);
  const [a, b] = domain(c);
  if (s === 0 || !(u > a && u < b)) return { curve: c, removed: 0 };
  let r = -1;
  for (let i = 0; i <= m; i++) if (Math.abs(U[i] - u) <= 1e-12 * (b - a)) r = i;
  const TOL = hTol(c, tol);
  const fout = (2 * r - s - p) >> 1;
  let first = r - p, last = r - s, t = 0;
  const temp = [];
  for (t = 0; t < Math.min(num, s); t++) {
    const off = first - 1;
    temp[0] = Pw[off]; temp[last + 1 - off] = Pw[last + 1];
    let i = first, j = last, ii = 1, jj = last - off, remflag = false;
    while (j - i > t) {
      const alfi = (u - U[i]) / (U[i + ord + t] - U[i]);
      const alfj = (u - U[j - t]) / (U[j + ord] - U[j - t]);
      temp[ii] = V.mul(V.sub(Pw[i], V.mul(temp[ii - 1], 1 - alfi)), 1 / alfi);
      temp[jj] = V.mul(V.sub(Pw[j], V.mul(temp[jj + 1], alfj)), 1 / (1 - alfj));
      i++; ii++; j--; jj--;
    }
    if (j - i < t) { if (V.dist(temp[ii - 1], temp[jj + 1]) <= TOL) remflag = true; }
    else {
      const alfi = (u - U[i]) / (U[i + ord + t] - U[i]);
      if (V.dist(Pw[i], V.comb(temp[ii + t + 1], alfi, temp[ii - 1], 1 - alfi)) <= TOL) remflag = true;
    }
    if (!remflag) break;
    i = first; j = last;
    while (j - i > t) { Pw[i] = temp[i - off]; Pw[j] = temp[j - off]; i++; j--; }
    first--; last++;
  }
  if (t === 0) return { curve: c, removed: 0 };
  for (let k = r + 1; k <= m; k++) U[k - t] = U[k];
  U.length = m + 1 - t;
  let j = fout, i = j;
  for (let k = 1; k < t; k++) { if (k % 2 === 1) i++; else j--; }
  for (let k = i + 1; k <= n; k++) { Pw[j] = Pw[k]; j++; }
  Pw.length = n + 1 - t;
  return { curve: fromH(p, U, Pw), removed: t };
}

Bézier pieces

64-bit

The curve as one Bézier piece per knot span.

Every interior knot is inserted until its multiplicity equals the degree; the pieces then share their end control points. Accuracy is that of insertKnot.

  • nurbs/curve.js
  • lines 221 to 233
  • ModelCraft 0.8.1
/** The curve as its Bézier pieces (each a clamped curve of one span on that span's own parameter interval). */
export function decompose(c) {
  const p = c.deg;
  let full = c;
  for (const k of distinctKnots(c.knots).slice(1, -1)) if (k.m < p) full = insertKnot(full, k.u, p - k.m);
  const Pw = hcvs(full), ks = distinctKnots(full.knots), out = [];
  for (let s = 0; s + 1 < ks.length; s++) {
    const a = ks[s].u, b = ks[s + 1].u;
    const seg = Pw.slice(s * p, s * p + p + 1);
    out.push(fromH(p, [...new Array(p + 1).fill(a), ...new Array(p + 1).fill(b)], seg));
  }
  return out;
}

Degree elevation

64-bit

Raises the degree of a curve by t.

Qᵢ = Σⱼ C(p, j)·C(t, i − j) ÷ C(p + t, i) · Pⱼ on each Bézier piece

The pieces are raised exactly in form, then the knots that elevation added are removed with a tolerance of 1e-10 × the diagonal of the control points' box (at least 1e-9). The result therefore matches the original within that removal tolerance.

  • nurbs/curve.js
  • lines 247 to 267
  • ModelCraft 0.8.1
export function elevate(c, t = 1, { removeKnots = true } = {}) {
  if (!Number.isInteger(t) || t < 0) fail('nurbs.err.degree');
  if (t === 0) return reparam(c, ...domain(c));
  const p = c.deg, ph = p + t;
  if (ph > MAX_DEG) fail('nurbs.err.degree', { max: MAX_DEG });
  const orig = distinctKnots(c.knots);
  const pieces = decompose(c).map((b) => {
    const P = hcvs(b), Q = [];
    for (let i = 0; i <= ph; i++) {
      let q = [0, 0, 0, 0];
      for (let j = Math.max(0, i - t); j <= Math.min(p, i); j++) q = V.add(q, V.mul(P[j], binomial(p, j) * binomial(t, i - j) / binomial(ph, i)));
      Q.push(q);
    }
    return Q;
  });
  let out = assemble(ph, pieces, orig.map((k) => k.u));
  if (!removeKnots) return out;
  const tol = 1e-10 * curveSize(c);
  for (const k of orig.slice(1, -1)) out = removeKnot(out, k.u, p - k.m, tol).curve;
  return out;
}

Degree reduction

64-bit

Lowers the degree by one if the curve moves no more than a tolerance, and reports a bound on the change.

left and right reduced points blended by λ = i ÷ q; error = largest distance between the re-elevated and the original control points

The error is a bound from control points, not a measured maximum between curves. If it exceeds the tolerance (default 1e-6) nothing is returned; otherwise what is left of the tolerance is spent on removing knots, so the total change can reach the tolerance.

  • nurbs/curve.js
  • lines 269 to 296
  • ModelCraft 0.8.1
/** One degree lower, if the curve moves no more than tol; the error is a bound (control points of the re-elevated pieces). */
export function reduce(c, tol = 1e-6) {
  const p = c.deg;
  if (p < 2) return null;
  const orig = distinctKnots(c.knots);
  let err = 0;
  const pieces = decompose(c).map((b) => {
    const P = hcvs(b), q = p - 1, L = new Array(p), R = new Array(p);
    L[0] = P[0];
    for (let i = 1; i < p; i++) { const al = i / p; L[i] = V.mul(V.sub(P[i], V.mul(L[i - 1], al)), 1 / (1 - al)); }
    R[q] = P[p];
    for (let i = q - 1; i >= 0; i--) { const al = (i + 1) / p; R[i] = V.mul(V.sub(P[i + 1], V.mul(R[i + 1], 1 - al)), 1 / al); }
    const Q = [];
    for (let i = 0; i <= q; i++) { const lam = q ? i / q : 0.5; Q.push(V.comb(L[i], 1 - lam, R[i], lam)); }
    Q[0] = P[0]; Q[q] = P[p];
    // bound: raise Q back to degree p and compare control points (convex hull: the curves differ by no more than that)
    for (let i = 0; i <= p; i++) {
      const a = i / p, e = i === 0 ? Q[0] : i === p ? Q[q] : V.comb(Q[i - 1], a, Q[i], 1 - a);
      err = Math.max(err, V.dist(e.slice(0, 3).map((x) => x / e[3]), P[i].slice(0, 3).map((x) => x / P[i][3])));
    }
    return Q;
  });
  if (err > tol) return null;
  let out = assemble(p - 1, pieces, orig.map((k) => k.u));
  const rest = Math.max(0, tol - err);
  for (const k of orig.slice(1, -1)) out = removeKnot(out, k.u, p - k.m, rest).curve;
  return { curve: out, error: err };
}

Splitting a curve

64-bit

Cuts a curve in two at a parameter.

The parameter is inserted as a knot up to the degree and the control points are divided between the halves, which share the point at the cut. The cut must be inside the domain by more than 1e-12 of its length.

  • nurbs/curve.js
  • lines 300 to 312
  • ModelCraft 0.8.1
export function split(c, u) {
  const [a, b] = domain(c);
  if (!(u > a + 1e-12 * (b - a) && u < b - 1e-12 * (b - a))) fail('nurbs.err.knot_outside');
  const p = c.deg, s = multiplicity(c.knots, u);
  const full = s < p ? insertKnot(c, u, p - s) : c;
  const Pw = hcvs(full), U = full.knots;
  let k = -1;
  for (let i = 0; i < U.length; i++) if (Math.abs(U[i] - u) <= 1e-12 * (b - a)) { k = i; break; }
  // knots U[k … k+p-1] are u (p copies); the left curve ends with p+1 copies, the right one starts with them
  const left = fromH(p, [...U.slice(0, k + p), u], Pw.slice(0, k));
  const right = fromH(p, [u, ...U.slice(k)], Pw.slice(k - 1));
  return [left, right];
}

Rescaling the parameter

64-bit

Maps a curve's knots onto a new interval.

knot′ = a + (knot − c_a)·(b − a) ÷ (c_b − c_a)

Interior knots are rounded once by the mapping; the end knots are then set to a and b exactly. Control points are copied.

  • nurbs/curve.js
  • lines 328 to 333
  • ModelCraft 0.8.1
export function reparam(c, a = 0, b = 1) {
  const [ca, cb] = domain(c), f = (b - a) / (cb - ca);
  const knots = c.knots.map((x) => a + (x - ca) * f);
  for (let i = 0; i <= c.deg; i++) { knots[i] = a; knots[knots.length - 1 - i] = b; }
  return { deg: c.deg, knots, cvs: c.cvs.map((q) => q.slice()), w: c.w ? c.w.slice() : null };
}

Reversing a curve

64-bit

The same curve run the other way.

knot′ = a + b − knot, in reverse order

Equal knots stay equal because the same expression is applied to each. Control points and weights are only reordered.

  • nurbs/curve.js
  • lines 323 to 326
  • ModelCraft 0.8.1
export function reverse(c) {
  const [a, b] = domain(c);
  return { deg: c.deg, knots: c.knots.map((x) => a + b - x).reverse(), cvs: c.cvs.map((q) => q.slice()).reverse(), w: c.w ? c.w.slice().reverse() : null };
}

Joining curves

64-bit

Makes one curve from curves that meet end to end.

joint point = (end of one + start of the next) ÷ 2 in homogeneous coordinates; knot ranges in proportion to arc length

Ends must meet within the tolerance (default 1e-6, absolute). The shared control point is the mean of the two ends, so each piece's end moves by up to half the gap. The pieces' lengths come from arcLength, a quadrature, and only set the knot ranges.

  • nurbs/curve.js
  • lines 484 to 515
  • ModelCraft 0.8.1
/** Join curves end to start (each next one turned round when its far end is the near one) into one curve, C0 at the joints. */
export function join(curves, tol = 1e-6) {
  if (!curves.length) fail('nurbs.err.cv_count', { min: 1 });
  const list = [curves[0]];
  for (let i = 1; i < curves.length; i++) {
    const prevEnd = endsOf(list[i - 1]).end, e = endsOf(curves[i]);
    if (V.dist(prevEnd, e.start) <= tol) list.push(curves[i]);
    else if (V.dist(prevEnd, e.end) <= tol) list.push(reverse(curves[i]));
    else if (i === 1 && V.dist(endsOf(list[0]).start, e.start) <= tol) { list[0] = reverse(list[0]); list.push(curves[i]); }
    else if (i === 1 && V.dist(endsOf(list[0]).start, e.end) <= tol) { list[0] = reverse(list[0]); list.push(reverse(curves[i])); }
    else fail('nurbs.err.join_gap', { index: i });
  }
  const p = Math.max(...list.map((c) => c.deg));
  const lens = list.map((c) => Math.max(1e-12, arcLength(c))), total = lens.reduce((x, y) => x + y, 0);
  let at = 0, knots = null, Pw = null;
  list.forEach((c0, i) => {
    let c = elevate(c0, p - c0.deg);
    c = reparam(c, at / total, (at + lens[i]) / total);
    at += lens[i];
    let H = hcvs(c);
    if (!Pw) { knots = c.knots.slice(); Pw = H; return; }
    // scale this piece's homogeneous points so its first weight equals the last weight so far (same curve, continuous weights)
    const f = Pw[Pw.length - 1][3] / H[0][3];
    H = H.map((q) => V.mul(q, f));
    Pw[Pw.length - 1] = V.mul(V.add(Pw[Pw.length - 1], H[0]), 0.5);
    Pw.push(...H.slice(1));
    knots = [...knots.slice(0, knots.length - 1), ...c.knots.slice(p + 1)];
  });
  knots[knots.length - 1] = 1;
  for (let i = 0; i <= p; i++) knots[knots.length - 1 - i] = 1;
  return fromH(p, knots, Pw);
}
nurbs/surface.js ModelCraft 0.8.1 64-bit 3 calculations Reviewed

Tests tools/nurbs/test.mjs

Rows back into a surface

64-bit

Rebuilds a surface from its rows after a curve operation was applied to each row.

All rows must have the same number of knots and agree on each within 1e-12 × (1 + |knot|); otherwise null is returned. The first row's knot vector is then used for the whole surface.

  • nurbs/surface.js
  • lines 246 to 252
  • ModelCraft 0.8.1
function fromRows(s, dir, rows) {
  const H = rows.map((c) => C.hcvs(c)), K = rows[0].knots;
  for (const c of rows) if (c.knots.length !== K.length || c.knots.some((x, i) => Math.abs(x - K[i]) > 1e-12 * (1 + Math.abs(K[i])))) return null;
  if (dir === 'v') return fromHGrid(s.degU, rows[0].deg, s.knotsU, K, H);
  const Pw = H[0].map((_, i) => H.map((col) => col[i]));
  return fromHGrid(rows[0].deg, s.degV, K, s.knotsV, Pw);
}

Degree elevation of a surface

64-bit

Raises the degree of a surface in one direction.

Every row is elevated as a curve. If the rows end with different knots after their knot removal, the elevation is redone without knot removal, which keeps the extra knots.

  • nurbs/surface.js
  • lines 259 to 263
  • ModelCraft 0.8.1
export function elevate(s, dir, t = 1) {
  checkDir(dir);
  const rows = rowsOf(s, dir);
  return fromRows(s, dir, rows.map((c) => C.elevate(c, t))) || fromRows(s, dir, rows.map((c) => C.elevate(c, t, { removeKnots: false })));
}

Rescaling a surface's parameters

64-bit

Maps the knots of one direction onto a new interval.

knot′ = a + (knot − k_a)·(b − a) ÷ (k_b − k_a)

The same mapping as for curves, with the end knots set exactly afterwards.

  • nurbs/surface.js
  • lines 283 to 287
  • ModelCraft 0.8.1
function mapKnots(K, p, n, a, b) {
  const ka = K[p], kb = K[n], f = (b - a) / (kb - ka), out = K.map((x) => a + (x - ka) * f);
  for (let i = 0; i <= p; i++) { out[i] = a; out[out.length - 1 - i] = b; }
  return out;
}
nurbs/tools.js ModelCraft 0.8.1 64-bit 2 calculations Reviewed

Tests None found by the review

Uniform knots for a curve from control points

64-bit

The knot vector given to a curve drawn by its control points when none is supplied.

p + 1 zeros; i ÷ (n − p) for i = 1 … n − p − 1; p + 1 ones

Each interior knot is one division of two whole numbers.

  • nurbs/tools.js
  • line 143
  • ModelCraft 0.8.1
function clamped(n, p) { const U = new Array(p + 1).fill(0); for (let i = 1; i < n - p; i++) U.push(i / (n - p)); for (let i = 0; i <= p; i++) U.push(1); return U; }

Parameter from a fraction

64-bit

The curve parameter for a command's position given as a fraction 0 to 1.

u = a + (b − a)·f

One 64-bit expression. The commands that use it (insert knot, remove knot, split) default to f = 0.5.

  • nurbs/tools.js
  • line 144
  • ModelCraft 0.8.1
const at = (c, f) => { const [a, b] = C.domain(c); return a + (b - a) * f; };

In detail

Method
The NURBS Book: knot insertion A5.1, knot removal A5.8 with the homogeneous tolerance of eq. 5.30, Bézier decomposition § 5.3, degree elevation eq. 5.36, degree reduction eq. 5.41 and 5.42, as cited in the file's header.
Number types
64-bit numbers in plain arrays; binomial coefficients from an integer table.
Tolerances
A knot must be inside the domain by more than 1e-12 of its length; knots are matched within 1e-12 of the domain. Knot removal default 1e-9 (absolute). Elevation removes its extra knots at 1e-10 × the box diagonal of the control points (at least 1e-9). Reduction default 1e-6. Join default 1e-6. Surface rows must agree on knots within 1e-12 × (1 + |knot|).
Limits
Removal undoes an insertion by dividing by α and 1 − α, which amplifies rounding when a knot lies close to its neighbors. After a join, each piece's end may have moved by up to half the gap between the ends.

Approach: 64-bit, direct

Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.

Every policy that takes this approach