Resources Numerical accuracy Calculation policy

Proportional editing weights and edge creases

Works out how far vertices near a selection follow a move, and stores the crease value of edges used by subdivision.

How the precision is kept

Distances and the falloff curve are computed in 64-bit, but the weights are returned in a Float32Array and the edit tool multiplies each following vertex's move by that 32-bit value before the position is stored. The crease layer is a 32-bit mesh layer, so a crease is kept and read back as a 32-bit number.

Open item. Return the proportional-editing weights in a Float64Array so the stored position of a following vertex is not scaled by a 32-bit factor.

Calculations under this policy

5 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/ops.js ModelCraft 0.8.1 32-bit in part 3 calculations Reviewed

Tests tools/modelcraft-test/mesh.mjs

Proportional editing weights

32-bit in part

For every vertex, how much of the selected vertices' move it follows: 1 for the selected ones, falling to 0 at the radius.

t = distance ÷ radius; smooth: x²(3 − 2x) with x = 1 − t; sphere: √(1 − t²); root: √x; linear: x; sharp: x²; constant: 1

The distance to the nearest selected vertex is kept in a Float64Array and the falloff is evaluated in 64-bit, but each weight is written to a Float32Array, which rounds it to about 7 digits. Selected vertices get exactly 1. The sphere curve clamps 1 − t² at 0 before the square root.

  • mesh/ops.js
  • lines 970 to 1004
  • ModelCraft 0.8.1
/**
 * Proportional editing: how much each vertex follows a move of the selected ones (1 for them, falling to 0 at `radius`):
 * by straight distance, or along edges when `connected`. → Float32Array per vertex slot (0 = stays).
 */
export function falloffWeights(m, sel, radius, { mode = 'smooth', connected = false } = {}) {
  const W = new Float32Array(m.nv), D = new Float64Array(m.nv).fill(Infinity);
  const f = (t) => { const x = 1 - t; switch (mode) { case 'sphere': return Math.sqrt(Math.max(0, 1 - t * t)); case 'root': return Math.sqrt(x); case 'linear': return x; case 'sharp': return x * x; case 'constant': return 1; default: return x * x * (3 - 2 * x); } };
  const S = [...new Set(sel)];
  for (const v of S) { D[v] = 0; W[v] = 1; }
  if (!(radius > 0)) return W;
  if (connected) {
    // shortest distance along edges (a plain heap of [distance, vertex])
    const heap = S.map((v) => [0, v]);
    const pop = () => { let bi = 0; for (let i = 1; i < heap.length; i++) if (heap[i][0] < heap[bi][0]) bi = i; return heap.splice(bi, 1)[0]; };
    while (heap.length) {
      const [d, v] = pop();
      if (d > D[v] || d > radius) continue;
      for (const h of m.outgoing(v)) {
        const w = m.head(h), nd = d + len(sub(P3(m, w), P3(m, v)));
        if (nd < D[w] && nd <= radius) { D[w] = nd; heap.push([nd, w]); }
      }
    }
  } else {
    // straight distance to the nearest selected vertex (a grid of the selected ones)
    const cell = radius, grid = new Map(), key = (x, y, z) => x + ',' + y + ',' + z;
    for (const v of S) { const c = P3(m, v).map((x) => Math.floor(x / cell)); const k = key(...c); if (!grid.has(k)) grid.set(k, []); grid.get(k).push(v); }
    for (let v = 0; v < m.nv; v++) {
      if (m.vertDead(v) || D[v] === 0) continue;
      const p = P3(m, v), c = p.map((x) => Math.floor(x / cell));
      for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) for (let dz = -1; dz <= 1; dz++) for (const s of grid.get(key(c[0] + dx, c[1] + dy, c[2] + dz)) || []) { const d = len(sub(P3(m, s), p)); if (d < D[v]) D[v] = d; }
    }
  }
  for (let v = 0; v < m.nv; v++) if (D[v] > 0 && D[v] <= radius) W[v] = f(D[v] / radius);
  return W;
}

Set a crease

32-bit in part

Stores the crease (0 smooth to 1 sharp) on each listed edge for subdivision.

v = clamp(value, 0, 1)

The value is clamped to 0…1 in 64-bit and written to the 'crease' layer, which is created with the mesh's default layer type, a Float32Array. The stored crease is therefore the nearest 32-bit number to the one given.

  • mesh/ops.js
  • lines 940 to 946
  • ModelCraft 0.8.1
/** Crease edges for subdivision (0.7 MD.4): the 'crease' edge layer (0 smooth … 1 sharp for four levels) on each edge. */
export function setCrease(m, edges, value) {
  const v = Math.max(0, Math.min(1, +value || 0));
  if (!m.hasAttr('crease')) { if (!v) return 0; m.addAttr('crease', 'edge', 1); }
  for (const h of edges) m.setAttr('crease', h, [v]);
  return edges.length;
}

Crease shared by edges

32-bit in part

The crease the selected edges have in common, shown as the starting value when a crease is edited.

Reads the 32-bit layer directly. Edges are treated as sharing a crease when each differs from the first by at most 1e-6, an absolute tolerance on a value between 0 and 1.

  • mesh/ops.js
  • lines 947 to 953
  • ModelCraft 0.8.1
/** The crease shared by edges, or null when they differ (or there are none). */
export function creaseOfEdges(m, edges) {
  const a = m.attrArray('crease');
  let v = null;
  for (const h of edges) { const x = a ? a[h] : 0; if (v === null) v = x; else if (Math.abs(x - v) > 1e-6) return null; }
  return v;
}
mesh/edit-tool.js ModelCraft 0.8.1 32-bit in part 2 calculations Reviewed

Tests tools/modelcraft-test/mesh.mjs

Applying a move with proportional weights

32-bit in part

Writes each affected vertex's new position while a grab, rotate or scale is in progress.

q = T(p); r = p + (q − p)·w; stored position = M⁻¹·r

With proportional editing on, w is read from the 32-bit weight array, so the stored position of a vertex that only follows the move carries a factor rounded to 32-bit. With it off, or for the selected vertices themselves, w is 1 and the position is q. Every position goes to world space with the placement matrix and back with its inverse.

  • mesh/edit-tool.js
  • lines 513 to 518
  • ModelCraft 0.8.1
const T = this.matrixFor(ev), W = this.weights, inv = this.inv;
this.h.model.meshPreview(this.tool.defId, (me) => {
  for (const [v, p] of this.base) {
    const q = T(p), w = W ? W[v] : 1, r = w === 1 ? q : add(p, mul(sub(q, p), w)), l = apply(inv, r);
    me.setPos(v, l[0], l[1], l[2]);
  }

Crease from the pointer or the wheel

32-bit in part

Turns the pointer's distance from the selection's center, or a wheel step, into a crease between 0 and 1.

pointer: crease = clamp(round(px ÷ 2) ÷ 100, 0, 1); wheel: crease = clamp(round((crease ± 0.1) × 10) ÷ 10, 0, 1)

The value kept is rounded to 0.01 from the pointer and to 0.1 from the wheel before it is stored; a typed value is not rounded. It then goes to the 32-bit crease layer through setCrease.

  • mesh/edit-tool.js
  • lines 413 to 416
  • ModelCraft 0.8.1
fromPointer: (op, px) => ({ crease: Math.max(0, Math.min(1, Math.round(px / 2) / 100)) }), lengthParam: 'crease',
format: (p) => p.crease.toFixed(2),
run: (me, p) => O.setCrease(me, p.edges, p.crease),
wheel: (p, dir) => ({ crease: Math.max(0, Math.min(1, Math.round((p.crease + dir * 0.1) * 10) / 10)) }),

In detail

Method
Weight = falloff(distance ÷ radius), with distance either straight to the nearest selected vertex (grid search) or along edges (shortest path); six falloff curves. Crease: one number 0…1 per edge.
Number types
Distances in a Float64Array; weights in a Float32Array; the crease layer is a Float32Array (the default layer type in mesh.js); positions in the mesh's Float64Array.
Tolerances
Two creases count as equal when they differ by at most 1e-6 (absolute). A crease set from the pointer is rounded to 0.01, from the wheel to 0.1.
Limits
A following vertex's move carries a factor with about 7 digits. A crease such as 0.3 reads back as the nearest 32-bit number, not as 0.3.

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