Finds walls that are too thin, gaps that are too narrow, loose pieces, sealed cavities and overhangs before a part goes to a printer.
How the precision is kept
All checks but the overhang run on a voxel grid, and the file says the answer is only as fine as about one voxel. Squared distances are whole numbers of voxels² computed in 64-bit and stored in a Float32Array, which holds whole numbers exactly up to 16,777,216 (a distance of 4,096 voxels). Sizes in the problem list are rounded to 0.001 mm.
Open item. Store the squared distances in a Float64Array so they stay exact whole numbers at any grid size.
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.
field/mfg.jsPartCraft 0.8.1 32-bit in part8 calculationsReviewed
Tests tools/field-test/mfg.mjs
Distance transform along one row
64-bit
The squared distance to the nearest empty voxel along one row.
The lower envelope of parabolas, in 64-bit. The inputs are whole numbers or the marker 1e20, so the squares and sums are exact below 2⁵³; the intersection s is one division by a non-zero whole number.
field/mfg.js
lines 25 to 45
PartCraft 0.8.1
// ---- squared Euclidean distance transform in voxel units -------------------------------------------------------------------
function edt1d(f, n, d, v, z) { // lower envelope of parabolas rooted at the samples f[q]
let k = 0;
v[0] = 0; z[0] = -INF; z[1] = INF;
for (let q = 1; q < n; q++) {
let s;
for (;;) {
const p = v[k];
s = ((f[q] + q * q) - (f[p] + p * p)) / (2 * q - 2 * p);
if (s <= z[k] && k > 0) k--; else break;
}
if (s <= z[k]) { v[0] = q; z[0] = -INF; z[1] = INF; k = 0; continue; }
k++; v[k] = q; z[k] = s; z[k + 1] = INF;
}
k = 0;
for (let q = 0; q < n; q++) {
while (z[k + 1] < q) k++;
const dq = q - v[k];
d[q] = dq * dq + f[v[k]];
}
}
Squared distance transform
32-bit in part
For each voxel in a set, the squared distance in voxels to the nearest voxel outside it.
Three passes (x, y, z) through Float64Array rows, but each pass writes its result back into the Float32Array. Whole numbers survive that exactly up to 16,777,216; a larger squared distance would be rounded. The marker 1e20 is stored as the nearest 32-bit value.
field/mfg.js
lines 46 to 68
PartCraft 0.8.1
/** src: Uint8Array, 1 = in the set. Returns Float32Array: for a voxel in the set the squared distance (voxels²) to the nearest voxel not in the set; 0 outside. */
export function distanceSquared(src, nx, ny, nz) {
const out = new Float32Array(src.length);
for (let i = 0; i < src.length; i++) out[i] = src[i] ? INF : 0;
const m = Math.max(nx, ny, nz), f = new Float64Array(m), d = new Float64Array(m), v = new Int32Array(m), z = new Float64Array(m + 1);
for (let k = 0; k < nz; k++) for (let j = 0; j < ny; j++) { // along x
const o = nx * (j + ny * k);
for (let i = 0; i < nx; i++) f[i] = out[o + i];
edt1d(f, nx, d, v, z);
for (let i = 0; i < nx; i++) out[o + i] = d[i];
}
for (let k = 0; k < nz; k++) for (let i = 0; i < nx; i++) { // along y
for (let j = 0; j < ny; j++) f[j] = out[i + nx * (j + ny * k)];
edt1d(f, ny, d, v, z);
for (let j = 0; j < ny; j++) out[i + nx * (j + ny * k)] = d[j];
}
for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) { // along z
for (let k = 0; k < nz; k++) f[k] = out[i + nx * (j + ny * k)];
edt1d(f, nz, d, v, z);
for (let k = 0; k < nz; k++) out[i + nx * (j + ny * k)] = d[k];
}
return out;
}
Connected pieces
Exact
Labels each connected piece and counts its voxels.
A flood fill over 6 neighbors with whole-number labels, counts and coordinate sums; nothing is rounded while the sums stay below 2⁵³.
field/mfg.js
lines 70 to 92
PartCraft 0.8.1
/** Connected components (6-neighbours) of the voxels where mask[i] = 1 → { label: Int32Array (−1 outside), sizes: number[], sum: [[x,y,z sums]] }. */
export function components(mask, nx, ny, nz) {
const n = mask.length, label = new Int32Array(n).fill(-1), sizes = [], sums = [], stack = new Int32Array(n);
const nxy = nx * ny;
for (let s = 0; s < n; s++) {
if (!mask[s] || label[s] >= 0) continue;
const id = sizes.length;
let sp = 0, cnt = 0, sx = 0, sy = 0, sz = 0;
stack[sp++] = s; label[s] = id;
while (sp) {
const c = stack[--sp], i = c % nx, j = Math.floor(c / nx) % ny, k = Math.floor(c / nxy);
cnt++; sx += i; sy += j; sz += k;
if (i > 0 && mask[c - 1] && label[c - 1] < 0) { label[c - 1] = id; stack[sp++] = c - 1; }
if (i < nx - 1 && mask[c + 1] && label[c + 1] < 0) { label[c + 1] = id; stack[sp++] = c + 1; }
if (j > 0 && mask[c - nx] && label[c - nx] < 0) { label[c - nx] = id; stack[sp++] = c - nx; }
if (j < ny - 1 && mask[c + nx] && label[c + nx] < 0) { label[c + nx] = id; stack[sp++] = c + nx; }
if (k > 0 && mask[c - nxy] && label[c - nxy] < 0) { label[c - nxy] = id; stack[sp++] = c - nxy; }
if (k < nz - 1 && mask[c + nxy] && label[c + nxy] < 0) { label[c + nxy] = id; stack[sp++] = c + nxy; }
}
sizes.push(cnt); sums.push([sx, sy, sz]);
}
return { label, sizes, sums };
}
Thin regions
32-bit in part
The parts of a solid (or of the empty space) thinner than a limit, with each one's thickness.
Squared distances are read from the Float32Array. A voxel belongs to the thick core when d² ≥ dk² − 1e-6, an absolute tolerance in voxels². A region's thickness is twice the square root of its largest squared distance minus half a voxel, and at least one voxel.
field/mfg.js
lines 94 to 125
PartCraft 0.8.1
/**
* The parts of `mask` that are thinner than `t` voxels: the opening by a ball of diameter t removes them; a thin region counts when some of
* it lies more than two voxels from the material that survives. → { count, voxels, thinnest (voxels, the thickest point of the thinnest region), at: [voxel centres of the regions] }
*/
function thinRegions(mask, nx, ny, nz, tVox) {
// a voxel grid cannot tell 3.26 from 3.0 mm at 0.4 mm voxels: a feature is reported only when it is thinner than the limit by more than one voxel
const d2 = distanceSquared(mask, nx, ny, nz), dk = (Math.max(1, tVox - 1) + 1) / 2, n = mask.length;
const core = new Uint8Array(n);
let anyCore = false;
for (let i = 0; i < n; i++) if (mask[i] && d2[i] >= dk * dk - 1e-6) { core[i] = 1; anyCore = true; }
// distance to the core: where it is within dk − 0.5 the voxel belongs to the opened (thick enough) set
const notCore = new Uint8Array(n);
for (let i = 0; i < n; i++) notCore[i] = core[i] ? 0 : 1;
const toCore = anyCore ? distanceSquared(notCore, nx, ny, nz) : null; // for a non-core voxel: squared distance to the nearest core voxel
const thin = new Uint8Array(n), far = new Uint8Array(n);
const r2 = (dk - 0.5) * (dk - 0.5);
// an opening cuts an edge of the shape to a depth of 0.41 and a corner to 0.73 of its radius (distance to the surviving core beyond the
// dilation reach: 0.41 dk + 0.5 and 0.73 dk + 0.5); only a region that reaches farther than that is a thin part of the shape
const farD = (dk - 0.5) + 0.75 * dk + 1.5;
for (let i = 0; i < n; i++) {
if (!mask[i] || core[i]) continue;
const dc = toCore ? toCore[i] : INF;
if (dc > r2) { thin[i] = 1; if (dc > farD * farD) far[i] = 1; }
}
const comp = components(thin, nx, ny, nz), keep = new Uint8Array(comp.sizes.length);
for (let i = 0; i < n; i++) if (far[i] && thin[i]) keep[comp.label[i]] = 1;
const regions = [];
const dmax = new Float64Array(comp.sizes.length);
for (let i = 0; i < n; i++) if (thin[i] && keep[comp.label[i]]) { const c = comp.label[i]; if (d2[i] > dmax[c]) dmax[c] = d2[i]; }
keep.forEach((k, c) => { if (k) regions.push({ voxels: comp.sizes[c], c: comp.sums[c].map((s) => s / comp.sizes[c]), thickness: Math.max(1, 2 * Math.sqrt(dmax[c]) - 0.5) }); });
return regions;
}
Loose pieces and sealed cavities
64-bit
The volume and position of each loose piece and each sealed cavity.
volume = voxel count × h³; position = origin + (mean index − pad + ½) × h
Whole-number counts times h³, and the mean of whole-number index sums (one division per axis) placed at voxel centers.
field/mfg.js
lines 148 to 157
PartCraft 0.8.1
const sol = components(P.a, P.nx, P.ny, P.nz);
if (sol.sizes.length > 1) {
const main = sol.sizes.indexOf(Math.max(...sol.sizes));
sol.sizes.forEach((n, c) => { if (c !== main) out.islands.push({ volume: n * h ** 3, at: at(sol.sums[c].map((s) => s / n)) }); });
}
// ---- empty space: reached from the outside or sealed
const empty = new Uint8Array(N);
for (let i = 0; i < N; i++) empty[i] = P.a[i] ? 0 : 1;
const emp = components(empty, P.nx, P.ny, P.nz), outsideId = emp.label[0];
emp.sizes.forEach((n, c) => { if (c !== outsideId) out.sealed.push({ volume: n * h ** 3, at: at(emp.sums[c].map((s) => s / n)) }); });
Thin wall and gap sizes in millimeters
32-bit in part
The thickness and volume of each thin wall and narrow gap.
The limits are passed to thinRegions in voxels (limit ÷ h), and its result, which reads the 32-bit squared distances, is multiplied back by h.
field/mfg.js
lines 175 to 176
PartCraft 0.8.1
if (profile.minWall > 0) for (const r of thinRegions(P.a, P.nx, P.ny, P.nz, profile.minWall / h)) out.thinWalls.push({ volume: r.voxels * h ** 3, at: at(r.c), thickness: r.thickness * h });
if (profile.minGap > 0) for (const r of thinRegions(empty, P.nx, P.ny, P.nz, profile.minGap / h)) out.thinGaps.push({ volume: r.voxels * h ** 3, at: at(r.c), thickness: r.thickness * h });
Rounding in the problem list
64-bit
Rounds sizes, volumes and positions for the problem list.
toFixed(3), then back to a number: the found thickness, the total volume and each marker position in a problem are rounded to 0.001.
field/mfg.js
line 145
PartCraft 0.8.1
const mm = (x) => +x.toFixed(3);
Overhang area
64-bit
The downward-facing area steeper than a process prints without support.
counts when −n_z ÷ |n| > sin(angle) + 1e-9; share = overhang area ÷ total area
Each triangle's normal is one cross product of edge vectors and its area is half the length; areas are added in index order. Faces on the lowest plane are left out with a 1e-6 tolerance that is scaled by 1 + |z_min| in one test and absolute in the other.
field/mfg.js
lines 192 to 219
PartCraft 0.8.1
/**
* Downward-facing surface that is steeper than the process prints without support, with the build direction +z. A face needs support when
* its overhang angle from vertical (asin of the downward part of its normal) is above `angle`; the faces on the lowest plane lie on the bed and need none.
* mesh = { positions, indices } in the printer's frame (z up). → { area, share (of the total area), at: [centroids of the 20 biggest], count }
*/
export function overhang(mesh, angle) {
const P = mesh.positions, I = mesh.indices, lim = Math.sin(angle * Math.PI / 180);
let zmin = Infinity;
for (let i = 2; i < P.length; i += 3) if (P[i] < zmin) zmin = P[i];
let area = 0, total = 0, count = 0;
const hits = [];
for (let t = 0; t < I.length; t += 3) {
const a = I[t] * 3, b = I[t + 1] * 3, c = I[t + 2] * 3;
const ux = P[b] - P[a], uy = P[b + 1] - P[a + 1], uz = P[b + 2] - P[a + 2], vx = P[c] - P[a], vy = P[c + 1] - P[a + 1], vz = P[c + 2] - P[a + 2];
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx, len = Math.sqrt(nx * nx + ny * ny + nz * nz);
if (!(len > 0)) continue;
const ar = len / 2;
total += ar;
if (-nz / len > lim + 1e-9 && Math.max(P[a + 2], P[b + 2], P[c + 2]) > zmin + 1e-6 * (1 + Math.abs(zmin))) {
// a face right on the lowest plane is the bed face
if (Math.abs(P[a + 2] - zmin) < 1e-6 && Math.abs(P[b + 2] - zmin) < 1e-6 && Math.abs(P[c + 2] - zmin) < 1e-6) continue;
area += ar; count++;
hits.push([ar, (P[a] + P[b] + P[c]) / 3, (P[a + 1] + P[b + 1] + P[c + 1]) / 3, (P[a + 2] + P[b + 2] + P[c + 2]) / 3]);
}
}
hits.sort((p, q) => q[0] - p[0]);
return { area, total, share: total > 0 ? area / total : 0, count, at: hits.slice(0, 20).map((h) => [h[1], h[2], h[3]]) };
}
In detail
Method
Euclidean distance transform (the file cites Felzenszwalb and Huttenlocher 2012); thin regions by a morphological opening with a ball (it cites Serra 1982); loose pieces and cavities by 6-neighbor connected components; overhang from each triangle's normal.
Core test d² ≥ dk² − 1e-6 (voxels²). A feature is reported only when thinner than the limit by more than one voxel. Thin regions under 4 voxels of volume are dropped, loose pieces and cavities under 27. Overhang: sine limit + 1e-9; bed faces within 1e-6 of the lowest z (one test scaled by 1 + |z|, the other absolute).
Limits
Voxel resolution: the code's comment says 3.26 mm and 3.0 mm cannot be told apart at 0.4 mm voxels.
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.