Resources Numerical accuracy Calculation policy

Curve objects

Evaluates Bézier, NURBS and poly splines and makes the mesh a curve object shows: a tube, a ribbon, or a filled flat shape with thickness and a rounded edge.

How the precision is kept

Control points and all evaluated points are 64-bit numbers. A spline is sampled at a fixed number of points per segment (12 by default, up to 64), so the mesh follows the true curve only at those samples and is straight between them. The two circle presets are approximations with rounded constants.

Open item. Say in the app that the NURBS circle preset is round to about 0.3 %, or build it from rational arcs so its radius is exact.

Calculations under this policy

14 calculations in 2 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.

mesh/curve.js ModelCraft 0.8.1 64-bit 12 calculations Reviewed

Tests tools/modelcraft-test/mesh.mjs

Preset curves

64-bit

The control points of a new curve: an S, a Bézier circle, a NURBS circle or a straight path.

Bézier circle: handle length = r × 0.5522847498; NURBS circle: 8 points on an octagon of radius r ÷ 0.9254

0.5522847498 is the quarter-circle handle constant 4(√2 − 1) ÷ 3 written to 10 digits. 0.9254 is a rounded factor: the code's comment says the resulting curve stays between 0.924 and 0.927 of the octagon's radius, so the NURBS circle is round to about 0.3 %.

  • mesh/curve.js
  • lines 68 to 86
  • ModelCraft 0.8.1
/** A new curve of `size` (m): 'bezier' (an S), 'bezier-circle', 'nurbs' (an S), 'nurbs-circle', 'path' (a straight NURBS). */
export function newCurve(kind, size = 1) {
  const s = size, base = { dim: '3d', fill: false, extrude: 0, bevel: { depth: s / 40, resolution: 4 }, caps: true, taper: [1] };
  if (kind === 'bezier-circle' || kind === 'nurbs-circle') {
    const r = s / 2;
    if (kind === 'bezier-circle') {
      const k = r * 0.5522847498;   // the cubic that best fits a quarter circle
      const P = [[r, 0, 0], [0, r, 0], [-r, 0, 0], [0, -r, 0]], T = [[0, 1, 0], [-1, 0, 0], [0, -1, 0], [1, 0, 0]];
      return { ...base, dim: '2d', splines: [{ type: 'bezier', cyclic: true, resolution: 12, order: 4, points: P.map((p, i) => pt(p, { hl: sub(p, mul(T[i], k)), hr: add(p, mul(T[i], k)), h: 'free' })) }] };
    }
    // eight points on a regular octagon, order 3, uniform knots, equal weights: the curve keeps between 0.924 and 0.927 of
    // the octagon's radius (round to 0.3 %), so the octagon is made 1 / 0.9254 of the circle's radius
    const R = r / 0.9254, P = Array.from({ length: 8 }, (_, i) => [R * Math.cos((i * Math.PI) / 4), R * Math.sin((i * Math.PI) / 4), 0]);
    return { ...base, dim: '2d', splines: [{ type: 'nurbs', cyclic: true, resolution: 12, order: 3, points: P.map((p) => pt(p)) }] };
  }
  if (kind === 'path') return { ...base, splines: [{ type: 'nurbs', cyclic: false, resolution: 12, order: 4, points: [0, 1, 2, 3, 4].map((i) => pt([((i - 2) * s) / 4, 0, 0])) }] };
  const P = [[-s / 2, 0, 0], [-s / 6, s / 4, 0], [s / 6, -s / 4, 0], [s / 2, 0, 0]];
  return { ...base, splines: [{ type: kind === 'nurbs' ? 'nurbs' : 'bezier', cyclic: false, resolution: 12, order: 4, points: P.map((p) => pt(p)) }] };
}

Automatic handles

64-bit

Places a Bézier point's handles when they are automatic or vector.

auto: along (next − previous) made unit, a third of each neighbor's distance; vector: a third of the way to each neighbor

64-bit lengths by Math.hypot. The thirds are divisions by 3 or the factor 1 ÷ 3, which is not an exact binary number.

  • mesh/curve.js
  • lines 89 to 107
  • ModelCraft 0.8.1
/** Place auto and vector handles (free ones are kept), as a copy. */
export function handles(sp) {
  const s = structuredClone(sp), n = s.points.length;
  for (let i = 0; i < n; i++) {
    const q = s.points[i];
    if (q.h === 'free') continue;
    const prev = i > 0 ? s.points[i - 1].p : s.cyclic ? s.points[n - 1].p : null, next = i < n - 1 ? s.points[i + 1].p : s.cyclic ? s.points[0].p : null;
    if (q.h === 'vector') {
      q.hl = prev ? lerp(q.p, prev, 1 / 3) : q.p.slice();
      q.hr = next ? lerp(q.p, next, 1 / 3) : q.p.slice();
      continue;
    }
    // auto: along the line from the point before to the point after, a third of each side's length
    const d = norm(sub(next || q.p, prev || q.p));
    q.hl = prev ? sub(q.p, mul(d, len(sub(q.p, prev)) / 3)) : q.p.slice();
    q.hr = next ? add(q.p, mul(d, len(sub(next, q.p)) / 3)) : q.p.slice();
  }
  return s;
}

Point on a cubic Bézier segment

64-bit

A point of a segment at a fraction t.

p = (1 − t)³ a + 3 (1 − t)² t b + 3 (1 − t) t² c + t³ d

The Bernstein form: four non-negative weights for t in [0, 1], so nothing cancels.

  • mesh/curve.js
  • line 108
  • ModelCraft 0.8.1
const bez = (a, b, c, d, t) => { const u = 1 - t; return [0, 1, 2].map((k) => u * u * u * a[k] + 3 * u * u * t * b[k] + 3 * u * t * t * c[k] + t * t * t * d[k]); };

Point on a NURBS spline

64-bit

A point of a NURBS spline at a parameter.

de Boor on (x w, y w, z w, w), then (x, y, z) ÷ w

The knots are whole numbers, so the blend factors are quotients of small integers and the parameter. The final division is by the blended weight; weights are kept at 1e-6 or more when a curve is read.

  • mesh/curve.js
  • lines 116 to 129
  • ModelCraft 0.8.1
/** de Boor at u (homogeneous points [x w, y w, z w, w]). */
function deBoor(P, U, k, u) {
  const p = k - 1;
  let s = p;
  while (s < P.length - 1 && u >= U[s + 1]) s++;
  const d = [];
  for (let j = 0; j <= p; j++) d.push(P[s - p + j].slice());
  for (let r = 1; r <= p; r++) for (let j = p; j >= r; j--) {
    const i = s - p + j, den = U[i + p - r + 1] - U[i], a = den > 0 ? (u - U[i]) / den : 0;
    for (let c = 0; c < 4; c++) d[j][c] = (1 - a) * d[j - 1][c] + a * d[j][c];
  }
  const q = d[p];
  return [q[0] / q[3], q[1] / q[3], q[2] / q[3]];
}

Sampling a spline

64-bit

Points along a spline with tangents, the fraction of the length at each, and the radius and tilt there.

resolution samples per segment; tangent = (next − previous) made unit; s = Σ chord lengths so far ÷ total

The length is the sum of the chords between samples, added in order, so it is shorter than the true curve by an amount that depends on the resolution. A NURBS spline's last point is evaluated 1e-12 of the knot range before its end, not at the end itself.

  • mesh/curve.js
  • lines 130 to 179
  • ModelCraft 0.8.1
/**
 * Points along a spline with their tangents, arc length (s, 0…1) and the radius and tilt between its control points.
 * → {pts: [{p, t, s, radius, tilt}], closed}
 */
export function sample(sp) {
  const n = sp.points.length, out = [];
  if (n < 2) return { pts: [], closed: false };
  const res = Math.max(1, sp.resolution || 12);
  const attr = (i, j, f) => ({ radius: sp.points[i].radius + (sp.points[j].radius - sp.points[i].radius) * f, tilt: sp.points[i].tilt + (sp.points[j].tilt - sp.points[i].tilt) * f });
  if (sp.type === 'poly') {
    const segs = sp.cyclic ? n : n - 1;
    for (let i = 0; i < segs; i++) out.push({ p: sp.points[i].p.slice(), ...attr(i, i, 0) });
    if (!sp.cyclic) out.push({ p: sp.points[n - 1].p.slice(), ...attr(n - 1, n - 1, 0) });
  } else if (sp.type === 'bezier') {
    const s = handles(sp), segs = s.cyclic ? n : n - 1;
    for (let i = 0; i < segs; i++) {
      const a = s.points[i], b = s.points[(i + 1) % n];
      for (let k = 0; k < res; k++) { const f = k / res; out.push({ p: bez(a.p, a.hr, b.hl, b.p, f), ...attr(i, (i + 1) % n, f) }); }
    }
    if (!s.cyclic) out.push({ p: s.points[n - 1].p.slice(), ...attr(n - 1, n - 1, 0) });
  } else {
    const k = Math.min(sp.order || 4, n + (sp.cyclic ? 0 : 0));
    const H = sp.points.map((q) => [q.p[0] * q.w, q.p[1] * q.w, q.p[2] * q.w, q.w]);
    const P = sp.cyclic ? H.concat(H.slice(0, k - 1)) : H;
    const kk = Math.min(k, P.length);
    const U = knots(P.length, kk, sp.cyclic);
    const u0 = U[kk - 1], u1 = U[P.length], steps = res * (sp.cyclic ? n : n - 1);
    for (let i = 0; i <= steps; i++) {
      if (sp.cyclic && i === steps) break;
      const u = u0 + ((u1 - u0) * i) / steps, f = (i / steps) * (sp.cyclic ? n : n - 1), j = Math.min(n - 1, Math.floor(f));
      out.push({ p: deBoor(P, U, kk, Math.min(u, u1 - 1e-12 * (u1 - u0 || 1))), ...attr(j % n, (j + 1) % n, f - j) });
    }
    if (!sp.cyclic) out[out.length - 1].p = deBoor(P, U, kk, u1 - 1e-12 * (u1 - u0 || 1));
  }
  // drop repeated points, then tangents and arc length
  const pts = [];
  for (const q of out) if (!pts.length || len(sub(q.p, pts[pts.length - 1].p)) > 1e-12) pts.push(q);
  if (sp.cyclic && pts.length > 1 && len(sub(pts[0].p, pts[pts.length - 1].p)) <= 1e-12) pts.pop();
  const m = pts.length, closed = !!sp.cyclic && m > 2;
  let L = 0;
  const acc = [0];
  for (let i = 1; i < m; i++) { L += len(sub(pts[i].p, pts[i - 1].p)); acc.push(L); }
  if (closed) L += len(sub(pts[0].p, pts[m - 1].p));
  for (let i = 0; i < m; i++) {
    const a = i > 0 ? pts[i - 1].p : closed ? pts[m - 1].p : pts[i].p, b = i < m - 1 ? pts[i + 1].p : closed ? pts[0].p : pts[i].p;
    pts[i].t = norm(sub(b, a));
    pts[i].s = L > 0 ? acc[i] / L : 0;
  }
  return { pts, closed, length: L };
}

Frames along the curve

64-bit

A side and an up direction at every sample that twist as little as possible, with the tilt applied.

double reflection from one sample to the next; closed curve: the leftover angle is undone in proportion to s

Each frame is built from the one before, so rounding accumulates along the curve; every frame is made square to the tangent and unit again at each step. The closing angle comes from one Math.atan2.

  • mesh/curve.js
  • lines 181 to 205
  • ModelCraft 0.8.1
/** Rotation-minimizing frames along the points (double reflection); a closed curve's left-over twist spread evenly. */
function frames(pts, closed, up = null) {
  const m = pts.length, R = new Array(m);
  const t0 = pts[0].t;
  let r = up && Math.abs(dot(up, t0)) < 0.999 ? norm(sub(up, mul(t0, dot(up, t0)))) : (() => { const a = Math.abs(t0[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]; return norm(cross(cross(t0, a), t0)); })();
  R[0] = r;
  for (let i = 0; i + 1 < m; i++) {
    const v1 = sub(pts[i + 1].p, pts[i].p), c1 = dot(v1, v1);
    if (!(c1 > 0)) { R[i + 1] = r; continue; }
    const rL = sub(r, mul(v1, (2 / c1) * dot(v1, r))), tL = sub(pts[i].t, mul(v1, (2 / c1) * dot(v1, pts[i].t)));
    const v2 = sub(pts[i + 1].t, tL), c2 = dot(v2, v2);
    r = c2 > 0 ? sub(rL, mul(v2, (2 / c2) * dot(v2, rL))) : rL;
    r = norm(sub(r, mul(pts[i + 1].t, dot(r, pts[i + 1].t))));
    R[i + 1] = r;
  }
  if (closed && m > 2) {
    // carry the last frame round to the start: the angle it misses by is undone a little at each point
    const v1 = sub(pts[0].p, pts[m - 1].p), c1 = dot(v1, v1);
    let rr = R[m - 1];
    if (c1 > 0) { const rL = sub(rr, mul(v1, (2 / c1) * dot(v1, rr))), tL = sub(pts[m - 1].t, mul(v1, (2 / c1) * dot(v1, pts[m - 1].t))), v2 = sub(pts[0].t, tL), c2 = dot(v2, v2); rr = c2 > 0 ? sub(rL, mul(v2, (2 / c2) * dot(v2, rL))) : rL; }
    const b0 = cross(pts[0].t, R[0]), miss = Math.atan2(dot(rr, b0), dot(rr, R[0]));
    for (let i = 0; i < m; i++) R[i] = rotate(R[i], pts[i].t, -miss * pts[i].s);
  }
  return R.map((x, i) => { const rt = rotate(x, pts[i].t, pts[i].tilt || 0); return { n: rt, b: cross(pts[i].t, rt) }; });
}

Taper along the curve

64-bit

The scale of the cross-section at a fraction of the length.

Catmull-Rom through the taper values, not below 0

A cubic through four neighboring values in 64-bit; the result is floored at 0 because the cubic can overshoot.

  • mesh/curve.js
  • lines 208 to 215
  • ModelCraft 0.8.1
/** The taper's scale at s (0…1): its values evenly along, joined smoothly (Catmull-Rom). */
function taperAt(taper, s) {
  const n = taper.length;
  if (n === 1) return taper[0];
  const f = Math.max(0, Math.min(1, s)) * (n - 1), i = Math.min(n - 2, Math.floor(f)), t = f - i;
  const p0 = taper[Math.max(0, i - 1)], p1 = taper[i], p2 = taper[i + 1], p3 = taper[Math.min(n - 1, i + 2)];
  return Math.max(0, 0.5 * (2 * p1 + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t * t + (-p0 + 3 * p1 - 3 * p2 + p3) * t * t * t));
}

Cross-section

64-bit

The outline swept along the curve: a circle, a stadium or a flat ribbon.

circle: 4 (resolution + 1) points at radius depth; stadium: two half circles offset by ± extrude

The round section is a polygon with points at sine and cosine of evenly spaced angles, so its area is that of the polygon and not of a circle.

  • mesh/curve.js
  • lines 216 to 229
  • ModelCraft 0.8.1
/** The cross-section: bevel round (a circle, or a stadium with extrude), or a ribbon (extrude only). → {pts: [[x, y]], closed} */
function profile(depth, res, extrude) {
  const q = Math.max(1, res + 1);   // segments per quarter
  if (depth > 0) {
    const out = [];
    if (extrude > 0) {
      for (let i = 0; i <= 2 * q; i++) { const a = -Math.PI / 2 + (Math.PI * i) / (2 * q); out.push([depth * Math.cos(a), extrude + depth * Math.sin(a)]); }
      for (let i = 0; i <= 2 * q; i++) { const a = Math.PI / 2 + (Math.PI * i) / (2 * q); out.push([depth * Math.cos(a), -extrude + depth * Math.sin(a)]); }
    } else for (let i = 0; i < 4 * q; i++) { const a = (2 * Math.PI * i) / (4 * q); out.push([depth * Math.cos(a), depth * Math.sin(a)]); }
    return { pts: out, closed: true };
  }
  if (extrude > 0) return { pts: [[0, -extrude], [0, extrude]], closed: false };
  return null;
}

Sweeping the section

64-bit

Places the cross-section at every sample and joins the rings into faces.

point = sample + side × (−x k) + up × (y k); k = radius × taper

Each mesh point is one 64-bit sum of the sample and two scaled frame vectors. The faces are turned to face outward by the sign of one dot product.

  • mesh/curve.js
  • lines 246 to 271
  • ModelCraft 0.8.1
function sweep(S, curve, P, F) {
  const pr = profile(curve.bevel.depth, curve.bevel.resolution, curve.extrude);
  if (!pr) return;
  const fr = frames(S.pts, S.closed, curve.dim === '2d' ? [0, 0, 1] : null);
  const base = P.length, np = pr.pts.length, m = S.pts.length, f0 = F.length;
  for (let i = 0; i < m; i++) {
    const q = S.pts[i], k = q.radius * taperAt(curve.taper, q.s), { n, b } = fr[i];
    // a flat curve: the profile's x along the curve's side in its plane (b), its y up (n = +z)
    for (const [x, y] of pr.pts) P.push(add(q.p, add(mul(b, -x * k), mul(n, y * k))));
  }
  const ring = (i) => base + (i % m) * np;
  const segs = S.closed ? m : m - 1, pe = pr.closed ? np : np - 1;
  for (let i = 0; i < segs; i++) for (let j = 0; j < pe; j++) {
    const a = ring(i) + j, b2 = ring(i) + ((j + 1) % np), c = ring(i + 1) + ((j + 1) % np), d = ring(i + 1) + j;
    F.push([a, d, c, b2]);
  }
  if (!S.closed && pr.closed && curve.caps) {
    F.push(Array.from({ length: np }, (_, j) => ring(0) + j));
    F.push(Array.from({ length: np }, (_, j) => ring(m - 1) + (np - 1 - j)));
  }
  // facing out: the first side face's normal against the way from the curve to it
  if (pr.closed && f0 < F.length) {
    const [a, b2, c] = F[f0], nrm = cross(sub(P[b2], P[a]), sub(P[c], P[a]));
    if (dot(nrm, sub(P[a], S.pts[0].p)) < 0) for (let i = f0; i < F.length; i++) F[i].reverse();
  }
}

Offsetting a loop

64-bit

Moves each vertex of a flat outline outward by a distance, with mitered corners.

m = (n₁ + n₂) made unit; p′ = p + m × d ÷ max(0.25, m · n₁)

The miter length is limited to 4 times the distance by the floor of 0.25. Edge lengths of 0 fall back to 1. No test is made for an offset outline crossing itself.

  • mesh/curve.js
  • lines 275 to 292
  • ModelCraft 0.8.1
/** Each vertex of a loop moved d along its outward side (mitred, limited to 4 × d at sharp corners). */
function offsetLoop(L, d) {
  const n = L.length, out = [];
  for (let i = 0; i < n; i++) {
    const a = L[(i + n - 1) % n], p = L[i], b = L[(i + 1) % n];
    let e1 = [p[0] - a[0], p[1] - a[1]], e2 = [b[0] - p[0], b[1] - p[1]];
    const l1 = Math.hypot(e1[0], e1[1]) || 1, l2 = Math.hypot(e2[0], e2[1]) || 1;
    e1 = [e1[0] / l1, e1[1] / l1]; e2 = [e2[0] / l2, e2[1] / l2];
    // outward = to the right of a counter-clockwise outline
    const n1 = [e1[1], -e1[0]], n2 = [e2[1], -e2[0]];
    let m = [n1[0] + n2[0], n1[1] + n2[1]];
    const ml = Math.hypot(m[0], m[1]);
    m = ml > 1e-9 ? [m[0] / ml, m[1] / ml] : n1;
    const c = Math.max(0.25, m[0] * n1[0] + m[1] * n1[1]);
    out.push([p[0] + (m[0] * d) / c, p[1] + (m[1] * d) / c]);
  }
  return out;
}

Filled shapes

64-bit

Sorts closed flat loops into outlines and holes and builds the top, bottom and edge of the filled shape.

nesting: a loop inside an even number of others is an outline; direction from the sign of the shoelace area

Nesting is decided by testing only each loop's first vertex against the other loops with an even-odd crossing count. The shoelace sum uses absolute coordinates, so for a small loop far from the origin its terms nearly cancel.

  • mesh/curve.js
  • lines 293 to 335
  • ModelCraft 0.8.1
/** Closed flat loops filled: outlines with their holes, given a thickness (extrude) and a rounded edge (bevel). */
function filled(loops, curve, P, F) {
  // nesting: a loop inside an even number of others is an outline, else a hole of the smallest outline round it
  const info = loops.map((L, i) => ({ L, i, depth: loops.filter((M, j) => j !== i && inside(L[0], M)).length, area: Math.abs(area2(L)) }));
  for (const x of info) x.L = (x.depth % 2 === 0) === area2(x.L) > 0 ? x.L : x.L.slice().reverse();   // outlines CCW, holes CW
  const outers = info.filter((x) => x.depth % 2 === 0);
  const e = curve.extrude, d = curve.bevel.depth, q = Math.max(1, curve.bevel.resolution + 1);
  // the edge's profile, bottom to top: [outward offset, z]
  const prof = [];
  if (d > 0) {
    for (let i = 0; i <= q; i++) { const a = -Math.PI / 2 + (Math.PI / 2) * (i / q); prof.push([d * Math.cos(a), -e + d * Math.sin(a)]); }
    for (let i = 0; i <= q; i++) { const a = (Math.PI / 2) * (i / q); prof.push([d * Math.cos(a), e + d * Math.sin(a)]); }
    if (e === 0) prof.splice(q + 1, 1);   // the two middle rings are the same
  } else if (e > 0) prof.push([0, -e], [0, e]);
  for (const o of outers) {
    const holes = info.filter((h) => h.depth === o.depth + 1 && inside(h.L[0], o.L) && !info.some((x) => x !== o && x.depth === o.depth && x.area < o.area && inside(h.L[0], x.L) && inside(x.L[0], o.L)));
    const group = [o, ...holes];
    // side rings
    const ringsOf = group.map((x) => {
      const rings = prof.map(([off, z]) => { const base = P.length; for (const [px, py] of offsetLoop(x.L, off)) P.push([px, py, z]); return base; });
      const n = x.L.length;
      for (let r = 0; r + 1 < rings.length; r++) for (let i = 0; i < n; i++) F.push([rings[r] + i, rings[r] + ((i + 1) % n), rings[r + 1] + ((i + 1) % n), rings[r + 1] + i]);
      return { rings, n };
    });
    // caps: the top and bottom rings (or one face at z = 0 when it has no thickness), with the holes cut by ear clipping
    const flat = [], holeIdx = [], idx = [];
    group.forEach((x, g) => { if (g) holeIdx.push(flat.length / 2); x.L.forEach(([px, py], i) => { flat.push(px, py); idx.push([g, i]); }); });
    const tri = earcut(flat, holeIdx);
    const vAt = (g, i, which) => { const R = ringsOf[g]; return R.rings.length ? (which ? R.rings[R.rings.length - 1] : R.rings[0]) + i : null; };
    if (!prof.length) {
      const base = P.length;
      group.forEach((x) => x.L.forEach(([px, py]) => P.push([px, py, 0])));
      for (let i = 0; i < tri.length; i += 3) F.push(ccw(flat, tri[i], tri[i + 1], tri[i + 2]) < 0 ? [base + tri[i], base + tri[i + 2], base + tri[i + 1]] : [base + tri[i], base + tri[i + 1], base + tri[i + 2]]);
      continue;
    }
    for (let i = 0; i < tri.length; i += 3) {
      let [a, b, c] = [idx[tri[i]], idx[tri[i + 1]], idx[tri[i + 2]]];
      if (ccw(flat, tri[i], tri[i + 1], tri[i + 2]) < 0) [b, c] = [c, b];   // the top faces up, the bottom down
      F.push([vAt(a[0], a[1], 1), vAt(b[0], b[1], 1), vAt(c[0], c[1], 1)]);
      F.push([vAt(c[0], c[1], 0), vAt(b[0], b[1], 0), vAt(a[0], a[1], 0)]);
    }
  }
}

Adding a point in a segment

64-bit

Splits a segment at its middle; a Bézier segment keeps its shape.

de Casteljau at t = ½

Six midpoints, each a 64-bit mean of two points. The new point and handles lie on the old curve up to the rounding of those means.

  • mesh/curve.js
  • lines 349 to 364
  • ModelCraft 0.8.1
/** A point added in the middle of segment i → i + 1: for Bézier by de Casteljau (the shape unchanged), else the midpoint. */
export function insertAfter(sp, i) {
  const s = sp.type === 'bezier' ? handles(sp) : structuredClone(sp), n = s.points.length, j = (i + 1) % n;
  if (!s.cyclic && i >= n - 1) return sp;
  const a = s.points[i], b = s.points[j];
  let np;
  if (s.type === 'bezier') {
    const p01 = lerp(a.p, a.hr, 0.5), p12 = lerp(a.hr, b.hl, 0.5), p23 = lerp(b.hl, b.p, 0.5), p012 = lerp(p01, p12, 0.5), p123 = lerp(p12, p23, 0.5), mid = lerp(p012, p123, 0.5);
    a.hr = p01; b.hl = p23;
    if (a.h === 'auto') a.h = 'free';
    if (b.h === 'auto') b.h = 'free';
    np = pt(mid, { hl: p012, hr: p123, h: 'free', radius: (a.radius + b.radius) / 2, tilt: (a.tilt + b.tilt) / 2 });
  } else np = pt(lerp(a.p, b.p, 0.5), { w: (a.w + b.w) / 2, radius: (a.radius + b.radius) / 2, tilt: (a.tilt + b.tilt) / 2 });
  s.points.splice(i + 1, 0, np);
  return s;
}
mesh/curve-tool.js ModelCraft 0.8.1 64-bit 2 calculations Reviewed

Tests None found by the review

Moving, scaling and turning points

64-bit

The new place of each selected point and handle while it is dragged.

grab: p + (M⁻¹ w₁ − M⁻¹ w₀); scale: c + (p − c) × |a₁| ÷ max(1, |a₀|); turn: Rodrigues' formula about the view axis through c

Every step of a drag is worked out from a copy of the curve as it was when the drag began, so the steps do not add up. The scale factor and the angle come from pointer positions in pixels, so they have the pointer's resolution.

  • mesh/curve-tool.js
  • lines 263 to 276
  • ModelCraft 0.8.1
const w1 = tool.planeAt(ev, this.anchorW);
let fn;
if (this.kind === 'grab') { const d = sub(apply(inv, w1), apply(inv, this.w0)); fn = (p) => add(p, d); }
else {
  const cW = apply(W, this.center), cs = this.h.cam.project(cW), a0 = [this.ev0.x - cs[0], this.ev0.y - cs[1]], a1 = [ev.x - cs[0], ev.y - cs[1]];
  if (this.kind === 'scale') { const k = Math.hypot(a1[0], a1[1]) / Math.max(1, Math.hypot(a0[0], a0[1])); fn = (p) => add(this.center, mul(sub(p, this.center), k)); }
  else {
    // turn about the line of sight through the centre (a flat curve: its own z)
    const ang = -(Math.atan2(a1[1], a1[0]) - Math.atan2(a0[1], a0[0]));
    const axisW = tool.curve.dim === '2d' ? norm(sub(apply(W, [0, 0, 1]), apply(W, [0, 0, 0]))) : norm(sub(this.h.cam.eye, this.h.cam.target));
    const ax = norm(sub(apply(inv, add(cW, axisW)), this.center)), c = Math.cos(ang), s = Math.sin(ang);
    fn = (p) => { const v = sub(p, this.center), k = dot(ax, v), x = [ax[1] * v[2] - ax[2] * v[1], ax[2] * v[0] - ax[0] * v[2], ax[0] * v[1] - ax[1] * v[0]]; return add(this.center, [v[0] * c + x[0] * s + ax[0] * k * (1 - c), v[1] * c + x[1] * s + ax[1] * k * (1 - c), v[2] * c + x[2] * s + ax[2] * k * (1 - c)]); };
  }
}

A new point at the pointer

64-bit

Where a point added after the end of a spline goes.

world point = pick ray ∩ plane through the last point; local = M⁻¹ × world; flat curve: z = 0

The world point goes through the inverse of the placement matrix in 64-bit. For a flat curve the z coordinate is set to exactly 0 afterward.

  • mesh/curve-tool.js
  • lines 234 to 237
  • ModelCraft 0.8.1
const from = c.splines[s].points[i], W = this.world;
const w = ev ? this.planeAt(ev, apply(W, from.p)) : apply(W, add(from.p, [0.1, 0, 0]));
let p = this.toLocal(w);
if (c.dim === '2d') p = [p[0], p[1], 0];

In detail

Method
Cubic Bézier in Bernstein form; NURBS by de Boor's algorithm in homogeneous coordinates (the file cites Piegl and Tiller); rotation-minimizing frames by double reflection (the file cites Wang and others, 2008); fills by ear clipping.
Number types
64-bit numbers in plain arrays; the line segments handed to the view are a Float32Array.
Tolerances
Sampled points closer than 1e-12 are merged (absolute). The NURBS end is evaluated 1e-12 of the knot range inside the last knot. Offsets at sharp corners are limited to 4 times the offset.
Checked by
tools/modelcraft-test/mesh.mjs loads mesh/curve.js from the build it is given and calls curveMesh, sample and newCurve.
Limits
A spline's length is the length of its sampled polyline. The Bézier circle uses 0.5522847498 for the handle length; the NURBS circle is an eight-point curve whose radius, by the file's own comment, is within 0.3 % of round.

Approach: 64-bit, stated limit

The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.

Every policy that takes this approach