Adds up a chain of dimensions and their tolerances across an assembly: the nominal and mean gap, the worst-case and statistical spread, each link's share, and the share of assemblies expected outside the limits.
How the precision is kept
The sums are plain 64-bit arithmetic on the nominals and deviations as stored. Three things are set by method rather than by the number format: the normal-distribution tail uses a polynomial approximation with a stated error, nominals measured from picked points are rounded to 9 decimals when the link is made, and the CSV export writes 4 decimals.
Open item. Write the CSV with full precision (or enough significant digits to round-trip) instead of 4 decimals.
Calculations under this policy
10 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.
The probability that a standard normal value exceeds z, used for the share of assemblies outside a limit.
P(Z > z) = ½ × erfc(z ÷ √2); erfc(x) ≈ k × exp(−x² − 1.26551223 + k·(1.00002368 + …)), k = 1 ÷ (1 + ½|x|)
An approximation by design: the polynomial's coefficients are typed to 8 decimals and the code comment gives its fractional error as under 1.2e-7 everywhere. The arithmetic is 64-bit, so the method error is what limits the result.
stackup.js
lines 41 to 48
PartCraft 0.8.1
/** P(Z > z) for a standard normal (erfc, fractional error < 1.2e-7 everywhere: Numerical Recipes' erfcc). */
export function normalTail(z) {
const x = z / Math.SQRT2, ax = Math.abs(x);
const k = 1 / (1 + 0.5 * ax);
const r = k * Math.exp(-ax * ax - 1.26551223 + k * (1.00002368 + k * (0.37409196 + k * (0.09678418 + k * (-0.18628806 + k * (0.27886807 + k * (-1.13520398 + k * (1.48851587 + k * (-0.82215223 + k * 0.17087277)))))))));
const erfc = x >= 0 ? r : 2 - r;
return erfc / 2;
}
Stack-up analysis
64-bit
The gap's nominal and mean, its worst-case and statistical limits, each link's share and rank, and the check against the limits.
Running 64-bit sums in link order with no compensation; the squares are summed before one square root. The limit checks allow 1e-12 of slack in absolute terms. With a zero spread the outside share is 0 or 1 by a direct comparison, so nothing is divided by zero.
stackup.js
lines 50 to 78
PartCraft 0.8.1
export function analyze(stack) {
const rows = [];
let nominal = 0, mean = 0, wc = 0, ss = 0;
for (const l of stack.links || []) {
const a = (l.sign < 0 ? -1 : 1) * (Number.isFinite(l.sens) ? l.sens : 1);
const m = l.nominal + (l.up + l.lo) / 2, tt = (l.up - l.lo) / 2;
nominal += a * l.nominal; mean += a * m; wc += Math.abs(a) * tt; ss += (a * tt) ** 2;
rows.push({ link: l, a, mean: m, t: tt, wc: Math.abs(a) * tt, rss2: (a * tt) ** 2 });
}
const rss = Math.sqrt(ss);
for (const r of rows) { r.wcShare = wc > 0 ? r.wc / wc : 0; r.rssShare = ss > 0 ? r.rss2 / ss : 0; }
const ranked = rows.slice().sort((x, y) => y.rssShare - x.rssShare || y.wcShare - x.wcShare);
ranked.forEach((r, i) => { r.rank = i + 1; });
const out = { nominal, mean, wc: { tol: wc, min: mean - wc, max: mean + wc }, rss: { tol: rss, sigma: rss / 3, min: mean - rss, max: mean + rss }, rows: ranked };
const sp = stack.spec;
if (sp && (Number.isFinite(sp.min) || Number.isFinite(sp.max))) {
const lo = Number.isFinite(sp.min) ? sp.min : -Infinity, hi = Number.isFinite(sp.max) ? sp.max : Infinity;
const sigma = rss / 3;
const below = Number.isFinite(lo) ? (sigma > 0 ? normalTail((mean - lo) / sigma) : (mean < lo ? 1 : 0)) : 0;
const above = Number.isFinite(hi) ? (sigma > 0 ? normalTail((hi - mean) / sigma) : (mean > hi ? 1 : 0)) : 0;
out.spec = {
min: lo, max: hi,
wcOk: out.wc.min >= lo - 1e-12 && out.wc.max <= hi + 1e-12,
rssOk: out.rss.min >= lo - 1e-12 && out.rss.max <= hi + 1e-12,
below, above, ppm: (below + above) * 1e6,
};
}
return out;
}
Links from picked points
64-bit
Makes the links of a chain from points picked in order, measured along one direction.
Δs = d · b − d · a; nominal = round(|Δs|, 9 decimals); sign = sign of Δs
Each pick is projected on the direction and the two projections are subtracted, which loses digits for picks far from the origin and close together. The nominal that is kept is then rounded to 9 decimals.
stackup.js
lines 82 to 97
PartCraft 0.8.1
/** Picks in the order clicked → links (see the header). */
export function fromPicks(picks, dir, labels = {}) {
const d = unit(dir);
const links = [];
for (let i = 0; i + 1 < picks.length; i++) {
const a = picks[i], b = picks[i + 1];
const ds = dot(d, b.at) - dot(d, a.at);
const same = a.inst === b.inst;
const nominal = round(Math.abs(ds));
links.push({
id: 'l' + (i + 1), label: same ? (labels.part ? labels.part(a, b) : (a.label || '') + ' → ' + (b.label || '')) : (labels.iface ? labels.iface(a, b) : ''),
inst: same ? a.inst : null, sign: ds < 0 ? -1 : 1, nominal, up: 0, lo: 0, sens: 1, source: same ? 'measured' : 'interface',
});
}
return links;
}
Rounding of measured nominals
64-bit
Rounds a measured length or direction component before it is stored on a link.
round(v) = Math.round(v × 10⁹) ÷ 10⁹
The stored value is the measurement rounded to 9 decimals (1e-9 mm for a length). The division returns the nearest 64-bit number to the 9-decimal value.
stackup.js
line 100
PartCraft 0.8.1
const round = (v) => Math.round(v * 1e9) / 1e9;
Vector stack projected on a direction
64-bit
Turns a chain whose links run in different directions into a one-direction chain along an output direction, with each link's angular swing as an extra link.
sens = (u · d) × sens; swing: c = nominal × ((axis × u) · d); up, lo = c × angle° × π ÷ 180
The sensitivity is one dot product of unit vectors. The swing is the first-order effect of a small rotation and is not exact for large angles. Degrees are converted with Math.PI.
stackup.js
lines 117 to 131
PartCraft 0.8.1
/** Per output direction, the 1-D stack the same analyze() takes: link i has sensitivity u_i·d, a swing is an extra link. */
export function projected(stack, d) {
const links = [];
for (const l of stack.links || []) {
const u0 = l.dir ? unit(l.dir) : [1, 0, 0], g = l.sign < 0 ? -1 : 1;
const u = u0.map((v) => v * g);
links.push({ ...l, sign: 1, sens: dot(u, d) * (Number.isFinite(l.sens) ? l.sens : 1) });
if (l.ang && (l.ang.up || l.ang.lo)) {
const w = cross(unit(l.ang.axis || [0, 0, 1]), u), c = l.nominal * dot(w, d); // mm per radian along d
const hi = c * l.ang.up * RAD, lo = c * l.ang.lo * RAD;
links.push({ id: l.id + 'a', label: (l.label || '') + ' ∠', inst: l.inst, sign: 1, nominal: 0, up: Math.max(hi, lo), lo: Math.min(hi, lo), sens: 1, source: 'typed', swing: true });
}
}
return { ...stack, dir: d, links };
}
Vector stack analysis
64-bit
The nominal gap as a vector and its length, and the analysis along each output direction and along the gap itself.
The gap vector is a running sum per coordinate in link order; links that nearly cancel leave a small difference of larger numbers. The gap's own direction is added as an output only when its length is over 1e-9.
stackup.js
lines 133 to 140
PartCraft 0.8.1
export function analyzeVector(stack) {
const gap = [0, 0, 0];
for (const l of stack.links || []) { const u = l.dir ? unit(l.dir) : [1, 0, 0], g = (l.sign < 0 ? -1 : 1) * l.nominal; for (let k = 0; k < 3; k++) gap[k] += g * u[k]; }
const outs = (stack.outputs && stack.outputs.length ? stack.outputs : directionsFor('xy')).slice();
const L = Math.hypot(...gap);
if (L > 1e-9 && !outs.some((o) => o.id === 'gap')) outs.push({ id: 'gap', name: 'gap', dir: gap.map((v) => v / L), spec: null, resultant: true });
return { gap, gapLength: L, outputs: outs.map((o) => ({ id: o.id, name: o.name, dir: unit(o.dir), ...analyze({ ...projected(stack, unit(o.dir)), spec: o.spec || null }) })) };
}
Links from picked points in 3-D
64-bit
Makes links that run straight from each picked point to the next.
nominal = round(|b − a|, 9 decimals); dir = round((b − a) ÷ |b − a|, 9 decimals)
The length and each component of the direction are rounded to 9 decimals when stored, so the stored direction is a unit vector only to about 1e-9. The analysis normalizes each direction again before use.
stackup.js
lines 142 to 152
PartCraft 0.8.1
/** Picks → links straight from one pick to the next, as the vector between them (assembly coordinates, 3-D length). */
export function fromPicks3(picks, labels = {}) {
const links = [];
for (let i = 0; i + 1 < picks.length; i++) {
const a = picks[i], b = picks[i + 1], v = [b.at[0] - a.at[0], b.at[1] - a.at[1], b.at[2] - a.at[2]], len = Math.hypot(...v);
const same = a.inst === b.inst;
links.push({ id: 'l' + (i + 1), label: same ? (labels.part ? labels.part(a, b) : (a.label || '') + ' → ' + (b.label || '')) : (labels.iface ? labels.iface(a, b) : ''),
inst: same ? a.inst : null, sign: 1, nominal: round(len), dir: len > 1e-12 ? v.map((q) => round(q / len)) : [1, 0, 0], up: 0, lo: 0, sens: 1, source: same ? 'measured' : 'interface' });
}
return links;
}
Tolerance from a feature control frame
64-bit
How a geometric tolerance enters a stack: plus and minus half the tolerance zone.
up = tol ÷ 2; lo = −tol ÷ 2
A halving, which is exact in binary. The zone is taken at its stated size; a material condition modifier adds a note but no bonus.
stackup.js
lines 173 to 179
PartCraft 0.8.1
export function fromGdt(note) {
if (!note || note.kind !== 'fcf' || !(note.tol > 0) || !GDT_CHAR[note.char]) return null;
const kind = GDT_CHAR[note.char], h = note.tol / 2;
const words = [t('stack.gdt.' + kind)];
if (note.mod === 'M' || note.mod === 'L') words.push(t('stack.gdt.bonus'));
return { up: h, lo: -h, kind, words };
}
General tolerance for a nominal size
64-bit
The deviation a title block's general tolerance gives one nominal size: a class of ISO 2768-1, or a typed ± value.
class f, m, c or v: the table row for the first range with nominal ≤ upper end (0.5 to 4000 mm); typed: parseFloat of the number after ±
A table lookup: the deviations are the table's own decimal values, and a nominal exactly on a range's upper end falls in that range. Typed values are converted with parseFloat from the text. Sizes under 0.5 or over 4000, and empty table cells, give no tolerance.
stackup.js
lines 190 to 207
PartCraft 0.8.1
/** A title block's general tolerance → the deviations for one nominal size, or null when it says nothing usable. */
export function generalTol(text, nominal) {
const s = String(text || '').replace(/−/g, '-').replace(/,/g, '.').trim();
if (!s) return null;
let m = /2768\s*[-–]?\s*([fmcv])\b/i.exec(s) || /2768\s*[-–]?\s*([fmcv])[hkl]?\b/i.exec(s);
if (m) {
const cls = m[1].toLowerCase(), n = Math.abs(nominal);
if (n < 0.5 || n > 4000) return null;
const i = ISO2768.ranges.findIndex((hi) => n <= hi);
const dev = ISO2768[cls][i];
return dev == null ? null : { up: dev, lo: -dev, rule: 'ISO 2768-' + cls };
}
m = /(?:±|\+\/-|\+-)\s*(\d*\.?\d+)/.exec(s);
if (m) { const v = parseFloat(m[1]); return v >= 0 && v <= LIMITS.tol ? { up: v, lo: -v, rule: '±' + v } : null; }
m = /\+\s*(\d*\.?\d+)\s*\/?\s*-\s*(\d*\.?\d+)/.exec(s);
if (m) { const u = parseFloat(m[1]), l = parseFloat(m[2]); return u <= LIMITS.tol && l <= LIMITS.tol ? { up: u, lo: -l, rule: '+' + u + '/-' + l } : null; }
return null;
}
Stack-up as CSV
64-bit
Writes the links and the results of a stack-up to a CSV file.
every number written as +v.toFixed(4)
Every exported number, lengths in mm and shares in percent alike, is rounded to 4 decimals by toFixed on the way out. The file therefore carries less than the analysis computed.
stackup.js
lines 262 to 272
PartCraft 0.8.1
/** The stack-up as CSV: one row per link (ranked), then the results. */
export function csv(stack, res, instName = (id) => id || '') {
const head = [t('stack.csv.rank'), t('stack.csv.link'), t('stack.csv.part'), t('stack.csv.sign'), t('stack.csv.nominal'), t('stack.csv.up'), t('stack.csv.lo'), t('stack.csv.mean'), t('stack.csv.tol'), t('stack.csv.wc'), t('stack.csv.rss'), t('stack.csv.source')];
const lines = [head.map(cell).join(',')];
for (const r of res.rows) lines.push([r.rank, r.link.label, instName(r.link.inst), r.a > 0 ? '+' : '-', f4(r.link.nominal), f4(r.link.up), f4(r.link.lo), f4(r.mean), f4(r.t), f4(r.wcShare * 100), f4(r.rssShare * 100), t('stack.source.' + r.link.source)].map(cell).join(','));
lines.push('');
lines.push([t('stack.csv.result'), t('stack.csv.nominal'), t('stack.csv.mean'), t('stack.csv.tol'), t('stack.csv.min'), t('stack.csv.max')].map(cell).join(','));
lines.push([t('stack.r.wc'), f4(res.nominal), f4(res.mean), f4(res.wc.tol), f4(res.wc.min), f4(res.wc.max)].map(cell).join(','));
lines.push([t('stack.r.rss'), f4(res.nominal), f4(res.mean), f4(res.rss.tol), f4(res.rss.min), f4(res.rss.max)].map(cell).join(','));
return '' + lines.join('\r\n') + '\r\n';
}
In detail
Method
Worst case ±Σ|a|·t; root sum of squares ±√Σ(a·t)² with each tolerance taken as ±3σ; mean shift from unequal deviations; vector stacks by projecting each link on an output direction, with angular swing to first order; complementary error function by the Numerical Recipes erfcc approximation (the code comment names it); general tolerances from an ISO 2768-1 Table 1 held in the file.
Number types
64-bit numbers.
Tolerances
Limits are tested with 1e-12 of slack (absolute, mm). A direction shorter than 1e-12 falls back to the x axis; a resultant gap under 1e-9 gets no direction of its own.
Limits
The normal tail has a fractional error under 1.2e-7 (code comment), so parts per million carry about 7 digits. The statistical result assumes independent, normally distributed links at ±3σ. Angular swing is first order (small angles). Bonus tolerance from material condition modifiers is not counted.
Approach: 64-bit, stated limit
The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.