Where a steel pipe run is cut into spools, the length of each cut piece, the pipe weight on the bill of materials, and the duct cut list.
How the precision is kept
Positions along a run and cut lengths are 64-bit millimeters worked out from the run's drawn points with Math.hypot. Comparisons use absolute tolerances in millimeters: 1e-6 for containment, 0.5 for merging breaks and 1 for the least distance from a cut to a joint. Values are rounded where they are printed or exported: sheet lengths by the caller's formatter, weights to 0.1 kg in the PDF and 0.01 kg in the CSV, duct piece lengths to a whole millimeter in the list rows.
Open item. Keep the unrounded piece length in each duct cut list row and round it only where the list is printed.
Calculations under this policy
10 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.
How far along the run's drawn line a point lies, measured from the first point.
s = Σ earlier leg lengths + clamp((p − a)·d, 0, L)
The point is projected onto every leg in 64-bit and the nearest leg wins; a later leg replaces an earlier one only if it is nearer by more than 1e-6 mm, an absolute value. The leg direction is divided by the leg length, or by 1 when the length is zero.
mep/spools.js
lines 40 to 51
BimCraft 0.8.1
/** Where a point lies along a polyline (verts), mm from its first point: the nearest leg, projected. */
function sOf(verts, p) {
let best = null, s0 = 0;
for (let i = 0; i + 1 < verts.length; i++) {
const a = verts[i], b = verts[i + 1], L = dist(a, b), d = sub(b, a).map((x) => x / (L || 1));
const t = Math.max(0, Math.min(L, dot(sub(p, a), d)));
const q = [a[0] + d[0] * t, a[1] + d[1] * t, a[2] + d[2] * t], off = dist(p, q);
if (!best || off < best.off - 1e-6) best = { s: s0 + t, off };
s0 += L;
}
return best ? best.s : 0;
}
Each part's place and center-line length
64-bit
The two ends of every pipe, bend and tee along the run, and the part's own center-line length.
bend: R × θ; tee: 2 × m; straight: |b − a|
Each length is one 64-bit expression; a straight length uses Math.hypot of the coordinate differences. The two ends are swapped if they come out in reverse order.
mep/spools.js
lines 52 to 61
BimCraft 0.8.1
/** A part's two ends along the run and its own centre-line length. parts in run order (mepLayout). */
export function partSpans(r, parts) {
return parts.map((p) => {
const [a, b] = p.kind === 'bend' ? [p.p0, p.p1] : p.kind === 'tee' ? [sub(p.at, p.d.map((x) => x * p.m)), [p.at[0] + p.d[0] * p.m, p.at[1] + p.d[1] * p.m, p.at[2] + p.d[2] * p.m]] : [p.a, p.b];
let s0 = sOf(r.verts, a), s1 = sOf(r.verts, b);
if (s1 < s0) [s0, s1] = [s1, s0];
const own = p.kind === 'bend' ? p.R * p.th : p.kind === 'tee' ? 2 * p.m : dist(p.a, p.b);
return { s0, s1, len: own, kind: p.kind };
});
}
Where the run is cut and where spools break
64-bit
The cut positions inside straight pipe and the breaks between spools, from the field welds and the longest spool length.
Field weld positions are rounded to 0.001 mm before use. A weld counts as inside a part within 1e-6 mm, a cut must be at least 1 mm from either end of a straight length, and breaks closer than 0.5 mm are merged; all three are absolute. Repeated cuts in one long length are found by adding the spool length to the previous cut position again and again, so the rounding of those additions carries along the length.
mep/spools.js
lines 63 to 109
BimCraft 0.8.1
/**
* Where a spooled run is cut (inside straight pipe) and where its spools break (joints included), from a layout made without
* cuts. A field weld inside a straight length cuts it there (it must be 1 mm clear of either end); one in a fitting or a valve
* moves to the nearer joint. A spool grows part by part; the part that would make it longer than SpoolMaxMm starts the next
* one (a joint break), and a straight length longer than that is cut where the spool reaches it ('auto').
*/
export function planSpools(r, parts) {
const max = Number(r.spoolMax) || 0;
const spans = partSpans(r, parts);
const cuts = [], breaks = [];
const fw = [...new Set((r.fieldWelds || []).map((s) => Math.round(Number(s) * 1000) / 1000))].filter(Number.isFinite).sort((a, b) => a - b);
// the person's welds first: inside a straight length → a cut, else the nearest joint
const fieldAt = [];
for (const s of fw) {
const i = spans.findIndex((x) => s >= x.s0 - 1e-6 && s <= x.s1 + 1e-6);
if (i < 0) continue;
const x = spans[i];
if (x.kind === 'segment' && s - x.s0 >= 1 && x.s1 - s >= 1) { cuts.push(s); fieldAt.push({ s, kind: 'field', inside: i }); }
else { const j = s - x.s0 <= x.s1 - s ? x.s0 : x.s1; if (j > spans[0].s0 + 1e-6 && j < spans[spans.length - 1].s1 - 1e-6) fieldAt.push({ s: j, kind: 'field' }); }
}
for (const f of fieldAt) if (!breaks.some((b) => Math.abs(b.s - f.s) < 0.5)) breaks.push({ s: f.s, kind: 'field' });
if (!(max > 0)) return { cuts, breaks: breaks.sort((a, b) => a.s - b.s) };
// then the longest spool: walk the parts (a straight length split by a field weld counts as two)
const pieces = [];
for (let i = 0; i < spans.length; i++) {
const x = spans[i], inner = cuts.filter((s) => s > x.s0 + 1e-6 && s < x.s1 - 1e-6).sort((a, b) => a - b);
let a = x.s0;
for (const s of [...inner, x.s1]) { pieces.push({ s0: a, s1: s, len: x.kind === 'segment' ? s - a : x.len, kind: x.kind }); a = s; }
}
let have = 0;
for (const pc of pieces) {
if (breaks.some((b) => Math.abs(b.s - pc.s0) < 0.5)) have = 0; // a field weld starts a new spool here
if (have + pc.len <= max + 1e-6) { have += pc.len; continue; }
if (have > 0 && pc.len <= max + 1e-6) { breaks.push({ s: pc.s0, kind: 'auto' }); have = pc.len; continue; }
if (pc.kind !== 'segment') { if (have > 0) breaks.push({ s: pc.s0, kind: 'auto' }); have = pc.len; continue; } // a fitting longer than a spool: its own
// a straight length that does not fit: cut where the spool is full, as often as it takes
let s = pc.s0 + (max - have);
while (pc.s1 - s > 1) {
if (s - pc.s0 >= 1) { cuts.push(s); breaks.push({ s, kind: 'auto' }); }
else if (have > 0) breaks.push({ s: pc.s0, kind: 'auto' });
s += max;
}
have = pc.s1 - (s - max);
}
const uniq = (list) => list.sort((a, b) => (a.s ?? a) - (b.s ?? b)).filter((x, i, all) => !i || Math.abs((x.s ?? x) - (all[i - 1].s ?? all[i - 1])) > 0.5);
return { cuts: uniq(cuts), breaks: uniq(breaks) };
}
Spool length
64-bit
The parts of each spool and the spool's total center-line length.
length = Σ part lengths, in run order
The part lengths are added in run order in 64-bit. A part starts a new spool when a break lies within 0.5 mm of its start.
mep/spools.js
lines 111 to 123
BimCraft 0.8.1
/** The spools of a layout made with the cuts: parts between breaks. → [{ index, id, parts: [part index], s0, s1, length }] */
export function assignSpools(r, parts, breaks) {
const spans = partSpans(r, parts), pre = spoolPrefix(r);
const out = [];
let cur = null;
spans.forEach((x, i) => {
const starts = !cur || breaks.some((b) => Math.abs(b.s - x.s0) < 0.5);
if (starts) { cur = { index: out.length + 1, parts: [], s0: x.s0, s1: x.s1, length: 0 }; out.push(cur); }
cur.parts.push(i); cur.s1 = Math.max(cur.s1, x.s1); cur.length += x.len;
});
for (const sp of out) sp.id = pre + '-S' + String(sp.index).padStart(2, '0');
return out;
}
Cut length of a straight piece
64-bit
The length of each straight pipe piece on the spool sheet's cut list.
length = |b − a|
Math.hypot of the three coordinate differences of the piece's two ends, kept unrounded in the sheet object.
The angle shown for each elbow and the count of like fittings.
angle = round(θ × 180 ÷ π)
The bend angle is converted to degrees and rounded to a whole degree, both for the value listed and for grouping. Bends that differ by less than the rounding step can be counted as one kind.
The total cut length of pipe in a spool and its steel weight.
kg = (kg per m) × Σ cut lengths ÷ 1000
The cut lengths are added in order in 64-bit. The pipe's outside diameter and wall are multiplied by 25.4, which is exact. The weight per meter comes from steelWeightKgM in mep/tables.js, whose factor 0.0246615 is a rounded constant.
How many stock-length duct pieces each straight duct needs, and the length left over.
full = floor((L + 0.5) ÷ piece); rest = L − full × piece
A straight length up to 0.5 mm short of a whole number of stock pieces counts as that many pieces, and a remainder of 0.5 mm or less is dropped. Each row stores the piece length rounded to a whole millimeter, and rows are grouped by that rounded length and the rounded duct width and height.
mep/spools.js
lines 184 to 200
BimCraft 0.8.1
/** Straight duct pieces by length only: each straight duct length cut into stock pieces of pieceMm and what is left. */
export function ductCutList(runs, layouts, pieceMm = DUCT_PIECE_DEFAULT) {
const rows = new Map();
for (const r of runs) {
if (r.kind !== 'duct') continue;
const parts = layouts.get(r.guid) || [];
for (const p of parts) {
if (p.kind !== 'segment') continue;
const L = dist(p.a, p.b);
const full = Math.floor((L + 0.5) / pieceMm), rest = L - full * pieceMm;
const add = (length, qty) => { const key = [r.system, r.shape, Math.round(r.w), Math.round(r.h), Math.round(length)].join('|'); const x = rows.get(key) || { system: r.system, shape: r.shape, w: r.w, h: r.h, length: Math.round(length), qty: 0 }; x.qty += qty; rows.set(key, x); };
if (full) add(pieceMm, full);
if (rest > 0.5) add(rest, 1);
}
}
return [...rows.values()].sort((a, b) => a.system.localeCompare(b.system) || (b.w * b.h - a.w * a.h) || (b.length - a.length));
}
The spool length, cut lengths, pipe length and weight as they appear in the PDF.
printed kg = round(kg × 10) ÷ 10
Lengths go through the length formatter the caller passes; the default rounds to a whole millimeter. The weight is rounded to 0.1 kg. Only the printed text is rounded; the sheet object keeps the 64-bit values.
The cut lengths, quantities and pipe weight exported as CSV.
exported kg = round(kg × 100) ÷ 100
Cut and pipe lengths go through the caller's formatter; the default writes whole millimeters. The weight is exported rounded to 0.01 kg.
mep/spool-sheet.js
lines 142 to 152
BimCraft 0.8.1
/** CSV rows for the spools: a block per spool (its id and size, then its cuts, fittings, welds and bill of materials). */
export function spoolCsvRows(sheets, { L = (mm) => String(Math.round(mm)), words = (k) => k } = {}) {
const rows = [[words('mep.sp.c.spool'), words('mep.sp.c.section'), words('mep.sp.c.n'), words('mep.sp.c.what'), words('mep.sp.c.size'), words('mep.sp.c.qty'), words('mep.sp.c.len'), words('mep.sp.c.kg')]];
for (const sh of sheets) {
for (const c of sh.cuts) rows.push([sh.id, words('mep.sp.cuts'), c.n, words('mep.sp.bom.pipe', { size: sh.sizeLabel, sch: sh.sch }), sh.sizeLabel, 1, L(c.length), '']);
for (const f of sh.fittings) rows.push([sh.id, words('mep.sp.fittings'), '', fitText(f, words), f.size, f.qty, '', '']);
for (const wd of sh.welds) rows.push([sh.id, words('mep.sp.welds'), wd.n, words('mep.sp.weld.' + wd.kind), wd.size, 1, '', '']);
for (const b of sh.bom) rows.push([sh.id, words('mep.sp.bom'), '', b.item === 'pipe' ? words('mep.sp.bom.pipe', { size: b.size, sch: b.sch }) : fitText({ kind: b.item, angle: b.angle, valve: b.valve, generic: b.generic }, words), b.size, b.item === 'pipe' ? '' : b.qty, b.item === 'pipe' ? L(b.qty) : '', b.kg === null ? '' : Math.round(b.kg * 100) / 100]);
}
return rows;
}
In detail
Method
Each part's ends are projected onto the run's drawn polyline to get a distance along it. A spool grows part by part until the next part would pass the longest spool length; a straight length that does not fit is cut where the spool is full. Pipe weight is weight per meter times the summed cut length.
Number types
64-bit numbers in plain arrays. No typed arrays.
Tolerances
1e-6 mm (absolute) when choosing the nearest leg and when testing whether a weld lies in a part or a length fits a spool; 0.5 mm (absolute) to merge breaks and to drop a duct remainder; 1 mm least distance from a cut to the end of a straight length; field weld positions rounded to 0.001 mm.
Limits
A bend's angle is rounded to a whole degree for the fitting list. Tees, reducers and valves have no weight on the bill of materials (kg is null). The duct cut list keeps piece lengths as whole millimeters.
Approach: 64-bit, direct
Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.