Reads STL, OBJ and 3MF meshes, welds and repairs them into a closed body, finds their flat faces and keeps the result in the part file.
How the precision is kept
Text formats (ASCII STL, OBJ, 3MF) are parsed into Float64Array and the repair works in 64-bit numbers with tolerances scaled to the mesh's size. Binary STL stores 32-bit coordinates, which is the format's own limit. The repaired mesh is then written into the part file as 32-bit floats (encodeMesh) whatever its source, and read back as a Float32Array, so a mesh that arrived with more digits is narrowed when the part is saved.
Open item. Keep the repaired mesh in the part file as 64-bit floats, so meshes read from text formats are not narrowed to 32-bit when saved.
Calculations under this policy
11 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.
meshimport.jsPartCraft 0.8.1 32-bit in part11 calculationsReviewed
Tests tools/partcraft-test/test-mesh-import.mjs
Binary STL coordinates
64-bit
Reads the corner coordinates of each triangle from a binary STL file.
Each coordinate is read with getFloat32 and stored in a Float64Array. Binary STL stores 32-bit floats, so about 7 significant digits is the file's own limit; nothing is rounded further here. The stored normals are skipped.
meshimport.js
lines 63 to 74
PartCraft 0.8.1
function stlBinary(bytes, n) {
if (n > MAX_TRIS) fail('mesh.err.too_many', { max: MAX_TRIS });
if (n === 0) fail('mesh.err.empty');
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const positions = new Float64Array(n * 9), indices = new Uint32Array(n * 3);
for (let t = 0; t < n; t++) {
const at = 84 + 50 * t + 12; // past the stored normal (not trusted: recomputed from the corners)
for (let k = 0; k < 9; k++) positions[t * 9 + k] = dv.getFloat32(at + 4 * k, true);
indices[t * 3] = t * 3; indices[t * 3 + 1] = t * 3 + 1; indices[t * 3 + 2] = t * 3 + 2;
}
return { positions, indices, unit: null, objects: 1, notes: [] };
}
3MF vertex placement
64-bit
Applies the unit of each model file and the chain of build and component transforms to every vertex.
p′ = [x s, y s, z s, 1] × M (row vectors, 4 × 3); s = unit of the part file ÷ unit of the root file
Transforms are parsed with Number and multiplied in 64-bit numbers (mul3mf), one product per nesting level. The unit ratio is a division of two table factors.
meshimport.js
lines 279 to 285
PartCraft 0.8.1
const s = UNITS[md.unit] / scale; // a part file in another unit, in the root's
if (ob.mesh) {
objects++;
const base = pos.length / 3, { vs, ts } = ob.mesh;
for (let i = 0; i < vs.length; i += 3) {
const x = vs[i] * s, y = vs[i + 1] * s, z = vs[i + 2] * s;
pos.push(x * M[0] + y * M[3] + z * M[6] + M[9], x * M[1] + y * M[4] + z * M[7] + M[10], x * M[2] + y * M[5] + z * M[8] + M[11]);
Welding coincident points
64-bit
Merges points that lie within a tolerance of each other and maps every index to the point that is kept.
merge when (Δx² + Δy² + Δz²) ≤ tol²; the earliest point is kept
Distances are compared as squares in 64-bit numbers. A merged point takes the position of the first point seen, so positions move by up to the tolerance. Cell numbers pass through Math.imul only to form a hash key; a collision costs an extra distance check and does not change the result.
meshimport.js
lines 307 to 332
PartCraft 0.8.1
/** Merge points within tol. A grid of 4·tol cells keyed by a numeric hash (collisions only cost a distance check); a point
* looks into a neighbouring cell only on the sides it is within tol of, so most points look into one cell. Old → new index. */
function weld(P, tol, only = null) {
const n = P.length / 3, map = new Int32Array(n).map((_, i) => i);
const cell = Math.max(4 * tol, 1e-300), grid = new Map();
const key = (x, y, z) => (Math.imul(x, 73856093) ^ Math.imul(y, 19349663) ^ Math.imul(z, 83492791)) | 0;
const near = [[0], [0], [0]];
for (let i = 0; i < n; i++) {
if (only && !only[i]) continue;
const p = [P[i * 3], P[i * 3 + 1], P[i * 3 + 2]], c = p.map((v) => Math.floor(v / cell));
for (let k = 0; k < 3; k++) {
const off = p[k] - c[k] * cell;
near[k] = off < tol ? (cell - off < tol ? [0, -1, 1] : [0, -1]) : cell - off < tol ? [0, 1] : [0];
}
let hit = -1, best = tol * tol;
for (const dx of near[0]) for (const dy of near[1]) for (const dz of near[2]) {
const l = grid.get(key(c[0] + dx, c[1] + dy, c[2] + dz));
if (!l) continue;
for (const j of l) { const d = (P[j * 3] - p[0]) ** 2 + (P[j * 3 + 1] - p[1]) ** 2 + (P[j * 3 + 2] - p[2]) ** 2; if (d <= best && (hit < 0 || d < best || j < hit)) { best = d; hit = j; } }
}
if (hit >= 0) { map[i] = hit; continue; }
const k = key(c[0], c[1], c[2]), l = grid.get(k);
if (l) l.push(i); else grid.set(k, [i]);
}
return map;
}
Repair tolerances from the mesh size
64-bit
Sets the weld distance, the gap-closing distance and the smallest triangle area kept, then welds and cleans.
exact = 1e-7 × size; gap = 1e-3 × size; area limit = 1e-14 × size²; size = bounding-box diagonal
All three tolerances scale with the diagonal of the used points' bounding box (at least 1e-12). Triangles whose area is not above the limit are dropped and counted as degenerate.
meshimport.js
lines 552 to 563
PartCraft 0.8.1
const { size } = sizeOf(compact(P, I).P);
const exact = 1e-7 * size, gapTol = gap === null ? 1e-3 * size : gap;
// 1. weld coincident corners (an STL repeats every corner for every triangle)
{
const map = weld(P, exact);
let merged = 0;
for (let i = 0; i < map.length; i++) if (map[i] !== i) merged++;
report.welded = merged;
I = I.map((v) => map[v]);
const c = compact(P, I); P = c.P; I = c.I;
}
const areaTol = 1e-14 * size * size;
Closing small gaps
64-bit
Merges boundary points that lie within the gap distance of another boundary point.
Only points on open edges are welded, with the gap tolerance (0.1 % of the mesh size unless given). This changes the geometry by up to that distance at the seam; the report counts the open edges it removed.
meshimport.js
lines 569 to 571
PartCraft 0.8.1
const onEdge = new Uint8Array(P.length / 3);
for (const [u, v] of census.open) { onEdge[u] = 1; onEdge[v] = 1; }
const map = weld(P, gapTol, onEdge);
Outward orientation by signed volume
64-bit
Turns each closed shell so that it encloses positive volume.
6 V = Σ (a − o)·((b − o) × (c − o)), o = the shell's first point
The tetrahedra are measured from the shell's own first vertex instead of the origin, which the code's comment gives as the way far-off coordinates keep their digits. Only the sign of the sum is used.
meshimport.js
lines 504 to 514
PartCraft 0.8.1
const o = I[list[0] * 3];
const ox = P[o * 3], oy = P[o * 3 + 1], oz = P[o * 3 + 2];
let vol = 0;
for (const t of list) {
const a = I[t * 3], b = I[t * 3 + 1], c = I[t * 3 + 2];
const ax = P[a * 3] - ox, ay = P[a * 3 + 1] - oy, az = P[a * 3 + 2] - oz;
const bx = P[b * 3] - ox, by = P[b * 3 + 1] - oy, bz = P[b * 3 + 2] - oz;
const cx = P[c * 3] - ox, cy = P[c * 3 + 1] - oy, cz = P[c * 3 + 2] - oz;
vol += ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx);
}
if (vol < 0) for (const t of list) flipT(t);
Filling a hole
64-bit
Closes a boundary loop with triangles.
Newell normal n = Σ ((a_y − b_y)(a_z + b_z), …); ear when cross(a, b, c) > 0 and no other point is inside
The loop is projected onto its best plane and clipped ear by ear; the convexity and inside tests are signs of 64-bit cross products with no tolerance. If no ear is found a fan from the mean of the loop's points is used, which adds one new vertex.
meshimport.js
lines 401 to 454
PartCraft 0.8.1
/** Triangles closing a loop: ear clipping in the loop's best plane; a centre fan when the projection folds. The loop runs the
* way its own triangles' edges run, so the new ones go the other way round. */
function fillLoop(P, loop, addPoint) {
const pts = loop.map((i) => [P[i * 3], P[i * 3 + 1], P[i * 3 + 2]]);
const n = [0, 0, 0];
for (let i = 0; i < pts.length; i++) {
const a = pts[i], b = pts[(i + 1) % pts.length];
n[0] += (a[1] - b[1]) * (a[2] + b[2]); n[1] += (a[2] - b[2]) * (a[0] + b[0]); n[2] += (a[0] - b[0]) * (a[1] + b[1]);
}
const nl = Math.hypot(...n);
const tris = [];
if (nl > 0) {
const N = n.map((x) => x / nl), ax = Math.abs(N[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
const X = [N[1] * ax[2] - N[2] * ax[1], N[2] * ax[0] - N[0] * ax[2], N[0] * ax[1] - N[1] * ax[0]], xl = Math.hypot(...X);
for (let k = 0; k < 3; k++) X[k] /= xl;
const Y = [N[1] * X[2] - N[2] * X[1], N[2] * X[0] - N[0] * X[2], N[0] * X[1] - N[1] * X[0]];
// the loop as its triangles' edges walk it: clockwise seen from outside the hole's fill, so reversed for a CCW polygon
const q = pts.map((p) => [p[0] * X[0] + p[1] * X[1] + p[2] * X[2], p[0] * Y[0] + p[1] * Y[1] + p[2] * Y[2]]).reverse();
const ids = loop.slice().reverse();
let area = 0;
for (let i = 0; i < q.length; i++) { const a = q[i], b = q[(i + 1) % q.length]; area += a[0] * b[1] - b[0] * a[1]; }
if (area < 0) { q.reverse(); ids.reverse(); }
const flip = area < 0;
const live = q.map((_, i) => i);
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
let guard = 0;
while (live.length > 3 && guard++ < 4 * q.length * q.length) {
let cut = -1;
for (let i = 0; i < live.length; i++) {
const ia = live[(i + live.length - 1) % live.length], ib = live[i], ic = live[(i + 1) % live.length];
const a = q[ia], b = q[ib], c = q[ic];
if (cross(a, b, c) <= 0) continue;
let inside = false;
for (const j of live) {
if (j === ia || j === ib || j === ic) continue;
const p = q[j];
if (cross(a, b, p) >= 0 && cross(b, c, p) >= 0 && cross(c, a, p) >= 0) { inside = true; break; }
}
if (!inside) { cut = i; break; }
}
if (cut < 0) { tris.length = 0; break; }
const ia = live[(cut + live.length - 1) % live.length], ib = live[cut], ic = live[(cut + 1) % live.length];
tris.push(flip ? [ids[ic], ids[ib], ids[ia]] : [ids[ia], ids[ib], ids[ic]]);
live.splice(cut, 1);
}
if (live.length === 3 && (tris.length || q.length === 3)) { const [ia, ib, ic] = live; tris.push(flip ? [ids[ic], ids[ib], ids[ia]] : [ids[ia], ids[ib], ids[ic]]); return tris; }
}
// a fan from the loop's centre (a warped or self-crossing hole)
const c = pts.reduce((s, p) => [s[0] + p[0], s[1] + p[1], s[2] + p[2]], [0, 0, 0]).map((x) => x / pts.length);
const ci = addPoint(c);
const out = [];
for (let i = 0; i < loop.length; i++) out.push([loop[(i + 1) % loop.length], loop[i], ci]);
return out;
}
Growing flat faces
64-bit
Joins neighboring triangles into one flat face when they share a plane.
join when n_s·n_t ≥ cos 0.05° and every corner of t is within tol of s's plane; tol = 2e-6 × size + 1e-7
Normals and areas are Float64Array values computed from the decoded positions, which are a Float32Array (decodeMesh). The plane tolerance scales with the mesh size plus a fixed 1e-7.
meshimport.js
lines 644 to 671
PartCraft 0.8.1
const tol = 2e-6 * size + 1e-7, cosA = Math.cos(FLAT_ANGLE * Math.PI / 180);
const N = new Float64Array(nt * 3), A = new Float64Array(nt);
for (let t = 0; t < nt; t++) {
const a = I[t * 3], b = I[t * 3 + 1], c = I[t * 3 + 2];
const ux = P[b * 3] - P[a * 3], uy = P[b * 3 + 1] - P[a * 3 + 1], uz = P[b * 3 + 2] - P[a * 3 + 2];
const vx = P[c * 3] - P[a * 3], vy = P[c * 3 + 1] - P[a * 3 + 1], vz = P[c * 3 + 2] - P[a * 3 + 2];
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx, l = Math.hypot(nx, ny, nz);
A[t] = l / 2;
if (l > 0) { N[t * 3] = nx / l; N[t * 3 + 1] = ny / l; N[t * 3 + 2] = nz / l; }
}
const parent = new Int32Array(nt).map((_, i) => i);
const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
const edgeTris = new Map(); // edge → [t] or [t, s] (a third triangle on an edge makes it −1: not a face border we can judge)
for (let t = 0; t < nt; t++) for (let k = 0; k < 3; k++) {
const key = ekey(I[t * 3 + k], I[t * 3 + (k + 1) % 3]);
const l = edgeTris.get(key);
if (l === undefined) { edgeTris.set(key, [t]); continue; }
if (l === -1) continue;
if (l.length === 2) { edgeTris.set(key, -1); continue; }
l.push(t);
const s = l[0];
const d = N[s * 3] * N[t * 3] + N[s * 3 + 1] * N[t * 3 + 1] + N[s * 3 + 2] * N[t * 3 + 2];
if (d < cosA) continue;
// the other triangle's corners on this one's plane
const o = I[s * 3];
let ok = true;
for (let q = 0; q < 3 && ok; q++) { const v = I[t * 3 + q]; ok = Math.abs((P[v * 3] - P[o * 3]) * N[s * 3] + (P[v * 3 + 1] - P[o * 3 + 1]) * N[s * 3 + 1] + (P[v * 3 + 2] - P[o * 3 + 2]) * N[s * 3 + 2]) <= tol; }
if (ok) { const rs = find(s), rt = find(t); if (rs !== rt) parent[rs] = rt; }
Plane of a flat face
32-bit in part
The normal and origin of each flat face a sketch can sit on.
n = unit(Σ n_t A_t); origin = Σ (triangle center × A_t) ÷ Σ A_t
The plane is the area-weighted mean of the triangles' normals and centers, computed in 64-bit numbers from positions held in a Float32Array. Every corner of the region must lie within 4 × tol of that plane or the region is not offered as a face. This plane becomes the frame of sketches placed on the face.
meshimport.js
lines 704 to 707
PartCraft 0.8.1
const n = g.n.map((x) => x / nl), c = g.c.map((x) => x / g.area);
let ok = true;
for (const t of g.tris) { for (let q = 0; q < 3 && ok; q++) { const v = I[t * 3 + q]; ok = Math.abs((P[v * 3] - c[0]) * n[0] + (P[v * 3 + 1] - c[1]) * n[1] + (P[v * 3 + 2] - c[2]) * n[2]) <= 4 * tol; } if (!ok) break; }
if (ok) flats.push({ n, c, area: g.area, tris: g.tris });
Mesh kept in the part file
32-bit in part
Packs the repaired mesh into the text the part file stores.
vertices → Float32Array → base64
Float32Array.from(positions) narrows every coordinate to 32 bits before it is saved. For a mesh read from ASCII STL, OBJ, 3MF or a STEP tessellation this drops the digits beyond about 7 that the 64-bit repair had kept.
Unpacks and checks the stored mesh when a part is opened or rebuilt.
Coordinates are read with getFloat32 into a Float32Array and each is checked to be finite; indexes are checked against the vertex count. The body built from it therefore has 32-bit vertex coordinates.
meshimport.js
lines 750 to 762
PartCraft 0.8.1
/** The mesh a part file keeps, checked (untrusted): counts against the data, every index in range, every number finite. */
export function decodeMesh(m) {
if (!m || typeof m !== 'object' || !Number.isInteger(m.nv) || !Number.isInteger(m.nt) || m.nv < 3 || m.nt < 1 || m.nt > MAX_TRIS || m.nv > 3 * MAX_TRIS) fail('mesh.err.kept_bad');
if (typeof m.v !== 'string' || typeof m.i !== 'string' || m.v.length !== 4 * Math.ceil(m.nv * 12 / 3) || m.i.length !== 4 * Math.ceil(m.nt * 12 / 3)) fail('mesh.err.kept_bad');
let vb, ib;
try { vb = fromB64(m.v); ib = fromB64(m.i); } catch { fail('mesh.err.kept_bad'); }
if (vb.length !== m.nv * 12 || ib.length !== m.nt * 12) fail('mesh.err.kept_bad');
const dv = new DataView(vb.buffer), di = new DataView(ib.buffer);
const positions = new Float32Array(m.nv * 3), indices = new Uint32Array(m.nt * 3);
for (let k = 0; k < positions.length; k++) { const x = dv.getFloat32(k * 4, true); if (!Number.isFinite(x)) fail('mesh.err.kept_bad'); positions[k] = x; }
for (let k = 0; k < indices.length; k++) { const x = di.getUint32(k * 4, true); if (x >= m.nv) fail('mesh.err.kept_bad'); indices[k] = x; }
return { positions, indices };
}
In detail
Method
Welding on a hash grid; removal of degenerate and doubled triangles; closing of small gaps by welding boundary points; hole filling by ear clipping in the hole's best plane (Newell normal) with a center fan as fallback; orientation by signed shell volume; flat faces by union–find over neighbors within 0.05° and a plane tolerance.
Number types
Float64Array while reading and repairing; Float32Array in the part file and after decoding; Uint32Array indexes.
Tolerances
Weld 1e-7 × size. Gap closing 1e-3 × size by default. Triangle area must exceed 1e-14 × size². Flat hole border within 1e-5 × size. Flat faces: neighbors within 0.05°, corners within 2e-6 × size + 1e-7 of the plane (4 × that for the whole region). Holes wider than 0.25 × size are filled only when flat.
Limits
Gap closing moves boundary points up to 0.1 % of the mesh's size onto each other. Unit factors: 0.001 for micrometers, 25.4 and 304.8 for inches and feet.
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.