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.
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.
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.
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.
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;
Branches onto other runs
64-bit
Finds where a run's end sits on another run and works out the tee or wye, its angle and how far the branch is cut back.
sin φ = |t_main × t_branch|; cut = (OD_main ÷ 2) ÷ sin φ; gap = OD_branch ÷ sin φ
The end counts as on the other run within 1e-7 × max(1, |x|, |y|), a tolerance scaled to the coordinates; branches with sin φ below 0.2 are ignored. The angle is asin of the sine clamped to 1, rounded to 0.1°, and the rounded value is stored and decides tee (within 15° of 90°) or wye.
addons/piping/lib/pipe.js
lines 111 to 140
CadCraft 0.8.2
/** As linkRuns for ducts: a run's end on another run's centerline (not at its ends) is a branch — a tee, or a wye at an angle. */
export function linkPipes(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 };
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;
const ang = Math.round((Math.asin(Math.min(1, sin)) * 180) / Math.PI * 10) / 10;
const msize = A.data.segs[near.seg].size, bsize = B.data.segs[end === 's' ? 0 : B.data.segs.length - 1].size;
out.get(B.id)[end] = { on: A.id, cut: odOf(A.data, msize) / 2 / sin, ang, kind: Math.abs(90 - ang) <= 15 ? 'tee' : 'wye', main: msize };
out.get(A.id).taps.push({ seg: near.seg, at: near.at, w: odOf(B.data, bsize) / 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, 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.
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) });
}
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);
}
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.