Manual: imported product meshes, copies and part volume
Reads a product from glTF, OBJ, STL or 3MF into a manual, scales it to millimeters, recognizes copies of one part and works out each part's volume and area.
How the precision is kept
File coordinates are read into 64-bit numbers and scaled to millimeters, then every part mesh is stored as a Float32Array, in memory and in the manual file. Volume and area are summed in 64-bit from those 32-bit corners. Unit factors are the defined ones (25.4 mm per inch, 304.8 mm per foot); glTF meters are multiplied by 1000.
Open item. Keep part meshes in a Float64Array, or carry each part's volume and area over from the source solid, so the parts list does not compute them from 32-bit corners.
Calculations under this policy
13 calculations in 2 source files. 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.
illustrate/import.jsPartCraft 0.8.1 32-bit in part8 calculationsReviewed
Tests tools/partcraft-test/test-illustrate.mjs
File unit to millimeters
Exact
The factor from a file's unit to millimeters.
μm 0.001; mm 1; cm 10; m 1000; in 25.4; ft 304.8
The inch and foot factors are the defined values. OBJ and STL positions are scaled through a scale matrix, and 3MF placements have their moves scaled by scaleTranslation.
Reads a glTF accessor's numbers, including normalized integers.
normalized integer: max(x ÷ max, −1)
Values are read into a Float64Array. Normalized integers are divided by 127, 255, 32767 or 65535 and limited below at −1; float components are read as 32-bit, which is the accessor's own type.
illustrate/import.js
lines 156 to 157
PartCraft 0.8.1
const norm = a.normalized && max ? (x) => Math.max(x / max, -1) : (x) => x;
for (let k = 0; k < a.count; k++) for (let c = 0; c < nc; c++) out[k * nc + c] = norm(get(aOff + k * stride + c * size));
Mesh as stored
32-bit in part
A part's corner positions as the manual keeps them.
The scaled 64-bit coordinates are converted to a Float32Array here. Every value is then checked to be finite and within ±1e7 mm.
illustrate/import.js
lines 59 to 64
PartCraft 0.8.1
// ---------------------------------------------------------------------------------------------- shared helpers
function meshFrom(pos, idx) {
const positions = Float32Array.from(pos), indices = Uint32Array.from(idx);
for (let i = 0; i < positions.length; i++) if (!Number.isFinite(positions[i]) || Math.abs(positions[i]) > 1e7) fail('bad_numbers');
return { positions, indices };
}
Weld grid for splitting shells
64-bit
Merges corners at the same place so separate pieces of a mesh can be told apart.
Corners merge when their coordinates round to the same multiple of q. Two corners closer than q that fall either side of a rounding boundary stay separate.
illustrate/import.js
lines 86 to 88
PartCraft 0.8.1
const q = Math.max(hi - lo, 1e-9) * 1e-7;
const weld = new Int32Array(nv), seen = new Map();
for (let i = 0; i < nv; i++) { const key = Math.round(P[i * 3] / q) + ',' + Math.round(P[i * 3 + 1] / q) + ',' + Math.round(P[i * 3 + 2] / q); let w = seen.get(key); if (w === undefined) { w = i; seen.set(key, i); } weld[i] = w; }
Part signature
32-bit in part
A short key (corner count, triangle count, area, volume) that groups parts that may be copies.
Area and signed volume are summed in 64-bit from the Float32Array corners, then written with toPrecision(5) into the key. The key only groups candidates; the rigid fit decides.
illustrate/import.js
lines 439 to 451
PartCraft 0.8.1
// ---------------------------------------------------------------------------------------------- copies of one part
function signature(mesh) {
const P = mesh.positions, I = mesh.indices;
let area = 0, vol = 0;
for (let k = 0; k < I.length; k += 3) {
const a = I[k] * 3, b = I[k + 1] * 3, c = I[k + 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];
area += Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx) / 2;
vol += (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;
}
const r = (x) => (x === 0 ? '0' : x.toPrecision(5));
return P.length / 3 + '/' + I.length / 3 + '/' + r(area) + '/' + r(Math.abs(vol));
}
Recognizing copies of a part
64-bit
Folds parts that are rigid moves of one another into one part with several placements.
copy when fit error ≤ 1e-5 × size and largest corner difference ≤ 4e-5 × size
Size is the largest coordinate difference from the part's first corner, floored at 1e-9. The copy's move is multiplied into its instances' matrices with matMul, so each placement carries the fit's rounding.
illustrate/import.js
lines 452 to 488
PartCraft 0.8.1
/**
* Fold copies of one part into one: two parts whose meshes have the same corners in the same order, one a rigid move of the
* other (within tol of the size), become one part; the copy's instances get that move folded into their matrix.
*/
export function sameParts(product, { tol = 1e-5 } = {}) {
const sig = new Map();
for (const [pid, p] of product.parts) { const s = signature(p.mesh); (sig.get(s) || sig.set(s, []).get(s)).push(pid); }
const into = new Map(); // copy pid → { pid, m (copy = m · canonical) }
for (const pids of sig.values()) {
if (pids.length < 2) continue;
const reps = [];
for (const pid of pids) {
const P = product.parts.get(pid).mesh.positions;
let size = 0;
for (let i = 3; i < P.length; i += 3) size = Math.max(size, Math.abs(P[i] - P[0]), Math.abs(P[i + 1] - P[1]), Math.abs(P[i + 2] - P[2]));
let found = null;
for (const r of reps) {
const R = product.parts.get(r).mesh;
if (!sameTriangles(R.indices, product.parts.get(pid).mesh.indices)) continue;
const fit = rigidFit(sample(R.positions), sample(P));
if (fit.err > tol * Math.max(size, 1e-9)) continue;
if (maxError(fit.m, R.positions, P) > tol * Math.max(size, 1e-9) * 4) continue;
found = { pid: r, m: fit.m };
break;
}
if (found) into.set(pid, found); else reps.push(pid);
}
}
if (!into.size) return { product, folded: 0 };
const parts = new Map([...product.parts].filter(([pid]) => !into.has(pid)));
const instances = product.instances.map((i) => {
const f = into.get(i.part);
return f ? { ...i, part: f.pid, matrix: matMul(i.matrix, f.m) } : i;
});
for (const [, f] of into) { const p = parts.get(f.pid); if (p) p.name = commonName(p.name); }
return { product: { ...product, parts, instances }, folded: into.size };
}
Largest corner difference after a move
64-bit
How far the moved corners of one mesh are from another's.
The largest absolute coordinate difference per axis, not the distance, over all corners.
illustrate/import.js
lines 504 to 508
PartCraft 0.8.1
function maxError(m, A, B) {
let e = 0;
for (let i = 0; i < A.length; i += 3) { const p = apply(m, [A[i], A[i + 1], A[i + 2]]); e = Math.max(e, Math.abs(p[0] - B[i]), Math.abs(p[1] - B[i + 1]), Math.abs(p[2] - B[i + 2])); }
return e;
}
Placement moved to millimeters
64-bit
A 3MF placement with its move in millimeters.
Only the three translation entries are multiplied by the unit factor; the rotation part is unchanged.
illustrate/import.js
lines 436 to 437
PartCraft 0.8.1
/** A transform written in a file's unit, with its move in millimetres (meshes are scaled to mm as they are read). */
function scaleTranslation(M, s) { const m = [...M]; m[12] *= s; m[13] *= s; m[14] *= s; return m; }
illustrate/manual.jsPartCraft 0.8.1 32-bit in part5 calculationsReviewed
Tests tools/partcraft-test/test-illustrate.mjs
Mesh in the manual file
32-bit in part
A part's mesh as written into the manual file.
Positions are written as 32-bit floats in base64 (Float32Array.from). decodeMesh reads them back as a Float32Array.
How far each part has moved at a point in an exploded view.
offset = Σ dir × dist × clamp(t × n − k, 0, 1) over the steps k
Steps play one after another; an instance in several steps has its offsets added in step order.
illustrate/manual.js
lines 373 to 384
PartCraft 0.8.1
/** Offsets of an exploded view at t (explode.js's rule: the steps play one after another, each moving dir · dist). */
export function explodeOffsets(ex, tt = 1) {
const out = new Map();
if (!ex) return out;
const n = ex.steps.length;
ex.steps.forEach((s, k) => {
const p = Math.max(0, Math.min(1, tt * n - k));
if (!p) return;
for (const id of s.insts) out.set(id, add(out.get(id) || [0, 0, 0], mul(s.dir, s.dist * p)));
});
return out;
}
Where each part is drawn
64-bit
Each instance's matrix in a view.
M = translation(explode offset + view move) × placement
The two offsets are added first, then one matMul with the placement matrix. An instance with no offset keeps its placement matrix unchanged.
illustrate/manual.js
lines 385 to 397
PartCraft 0.8.1
/** Where each instance is drawn in a view: Map(inst → matrix) = translation(explode offset + the view's own move) · placement. */
export function worldOf(m, view) {
const ex = view?.explode ? m.explodes.find((e) => e.id === view.explode.ref) : null;
const offs = explodeOffsets(ex, view?.explode?.t ?? 0);
const out = new Map();
for (const i of m.instances) {
let o = offs.get(i.id) || null;
const mv = view?.moves?.[i.id];
if (mv) o = o ? add(o, mv) : mv;
out.set(i.id, o ? matMul(translation(o), i.matrix) : i.matrix);
}
return out;
}
Moving parts in a view
64-bit
Adds a move to parts in one view.
The move is added to any earlier move; a total shorter than 1e-9 mm is removed.
illustrate/manual.js
lines 521 to 527
PartCraft 0.8.1
/** Move instances in a view by d (mm), on top of any explode. */
export function moveInstances(view, ids, d) {
for (const id of ids) {
const o = add(view.moves[id] || [0, 0, 0], d);
if (len(o) < 1e-9) delete view.moves[id]; else view.moves[id] = o;
}
}
Part volume and area
32-bit in part
The volume and surface area of a part's mesh.
V = |Σ a·(b × c) ÷ 6|; A = Σ |(b − a) × (c − a)| ÷ 2
Corners are read from the manual's Float32Array mesh; the sums run in 64-bit in triangle order. The volume terms are measured from the origin, so a part that sits far from its own origin loses further digits. parts-list.js reads this result.
illustrate/manual.js
lines 569 to 581
PartCraft 0.8.1
/** Volume (mm³) and area of a closed mesh under a matrix (signed volume; |·| for badly wound meshes). */
export function meshStats(mesh) {
const P = mesh.positions, I = mesh.indices;
let vol = 0, area = 0;
for (let k = 0; k < I.length; k += 3) {
const a = I[k] * 3, b = I[k + 1] * 3, c = I[k + 2] * 3;
const ax = P[a], ay = P[a + 1], az = P[a + 2], bx = P[b], by = P[b + 1], bz = P[b + 2], cx = P[c], cy = P[c + 1], cz = P[c + 2];
vol += (ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx)) / 6;
const ux = bx - ax, uy = by - ay, uz = bz - az, vx = cx - ax, vy = cy - ay, vz = cz - az;
area += Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx) / 2;
}
return { volume: Math.abs(vol), area };
}
In detail
Method
Copies: two meshes with the same triangles are compared by the best rigid move (m4.js rigidFit) on up to 3,000 sampled corners and then on all corners. Volume by signed tetrahedra to the origin; area by half the cross product.
Number types
64-bit numbers while reading; Float32Array meshes; 64-bit sums; instance matrices as sixteen 64-bit numbers.
Tolerances
Copy test: fit error ≤ 1e-5 × the part's size, then every corner within 4 times that. Shell split welds corners on a grid of 1e-7 × the coordinate range. Coordinates beyond ±1e7 mm are refused. A view move shorter than 1e-9 mm is dropped.
Limits
glTF float accessors and binary STL are 32-bit in the file already; OBJ, ASCII STL and 3MF text can carry more digits than the stored mesh keeps. The signature that groups candidate copies rounds area and volume to 5 significant digits.
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.