Resources Numerical accuracy Calculation policy

Freeform bodies: subdivision surface and cage edits

Refines a control cage into a smooth surface, converts it to Bézier patches for the solid model, and moves, mirrors, extrudes and flattens cage vertices.

How the precision is kept

Cage vertices are plain 64-bit numbers. Subdivision, limit points and the patch conversion are weighted sums whose weights are formed as divisions of whole numbers (1 ÷ n, 4 ÷ 6 and so on) in 64-bit. Patches whose four corners are regular are the limit surface itself; at irregular corners the patch is an approximation, which the file header says and patchReport measures. Tolerances for symmetry scale with the size of the form.

Calculations under this policy

19 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.

form.js PartCraft 0.8.1 64-bit 19 calculations Reviewed

Tests tools/partcraft-test/test-form.mjs

One subdivision step

64-bit

New face, edge and vertex points of the cage refined once.

smooth vertex = Q ÷ n + 2R ÷ n + (n − 3) v ÷ n; crease vertex = (a + 6v + b) ÷ 8; edge = mean of its ends and the two face points

Each point is a 64-bit weighted sum; the weights are divisions of whole numbers and each mean is a sum divided by the count. A semi-sharp edge blends the smooth and sharp results by its weight and loses 1 from the weight at each level.

  • form.js
  • lines 88 to 144
  • PartCraft 0.8.1
/** One Catmull–Clark step. `track` (optional): per face { root, uv:[[u,v]…] }, refined alongside (quads). */
export function subdivide(m, track = null) {
  const t = topo(m);
  const nV = m.P.length;
  const fp = m.F.map((f) => avg(f.map((v) => m.P[v])));
  const eIdx = new Map();
  const P = m.P.map(() => null);
  const edges = [...t.E.values()];
  edges.forEach((e, i) => {
    eIdx.set(e.key, nV + i);
    const mid = lerp(m.P[e.a], m.P[e.b], 0.5);
    let p;
    if (e.w >= 1) p = mid;
    else {
      const sm = avg([m.P[e.a], m.P[e.b], fp[e.f[0]], fp[e.f[1]]]);
      p = e.w > 0 ? lerp(sm, mid, e.w) : sm;
    }
    P.push(p);
  });
  for (const p of fp) P.push(p);
  for (let v = 0; v < nV; v++) {
    const pv = m.P[v];
    const n = t.ve[v].length;
    if (!n) { P[v] = [...pv]; continue; }
    const Q = avg(t.vf[v].map((fi) => fp[fi]));
    const R = avg(t.ve[v].map((k) => { const e = t.E.get(k); return lerp(m.P[e.a], m.P[e.b], 0.5); }));
    const smooth = comb([[1 / n, Q], [2 / n, R], [(n - 3) / n, pv]]);
    const s = sharpAt(t, v);
    const corner = isCorner(m, t, v, s);
    if (s.length < 2 && !corner) { P[v] = smooth; continue; }
    const sharpP = corner ? [...pv] : comb([[1 / 8, m.P[other(s[0], v)]], [6 / 8, pv], [1 / 8, m.P[other(s[1], v)]]]);
    const sh = m.C.has(v) ? Infinity : s.reduce((a, e) => a + e.w, 0) / s.length;
    P[v] = sh >= 1 ? sharpP : lerp(smooth, sharpP, sh);
  }
  const F = [], nt = track ? [] : null;
  m.F.forEach((f, fi) => {
    const fv = nV + edges.length + fi;
    const L = f.length;
    for (let k = 0; k < L; k++) {
      const c = f[k];
      F.push([c, eIdx.get(ek(c, f[(k + 1) % L])), fv, eIdx.get(ek(f[(k + L - 1) % L], c))]);
      if (track) {
        const U = track[fi].uv, mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
        nt.push({ root: track[fi].root, uv: [U[k], mid(U[k], U[(k + 1) % L]), [U.reduce((s, q) => s + q[0], 0) / L, U.reduce((s, q) => s + q[1], 0) / L], mid(U[(k + L - 1) % L], U[k])] });
      }
    }
  });
  const W = new Map();
  for (const e of edges) {
    if (e.f.length < 2) continue;
    const w = m.W.get(e.key);
    if (!(w > 0)) continue;
    const w2 = w === Infinity ? Infinity : w - 1;
    if (w2 > 0) { const mi = eIdx.get(e.key); W.set(ek(e.a, mi), w2); W.set(ek(mi, e.b), w2); }
  }
  return { P, F, W, C: new Set(m.C), track: nt };
}

Limit position of a vertex

64-bit

Where a vertex ends up on the smooth surface.

smooth: (n² v + 4 Σ e + Σ f) ÷ (n (n + 5)); crease: (a + 4v + b) ÷ 6; corner: v

A closed-form weighted sum with each weight divided by n (n + 5) before the terms are added. The weights add up to 1 within rounding; they are not renormalized.

  • form.js
  • lines 146 to 157
  • PartCraft 0.8.1
/** Exact limit position of vertex v of an all-quad mesh (remaining creases treated as sharp). */
export function limitPoint(m, t, v) {
  const s = sharpAt(t, v);
  const pv = m.P[v];
  if (isCorner(m, t, v, s)) return [...pv];
  if (s.length === 2) return comb([[1 / 6, m.P[other(s[0], v)]], [4 / 6, pv], [1 / 6, m.P[other(s[1], v)]]]);
  const n = t.ve[v].length;
  const terms = [[n * n, pv]];
  for (const k of t.ve[v]) terms.push([4, m.P[other(t.E.get(k), v)]]);
  for (const fi of t.vf[v]) { const f = m.F[fi]; const p = f.indexOf(v); terms.push([1, m.P[f[(p + 2) % 4]]]); }
  return comb(terms.map(([w, p]) => [w / (n * (n + 5)), p]));
}

Limit surface normal at a vertex

64-bit

The unit normal of the smooth surface at an interior vertex.

A = 1 + cos(2π ÷ n) + cos(π ÷ n) √(2 (9 + cos(2π ÷ n))); normal = t₁ × t₂ ÷ |t₁ × t₂|

The two tangents are sums over the vertex's ring with cosine and sine weights computed from the ring index. No normal is returned when the cross product is not longer than 1e-300, so there is no division by zero.

  • form.js
  • lines 176 to 192
  • PartCraft 0.8.1
/** Unit normal of the limit surface at an interior vertex (Halstead's tangent masks), or null. */
export function tangentNormal(m, t, v) {
  const r = ring(m, t, v);
  if (!r) return null;
  const n = r.length;
  const c = Math.cos(2 * Math.PI / n);
  const A = 1 + c + Math.cos(Math.PI / n) * Math.sqrt(2 * (9 + c));
  let t1 = [0, 0, 0], t2 = [0, 0, 0];
  r.forEach(({ e, f }, j) => {
    const a = 2 * Math.PI * j / n, b = 2 * Math.PI * (j + 1) / n;
    t1 = V.add(t1, V.add(V.mul(m.P[e], A * Math.cos(a)), V.mul(m.P[f], Math.cos(a) + Math.cos(b))));
    t2 = V.add(t2, V.add(V.mul(m.P[e], A * Math.sin(a)), V.mul(m.P[f], Math.sin(a) + Math.sin(b))));
  });
  const nn = V.cross(t1, t2);
  const l = V.len(nn);
  return l > 1e-300 ? V.mul(nn, 1 / l) : null;
}

Bézier patch of one quad

64-bit

The 16 control points of the bicubic patch for one face of the refined cage.

b = M G Mᵀ with rows of M: (1, 4, 1, 0) ÷ 6, (0, 4, 2, 0) ÷ 6, (0, 2, 4, 0) ÷ 6, (0, 1, 4, 1) ÷ 6; across a sharp edge: 2 × edge − inner

The conversion weights are sixths formed in 64-bit. The four corner points are replaced by the limit positions. At an irregular smooth corner the two neighboring edge points are moved into the limit tangent plane by subtracting their component along the normal; such a patch is marked not exact.

  • form.js
  • lines 264 to 304
  • PartCraft 0.8.1
/** Bézier net (4×4, [i][j]) of quad fi, and whether it is exact (all corners regular). */
export function quadPatch(m, t, fi, limits = null) {
  const f = m.F[fi];
  if (f.length !== 4) fail('form.err.quad_patch');
  const G = [0, 1, 2, 3].map(() => [null, null, null, null]);
  G[1][1] = m.P[f[0]]; G[2][1] = m.P[f[1]]; G[2][2] = m.P[f[2]]; G[1][2] = m.P[f[3]];
  [G[1][0], G[2][0]] = across(m, t, fi, 0);
  [G[3][1], G[3][2]] = across(m, t, fi, 1);
  [G[2][3], G[1][3]] = across(m, t, fi, 2);
  [G[0][2], G[0][1]] = across(m, t, fi, 3);
  const b = [0, 1, 2, 3].map(() => [null, null, null, null]);
  for (let i = 0; i < 4; i++) for (let j = 0; j < 4; j++) {
    if ((i === 0 || i === 3) && (j === 0 || j === 3)) continue;
    const s = [0, 0, 0];
    for (let a = 0; a < 4; a++) {
      if (!MB[i][a]) continue;
      for (let c = 0; c < 4; c++) {
        const w = MB[i][a] * MB[j][c];
        if (!w) continue;
        const p = G[a][c];
        s[0] += w * p[0]; s[1] += w * p[1]; s[2] += w * p[2];
      }
    }
    b[i][j] = s;
  }
  const lim = (v) => (limits ? limits[v] : limitPoint(m, t, v));
  const CORN = [[0, 0, 1, 0, 0, 1], [3, 0, 2, 0, 3, 1], [3, 3, 3, 2, 2, 3], [0, 3, 0, 2, 1, 3]];   // corner, its two edge neighbours
  let exact = true;
  f.forEach((v, k) => {
    const [ci, cj, ai, aj, bi, bj] = CORN[k];
    b[ci][cj] = lim(v);
    if (regularCorner(m, t, fi, v)) return;
    exact = false;
    const s = sharpAt(t, v);
    if (s.length >= 2 || isCorner(m, t, v, s)) return;   // crease/corner: the curves along sharp edges are already exact
    const n = tangentNormal(m, t, v);
    if (!n) return;
    for (const [i, j] of [[ai, aj], [bi, bj]]) b[i][j] = V.sub(b[i][j], V.mul(n, V.dot(V.sub(b[i][j], b[ci][cj]), n)));
  });
  return { b, exact };
}

Point on a patch

64-bit

Evaluates a bicubic Bézier patch at (u, v).

S(u, v) = Σᵢ Σⱼ Bᵢ(u) Bⱼ(v) b_ij, B = ((1 − u)³, 3u(1 − u)², 3u²(1 − u), u³)

The Bernstein weights are evaluated as powers and the 16 weighted control points are added in order. All weights are non-negative for u and v in [0, 1], so the sum does not cancel.

  • form.js
  • lines 228 to 234
  • PartCraft 0.8.1
/** Point of a bicubic Bézier patch (b[i][j], i along u). */
export function evalPatch(b, u, v) {
  const bu = bern(u), bv = bern(v);
  const s = [0, 0, 0];
  for (let i = 0; i < 4; i++) for (let j = 0; j < 4; j++) { const w = bu[i] * bv[j], p = b[i][j]; s[0] += w * p[0]; s[1] += w * p[1]; s[2] += w * p[2]; }
  return s;
}

Normal of a patch

64-bit

The unit normal of a patch at (u, v).

n = ∂S/∂u × ∂S/∂v, normalized

The two partial derivatives are sums with the derivative Bernstein weights, which have both signs. The cross product is normalized by the vector helper in vec.js.

  • form.js
  • lines 235 to 244
  • PartCraft 0.8.1
/** Unit normal (∂u × ∂v) of a patch. */
export function patchNormal(b, u, v) {
  const bu = bern(u), bv = bern(v), du = bernD(u), dv = bernD(v);
  const su = [0, 0, 0], sv = [0, 0, 0];
  for (let i = 0; i < 4; i++) for (let j = 0; j < 4; j++) {
    const p = b[i][j];
    for (let k = 0; k < 3; k++) { su[k] += du[i] * bv[j] * p[k]; sv[k] += bu[i] * dv[j] * p[k]; }
  }
  return V.norm(V.cross(su, sv));
}

Refinement level for conversion

Exact

How many times the cage is refined before it is turned into patches.

level = max(1 if any face is not a quad, ⌈largest semi-sharp weight − 1e-12⌉), at most 4

A whole number chosen from the cage's structure. The 1e-12 allowance stops a crease weight that is a whole number within rounding from costing one more level.

  • form.js
  • lines 306 to 317
  • PartCraft 0.8.1
/** The refinement level a conversion needs: all quads and no semi-sharp creases left; `level` may ask for more (≤ 3). */
export function conversionLevel(form, level = null) {
  const wmax = Math.max(0, ...(form.creases || []).map((c) => (c[2] >= SHARP ? 0 : c[2])));
  const nonQuad = form.faces.some((f) => f.length !== 4);
  let need = Math.max(nonQuad ? 1 : 0, Math.ceil(wmax - 1e-12));
  if (level === null) {
    const m = meshOf(form), t = topo(m);
    if (!need && m.F.some((f, fi) => f.some((v) => !regularCorner(m, t, fi, v)))) need = 1;   // isolate extraordinary points
    return Math.min(4, need);
  }
  return Math.min(4, Math.max(need, Math.round(level)));
}

How well the patches meet

64-bit

The largest gap and the largest angle between normals across shared patch edges.

gap = max |p₀ − p₁|; angle = acos(clamp(n₀ · n₁, −1, 1)) × 180 ÷ π

Both are maxima over sampled points (6 per edge by default), so they are measurements at those points, not bounds. The dot product is clamped before the arccosine; because the angle comes from an arccosine of a value near 1, angles under about 1e-6 degree are not resolved.

  • form.js
  • lines 349 to 377
  • PartCraft 0.8.1
/**
 * How well the patches meet: largest gap and normal angle (degrees) across shared edges — overall and
 * between exact patches only — sampled at `n` points per edge.
 */
export function patchReport(pr, n = 5) {
  const { mesh: m, topo: t, patches: ps } = pr;
  const SIDE = [(s) => [s, 0], (s) => [1, s], (s) => [1 - s, 1], (s) => [0, 1 - s]];
  let gap = 0, deg = 0, degExact = 0;
  for (const e of t.E.values()) {
    if (e.f.length !== 2) continue;
    const [f0, f1] = e.f;
    const k0 = m.F[f0].findIndex((v, i) => ek(v, m.F[f0][(i + 1) % 4]) === e.key);
    const k1 = m.F[f1].findIndex((v, i) => ek(v, m.F[f1][(i + 1) % 4]) === e.key);
    const sharp = e.w > 0;
    for (let i = 0; i <= n; i++) {
      const s = i / n;
      const [u0, v0] = SIDE[k0](s), [u1, v1] = SIDE[k1](1 - s);
      const p0 = evalPatch(ps[f0].b, u0, v0), p1 = evalPatch(ps[f1].b, u1, v1);
      gap = Math.max(gap, V.len(V.sub(p0, p1)));
      if (sharp) continue;
      const n0 = patchNormal(ps[f0].b, u0, v0), n1 = patchNormal(ps[f1].b, u1, v1);
      const a = Math.acos(Math.min(1, Math.max(-1, V.dot(n0, n1)))) * 180 / Math.PI;
      if (!Number.isFinite(a)) continue;
      deg = Math.max(deg, a);
      if (ps[f0].exact && ps[f1].exact) degExact = Math.max(degExact, a);
    }
  }
  return { gap, deg, degExact };
}

Fitting a cage to a target surface

64-bit

Moves cage vertices until their limit points lie on a sphere or torus.

p ← p + (proj(L) − L), L the vertex's limit point

Repeated until the largest move is under 1e-12 × max(1, largest absolute coordinate) or 400 rounds have run; the result is returned either way without a flag. Only the limit points land on the target; between them the surface is the subdivision surface.

  • form.js
  • lines 499 to 514
  • PartCraft 0.8.1
/**
 * Move cage vertices so their limit points land on a target surface (`proj` = closest point on it):
 * p ← p + (proj(L) − L), a few rounds. The limit points then lie on it; between them the surface is
 * the subdivision surface, not the analytic one.
 */
export function fitTo(form, proj, rounds = 400) {
  let f = clone(form);
  for (let r = 0; r < rounds; r++) {
    const m = subdivide(meshOf(f));
    const t = topo(m);
    let worst = 0;
    f.verts = f.verts.map((p, v) => { const L = limitPoint(m, t, v); const d = V.sub(proj(L), L); worst = Math.max(worst, V.len(d)); return V.add(p, d); });
    if (worst < 1e-12 * sizeOf(f)) break;
  }
  return f;
}

Torus cage

64-bit

The vertices of a torus-shaped starting cage.

((R + r cos b) cos a, r sin b, (R + r cos b) sin a), a = 2π i ÷ n, b = 2π j ÷ m

Angles are computed from the index each time, not accumulated. The segment counts are rounded to whole numbers between 3 and 64.

  • form.js
  • lines 473 to 484
  • PartCraft 0.8.1
/** A torus about Y: n around the axis, m around the tube; R major, r minor. Every vertex is regular. */
export function torus(n = 8, m = 6, R = 50, r = 20) {
  n = Math.max(3, Math.min(64, Math.round(n))); m = Math.max(3, Math.min(64, Math.round(m)));
  const verts = [], faces = [];
  for (let i = 0; i < n; i++) for (let j = 0; j < m; j++) {
    const a = 2 * Math.PI * i / n, b = 2 * Math.PI * j / m;
    verts.push([(R + r * Math.cos(b)) * Math.cos(a), r * Math.sin(b), (R + r * Math.cos(b)) * Math.sin(a)]);
  }
  const id = (i, j) => (i % n) * m + (j % m);
  for (let i = 0; i < n; i++) for (let j = 0; j < m; j++) faces.push([id(i, j), id(i, j + 1), id(i + 1, j + 1), id(i + 1, j)]);
  return fromQuads(verts, faces);
}

Mirror partners

64-bit

Finds each vertex's partner across the symmetry plane.

q = p mirrored; partner when |v − q| ≤ 1e-6 × size

The tolerance scales with the form: 1e-6 × max(1, largest absolute coordinate). Candidates are found through a grid of cells ten tolerances wide, and the first one within the tolerance is taken.

  • form.js
  • lines 616 to 631
  • PartCraft 0.8.1
/** Mirror partner of every vertex (itself on the plane, −1 when none). */
export function partners(form) {
  if (!form.sym) return form.verts.map(() => -1);
  const tol = 1e-6 * sizeOf(form);
  const cell = (p) => p.map((x) => Math.round(x / (tol * 10))).join(',');
  const grid = new Map();
  form.verts.forEach((p, i) => { const k = cell(p); if (!grid.has(k)) grid.set(k, []); grid.get(k).push(i); });
  return form.verts.map((p) => {
    const q = mirrorP(form.sym, p);
    const c = q.map((x) => Math.round(x / (tol * 10)));
    for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) for (let dz = -1; dz <= 1; dz++) {
      for (const j of grid.get([c[0] + dx, c[1] + dy, c[2] + dz].join(',')) || []) if (V.len(V.sub(form.verts[j], q)) <= tol) return j;
    }
    return -1;
  });
}

Rebuilding the mirrored half

64-bit

Keeps one side of the symmetry plane and mirrors it.

mirror: x' = 2 × at − x

A vertex of the kept faces that lies within the tolerance (1e-6 × size) of the plane, or past it, is set exactly onto the plane, so the seam coordinates are equal bit for bit on both halves. The mirror is one subtraction per coordinate.

  • form.js
  • lines 646 to 676
  • PartCraft 0.8.1
/** Rebuild the mirrored half from the kept half (stable order: kept faces first). */
export function resym(form) {
  if (!form.sym) return form;
  const { sym } = form;
  const a = AXES[sym.axis];
  const tol = 1e-6 * sizeOf(form);
  const sd = (p) => (p[a] - sym.at) * sym.side;
  const keepF = form.faces.filter((f) => sd(avg(f.map((v) => form.verts[v]))) > tol);
  if (!keepF.length) fail('form.err.sym_empty');
  const used = [...new Set(keepF.flat())].sort((x, y) => x - y);
  const map = new Map(used.map((v, i) => [v, i]));
  const verts = used.map((v) => { const p = [...form.verts[v]]; if (sd(p) < tol) p[a] = sym.at; return p; });
  const mirrorOf = new Map();
  verts.forEach((p, i) => {
    if (Math.abs(p[a] - sym.at) <= tol) { mirrorOf.set(i, i); return; }
    mirrorOf.set(i, verts.length); verts.push(mirrorP(sym, p));
  });
  const faces = keepF.map((f) => f.map((v) => map.get(v)));
  for (const f of keepF.map((g) => g.map((v) => map.get(v)))) faces.push(f.map((v) => mirrorOf.get(v)).reverse());
  const creases = [];
  for (const [x, y, w] of form.creases || []) {
    if (!map.has(x) || !map.has(y)) continue;
    const X = map.get(x), Y = map.get(y);
    creases.push([X, Y, w]);
    const MX = mirrorOf.get(X), MY = mirrorOf.get(Y);
    if (MX !== X || MY !== Y) creases.push([MX, MY, w]);
  }
  const corners = [];
  for (const v of form.corners || []) if (map.has(v)) { const X = map.get(v); corners.push(X); if (mirrorOf.get(X) !== X) corners.push(mirrorOf.get(X)); }
  return checked({ ...emptyForm(), verts, faces, creases: dedupeCreases(creases), corners: [...new Set(corners)], sym: { ...sym } });
}

Moving, rotating and scaling vertices

64-bit

Applies a 4 × 4 transform to the selected vertices, with an optional soft falloff and mirrored motion.

p' = p + w (M p − p); soft weight w = (1 − (d ÷ R)²)²

The transformed point is blended with the original by the weight in 64-bit. With symmetry the far side uses R M R, and a vertex on the seam has its coordinate set exactly to the plane after the move.

  • form.js
  • lines 701 to 738
  • PartCraft 0.8.1
/**
 * Transform the selection by a 4×4 column-major matrix. `soft` > 0 pulls the vertices within that
 * distance along with a smooth falloff. With symmetry on, the mirror side follows (mirrored
 * transform) and seam vertices stay on the plane.
 */
export function transform(form, sel, M4, { soft = 0 } = {}) {
  const f = clone(form);
  const ids = selVerts(form, sel);
  if (!ids.length) return f;
  const w = new Map(ids.map((v) => [v, 1]));
  const pt = form.sym ? partners(form) : null;
  const src = new Set(ids);
  if (pt) for (const v of ids) if (pt[v] >= 0) src.add(pt[v]);
  if (soft > 0) {
    const S = [...src].map((v) => form.verts[v]);
    form.verts.forEach((p, v) => { if (w.has(v)) return; let d = Infinity; for (const q of S) d = Math.min(d, V.len(V.sub(p, q))); const x = falloff(d, soft); if (x > 0) w.set(v, x); });
  }
  let MR = null, sideSel = 1, a = 0;
  if (form.sym) {
    a = AXES[form.sym.axis];
    const R = V.reflection(V.mul([0, 0, 0].map((_, i) => (i === a ? 1 : 0)), form.sym.at), [0, 0, 0].map((_, i) => (i === a ? 1 : 0)));
    MR = V.matMul(R, V.matMul(M4, R));
    const s = ids.reduce((acc, v) => acc + (form.verts[v][a] - form.sym.at), 0);
    sideSel = s < 0 ? -1 : 1;
    for (const v of ids) if (pt[v] >= 0 && pt[v] !== v && !w.has(pt[v])) w.set(pt[v], 1);
  }
  const tol = 1e-6 * sizeOf(form);
  for (const [v, wt] of w) {
    const p = form.verts[v];
    let M = M4;
    if (MR) { const d = p[a] - form.sym.at; if (Math.abs(d) > tol && Math.sign(d) !== sideSel) M = MR; }
    const q = V.applyMat(M, p);
    let r = lerp(p, q, wt);
    if (pt && pt[v] === v) r[a] = form.sym.at;
    f.verts[v] = r;
  }
  return f;
}

Extruding faces

64-bit

Moves a region of faces along its averaged normals and adds side walls.

new vertex = v + dist × (Σ face normals at v) ÷ |Σ face normals at v|

Face normals come from Newell's method and are added per vertex unnormalized, so larger faces weigh more. With symmetry, a vertex within 1e-6 × size of the plane keeps its coordinate on the plane.

  • form.js
  • lines 788 to 823
  • PartCraft 0.8.1
/** Extrude faces as one region: the region moves `dist` along its averaged normals and side walls join it. */
export function extrudeFaces(form, faceIds, dist = 0) {
  return symEdit(form, { faces: faceIds }, (f, sel) => {
    const set = new Set(sel.faces.filter((i) => f.faces[i]));
    if (!set.size) fail('form.err.pick_extrude');
    const nrm = new Map();
    for (const fi of set) {
      const g = f.faces[fi];
      const n = newell(g.map((v) => f.verts[v]));
      for (const v of g) nrm.set(v, V.add(nrm.get(v) || [0, 0, 0], n));
    }
    const dup = new Map();
    for (const [v, n] of nrm) {
      let d = V.mul(V.norm(n), dist);
      if (f.sym) { const a = AXES[f.sym.axis]; if (Math.abs(f.verts[v][a] - f.sym.at) <= 1e-6 * sizeOf(f)) d[a] = 0; }
      dup.set(v, f.verts.length); f.verts.push(V.add(f.verts[v], d));
    }
    const dirUse = new Map();
    for (const fi of set) { const g = f.faces[fi]; g.forEach((v, k) => dirUse.set(ek(v, g[(k + 1) % g.length]), (dirUse.get(ek(v, g[(k + 1) % g.length])) || 0) + 1)); }
    const sides = [];
    for (const fi of set) {
      const g = f.faces[fi];
      g.forEach((v, k) => {
        const w = g[(k + 1) % g.length];
        if (dirUse.get(ek(v, w)) === 1) sides.push([v, w, dup.get(w), dup.get(v)]);
      });
      f.faces[fi] = g.map((v) => dup.get(v));
    }
    // with symmetry, a wall standing on the seam is inside the body: leave it out
    const seam = (v) => f.sym && Math.abs(f.verts[v][AXES[f.sym.axis]] - f.sym.at) <= 1e-6 * sizeOf(f);
    for (const s of sides) if (!(seam(s[0]) && seam(s[1]))) f.faces.push(s);
    // creases on the region's edges move up with it
    f.creases = f.creases.map(([a, b, w]) => (dirUse.has(ek(a, b)) ? [dup.get(a), dup.get(b), w] : [a, b, w]));
    return compact(f);
  });
}

Face normal

64-bit

The area-weighted normal of a cage face with any number of corners.

n_x = Σ (p_y − q_y)(p_z + q_z), and cyclic

Newell's method. The sums p + q use coordinates measured from the model origin, so for a face far from the origin the terms are large and nearly cancel.

  • form.js
  • lines 824 to 828
  • PartCraft 0.8.1
function newell(pts) {
  const n = [0, 0, 0];
  for (let i = 0; i < pts.length; i++) { const p = pts[i], q = pts[(i + 1) % pts.length]; n[0] += (p[1] - q[1]) * (p[2] + q[2]); n[1] += (p[2] - q[2]) * (p[0] + q[0]); n[2] += (p[0] - q[0]) * (p[1] + q[1]); }
  return n;
}

Flattening vertices onto a plane

64-bit

Projects the selected vertices onto a given plane or their best-fit plane.

p' = p − n ((p − o) · n)

One projection per vertex in 64-bit, with the plane point at the selection's mean when no plane is given. The projection subtracts the vertex's distance from the plane, so the result is on the plane to rounding of that product.

  • form.js
  • lines 1069 to 1087
  • PartCraft 0.8.1
/** Flatten vertices onto a plane (default: the least-squares plane through them). */
export function flatten(form, sel, pl = null) {
  const ids = selVerts(form, sel);
  if (ids.length < 3 && !pl) fail('form.err.pick_flatten');
  let o, n;
  if (pl) { o = pl.o; n = V.norm(pl.n); } else { o = avg(ids.map((v) => form.verts[v])); n = bestNormal(ids.map((v) => V.sub(form.verts[v], o))); }
  const f = clone(form);
  const pt = form.sym ? partners(form) : null;
  const todo = new Set(ids);
  if (pt) for (const v of ids) if (pt[v] >= 0) todo.add(pt[v]);
  for (const v of todo) {
    const isMirror = pt && !ids.includes(v);
    const oo = isMirror ? mirrorP(form.sym, o) : o;
    const nn = isMirror ? mirrorP({ ...form.sym, at: 0 }, n) : n;
    const p = form.verts[v];
    f.verts[v] = V.sub(p, V.mul(nn, V.dot(V.sub(p, oo), nn)));
  }
  return f;
}

Best-fit plane normal

64-bit

The direction in which a set of centered points varies least.

A = Σ p pᵀ; normal = eigenvector of the smallest eigenvalue

Cyclic Jacobi rotations on the 3 × 3 matrix, stopped when the sum of squared off-diagonal entries is under 1e-30 or after 50 sweeps. The threshold is absolute, so it is not tied to the size of the points; forming A squares the coordinates.

  • form.js
  • lines 1088 to 1110
  • PartCraft 0.8.1
/** Smallest-variance direction of centred points (Jacobi eigen-solve of the 3×3 covariance). */
export function bestNormal(pts) {
  const A = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
  for (const p of pts) for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) A[i][j] += p[i] * p[j];
  const Vm = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
  for (let sweep = 0; sweep < 50; sweep++) {
    let off = 0;
    for (let p = 0; p < 3; p++) for (let q = p + 1; q < 3; q++) off += A[p][q] ** 2;
    if (off < 1e-30) break;
    for (let p = 0; p < 3; p++) for (let q = p + 1; q < 3; q++) {
      if (Math.abs(A[p][q]) < 1e-300) continue;
      const th = (A[q][q] - A[p][p]) / (2 * A[p][q]);
      const tt = Math.sign(th || 1) / (Math.abs(th) + Math.sqrt(th * th + 1));
      const c = 1 / Math.sqrt(tt * tt + 1), s = tt * c;
      for (let k = 0; k < 3; k++) { const akp = A[k][p], akq = A[k][q]; A[k][p] = c * akp - s * akq; A[k][q] = s * akp + c * akq; }
      for (let k = 0; k < 3; k++) { const apk = A[p][k], aqk = A[q][k]; A[p][k] = c * apk - s * aqk; A[q][k] = s * apk + c * aqk; }
      for (let k = 0; k < 3; k++) { const vkp = Vm[k][p], vkq = Vm[k][q]; Vm[k][p] = c * vkp - s * vkq; Vm[k][q] = s * vkp + c * vkq; }
    }
  }
  let mi = 0;
  for (let i = 1; i < 3; i++) if (A[i][i] < A[mi][mi]) mi = i;
  return V.norm([Vm[0][mi], Vm[1][mi], Vm[2][mi]]);
}

Adding a face through points

64-bit

Adds a face, reusing existing vertices that are close to the given points.

reuse a vertex when |q − p| ≤ 1e-6

The reuse tolerance is 1e-6 in model units by default and does not scale with the size of the form. The first vertex in index order within the tolerance is used, not the nearest.

  • form.js
  • lines 516 to 534
  • PartCraft 0.8.1
/** Add one face through points (3+), reusing existing vertices within `tol` (builds a surface click by click). */
export function addFace(form, pts, { tol = 1e-6 } = {}) {
  if (!Array.isArray(pts) || pts.length < 3 || pts.length > 64) fail('form.err.face_points');
  const f = clone(form);
  const ids = pts.map((p) => {
    if (typeof p === 'number') { if (!f.verts[p]) fail('form.err.face_point_gone'); return p; }
    const i = f.verts.findIndex((q) => V.len(V.sub(q, p)) <= tol);
    if (i >= 0) return i;
    f.verts.push([...p]);
    return f.verts.length - 1;
  });
  if (new Set(ids).size !== ids.length) fail('form.err.face_dup');
  f.faces.push(ids);
  // match the neighbours' orientation: reverse when it shares an edge in the same direction
  const used = new Set();
  form.faces.forEach((g) => g.forEach((v, k) => used.add(v + '>' + g[(k + 1) % g.length])));
  if (ids.some((v, k) => used.has(v + '>' + ids[(k + 1) % ids.length]))) f.faces[f.faces.length - 1] = ids.reverse();
  return checked(f);
}

Bridging two loops

64-bit

Joins two loops of equal size with rows of quads.

offset = argmin Σ |aᵢ − b₍offset − i₎|²; row vertex = a + (b − a) × r ÷ segments

The pairing that gives the smallest sum of squared distances is used; the first one wins on a tie. Intermediate rows are linear blends at r ÷ segments.

  • form.js
  • lines 1003 to 1029
  • PartCraft 0.8.1
/** Bridge two faces or two open loops (A, B: { face } | { edge }) of equal size with `segments` rows of quads. */
export function bridge(form, A, B, { segments = 1 } = {}) {
  const sel = { faces: [A.face, B.face].filter((x) => x !== undefined), edges: [A.edge, B.edge].filter((x) => x !== undefined) };
  return symEdit(form, sel, (f, s) => {
    let fi = 0, ei = 0;
    const take = (X) => (X.face !== undefined ? { face: s.faces[fi++] } : { edge: s.edges[ei++] });
    const a = take(A), b = take(B);
    const loopOf = (X) => (X.face !== undefined ? (f.faces[X.face] || fail('form.err.bridge_gone')) : boundaryLoop(f, X.edge));
    const la = [...loopOf(a)], lb = [...loopOf(b)];
    if (la.length !== lb.length) fail('form.err.bridge_count', { a: la.length, b: lb.length });
    if (la.some((v) => lb.includes(v))) fail('form.err.bridge_share');
    const n = la.length;
    let best = 0, bd = Infinity;
    for (let o = 0; o < n; o++) { let d = 0; for (let i = 0; i < n; i++) d += V.len(V.sub(f.verts[la[i]], f.verts[lb[((o - i) % n + n) % n]])) ** 2; if (d < bd) { bd = d; best = o; } }
    const gone = new Set([a.face, b.face].filter((x) => x !== undefined));
    f.faces = f.faces.filter((_, i) => !gone.has(i));
    const segs = Math.max(1, Math.min(32, Math.round(segments)));
    const rows = [la];
    for (let r = 1; r < segs; r++) rows.push(la.map((v, i) => { f.verts.push(lerp(f.verts[v], f.verts[lb[((best - i) % n + n) % n]], r / segs)); return f.verts.length - 1; }));
    rows.push(la.map((_, i) => lb[((best - i) % n + n) % n]));
    for (let r = 0; r < segs; r++) for (let i = 0; i < n; i++) {
      const i1 = (i + 1) % n;
      f.faces.push([rows[r][i], rows[r][i1], rows[r + 1][i1], rows[r + 1][i]]);
    }
    return compact(f);
  });
}

In detail

Method
Catmull-Clark subdivision with boundary, crease and corner rules and semi-sharp creases (the file header cites DeRose); limit position masks; Halstead's tangent masks; uniform B-spline to Bézier conversion; Newell's method for face normals; a 3 × 3 Jacobi eigen-solve for the best-fit plane.
Number types
64-bit numbers in plain arrays.
Tolerances
Symmetry and seam tests: 1e-6 × max(1, largest absolute coordinate). Cage fitting stops at a move under 1e-12 × that size, or after 400 rounds. Reusing a vertex when adding a face: 1e-6 in model units by default. Jacobi: sum of squared off-diagonals under 1e-30 (absolute) or 50 sweeps. Tangent normal: cross product length over 1e-300.
Limits
Tangent continuity across edges that meet an irregular corner is approximate (file header). The continuity report is a maximum over sampled points, 6 per edge by default.

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