Resources Numerical accuracy Calculation policy

Duct runs, fittings and schedule

Works out the elbows, transitions and branch fittings of drawn duct runs, the straight lengths between them, and the schedule of lengths and sheet-metal areas.

How the precision is kept

Geometry is 64-bit: tangent lengths from tan(θ ÷ 2), lengths from Math.hypot, areas as perimeter × length with exact divisors (12, 144, 1000, 1e6). Fitting areas follow stated simplifications (average perimeter × slant length for non-rectangular transitions). The drawn schedule shows 1 decimal and the CSV 3. Converting a run between inch and millimeter sizes uses 25.4 exactly, and a rounded factor for airflow.

Open item. Replace 0.47194745 in convertRun with 0.4719474432, the exact liters per second in one cubic foot per minute.

Calculations under this policy

15 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.

addons/hvac/lib/duct.js CadCraft 0.8.2 64-bit 11 calculations Reviewed

Tests None found by the review

A run moved, turned or scaled

64-bit

Applies a move, rotation, scale or mirror to a run's centerline and its scale.

p′ = M · p; sc = √|det M|; k′ = k ÷ sc; z′ = z × sc

The scale is the square root of the absolute determinant, so a mirror gives a positive scale. k and z change only when the scale differs from 1 by more than 1e-12, which keeps a pure rotation from altering them through rounding.

  • addons/hvac/lib/duct.js
  • lines 100 to 108
  • CadCraft 0.8.2
/** A move / rotate / scale / mirror (matrix [a, b, c, d, e, f]) baked into the run: points move, sizes stay, drawn widths follow a scale. */
export function transformDuct(d, m) {
  const out = structuredClone(d);
  out.pts = d.pts.map(([x, y]) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]);
  const sc = Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2]));
  if (sc > 1e-12 && Math.abs(sc - 1) > 1e-12) { out.k = d.k / sc; out.z = d.z * sc; }
  out.links = { taps: [], s: null, e: null };   // worked out again from where everything is now
  return out;
}

Section width in drawing units

64-bit

The drawn width of a duct section.

width = w ÷ k

One division of the size by k, the size units per drawing unit.

  • addons/hvac/lib/duct.js
  • lines 111 to 112
  • CadCraft 0.8.2
/** Plan width and height of a section in drawing units. */
export const planW = (d, s) => s.w / d.k;

Elbows, transitions and straight pieces

64-bit

The fittings of a run and the start and end of each straight piece along its centerline.

T = R tan(θ ÷ 2), R = r × W; mitred: T = (W ÷ 2) tan(θ ÷ 2); elbow length = R θ or 2T; transition L = max(max(ΔW ÷ 2, ΔH) ÷ tan 15° ÷ k, minimum)

Turns of 1° or less get no elbow, and tan(θ ÷ 2) is not evaluated above 179°. Trims at each end of a segment are added up and subtracted from its length; a piece that comes out negative by more than 1e-9 is reported and set to zero length.

  • addons/hvac/lib/duct.js
  • lines 164 to 209
  • CadCraft 0.8.2
/**
 * The run's fittings and straight pieces (drawing units). Elbows sit on turns (radius: centreline radius r × the width,
 * tangent length R·tan(θ/2); mitred: W/2·tan(θ/2)); a size change makes a transition just downstream of the corner (after
 * its elbow), as long as its sides need to slope no more than TRANSITION_DEG; a branch end is cut back to the main's side
 * and gets a collar. → { segs, pieces [{seg, s0, s1}], elbows, transitions, taps, problems }
 */
export function layoutDuct(d) {
  const G = segsOf(d.pts), n = G.length;
  const trimS = new Array(n).fill(0), trimE = new Array(n).fill(0);
  const elbows = [], transitions = [], problems = [];
  const minT = MIN_TRANSITION[d.u] / d.k, tanA = Math.tan((TRANSITION_DEG * Math.PI) / 180);
  for (let i = 1; i < n; i++) {
    const A = G[i - 1], B = G[i], sa = d.segs[i - 1], sb = d.segs[i];
    const turn = turnOf(A.t, B.t), theta = Math.abs(turn);
    if (theta > (179 * Math.PI) / 180) { problems.push({ kind: 'hairpin', at: i }); continue; }
    if (theta > Math.PI / 180) {
      const W = planW(d, sa);
      const mitred = d.elbow === 'mitred';
      const R = mitred ? 0 : d.r * W;
      const T = mitred ? (W / 2) * Math.tan(theta / 2) : R * Math.tan(theta / 2);
      trimE[i - 1] += T; trimS[i] += T;
      elbows.push({ at: i, V: A.b, tin: A.t, tout: B.t, theta, side: turn >= 0 ? 1 : -1, R, T, sec: sa, mitred, len: mitred ? 2 * T : R * theta });
    }
    if (!same(sa, sb)) {
      const dW = Math.abs(sa.w - sb.w) / 2, dH = Math.abs(sa.h - sb.h);   // flat bottom: the top takes the whole height change
      const L = Math.max(Math.max(dW, dH) / tanA / d.k, minT);
      transitions.push({ seg: i, s: trimS[i], len: L, from: sa, to: sb });
      trimS[i] += L;
    }
  }
  const taps = [];
  for (const end of ['s', 'e']) {
    const l = d.links?.[end];
    if (!l || !n) continue;
    const i = end === 's' ? 0 : n - 1, sec = d.segs[i];
    const c = COLLAR[d.u] / d.k;
    if (end === 's') trimS[0] = Math.max(trimS[0], l.cut); else trimE[n - 1] = Math.max(trimE[n - 1], l.cut);
    taps.push({ end, seg: i, cut: l.cut, collar: c, sec, kind: l.kind, ang: l.ang, main: l.main || null });
  }
  const pieces = G.map((g, i) => {
    const s0 = trimS[i], s1 = g.len - trimE[i];
    if (s1 < s0 - 1e-9) problems.push({ kind: 'short', at: i });
    return { seg: i, s0, s1: Math.max(s0, s1) };
  });
  return { segs: G, pieces, elbows, transitions, taps, problems };
}

Length in feet or meters

64-bit

Converts a length in drawing units to feet or meters.

feet = du × k ÷ 12; meters = du × k ÷ 1000

A multiplication by k and a division by the exact 12 or 1000.

  • addons/hvac/lib/duct.js
  • lines 317 to 318
  • CadCraft 0.8.2
/** Length in drawing units → feet or metres; area of a perimeter (size units) over a length (drawing units) → ft² or m². */
const lenOut = (d, du) => (d.u === 'in' ? (du * d.k) / 12 : (du * d.k) / 1000);

Sheet-metal area of a length of duct

64-bit

The surface area of a length of duct in square feet or square meters.

ft² = perimeter × du × k ÷ 144; m² = perimeter × du × k ÷ 1e6

Products in 64-bit and a division by the exact 144 or 1e6. The perimeter comes from section() in sizing.js.

  • addons/hvac/lib/duct.js
  • line 319
  • CadCraft 0.8.2
const areaOut = (d, perim, du) => (d.u === 'in' ? (perim * du * d.k) / 144 : (perim * du * d.k) / 1e6);

Elbow area and angle

64-bit

The sheet-metal area of an elbow and the angle in its name.

area = perimeter × centerline length; name angle = round(θ × 180 ÷ π)

The area uses the unrounded centerline length. The angle in the name is rounded to a whole degree, and the schedule groups fittings by name, so elbows that round to the same degree share a row.

  • addons/hvac/lib/duct.js
  • lines 326 to 330
  • CadCraft 0.8.2
const deg = Math.round((e.theta * 180) / Math.PI);
const p = section(e.sec.s, e.sec.w, e.sec.h).perimeter;
const name = e.mitred ? t('fit.elbow.mitred', { deg, size: sizeText(e.sec) })
  : t(e.sec.s === 'rect' ? 'fit.elbow.rect' : 'fit.elbow.round', { deg, size: sizeText(e.sec), r: fmtNum(d.r, 2) });
out.push({ kind: 'elbow', name, area: areaOut(d, p, e.len), sec: e.sec, x: e.V.x, y: e.V.y, deg });

Transition area

64-bit

The sheet-metal area of a transition between two sizes.

rectangular: bottom + top + two sides as trapezoids over their slant lengths; other: (pₐ + p_b) ÷ 2 × slant length

Slant lengths are Math.hypot of the transition length and the offset. The second formula is exact for a concentric round reducer and close for other shapes, as the comment says.

  • addons/hvac/lib/duct.js
  • lines 333 to 346
  • CadCraft 0.8.2
const a = tr.from, b = tr.to, Ls = tr.len * d.k;   // size units
let area;
if (a.s === 'rect' && b.s === 'rect') {
  // four flat trapezoids (flat bottom, centred on plan): bottom (wa+wb)/2·L, top the same over its slope,
  // the two sides (ha+hb)/2 over the slope of the side wall
  const bot = ((a.w + b.w) / 2) * Ls, top = ((a.w + b.w) / 2) * Math.hypot(Ls, a.h - b.h);
  const sides = 2 * ((a.h + b.h) / 2) * Math.hypot(Ls, (a.w - b.w) / 2);
  area = bot + top + sides;
} else {
  // average perimeter × slant length: exact for a concentric round reducer (π (r1 + r2) s), close for the others
  const pa = section(a.s, a.w, a.h).perimeter, pb = section(b.s, b.w, b.h).perimeter;
  area = ((pa + pb) / 2) * Math.hypot(Ls, (Math.max(a.w, a.h) - Math.max(b.w, b.h)) / 2);
}
area = d.u === 'in' ? area / 144 : area / 1e6;

Straight pieces

64-bit

The length and area of each straight piece of a run.

du = s₁ − s₀; length = lenOut(du); area = perimeter × du

A piece is the segment length less the fitting trims at its two ends; pieces of 1e-9 drawing units or less are left out.

  • addons/hvac/lib/duct.js
  • lines 361 to 369
  • CadCraft 0.8.2
/** The straight pieces of one run: { sec, len (ft/m), area (ft²/m²), pts [[a], [b]] (drawing coordinates) }. */
export function straightsOf(d) {
  const L = layoutDuct(d);
  return L.pieces.filter((p) => p.s1 - p.s0 > 1e-9).map((p) => {
    const g = L.segs[p.seg], sec = d.segs[p.seg], du = p.s1 - p.s0;
    const a = at(g, p.s0), b = at(g, p.s1);
    return { sec, len: lenOut(d, du), area: areaOut(d, section(sec.s, sec.w, sec.h).perimeter, du), pts: [[a.x, a.y], [b.x, b.y]] };
  });
}

Schedule totals

64-bit

The total duct length, total sheet-metal area and number of fittings.

length = Σ rows; area = Σ duct areas + Σ fitting areas

Plain 64-bit sums over the rows in sorted order; each row is itself a running sum over its pieces. Nothing is rounded here.

  • addons/hvac/lib/duct.js
  • line 405
  • CadCraft 0.8.2
totals: { len: dl.reduce((s, r) => s + r.len, 0), area: dl.reduce((s, r) => s + r.area, 0) + fl.reduce((s, r) => s + r.area, 0), fittings: fl.reduce((s, r) => s + r.n, 0) },

A run in the other unit system

64-bit

Converts a run's sizes and airflows between inches with cfm and millimeters with L/s.

size × 25.4 or ÷ 25.4; airflow × 0.47194745 or ÷ 0.47194745

25.4 is exact. The airflow factor is rounded: one cubic foot per minute is exactly 0.4719474432 L/s, and 0.47194745 is about 1.4e-8 too large in relative terms. ductnet.js in the same add-on uses a different rounding of the same factor.

  • addons/hvac/lib/duct.js
  • lines 409 to 414
  • CadCraft 0.8.2
/** A run with its sizes and airflows in the other unit system (in ↔ mm; cfm ↔ L/s), its geometry unchanged. */
export function convertRun(d, u) {
  if (d.u === u) return d;
  const f = u === 'mm' ? 25.4 : 1 / 25.4, fq = u === 'mm' ? 0.47194745 : 1 / 0.47194745;
  return { ...d, u, k: d.k * f, segs: d.segs.map((s) => ({ ...s, w: s.w * f, h: s.h * f, ...(s.q ? { q: s.q * fq } : {}) })) };
}
addons/hvac/main.js CadCraft 0.8.2 64-bit 4 calculations Reviewed

Tests None found by the review

A typed duct size

64-bit

Reads a size such as 24x12 or Ø14.

The matched text goes to Number(), so the typed decimals are kept. Each number must start with a digit; a size typed as .5x12 does not match and is refused, not misread.

  • addons/hvac/main.js
  • lines 30 to 39
  • CadCraft 0.8.2
/** "24x12", "24 × 12", "Ø14", "14" → { w, h } in size units (round: w = h). null when it does not read. */
export function parseSize(text, shape) {
  const s = String(text ?? '').trim().replace(/^(ø|Ø|d|dia\.?)\s*/i, '');
  if (shape === 'round') { const d = Number(s); return Number.isFinite(d) && d > 0 ? { w: d, h: d } : null; }
  const m = /^(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)$/i.exec(s);
  if (!m) return null;
  const w = Number(m[1]), h = Number(m[2]);
  if (!(w > 0 && h > 0) || (shape === 'oval' && w < h)) return null;
  return { w, h };
}

A point pulled onto a run's centerline

64-bit

Moves a point picked inside a duct's outline onto its centerline, to start or end a branch there.

inside when distance ≤ W ÷ 2 + 1e-9

The stored point is the projection on the centerline from nearestOnPath in mep-core.js. The nearest run wins.

  • addons/hvac/main.js
  • lines 122 to 133
  • CadCraft 0.8.2
/** A point inside a run's outline, pulled onto its centreline (to start or end a branch there). → { x, y, run } | null */
function onRun(doc, p, except = null) {
  let best = null;
  for (const r of runsIn(doc)) {
    if (except && r.data.id === except) continue;
    const n = nearestOnPath(r.data.pts, p);
    if (!n) continue;
    const W = planW(r.data, r.data.segs[n.seg]);
    if (n.dist <= W / 2 + 1e-9 && (!best || n.dist < best.dist)) best = { x: n.x, y: n.y, dist: n.dist, run: r };
  }
  return best;
}

Schedule table in the drawing

64-bit

The rows of the duct schedule placed in the drawing.

shown length and area = value.toFixed(1), trailing zeros removed

Lengths and areas in the drawn table are text with 1 decimal (fmtNum in mep-core.js). The schedule itself keeps the full values.

  • addons/hvac/main.js
  • lines 321 to 331
  • CadCraft 0.8.2
// ---------------------------------------------------------------------------------------------- DUCTSCHED and tables
function tableRows(doc) {
  const runs = runsIn(doc).map((r) => r.data);
  if (!runs.length) return null;
  const S = schedule(runs), U = units(S.u);
  const rows = [[t('col.item'), t('col.qty'), t('col.area', { unit: U.area })]];
  for (const r of S.ducts) rows.push([r.name, fmtNum(r.len, 1) + ' ' + U.len, fmtNum(r.area, 1)]);
  for (const f of S.fittings) rows.push([f.name, String(f.n), fmtNum(f.area, 1)]);
  rows.push([t('row.total'), t('row.total.v', { len: fmtNum(S.totals.len, 1), lu: U.len, n: S.totals.fittings }), fmtNum(S.totals.area, 1)]);
  return { S, rows };
}

Schedule CSV

64-bit

Writes the duct schedule as CSV.

length, perimeter, area: 3 decimals; velocity: 0 decimals (inch units) or 2; friction rate: 4 decimals

Each number is rounded with toFixed and converted back with unary plus, so the file holds at most that many decimals.

  • addons/hvac/main.js
  • lines 366 to 379
  • CadCraft 0.8.2
// ---------------------------------------------------------------------------------------------- files
function scheduleCsv(doc) {
  const runs = runsIn(doc).map((r) => r.data);
  if (!runs.length) throw new Error(t('err.noducts'));
  const S = schedule(runs), U = units(S.u);
  const rows = [[t('csv.ducts')], [t('col.item'), t('col.shape'), t('col.size', { unit: U.size }), t('col.mat'), t('col.len', { unit: U.len }), t('col.perimeter', { unit: U.size }), t('col.area', { unit: U.area })]];
  for (const r of S.ducts) rows.push([r.name, t('shape.' + r.sec.s), sizeText(r.sec), t('mat.' + r.mat), +r.len.toFixed(3), +r.perimeter.toFixed(3), +r.area.toFixed(3)]);
  rows.push([], [t('csv.fittings')], [t('col.item'), t('col.count'), t('col.areaeach', { unit: U.area }), t('col.area', { unit: U.area })]);
  for (const f of S.fittings) rows.push([f.name, f.n, +f.areaEach.toFixed(3), +f.area.toFixed(3)]);
  rows.push([], [t('csv.sections')], [t('col.tag'), t('col.system'), t('col.section'), t('col.size', { unit: U.size }), t('col.len', { unit: U.len }), t('col.q', { unit: U.q }), t('col.v', { unit: U.v }), t('col.rate', { unit: U.rate })]);
  for (const s of S.sections) rows.push([s.tag, t('sys.' + s.sys), s.seg + 1, sizeText(s.sec), +s.len.toFixed(3), s.q ?? '', s.V === null ? '' : +s.V.toFixed(u2(S.u)), s.rate === null ? '' : +s.rate.toFixed(4)]);
  rows.push([], [t('row.total'), '', '', '', +S.totals.len.toFixed(3), '', +S.totals.area.toFixed(3)]);
  return csv(rows);
}

In detail

Method
Elbow tangent length T = R tan(θ ÷ 2) (radius) or (W ÷ 2) tan(θ ÷ 2) (mitred); transition length = larger size change ÷ tan 15° with a minimum; a branch is cut back by half the main's width ÷ sin of the angle between them; sheet-metal area = perimeter × length.
Number types
64-bit numbers.
Tolerances
A branch end is on a main when within 1e-7 × max(1, |x|, |y|) of its centerline; a branch is ignored when the sine of the angle is under 0.2; turns under 1° make no elbow and over 179° are reported; sections are the same size within 1e-9; repeated points within 1e-9 are dropped.
Limits
Rectangular transitions are summed as four flat trapezoids. Other shapes use average perimeter × slant length, which the code's comment calls exact for a concentric round reducer and close for the others. The branch angle is stored rounded to 0.1 degree. Fittings are grouped by their name, which carries the angle rounded to a whole degree.

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