Surfaces from curves: extrude, ruled, revolve and loft
Builds a surface by pulling a curve along a direction, spanning two curves, turning a profile about an axis or passing through a set of sections.
How the precision is kept
These constructions place control points directly in 64-bit and report themselves as exact: the surface contains the input curves up to rounding. Curves are first brought to one degree and one knot vector; in that step knot values are rounded to 12 decimals and every curve is given the first curve's knot vector.
Open item. Insert the knots of the other curves at their own 64-bit values instead of values rounded to 12 decimals.
Calculations under this policy
7 calculations in 1 source file. 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.
The list of distinct knots, each with its highest multiplicity, over several curves or surfaces.
key = round(u × 10¹²) ÷ 10¹²
Knot values are rounded to 12 decimals to decide which knots are the same, and the rounded value is what is returned and later inserted. A knot is therefore inserted at a value that can differ from the original by up to 5e-13 of the 0…1 range.
nurbs/construct.js
lines 39 to 46
ModelCraft 0.8.1
function unionKnots(list) {
const map = new Map();
for (const U of list) for (const k of B.distinctKnots(U)) {
const key = Math.round(k.u * 1e12) / 1e12;
map.set(key, Math.max(map.get(key) || 0, k.m));
}
return [...map.entries()].sort((a, b) => a[0] - b[0]);
}
Making curves compatible
64-bit
Brings curves to one degree and one knot vector on 0…1.
Curves are rescaled to 0…1, elevated to the highest degree and given the missing knots from the merged list. At the end every curve receives the first curve's knot vector as it is; control points are not recomputed for knots that differed from it by rounding.
nurbs/construct.js
lines 48 to 66
ModelCraft 0.8.1
/** Bring curves to one degree and one knot vector on [0, 1] (no curve moves). */
export function compatible(curves) {
if (!curves.length) return [];
const p = Math.max(...curves.map((c) => c.deg));
let cs = curves.map((c) => C.elevate(C.reparam(c, 0, 1), p - c.deg));
const all = unionKnots(cs.map((c) => c.knots));
cs = cs.map((c) => {
let out = c;
for (const [u, m] of all) {
if (u <= 0 || u >= 1) continue;
const have = B.multiplicity(out.knots, u, 1e-12);
if (m > have) out = C.insertKnot(out, u, m - have);
}
// snap knot values that differ only by rounding, so every curve carries the identical vector
return out;
});
const K = cs[0].knots;
return cs.map((c) => ({ ...c, knots: K.slice() }));
}
Extrude
64-bit
A surface from a curve pulled along a direction.
second row = P·w + dir·w in homogeneous coordinates
The second row of control points is the first plus the direction times the weight: one product and one addition per coordinate. The curve's knots and weights are kept.
A surface from a profile turned about an axis by an angle.
o = axis point + Z·((P − axis point)·Z); r = |P − o|; row = arc(o, X, Z × X, r, 0, angle)·w
Each profile control point is projected on the axis to get its circle's center and radius; a radius of 1e-14 or less is a pole and uses the first non-zero direction found. Each row is the rational arc from curve.arc, so a full turn closes within the arc's own gap.
nurbs/construct.js
lines 83 to 104
ModelCraft 0.8.1
/** A8.1: revolve a profile about an axis; the profile runs in u, the rotation in v. */
export function revolve(profile, axisPoint, axisDir, angle = 2 * Math.PI) {
const Z = V.norm(axisDir);
if (V.len(Z) === 0) fail('nurbs.err.axis');
const H = C.hcvs(profile);
let ref = null;
const rows = profile.cvs.map((p, i) => {
const o = V.add(axisPoint, V.mul(Z, V.dot(V.sub(p, axisPoint), Z)));
const x = V.sub(p, o), r = V.len(x);
if (r > 1e-14 && !ref) ref = V.norm(x);
return { o, x: r > 1e-14 ? V.norm(x) : null, r, w: H[i][3] };
});
if (!ref) fail('nurbs.err.on_axis');
let arcKnots = null;
const cvs = rows.map((row) => {
const X = row.x || ref, Y = V.cross(Z, X);
const a = C.arc(row.o, X, Y, row.r, 0, angle);
arcKnots = a.knots;
return C.hcvs(a).map((q) => V.mul(q, row.w));
});
return { surface: rowsToSurface(profile.deg, 2, profile.knots, arcKnots, cvs), report: { exact: true } };
}
Loft parameters
64-bit
The v parameter of each section of a loft.
tₖ = mean over control-point columns of the chord-length parameter of section k; t₀ = 0, t(last) = 1
The mean is formed by adding each column's parameter divided by the number of columns. The first and last are then set to 0 and 1 exactly.
nurbs/construct.js
lines 112 to 124
ModelCraft 0.8.1
/** § 10.3: v parameters averaged over the control-point columns (chord or centripetal). */
function skinParams(H, kind) {
const ns = H.length, t = new Array(ns).fill(0);
if (ns < 2) return [0];
const cols = H[0].length;
for (let j = 0; j < cols; j++) {
const pts = H.map((row) => row[j].slice(0, 3).map((x) => x / row[j][3]));
const tj = F.params(pts, kind);
for (let k = 0; k < ns; k++) t[k] += tj[k] / cols;
}
t[0] = 0; t[ns - 1] = 1;
return t;
}
Loft
64-bit
A surface that passes through each section curve at its parameter.
The sections are made compatible, then each column of control points is interpolated across the sections in homogeneous coordinates with one shared matrix (fit.interpolateMany). The surface passes through each section up to the rounding of that linear solve and of the compatibility step.
nurbs/construct.js
lines 126 to 137
ModelCraft 0.8.1
export function loft(sections, { degree = 3, params = null, kind = 'chord', d0 = null, d1 = null } = {}) {
if (sections.length < 2) fail('nurbs.err.sections', { min: 2 });
const cs = compatible(sections), H = cs.map((c) => C.hcvs(c));
const t = params ? params.slice() : skinParams(H, kind);
for (let k = 1; k < t.length; k++) if (!(t[k] > t[k - 1])) fail('nurbs.err.sections_order');
// one interpolation per control-point column, all with the same matrix; homogeneous values, so rational sections stay exact
const cols = H[0].map((_, i) => H.map((row) => row[i]));
const dd = (d) => (d ? d.map((x) => [x[0], x[1], x[2], 0]) : null);
const r = F.interpolateMany(cols, t, degree, { d0: dd(d0), d1: dd(d1) });
const surface = rowsToSurface(cs[0].deg, r.deg, cs[0].knots, r.knots, r.rows);
return { surface, report: { exact: true, params: t } };
}
Sum of control nets
64-bit
The control net a + b − c used by the Gordon and Coons surfaces.
Pᵢⱼ = aᵢⱼ + bᵢⱼ − cᵢⱼ
One addition and one subtraction per coordinate on nets that were first brought to the same degrees and knots. Weights are dropped, so the three nets must be polynomial.
nurbs/construct.js
lines 331 to 334
ModelCraft 0.8.1
function sumNets(a, b, c) {
// a + b − c, control point by control point (polynomial nets of one shape)
return { ...a, cvs: a.cvs.map((row, i) => row.map((p, j) => V.sub(V.add(p, b.cvs[i][j]), c.cvs[i][j]))), w: null };
}
In detail
Method
The NURBS Book § 8.3 (extruded and ruled surfaces), A8.1 (surface of revolution), § 10.3 (skinning with parameters averaged over the control-point columns), as cited in the file's header. Lofting solves one interpolation system per column in homogeneous coordinates.
Number types
64-bit numbers in plain arrays.
Tolerances
Knots are merged at 12 decimals on curves rescaled to 0…1, and multiplicities are counted within 1e-12. A profile control point within 1e-14 (absolute) of the axis becomes a pole of the revolved surface. Loft parameters must be strictly increasing.
Limits
Making curves compatible replaces each curve's knots with the first curve's, so a knot that differed by up to about 1e-12 moves without its control points being recomputed. A revolved full turn inherits the arc's closing gap of about 2.4e-16 × radius.
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.