Fillet, chamfer, draft, thread and mesh bodies on the mesh kernel
Builds rounds, bevels, drafted faces, threads and imported mesh bodies from the body as it is at that point in the history.
How the precision is kept
These features read the body's current mesh back from manifold-3d into a Float32Array (bodyFaces) and take edge ends, face outlines and thread extents from it, so the tools they build start from coordinates with 32-bit precision. Face normals, plane origins and cylinder axes come from the features' own named surfaces in 64-bit numbers. An imported mesh is handed to the kernel as a Float32Array. The fillet, chamfer and shell are approximations by design, as the code's comments say.
Open item. Read the body mesh in 64-bit (or take edge ends and outlines from the features' own surfaces) before building fillet, draft and thread tools, and pass imported vertices to the kernel in 64-bit.
Calculations under this policy
9 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.
kernel.jsPartCraft 0.8.1 32-bit in part9 calculationsReviewed
Tests bin/test-partcraft.mjs
Body mesh read back for named faces and edges
32-bit in part
Copies the current body's vertices and triangles so its faces and edges can be named and measured by later features.
The vertex positions are copied from the kernel's mesh into a new Float32Array. Edge ends, edge lengths and face outlines used by the fillet, chamfer, draft and thread features are derived from this array.
kernel.js
lines 138 to 156
PartCraft 0.8.1
/** The named faces and edges of the body as it is now (K.2b): its mesh, each triangle's tool, meshfaces.js. */
function bodyFaces(ctx) {
const gm = ctx.body.getMesh();
const np = gm.numProp, nv = gm.vertProperties.length / np;
const positions = new Float32Array(nv * 3);
for (let i = 0; i < nv; i++) for (let k = 0; k < 3; k++) positions[i * 3 + k] = gm.vertProperties[i * np + k];
const indices = new Uint32Array(gm.triVerts);
const runs = gm.runIndex, ids = gm.runOriginalID;
const lists = [], listOf = new Map(), triTool = new Int32Array(indices.length / 3).fill(-1);
for (let r = 0; r + 1 < runs.length; r++) {
const sf = ctx.toolSurfs.get(ids[r]);
if (!sf) continue;
let k = listOf.get(ids[r]);
if (k === undefined) { k = lists.length; listOf.set(ids[r], k); lists.push(sf); }
for (let ti = runs[r] / 3; ti < runs[r + 1] / 3; ti++) triTool[ti] = k;
}
const mesh = { positions, indices };
return { mesh, ...MF.namedFaces(mesh, triTool, lists, { segments: ctx.part.resolution || 64, triHint: faceHintsOf(gm, ctx.faceHints) }) };
}
Fillet tangent points and arc
32-bit in part
Where a round of radius r touches the two faces of an edge, its center, and the points of its arc.
The corner point O and the edge direction come from the edge's two ends, which are 32-bit mesh vertices; the face normals are 64-bit. The acos argument is clamped to −1…1. The arc is a polyline with at least 2 steps, its count set by the part's resolution, so the round is faceted.
kernel.js
lines 209 to 217
PartCraft 0.8.1
const k1 = 1 - V.dot(nA, nB), sA = (s * size * k1) / V.dot(uA, nB), sB = (s * size * k1) / V.dot(uB, nA);
if (!(sA > 0 && sB > 0) || sA > L * 1e3) fail(t('model.brep.round_no_fit', { size: size }));
tA = V.add(O, V.mul(uA, sA)); tB = V.add(O, V.mul(uB, sB));
const c = V.add(V.add(O, V.mul(uA, sA)), V.mul(nA, s * size));
// the arc from tA to tB the short way, on the side facing the corner
const v0 = V.sub(tA, c), v1 = V.sub(tB, c), ax = V.norm(V.cross(v0, v1));
const ang = Math.acos(Math.max(-1, Math.min(1, V.dot(v0, v1) / (size * size))));
const n = Math.max(2, Math.ceil(ang / (2 * Math.PI) * seg));
for (let i = 1; i < n; i++) arc.push(V.add(c, V.applyMat(V.rotationAbout([0, 0, 0], ax, (ang * i / n) * 180 / Math.PI), v0)));
Chamfer distances
32-bit in part
The two setback distances of a chamfer, for equal, two-distance and distance-and-angle modes.
θ = acos(u_a·u_b); angle mode: d_b = d × sin α ÷ sin(π − α − θ)
The acos argument is clamped. In angle mode the sine in the denominator is not guarded against zero; a result that is not positive is refused. The chamfer starts from the corner point O, a 32-bit mesh vertex.
kernel.js
lines 222 to 227
PartCraft 0.8.1
const theta = Math.acos(Math.max(-1, Math.min(1, V.dot(ua, ub))));
let da = size, db = size;
if (f.mode === 'two') db = f.d2;
else if (f.mode === 'angle') { const al = f.angle * Math.PI / 180; db = size * Math.sin(al) / Math.sin(Math.PI - al - theta); }
if (!(db > 0)) fail(t('model.brep.chamfer_no_fit', { size }));
const pa = V.add(O, V.mul(ua, da)), pb = V.add(O, V.mul(ub, db));
Section and prism of a fillet or chamfer
32-bit in part
The cross-section that is extruded along the edge and cut from (or added to) the body.
section pushed past both faces by 0.01 × size; prism length = edge length
The section is deliberately extended 1 % of the radius or distance beyond the two faces so the boolean does not meet them face to face. Its orientation is fixed by the sign of a shoelace sum. The prism's length and placement come from the 32-bit edge ends.
kernel.js
lines 233 to 240
PartCraft 0.8.1
const eps = 0.01 * size, out = V.mul(V.add(nA, nB), -s * eps);
const poly = [V.add(O, out), V.sub(tA, V.mul(nA, s * eps)), tA, ...arc, tB, V.sub(tB, V.mul(nB, s * eps))];
const X = V.norm(V.sub(tA, O)), Y = V.cross(d, X);
let loop = poly.map((p) => [V.dot(V.sub(p, O), X), V.dot(V.sub(p, O), Y)]);
let ar = 0; for (let i = 0; i < loop.length; i++) { const q = loop[i], r = loop[(i + 1) % loop.length]; ar += q[0] * r[1] - r[0] * q[1]; }
if (ar < 0) loop = loop.reverse();
const cs = ctx.T(new ctx.M.CrossSection([loop], 'Positive'));
const prism = ctx.T(ctx.T(cs.extrude(L)).transform(V.basisMatrix(X, Y, d, O)));
Shell by offsetting the body
64-bit
Hollows the body to a wall thickness, or thickens it outward, and opens the picked flat faces.
inward: body − (body ⊖ ball(t)); outward: (body ⊕ ball(t)) − body
The offset is a Minkowski sum or difference with a sphere of the wall thickness that has between 8 and 32 segments, so the offset distance is the ball's faceted radius, not exactly t in every direction. Each opened face is cut through by sweeping the removed solid 1.5 × t + 0.001 along the face normal with a bar 1e-7 × t wide. The offset is cached under a key that includes the volume to 12 significant digits.
kernel.js
lines 256 to 306
PartCraft 0.8.1
/**
* Shell on the mesh kernel (0.4 K.5): inward, the body less its erosion by a ball of radius t (the true inward offset:
* sharp where the body has outside corners, rounded at inside ones); outward, the body's dilation less the body. Each
* opened flat face is cut through by sweeping the removed solid along that face's normal past it. The inner faces are named
* `<face>~<feature>` as brep.js names them (planes and cylinders offset by t). Faces may only be flat to be opened.
*/
function shellTools(f, ctx) {
if (!ctx.body) fail(t('model.brep.no_body_shell'));
if (!(f.t > 0)) fail(t('model.brep.wall_positive'));
if (!f.faces.length) fail(t('model.brep.shell_pick_faces'));
const B = bodyFaces(ctx);
const open = f.faces.map((ref) => {
const i = findFace(B.faces, ref);
if (i < 0) fail(t('model.brep.shell_face_gone', { face: ref.name }));
if (B.faces[i].type !== 'plane') fail(t('kernel.mesh.shell_flat'));
return B.faces[i];
});
const M = ctx.M, th = f.t;
const key = f.id + '|' + th + '|' + !!f.outward + '|' + ctx.body.numTri() + '|' + ctx.body.volume().toPrecision(12);
let off = offsetCache.get(key);
if (!off) {
const ball = M.Manifold.sphere(th, Math.max(8, Math.min(32, (ctx.part.resolution || 64) / 2)));
off = f.outward ? ctx.body.minkowskiSum(ball) : ctx.body.minkowskiDifference(ball);
ball.delete();
offsetCache.set(key, off);
if (offsetCache.size > OFFSET_CACHE_MAX) { const k0 = offsetCache.keys().next().value; try { offsetCache.get(k0).delete(); } catch { /* gone */ } offsetCache.delete(k0); }
}
if (off.isEmpty()) fail(t('model.brep.shell_thickness'));
// what is taken away: the eroded inside (inward) or the body itself (outward), swept out through each opened face
let hollow = f.outward ? ctx.body : off;
for (const fc of open) {
const n = fc.n, len = th * 1.5 + 1e-3;
const bar = ctx.T(ctx.T(M.Manifold.cube([1e-7 * th, 1e-7 * th, len], true)).transform(V.basisMatrix(...(() => { const x = V.norm(V.cross(n, Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0])); return [x, V.cross(n, x), n]; })(), V.mul(n, len / 2))));
hollow = ctx.T(hollow.add(ctx.T(hollow.minkowskiSum(bar))));
}
// the offset faces: each named face of the body moved by t (in for inward, out for outward), named as brep.js names them;
// a cylinder moves to r + t or r − t depending on whether it is a boss or a hole: both are offered, the facets pick one
const s = f.outward ? 1 : -1, moved = [], kept = [];
for (const fc of B.faces) {
if (!fc.name) continue;
const nm = `${fc.name}~${f.id}`; // i18n-ignore: a face name (identifier)
if (fc.type === 'plane') { moved.push(MF.plane(nm, fc.n, V.add(fc.o, V.mul(fc.n, s * th)))); kept.push(MF.plane(fc.name, fc.n, fc.o)); }
else if (fc.type === 'cylinder') {
moved.push(MF.cylinder(nm, fc.axis.o, fc.axis.d, fc.r + th), MF.cylinder(nm, fc.axis.o, fc.axis.d, Math.max(1e-9, fc.r - th)));
kept.push(MF.cylinder(fc.name, fc.axis.o, fc.axis.d, fc.r));
}
}
// outward: the dilated body, then the original (opened) taken out of it, its faces keeping their names; inward: the hollow cut
if (f.outward) return { tools: [{ m: off, op: 'add', surfs: moved }, { m: hollow, op: 'cut', surfs: kept }] };
return { tools: [{ m: hollow, op: 'cut', surfs: moved }] };
}
Draft: new normal and hinge line
64-bit
The plane a drafted face turns to, and the line where the face meets the neutral plane.
n₂ = unit(n_perp × cos a + pull × sin a); q = n × (d₁ − d₂ n·m) ÷ (1 − (n·m)²) + m × (d₂ − d₁ n·m) ÷ (1 − (n·m)²)
The face normal and origin are the named surface's 64-bit values. The denominator 1 − (n·m)² subtracts nearly equal numbers when the face is close to parallel with the neutral plane; below 1e-12 the draft is refused.
kernel.js
lines 333 to 340
PartCraft 0.8.1
const np = V.sub(n, V.mul(pull, V.dot(n, pull)));
if (V.len(np) < 1e-9) fail(t('model.brep_design.draft_neutral', { face: ref.name }));
const n2 = V.norm(V.add(V.mul(V.norm(np), Math.cos(a)), V.mul(pull, Math.sin(a))));
// the hinge: where the face's plane meets the neutral plane
const m = neutral.n, d1 = c0, d2 = V.dot(neutral.o, m), nm = V.dot(n, m), den = 1 - nm * nm;
if (Math.abs(den) < 1e-12) fail(t('model.brep_design.draft_neutral', { face: ref.name }));
const q = V.add(V.mul(n, (d1 - d2 * nm) / den), V.mul(m, (d2 - d1 * nm) / den));
const c1 = V.dot(q, n2);
Draft: outline of the face
32-bit in part
The outline of the drafted face, extruded into the prism that is trimmed by the old and new planes.
The outline points are the face's boundary vertices read from the 32-bit mesh array, projected into the face plane with 64-bit dot products. The prism is extruded twice the body's bounding-box diagonal each way.
kernel.js
lines 357 to 362
PartCraft 0.8.1
for (let v = st, g = 0; v !== undefined && !used.has(v) && g < next.size + 1; v = next.get(v), g++) { used.add(v); const w = V.sub([P[v * 3], P[v * 3 + 1], P[v * 3 + 2]], fc.o); loop.push([V.dot(w, X), V.dot(w, Y)]); }
if (loop.length >= 3) loops.push(loop);
}
if (!loops.length) fail(t('model.brep_design.draft_failed'));
const L = size * 2;
const prism = ctx.T(ctx.T(ctx.T(new ctx.M.CrossSection(loops, 'EvenOdd')).extrude(2 * L)).transform(V.basisMatrix(X, Y, n, V.sub(fc.o, V.mul(n, L)))));
Thread: extent of the round face
32-bit in part
Where a picked cylinder face starts and ends along its axis and whether it is a shaft or a bore, which set the thread's start, length and callout.
t₀, t₁ = min and max of (p − o)·d over the face's vertices; full length = t₁ − t₀
The extent is taken from the face's vertices in the 32-bit mesh array, so the thread's start point and its full length carry 32-bit precision; that length is used in the callout when no length is given. Shaft or bore is decided by the sign of a sum of normal components, with the radial length guarded by max(1e-30, …).
kernel.js
lines 398 to 408
PartCraft 0.8.1
let t0 = Infinity, t1 = -Infinity, out = 0;
for (let ti = 0; ti < I.length / 3; ti++) {
if (B.triFace[ti] !== fi) continue;
const q = [0, 1, 2].map((k) => [P[I[ti * 3 + k] * 3], P[I[ti * 3 + k] * 3 + 1], P[I[ti * 3 + k] * 3 + 2]]);
for (const p of q) { const h = V.dot(V.sub(p, o), d); t0 = Math.min(t0, h); t1 = Math.max(t1, h); }
const c = V.mul(V.add(V.add(q[0], q[1]), q[2]), 1 / 3), n = V.cross(V.sub(q[1], q[0]), V.sub(q[2], q[0]));
const v = V.sub(c, o), rad = V.sub(v, V.mul(d, V.dot(v, d)));
out += V.dot(n, rad) / Math.max(1e-30, V.len(rad));
}
if (!(t1 > t0)) fail(t('thread.err.round'));
const external = out > 0;
Mesh body from an imported file
32-bit in part
Scales an imported mesh to millimeters, turns it Y up and makes it a solid body.
(x, y, z) → (x × s, z × s, −y × s) for a Z-up file; s = unit factor
The scaled coordinates are written into a Float32Array before the solid is made, and the kept mesh they come from is already 32-bit (meshimport.js decodeMesh). Flat-face origins and normals are taken from the stored planes in 64-bit numbers.
kernel.js
lines 752 to 788
PartCraft 0.8.1
/**
* A body from a mesh (0.7 D7.49): the repaired mesh the file keeps (meshimport.js), scaled to mm and turned Y up, made a manifold
* with a reserved original id and each triangle's flat face as its faceID, so after any boolean every triangle still says which
* flat face it lies on (faceHintsOf). Flat faces are named `<feature>.flat:<k>` and offered to sketches as candidates.
*/
function meshImport(f, ctx) {
if (!f.mesh) fail(t('kernel.feature.not_built', { label: FEATURE_LABELS.import }));
let src;
try { src = meshOf(f.mesh); } catch { fail(t('kernel.mesh.import_bad')); }
const s = UNITS[f.units] ?? 1, zup = f.up !== 'y';
const at = (x, y, z) => (zup ? [x * s, z * s, -y * s] : [x * s, y * s, z * s]);
const dir = (x, y, z) => (zup ? [x, z, -y] : [x, y, z]);
const P = src.positions, vp = new Float32Array(P.length);
for (let i = 0; i < P.length; i += 3) { const q = at(P[i], P[i + 1], P[i + 2]); vp[i] = q[0]; vp[i + 1] = q[1]; vp[i + 2] = q[2]; }
const { triPlane, planes } = src.flats;
const K = planes.length, nt = src.indices.length / 3;
const faceID = new Uint32Array(nt);
for (let ti = 0; ti < nt; ti++) faceID[ti] = triPlane[ti] >= 0 ? triPlane[ti] : K + ti;
const oid = ctx.M.Manifold.reserveIDs(1);
let m;
try {
m = ctx.T(new ctx.M.Manifold(new ctx.M.Mesh({ numProp: 3, vertProperties: vp, triVerts: src.indices, faceID, runIndex: new Uint32Array([0, nt * 3]), runOriginalID: new Uint32Array([oid]) })));
} catch {
const r = f.report || {};
fail(r.openEdges || r.nonManifold ? t('kernel.mesh.import_open', { n: r.openEdges || 0, m: r.nonManifold || 0 }) : t('kernel.mesh.import_not_solid'));
}
if (m.status() !== 'NoError' || m.isEmpty()) fail(t('kernel.mesh.import_not_solid'));
const op = f.op || 'add', out = op === 'cut' ? -1 : 1; // a cut's faces point back into the tool
const surfs = [], cands = [];
planes.forEach((p, k) => {
const n = dir(...p.n), o = at(...p.o);
surfs.push({ ...MF.plane(`${f.id}.flat:${k}`, n, o), noSpread: true }); // i18n-ignore: a face name (identifier)
cands.push({ ref: { face: 'flat', entity: String(k) }, frame: V.frameFor(V.mul(n, out), o) });
});
ctx.candidates.set(f.id, cands);
return { tools: [{ m, op, surfs, oid, faceHint: K }] };
}
In detail
Method
Fillet and chamfer: a prism along a straight edge between two flat faces, its section the corner region up to the arc or bevel line, pushed 1 % of the size past both faces. Shell: Minkowski sum or difference with a faceted ball of the wall thickness. Draft: a prism over the face's outline trimmed by the old and the new plane. Thread: extent of a cylinder face along its axis, then a helical tool from threads.js.
Number types
Float32Array for the body mesh read back from the kernel and for an imported mesh's vertices; 64-bit numbers for the arithmetic done on them.
Tolerances
Edge membership 1e-5 × max(1, edge length). Fillet rejected when a tangent distance is not positive or the first one exceeds 1000 × the edge length. Draft: face parallel to the pull below 1e-9; hinge denominator below 1e-12. Thread longer than the face by more than 1e-6 is shortened. Mitre warning when edge ends are within 1e-6 × max(1, largest end distance from the origin).
Limits
Straight edges between flat faces only; meeting rounds are mitred, not blended. The shell's ball has 8 to 32 segments, so the wall thickness varies with direction between the ball's vertices and its facets. Opened shell faces are swept by a bar 1e-7 × thickness wide.
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.