Resources Numerical accuracy Calculation policy

3D-print mesh repair

Fixes a mesh for printing: removes tiny loose pieces, closes holes and turns inside-out pieces, and reports what it changed.

How the precision is kept

Most fixes are decisions on vertex indices and need no arithmetic. The numeric steps are the size of a loose piece, the middle point of a filled hole, the signed volume of a piece and a ray test for a piece inside another. All use 64-bit numbers, and the result's positions are a Float64Array.

Calculations under this policy

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

print-repair.js ModelCraft 0.8.1 64-bit 4 calculations Reviewed

Tests tools/modelcraft-test/print07.mjs

Size of a loose piece

64-bit

Decides whether a separate piece is small enough to remove.

max(Δx, Δy, Δz) < minSize

The box is the min and max of the piece's own coordinates; the largest side is compared with minSize directly, an absolute length in the mesh's millimeters.

  • print-repair.js
  • lines 73 to 84
  • ModelCraft 0.8.1
// small loose pieces
{
  const drop = new Set();
  let n = 0;
  const ps = pieces();
  if (ps.length > 1) for (const ids of ps) {
    const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
    for (const i of ids) for (const v of tris[i]) for (let k = 0; k < 3; k++) { const x = P[v * 3 + k]; if (x < lo[k]) lo[k] = x; if (x > hi[k]) hi[k] = x; }
    if (Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) < minSize) { n++; for (const i of ids) drop.add(i); }
  }
  if (drop.size) tris = tris.filter((_, i) => !drop.has(i));
  note(fixed, 'small', n);

Middle point of a filled hole

64-bit

The new vertex a hole's fan of triangles meets at.

c = Σ pᵢ ÷ n

Each coordinate is divided by the corner count before it is added, so the sum is of n rounded quotients instead of one division of the total. The difference is at the last digits of the new point.

  • print-repair.js
  • lines 150 to 155
  • ModelCraft 0.8.1
const c = [0, 0, 0];
for (const u of loop) for (let k = 0; k < 3; k++) c[k] += P[u * 3 + k] / loop.length;
const ci = P.length / 3;
P.push(c[0], c[1], c[2]);
for (let i = 0; i < loop.length; i++) tris.push([loop[i], loop[(i + 1) % loop.length], ci]);
filled++;

Signed volume and inside-out pieces

64-bit

Finds closed pieces that face inward and turns them outward.

V = Σ a · (b × c) ÷ 6; turned when V < 0 and the piece is not inside another

The volume sums tetrahedra measured from the coordinate origin, so for a small piece far from the origin large terms cancel. Only the sign of the sum is used. The inside test casts one fixed ray (0.000123, 0.000457, 1) and counts hits beyond 1e-9.

  • print-repair.js
  • lines 163 to 179
  • ModelCraft 0.8.1
const ps = pieces(), vol = (ids) => { let v = 0; for (const i of ids) { const [a, b, c] = tris[i].map(pt); v += (a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0])) / 6; } return v; };
const inside = (p, mine) => {
  // a ray from p crossing the other pieces an odd number of times
  const d = [0.000123, 0.000457, 1];
  let n = 0;
  tris.forEach((t, i) => { if (mine.has(i)) return; const h = rayHit(p, d, pt(t[0]), pt(t[1]), pt(t[2])); if (h > 1e-9) n++; });
  return n % 2 === 1;
};
let n = 0, tested = 0;
for (const ids of ps) {
  if (vol(ids) >= 0) continue;
  const mine = new Set(ids);
  if (ps.length > 1 && (++tested > 200 || inside(pt(tris[ids[0]][0]), mine))) continue;   // a hollow inside another piece faces in on purpose (past 200 pieces: left as it is)
  for (const i of ids) tris[i] = [tris[i][0], tris[i][2], tris[i][1]];
  n++;
}
note(fixed, 'inverted', n);

Ray and triangle

64-bit

Where a ray meets a triangle, for the inside test.

det = e₁ · (d × e₂); u = (s · h) ÷ det; v = (d · q) ÷ det; t = (e₂ · q) ÷ det

A determinant below 1e-18 in magnitude is treated as parallel; the number is absolute, so its meaning changes with the triangle's size. The edge cases u = 0, v = 0 and u + v = 1 count as hits.

  • print-repair.js
  • lines 203 to 213
  • ModelCraft 0.8.1
function rayHit(o, d, a, b, c) {
  const e1 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]], e2 = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
  const h = [d[1] * e2[2] - d[2] * e2[1], d[2] * e2[0] - d[0] * e2[2], d[0] * e2[1] - d[1] * e2[0]];
  const det = e1[0] * h[0] + e1[1] * h[1] + e1[2] * h[2];
  if (Math.abs(det) < 1e-18) return -1;
  const f = 1 / det, s = [o[0] - a[0], o[1] - a[1], o[2] - a[2]], u = f * (s[0] * h[0] + s[1] * h[1] + s[2] * h[2]);
  if (u < 0 || u > 1) return -1;
  const q = [s[1] * e1[2] - s[2] * e1[1], s[2] * e1[0] - s[0] * e1[2], s[0] * e1[1] - s[1] * e1[0]], v = f * (d[0] * q[0] + d[1] * q[1] + d[2] * q[2]);
  if (v < 0 || u + v > 1) return -1;
  return f * (e2[0] * q[0] + e2[1] * q[1] + e2[2] * q[2]);
}

In detail

Method
Piece size from its box; hole fill by a triangle fan from the mean of the loop's corners; signed volume as a sum of tetrahedra from the origin; inside test by counting the crossings of one ray with the other pieces' triangles.
Number types
64-bit numbers; Float64Array for the returned positions; Uint32Array for indices.
Tolerances
Loose piece: box smaller than 0.5 mm across (option minSize). Hole: at most 500 edges (option maxHole). Ray test: determinant below 1e-18 is parallel, hit distance must exceed 1e-9; both absolute.
Limits
The inside test is skipped after 200 pieces. The fill of a hole that is not flat is a fan to one middle point.

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