Checks whether a mesh is ready for a 3D printer and reports its size, volume, surface area, overhang area and problems.
How the precision is kept
Positions are Float64Array in millimeters and all sums are 64-bit. Corners are joined within a weld tolerance, edges are counted to find holes and bad edges, volume is a signed tetrahedron sum per shell, and triangle pairs are tested for crossing with tolerances scaled to the model's diagonal.
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.
Joins corners that lie within the tolerance of each other and drops triangles that collapse.
join if |Δx| ≤ tol and |Δy| ≤ tol and |Δz| ≤ tol; drop if two corners are the same or |n|² ≤ (tol² × 0.01)²
The distance test is per axis (a cube of side 2 × tol, not a sphere). A joined corner takes the coordinates of the first corner found, unchanged; nothing is averaged. The tolerance is absolute, 1e-4 mm by default.
print3d.js
lines 51 to 93
ModelCraft 0.8.1
export function weld(mesh, tol = 1e-4) {
const P = mesh.positions, I = mesh.indices, nIn = P.length / 3;
// cells 4 × tol wide, hashed to numbers (a clash only costs a comparison); a corner looks in the next cell only
// when it lies within tol of that side
const cs = tol * 4, cell = new Map(), remap = new Int32Array(nIn), out = [];
const hash = (x, y, z) => (Math.imul(x, 73856093) ^ Math.imul(y, 19349663) ^ Math.imul(z, 83492791)) | 0;
let nv = 0;
for (let i = 0; i < nIn; i++) {
const x = P[i * 3], y = P[i * 3 + 1], z = P[i * 3 + 2];
const fx = x / cs, fy = y / cs, fz = z / cs, cx = Math.floor(fx), cy = Math.floor(fy), cz = Math.floor(fz);
const r = 0.25 + 1e-9; // tol in cells
const x0 = fx - cx < r ? -1 : 0, x1 = cx + 1 - fx < r ? 1 : 0, y0 = fy - cy < r ? -1 : 0, y1 = cy + 1 - fy < r ? 1 : 0, z0 = fz - cz < r ? -1 : 0, z1 = cz + 1 - fz < r ? 1 : 0;
let found = -1;
for (let dx = x0; dx <= x1 && found < 0; dx++) for (let dy = y0; dy <= y1 && found < 0; dy++) for (let dz = z0; dz <= z1 && found < 0; dz++) {
const list = cell.get(hash(cx + dx, cy + dy, cz + dz));
if (!list) continue;
for (let k = 0; k < list.length; k++) {
const j = list[k];
if (Math.abs(out[j * 3] - x) <= tol && Math.abs(out[j * 3 + 1] - y) <= tol && Math.abs(out[j * 3 + 2] - z) <= tol) { found = j; break; }
}
}
if (found < 0) {
found = nv++;
out.push(x, y, z);
const h = hash(cx, cy, cz), list = cell.get(h);
if (list) list.push(found); else cell.set(h, [found]);
}
remap[i] = found;
}
const positions = new Float64Array(out);
const idx = [], cols = mesh.colors ? [] : null;
let degenerate = 0;
const area2 = tol * tol * 1e-2;
for (let t = 0; t < I.length / 3; t++) {
const a = remap[I[t * 3]], b = remap[I[t * 3 + 1]], c = remap[I[t * 3 + 2]];
if (a === b || b === c || a === c) { degenerate++; continue; }
const n = triNormal(positions, a, b, c);
if (n[0] * n[0] + n[1] * n[1] + n[2] * n[2] <= area2 * area2) { degenerate++; continue; }
idx.push(a, b, c);
if (cols) cols.push(mesh.colors[t] || null);
}
return { positions, indices: new Uint32Array(idx), colors: cols && cols.some(Boolean) ? cols : null, degenerate };
}
Volume, area, overhang and bed contact
64-bit
Each shell's volume, the total surface area, the area that needs support and the area lying on the bed.
area = Σ |n| ÷ 2; V_shell = Σ a·(b × c) ÷ 6; overhang if n_z ÷ |n| < −cos((90 − angle)·π ÷ 180)
The volume terms are signed tetrahedra to the coordinate origin, in absolute coordinates and summed in mesh order; for a mesh far from the origin they nearly cancel and digits are lost. The area terms use edge differences and do not depend on position. A triangle is on the bed when its three corners are within zTol of the lowest z and its normal's z is below −0.999.
print3d.js
lines 156 to 170
ModelCraft 0.8.1
const vol = new Float64Array(rep.shells);
const cosLimit = Math.cos((90 - overhang) * Math.PI / 180); // a face whose normal points down more steeply than this needs support
const zTol = Math.max(tol * 10, 1e-6 * Math.max(...rep.size, 1));
for (let t = 0; t < nt; t++) {
const a = I[t * 3], b = I[t * 3 + 1], c = I[t * 3 + 2];
const n = triNormal(P, a, b, c), l = Math.hypot(n[0], n[1], n[2]), area = l / 2;
rep.area += area;
vol[shellOf[t]] += (P[a * 3] * (P[b * 3 + 1] * P[c * 3 + 2] - P[b * 3 + 2] * P[c * 3 + 1])
- P[a * 3 + 1] * (P[b * 3] * P[c * 3 + 2] - P[b * 3 + 2] * P[c * 3])
+ P[a * 3 + 2] * (P[b * 3] * P[c * 3 + 1] - P[b * 3 + 1] * P[c * 3])) / 6;
const nz = l ? n[2] / l : 0;
const onBed = Math.abs(P[a * 3 + 2] - lo[2]) <= zTol && Math.abs(P[b * 3 + 2] - lo[2]) <= zTol && Math.abs(P[c * 3 + 2] - lo[2]) <= zTol;
if (onBed && nz < -0.999) rep.bedArea += area;
else if (nz < -cosLimit) rep.overhangArea += area;
}
A shell inside another shell
64-bit
Decides whether an inside-out shell is a hollow inside another shell, which is correct, or a real fault.
ray from one corner along [0.000123, 0.000457, 1]; inside if it crosses the other shells an odd number of times
The ray direction is slightly off the z axis so it rarely runs along an edge. A hit closer than 1e-9 is not counted. A ray that passes exactly through a shared edge or corner can be counted once or twice; the code does not treat that case.
print3d.js
lines 182 to 191
ModelCraft 0.8.1
function insideOther(p, s) {
// a ray from p, a little off every axis, crossing the other shells an odd number of times
const d = [0.000123, 0.000457, 1], hits = [];
for (let t = 0; t < nt; t++) {
if (shellOf[t] === s) continue;
const h = rayHit(p, d, pt(I[t * 3]), pt(I[t * 3 + 1]), pt(I[t * 3 + 2]));
if (h > 1e-9) hits.push(h);
}
return hits.length % 2 === 1;
}
Ray and triangle
64-bit
The distance along a ray to a triangle, or −1 when it misses.
det = e₁·(d × e₂); u = s·(d × e₂) ÷ det; v = d·(s × e₁) ÷ det; t = e₂·(s × e₁) ÷ det
Möller–Trumbore with exact limits 0 ≤ u, 0 ≤ v, u + v ≤ 1 and no tolerance. A triangle is skipped when |det| < 1e-18, an absolute value.
print3d.js
lines 275 to 284
ModelCraft 0.8.1
/** Möller–Trumbore: distance along d where the ray from o meets the triangle, or -1. */
function rayHit(o, d, a, b, c) {
const e1 = sub(b, a), e2 = sub(c, a), h = cross(d, e2), det = dot(e1, h);
if (Math.abs(det) < 1e-18) return -1;
const f = 1 / det, s = sub(o, a), u = f * dot(s, h);
if (u < 0 || u > 1) return -1;
const q = cross(s, e1), v = f * dot(d, q);
if (v < 0 || u + v > 1) return -1;
return f * dot(e2, q);
}
Finding triangle pairs to test
64-bit
Finds pairs of triangles whose boxes overlap and counts those that cross, within one shell and between shells.
eps = diagonal × 10⁻⁹; boxes grown by eps; pair tested with tolerance 10 × eps
The tolerance is scaled to the model's diagonal. Pairs that share a corner are not tested. Triangles are sorted into a uniform grid; one covering more than 512 cells is tested against every other triangle. The count of tests is capped at maxTests, after which checked is false.
print3d.js
lines 219 to 272
ModelCraft 0.8.1
function selfIntersections(P, I, nt, shellOf, size, maxTests) {
const res = { same: 0, other: 0, atSame: [], atOther: [], checked: true };
const diag = Math.hypot(size[0], size[1], size[2]) || 1, eps = diag * 1e-9;
const box = new Float64Array(nt * 6);
let ext = 0;
for (let t = 0; t < nt; t++) {
for (let k = 0; k < 3; k++) {
const a = P[I[t * 3] * 3 + k], b = P[I[t * 3 + 1] * 3 + k], c = P[I[t * 3 + 2] * 3 + k];
box[t * 6 + k] = Math.min(a, b, c) - eps; box[t * 6 + 3 + k] = Math.max(a, b, c) + eps;
ext += box[t * 6 + 3 + k] - box[t * 6 + k];
}
}
// cell size about twice the mean triangle extent, no more than ~2M cells
let cs = Math.max((ext / (nt * 3)) * 2, diag / 1e3);
const cellsOf = (s) => Math.max(1, Math.ceil(s / cs));
while (cellsOf(size[0] || cs) * cellsOf(size[1] || cs) * cellsOf(size[2] || cs) > 2e6) cs *= 1.5;
const o = [box[0], box[1], box[2]];
for (let t = 0; t < nt; t++) for (let k = 0; k < 3; k++) o[k] = Math.min(o[k], box[t * 6 + k]);
const nx = cellsOf(size[0] + 4 * eps), ny = cellsOf(size[1] + 4 * eps), nz = cellsOf(size[2] + 4 * eps);
const ci = (v, k) => Math.max(0, Math.min([nx, ny, nz][k] - 1, Math.floor((v - o[k]) / cs)));
const grid = new Map(), big = [], isBig = new Uint8Array(nt);
for (let t = 0; t < nt; t++) {
const x0 = ci(box[t * 6], 0), x1 = ci(box[t * 6 + 3], 0), y0 = ci(box[t * 6 + 1], 1), y1 = ci(box[t * 6 + 4], 1), z0 = ci(box[t * 6 + 2], 2), z1 = ci(box[t * 6 + 5], 2);
if ((x1 - x0 + 1) * (y1 - y0 + 1) * (z1 - z0 + 1) > 512) { big.push(t); isBig[t] = 1; continue; }
for (let x = x0; x <= x1; x++) for (let y = y0; y <= y1; y++) for (let z = z0; z <= z1; z++) {
const k = (x * ny + y) * nz + z, l = grid.get(k);
if (l) l.push(t); else grid.set(k, [t]);
}
}
let tests = 0;
const overlapBox = (a, b) => box[a * 6] <= box[b * 6 + 3] && box[b * 6] <= box[a * 6 + 3] && box[a * 6 + 1] <= box[b * 6 + 4] && box[b * 6 + 1] <= box[a * 6 + 4] && box[a * 6 + 2] <= box[b * 6 + 5] && box[b * 6 + 2] <= box[a * 6 + 5];
const shares = (a, b) => { for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) if (I[a * 3 + i] === I[b * 3 + j]) return true; return false; };
const V = (t, k) => [P[I[t * 3 + k] * 3], P[I[t * 3 + k] * 3 + 1], P[I[t * 3 + k] * 3 + 2]];
const test = (a, b) => {
if (!overlapBox(a, b) || shares(a, b)) return;
if (++tests > maxTests) { res.checked = false; return; }
if (!triTri(V(a, 0), V(a, 1), V(a, 2), V(b, 0), V(b, 1), V(b, 2), eps * 10)) return;
const p = V(a, 0);
if (shellOf[a] === shellOf[b]) { res.same++; if (res.atSame.length < 20) res.atSame.push(p); }
else { res.other++; if (res.atOther.length < 20) res.atOther.push(p); }
};
for (const [k, l] of grid) {
if (!res.checked) break;
const z = k % nz, y = ((k - z) / nz) % ny, x = (k - z - y * nz) / (nz * ny);
for (let i = 0; i < l.length && res.checked; i++) for (let j = i + 1; j < l.length; j++) {
const a = l[i], b = l[j];
// test each pair once: in the cell holding the low corner of the two boxes' overlap
if (ci(Math.max(box[a * 6], box[b * 6]), 0) !== x || ci(Math.max(box[a * 6 + 1], box[b * 6 + 1]), 1) !== y || ci(Math.max(box[a * 6 + 2], box[b * 6 + 2]), 2) !== z) continue;
test(a, b);
}
}
for (const a of big) for (let b = 0; b < nt && res.checked; b++) if (b !== a && (!isBig[b] || b > a)) test(a, b);
return res;
}
Do two triangles cross
64-bit
Whether two triangles pass through each other, more than touching.
signed distances of each triangle's corners to the other's plane, set to 0 within eps; overlap of the two intervals on the planes' common line must exceed eps
Normals are normalized, so the distances are lengths and eps is a length. Distances within eps are snapped to zero, which makes touching pairs and pairs that cross by less than eps count as not crossing. The intervals are projected on the largest axis of the line's direction, not on the line itself.
2D orientation tests on the projection that drops the normal's largest axis; crossing edges, or one centroid inside the other
The orientation values are twice an area and are compared with eps², so the tolerance has matching units. They are plain 64-bit differences of products, not exact predicates, so a nearly degenerate pair is decided by rounding.
The volume reported for a mesh without problems, and the warnings for a tiny model or one larger than the bed.
V = Σ shell volumes (hollows are negative); fits if sizeₖ ≤ bedₖ + 10⁻⁹, as given or with x and y swapped
The volume is set only when no problem was found; otherwise it stays null. The bed test tries the model as it stands and turned a quarter in plan, with an absolute 1e-9 mm allowance.
print3d.js
lines 203 to 211
ModelCraft 0.8.1
if (rep.ok) { let v = 0; for (let s = 0; s < rep.shells; s++) v += vol[s]; rep.volume = v; } // hollows subtract
if (rep.overlaps) rep.warnings.push({ kind: 'overlap', n: rep.overlaps, at: si.atOther });
if (rep.touching) rep.warnings.push({ kind: 'touch', n: rep.touching, at: at.touch });
if (Math.max(...rep.size) < 1) rep.warnings.push({ kind: 'tiny', size: rep.size });
if (Array.isArray(bed) && bed.length === 3) {
const fit = (s, b) => s[0] <= b[0] + 1e-9 && s[1] <= b[1] + 1e-9 && s[2] <= b[2] + 1e-9;
if (!fit(rep.size, bed) && !fit([rep.size[1], rep.size[0], rep.size[2]], bed)) rep.warnings.push({ kind: 'bed', size: rep.size, bed });
}
In detail
Method
Grid-hashed weld; edge use counts; signed tetrahedron volumes; Möller's triangle and triangle interval test, with a 2D test for coplanar pairs; Möller–Trumbore rays for shells inside other shells.
Number types
Float64Array positions and boxes, Uint32Array indices; millimeters, mm² and mm³.
Tolerances
Weld: 1e-4 mm per axis by default. Zero-area triangles: |n| ≤ tol² × 0.01. Bed contact: max(10 × tol, 1e-6 × the larger of the largest size and 1). Intersection: boxes grown by 1e-9 × diagonal, plane distances within 1e-8 × diagonal count as zero. Bed fit: + 1e-9 mm. Ray and triangle: |det| < 1e-18.
Limits
Volume is reported only when no problem is found. The self-intersection test stops after maxTests pair tests (4 000 000 by default) and says so. Only 200 inside-out shells are tested for lying inside another shell.
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.