Resources Numerical accuracy Calculation policy

Sculpt brushes

Moves the vertices under a round brush: draw, clay, smooth, grab, inflate, crease, pinch and flatten, with a mask that protects vertices.

How the precision is kept

Positions, normals and the falloff are 64-bit. The mask is a 32-bit vertex layer, and where a mask exists each vertex's move is multiplied by 1 − mask read from it, so the stored position carries a 32-bit factor. The brush size comes from pixels, so the amounts are freehand, not typed.

Open item. Keep the mask as a 64-bit layer, or state that masked moves are scaled by a 32-bit weight.

Calculations under this policy

10 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/sculpt.js ModelCraft 0.8.1 32-bit in part 8 calculations Reviewed

Tests tools/modelcraft-test/mesh.mjs

Brush falloff

64-bit

How strongly a vertex follows the brush, from 1 at its middle to 0 at its edge.

w(x) = 1 − 3x² + 2x³ for 0 < x < 1; 1 for x ≤ 0; 0 for x ≥ 1

One 64-bit polynomial. Near x = 1 the three terms cancel to a value near zero, where the weight itself is small.

  • mesh/sculpt.js
  • line 21
  • ModelCraft 0.8.1
export const falloff = (x) => (x >= 1 ? 0 : x <= 0 ? 1 : 1 - 3 * x * x + 2 * x * x * x);

Vertex normals for sculpting

64-bit

A unit normal per vertex from the triangles round it, weighted by triangle area.

N(v) = unit(Σ (b − a) × (c − a))

Unnormalized triangle cross products are added into a Float64Array and each sum is divided by its Math.hypot length; a zero sum is divided by 1 and stays zero.

  • mesh/sculpt.js
  • lines 53 to 63
  • ModelCraft 0.8.1
normals() {
  const m = this.m, N = new Float64Array(m.nv * 3), T = this.tri, P = m.P;
  for (let i = 0; i < T.n; i++) {
    const a = m.hV[T.tri[i * 3]] * 3, b = m.hV[T.tri[i * 3 + 1]] * 3, c = m.hV[T.tri[i * 3 + 2]] * 3;
    const ux = P[b] - P[a], uy = P[b + 1] - P[a + 1], uz = P[b + 2] - P[a + 2], vx = P[c] - P[a], vy = P[c + 1] - P[a + 1], vz = P[c + 2] - P[a + 2];
    const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
    for (const o of [a, b, c]) { N[o] += nx; N[o + 1] += ny; N[o + 2] += nz; }
  }
  for (let v = 0; v < m.nv; v++) { const l = Math.hypot(N[v * 3], N[v * 3 + 1], N[v * 3 + 2]) || 1; N[v * 3] /= l; N[v * 3 + 1] /= l; N[v * 3 + 2] /= l; }
  this.N = N;
}

Vertices under the brush

64-bit

The vertices within the brush radius of its center, each with its falloff weight.

d² = |P(v) − p|² ≤ r²; w = falloff(√d² ÷ r)

The inclusion test compares squared distances, a sum of three squares, so nothing cancels. The weight is 64-bit.

  • mesh/sculpt.js
  • lines 87 to 102
  • ModelCraft 0.8.1
/** Vertices within r of p → [{v, w (falloff)}]. */
near(p, r) {
  const m = this.m, T = this.tri, P = m.P, out = [], r2 = r * r, s = this.slack;
  this.tick = (this.tick + 1) >>> 0 || 1;
  const st = this.stamp, tk = this.tick;
  this.tree.box([p[0] - r - s, p[1] - r - s, p[2] - r - s], [p[0] + r + s, p[1] + r + s, p[2] + r + s], (i) => {
    for (let q = 0; q < 3; q++) {
      const v = m.hV[T.tri[i * 3 + q]];
      if (st[v] === tk) continue;
      st[v] = tk;
      const dx = P[v * 3] - p[0], dy = P[v * 3 + 1] - p[1], dz = P[v * 3 + 2] - p[2], d2 = dx * dx + dy * dy + dz * dz;
      if (d2 <= r2) out.push({ v, w: falloff(Math.sqrt(d2) / r) });
    }
  });
  return out;
}

Brush plane

64-bit

The normal and center of the surface under the brush, and the size of one step.

n = unit(Σ wᵢ·Nᵢ); c = Σ wᵢ·Pᵢ ÷ Σ wᵢ; step = 0.1·r·k, k = strength × pressure

Weighted sums in vertex order, in 64-bit. A zero normal sum is divided by 1, a zero weight sum likewise.

  • mesh/sculpt.js
  • lines 139 to 144
  • ModelCraft 0.8.1
// the area under the brush: its normal and centre
let an = [0, 0, 0], ac = [0, 0, 0], ws = 0;
for (const { v, w } of list) { for (let q = 0; q < 3; q++) { an[q] += this.N[v * 3 + q] * w; ac[q] += P[v * 3 + q] * w; } ws += w; }
const al = Math.hypot(...an) || 1;
an = an.map((x) => x / al); ac = ac.map((x) => x / (ws || 1));
const step = r * 0.1 * k;   // how far one full-strength dab moves the middle of the brush

Brush moves

32-bit in part

The new position of each vertex under the brush for draw, inflate, flatten, clay, smooth, pinch and crease.

f = w·(1 − mask); draw: p + n·(±step·f); inflate: p + N(v)·(±step·f); flatten: p − n·dd·min(1, f·max(0.1, k)), dd = (p − plane)·n; smooth: p + (mean of neighbors − p)·min(1, f·max(0.1, k))

When the mesh has a mask layer the factor 1 − mask is read from a Float32Array, so the move, and with it the stored position, carries a 32-bit factor; with no mask layer the whole expression is 64-bit. All moves of a dab are computed from the old positions before any is written.

  • mesh/sculpt.js
  • lines 147 to 170
  • ModelCraft 0.8.1
const f = w * (mask ? 1 - mask[v] : 1);
if (f <= 0) continue;
const p = [P[v * 3], P[v * 3 + 1], P[v * 3 + 2]], nv = [this.N[v * 3], this.N[v * 3 + 1], this.N[v * 3 + 2]];
let q = p;
if (brush === 'draw') q = addS(p, an, sign * step * f);
else if (brush === 'inflate') q = addS(p, nv, sign * step * f);
else if (brush === 'flatten' || brush === 'clay') {
  // toward the area's plane (clay: a plane raised by a step, and only up to it)
  const plane = brush === 'clay' ? addS(ac, an, sign * step) : ac, dd = dot(sub(p, plane), an);
  if (brush === 'clay' && sign * dd >= 0) continue;
  q = addS(p, an, -dd * Math.min(1, f * Math.max(0.1, k)));
} else if (brush === 'smooth') {
  const nb = this.nb[v];
  if (!nb.length) continue;
  const avg = [0, 0, 0];
  for (const u of nb) for (let i = 0; i < 3; i++) avg[i] += P[u * 3 + i];
  q = lerp(p, avg.map((x) => x / nb.length), Math.min(1, f * Math.max(0.1, k)));
} else if (brush === 'pinch' || brush === 'crease') {
  // toward the brush's middle, along the surface; crease also cuts in (out, inverted)
  const to = sub(c.p, p), tan = sub(to, mul(an, dot(to, an)));
  q = addS(p, tan, f * k * 0.3 * (brush === 'pinch' ? sign : 1));
  if (brush === 'crease') q = addS(q, an, -sign * step * f * 0.6);
}
moves.push([v, q]);

Grab

32-bit in part

Moves the vertices that were under the brush at the start of the stroke by the pointer's move.

p = p₀ + δ·w·(1 − mask)

Each position is computed from the position saved at the start of the stroke and the total move, so frames do not add up. The mask factor is read from the 32-bit layer when one exists. On the mirrored side the x part of the move is negated.

  • mesh/sculpt.js
  • lines 115 to 123
  • ModelCraft 0.8.1
if (brush === 'grab') {
  if (!this.grab) return 0;
  for (const set of this.grab.sets) {
    const d = set.mir ? [-at.delta[0], at.delta[1], at.delta[2]] : at.delta;
    for (const { v, w, p0 } of set.list) {
      const ww = w * (mask ? 1 - mask[v] : 1);
      m.setPos(v, p0[0] + d[0] * ww, p0[1] + d[1] * ww, p0[2] + d[2] * ww); n++;
    }
  }

Painting the mask

32-bit in part

Raises or lowers the mask of the vertices under the brush, between 0 and 1.

mask = clamp(mask ± w·k, 0, 1)

The old value is read from the 32-bit layer, the sum is formed in 64-bit and the result is stored back in the 32-bit layer, so each dab rounds the mask to 32-bit again.

  • mesh/sculpt.js
  • lines 133 to 138
  • ModelCraft 0.8.1
if (brush === 'mask') {
  if (!m.hasAttr('mask')) m.addAttr('mask', 'vertex', 1);
  const M = m.attrArray('mask');
  for (const { v, w } of list) { const x = Math.max(0, Math.min(1, M[v] + sign * w * k)); if (x !== M[v]) { m.setAttr('mask', v, [x]); n++; } }
  continue;
}

Split an edge at its middle

64-bit

The new vertex when dynamic detail splits a long triangle edge.

mid = (P_a + P_b) ÷ 2

One addition and an exact halving per coordinate. An edge is split when its squared length exceeds the squared detail length, longest first, at most 400 per dab.

  • mesh/sculpt.js
  • lines 218 to 237
  • ModelCraft 0.8.1
/** Split triangle edge h (and its twin's triangle) at the middle: two triangles become four, corner layers kept. */
export function splitEdge(m, h) {
  const t = m.hTw[h], a = m.hV[h], b = m.head(h), P = m.P;
  const mid = m.addVertex((P[a * 3] + P[b * 3]) / 2, (P[a * 3 + 1] + P[b * 3 + 1]) / 2, (P[a * 3 + 2] + P[b * 3 + 2]) / 2);
  const remove = [m.hF[h]], add = [];
  // triangle (a, b, c): corners h (a), next (b), prev (c)
  const c1 = m.hPv[h], hb = m.hNx[h], c = m.hV[c1];
  add.push({ verts: [a, mid, c], from: m.hF[h], corners: [h, [[h, 0.5], [hb, 0.5]], c1] });
  add.push({ verts: [mid, b, c], from: m.hF[h], corners: [[[h, 0.5], [hb, 0.5]], hb, c1] });
  if (t >= 0) {
    // the twin's triangle (b, a, d): corners t (b), next (a), prev (d)
    remove.push(m.hF[t]);
    const ta = m.hNx[t], d1 = m.hPv[t], d = m.hV[d1];
    add.push({ verts: [b, mid, d], from: m.hF[t], corners: [t, [[t, 0.5], [ta, 0.5]], d1] });
    add.push({ verts: [mid, a, d], from: m.hF[t], corners: [[[t, 0.5], [ta, 0.5]], ta, d1] });
  }
  for (const x of add) x.mat = m.fMat[x.from];
  m.replaceFaces(remove, add);
  return mid;
}
mesh/sculpt-tool.js ModelCraft 0.8.1 64-bit 2 calculations Reviewed

Tests None found by the review

Brush radius in mesh units

64-bit

Turns the brush's radius in pixels into a length in the mesh's own space at the point under the pointer.

r = px × pixel size at w ÷ scale, scale = |first column of the placement matrix|

The scale is the length of the matrix's first column only, so under a placement scaled differently along its axes the radius is right along x and not along the others. A pen's pressure multiplies the pixels and is floored at 0.1.

  • mesh/sculpt-tool.js
  • lines 43 to 48
  • ModelCraft 0.8.1
/** The brush's radius in mesh units at world point w (from its pixels, a pen's pressure if it should). */
radius(w, ev) {
  const W = this.world, scale = Math.hypot(W[0], W[1], W[2]) || 1;
  const px = this.set.radius * (this.set.pressureSize && ev?.pen ? Math.max(0.1, ev.pressure ?? 1) : 1);
  return (px * this.h.cam.pixelSize(w)) / scale;
}

Dabs along a stroke

64-bit

Places dabs a quarter of the brush radius apart between the last dab and the pointer.

step = 0.25·r; k = min(32, floor(d ÷ step)); pᵢ = last + (hit − last)·i ÷ k

The dabs are evenly spaced fractions i ÷ k of the straight line between two surface hits in mesh space, not points on the surface between them. Nothing is placed until the pointer has moved at least one step.

  • mesh/sculpt-tool.js
  • lines 93 to 101
  • ModelCraft 0.8.1
const r = this.radius(hit.w, ev), step = r * 0.25;
// dabs a quarter of the brush apart along the way from the last one
const d = Math.hypot(hit.p[0] - this.last[0], hit.p[1] - this.last[1], hit.p[2] - this.last[2]);
if (d < step) return;
const k = Math.min(32, Math.floor(d / step));
for (let i = 1; i <= k; i++) {
  const f = i / k, p = [this.last[0] + (hit.p[0] - this.last[0]) * f, this.last[1] + (hit.p[1] - this.last[1]) * f, this.last[2] + (hit.p[2] - this.last[2]) * f];
  this.dabAt(p, r, ev);
}

In detail

Method
Falloff 1 − 3x² + 2x³ of distance ÷ radius; the brush's plane from the falloff-weighted mean of vertex normals and positions; a full-strength dab moves the middle of the brush by 0.1 × radius × strength.
Number types
Float64Array positions and normals; the mask layer is a Float32Array (the mesh's default layer type).
Tolerances
Flatten, clay and smooth blend by min(1, falloff × max(0.1, strength)); pinch and crease pull by 0.3 × falloff × strength; symmetry adds a mirrored dab only when the brush is more than 0.05 × radius from x = 0; dabs are spaced 0.25 × radius apart, at most 32 per pointer move.
Limits
The radius in mesh units divides by the length of the placement matrix's first column only, which is the placement's scale only when it is the same on all three axes.

Approach: 32-bit step, flagged

One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.

Every policy that takes this approach