Matches each triangle of the built body to the surface that made it and reports each face's area and each edge's length, ends and circle.
How the precision is kept
The surfaces themselves (planes, cylinders, cones, spheres, tori) are kept in 64-bit numbers from the sketch and feature values. The matching, the face areas and the edge lengths are computed in 64-bit arithmetic from mesh positions that kernel.js supplies as a Float32Array; the code sizes its tolerance for that (its comment: float32 positions round to about 1e-7 of their size). A face's area is the sum of its facets and an edge's length is the sum of its segments.
Open item. Take the mesh positions in 64-bit and report a round face's area and a circular edge's length from the surface's radius and angle instead of from the facets.
Calculations under this policy
7 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.
meshfaces.jsPartCraft 0.8.1 32-bit in part7 calculationsReviewed
Tests tools/partcraft-test/test-meshfaces.mjs
Matching tolerance and chord sag
64-bit
The distance within which a triangle's corners count as lying on a surface.
sag = 1.5 × (1 − cos(π ÷ max(8, segments))); tol = 2e-6 × diagonal + 1e-7
The tolerance scales with the body's bounding-box diagonal plus a fixed 1e-7, and the code's comment ties it to the rounding of 32-bit positions. For large segment counts 1 − cos subtracts nearly equal numbers; the result is only used as an allowance.
meshfaces.js
lines 96 to 102
PartCraft 0.8.1
const sag = 1.5 * (1 - Math.cos(Math.PI / Math.max(8, segments))); // segments: the part's facets per full turn
const P = mesh.positions, I = mesh.indices, nt = I.length / 3;
const pt = (i) => [P[i * 3], P[i * 3 + 1], P[i * 3 + 2]];
let lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i < P.length; i += 3) for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k], P[i + k]); hi[k] = Math.max(hi[k], P[i + k]); }
const size = Math.max(1e-9, Math.hypot(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]));
const tol = 2e-6 * size + 1e-7; // float32 positions round to ~1e-7 of their size
Triangle on a surface
64-bit
Decides whether a triangle lies on a plane, cylinder, cone, sphere, torus or thread zone.
plane: |(p − o)·n| ≤ tol; cylinder: −(tol + sag × r) ≤ ρ − r ≤ tol + 1e-6 × r
Yes-or-no tests in 64-bit arithmetic with the tolerance above; round surfaces accept points up to one chord sag inside. The decisions are not exact predicates: a point within the tolerance of two surfaces goes to the first type tried (plane, cylinder, cone, sphere, torus, thread).
meshfaces.js
lines 67 to 93
PartCraft 0.8.1
/** Does triangle (points P, unit normal nt) lie on surface s? tol: distance in model units; sag: the chord sag of a round
* surface as a fraction of its radius (a boolean puts new points on the facets, up to one sag inside the true surface). */
function onSurface(s, P, nt, tol, sag) {
// bounds tell coincident surfaces apart (two counterbore floors in one plane, two collinear sketch lines): the triangle's
// centre must lie within the hole's radius of its axis, or along its sketch line's extent
if (s.within) { const c = V.mul(P.reduce((q, p) => V.add(q, p), [0, 0, 0]), 1 / 3); if (radial(c, s.within)[0] > s.within.r + tol) return false; }
if (s.span) { const c = V.mul(P.reduce((q, p) => V.add(q, p), [0, 0, 0]), 1 / 3); const u = V.dot(sub(c, s.span.a), s.span.d); if (u < -tol || u > s.span.len + tol) return false; }
// nt null: a needle triangle (a boolean leaves some with two points in one place) whose normal means nothing: points only
if (s.type === 'plane') return (!nt || Math.abs(V.dot(nt, s.n)) > 0.9) && P.every((p) => Math.abs(V.dot(sub(p, s.o), s.n)) <= tol);
if (s.type === 'cylinder') {
if (nt && Math.abs(V.dot(nt, s.axis.d)) > 0.5) return false; // a cap: its points can lie on the cylinder too
return P.every((p) => { const d = radial(p, s.axis)[0] - s.r; return d <= tol + 1e-6 * s.r && d >= -(tol + sag * s.r); });
}
if (s.type === 'cone') {
if (nt && Math.abs(V.dot(nt, s.axis.d)) > 0.999) return false; // a flat cap, not the cone
return P.every((p) => { const [rho, h] = radial(p, s.axis); const d = rho - h * s.k; return h >= -tol && d <= tol * (1 + s.k) && d >= -(tol * (1 + s.k) + sag * h * s.k); });
}
if (s.type === 'sphere') return P.every((p) => { const d = Math.hypot(...sub(p, s.c)) - s.r; return d <= tol && d >= -(tol + sag * s.r); });
if (s.type === 'torus') {
return P.every((p) => { const [rho, h] = radial(p, s.axis); const d = Math.hypot(rho - s.R, h) - s.r; return d <= tol && d >= -(tol + sag * (s.R + s.r)); });
}
if (s.type === 'thread') {
if (nt && Math.abs(V.dot(nt, s.axis.d)) > 0.99) return false; // a flat end, not the thread
return P.every((p) => { const [rho, h] = radial(p, s.axis); return rho >= s.r0 - tol - sag * s.r0 && rho <= s.r1 + tol && h >= s.t0 - tol && h <= s.t1 + tol; });
}
return false;
}
Face area and center
32-bit in part
The area of each named face and its area-weighted center.
area = Σ |(b − a) × (c − a)| ÷ 2; center = Σ (triangle center × area) ÷ area
Triangle areas are 64-bit values computed from the 32-bit mesh positions and added in triangle order. For a cylinder, cone, sphere or torus face this is the area of the facets, not of the curved surface.
The length of an edge between two faces, its two ends and its middle point.
len = Σ |pᵢ − pᵢ₋₁|; middle = the point at len ÷ 2 along the polyline
The edge is a chain of mesh vertices read from the 32-bit position array; its length is the sum of the segment lengths, so a circular edge reports the perimeter of its polygon. The middle is interpolated on the segment that contains half the length, with the divisor guarded by max(1e-30, …).
meshfaces.js
lines 234 to 240
PartCraft 0.8.1
const pts = chain.map(pt);
let len = 0;
const cum = [0];
for (let i = 1; i < pts.length; i++) { len += Math.hypot(...sub(pts[i], pts[i - 1])); cum.push(len); }
let mi = 1; while (mi < cum.length - 1 && cum[mi] < len / 2) mi++;
const f0 = (cum[mi] - len / 2) / Math.max(1e-30, cum[mi] - cum[mi - 1]);
const at = V.add(V.mul(pts[mi - 1], f0), V.mul(pts[mi], 1 - f0));
Straight edge or circle
32-bit in part
Classifies an edge as a line, a circle or a curve and gives a circle its center, axis and radius.
line: every point within 10 × tol of the chord; circle: every point at one radius and one height about a round face's axis
The radius is the cylinder's own 64-bit radius when a cylinder face is within 10 × tol + sag × r of the largest vertex radius; otherwise it is the largest vertex radius, or for a closed loop on other faces the mean distance of the vertices from their mean point, both from 32-bit positions.
meshfaces.js
lines 244 to 260
PartCraft 0.8.1
const dir = V.sub(pts[pts.length - 1], pts[0]), dl = Math.hypot(...dir);
if (dl > tol && pts.every((p) => Math.hypot(...V.cross(sub(p, pts[0]), V.mul(dir, 1 / dl))) <= tol * 10)) rec.type = 'line';
else {
const A = faceRecs[fa], B = faceRecs[fb];
// a round face's axis: if every point sits at one radius and one height about it, the edge is (part of) a circle
const ax = [A, B].find((x) => x.type === 'cylinder' || x.type === 'cone' || x.type === 'torus')?.axis;
const rh = ax ? pts.map((p) => radial(p, ax)) : null;
const rmax = rh ? Math.max(...rh.map((x) => x[0])) : 0; // facet corners sit on the circle, boolean-made points up to a sag inside
if (rh && rh.every(([r, h]) => r <= rmax + tol && r >= rmax - tol * 10 - sag * rmax && Math.abs(h - rh[0][1]) <= tol * 10) && rmax > tol) {
const cyl = [A, B].find((x) => x.type === 'cylinder' && x.axis === ax); // a cylinder's radius is exact (from the sketch)
rec.type = 'circle'; rec.circ = { c: V.add(ax.o, V.mul(ax.d, rh[0][1])), n: [...ax.d], r: cyl && Math.abs(cyl.r - rmax) <= tol * 10 + sag * cyl.r ? cyl.r : rmax };
} else if (chain[0] === chain[chain.length - 1] && pts.length > 4) {
// any other closed, flat loop whose points all sit at one distance from their centre (a cone meeting a plane …)
const ring = pts.slice(0, -1), c = V.mul(ring.reduce((q, p) => V.add(q, p), [0, 0, 0]), 1 / ring.length);
const r = ring.reduce((q, p) => q + Math.hypot(...sub(p, c)), 0) / ring.length;
const k = Math.floor(ring.length / 4), n = V.norm(V.cross(sub(ring[0], c), sub(ring[k], c)));
if (r > tol && ring.every((p) => Math.abs(Math.hypot(...sub(p, c)) - r) <= tol * 10 && Math.abs(V.dot(sub(p, c), n)) <= tol * 10)) { rec.type = 'circle'; rec.circ = { c, n, r }; }
Circle edge points put on the circle
32-bit in part
Moves a circular edge's ends and middle exactly onto its circle.
p′ = c + r × w ÷ |w|, w = (p − c) − n × ((p − c)·n); closed circle: middle = 2c − a
Each point is projected into the circle's plane and scaled to the radius in 64-bit arithmetic; a point at the center is left where it is. The points being moved are 32-bit mesh vertices, so their angular position keeps that precision.
Moves a tool's named planes, axes and centers with the matrix of a pattern or mirror copy.
point′ = M × point; direction′ = unit(linear part of M × direction)
Points are transformed by the full matrix and directions by its linear part, then re-normalized, all in 64-bit numbers. Radii are copied unchanged, which is correct for the rigid motions and reflections this is used for.
meshfaces.js
lines 47 to 62
PartCraft 0.8.1
/** The surfaces of a copied tool: moved by M4 (a rigid motion or a reflection) and renamed with the copy's suffix. */
export function moveSurfs(surfs, M, suffix) {
if (!surfs) return null;
return surfs.map((s0) => {
let s = s0;
const name = s.name ? s.name + suffix : s.name;
const moved = {};
if (s.within) moved.within = { ...s.within, o: V.applyMat(M, s.within.o), d: V.norm(dirOf(M, s.within.d)) };
if (s.span) moved.span = { ...s.span, a: V.applyMat(M, s.span.a), d: V.norm(dirOf(M, s.span.d)) };
s = { ...s, ...moved };
if (s.type === 'plane') return { ...s, name, n: V.norm(dirOf(M, s.n)), o: V.applyMat(M, s.o) };
if (s.type === 'cylinder' || s.type === 'cone' || s.type === 'torus' || s.type === 'thread') return { ...s, name, axis: { o: V.applyMat(M, s.axis.o), d: V.norm(dirOf(M, s.axis.d)) } };
if (s.type === 'sphere') return { ...s, name, c: V.applyMat(M, s.c) };
return { ...s, name };
});
}
In detail
Method
Each triangle is tested against the surfaces of the tool that made it; connected triangles on one surface become a face (union–find); mesh edges between two faces are chained into polylines and classified as line, circle or curve.
Number types
Float32Array mesh positions in; Float64Array for triangle areas, centers and normals; 64-bit numbers for surfaces and results; Float32Array for the edge segments the view draws.
Tolerances
tol = 2e-6 × bounding-box diagonal + 1e-7. Chord sag allowance = 1.5 × (1 − cos(π ÷ segments)) as a fraction of the radius. Plane test also needs |n_t·n| > 0.9. A needle triangle (cross-product length not above 1e-6 × longest edge²) is matched by its points only. Line and circle fits use 10 × tol.
Limits
The area of a round face and the length of a curved edge are those of the facets, which are smaller than the true values. A circle's radius is the cylinder's own radius when one is found within tolerance, otherwise the largest vertex radius.
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.