Resources Numerical accuracy Calculation policy

Push/pull, move, stretch, follow me and intersect

The new positions of vertices when a face is pushed or pulled, geometry is moved or stretched, a face is swept along a path, or faces are cut where they cross.

How the precision is kept

Every new position is one 64-bit expression of the old position, a unit direction and a distance or matrix. Limits and refusals compare against EPS = 1e-6 m or 10 × EPS as absolute distances. After each edit the vertices are welded again at EPS.

Calculations under this policy

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

m3d/model.js CadCraft 0.8.2 64-bit 9 calculations Reviewed

Tests bin/test-cadcraft-3d.mjs

Push or pull a face

64-bit

Moves a face along its normal by a typed or dragged distance, or extrudes it into side faces.

p′ = p + n × distance

Each vertex moves by the unit Newell normal times the distance in one expression. A push that reaches within EPS of its limit (the shortest side when the face moves back, the face behind when pushing into a solid) or goes past it is set to exactly that limit. Distances of EPS or less are refused.

  • m3d/model.js
  • lines 966 to 1046
  • CadCraft 0.8.2
/**
 * Push or pull face `fid` by `distance` along its normal (positive = out of its front).
 * Returns {ok, mode: 'move'|'extrude', distance (after clamping), face (the moved/new face id)}.
 */
export function pushPull(def, fid, distance) {
  const f = def.faces.get(fid);
  if (!f || !(Math.abs(distance) > EPS)) return { ok: false };
  const n = faceNormal(def, f);
  if (!len(n)) return { ok: false };
  const ef = edgeFaces(def);
  const vids = new Set(f.loops.flat());
  const segs = loopSegs(f);
  const contains = (g) => Math.abs(dot(faceNormal(def, g), n)) < 1e-6;

  // Move mode: every boundary edge has a neighbour face that runs along n, and so does every
  // other face touching the face's corners.
  let move = segs.every(([a, b]) => {
    const others = (ef.get(key(a, b)) || []).filter((x) => x !== fid);
    return others.length > 0 && others.every((o) => contains(def.faces.get(o)));
  });
  if (move) {
    const vf = vertFaces(def);
    for (const v of vids) for (const g of vf.get(v) || []) if (g !== fid && !contains(def.faces.get(g))) move = false;
  }
  if (move) {
    // the face can come back no further than its shortest side
    let limit = Infinity;
    const ve = vertEdges(def);
    for (const v of vids) for (const eid of ve.get(v) || []) {
      const e = def.edges.get(eid), w = e.a === v ? e.b : e.a;
      if (vids.has(w)) continue;
      const along = dot(sub(def.verts.get(w), def.verts.get(v)), n);
      if (along < -EPS) limit = Math.min(limit, -along);
    }
    if (distance < 0 && -distance >= limit - EPS) distance = -limit;
    for (const v of vids) def.verts.set(v, add(def.verts.get(v), mul(n, distance)));
    const anchor = add(def.verts.get(f.loops[0][0]), [0, 0, 0]);
    cleanup(def);
    reface(def, planeFrom(n, anchor));
    cleanup(def);
    return { ok: true, mode: 'move', distance, face: def.faces.has(fid) ? fid : null };
  }

  const solid = backed(def, fid, ef);
  if (solid && distance < 0) {
    const t = depthBehind(def, fid);
    if (-distance > t - EPS) distance = -t;
  }
  const map = new Map();
  for (const v of vids) map.set(v, vAdd(def, add(def.verts.get(v), mul(n, distance))));
  const orig = f.loops.map((l) => l.slice());
  const top = orig.map((l) => l.map((v) => map.get(v)));
  const lone = !solid;
  const flip = lone && distance < 0;
  if (lone && distance > 0) f.loops = orig.map((l) => l.slice().reverse());
  else if (!lone) def.faces.delete(fid);
  const topId = fAdd(def, flip ? top.map((l) => l.slice().reverse()) : top, f.color);
  const sideEdges = [];
  for (const l of orig) {
    for (let i = 0; i < l.length; i++) {
      const a = l[i], b = l[(i + 1) % l.length], a2 = map.get(a), b2 = map.get(b);
      const quad = [a, b, b2, a2];
      fAdd(def, [flip ? quad.reverse() : quad], f.color);
      sideEdges.push(edgeBetween(def, a, a2), edgeBetween(def, a, b));
    }
  }
  cleanup(def);
  const topFace = def.faces.get(topId);
  if (topFace) reface(def, planeFrom(n, def.verts.get(topFace.loops[0][0])));
  cleanup(def);
  mergeCoplanar(def, sideEdges.filter((x) => x !== null));
  // the sides of a pulled circle read as one smooth surface: soften edges between side faces < 25° apart
  const ef2 = edgeFaces(def);
  for (const l of orig) for (const a of l) {
    const eid = edgeBetween(def, a, map.get(a));
    if (eid === null) continue;
    const e = def.edges.get(eid), fs = ef2.get(key(e.a, e.b)) || [];
    if (fs.length === 2 && dot(faceNormal(def, def.faces.get(fs[0])), faceNormal(def, def.faces.get(fs[1]))) > Math.cos(25 * Math.PI / 180)) e.soft = true;
  }
  return { ok: true, mode: 'extrude', distance, face: def.faces.has(topId) ? topId : null };
}

Depth to the face behind

64-bit

How far a face can be pushed in before it meets the first face behind it.

smallest ray parameter t > EPS along −n

One ray from a point inside the face along −n is tested against every triangle of the other faces (rayTri in vec.js); the smallest t above EPS wins. The ray starts from a single sample point, so the depth is the depth at that point.

  • m3d/model.js
  • lines 946 to 964
  • CadCraft 0.8.2
/** How far a face can be pushed in before it meets the first face behind it (Infinity if none). */
export function depthBehind(def, fid) {
  const f = def.faces.get(fid), n = faceNormal(def, f);
  const fr = frame(n);
  const pt2 = samplePoint(f.loops.map((l) => l.map((v) => to2(fr, def.verts.get(v)))));
  if (!pt2) return Infinity;
  const P0 = def.verts.get(f.loops[0][0]);
  const o = add(add(mul(fr.u, pt2[0]), mul(fr.v, pt2[1])), mul(n, dot(n, P0)));
  const d = mul(n, -1);
  let best = Infinity;
  for (const [gid, g] of def.faces) {
    if (gid === fid) continue;
    for (const [a, b, c] of faceTris(def, g)) {
      const t = rayTri(o, d, def.verts.get(a), def.verts.get(b), def.verts.get(c));
      if (t !== null && t > EPS && t < best) best = t;
    }
  }
  return best;
}

Moving vertices by a matrix

64-bit

The new place of each selected vertex under a move, rotate, scale or flip.

p′ = M · p

Each vertex is multiplied by the 4×4 matrix once (apply in vec.js). A face whose corners then leave its plane by more than 10 × EPS is replaced by the triangles it had before the move. The result is welded again at EPS.

  • m3d/model.js
  • lines 1092 to 1118
  • CadCraft 0.8.2
/** Move vertices by matrix m; faces bent out of plane fold into triangles. */
export function transformVerts(def, vs, m) {
  if (vs.size) {
    const vf = vertFaces(def);
    const touched = new Set();
    for (const v of vs) for (const f of vf.get(v) || []) touched.add(f);
    const before = new Map();
    for (const fid of touched) before.set(fid, faceTris(def, def.faces.get(fid)));
    for (const v of vs) def.verts.set(v, apply(m, def.verts.get(v)));
    // a mirror (Flip, a negative scale) turns faces inside out: those it moved whole are reversed so they face out again
    if (det3(m) < 0) for (const f of def.faces.values()) if (f.loops.every((l) => l.every((v) => vs.has(v)))) f.loops = f.loops.map((l) => l.slice().reverse());
    // a curve moved whole keeps its points (Move, Rotate, Scale, Flip); stretched in part, it is no longer editable
    for (const [cid, sp] of def.splines) {
      const own = [...def.edges.values()].filter((e) => e.curve === cid);
      const moved = own.filter((e) => vs.has(e.a) && vs.has(e.b)).length;
      if (own.length && moved === own.length) sp.pts = sp.pts.map((p) => apply(m, p));
      else if (moved || own.some((e) => vs.has(e.a) || vs.has(e.b))) def.splines.delete(cid);
    }
    for (const fid of touched) {
      const f = def.faces.get(fid);
      if (planar(def, f)) continue;
      def.faces.delete(fid);
      for (const tri of before.get(fid)) fAdd(def, [tri], f.color, undefined, f.hidden);
    }
    cleanup(def);
  }
}

Is a face still flat

64-bit

Whether every corner of a face lies in the face's plane after an edit.

|n · p − d| ≤ 10 × EPS for every corner

An absolute tolerance of 1e-5 m (10 micrometers), the same for a face of any size.

  • m3d/model.js
  • lines 1119 to 1123
  • CadCraft 0.8.2
function planar(def, f) {
  const pl = facePlane(def, f);
  if (!len(pl.n)) return false;
  return f.loops.every((l) => l.every((v) => Math.abs(dot(pl.n, def.verts.get(v)) - pl.d) <= EPS * 10));
}

Stretch to a new size

64-bit

Moves every vertex beyond a cut plane by a distance, as used by typed dimensions and sizes.

p′ = p + d × δ for every vertex with p · d ≥ cut − 10 × EPS

Vertices within 10 × EPS (1e-5 m) short of the cut also move. The edit is refused when a face would flip, collapse to a zero Newell normal, or leave its plane by more than 10 × EPS.

  • m3d/model.js
  • lines 1125 to 1165
  • CadCraft 0.8.2
/**
 * Stretch (resize after the fact): move every vertex of `def` at or beyond `cut` along unit direction `d` by
 * `delta` — only the vertices in `only`, when given — and every instance standing wholly beyond the cut on that
 * geometry. Faces between the moved and the still vertices stretch. Throws (changing nothing) when a face would
 * bend out of its plane or turn inside out. `boxOf(defId)` gives a definition's own box (to move instances).
 * Returns the number of vertices moved.
 */
export function stretch(def, d, cut, delta, { only = null, boxOf = () => null } = {}) {
  const tol = EPS * 10;
  const vs = new Set();
  for (const [id, p] of def.verts) if ((!only || only.has(id)) && dot(p, d) >= cut - tol) vs.add(id);
  if (!vs.size && !def.insts.size) throw new Error(t('m3d.model.there_is_nothing_on_that_side'));
  const at = new Map([...vs].map((v) => [v, add(def.verts.get(v), mul(d, delta))]));
  const pos = (v) => at.get(v) || def.verts.get(v);
  for (const f of def.faces.values()) {
    const all = f.loops.flat();
    const n = all.filter((v) => vs.has(v)).length;
    if (!n || n === all.length) continue;
    const n0 = faceNormal(def, f), n1 = newell(f.loops[0].map(pos));
    if (len(n1) <= EPS2 || dot(norm(n1), n0) <= 0) throw new Error(t('m3d.model.a_face_would_shrink_to_nothing'));
    const k = norm(n1), c = dot(k, pos(f.loops[0][0]));
    if (!all.every((v) => Math.abs(dot(k, pos(v)) - c) <= tol)) throw new Error(t('m3d.model.a_face_would_bend_out_of'));
  }
  let bb = null;
  for (const v of only || []) bb = bboxAdd(bb, def.verts.get(v));
  for (const [v, p] of at) def.verts.set(v, p);
  for (const inst of def.insts.values()) {
    const local = boxOf(inst.def);
    if (!local) continue;
    // the instance's corners in def's coordinates: it moves when it stands wholly beyond the cut, on this piece
    const corners = [];
    for (let i = 0; i < 8; i++) corners.push(apply(inst.m, [i & 1 ? local.max[0] : local.min[0], i & 2 ? local.max[1] : local.min[1], i & 4 ? local.max[2] : local.min[2]]));
    if (corners.some((p) => dot(p, d) < cut - tol)) continue;
    let ib = null;
    for (const p of corners) ib = bboxAdd(ib, p);
    if (bb && [0, 1, 2].some((i) => ib.min[i] > bb.max[i] + tol || ib.max[i] < bb.min[i] - tol)) continue;
    inst.m = mmul(translation(mul(d, delta)), inst.m);
  }
  cleanup(def);
  return vs.size;
}

Profile ring at a path corner

64-bit

Where each corner of a swept profile lands on the next miter plane of the path.

q′ = q + d × (n · (o − q)) ÷ (n · d)

Each ring is the previous ring projected along the path direction d onto the next plane: at a corner the miter plane with normal n = d + d_next, normalized; at the end of an open path the plane square to it. The step is refused when |n · d| < 1e-9 or when the path turns back with d · d_next < −0.999. Rings are computed from the previous ring, so rounding carries along the path.

  • m3d/model.js
  • lines 1672 to 1687
  • CadCraft 0.8.2
for (let k = 1; k <= nSeg; k++) {
  const d = dirs[k - 1];
  let n, o;
  if (closed && k === nSeg) { n = np; o = p0; }   // back to the profile's own plane: the ring closes on it
  else if (!closed && k === nSeg) { n = d; o = P[k]; }
  else {
    const dn = dirs[k % nSeg];
    if (dot(d, dn) < -0.999) throw new Error(t('m3d.model.the_path_turns_straight_back_on'));
    n = norm(add(d, dn)); o = P[k];
  }
  const nd = dot(n, d);
  if (Math.abs(nd) < 1e-9) throw new Error(t('m3d.model.the_path_turns_too_sharply_here'));
  ring = ring.map((L) => L.map((q) => add(q, mul(d, dot(n, sub(o, q)) / nd))));
  if (closed && k === nSeg) rings.push(rings[0]);
  else rings.push(ring.map((L) => L.map((q) => vAdd(def, q))));
}

Where a line crosses a face

64-bit

The stretches of a line that lie inside a face, holes left out.

c = P + (Q − P) × s_P ÷ (s_P − s_Q); t = (c − x₀) · u

Each loop edge is tested by the sign of the two ends' signed distances with no tolerance; the crossing divides by their difference, which is not zero when the signs differ. Stretches of EPS or shorter are dropped.

  • m3d/model.js
  • lines 870 to 883
  • CadCraft 0.8.2
const spans = (F, x0, u) => {
  const w = cross(F.n, u), ts = [];
  for (const loop of F.loops) for (let i = 0; i < loop.length; i++) {
    const P = loop[i], Q = loop[(i + 1) % loop.length];
    const sp = dot(sub(P, x0), w), sq = dot(sub(Q, x0), w);
    if ((sp > 0) === (sq > 0)) continue;
    const c = add(P, mul(sub(Q, P), sp / (sp - sq)));
    ts.push(dot(sub(c, x0), u));
  }
  ts.sort((a, b) => a - b);
  const out = [];
  for (let i = 0; i + 1 < ts.length; i += 2) if (ts[i + 1] - ts[i] > EPS) out.push([ts[i], ts[i + 1]]);
  return out;
};

Line where two faces cross

64-bit

The line shared by the planes of two faces, clipped to both.

dir = n_a × n_b; x₀ = ((n_b·d_a − n_a·d_b) × dir) ÷ |dir|²

Faces whose unit normals have a cross product shorter than 1e-9 are treated as parallel and skipped. x₀ is the point of the line nearest the origin, so segment ends are x₀ + u × t with t as large as the distance from the origin. Overlaps of EPS or shorter are dropped.

  • m3d/model.js
  • lines 892 to 901
  • CadCraft 0.8.2
  const dir = cross(a.n, b.n), L = len(dir);
  if (L < 1e-9) continue;   // parallel or coplanar
  const u = mul(dir, 1 / L);
  const x0 = mul(cross(sub(mul(b.n, a.d), mul(a.n, b.d)), dir), 1 / (L * L));
  const sa = spans(a, x0, u), sb = spans(b, x0, u);
  for (const [a0, a1] of sa) for (const [b0, b1] of sb) {
    const t0 = Math.max(a0, b0), t1 = Math.min(a1, b1);
    if (t1 - t0 > EPS) segs.push([add(x0, mul(u, t0)), add(x0, mul(u, t1))]);
  }
}

Mirror matrix

64-bit

The matrix that flips geometry through a plane.

M = T(c) · (I − 2·n·nᵀ) · T(−c)

The normal is normalized first; the reflection is built from its three components and moved to the point c by two translations.

  • m3d/model.js
  • lines 1596 to 1601
  • CadCraft 0.8.2
/** The mirror through the plane at point c with unit normal n (an affine 4×4, column-major). */
export function mirrorMatrix(c, n) {
  const [x, y, z] = norm(n);
  const L = [1 - 2 * x * x, -2 * x * y, -2 * x * z, 0, -2 * x * y, 1 - 2 * y * y, -2 * y * z, 0, -2 * x * z, -2 * y * z, 1 - 2 * z * z, 0, 0, 0, 0, 1];
  return mmul(translation(c), mmul(L, translation(mul(c, -1))));
}

In detail

Method
Translation along the unit Newell normal; 4×4 matrix times point; projection of a profile onto miter planes along a path; line of intersection of two planes, clipped to both faces.
Number types
64-bit numbers; 4×4 matrices as 16 numbers.
Tolerances
EPS = 1e-6 m: least push distance, clamps, least overlap of an intersection. 10 × EPS = 1e-5 m: how far a moved face may leave its plane before it is folded into triangles or the stretch is refused. Neighbor faces run along a normal when |n_g · n| < 1e-6. Parallel planes: |n_a × n_b| < 1e-9. Miter refused when |n · d| < 1e-9.
Limits
Tolerances are absolute, in meters. A push that ends within 1 micrometer of its limit is snapped to the limit.

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