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.
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;
Branches found on mains
64-bit
Finds where a run's end sits on another run's centerline, the angle between them and how far the branch is cut back.
The on-centerline test scales with the coordinates: 1e-7 × max(1, |x|, |y|). The asin argument is capped at 1. The stored angle is rounded to 0.1 degree; the cut and opening use the unrounded sine.
addons/hvac/lib/duct.js
lines 127 to 161
CadCraft 0.8.2
/**
* Work out branches: a run's end on another run's centreline (not at its ends) is a branch there. runs = [{ id, data }].
* → Map id → { taps, s, e } (drawing units; `at` along the main's segment, `cut` = how far the branch stops short of the
* main's centreline: half the main's width over the sine of the angle between them).
*/
export function linkRuns(runs) {
const out = new Map(runs.map((r) => [r.id, { taps: [], s: null, e: null }]));
for (const B of runs) {
const bs = segsOf(B.data.pts);
if (!bs.length) continue;
for (const end of ['s', 'e']) {
const seg = end === 's' ? bs[0] : bs[bs.length - 1];
const p = end === 's' ? seg.a : seg.b;
const into = end === 's' ? seg.t : { x: -seg.t.x, y: -seg.t.y }; // pointing from the joint into the branch
const tol = 1e-7 * Math.max(1, Math.abs(p.x), Math.abs(p.y));
for (const A of runs) {
if (A === B) continue;
const near = nearestOnPath(A.data.pts, p);
const total = pathLength(A.data.pts);
if (!near || near.dist > tol || near.s <= tol || near.s >= total - tol) continue;
const ga = segsOf(A.data.pts)[near.seg];
const cr = ga.t.x * into.y - ga.t.y * into.x, sin = Math.abs(cr);
if (sin < 0.2) continue; // running along the main, not off it
const ms = A.data.segs[near.seg], bsec = B.data.segs[end === 's' ? 0 : B.data.segs.length - 1];
const ang = Math.round((Math.asin(Math.min(1, sin)) * 180) / Math.PI * 10) / 10;
const kind = bsec.s !== 'rect' ? 'takeoff' : Math.abs(90 - ang) <= 15 ? 'tee' : 'lateral';
out.get(B.id)[end] = { on: A.id, cut: planW(A.data, ms) / 2 / sin, ang, kind, main: { s: ms.s, w: ms.w, h: ms.h } };
out.get(A.id).taps.push({ seg: near.seg, at: near.at, w: planW(B.data, bsec) / sin, side: cr > 0 ? 1 : -1, by: B.id });
break;
}
}
}
for (const l of out.values()) l.taps.sort((a, b) => a.seg - b.seg || a.at - b.at);
return out;
}
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.
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.
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 } : {}) })) };
}
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 };
}
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.