Resources Numerical accuracy Calculation policy

Mesh edit moves: grab, rotate, scale, extrude, spin, knife, bisect

Works out where the selected vertices of a mesh go when they are moved, turned, scaled, extruded or spun, and where a knife or bisect cut lies.

How the precision is kept

Vertex positions are 64-bit numbers in the mesh's own space. A move takes the positions to world space with the placement's matrix, applies the move there and brings them back with the inverted matrix. A typed distance, angle or factor is used as typed; a dragged one comes from pointer positions on screen.

Open item. Carry the bisect plane's normal into the mesh's space with the transpose of the placement matrix so the cut stays on the plane that was drawn under a non-uniform scale.

Calculations under this policy

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

mesh/edit-tool.js ModelCraft 0.8.1 64-bit 8 calculations Reviewed

Tests tools/modelcraft-test/mesh.mjs

Selection center

64-bit

The mean position of the selected vertices: the pivot for rotate, scale and spin.

c = (Σ pᵢ) × (1 ÷ n)

The coordinates are added in list order and the sum is multiplied by 1 ÷ n, so the reciprocal and the product are each rounded once. Positions are read from the mesh's Float64Array.

  • mesh/edit-tool.js
  • lines 187 to 193
  • ModelCraft 0.8.1
selCenter() {
  const me = this.mesh, vs = this.sel.verts();
  if (!vs.length) return null;
  let c = [0, 0, 0];
  for (const v of vs) c = add(c, [me.P[v * 3], me.P[v * 3 + 1], me.P[v * 3 + 2]]);
  return mul(c, 1 / vs.length);
}

Point on an axis nearest the pointer

64-bit

The point of a line through w along dir that is nearest the pointer's ray; used for axis-locked moves and extrude distances.

s = (b·e − c·d) ÷ (a·c − b²); point = w + d₁·s

The denominator a·c − b² subtracts two nearly equal numbers when the axis is nearly parallel to the pointer's ray. When its absolute value is below 1e-12 the parameter is set to 0 and the point returned is w.

  • mesh/edit-tool.js
  • lines 180 to 186
  • ModelCraft 0.8.1
/** The point of the line (w, dir) nearest the pointer's ray. */
onLine(ev, w, dir) {
  const r = this.h.cam.ray(ev.x, ev.y), d1 = norm(dir), d2 = r.d, w0 = sub(w, r.o);
  const a = dot(d1, d1), b = dot(d1, d2), c = dot(d2, d2), d = dot(d1, w0), e = dot(d2, w0), den = a * c - b * b;
  const s = Math.abs(den) < 1e-12 ? 0 : (b * e - c * d) / den;
  return add(w, mul(d1, s));
}

Grab, rotate and scale

64-bit

Builds the move applied to every affected vertex in world space.

grab: p′ = p + d; rotate: p′ = R(k, θ)(p − P) + P; scale: p′ = P + f·(p − P), or along a locked axis a: p′ = p + a·((p − P)·a)·(f − 1)

A typed distance multiplies a unit direction, a typed angle is multiplied by π ÷ 180 and a typed factor is used as it is. Dragged values come from screen coordinates: the angle from two atan2 calls about the projected pivot, the factor from the ratio of two pixel distances, with a start distance of zero replaced by 1.

  • mesh/edit-tool.js
  • lines 484 to 510
  • ModelCraft 0.8.1
matrixFor(ev) {
  const cam = this.h.cam, P = this.pivot, ax = this.axis();
  if (this.kind === 'grab') {
    let d;
    if (this.typed !== null) {
      const dir = ax || norm(sub(this.tool.onViewPlane(ev, P), this.tool.onViewPlane(this.ev0, P))) ;
      d = mul(len(dir) ? dir : [1, 0, 0], this.typed);
    } else if (ax) d = sub(this.tool.onLine(ev, P, ax), this.tool.onLine(this.ev0, P, ax));
    else d = sub(this.tool.onViewPlane(ev, P), this.tool.onViewPlane(this.ev0, P));
    this.shown = len(d);
    return (p) => add(p, d);
  }
  const s0 = cam.project(P);
  if (this.kind === 'rotate') {
    const a0 = Math.atan2(this.ev0.y - s0[1], this.ev0.x - s0[0]), a1 = Math.atan2(ev.y - s0[1], ev.x - s0[0]);
    const toward = norm(sub(cam.eye, P));
    let k = ax || toward;
    let ang = this.typed !== null ? this.typed * DEG : -(a1 - a0) * Math.sign(dot(k, toward) || 1);
    this.shown = ang / DEG;
    return (p) => rotateAbout(p, P, k, ang);
  }
  // scale
  const r0 = Math.hypot(this.ev0.x - s0[0], this.ev0.y - s0[1]) || 1, r1 = Math.hypot(ev.x - s0[0], ev.y - s0[1]);
  const f = this.typed !== null ? this.typed : r1 / r0;
  this.shown = f;
  return (p) => { const q = sub(p, P); if (ax) { const a = norm(ax), along = dot(q, a); return add(p, mul(a, along * (f - 1))); } return add(P, mul(q, f)); };
}

Rotation about an axis

64-bit

Turns a point about the axis k through c by the angle a.

q = p − c; p′ = c + q·cos a + (n × q)·sin a + n·(n·q)·(1 − cos a)

Rodrigues' formula in one 64-bit expression; the axis is normalized on every call. The term 1 − cos a is formed by subtracting the rounded cosine from 1; for a small angle the result is small while its absolute error stays that of the cosine.

  • mesh/edit-tool.js
  • lines 548 to 551
  • ModelCraft 0.8.1
const rotateAbout = (p, c, k, a) => {
  const q = sub(p, c), n = norm(k), co = Math.cos(a), si = Math.sin(a);
  return add(c, add(add(mul(q, co), mul(cross(n, q), si)), mul(n, dot(n, q) * (1 - co))));
};

Extrude distance

64-bit

Moves the new vertices of an extrusion by the dragged or typed distance along the region's normal, or along each face's own normal.

d = (line point now − line point at start)·dir; p′ = p + dirL·d, dirL = M⁻¹·dir

A typed distance is used as typed. The world direction is carried into the mesh's space with the inverted matrix and is not normalized there, so d is a distance in world units; for individual faces each unit face normal is multiplied by d × |dirL|.

  • mesh/edit-tool.js
  • lines 574 to 589
  • ModelCraft 0.8.1
const d = this.typed !== null ? this.typed : dot(sub(this.tool.onLine(ev, this.pivot, this.dir), this.tool.onLine(this.ev0, this.pivot, this.dir)), this.dir);
this.d = d;
const dirL = applyDir(this.inv, this.dir);
this.result = this.h.model.meshPreview(this.tool.defId, (me) => {
  if (this.faces.length) {
    const r = this.individual ? O.extrudeIndividual(me, this.faces) : O.extrudeRegion(me, this.faces);
    for (const v of r.verts) {
      const dl = this.individual ? mul(r.dirs.get(v), d * len(dirL)) : mul(dirL, d);
      me.setPos(v, me.P[v * 3] + dl[0], me.P[v * 3 + 1] + dl[1], me.P[v * 3 + 2] + dl[2]);
    }
    return r;
  }
  const r = O.extrudeEdges(me, this.edges);
  for (const v of r.verts) { const dl = mul(dirL, d); me.setPos(v, me.P[v * 3] + dl[0], me.P[v * 3 + 1] + dl[1], me.P[v * 3 + 2] + dl[2]); }
  const near = this.tool.snapNear();   // MD.8: onto the retopology target
  if (near) projectVerts(me, r.verts, near);

Knife point on an edge

64-bit

Where along an edge a knife click lands, as a fraction of the edge from its start.

s = clamp((q − a)·(b − a) ÷ L², 0, 1)

q is the point of the edge's line nearest the pointer's ray (onLine). The fraction is clamped to 0…1, and an edge of zero length uses L = 1 to avoid dividing by zero.

  • mesh/edit-tool.js
  • lines 783 to 793
  • ModelCraft 0.8.1
snapAt(ev) {
  const pk = this.tool.picker(), cam = this.h.cam, me = this.tool.mesh;
  const v = pk.at(cam, ev.x, ev.y, 'vert', { radius: 10 });
  if (v) return { v: v.el, w: v.p };
  const e = pk.at(cam, ev.x, ev.y, 'edge', { radius: 12 });
  if (!e) return null;
  // where along the edge: the point of it nearest the pointer's ray
  const m = this.tool.world, a = apply(m, [me.P[me.hV[e.el] * 3], me.P[me.hV[e.el] * 3 + 1], me.P[me.hV[e.el] * 3 + 2]]), b = apply(m, [me.P[me.head(e.el) * 3], me.P[me.head(e.el) * 3 + 1], me.P[me.head(e.el) * 3 + 2]]);
  const q = this.tool.onLine(ev, a, sub(b, a)), L = len(sub(b, a)) || 1, s = Math.max(0, Math.min(1, dot(sub(q, a), sub(b, a)) / (L * L)));
  return { h: e.el, s, w: add(a, mul(sub(b, a), s)) };
}

Bisect plane

64-bit

The cutting plane through two clicked points as seen from the camera, in the mesh's own space.

n = unit((p₂ − p₁) × ray direction); plane = {M⁻¹·p₁, unit(M⁻¹ applied to n as a direction)}

The two points are where the pointer's rays meet the view plane through the selection's center. The normal is transformed with applyDir and the inverted matrix, the rule for directions; for a plane normal that is only the same when the placement has no non-uniform scale or shear.

  • mesh/edit-tool.js
  • lines 832 to 837
  • ModelCraft 0.8.1
const r1 = cam.ray(this.a.x, this.a.y), r2 = cam.ray(ev.x, ev.y);
const P = tool.selCenter() ? apply(tool.world, tool.selCenter()) : cam.target;
const p1 = tool.onViewPlane(this.a, P), p2 = tool.onViewPlane(ev, P);
const n = norm(cross(sub(p2, p1), r1.d));
void r2;
const inv = invert(tool.world), plane = { p: apply(inv, p1), n: norm(applyDir(inv, n)) };

Spin center, axis and angle

64-bit

The center, axis and total angle handed to the spin operator, in the mesh's own space.

center = M⁻¹·c; axis = unit(M⁻¹ applied to the world axis); angle = typed × π ÷ 180, or −(a₁ − a₀)

An angle of exactly zero is replaced by 1e-6 rad before the operator runs. The value shown beside the pointer is rounded to 0.1°, but the angle used is not rounded.

  • mesh/edit-tool.js
  • lines 868 to 870
  • ModelCraft 0.8.1
const inv = invert(this.tool.world), axisW = this.tool.T.lock?.dir || [0, 0, 1];
const p = { faces: this.faces, edges: this.edges, center: apply(inv, this.center), axis: norm(applyDir(inv, axisW)), angle: this.ang || 1e-6, steps: this.steps };
this.params = p;

In detail

Method
Translation in the view plane or along a locked axis; rotation by Rodrigues' formula about the mean of the selected vertices; scaling about the same point; the closest point between a line and the pointer's ray for axis moves.
Number types
64-bit numbers; matrices are arrays of 16 numbers; DEG = π ÷ 180 computed from Math.PI.
Tolerances
1e-12 absolute on the denominator of the line-to-ray solve (onLine); a spin angle of exactly zero is replaced by 1e-6 rad; a knife point's place along its edge is clamped to 0…1.
Limits
A dragged amount is only as fine as the pointer position. The bisect plane's normal is carried into the mesh's space as a direction with the inverted matrix, which equals the plane's true normal there only when the placement has no non-uniform scale or shear.

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