Resources Numerical accuracy Calculation policy

Pipe runs: layout, 3D solid and schedule

Lays out a pipe run with its elbows, reducers and branches, builds its 3D solid, and adds up pipe lengths, weights and fittings for the schedule.

How the precision is kept

A run is a centerline of 64-bit points with a size per span, a start elevation and a slope. Lengths are sums of segment lengths converted with exact factors (12, 25.4, 1000) and multiplied by √(1 + slope²). Decisions use small tolerances: 1e-9 drawing units for repeated points, and 1e-7 scaled to the coordinates for a branch end on a main. The exported schedule CSV and the table on the drawing round the values they show.

Open item. Write schedule CSV lengths, weights and elevations without the toFixed(3), toFixed(1) and toFixed(2) rounding.

Calculations under this policy

31 calculations in 4 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/piping/lib/mep-core.js CadCraft 0.8.2 64-bit 13 calculations Reviewed

Tests tools/addons-test/browser-mep.mjs

Size units per drawing unit

64-bit

How many inches or millimeters one drawing unit is.

factor = (meters per drawing unit) ÷ 0.0254 or ÷ 0.001

One 64-bit division of two decimal constants from the unit table (0.0254, 0.3048, 0.9144, 0.001, 0.01, 1). For a drawing in feet sized in inches, 0.3048 ÷ 0.0254 evaluates to 12.000000000000002, not 12.

  • addons/piping/lib/mep-core.js
  • lines 46 to 47
  • CadCraft 0.8.2
/** Size units ('in' or 'mm') per drawing unit: a 24 in duct in a drawing in feet is 2 drawing units wide. */
export const perUnit = (doc, u) => mPerUnitOf(doc) / (u === 'in' ? 0.0254 : 0.001);

Feet per drawing unit

64-bit

The factor that turns drawing units into feet for the takeoff export.

feet = (meters per drawing unit) ÷ 0.3048

One 64-bit division by the exact foot, 0.3048 m.

  • addons/piping/lib/mep-core.js
  • lines 48 to 49
  • CadCraft 0.8.2
/** Feet per drawing unit (TakeoffCraft counts in feet). */
export const ftPerUnit = (doc) => mPerUnitOf(doc) / 0.3048;

Segments of a run

64-bit

Each straight stretch of a run with its length, unit direction, left normal and distance from the start.

t = (b − a) ÷ |b − a|; n = (−t_y, t_x); s₀ = Σ earlier lengths

The length is Math.hypot of the coordinate differences and the direction is the vector divided by it; a zero-length segment gets the direction (1, 0). The start distance is the running sum of the earlier lengths.

  • addons/piping/lib/mep-core.js
  • lines 64 to 75
  • CadCraft 0.8.2
/** The run's segments: [{ a, b, len, t (unit direction), n (left normal), s0 (distance from the run's start) }]. */
export function segs(pts) {
  const out = [];
  let s = 0;
  for (let i = 0; i + 1 < pts.length; i++) {
    const a = P(pts[i]), b = P(pts[i + 1]), d = sub(b, a), l = len(d);
    const tt = l > 0 ? { x: d.x / l, y: d.y / l } : { x: 1, y: 0 };
    out.push({ a, b, len: l, t: tt, n: { x: -tt.y, y: tt.x }, s0: s });
    s += l;
  }
  return out;
}

Point at a distance along a run

64-bit

The point a given distance along the centerline.

The distance within the segment is clamped to [0, segment length], so a distance before the start or past the end returns the end point.

  • addons/piping/lib/mep-core.js
  • lines 78 to 86
  • CadCraft 0.8.2
/** The point `s` along the run (clamped to it): { x, y, seg, at, t }. */
export function pointAt(pts, s) {
  const list = segs(pts);
  if (!list.length) return null;
  let i = 0;
  while (i < list.length - 1 && s > list[i].s0 + list[i].len) i++;
  const g = list[i], at = Math.max(0, Math.min(g.len, s - g.s0));
  return { x: g.a.x + g.t.x * at, y: g.a.y + g.t.y * at, seg: i, at, t: g.t };
}

Nearest point on a run

64-bit

The point of a run's centerline closest to a given point.

at = clamp((p − a)·t, 0, length)

The projection uses the segment's unit direction and is clamped to the segment. A later segment replaces the best so far only when it is closer by more than 1e-12 (absolute), so ties go to the earlier segment.

  • addons/piping/lib/mep-core.js
  • lines 88 to 97
  • CadCraft 0.8.2
/** The nearest point of the run to p: { seg, at (along that segment), s (along the run), dist, x, y }. */
export function nearestOnPath(pts, p) {
  let best = null;
  segs(pts).forEach((g, i) => {
    const at = g.len > 0 ? Math.max(0, Math.min(g.len, (p.x - g.a.x) * g.t.x + (p.y - g.a.y) * g.t.y)) : 0;
    const x = g.a.x + g.t.x * at, y = g.a.y + g.t.y * at, dist = Math.hypot(p.x - x, p.y - y);
    if (!best || dist < best.dist - 1e-12) best = { seg: i, at, s: g.s0 + at, dist, x, y };
  });
  return best;
}

Turn between two directions

64-bit

The signed angle from one direction to the next.

turn = atan2(t₁ × t₂, t₁ · t₂)

atan2 of the cross and dot products, so no clamping is needed and the angle keeps its precision near 0° and near 180°.

  • addons/piping/lib/mep-core.js
  • lines 99 to 100
  • CadCraft 0.8.2
/** The signed turn from direction t1 to t2, radians in (−π, π]: + = to the left (counter-clockwise). */
export function turnOf(t1, t2) { return Math.atan2(t1.x * t2.y - t1.y * t2.x, t1.x * t2.x + t1.y * t2.y); }

Bend at a corner

64-bit

Where an elbow starts and ends, its center and its angles.

T = R × tan(θ ÷ 2); p₁ = V − t_in T; p₂ = V + t_out T; c = p₁ + n_in R

The tangent length grows without bound as the turn nears 180°; layoutPipe refuses turns above 179° before this is used. The two arc angles are atan2 of the start and end points seen from the center.

  • addons/piping/lib/mep-core.js
  • lines 102 to 114
  • CadCraft 0.8.2
/**
 * A bend of centreline radius R at corner V (incoming direction tin, outgoing tout): { theta (unsigned turn), side (+1 left,
 * −1 right), T (tangent length R·tan(θ/2)), p1, p2 (where the bend starts and ends), c (its centre), a1, a2 (angles of p1
 * and p2 seen from c) }.
 */
export function bendAt(V, tin, tout, R) {
  const turn = turnOf(tin, tout), theta = Math.abs(turn), side = turn >= 0 ? 1 : -1;
  const T = R * Math.tan(theta / 2);
  const p1 = { x: V.x - tin.x * T, y: V.y - tin.y * T }, p2 = { x: V.x + tout.x * T, y: V.y + tout.y * T };
  const nin = { x: -tin.y * side, y: tin.x * side };   // toward the inside of the bend
  const c = { x: p1.x + nin.x * R, y: p1.y + nin.y * R };
  return { theta, side, T, p1, p2, c, a1: Math.atan2(p1.y - c.y, p1.x - c.x), a2: Math.atan2(p2.y - c.y, p2.x - c.x) };
}

Cross-section outline

64-bit

The points of a rectangular, round or flat-oval section used to build the 3D solid.

round: (r cos a, r + r sin a), a = −¾π + 2πj ÷ N

A round section is N points on the circle at equal angles, an inscribed polygon. Flat-oval points are spaced by arc length along the perimeter 2f + 2πr. For a rectangle with more than 4 points the extra points are shared between the sides by length, using floor and a largest-remainder pass.

  • addons/piping/lib/mep-core.js
  • lines 160 to 199
  • CadCraft 0.8.2
/**
 * A cross-section in its own plane: [[u, v]…] counter-clockwise, u across (centred), v up from the bottom (v = 0 is the
 * bottom of the duct or pipe). shape 'rect' (w × h), 'round' (diameter w), 'oval' (flat oval w × h, flat top and bottom).
 * N = 4 gives a rectangle's four corners; a larger N keeps a rectangle's corners and spreads the other points by side length,
 * so rings of different shapes with the same N can be joined (transitions).
 */
export function profile(shape, w, h, N = 24) {
  if (shape === 'rect') {
    const c = [[-w / 2, 0], [w / 2, 0], [w / 2, h], [-w / 2, h]];
    if (N <= 4) return c;
    const sides = [w, h, w, h], extra = N - 4, tot = 2 * (w + h);
    const share = sides.map((s) => (s / tot) * extra), base = share.map(Math.floor);
    let left = extra - base.reduce((a, b) => a + b, 0);
    share.map((s, i) => [s - base[i], i]).sort((a, b) => b[0] - a[0]).forEach(([, i]) => { if (left > 0) { base[i]++; left--; } });
    const out = [];
    for (let i = 0; i < 4; i++) {
      const a = c[i], b = c[(i + 1) % 4], k = base[i] + 1;
      for (let j = 0; j < k; j++) out.push([a[0] + (b[0] - a[0]) * j / k, a[1] + (b[1] - a[1]) * j / k]);
    }
    return out;
  }
  if (shape === 'oval' && w - h > 1e-9) {
    const r = h / 2, f = w - h, per = 2 * f + 2 * Math.PI * r, out = [];
    for (let j = 0; j < N; j++) {
      let s = (j / N) * per;
      if (s < f) { out.push([-f / 2 + s, 0]); continue; }
      s -= f;
      if (s < Math.PI * r) { const a = -Math.PI / 2 + s / r; out.push([f / 2 + r * Math.cos(a), r + r * Math.sin(a)]); continue; }
      s -= Math.PI * r;
      if (s < f) { out.push([f / 2 - s, h]); continue; }
      s -= f;
      const a = Math.PI / 2 + s / r;
      out.push([-f / 2 + r * Math.cos(a), r + r * Math.sin(a)]);
    }
    return out;
  }
  const d = shape === 'oval' ? h : w, r = d / 2, out = [];
  for (let j = 0; j < N; j++) { const a = -0.75 * Math.PI + (2 * Math.PI * j) / N; out.push([r * Math.cos(a), r + r * Math.sin(a)]); }
  return out;
}

Signed volume of a closed mesh

64-bit

The volume enclosed by a set of polygons, positive when the faces point outward.

V = Σ a · (b × c) ÷ 6 over the fan triangles of each face

Each term is a 3 × 3 determinant of absolute coordinates, added in face order. In this file tube() uses only the sign of the result, to turn the faces outward.

  • addons/piping/lib/mep-core.js
  • lines 207 to 215
  • CadCraft 0.8.2
/** Signed volume of closed polygons (divergence theorem over fans): > 0 when the faces point outward. */
export function signedVolume(polys) {
  let v = 0;
  for (const p of polys) for (let i = 1; i + 1 < p.length; i++) {
    const a = p[0], b = p[i], c = p[i + 1];
    v += (a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0])) / 6;
  }
  return v;
}

Solid through a row of sections

64-bit

One closed solid from the cross-sections along a run, with its two ends capped.

A ring is dropped when every one of its points is within 1e-9 drawing units (absolute) of the same point of the ring before it. Side faces are quads between neighboring rings; the faces are reversed when the signed volume is negative.

  • addons/piping/lib/mep-core.js
  • lines 217 to 228
  • CadCraft 0.8.2
/** One closed solid through rings of equal length (a tube with its two ends capped), faces pointing outward. */
export function tube(rings) {
  const R = rings.filter((r, i) => i === 0 || r.some((q, j) => Math.hypot(q[0] - rings[i - 1][j][0], q[1] - rings[i - 1][j][1], q[2] - rings[i - 1][j][2]) > 1e-9));
  if (R.length < 2) return [];
  const n = R[0].length, polys = [];
  for (let k = 0; k + 1 < R.length; k++) {
    const A = R[k], B = R[k + 1];
    for (let j = 0; j < n; j++) { const i2 = (j + 1) % n; polys.push([A[j], A[i2], B[i2], B[j]]); }
  }
  polys.push(R[0].slice().reverse(), R[R.length - 1].slice());
  return signedVolume(polys) < 0 ? polys.map((p) => p.slice().reverse()) : polys;
}

Numbers in CSV exports

64-bit

How a number cell is written in a CSV file.

Number cells are written with toFixed(4) and trailing zeros removed, so a number keeps at most 4 decimals in the file.

  • addons/piping/lib/mep-core.js
  • lines 232 to 235
  • CadCraft 0.8.2
export function csv(rows) {
  const q = (v) => { const s = v === null || v === undefined ? '' : typeof v === 'number' ? fmtNum(v, 4) : String(v); return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; };
  return rows.map((r) => r.map(q).join(',')).join('\r\n') + '\r\n';
}

Takeoff mark coordinates

64-bit

Rounds the points of each takeoff mark before they are stored in the takeoff project.

v′ = round(v × 10⁶) ÷ 10⁶

Mark points are in PDF points and are rounded to 6 decimals before they are stored. Math.round on a scaled value followed by a division, so the result is the nearest 64-bit number to the 6-decimal value.

  • addons/piping/lib/mep-core.js
  • line 329
  • CadCraft 0.8.2
const round6 = (v) => Math.round(v * 1e6) / 1e6;

Scale of the takeoff sheet

64-bit

Feet per PDF point for the sheet the takeoff is measured on, and the mapping of drawing points to PDF points.

K = 72 ÷ 25.4; feet per point = (feet per drawing unit) ÷ (m₀ × K)

Points are mapped with the full view matrix times K. The scale uses only the first matrix element m₀, so it assumes a view that is neither rotated nor stretched.

  • addons/piping/lib/mep-core.js
  • lines 350 to 352
  • CadCraft 0.8.2
const m = sheet.views[0].m, K = 72 / 25.4;
const toPt = (p) => [(m[0] * p[0] + m[2] * p[1] + m[4]) * K, (m[1] * p[0] + m[3] * p[1] + m[5]) * K];
const fpu = ftPerUnit(doc) / (m[0] * K);
addons/piping/lib/pipe.js CadCraft 0.8.2 64-bit 12 calculations Reviewed

Tests tools/addons-test/browser-mep.mjs

Cleaning a run's data

64-bit

Checks a run's points, sizes, elevation, slope and valves before the run is stored.

The run is rejected when a coordinate is not finite or is larger than 1e12, and a point is dropped when it is within 1e-9 drawing units (absolute) of the last kept point. The slope is kept only in [0, 1] and valve positions only within the run's length.

  • addons/piping/lib/pipe.js
  • lines 30 to 67
  • CadCraft 0.8.2
/** Untrusted data → a clean run, or null. */
export function cleanPipe(raw) {
  if (!raw || typeof raw !== 'object' || !Array.isArray(raw.pts) || !Array.isArray(raw.segs)) return null;
  const mat = pick(raw.mat, MATERIALS, 'steel');
  const cls = pick(String(raw.cls ?? ''), CLASSES[mat], DEFAULT_CLASS[mat]);
  const pts = [];
  for (const p of raw.pts.slice(0, LIMITS.pts)) {
    if (!Array.isArray(p) || !fin(p[0]) || !fin(p[1]) || Math.abs(p[0]) > 1e12 || Math.abs(p[1]) > 1e12) return null;
    if (pts.length && Math.hypot(p[0] - pts[pts.length - 1][0], p[1] - pts[pts.length - 1][1]) <= 1e-9) continue;
    pts.push([p[0], p[1]]);
  }
  if (pts.length < 2) return null;
  const segs = [];
  for (let i = 0; i + 1 < pts.length; i++) {
    const s = raw.segs[Math.min(i, raw.segs.length - 1)];
    const size = s && typeof s === 'object' ? String(s.size ?? '') : '';
    if (!pipeSize(mat, cls, size)) return null;
    segs.push({ size });
  }
  const k = fin(raw.k) && raw.k > 0 && raw.k < 1e9 ? raw.k : 1;
  const total = pathLength(pts);
  const valves = [];
  for (const v of Array.isArray(raw.valves) ? raw.valves.slice(0, LIMITS.valves) : []) {
    if (!v || !fin(v.s) || v.s < 0 || v.s > total || !VALVES.includes(v.type)) continue;
    valves.push({ s: v.s, type: v.type });
  }
  valves.sort((a, b) => a.s - b.s);
  return {
    id: typeof raw.id === 'string' && /^[a-z0-9]{4,16}$/.test(raw.id) ? raw.id : newRunId(),
    u: raw.u === 'mm' ? 'mm' : 'in', k, mat, cls, sys: pick(raw.sys, SYSTEMS, 'cw'), tag: str(raw.tag, LIMITS.text), pts, segs,
    z: fin(raw.z) && Math.abs(raw.z) < 1e12 ? raw.z : 0,
    slope: fin(raw.slope) && raw.slope >= 0 && raw.slope <= 1 ? raw.slope : 0,
    lines: pick(raw.lines, LINES, 'single'),
    sym: fin(raw.sym) && raw.sym > 0 && raw.sym < 1e9 ? raw.sym : 12 / k,
    valves,
    links: cleanLinks(raw.links, segs.length, mat, cls),
  };
}

Move, rotate, scale or mirror a run

64-bit

The run's points, scale, elevation, symbol size and valve positions after a transform.

s = √|m₀m₃ − m₁m₂|; k′ = k ÷ s; z′ = z × s

Points go through the 2×3 matrix in one expression each. When s differs from 1 by more than 1e-12, k is divided by s and the elevation, symbol size and valve positions are multiplied by s; a non-uniform scale is reduced to that one factor.

  • addons/piping/lib/pipe.js
  • lines 84 to 92
  • CadCraft 0.8.2
/** A move / rotate / scale / mirror baked into the run: points and valve positions move, sizes stay. */
export function transformPipe(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.sym = d.sym * sc; out.valves = d.valves.map((v) => ({ ...v, s: v.s * sc })); }
  out.links = { taps: [], s: null, e: null };
  return out;
}

Elevation along a run

64-bit

The centerline elevation a given plan distance along the run.

z(s) = z₀ − slope × s

One multiplication and one subtraction. s is plan distance, so the slope is fall per plan length.

  • addons/piping/lib/pipe.js
  • lines 107 to 108
  • CadCraft 0.8.2
/** Elevation of the centerline `s` along the run (plan distance, drawing units). */
export const zAt = (d, s) => d.z - d.slope * s;

Elbows, reducers and trimmed pieces

64-bit

Places an elbow at each turn and a reducer at each size change, and trims the straight pieces to fit.

T = R × tan(θ ÷ 2); reducer length = 1.5 × max(OD₁, OD₂)

An elbow is made for a turn above 1° and the corner is reported instead above 179°. A piece is reported as too short when its end falls more than 1e-9 before its start. A drain's slope is compared with the table minimum less 1e-12.

  • addons/piping/lib/pipe.js
  • lines 143 to 184
  • CadCraft 0.8.2
/**
 * Elbows on turns (bend radius from tables.js: ASME B16.9 long radius for steel), reducers on size changes (concentric, drawn
 * 1.5 × the larger OD long, after the corner's elbow), branch ends cut back to the main's side (double line).
 */
export function layoutPipe(d) {
  const G = segsOf(d.pts), n = G.length;
  const trimS = new Array(n).fill(0), trimE = new Array(n).fill(0);
  const elbows = [], reducers = [], problems = [];
  for (let i = 1; i < n; i++) {
    const A = G[i - 1], B = G[i], sa = d.segs[i - 1].size, sb = d.segs[i].size;
    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 R = bendRadius(sizeOf(d, sa)) / d.k, T = 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, size: sa });
    }
    if (sa !== sb) {
      const len = 1.5 * Math.max(odOf(d, sa), odOf(d, sb));
      reducers.push({ seg: i, s: trimS[i], len, from: sa, to: sb });
      trimS[i] += len;
    }
  }
  const taps = [];
  for (const end of ['s', 'e']) {
    const l = d.links?.[end];
    if (!l || !n) continue;
    const i = end === 's' ? 0 : n - 1;
    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, size: d.segs[i].size, kind: l.kind, ang: l.ang, main: l.main });
  }
  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) };
  });
  if (isDrain(d)) {
    const need = Math.max(...d.segs.map((s) => minDrainSlope(sizeOf(d, s.size).nps)));
    if (d.slope < need - 1e-12) problems.push({ kind: d.slope > 0 ? 'slope' : 'flat', need });
  }
  return { segs: G, pieces, elbows, reducers, taps, problems };
}

Sections for the 3D pipe

64-bit

The cross-sections along the centerline that the 3D solid is built from.

elbow steps = max(2, ceil(θ ÷ 15°)); z = z₀ − slope × s − OD ÷ 2

Each section is a 16-sided polygon inscribed in the outside circle, so the solid's cross-section area is 8·sin(22.5°)·r² ≈ 3.0615 r², about 2.5 % less than π r². Along an elbow the distance used for the fall is the arc length R·φ.

  • addons/piping/lib/pipe.js
  • lines 285 to 314
  • CadCraft 0.8.2
/** The run's cross-sections (16 sides) along its centerline, elbows stepped ≤ 15°, the centerline falling with the slope. */
export function pipeRings(d) {
  const L = layoutPipe(d), G = L.segs;
  if (!G.length) return [];
  const N = 16;
  const prof = (size) => profile('round', odOf(d, size), odOf(d, size), N);
  const ring = (size, p, dir, s) => placeRing(prof(size), p, dir, zAt(d, s) - odOf(d, size) / 2);
  const rings = [ring(d.segs[0].size, G[0].a, G[0].t, 0)];
  const byV = new Map(L.elbows.map((e) => [e.at, e])), red = new Map(L.reducers.map((r) => [r.seg, r]));
  for (let i = 1; i < G.length; i++) {
    const e = byV.get(i), size = d.segs[i - 1].size, sV = G[i].s0;
    if (e) {
      const b = bendAt(e.V, e.tin, e.tout, e.R), steps = Math.max(2, Math.ceil(e.theta / (Math.PI / 12)));
      const a0 = Math.atan2(e.tin.y, e.tin.x);
      for (let k = 0; k <= steps; k++) {
        const phi = (e.theta * k) / steps, a = b.a1 + b.side * phi;
        const p = { x: b.c.x + e.R * Math.cos(a), y: b.c.y + e.R * Math.sin(a) };
        rings.push(ring(size, p, { x: Math.cos(a0 + b.side * phi), y: Math.sin(a0 + b.side * phi) }, sV - e.T + e.R * phi));
      }
    } else rings.push(ring(size, G[i].a, G[i].t, sV));
    const r = red.get(i);
    if (r) {
      rings.push(ring(r.from, at(G[i], r.s), G[i].t, sV + r.s));
      rings.push(ring(r.to, at(G[i], r.s + r.len), G[i].t, sV + r.s + r.len));
    }
  }
  const last = G[G.length - 1];
  rings.push(ring(d.segs[d.segs.length - 1].size, last.b, last.t, last.s0 + last.len));
  return rings;
}

Drawing units to feet or meters, and the slope factor

64-bit

Converts a plan length to feet or meters and corrects it for the run's slope.

ft = du × k ÷ 12; m = du × k × 25.4 ÷ 1000; factor = √(1 + slope²)

k is inches per drawing unit; 12, 25.4 and 1000 are exact. The slope factor is the square root of a sum of squares, so nothing cancels.

  • addons/piping/lib/pipe.js
  • lines 319 to 320
  • CadCraft 0.8.2
const lenOut = (d, du) => (d.u === 'in' ? (du * d.k) / 12 : (du * d.k * 25.4) / 1000);
const fall = (d) => Math.sqrt(1 + d.slope * d.slope);

Pipe length by size

64-bit

The length of pipe of each size in a run, in plan and along the slope.

len = Σ lenOut(segment) × √(1 + slope²)

Segment lengths run corner to corner, so a bend is counted as its two tangents, not its arc. The lengths of one size are added in segment order.

  • addons/piping/lib/pipe.js
  • lines 322 to 335
  • CadCraft 0.8.2
/** The run's pipe by size: [{ size, len (true, ft or m), plan (drawing units), marks [[x, y]…] (the centerline, one polyline per stretch of one size) }]. */
export function lengthsOf(d) {
  const G = segsOf(d.pts), out = new Map();
  let cur = null;
  G.forEach((g, i) => {
    const size = d.segs[i].size;
    const r = out.get(size) || { size, len: 0, plan: 0, marks: [] };
    r.plan += g.len; r.len += lenOut(d, g.len) * fall(d);
    if (cur && cur.size === size) cur.pts.push([g.b.x, g.b.y]);
    else { cur = { size, pts: [[g.a.x, g.a.y], [g.b.x, g.b.y]] }; r.marks.push(cur.pts); }
    out.set(size, r);
  });
  return [...out.values()];
}

Schedule totals

64-bit

Pipe length and steel weight by material and size, fitting counts, and the totals.

weight = (weight per length) × length; total = Σ rows

Lengths of the same material, class, size and slope are added run by run, and rows that differ only by slope are then merged by one more addition. Weight is the size table's weight per foot or meter times the row's length; totals are sums over the sorted rows.

  • addons/piping/lib/pipe.js
  • lines 360 to 403
  • CadCraft 0.8.2
/**
 * The schedule of a set of runs. → { u, pipes: [{key, name, mat, cls, size, len, weight?, marks, slope}], fittings: [{key, name,
 * kind, mat, n, at}], runs: [{tag, sys, mat, len, z0, z1, slope}], totals: {len, fittings, valves, weight}, problems }
 * Pipe length is centerline length (through the fittings, center to center), along the slope; weight for steel only.
 */
export function schedule(runs) {
  const u = runs[0]?.u || 'in';
  const pipes = new Map(), fits = new Map(), list = [], problems = [];
  for (const d0 of runs) {
    const d = d0.u === u ? d0 : { ...d0, u };
    for (const r of lengthsOf(d)) {
      const key = d.mat + '|' + d.cls + '|' + r.size + '|' + (d.slope > 0 ? 's' + d.slope : 'level');
      const p = sizeOf(d, r.size);
      const row = pipes.get(key) || { key, name: t('row.pipe', { size: sizeLabel(d, r.size), mat: matLabel(d) }), mat: d.mat, cls: d.cls, size: r.size, len: 0, marks: [], slope: d.slope, sys: d.sys };
      row.len += r.len;
      for (const m of r.marks) row.marks.push(m);
      if (d.mat === 'steel') row.weight = u === 'in' ? steelWeightLbFt(p.od, p.wall) * row.len : steelWeightKgM(p.od * 25.4, p.wall * 25.4) * row.len;
      pipes.set(key, row);
    }
    for (const f of fittingsOf(d)) {
      const key = f.name + '|' + d.mat + '|' + d.cls;
      const row = fits.get(key) || { key, name: t('row.fit', { name: f.name, mat: matLabel(d) }), kind: f.kind, mat: d.mat, sys: d.sys, n: 0, at: [] };
      row.n++; row.at.push([f.x, f.y]);
      fits.set(key, row);
    }
    const total = pathLength(d.pts);
    list.push({ id: d.id, tag: d.tag, sys: d.sys, mat: d.mat, cls: d.cls, len: lenOut(d, total) * fall(d), z0: d.z, z1: zAt(d, total), slope: d.slope });
    for (const p of layoutPipe(d).problems) problems.push({ tag: d.tag || d.id, ...p });
  }
  // two rows for the same pipe that differ only by slope: one row (the slope is a drawing detail)
  const merged = new Map();
  for (const r of pipes.values()) {
    const k = r.mat + '|' + r.cls + '|' + r.size;
    const m = merged.get(k);
    if (!m) merged.set(k, { ...r, marks: r.marks.map((pts) => ({ pts, slope: r.slope })) });
    else { m.len += r.len; if (r.weight !== undefined) m.weight = (m.weight || 0) + r.weight; for (const pts of r.marks) m.marks.push({ pts, slope: r.slope }); }
  }
  const pl = [...merged.values()].sort((a, b) => a.name.localeCompare(b.name, 'en', { numeric: true }));
  const fl = [...fits.values()].sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name, 'en', { numeric: true }));
  return {
    u, pipes: pl, fittings: fl, runs: list, problems,
    totals: { len: pl.reduce((s, r) => s + r.len, 0), fittings: fl.filter((f) => f.kind !== 'valve').reduce((s, f) => s + f.n, 0), valves: fl.filter((f) => f.kind === 'valve').reduce((s, f) => s + f.n, 0), weight: pl.reduce((s, r) => s + (r.weight || 0), 0) },
  };
}

Slope label

64-bit

The slope written on the drawing and in the schedule: a fraction of an inch per foot, or a percentage.

in/ft = slope × 12; % = slope × 100

Imperial: the value is shown as a fraction in sixteenths when it is within 1e-6 of one, otherwise with 3 decimals. Metric: 2 decimals. Only the text is rounded; the stored slope is unchanged.

  • addons/piping/lib/pipe.js
  • lines 276 to 282
  • CadCraft 0.8.2
/** "1/4 in/ft" (imperial, in eighths and sixteenths when they fit) or "2 %". */
export function slopeText(d) {
  if (d.u === 'mm') return t('slope.pct', { p: fmtNum(d.slope * 100, 2) });
  const inPerFt = d.slope * 12, x16 = Math.round(inPerFt * 16);
  const frac = Math.abs(inPerFt * 16 - x16) < 1e-6 && x16 > 0 && x16 < 16 ? (() => { let n = x16, q = 16; while (n % 2 === 0) { n /= 2; q /= 2; } return n + '/' + q; })() : fmtNum(inPerFt, 3);
  return t('slope.in', { s: frac });
}

Plan of a run

64-bit

The lines and arcs drawn for a run: a single centerline, or two side lines with elbow arcs and reducers.

side line = centerline ± n × OD ÷ 2; elbow arcs at R ± OD ÷ 2

Side lines are the centerline moved half the outside diameter along the unit normal, and elbow arcs share the bend's center with radii R ± OD ÷ 2; the inner arc is left out when its radius is 1e-9 or less. Joint lines are drawn once per point, matched on a grid of 1e-6 drawing units. Pieces shorter than 1e-9 are not drawn.

  • addons/piping/lib/pipe.js
  • lines 217 to 274
  • CadCraft 0.8.2
/** The plan of a run: CadCraft entities in drawing coordinates. */
export function drawPipe(d) {
  const L = layoutPipe(d), out = [], G = L.segs;
  const V = valvesOf(d);
  const valveGaps = (seg) => V.filter((v) => v.seg === seg).map((v) => [v.at - v.l / 2, v.at + v.l / 2]);
  if (d.lines === 'single') {
    // the centerline: straight from corner to corner, broken at valves
    G.forEach((g, i) => { for (const [a, b] of minus(0, g.len, valveGaps(i))) segLine(out, at(g, a), at(g, b)); });
    for (const r of L.reducers) {
      const g = G[r.seg], p = at(g, r.s + r.len / 2);
      out.push(...placeEnts(drawSymbol('reducer', d.sym * 0.6, d.sym * 0.4), p, Math.atan2(g.t.y, g.t.x)));
    }
  } else {
    const joints = new Map();
    const joint = (p, n, W) => { const k = Math.round(p.x * 1e6) + ':' + Math.round(p.y * 1e6); if (!joints.has(k)) { joints.set(k, 1); out.push(line(off(p, n, W / 2), off(p, n, -W / 2))); } };
    for (const pc of L.pieces) {
      const g = G[pc.seg], W = odOf(d, d.segs[pc.seg].size);
      for (const side of [1, -1]) {
        const gaps = [...(d.links?.taps || []).filter((tp) => tp.seg === pc.seg && tp.side === side).map((tp) => [tp.at - tp.w / 2, tp.at + tp.w / 2]), ...valveGaps(pc.seg)].sort((a, b) => a[0] - b[0]);
        for (const [a, b] of minus(pc.s0, pc.s1, gaps)) segLine(out, off(at(g, a), g.n, side * W / 2), off(at(g, b), g.n, side * W / 2));
      }
      joint(at(g, pc.s0), g.n, W);
      joint(at(g, pc.s1), g.n, W);
    }
    for (const e of L.elbows) {
      const W = odOf(d, e.size), b = bendAt(e.V, e.tin, e.tout, e.R), ccw = b.side > 0;
      out.push(arcThrough(b.c, e.R + W / 2, b.a1, b.a2, ccw));
      if (e.R - W / 2 > 1e-9) out.push(arcThrough(b.c, e.R - W / 2, b.a1, b.a2, ccw));
    }
    for (const r of L.reducers) {
      const g = G[r.seg], a = at(g, r.s), b = at(g, r.s + r.len), wa = odOf(d, r.from), wb = odOf(d, r.to);
      for (const side of [1, -1]) segLine(out, off(a, g.n, side * wa / 2), off(b, g.n, side * wb / 2));
      joint(a, g.n, wa); joint(b, g.n, wb);
    }
  }
  for (const v of V) out.push(...placeEnts(drawSymbol(v.type, v.l, v.h), v.p, Math.atan2(v.t.y, v.t.x)));
  // labels: the size and system beside the longest straight piece of each size, and the slope of a sloped run
  const th = d.sym * 0.4;
  const done = new Set();
  const order = L.pieces.map((p, i) => ({ ...p, len: p.s1 - p.s0, i })).sort((a, b) => b.len - a.len);
  for (const pc of order) {
    const size = d.segs[pc.seg].size;
    if (done.has(size) || pc.len <= 0) continue;
    const label = sizeLabel(d, size) + ' ' + t('sys.short.' + d.sys) + (d.tag ? ' ' + d.tag : '');
    if (textWidth(label, th) > pc.len * 0.9) continue;
    done.add(size);
    const g = G[pc.seg], W = d.lines === 'double' ? odOf(d, size) : 0;
    const flip = Math.cos(Math.atan2(g.t.y, g.t.x)) < -1e-9 || (Math.abs(g.t.x) < 1e-9 && g.t.y < 0) ? -1 : 1;
    out.push(textAlong(off(at(g, (pc.s0 + pc.s1) / 2), g.n, flip * (W / 2 + th)), g.t, label, th));
  }
  if (d.slope > 0 && order.length) {
    const pc = order[0], g = G[pc.seg], W = d.lines === 'double' ? odOf(d, d.segs[pc.seg].size) : 0;
    const p = off(at(g, (pc.s0 + pc.s1) / 2), g.n, -(W / 2 + th * 1.5));
    out.push(...placeEnts(drawSymbol('flow', th * 1.5, th), off(p, g.t, -textWidth('0000', th)), Math.atan2(g.t.y, g.t.x)));
    out.push(textAlong(p, g.t, slopeText(d), th));
  }
  return out;
}

Size label

64-bit

The size written beside a pipe: NPS, DN, or a tube size with its outside diameter in millimeters.

mm = OD (in) × 25.4

For copper and PEX in a metric drawing the outside diameter is converted with the exact 25.4 and shown with at most 1 decimal. Only the label is rounded.

  • addons/piping/lib/pipe.js
  • lines 98 to 104
  • CadCraft 0.8.2
/** 2" or DN50 (steel and PVC), 1/2" or 1/2 (15.9 mm) for copper and PEX in a metric drawing. */
export function sizeLabel(d, size) {
  const p = sizeOf(d, size);
  if (d.u === 'in') return t('size.in', { s: size });
  if ((d.mat === 'steel' || d.mat === 'pvc') && p.dn) return t('size.dn', { dn: p.dn });
  return t('size.cts', { s: size, mm: fmtNum(p.od * 25.4, 1) });
}
addons/piping/main.js CadCraft 0.8.2 64-bit 5 calculations Reviewed

Tests tools/addons-test/browser-mep.mjs

Typed pipe size

64-bit

Matches what a person types (2, 1-1/2, 1.5, DN50) to a size of the pipe table.

A typed number matches a table size when it is within 1e-9 of that size's nominal value. A DN number must equal the table's DN exactly.

  • addons/piping/main.js
  • lines 28 to 38
  • CadCraft 0.8.2
/** A typed size → the table's size name, or null: 2, 2", 1-1/2, 1 1/2, 1.5, DN50 / 50 (a metric drawing, steel and PVC). */
export function parseSize(text, mat, cls, u = 'in') {
  let s = String(text ?? '').trim().replace(/["”]|\s*in$/i, '').replace(/^(\d+)\s+(\d+\/\d+)$/, '$1-$2');
  const list = sizesFor(mat, cls);
  if (list.includes(s)) return s;
  const dn = /^dn\s*(\d+)$/i.exec(s) || (u === 'mm' && /^(\d+)$/.exec(s));
  if (dn) { const hit = list.find((k) => pipeSize(mat, cls, k)?.dn === Number(dn[1])); if (hit) return hit; if (/^dn/i.test(s)) return null; }
  const v = Number(s);
  if (Number.isFinite(v)) return list.find((k) => Math.abs(nominal(k) - v) < 1e-9) || null;
  return null;
}

Typed slope

64-bit

Turns a typed slope into fall per unit of run.

imperial: slope = (inches per foot) ÷ 12; metric: slope = percent ÷ 100

A fraction a/b is one division, followed by one division by 12 or 100. A value such as 1/4 in per ft (0.0208333…) has no exact binary form, so the stored slope is the nearest 64-bit number.

  • addons/piping/main.js
  • lines 39 to 46
  • CadCraft 0.8.2
/** A typed slope → fall per run: imperial "1/4" or "0.25" (inches per foot); metric "2" or "2%" (percent). null when it does not read. */
export function parseSlope(text, u = 'in') {
  const s = String(text ?? '').trim().replace(/\s*(%|in\/ft|"\/ft|\/ft)$/i, '');
  const f = /^(\d+)\/(\d+)$/.exec(s);
  const v = f ? Number(f[1]) / Number(f[2]) : Number(s);
  if (!Number.isFinite(v) || v < 0) return null;
  return u === 'in' && !/%$/.test(String(text).trim()) ? v / 12 : v / 100;
}

Point pulled onto a centerline

64-bit

Moves a picked point that lies on a pipe onto that pipe's centerline.

moved when distance ≤ max(OD ÷ 2, slack) + 1e-9

The point is replaced by the nearest point on the centerline when it is within half the outside diameter, so a branch starts on the main's centerline as computed. The nearest run wins.

  • addons/piping/main.js
  • lines 134 to 144
  • CadCraft 0.8.2
/** A point on (or inside) a run, pulled onto its centerline. → { x, y, run, s } | null */
function onRun(doc, p, slack = 0) {
  let best = null;
  for (const r of runsIn(doc)) {
    const n = nearestOnPath(r.data.pts, p);
    if (!n) continue;
    const tol = Math.max(odOf(r.data, r.data.segs[n.seg].size) / 2, slack);
    if (n.dist <= tol + 1e-9 && (!best || n.dist < best.dist)) best = { x: n.x, y: n.y, dist: n.dist, run: r, s: n.s };
  }
  return best;
}

Schedule table on the drawing

64-bit

The rows of the pipe schedule placed in the drawing.

Each length is shown with at most 1 decimal of a foot or meter and each weight with none. The rows are stored in the table as text, so the table holds the rounded values.

  • addons/piping/main.js
  • lines 295 to 305
  • CadCraft 0.8.2
// ---------------------------------------------------------------------------------------------- PIPESCHED, PIPE3D
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')]];
  for (const r of S.pipes) rows.push([r.name, fmtNum(r.len, 1) + ' ' + U.len + (r.weight ? ' (' + fmtNum(r.weight, 0) + ' ' + U.weight + ')' : '')]);
  for (const f of S.fittings) rows.push([f.name, String(f.n)]);
  rows.push([t('row.total'), t('row.total.v', { len: fmtNum(S.totals.len, 1), lu: U.len, f: S.totals.fittings, v: S.totals.valves })]);
  return { S, rows };
}

Schedule CSV export

64-bit

The pipe schedule written as a CSV file: pipe by size, fittings, and each run with its elevations.

elevation = z × k (in) or z × k × 25.4 (mm)

Lengths and the total length are rounded with toFixed(3), weights with toFixed(1) and elevations with toFixed(2) before they are written. The file therefore carries fewer digits than the schedule that was computed.

  • addons/piping/main.js
  • lines 338 to 354
  • CadCraft 0.8.2
// ---------------------------------------------------------------------------------------------- files
function scheduleCsv(doc) {
  const runs = runsIn(doc).map((r) => r.data);
  if (!runs.length) throw new Error(t('err.nopipes'));
  const S = schedule(runs), U = units(S.u);
  const rows = [[t('csv.pipe')], [t('col.item'), t('col.material'), t('col.size'), t('col.len', { unit: U.len }), t('col.weight', { unit: U.weight })]];
  for (const r of S.pipes) rows.push([r.name, t('mat.' + r.mat + '.' + r.cls), r.size, +r.len.toFixed(3), r.weight === undefined ? '' : +r.weight.toFixed(1)]);
  rows.push([], [t('csv.fittings')], [t('col.item'), t('col.count')]);
  for (const f of S.fittings) rows.push([f.name, f.n]);
  rows.push([], [t('csv.runs')], [t('col.tag'), t('col.system'), t('col.material'), t('col.len', { unit: U.len }), t('col.z0', { unit: t('unit.' + S.u) }), t('col.z1', { unit: t('unit.' + S.u) }), t('col.slope')]);
  for (const r of S.runs) {
    const d = runs.find((x) => x.id === r.id), toU = (z) => (S.u === 'in' ? z * d.k : z * d.k * 25.4);   // drawing units → in or mm
    rows.push([r.tag, t('sys.' + r.sys), t('mat.' + r.mat + '.' + r.cls), +r.len.toFixed(3), +toU(r.z0).toFixed(2), +toU(r.z1).toFixed(2), r.slope > 0 ? slopeText({ u: S.u, slope: r.slope }) : '']);
  }
  rows.push([], [t('row.total'), '', '', +S.totals.len.toFixed(3), S.totals.weight ? +S.totals.weight.toFixed(1) : '']);
  return csv(rows);
}
addons/piping/lib/symbols.js CadCraft 0.8.2 64-bit 1 calculation Reviewed

Tests tools/addons-test/browser-mep.mjs

Valve and fitting symbols placed on a run

64-bit

Rotates a symbol's entities to the run's direction and moves them to their place.

x′ = pₓ + x cos θ − y sin θ; y′ = p_y + x sin θ + y cos θ

cos and sin are taken once per call and applied to every coordinate. Arc angles and text rotations have the angle added.

  • addons/piping/lib/symbols.js
  • lines 59 to 72
  • CadCraft 0.8.2
/** Entities turned by `ang` about the origin and moved to p. */
export function placeEnts(ents, p, ang) {
  const c = Math.cos(ang), s = Math.sin(ang);
  const tx = (x, y) => [p.x + x * c - y * s, p.y + x * s + y * c];
  return ents.map((e) => {
    if (e.type === 'line') { const [x1, y1] = tx(e.x1, e.y1), [x2, y2] = tx(e.x2, e.y2); return { ...e, x1, y1, x2, y2 }; }
    if (e.type === 'circle') { const [cx, cy] = tx(e.cx, e.cy); return { ...e, cx, cy }; }
    if (e.type === 'arc') { const [cx, cy] = tx(e.cx, e.cy); return { ...e, cx, cy, a0: e.a0 + ang, a1: e.a1 + ang }; }
    if (e.type === 'poly') return { ...e, pts: e.pts.map((q) => { const [x, y] = tx(q.x, q.y); return { x, y, b: q.b || 0 }; }) };
    if (e.type === 'hatch') return { ...e, loops: e.loops.map((l) => { const o = []; for (let i = 0; i + 1 < l.length; i += 2) o.push(...tx(l[i], l[i + 1])); return o; }) };
    if (e.type === 'text') { const [x, y] = tx(e.x, e.y); return { ...e, x, y, rot: (e.rot || 0) + ang }; }
    return e;
  });
}

In detail

Method
Polyline segments with unit directions; signed turn by atan2 of cross and dot products; elbow tangent length R·tan(θ ÷ 2); branch detection by nearest point on the other run; 3D solid as rings of a 16-sided section joined into a closed mesh.
Number types
64-bit numbers throughout; points are plain arrays and objects.
Tolerances
Repeated points: 1e-9 drawing units, absolute. Branch end on a main: 1e-7 × max(1, |x|, |y|). Elbow made above 1° and refused above 179°. Typed size match: 1e-9. Fraction display of a slope: within 1e-6 of a sixteenth. Scale change on transform: more than 1e-12 from 1.
Limits
Schedule length runs corner to corner along the centerline, not around bend arcs. The 3D pipe section is a 16-sided inscribed polygon. The branch angle stored with a link is rounded to 0.1°. The schedule CSV rounds lengths to 3 decimals, weights to 1 and elevations to 2; the table on the drawing shows lengths with 1 decimal.

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.

Every policy that takes this approach