Resources Numerical accuracy Calculation policy

Hollowing a body to a wall thickness

Makes the inside surface that leaves a wall of the asked thickness, reports the volume taken out, and builds drain holes through the wall.

How the precision is kept

Two methods. The offset moves every corner inward in 64-bit so each face around it moves by the wall, and is used only when it passes the checks in the code. Otherwise a signed distance field on a grid is contoured by marching tetrahedra; that field is held in two Float32Array, and the cavity's points are interpolated from it. The code's own header states the field's walls are the asked thickness to within one grid step.

Open item. Hold the squared distances and the signed field in Float64Array so the cavity's points are interpolated from 64-bit values.

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.

print-hollow.js ModelCraft 0.8.1 32-bit in part 8 calculations Reviewed

Tests tools/modelcraft-test/print07.mjs, tools/modelcraft-test/kernel-print.mjs

Inward offset of every corner

64-bit

Moves each corner inward so that every face around it moves by the wall thickness, and rejects the result where an offset would fold.

q = p − wall × m, with n·m = 1 for every face direction n at the corner

Positions and moves are 64-bit. Face directions are merged when their dot product exceeds 1 − 1e-9. The offset is refused when |m| > 3, when a moved face's normal has a dot product ≤ 1e-12 with its old unit normal (an absolute value in mm²), or when a moved corner is closer than 0.95 × wall to the outside.

  • print-hollow.js
  • lines 35 to 65
  • ModelCraft 0.8.1
function offsetCavity(T, wall) {
  const { P, I, nt, N, A } = T, nv = P.length / 3;
  // the face directions around each corner (distinct ones, area-weighted for the fallback)
  const fans = Array.from({ length: nv }, () => []);
  for (let t = 0; t < nt; t++) if (A[t]) for (let k = 0; k < 3; k++) fans[I[t * 3 + k]].push(t);
  const Q = new Float64Array(nv * 3);
  for (let v = 0; v < nv; v++) {
    const dirs = [];
    for (const t of fans[v]) {
      const n = [N[t * 3], N[t * 3 + 1], N[t * 3 + 2]];
      if (!dirs.some((d) => d[0] * n[0] + d[1] * n[1] + d[2] * n[2] > 1 - 1e-9)) dirs.push(n);
    }
    const m = moveFor(dirs);
    if (!m || Math.hypot(m[0], m[1], m[2]) > 3) return { why: 'spike' };
    for (let k = 0; k < 3; k++) Q[v * 3 + k] = P[v * 3 + k] - wall * m[k];
  }
  // reversed triangles: the inside surface faces the hollow
  const Iq = new Uint32Array(nt * 3);
  for (let t = 0; t < nt; t++) { Iq[t * 3] = I[t * 3]; Iq[t * 3 + 1] = I[t * 3 + 2]; Iq[t * 3 + 2] = I[t * 3 + 1]; }
  // no face turned over
  for (let t = 0; t < nt; t++) {
    if (!A[t]) continue;
    const a = I[t * 3] * 3, b = I[t * 3 + 1] * 3, c = I[t * 3 + 2] * 3;
    const ux = Q[b] - Q[a], uy = Q[b + 1] - Q[a + 1], uz = Q[b + 2] - Q[a + 2], vx = Q[c] - Q[a], vy = Q[c + 1] - Q[a + 1], vz = Q[c + 2] - Q[a + 2];
    const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
    if (nx * N[t * 3] + ny * N[t * 3 + 1] + nz * N[t * 3 + 2] <= 1e-12) return { why: 'flipped' };
  }
  // every corner a wall away from the outside (a concave place pushes a corner close to another face)
  for (let v = 0; v < nv; v++) if (nearestDist(T, Q[v * 3], Q[v * 3 + 1], Q[v * 3 + 2], wall) < wall * 0.95) return { why: 'close' };
  return { positions: Q, indices: Iq };
}

Corner move from the face directions

64-bit

The vector m with n·m = 1 for each face direction at a corner.

one direction: m = n; two: m = (n₁ + n₂) ÷ (1 + n₁·n₂); more: (NᵀN) m = Nᵀ1

For three or more directions the normal equations are solved with the 3×3 adjugate and determinant; forming NᵀN squares the conditioning of the problem. When |det| < 1e-9 × n³ the mean direction divided by its smallest dot product is used, and no move is returned if that dot product is ≤ 1e-3. For two directions 1 + n₁·n₂ approaches zero for a knife edge and is divided by without a guard. The caller's |m| > 3 test catches a large result, but when the two directions are exactly opposite the result is not a number and that test does not catch it.

  • print-hollow.js
  • lines 67 to 92
  • ModelCraft 0.8.1
/** m with n·m = 1 for every face direction n (least squares beyond three). */
function moveFor(dirs) {
  if (!dirs.length) return null;
  if (dirs.length === 1) return dirs[0].slice();
  if (dirs.length === 2) {
    const c = dirs[0][0] * dirs[1][0] + dirs[0][1] * dirs[1][1] + dirs[0][2] * dirs[1][2], s = 1 / (1 + c);
    return [0, 1, 2].map((k) => s * (dirs[0][k] + dirs[1][k]));
  }
  // normal equations (NᵀN) m = Nᵀ1
  const M = [[0, 0, 0], [0, 0, 0], [0, 0, 0]], r = [0, 0, 0];
  for (const n of dirs) for (let i = 0; i < 3; i++) { r[i] += n[i]; for (let j = 0; j < 3; j++) M[i][j] += n[i] * n[j]; }
  const det = M[0][0] * (M[1][1] * M[2][2] - M[1][2] * M[2][1]) - M[0][1] * (M[1][0] * M[2][2] - M[1][2] * M[2][0]) + M[0][2] * (M[1][0] * M[2][1] - M[1][1] * M[2][0]);
  if (Math.abs(det) < 1e-9 * dirs.length ** 3) {
    // all the directions in one plane or nearly (a ridge): the mean direction, scaled so the faces move a wall
    const s = [0, 0, 0];
    for (const n of dirs) for (let k = 0; k < 3; k++) s[k] += n[k];
    const l = Math.hypot(...s);
    if (!l) return null;
    const u = s.map((x) => x / l);
    const cmin = Math.min(...dirs.map((n) => n[0] * u[0] + n[1] * u[1] + n[2] * u[2]));
    return cmin > 1e-3 ? u.map((x) => x / cmin) : null;
  }
  const inv = (a, b, c, d, e, f, g, h, i) => [e * i - f * h, c * h - b * i, b * f - c * e, f * g - d * i, a * i - c * g, c * d - a * f, d * h - e * g, b * g - a * h, a * e - b * d];
  const C = inv(M[0][0], M[0][1], M[0][2], M[1][0], M[1][1], M[1][2], M[2][0], M[2][1], M[2][2]);
  return [0, 1, 2].map((i) => (C[i * 3] * r[0] + C[i * 3 + 1] * r[1] + C[i * 3 + 2] * r[2]) / det);
}

Grid step of the distance field

64-bit

Chooses the grid step and size for the distance field.

h = min(wall ÷ 2.5, largest size ÷ 24); h ← 1.15·h while Π(⌈sizeₖ ÷ h⌉ + 5) > maxCells; band B = wall + 2h

The step sets the accuracy of the field result. It grows by 15% at a time until the grid fits maxCells, so on a large model the step is larger than wall ÷ 2.5; the step used is returned as cell.

  • print-hollow.js
  • lines 100 to 106
  • ModelCraft 0.8.1
let h = Math.min(wall / 2.5, Math.max(...size) / 24);
const dims = (hh) => size.map((s) => Math.ceil(s / hh) + 5);
while (dims(h).reduce((a, b) => a * b, 1) > maxCells) h *= 1.15;
const [nx, ny, nz] = dims(h), N = nx * ny * nz;
const ox = lo[0] - 2 * h, oy = lo[1] - 2 * h, oz = lo[2] - 2 * h;
const id = (i, j, k) => (k * ny + j) * nx + i;
const B = wall + 2 * h, B2 = B * B;

Signed distance field

32-bit in part

The distance from every grid point to the outside surface, negative where the point is deeper inside than the wall.

d² = min over triangles of |x − closest point|², capped at B²; φ = ±√d² + wall (− inside)

Each squared distance is computed in 64-bit by triPointDist2 but stored in a Float32Array, and the signed value is stored in a second Float32Array, so the field carries about 7 significant digits. Inside or outside is decided by counting ray crossings along each grid row, with the row shifted by h × 1.2345e-4 and h × 2.3456e-4 so it does not run along an edge; each further crossing is searched from the last distance plus 1e-9 × h, at most 10 000 times per row. A value of exactly 0 is replaced by 1e-7.

  • print-hollow.js
  • lines 107 to 140
  • ModelCraft 0.8.1
const d2 = new Float32Array(N).fill(B2);
// exact distance near the surface: each triangle visits the grid points within B of its box
for (let t = 0; t < nt; t++) {
  const bo = t * 6, bx = T.boxes;
  const i0 = Math.max(0, Math.floor((bx[bo] - B - ox) / h)), i1 = Math.min(nx - 1, Math.ceil((bx[bo + 3] + B - ox) / h));
  const j0 = Math.max(0, Math.floor((bx[bo + 1] - B - oy) / h)), j1 = Math.min(ny - 1, Math.ceil((bx[bo + 4] + B - oy) / h));
  const k0 = Math.max(0, Math.floor((bx[bo + 2] - B - oz) / h)), k1 = Math.min(nz - 1, Math.ceil((bx[bo + 5] + B - oz) / h));
  for (let k = k0; k <= k1; k++) for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) {
    const q = id(i, j, k), v = triPointDist2(T, t, ox + i * h, oy + j * h, oz + k * h);
    if (v < d2[q]) d2[q] = v;
  }
}
// inside or out: crossings along each row (a hair off the grid rows, so a row never runs along an edge)
const phi = new Float32Array(N), hit = { tri: -1, back: 0 }, xs = [];
const dy = h * 1.2345e-4, dz = h * 2.3456e-4, far = size[0] + 8 * h;
for (let k = 0; k < nz; k++) for (let j = 0; j < ny; j++) {
  const y = oy + j * h + dy, z = oz + k * h + dz;
  xs.length = 0;
  let at = 0;
  for (let guard = 0; guard < 10000; guard++) {
    const d = rayNearest(T, ox - h, y, z, 1, 0, 0, at, far, -1, hit);
    if (d < 0) break;
    xs.push(ox - h + d);
    at = d + 1e-9 * h;
  }
  let c = 0;
  for (let i = 0; i < nx; i++) {
    const x = ox + i * h;
    while (c < xs.length && xs[c] <= x) c++;
    const q = id(i, j, k), dist = Math.sqrt(d2[q]);
    const v = (c % 2 ? -dist : dist) + wall;   // < 0: deeper inside than a wall = the hollow
    phi[q] = v === 0 ? 1e-7 : v;
  }
}

Cavity surface point on a grid edge

32-bit in part

Where the cavity surface crosses a grid edge between a point in the hollow and a point in the material.

s = φₐ ÷ (φₐ − φ_b), clamped to [0.001, 0.999]; p = pₐ + (p_b − pₐ) × s

s is formed from the two 32-bit field values; they have opposite signs, so the denominator is a sum of magnitudes and does not cancel. The point is then computed and kept in 64-bit, but its position along the edge has the 32-bit field's digits. The clamp keeps every point at least a thousandth of a cell away from a grid point.

  • print-hollow.js
  • lines 145 to 154
  • ModelCraft 0.8.1
const vert = (a, b) => {
  const key = a < b ? a * N + b : b * N + a;
  let v = onEdge.get(key);
  if (v !== undefined) return v;
  const s = Math.min(0.999, Math.max(0.001, phi[a] / (phi[a] - phi[b]))), pa = coord(a), pb = coord(b);
  v = pts.length / 3;
  pts.push(pa[0] + (pb[0] - pa[0]) * s, pa[1] + (pb[1] - pa[1]) * s, pa[2] + (pb[2] - pa[2]) * s);
  onEdge.set(key, v);
  return v;
};

Signed volume of a mesh

64-bit

The volume enclosed by a triangle mesh, used for the body and for the cavity.

V = Σ a·(b × c) ÷ 6

Each triangle adds the signed volume of a tetrahedron to the coordinate origin, summed in mesh order. For a body far from the origin the terms are large with both signs and digits are lost in the sum; the coordinates are not shifted to the body's own middle first.

  • print-hollow.js
  • lines 184 to 191
  • ModelCraft 0.8.1
function signedVolume(P, I) {
  let v = 0;
  for (let t = 0; t < I.length; t += 3) {
    const a = I[t] * 3, b = I[t + 1] * 3, c = I[t + 2] * 3;
    v += (P[a] * (P[b + 1] * P[c + 2] - P[b + 2] * P[c + 1]) - P[a + 1] * (P[b] * P[c + 2] - P[b + 2] * P[c]) + P[a + 2] * (P[b] * P[c + 1] - P[b + 1] * P[c])) / 6;
  }
  return v;
}

Choosing the method and reporting the result

64-bit

Runs the offset, falls back to the field, and returns the hollow body, the cavity and their volumes.

cavity volume = −V(cavity mesh)

With method auto the offset is kept only if printCheck finds the outside joined with the inside free of problems, overlaps and self-intersections, with a weld tolerance of min(1e-4, wall × 1e-4) mm. When the field is used, the cavity positions it returns were interpolated from the 32-bit field (see the surface point above), and the cavity volume is computed from those positions in 64-bit.

  • print-hollow.js
  • lines 201 to 224
  • ModelCraft 0.8.1
export function hollowMesh(mesh, { wall = 2, method = 'auto', maxCells = 2e6, T = null } = {}) {
  T ||= triMesh(mesh);
  const volume = signedVolume(mesh.positions, mesh.indices);
  let cav = null, why = null, used = 'offset', cell = 0;
  if (method !== 'field') {
    const o = offsetCavity(T, wall);
    if (o.why) why = o.why;
    else {
      const both = join(mesh, o);
      const r = printCheck(meshFromTriangles(asTris(both)), { tol: Math.min(1e-4, wall * 1e-4) });
      if (r.ok && !r.overlaps && !r.selfIntersections) cav = o; else why = 'crossing';
    }
  }
  if (!cav && method !== 'offset') {
    used = 'field';
    const f = fieldCavity(T, wall, maxCells);
    cell = f.cell;
    cav = f.indices.length ? f : null;
    if (!cav) used = 'none';
  }
  if (!cav) return { method: method === 'offset' ? 'offset' : 'none', why, positions: mesh.positions, indices: mesh.indices, cavity: null, cavityVolume: 0, volume, cell };
  const both = join(mesh, cav);
  return { method: used, why, ...both, cavity: cav, cavityVolume: -signedVolume(cav.positions, cav.indices), volume, cell };
}

Drain hole cylinder

64-bit

A 24-sided cylinder along the inward normal of a picked face, long enough to reach the hollow.

depth = first face behind the pick; top = p + n·extra; bottom = p − n·(depth + extra); ring = c + r·(cos θ·u + sin θ·w)

The depth ray starts 1e-4 mm inside the face and looks no farther than 20 walls. The cylinder is a 24-sided prism whose corners are at radius d ÷ 2, so the flats lie inside the asked diameter by the factor cos(π ÷ 24).

  • print-hollow.js
  • lines 228 to 251
  • ModelCraft 0.8.1
export function drainHole(T, at, { d = 3, extra = 1, wall = 2 } = {}) {
  const t = at.tri, n = [T.N[t * 3], T.N[t * 3 + 1], T.N[t * 3 + 2]], p = at.p;
  const hit = { tri: -1, back: 0 };
  const depth = rayNearest(T, p[0] - n[0] * 1e-4, p[1] - n[1] * 1e-4, p[2] - n[2] * 1e-4, -n[0], -n[1], -n[2], 0, wall * 20, t, hit);
  if (depth < 0) return null;
  // a frame across the hole's axis
  const a = Math.abs(n[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0];
  let u = [n[1] * a[2] - n[2] * a[1], n[2] * a[0] - n[0] * a[2], n[0] * a[1] - n[1] * a[0]];
  const ul = Math.hypot(...u); u = u.map((x) => x / ul);
  const w = [n[1] * u[2] - n[2] * u[1], n[2] * u[0] - n[0] * u[2], n[0] * u[1] - n[1] * u[0]];
  const r = d / 2, top = p.map((x, k) => x + n[k] * extra), bot = p.map((x, k) => x - n[k] * (depth + extra)), sides = 24;
  const ring = (c) => Array.from({ length: sides }, (_, i) => { const an = 2 * Math.PI * i / sides; return c.map((x, k) => x + r * (Math.cos(an) * u[k] + Math.sin(an) * w[k])); });
  const R1 = ring(top), R0 = ring(bot), out = [];
  for (let i = 0; i < sides; i++) {
    const j = (i + 1) % sides;
    out.push([R0[i], R0[j], R1[j]], [R0[i], R1[j], R1[i]]);
    out.push([top, R1[i], R1[j]], [bot, R0[j], R0[i]]);
  }
  // wound outward: check one side face against the way out from the axis
  const s = out[0], e1 = s[1].map((x, k) => x - s[0][k]), e2 = s[2].map((x, k) => x - s[0][k]);
  const fn = [e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]];
  const outv = s[0].map((x, k) => x - bot[k]);
  return fn[0] * outv[0] + fn[1] * outv[1] + fn[2] * outv[2] >= 0 ? out : out.map((q) => [q[0], q[2], q[1]]);
}

In detail

Method
Per-corner offset by a least-squares fit of the face directions; else a signed distance grid (exact point to triangle distance in a band, sign by ray crossings) contoured with six tetrahedra per cell.
Number types
Float64Array for positions; Float32Array for the squared distances and the signed field; millimeters.
Tolerances
Offset: face directions closer than 1 − 1e-9 (dot product) count as one; the 3×3 system is treated as flat when |det| < 1e-9 × n³; a corner move longer than 3 walls, a face whose new normal has dot ≤ 1e-12 with the old one, or a corner closer than 0.95 × wall to the outside rejects the offset. Field: grid step h = min(wall ÷ 2.5, largest size ÷ 24), grown by 1.15 until the grid has at most maxCells points (2 000 000 by default).
Limits
The field result is accurate to about one grid step, and the step grows on a large model. The offset is exact only where it is accepted (the code names convex bodies and boxes).

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