Resources Numerical accuracy Calculation policy

Pipe and duct runs: layout, lengths and sizing

Turns the pipe and duct lines a person draws into straight pieces, elbows, tees and reducers, measures their lengths and surface, and picks a pipe or duct size for a flow.

How the precision is kept

Layout runs in 64-bit plan millimeters: elbow tangent lengths from the turn angle, tees and reducers from the section sizes, with fixed millimeter tolerances for what counts as touching or straight. Table sizes in inches are multiplied by 25.4, and the flow conversions use 0.3048, 0.0254 and 3.785411784, all exact by definition. What is saved is rounded: a run's points to 0.001 mm, and every length written through the model writer to 6 decimals of the file's unit, so the schedule lengths that are read back carry that rounding.

Open item. Write run points and lengths as computed instead of rounding them to 0.001 mm and to 6 decimals of the file's unit.

Calculations under this policy

23 calculations in 3 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.

mep-model.js BimCraft 0.8.1 64-bit 18 calculations Reviewed

Tests tools/bimcraft-test/mep-test.mjs, bin/test-bimcraft-drainage.mjs

Section of a run

64-bit

The outside diameter, wall and bend radius of a pipe from the size table, or the width and height of a duct, in millimeters.

mm = inches × 25.4

Table sizes in inches are multiplied by 25.4, which is exact. A duct's label rounds its width and height to whole millimeters; the section itself is not rounded.

  • mep-model.js
  • lines 112 to 124
  • BimCraft 0.8.1
/** The section a run is drawn with (mm), or null when that size is not made. */
export function sectionOf(r) {
  if (r.kind === 'pipe') {
    const p = pipeSize(r.mat, r.cls, r.size);
    if (!p) return null;
    return { kind: 'pipe', round: true, shape: 'round', od: p.od * IN, wall: p.wall * IN, id: p.id * IN, nps: p.nps, w: p.od * IN, h: p.od * IN, R: bendRadius(p) * IN, label: p.label || p.size + '"', p };
  }
  const w = Number(r.w), h = r.shape === 'round' ? w : Number(r.h);
  const minDim = carrierOf(r) ? 10 : 50;   // a conduit is narrower than a duct
  if (!(w >= minDim) || !(h >= minDim) || !DUCT_SHAPES.includes(r.shape)) return null;
  if (r.shape === 'oval' && !(w > h)) return null;
  return { kind: 'duct', round: r.shape === 'round', shape: r.shape, od: r.shape === 'round' ? w : null, w, h, label: r.shape === 'round' ? 'Ø' + Math.round(w) : Math.round(w) + '×' + Math.round(h) };   // i18n-ignore: size marks
}

Run corners with the slope applied

64-bit

The corners of a run with its fall along the horizontal length from the first point.

z = z₀ − slope × Σ horizontal leg length

The fall is a running 64-bit sum of slope × horizontal leg length. A corner closer than 0.5 mm to the one before it is dropped.

  • mep-model.js
  • lines 133 to 144
  • BimCraft 0.8.1
/** The run's corners with its slope applied (it falls from its first point along its horizontal length). */
export function runVertices(r) {
  const out = [];
  let fall = 0;
  for (let i = 0; i < r.points.length; i++) {
    const q = r.points[i];
    if (i) { const p = r.points[i - 1]; fall += (r.slope || 0) * Math.hypot(q[0] - p[0], q[1] - p[1]); }
    out.push([q[0], q[1], (q[2] || 0) - fall]);
  }
  // corners on one spot count once
  return out.filter((q, i) => !i || dist(q, out[i - 1]) > 0.5);
}

Bend radius of an elbow

64-bit

The center-line radius an elbow is built with.

pipe: the table's radius; round duct: 1.5 × diameter; rectangular duct: its width for a turn in plan, its height for a turn up or down

A turn counts as in plan when the vertical part of the unit normal of the two directions is above 0.7 in size. The cross product is divided by its length, with a zero length replaced by 1.

  • mep-model.js
  • lines 125 to 131
  • BimCraft 0.8.1
/** The bend's centre-line radius for a turn from d1 to d2 (mm). */
function bendR(sec, d1, d2) {
  if (sec.kind === 'pipe') return sec.R;
  if (sec.round) return 1.5 * sec.w;
  const m = cross(d1, d2);
  return Math.abs(norm(m)[2]) > 0.7 ? sec.w : sec.h;   // a turn in plan bends across the width; up or down across the height
}

Elbow geometry at a corner

64-bit

The two tangent points, center and angle of the elbow where a run turns.

θ = acos(d₁ · d₂); T = R × tan(θ ÷ 2); center = p₀ + n × R

The dot product of the two unit directions is clamped to [−1, 1] before acos. Near a straight line acos loses digits and near a full reversal tan(θ ÷ 2) grows without bound; the layout only calls this for turns between 0.5° and 179°.

  • mep-model.js
  • lines 146 to 153
  • BimCraft 0.8.1
/** A bend at corner P from direction d1 to d2 with radius R: its two tangent points, centre and angle. */
function bendAt(P, d1, d2, R) {
  const c = Math.max(-1, Math.min(1, dot(d1, d2))), th = Math.acos(c);
  const T = R * Math.tan(th / 2);
  const n = norm(sub(d2, mul(d1, c)));
  const p0 = sub(P, mul(d1, T)), p1 = add(P, mul(d2, T));
  return { p0, p1, center: add(p0, mul(n, R)), n, th, T, R, d1, d2 };
}

Where runs meet

64-bit

Finds run ends that touch another run's end or lie on another run's line, to make an elbow, reducer or tee there.

end to end when |P − Q| ≤ max(tol_a, tol_b); tee when the foot of P on a leg is within the tolerance

Distances are 64-bit Math.hypot values compared with millimeter tolerances that scale with the section: max(5, 0.3 × the smaller side) for two ends, and the larger of that and half the host's width for a tee. A foot within 1 mm of a leg's end is not a tee.

  • mep-model.js
  • lines 168 to 196
  • BimCraft 0.8.1
// ---- where runs meet: end to end, or an end on another run's line (a tee) ----
const endAt = (r, e) => (e === 0 ? r.verts[0] : r.verts[r.verts.length - 1]);
const joins = [], tees = [];
const used = new Set();
for (const r of R) for (const e of [0, 1]) {
  const P = endAt(r, e), key = r.guid + ':' + e;
  if (used.has(key)) continue;
  let done = false;
  for (const o of R) {
    if (o === r || o.kind !== r.kind) continue;
    for (const f of [0, 1]) {
      if (used.has(o.guid + ':' + f)) continue;
      if (dist(P, endAt(o, f)) <= Math.max(tolOf(r), tolOf(o))) { joins.push({ a: { r, e }, b: { r: o, e: f } }); used.add(key); used.add(o.guid + ':' + f); done = true; break; }
    }
    if (done) break;
  }
  if (done) continue;
  for (const o of R) {
    if (o === r || o.kind !== r.kind) continue;
    for (let i = 0; i < o.legs.length; i++) {
      const l = o.legs[i], s = dot(sub(P, l.a), l.d);
      if (s <= 1 || s >= l.L - 1) continue;
      const foot = add(l.a, mul(l.d, s));
      if (dist(P, foot) <= Math.max(tolOf(r), o.sec.w / 2)) { tees.push({ host: o, leg: i, s, at: foot, branch: r, e }); used.add(key); done = true; break; }
    }
    if (done) break;
  }
}
// ---- trims: how much each leg loses at its start and end, and where it is split ----

Elbow, reducer and tee allowances

64-bit

How much of each leg an elbow, a reducer or a tee takes.

reducer length (duct) = max(150, max(|Δw|, |Δh|) ÷ 2 ÷ tan 15°); tee half length = max(25, 0.5 × branch + 0.25 × host)

A turn counts as straight when the cosine of its angle is above cos 0.5° and is refused below cos 179°. A duct transition is as long as the size change needs at a 15° half angle, at least 150 mm; a pipe reducer is the larger of 38 mm and the biggest side. Two sections are one size when width and height agree within 0.5 mm.

  • mep-model.js
  • lines 199 to 244
  • BimCraft 0.8.1
// elbows at a run's own corners
for (const r of R) {
  let wd = across(r.legs[0].d, null);
  r.wds = r.legs.map((l) => (wd = across(l.d, wd)));
  for (let i = 1; i < r.verts.length - 1; i++) {
    const d1 = r.legs[i - 1].d, d2 = r.legs[i].d, c = dot(d1, d2);
    if (c > Math.cos(0.5 * Math.PI / 180)) continue;   // straight on: no fitting
    if (c < Math.cos(179 * Math.PI / 180)) return fail('mep.err.back');
    const b = bendAt(r.verts[i], d1, d2, bendR(r.sec, d1, d2));
    r.trim[i - 1][1] += b.T; r.trim[i][0] += b.T;
    r.bends.push({ i, b });
  }
}
// end to end: an elbow (one size, turning), a reducer (two sizes) or nothing (one size, straight on)
const sameSize = (a, b) => a.shape === b.shape && Math.abs(a.w - b.w) < 0.5 && Math.abs(a.h - b.h) < 0.5;
for (const j of joins) {
  // the fitting belongs to the run that ENDS at the joint and flows into the other one; else the first by GlobalId
  let A = j.a, B = j.b;
  if (!(A.e === 1 && B.e === 0) && ((B.e === 1 && A.e === 0) || B.r.guid < A.r.guid)) [A, B] = [B, A];
  const legA = A.e === 1 ? A.r.legs.length - 1 : 0, legB = B.e === 1 ? B.r.legs.length - 1 : 0;
  const dA = A.e === 1 ? A.r.legs[legA].d : mul(A.r.legs[legA].d, -1);   // into the joint
  const dB = B.e === 0 ? B.r.legs[legB].d : mul(B.r.legs[legB].d, -1);   // out of the joint
  const P = endAt(A.r, A.e);
  if (!sameSize(A.r.sec, B.r.sec)) {
    const big = Math.max(A.r.sec.w, A.r.sec.h, B.r.sec.w, B.r.sec.h);
    const Lr = A.r.kind === 'pipe' ? Math.max(38, big) : Math.max(150, Math.max(Math.abs(A.r.sec.w - B.r.sec.w), Math.abs(A.r.sec.h - B.r.sec.h)) / 2 / Math.tan(15 * Math.PI / 180));
    A.r.trim[legA][A.e === 1 ? 1 : 0] += Lr;
    A.r.endFit[A.e] = { kind: 'reducer', a: sub(P, mul(dA, Lr)), b: P, d: dA, from: A.r.sec, to: B.r.sec };
    continue;
  }
  if (dot(dA, dB) > Math.cos(0.5 * Math.PI / 180)) continue;
  if (dot(dA, dB) < Math.cos(179 * Math.PI / 180)) return fail('mep.err.back');
  const b = bendAt(P, dA, dB, bendR(A.r.sec, dA, dB));
  A.r.trim[legA][A.e === 1 ? 1 : 0] += b.T; B.r.trim[legB][B.e === 0 ? 0 : 1] += b.T;
  A.r.endFit[A.e] = { kind: 'bend', b };
}
// tees: the host is split round the joint, the branch stops short of it
for (const t of tees) {
  const h = t.host.sec, b = t.branch.sec;
  const m = Math.max(25, 0.5 * Math.max(b.w, b.h) + 0.25 * Math.max(h.w, h.h)), mb = Math.max(25, 0.5 * Math.max(h.w, h.h) + 0.25 * Math.max(b.w, b.h));
  t.host.cuts[t.leg].push({ s: t.s, half: m, kind: 'tee', tee: t, m, mb });
  const legB = t.e === 1 ? t.branch.legs.length - 1 : 0;
  t.branch.trim[legB][t.e === 1 ? 1 : 0] += mb;
  t.bd = norm(sub(t.e === 1 ? t.branch.verts[t.branch.verts.length - 2] : t.branch.verts[1], t.at));   // from the joint out along the branch
}
// 0.7: welds that cut straight pipe (spools), by distance along the run

Straight pieces between fittings

64-bit

The start and end of every straight piece along each leg, after trims, tees, valves and weld cuts.

Each piece runs from a + d × s₀ to a + d × s₁ with s measured along the leg in 64-bit. A piece shorter than 1 mm stops the layout with an error instead of being written.

  • mep-model.js
  • lines 259 to 288
  • BimCraft 0.8.1
// ---- the parts, in order along each run ----
for (const r of R) {
  const parts = out.get(r.guid).parts;
  const push = (p) => parts.push(p);
  if (r.endFit[0]) push(endPart(r.endFit[0], r.wds[0], 0));
  for (let i = 0; i < r.legs.length; i++) {
    const l = r.legs[i], wd = r.wds[i];
    const bend = r.bends.find((x) => x.i === i);
    if (bend) push({ kind: 'bend', ...bend.b, wd: r.wds[i - 1] });
    // pieces between the trims and the cuts
    const cuts = r.cuts[i].slice().sort((a, b) => a.s - b.s);
    let s0 = r.trim[i][0];
    for (const c of cuts) {
      const s1 = c.s - c.half;
      if (s1 - s0 < 1) return fail('mep.err.short');
      push({ kind: 'segment', a: add(l.a, mul(l.d, s0)), b: add(l.a, mul(l.d, s1)), d: l.d, wd });
      if (c.kind === 'weld') { s0 = c.s; continue; }
      const a = add(l.a, mul(l.d, c.s - c.half)), b = add(l.a, mul(l.d, c.s + c.half));
      if (c.kind === 'tee') push({ kind: 'tee', at: c.tee.at, d: l.d, bd: c.tee.bd, m: c.m, mb: c.mb, wd, branchGuid: c.tee.branch.guid, branchSec: c.tee.branch.sec, a, b });
      else push({ kind: 'valve', a, b, d: l.d, wd, valve: c.valve });
      s0 = c.s + c.half;
    }
    const s1 = l.L - r.trim[i][1];
    if (s1 - s0 < 1) return fail('mep.err.short');
    push({ kind: 'segment', a: add(l.a, mul(l.d, s0)), b: add(l.a, mul(l.d, s1)), d: l.d, wd });
  }
  if (r.endFit[1]) push(endPart(r.endFit[1], r.wds[r.wds.length - 1], 1));
  for (const p of parts) p.ports = portsOf(p);
}
return { runs: out };

Saving a run's definition

64-bit

The points, sizes, slope and flow kept in the run's property set.

point = round to 3 decimals of a millimeter

Points and valve positions are rounded to 0.001 mm, the slope to 9 decimals, the flow to 6 decimals and widths to 6 decimals of the file's unit before they are written. The run is rebuilt from these rounded values the next time it is read.

  • mep-model.js
  • lines 341 to 353
  • BimCraft 0.8.1
/** The run's definition into its CadCraft_MepRun (mm in the points; lengths in file units). */
function writeDef(w, id, r) {
  if (!okPoints(r.points)) refuse('mep.err.points');
  if (!sectionOf(r)) refuse('mep.err.size');
  const Lm = (k, v) => [k, 'IFCLENGTHMEASURE', round(w.u(v || 0))];
  w.upsertPset(id, RUN_PSET, [['Kind', 'IFCLABEL', r.kind], ['System', 'IFCLABEL', r.system], ['PointsMm', 'IFCTEXT', JSON.stringify(r.points.map((q) => q.map((x) => round(x, 3))))],
    ['Material', 'IFCLABEL', r.mat || ''], ['Class', 'IFCLABEL', r.cls || ''], ['Size', 'IFCLABEL', r.size || ''], ['Shape', 'IFCLABEL', r.kind === 'duct' ? r.shape : ''],
    Lm('Width', r.kind === 'duct' ? r.w : 0), Lm('Height', r.kind === 'duct' ? r.h : 0), ['Slope', 'IFCREAL', round(r.slope || 0, 9)], ['Flow', 'IFCREAL', round(r.flow || 0, 6)],
    ['Valves', 'IFCTEXT', JSON.stringify((r.valves || []).map((v) => ({ s: round(v.s, 3), kind: v.kind })))],
    ['Ends', 'IFCTEXT', JSON.stringify(okEnds(r.ends || [null, null]))], Lm('Insulation', Math.max(0, Number(r.insulation) || 0)),
    ['SpoolMaxMm', 'IFCREAL', round(Math.max(0, Number(r.spoolMax) || 0), 3)], ['FieldWelds', 'IFCTEXT', JSON.stringify((r.fieldWelds || []).map((s) => round(s, 3)))], ['Line', 'IFCLABEL', String(r.line || '').slice(0, 20)],
    ['DrainRole', 'IFCLABEL', r.role === 'main' ? 'main' : 'branch']]);
}

Length and surface written on each piece

64-bit

The length of a straight pipe or duct piece and a duct's outside surface, written as IFC quantities.

L = |b − a|; perimeter = π w (round), π h + 2 (w − h) (oval), 2 (w + h) (rectangle); surface = perimeter × L

The length is one Math.hypot between the piece's ends. A flat oval's perimeter is π × h + 2 × (w − h), exact for semicircular ends. The model writer rounds the length to 6 decimals of the file's unit and the area to 6 decimals of a square meter when it stores them.

  • mep-model.js
  • lines 540 to 552
  • BimCraft 0.8.1
const run = w.guidOf(r.id), Lm = (k, v) => [k, 'IFCLENGTHMEASURE', round(w.u(v))];
if (part.kind === 'segment') {
  const L = dist(part.a, part.b);
  if (r.kind === 'pipe') {
    w.upsertPset(id, 'CadCraft_Pipe', [['Run', 'IFCLABEL', run], ['System', 'IFCLABEL', r.system], ['Material', 'IFCLABEL', r.mat], ['Class', 'IFCLABEL', r.cls], ['NominalSize', 'IFCLABEL', r.size], Lm('OuterDiameter', sec.od), Lm('WallThickness', sec.wall), ['Slope', 'IFCREAL', round(r.slope || 0, 9)], Lm('Length', L)]);
    w.upsertPset(id, 'Pset_PipeSegmentTypeCommon', [['NominalDiameter', 'IFCPOSITIVELENGTHMEASURE', round(w.u(sec.nps * IN))], ['InnerDiameter', 'IFCPOSITIVELENGTHMEASURE', round(w.u(sec.id))], ['OuterDiameter', 'IFCPOSITIVELENGTHMEASURE', round(w.u(sec.od))]]);
    w.upsertQto(id, 'Qto_PipeSegmentBaseQuantities', [['Length', 'length', L]]);
  } else {
    const per = sec.round ? Math.PI * sec.w : sec.shape === 'oval' ? Math.PI * sec.h + 2 * (sec.w - sec.h) : 2 * (sec.w + sec.h);
    w.upsertPset(id, 'CadCraft_Duct', [['Run', 'IFCLABEL', run], ['System', 'IFCLABEL', r.system], ['Shape', 'IFCLABEL', sec.shape], Lm('Width', sec.w), Lm('Height', sec.h), Lm('Length', L), ['Flow', 'IFCREAL', round(r.flow || 0, 6)]]);
    w.upsertQto(id, 'Qto_DuctSegmentBaseQuantities', [['Length', 'length', L], ['OuterSurfaceArea', 'area', per * L]]);
  }
} else if (part.kind === 'valve') {

Elbow written as a revolved section

64-bit

The axis and angle of the solid of revolution that an elbow is written as.

axis at R to the side of the section; angle = θ

The axis position is rounded to 6 decimals of the file's unit, its direction to 12 decimals and the angle to 9 decimals in the file's angle unit (radians, or degrees when the project says so).

  • mep-model.js
  • lines 393 to 396
  • BimCraft 0.8.1
// revolve the section about an axis R off to the side; X of the profile = the width side
const Y = cross(part.d1, part.wd), loc = [dot(part.n, part.wd) * part.R, dot(part.n, Y) * part.R, 0], a = cross(part.d1, part.n), ax = [dot(a, part.wd), dot(a, Y), 0];
const axis = w.make('IFCAXIS1PLACEMENT', w.H(w.point3(round(w.u(loc[0])), round(w.u(loc[1])), 0)), w.H(w.dir(round(ax[0], 12), round(ax[1], 12), 0)));
return { type: 'SweptSolid', items: [w.make('IFCREVOLVEDAREASOLID', w.H(profileFor(w, sec)), w.H(frame(part.p0, part.d1, part.wd)), w.H(axis), round(part.th * angleUnit(w), 9))] };

Flat-oval outline

64-bit

The outline of a flat-oval duct with semicircular ends.

r = H ÷ 2; c = W ÷ 2 − r; points at c + r cos a, r sin a

Each semicircle is 12 straight pieces, so the written profile is a polygon inside the true oval. The corners are computed from cos and sin of evenly divided angles.

  • mep-model.js
  • lines 371 to 377
  • BimCraft 0.8.1
/** A flat oval's outline (w across, h high), semicircle ends of n pieces. */
function ovalOutline(W, H, n) {
  const r = H / 2, c = W / 2 - r, pts = [];
  for (let i = 0; i <= n; i++) { const a = -Math.PI / 2 + Math.PI * i / n; pts.push([c + r * Math.cos(a), r * Math.sin(a)]); }
  for (let i = 0; i <= n; i++) { const a = Math.PI / 2 + Math.PI * i / n; pts.push([-c + r * Math.cos(a), r * Math.sin(a)]); }
  return pts;
}

Position along a run

64-bit

How far along a run a plan point lies, and how far off the line.

t = clamp((p − a) · d, 0, L); s = Σ earlier legs + t

The point is projected on each leg in plan with the parameter clamped to the leg; the distance along is the running sum of leg lengths plus the projection. Leg lengths include the height change, the projection uses plan coordinates only.

  • mep-model.js
  • lines 844 to 856
  • BimCraft 0.8.1
/** Where a plan point lies along a run (its nearest leg): { s (mm from the first point), d (mm off the line), at, leg }. */
export function along(r, p) {
  const V = runVertices(r);
  let best = null, s0 = 0;
  for (let i = 0; i + 1 < V.length; i++) {
    const a = V[i], b = V[i + 1], L = dist(a, b), d = norm(sub(b, a));
    const t = Math.max(0, Math.min(L, (p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]));
    const q = add(a, mul(d, t)), off = Math.hypot(p[0] - q[0], p[1] - q[1]);
    if (!best || off < best.d) best = { s: s0 + t, d: off, at: q, leg: i };
    s0 += L;
  }
  return best;
}

Length of a run

64-bit

The length of a run's center line through its corners, with the slope applied.

L = Σ |Vᵢ − Vᵢ₋₁|

A 64-bit sum of three-dimensional distances between successive corners. Elbows are not subtracted, so this is the corner-to-corner length.

  • mep-model.js
  • line 857
  • BimCraft 0.8.1
export const runLength = (r) => { const V = runVertices(r); let L = 0; for (let i = 1; i < V.length; i++) L += dist(V[i - 1], V[i]); return L; };

Pipe size for a flow and a velocity limit

64-bit

The smallest pipe of a material and class whose velocity stays at or under the limit.

V = Q ÷ (π D² ÷ 4); first size with V ≤ V_max

A US gallon a minute is 3.785411784 ÷ 60 ÷ 1000 m³/s, a foot 0.3048 m and an inch 0.0254 m, all exact. The velocity is compared with the limit plus 1e-12 m/s. The result can only be one of the table's sizes.

  • mep-model.js
  • lines 904 to 916
  • BimCraft 0.8.1
/** The smallest pipe of a material and class whose flow velocity stays at or under vmax. q in gpm ('in') or L/s ('mm'),
 *  vmax in ft/s or m/s. → { size, v (typed units), id (mm) } or null. */
export function pipeForFlow(mat, cls, q, vmax, u = 'in') {
  const Q = q * (u === 'in' ? GPM : 0.001), Vm = vmax * (u === 'in' ? 0.3048 : 1);
  if (!(Q > 0) || !(Vm > 0)) return null;
  for (const size of sizesFor(mat, cls)) {
    const p = pipeSize(mat, cls, size);
    if (!p) continue;
    const D = p.id * 0.0254, V = Q / (Math.PI * D * D / 4);
    if (V <= Vm + 1e-12) return { size, v: V / (u === 'in' ? 0.3048 : 1), id: p.id * IN };
  }
  return null;
}

Duct size for a flow

64-bit

A duct sized by equal friction, returned in millimeters.

A thin wrapper: the sizing itself is in mep/sizing.js. Heights go in and sizes come out through a multiplication or division by 25.4 when the units are inches.

  • mep-model.js
  • lines 917 to 924
  • BimCraft 0.8.1
/** A duct sized by equal friction (the HVAC add-on's sizeSection), in mm: { w, h, v, rate, note } or null. q cfm ('in') or
 *  L/s ('mm'), rate in. w.g./100 ft or Pa/m; shape round | rect | oval with the fixed side h (mm). */
export function ductForFlow({ q, rate, shape, h, u = 'in' }) {
  const s = sizeSection({ q, rate, shape, h: h ? (u === 'in' ? h / IN : h) : null, u });
  if (!s) return null;
  const k = u === 'in' ? IN : 1;
  return { w: s.w * k, h: s.h * k, v: s.V, rate: s.rate, note: s.note };
}

Drain sizes by fixture units

64-bit

The smallest drain size that carries the fixture units reaching each sanitary run, never smaller than what flows into it.

grow when needed nominal > drawn nominal + 1e-9

The units and size tables are in mep/drainage.js; here nominal sizes are compared, and a run grows only when the needed size exceeds the drawn one by more than 1e-9.

  • mep-model.js
  • lines 867 to 886
  • BimCraft 0.8.1
export function drainPlan(w) {
  const cache = new Map();
  const runs = readRuns(w).filter((r) => r.kind === 'pipe' && r.system === 'san').map((r) => ({ r, sec: sectionOf(r) })).filter((x) => x.sec)
    .map(({ r, sec }) => ({ r, guid: r.guid, od: sec.od, verts: runVertices(r).map(worldOf(w, r.storey, cache)) }));
  const fixtures = readFixturePorts(w).filter((q) => q.domain === 'pipe' && q.system === 'san' && q.flow === 'out' && q.fixture)
    .map((q) => ({ id: q.id, p: worldOf(w, q.storey, cache)(q.p), size: q.size, kind: q.fixture }));
  const net = drainNetwork(runs, fixtures), by = new Map(runs.map((x) => [x.guid, x])), plan = new Map();
  for (const g of upstreamFirst(net)) {
    const { r } = by.get(g), n = net.get(g), std = standardOf(r.mat), units = std === 'br' ? n.uhc : n.dfu;
    const sizes = sizesFor(r.mat, r.cls).map((key) => ({ key, nominal: nominalOf(pipeSize(r.mat, r.cls, key)) }));
    // never smaller than what flows into it (sized first), nor than the fixture drains it serves; a Brazilian building drain DN 100
    let floor = std === 'br' ? Math.max(n.minDn, r.role === 'main' ? MAIN_MIN_DN : 0) : n.minIn;
    for (const u of n.up) { const q = plan.get(u); if (q && q.std === std) floor = Math.max(floor, q.after); }
    const now = nominalOf(pipeSize(r.mat, r.cls, r.size));
    const need = units > 0 || floor > 0 ? smallestSize(std, sizes, units, { slope: r.slope, role: r.role, floor }) : null;
    const grow = !!need && need.nominal > now + 1e-9;
    plan.set(g, { id: r.id, guid: g, std, units, floor, size: r.size, need: need?.key ?? null, needNominal: need?.nominal ?? null, grow, toobig: (units > 0 || floor > 0) && !need, after: grow ? need.nominal : now });
  }
  return plan;
}

Pipe and duct schedules

64-bit

Total length and piece count per pipe size, and length and surface per duct size.

length = Σ stored piece lengths; surface = Σ perimeter × length

Each piece's length is read back from its property set, where it was stored rounded to 6 decimals of the file's unit, multiplied by the millimeters per unit and added in order. Duct surface is perimeter × that length.

  • mep-model.js
  • lines 970 to 993
  • BimCraft 0.8.1
/** Schedules from the parts as written: pipe and duct by size with their lengths, fittings and valves by kind and size. */
export function runSchedules(w) {
  const pipes = new Map(), ducts = new Map(), fits = new Map();
  const so = w.storeyOf();
  const bump = (m, k, row, by) => { if (!m.has(k)) m.set(k, { ...row, n: 0, length: 0, area: 0, ids: [] }); const x = m.get(k); x.n++; x.length += by.length || 0; x.area += by.area || 0; x.ids.push(by.id); };
  for (const r of readRuns(w)) {
    const sec = sectionOf(r);
    if (!sec) continue;
    for (const id of r.parts) {
      const ps = w.readPset(id, r.kind === 'pipe' ? 'CadCraft_Pipe' : 'CadCraft_Duct');
      if (ps) {
        const L = (Number(ps.Length) || 0) * w.unitMm;
        if (r.kind === 'pipe') bump(pipes, [r.system, r.mat, r.cls, r.size].join('|'), { system: r.system, mat: r.mat, cls: r.cls, size: r.size, label: sec.label, od: sec.od, p: sec.p }, { length: L, id });
        else { const per = sec.round ? Math.PI * sec.w : sec.shape === 'oval' ? Math.PI * sec.h + 2 * (sec.w - sec.h) : 2 * (sec.w + sec.h); bump(ducts, [r.system, sec.shape, sec.w, sec.h].join('|'), { system: r.system, shape: sec.shape, label: sec.label, w: sec.w, h: sec.h }, { length: L, area: per * L, id }); }
        continue;
      }
      const f = w.readPset(id, 'CadCraft_MepFitting') || w.readPset(id, 'CadCraft_Valve');
      if (!f) continue;
      const kind = String(f.Kind || '');
      bump(fits, [r.kind, r.system, kind, f.Size, f.AngleDegrees ?? ''].join('|'), { kind: r.kind, system: r.system, what: kind, valve: !!VALVE_KINDS[kind], size: String(f.Size || ''), angle: f.AngleDegrees ?? null, storey: so.get(id) }, { id });
    }
  }
  return { pipes: [...pipes.values()], ducts: [...ducts.values()], fittings: [...fits.values()] };
}
mep-calc.js BimCraft 0.8.1 64-bit 2 calculations Reviewed

Tests tools/bimcraft-test/eg-mep-test.mjs

Friction rate of a round duct at the velocity limit

64-bit

The pressure drop per meter used to propose a duct size when the calculation ran by velocity.

D = √(4 Q ÷ (π v)); rate = 0.02 × (1.204 ÷ D) × v² ÷ 2

The friction factor 0.02 and the air density 1.204 kg/m³ are fixed, rounded figures. A result of zero or not-a-number is replaced by 0.8 Pa/m.

  • mep-calc.js
  • line 124
  • BimCraft 0.8.1
const roundRate = (Q, vmax) => { const D = Math.sqrt(4 * Q / (Math.PI * vmax)); return 0.02 * (1.204 / D) * vmax * vmax / 2 * 1.0 || 0.8; };   // f ≈ 0.02 at the velocity limit

Proposed duct sizes

64-bit

The width and height proposed for each duct run that is too small or too fast.

size for the largest flow in the run, rounded to 1 mm

The largest flow of the run's pieces is multiplied by 1000 to liters a second and sized by equal friction; the proposed width and height are rounded to whole millimeters before they are offered.

  • mep-calc.js
  • lines 114 to 120
  • BimCraft 0.8.1
const byRun = new Map();
for (const s of res.segs) if ((s.status === 'small' || s.status === 'fast') && s.flow > 0) byRun.set(s.run, Math.max(byRun.get(s.run) || 0, s.flow));
const proposals = [];
for (const [guid, Q] of byRun) {
  const x = runs.find((r) => r.id === guid), rate = res.opts.method === 'friction' ? res.opts.rate : roundRate(Q, res.opts.vmax);
  const d = ductForFlow({ q: Q * 1000, rate, shape: x.r.shape, h: x.r.shape === 'round' ? null : x.sec.h, u: 'mm' });
  if (d) proposals.push({ guid, w: Math.round(d.w), h: x.r.shape === 'round' ? Math.round(d.w) : Math.round(d.h) });
mep-ui.js BimCraft 0.8.1 64-bit 3 calculations Reviewed

Tests None found by the review

Reading a typed slope

64-bit

Turns '1/4"/ft', '2%', '1:50' or '0.02' into rise per run.

inch per foot ÷ 12; percent ÷ 100; 1 ÷ n

Fractions of an inch per foot are divided by 12, percentages by 100 and 1:n is 1 ÷ n, each one 64-bit division of numbers parsed with Number. A bare number is accepted only below 0.2.

  • mep-ui.js
  • lines 45 to 55
  • BimCraft 0.8.1
/** '1/4', '1/4"/ft', '0.25 in/ft', '2%', '2 %', '1:50', '0.02' → rise per run, or NaN. */
export function parseSlope(text) {
  const s = String(text || '').trim().toLowerCase().replace(',', '.');
  if (s === '' || s === '0') return 0;
  let m = /^([\d.]+)\s*%$/.exec(s); if (m) return Number(m[1]) / 100;
  m = /^1\s*:\s*([\d.]+)$/.exec(s); if (m) return 1 / Number(m[1]);
  m = /^(?:(\d+)[-\s])?(\d+)\/(\d+)\s*(?:"|in)?\s*(?:\/\s*(?:ft|'))?$/.exec(s); if (m) return ((Number(m[1]) || 0) + Number(m[2]) / Number(m[3])) / 12;
  m = /^([\d.]+)\s*(?:"|in)\s*(?:\/\s*(?:ft|'))?$/.exec(s); if (m) return Number(m[1]) / 12;
  m = /^([\d.]+)$/.exec(s); if (m) { const v = Number(m[1]); return v < 0.2 ? v : NaN; }
  return NaN;
}

Schedule totals

64-bit

The total length, piece count and duct surface shown under the pipe and duct schedules.

ft² = mm² ÷ 92903.04

Totals are 64-bit sums of the per-size rows. Surface is shown rounded to 0.1 m² or 0.1 ft²; a square foot is taken as 92903.04 mm², which is exact (304.8²).

  • mep-ui.js
  • lines 546 to 550
  • BimCraft 0.8.1
const len = (mm) => L(mm), area = (mm2) => imperial() ? (Math.round(mm2 / 92903.04 * 10) / 10) + ' ' + t('mep.unit.sf') : (Math.round(mm2 / 1e5) / 10) + ' m²';   // i18n-ignore: unit
const pipes = S.pipes.sort((a, b) => (a.system + a.mat + a.cls).localeCompare(b.system + b.mat + b.cls) || (a.od - b.od));
const ducts = S.ducts.sort((a, b) => a.system.localeCompare(b.system) || (b.w * b.h - a.w * a.h));
const fits = S.fittings.sort((a, b) => (a.kind + a.system + a.what).localeCompare(b.kind + b.system + b.what));
R.sched = { pipes, ducts, mepfit: fits };

Runs handed to the takeoff

64-bit

The plan length, pitch and surface height of pipe and duct pieces passed to TakeoffCraft.

pitch = atan(slope) × 180 ÷ π; heightFt = perimeter ÷ 304.8

Only the plan length of a piece is passed, so pieces under 1 mm in plan (risers) are left out. The pitch is atan(slope) in degrees and a duct's perimeter is divided by 304.8, the exact millimeters in a foot.

  • mep-ui.js
  • lines 581 to 585
  • BimCraft 0.8.1
const pl = Math.hypot(p.b[0] - p.a[0], p.b[1] - p.a[1]);
if (pl < 1) return;   // risers: a run on a plan cannot measure them
const key = r.kind === 'pipe' ? def(['pipe', r.system, r.mat, r.cls, r.size, r.slope ? 's' : ''].join(':'), { name: t('mep.to.pipe', { size: sizeLabel(sec), mat: t('mep.cls.' + r.mat, { c: r.cls }), sys: systemLabel('pipe', r.system) }), type: 'linear', ...(r.slope > 0 ? { slope: 'rake', pitch: Math.atan(r.slope) * 180 / Math.PI } : {}) })
  : def(['duct', r.system, sec.shape, Math.round(sec.w), Math.round(sec.h)].join(':'), { name: t('mep.to.duct', { size: sizeLabel(sec), shape: t('mep.shape.' + sec.shape), sys: systemLabel('duct', r.system) }), type: 'linear', heightFt: (sec.round ? Math.PI * sec.w : sec.shape === 'oval' ? Math.PI * sec.h + 2 * (sec.w - sec.h) : 2 * (sec.w + sec.h)) / FT });
lv.runs.push({ item: key, pts: [[p.a[0], p.a[1]], [p.b[0], p.b[1]]] });

In detail

Method
Elbow: θ = acos(d₁ · d₂), tangent length R tan(θ ÷ 2). Pipe by velocity: the smallest table size with Q ÷ (π D² ÷ 4) at or under the limit. Duct by equal friction through mep/sizing.js. Lengths are sums of straight-piece lengths.
Number types
64-bit numbers; points are arrays of three numbers in millimeters.
Tolerances
All absolute, in millimeters unless said: run ends join within max(5, 0.3 × the smaller section side); a tee foot within max of that and half the host width; a turn under 0.5° is straight and over 179° is refused; legs and pieces under 1 mm are refused; corners within 0.5 mm are one; ports connect within 0.6 mm; two sections are the same size within 0.5 mm; a fixed end moves when it is more than 0.05 mm off its port; velocity limit compared with 1e-12 m/s slack.
Checked by
tools/bimcraft-test/mep-test.mjs calls runVertices, mepLayout, pipeForFlow, ductForFlow, ductLoss and runSchedules; tools/bimcraft-test/eg-mep-test.mjs calls calcPipes, calcDucts, calcCarriers and calcLighting; bin/test-bimcraft-drainage.mjs calls drainPlan.
Limits
Fittings and valves are generic bodies sized from the section, not a maker's dimensions. Flat-oval ends and duct transitions are polygons. A run's length counts straight pieces only.

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.

Every policy that takes this approach