Resources Numerical accuracy Calculation policy

Truss geometry, cut list and shop outputs

Builds the centerline frame of a truss from its type or from the roof's section, and produces the cut list, joint schedule, jig drawing, stock plan, 3D members and CAD drawings.

How the precision is kept

Joint coordinates and member lengths are computed in 64-bit millimeters with closed forms. Several stored and exported values are then rounded: the heel raise to a whole millimeter, cut lengths and angles to 0.1, STL and OBJ coordinates to 0.001 mm, and jig drawing coordinates to 0.1 mm. Joints are merged and roof profiles simplified with an absolute tolerance of 0.5 mm.

Open item. Keep unrounded cut lengths and angles in the cut-list rows and round only where they are printed.

Calculations under this policy

26 calculations in 7 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/trusscraft/lib/geometry.js CadCraft 0.8.2 64-bit 5 calculations Reviewed

Tests tools/addons-test/browser-trusscraft-cadcraft.mjs

Joint merge

64-bit

Reuses an existing joint when a new one is asked for at the same place.

A new joint is merged with an existing one when both its x and z are within 0.5 mm of it (absolute). The existing joint's coordinates are kept.

  • addons/trusscraft/lib/geometry.js
  • lines 16 to 22
  • CadCraft 0.8.2
node(x, z) {
  const f = this.nodes.find((n) => Math.abs(n.x - x) < TOL && Math.abs(n.z - z) < TOL);
  if (f) return f.id;
  const id = this.nodes.length;
  this.nodes.push({ id, x, z });
  return id;
}

Member length and angle

64-bit

The centerline length and the slope angle of a member between two joints.

len = √(Δx² + Δz²); angle = |atan2(Δz, Δx)| in degrees

Math.hypot of the joint coordinates in 64-bit. A member shorter than 1 mm is not added.

  • addons/trusscraft/lib/geometry.js
  • lines 23 to 31
  • CadCraft 0.8.2
add(role, a, b) {
  if (a === b) return null;
  if (this.members.some((m) => (m.a === a && m.b === b) || (m.a === b && m.b === a))) return null;
  const A = this.nodes[a], B = this.nodes[b];
  const len = Math.hypot(B.x - A.x, B.z - A.z);
  if (len < 1) return null;
  this.members.push({ id: this.members.length, role, a, b, len, angle: Math.abs(Math.atan2(B.z - A.z, B.x - A.x)) / D2R });
  return this.members.length - 1;
}

Frame of a named truss type

64-bit

The joints and members of a king, queen, Howe, Pratt, Fink, scissor, mono or attic truss from span, pitch and overhang.

rise(x) = min(x, span − x) × tan(pitch); panel points at span × i ÷ n

tan(pitch) is taken once and reused for every top-chord joint. Span must be 1500 to 30000 mm and pitch 5° to 60°. The overhang tail ends at (−overhang, −overhang × tan pitch).

  • addons/trusscraft/lib/geometry.js
  • lines 37 to 106
  • CadCraft 0.8.2
export function generate(spec = {}) {
  const type = String(spec.type || 'fink');
  const S = Number(spec.span), pitch = Number(spec.pitch), oh = Math.max(0, Number(spec.overhang) || 0);
  if (!(S >= 1500 && S <= 30000)) return err('span');
  if (!(pitch >= 5 && pitch <= 60)) return err('pitch');
  const tan = Math.tan(pitch * D2R), F = new Frame(), notes = [];
  const rise = (x) => Math.min(x, S - x) * tan;
  const bot = (x, z = 0) => F.node(x, z);
  let tail = true;
  const panelsOf = (dflt, even) => { let n = Math.round(Number(spec.panels) || dflt); n = Math.max(even ? 4 : 2, Math.min(12, n)); return even && n % 2 ? n + 1 : n; };

  if (type === 'king') {
    const B0 = bot(0), B1 = bot(S / 2), B2 = bot(S), P = F.node(S / 2, rise(S / 2));
    F.chain('bottom', [B0, B1, B2]); F.chain('top', [B0, P, B2]); F.add('post', B1, P);
  } else if (type === 'queen') {
    const x1 = S / 3, x2 = 2 * S / 3;
    const B = [bot(0), bot(x1), bot(x2), bot(S)], R1 = F.node(x1, rise(x1)), R2 = F.node(x2, rise(x2)), P = F.node(S / 2, rise(S / 2));
    F.chain('bottom', B); F.chain('top', [B[0], R1, P, R2, B[3]]);
    F.add('post', B[1], R1); F.add('post', B[2], R2); F.add('web', R1, R2); F.add('web', B[1], R2);
    notes.push('queen: a horizontal strut between the queen posts and one diagonal brace make the middle panel stiff');
  } else if (type === 'howe' || type === 'pratt') {
    const n = panelsOf(6, true), Bs = [], Ts = [];
    for (let i = 0; i <= n; i++) { const x = (S * i) / n; Bs.push(bot(x)); Ts.push(i === 0 || i === n ? Bs[i] : F.node(x, rise(x))); }
    F.chain('bottom', Bs); F.chain('top', Ts);
    for (let i = 1; i < n; i++) F.add('post', Bs[i], Ts[i]);
    for (let i = 1; i < n / 2; i++) {
      const j = n - i;
      if (type === 'pratt') { F.add('web', Ts[i], Bs[i + 1]); F.add('web', Ts[j], Bs[j - 1]); }
      else { F.add('web', Bs[i], Ts[i + 1]); F.add('web', Bs[j], Ts[j - 1]); }
    }
  } else if (type === 'fink') {
    const xs = [0, S / 3, 2 * S / 3, S], B = xs.map((x) => bot(x)), R1 = F.node(S / 4, rise(S / 4)), R2 = F.node(3 * S / 4, rise(3 * S / 4)), P = F.node(S / 2, rise(S / 2));
    F.chain('bottom', B); F.chain('top', [B[0], R1, P, R2, B[3]]);
    F.add('web', R1, B[1]); F.add('web', B[1], P); F.add('web', P, B[2]); F.add('web', B[2], R2);
  } else if (type === 'scissor') {
    let bp = Number(spec.bottomPitch);
    if (!(bp > 0)) bp = pitch / 2;
    if (bp >= pitch - 2) return err('scissor');
    const zb = (S / 2) * Math.tan(bp * D2R);
    const B0 = bot(0), Bm = bot(S / 2, zb), B2 = bot(S), P = F.node(S / 2, rise(S / 2));
    F.chain('bottom', [B0, Bm, B2]); F.chain('top', [B0, P, B2]); F.add('post', Bm, P);
    notes.push('scissor: the bottom chords rise at ' + Math.round(bp * 10) / 10 + ' degrees; the supports spread, so one bearing is drawn as a roller');
  } else if (type === 'mono') {
    const n = panelsOf(4, false), Bs = [], Ts = [];
    for (let i = 0; i <= n; i++) { const x = (S * i) / n; Bs.push(bot(x)); Ts.push(i === 0 ? Bs[0] : F.node(x, x * tan)); }
    F.chain('bottom', Bs); F.chain('top', Ts);
    for (let i = 1; i <= n; i++) F.add('post', Bs[i], Ts[i]);
    for (let i = 1; i < n; i++) F.add('web', Ts[i], Bs[i + 1]);
    tail = false;
  } else if (type === 'attic') {
    const W = Number(spec.atticWidth) || 3000, H = Number(spec.atticHeight) || 2400, xa = (S - W) / 2, xb = xa + W;
    if (!(W >= 1500 && W < S - 600)) return err('attic');
    if (!(H >= 1800 && H < rise(xa) - 150)) return err('atticHeight');
    const B = [bot(0), bot(xa), bot(xb), bot(S)], C1 = F.node(xa, H), C2 = F.node(xb, H), R1 = F.node(xa, rise(xa)), R2 = F.node(xb, rise(xb)), P = F.node(S / 2, rise(S / 2));
    F.chain('bottom', B); F.chain('top', [B[0], R1, P, R2, B[3]]);
    F.chain('post', [B[1], C1, R1]); F.chain('post', [B[2], C2, R2]); F.add('web', C1, C2); F.add('web', C1, P); F.add('web', C2, P);
    // the room box: floor, two knee walls and the ceiling tie. A pin-jointed box is a mechanism; it is held only if its corner joints resist bending.
    for (const [p, q] of [[B[1], B[2]], [B[1], C1], [B[2], C2], [C1, C2]]) { const mm = F.members.find((x) => (x.a === p && x.b === q) || (x.a === q && x.b === p)); if (mm) mm.room = true; }
    notes.push('attic: the room box (floor, knee walls, ceiling tie) is a rectangle; a pin-jointed frame cannot hold it, the joints must resist bending');
  } else return err('type');

  // the overhang: each top chord runs on past the bearing along its own line (a mono truss has it at the low end only)
  const n0 = 0, nLast = F.nodes.findIndex((n) => Math.abs(n.x - S) < TOL && Math.abs(n.z) < TOL);
  if (oh > 0) {
    F.add('tail', F.node(-oh, -oh * tan), n0);
    if (tail && nLast >= 0) F.add('tail', F.node(S + oh, -oh * tan), nLast);
  }
  return { ok: true, type, span: S, pitch, overhang: oh, nodes: F.nodes, members: F.members, supports: [n0, nLast], notes,
    rise: rise(S / 2) * (type === 'mono' ? 2 : 1) };
}

Bearing stubs under a raised heel

64-bit

Adds a vertical stub under each heel joint when the heel is raised.

Each support gets a new joint exactly the raise below it and a vertical member of that length. The raise passed in is the whole-millimeter value from heelInfo.

  • addons/trusscraft/lib/geometry.js
  • lines 108 to 126
  • CadCraft 0.8.2
/**
 * Raised heel / bearing stub: a heel taller than the chords need stands on a vertical stub under each heel joint. The stub is a real member (role 'stub',
 * from the bearing up to the heel joint) with its own nodes; the supports of the pin-jointed analysis stay at the heel joints, and the stub carries the
 * vertical bearing reaction as a compression member (the horizontal reaction there is shear at the stub and its anchorage, said in Problems).
 * Frame z = 0 is the heel joint; the bearing is `raise` below it, and the whole frame is lifted by `raise` when it is drawn or placed (g.raise).
 */
export function addStubs(g, raise) {
  if (!(raise > 1) || g.members.some((m) => m.role === 'stub')) return g;
  g.bearings = [];
  for (const sup of g.supports) {
    if (sup < 0) continue;
    const n = g.nodes[sup], id = g.nodes.length;
    g.nodes.push({ id, x: n.x, z: n.z - raise });
    g.members.push({ id: g.members.length, role: 'stub', a: id, b: sup, len: raise, angle: 90 });
    g.bearings.push(id);
  }
  g.raise = raise;
  return g;
}

Heel depth and raise

64-bit

The heel depth the chords need at the outer face and how far a taller stated heel raises the truss.

needed = d_bottom ÷ 2 + d_top ÷ (2 cos pitch); raise = max(0, heel − needed)

Both the needed depth and the raise are rounded to whole millimeters with Math.round. The rounded raise is the value that lifts the frame and sets the stub length.

  • addons/trusscraft/lib/geometry.js
  • lines 136 to 141
  • CadCraft 0.8.2
/** The depth the chords need at the outer face (the top chord's plumb cut over half the bottom chord) and the raise a taller stated heel gives. */
export function heelInfo(g, { dTop, dBottom, heel }) {
  const cos = Math.cos(g.pitch * D2R), needed = dBottom / 2 + dTop / (2 * cos);
  const h = Number(heel) || 0;
  return { needed: Math.round(needed), stated: h, raise: Math.round(Math.max(0, h - needed)), short: h > 0 && h < needed - 0.5 };
}
addons/trusscraft/lib/profile.js CadCraft 0.8.2 64-bit 5 calculations Reviewed

Tests tools/addons-test/browser-trusscraft-cadcraft.mjs

Roof section along a truss line

64-bit

The height of the roof's underside along the line a truss stands on.

z = start + (n·p − c) ÷ speed on each face; highest face wins

The line is cut by every edge of every roof face. A crossing is skipped when the 2 × 2 determinant is below 1e-12 (absolute) and accepted when its edge parameter is within 1e-9 of [0, 1]. The start of a stretch is not added again when it matches the last point within 1e-6 in x and 0.5 mm in height.

  • addons/trusscraft/lib/profile.js
  • lines 27 to 64
  • CadCraft 0.8.2
/** The roof's underside along P0 → P1 as a polyline [x, z] (x from P0). Exact: every face's polygon is cut by the line. */
export function sectionProfile(plan, P0, P1) {
  const dx = P1[0] - P0[0], dy = P1[1] - P0[1], L = Math.hypot(dx, dy);
  if (!(L > 1)) return [];
  const at = (t) => [P0[0] + dx * t, P0[1] + dy * t];
  const pieces = [];
  for (const f of plan.faces) {
    const poly = f.poly, ts = [0, 1];
    for (let i = 0; i < poly.length; i++) {
      const A = poly[i], B = poly[(i + 1) % poly.length], ex = B[0] - A[0], ey = B[1] - A[1], den = dx * ey - dy * ex;
      if (Math.abs(den) < 1e-12) continue;
      const t = ((A[0] - P0[0]) * ey - (A[1] - P0[1]) * ex) / den, s = ((A[0] - P0[0]) * dy - (A[1] - P0[1]) * dx) / den;
      if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t);
    }
    ts.sort((a, b) => a - b);
    for (let i = 0; i + 1 < ts.length; i++) {
      const t0 = ts[i], t1 = ts[i + 1];
      if (t1 - t0 < 1e-9) continue;
      const m = at((t0 + t1) / 2);
      if (inPoly(m[0], m[1], poly)) pieces.push({ t0, t1, z0: faceZ(f, at(t0)), z1: faceZ(f, at(t1)), f });
    }
  }
  if (!pieces.length) return [];
  const bk = [...new Set(pieces.flatMap((p) => [p.t0, p.t1]))].sort((a, b) => a - b);
  const pts = [];
  for (let i = 0; i + 1 < bk.length; i++) {
    const a = bk[i], b = bk[i + 1];
    if (b - a < 1e-9) continue;
    const mid = (a + b) / 2;
    let best = null;
    for (const p of pieces) if (p.t0 <= mid && p.t1 >= mid) { const z = p.z0 + ((p.z1 - p.z0) * (mid - p.t0)) / (p.t1 - p.t0); if (!best || z > best.z) best = { p, z }; }
    if (!best) continue;
    const z = (t) => best.p.z0 + ((best.p.z1 - best.p.z0) * (t - best.p.t0)) / (best.p.t1 - best.p.t0);
    if (!pts.length || Math.abs(pts[pts.length - 1][0] - a * L) > 1e-6 || Math.abs(pts[pts.length - 1][1] - z(a)) > 0.5) pts.push([a * L, z(a)]);
    pts.push([b * L, z(b)]);
  }
  return simplify(pts);
}

Profile simplification

64-bit

Removes repeated points and points that lie on a straight line between their neighbors.

The middle of three points is removed when it lies within 0.5 mm in height of the line between its neighbors, so the kept profile can differ from the cut section by up to 0.5 mm there.

  • addons/trusscraft/lib/profile.js
  • lines 66 to 79
  • CadCraft 0.8.2
/** Drop the middle point of three that lie on one line (within 0.5 mm in z), and repeated points. */
export function simplify(pts) {
  const out = [];
  for (const p of pts) {
    if (out.length && Math.abs(out[out.length - 1][0] - p[0]) < 1e-6 && Math.abs(out[out.length - 1][1] - p[1]) < 0.5) continue;
    out.push([p[0], p[1]]);
    while (out.length >= 3) {
      const [a, b, c] = out.slice(-3);
      const zl = a[1] + ((c[1] - a[1]) * (b[0] - a[0])) / (c[0] - a[0] || 1);
      if (Math.abs(zl - b[1]) < 0.5) out.splice(out.length - 2, 1); else break;
    }
  }
  return out;
}

Height of a profile at x

64-bit

The profile's height at a distance along the truss.

z = z₀ + (z₁ − z₀)(x − x₀) ÷ (x₁ − x₀)

Linear interpolation in the segment containing x. A segment narrower than 1e-9 in x returns its far height instead of dividing.

  • addons/trusscraft/lib/profile.js
  • lines 81 to 88
  • CadCraft 0.8.2
export const heightIn = (pts, x) => {
  if (x <= pts[0][0]) return pts[0][1];
  for (let i = 0; i + 1 < pts.length; i++) {
    const [x0, z0] = pts[i], [x1, z1] = pts[i + 1];
    if (x <= x1 + 1e-9) return x1 - x0 < 1e-9 ? z1 : z0 + ((z1 - z0) * (x - x0)) / (x1 - x0);
  }
  return pts[pts.length - 1][1];
};

What a profile is

64-bit

Decides whether a roof section is a plain triangle, has a flat top, has a valley across it, and is symmetric.

The thresholds are absolute, in millimeters: a flat changes height by less than 0.5 over more than 50; the symmetric test allows 1; a triangle's apex must be within 2 of mid-span and its ends under 0.5.

  • addons/trusscraft/lib/profile.js
  • lines 90 to 104
  • CadCraft 0.8.2
/** What a profile is: how many peaks, whether it has a flat top, a notch (a valley across the line), whether it is the symmetric triangle. */
export function classify(pts) {
  const n = pts.length, S = pts[n - 1][0] - pts[0][0];
  const seg = pts.slice(1).map((p, i) => ({ x0: pts[i][0], x1: p[0], z0: pts[i][1], z1: p[1], dz: p[1] - pts[i][1], len: p[0] - pts[i][0] }));
  const flats = seg.filter((s) => Math.abs(s.dz) < 0.5 && s.len > 50);
  let notches = 0;
  for (let i = 1; i + 1 < n; i++) if (pts[i][1] < pts[i - 1][1] - 0.5 && pts[i][1] < pts[i + 1][1] - 0.5) notches++;
  // a "notch" also where the profile falls then rises again after a flat
  const dirs = seg.filter((s) => Math.abs(s.dz) >= 0.5).map((s) => Math.sign(s.dz));
  let changes = 0;
  for (let i = 1; i < dirs.length; i++) if (dirs[i] !== dirs[i - 1]) changes++;
  const apex = Math.max(...pts.map((p) => p[1])), apexAt = pts.filter((p) => p[1] >= apex - 0.5).map((p) => p[0]);
  const triangle = n === 3 && pts[0][1] < 0.5 && pts[2][1] < 0.5 && Math.abs(pts[1][0] - S / 2) < 2;
  return { n, span: S, apex, apexAt, flats, notches: changes > 2 ? Math.floor(changes / 2) : 0, vertexNotches: notches, triangle, endsLow: pts[0][1] < 0.5 && pts[n - 1][1] < 0.5, symmetric: isSymmetric(pts) };
}

Frame from a roof profile

64-bit

The joints and members of a truss whose top chord follows the roof's section.

pitch = atan(slope of the first panel), or atan(steepest profile slope) when the left end stands on a post

Stations closer than 60 mm to the previous fixed station are dropped, and joints merge within 0.5 mm. A station where the roof is under 2 mm above the bottom chord shares the bottom joint. The profile's first and last x are set to exactly 0 and the span when they are within 1 mm of them.

  • addons/trusscraft/lib/profile.js
  • lines 110 to 158
  • CadCraft 0.8.2
/** Frame from a profile. See the file header. spec: { span, profile: [[x, z]...] from x = 0 to span, panels (0 = about 1.2 m), extra: [x...] stations to force, overhangL, overhangR, form }. */
export function profileTruss(spec = {}) {
  const S = Number(spec.span), prof = (spec.profile || []).map((p) => [Number(p[0]), Number(p[1])]);
  if (!(S >= 600 && S <= 30000) || prof.length < 2 || prof.some((p) => !Number.isFinite(p[0]) || !Number.isFinite(p[1]) || p[1] < -TOL)) return { ok: false, error: 'profile' };
  if (Math.abs(prof[0][0]) > 1 || Math.abs(prof[prof.length - 1][0] - S) > 1) return { ok: false, error: 'profile' };
  prof[0][0] = 0; prof[prof.length - 1][0] = S;
  const apex = Math.max(...prof.map((p) => p[1]));
  if (apex < 120) return { ok: false, error: 'low' };
  const h = (x) => heightIn(prof, x);
  // stations: the profile's own vertices and the forced ones first, then an even spacing between
  const hard = [0, S, ...prof.map((p) => p[0]), ...(spec.extra || []).map(Number).filter((x) => x > 0 && x < S)].sort((a, b) => a - b);
  const keep = [];
  for (const x of hard) if (!keep.length || x - keep[keep.length - 1] > 60) keep.push(x);
  const N0 = spec.panels > 0 ? Math.round(spec.panels) : Math.round(S / 1200);
  const n0 = Math.max(2, Math.min(24, N0));
  const xs = [...keep];
  for (let i = 1; i < n0; i++) {
    const x = (S * i) / n0;
    if (xs.every((k) => Math.abs(k - x) > Math.max(150, S / (2 * n0)))) xs.push(x);
  }
  xs.sort((a, b) => a - b);
  const nodes = [], members = [];
  const node = (x, z) => { const f = nodes.find((n) => Math.abs(n.x - x) < TOL && Math.abs(n.z - z) < TOL); if (f) return f.id; nodes.push({ id: nodes.length, x, z }); return nodes.length - 1; };
  const add = (role, a, b) => {
    if (a === b) return;
    const A = nodes[a], B = nodes[b], len = Math.hypot(B.x - A.x, B.z - A.z);
    if (len < 1 || members.some((m) => (m.a === a && m.b === b) || (m.a === b && m.b === a))) return;
    members.push({ id: members.length, role, a, b, len, angle: (Math.abs(Math.atan2(B.z - A.z, B.x - A.x)) * 180) / Math.PI });
  };
  const Bn = xs.map((x) => node(x, 0)), Tn = xs.map((x, i) => (h(x) < 2 ? Bn[i] : node(x, h(x))));
  for (let i = 0; i + 1 < xs.length; i++) { add('bottom', Bn[i], Bn[i + 1]); add('top', Tn[i], Tn[i + 1]); }
  for (let i = 0; i < xs.length; i++) if (Tn[i] !== Bn[i]) add('post', Bn[i], Tn[i]);
  for (let i = 0; i + 1 < xs.length; i++) {
    if (Tn[i] === Bn[i] || Tn[i + 1] === Bn[i + 1]) continue;   // a panel that closes at a heel is already a triangle
    const mid = (xs[i] + xs[i + 1]) / 2;
    if (mid <= S / 2 + 1) add('web', Tn[i], Bn[i + 1]); else add('web', Tn[i + 1], Bn[i]);
  }
  const slopeAt = (i, j) => (h(xs[j]) - h(xs[i])) / (xs[j] - xs[i]);
  const shL = Tn[0] === Bn[0], shR = Tn[xs.length - 1] === Bn[xs.length - 1];
  const tanL = slopeAt(0, 1), tanR = -slopeAt(xs.length - 2, xs.length - 1);
  const ohL = Math.max(0, Number(spec.overhangL) || 0), ohR = Math.max(0, Number(spec.overhangR) || 0);
  if (ohL > 0 && shL && tanL > 0.02) add('tail', node(-ohL, -ohL * tanL), Bn[0]);
  if (ohR > 0 && shR && tanR > 0.02) add('tail', node(S + ohR, -ohR * tanR), Bn[xs.length - 1]);
  const maxSlope = Math.max(...prof.slice(1).map((p, i) => Math.abs(p[1] - prof[i][1]) / Math.max(1e-9, p[0] - prof[i][0])));
  const pitch = (Math.atan(shL ? tanL : maxSlope) / D2R);
  const notes = [];
  if (!shL || !shR) notes.push('an end stands on a post: the roof is above the bearing there');
  return { ok: true, type: spec.form || 'profile', form: spec.form || 'profile', span: S, pitch, pitchR: Math.atan(shR ? tanR : maxSlope) / D2R, overhang: ohL, nodes, members, supports: [Bn[0], Bn[xs.length - 1]], notes, rise: apex, profile: prof, stations: xs, flatTop: prof.some((p, i) => i > 0 && Math.abs(p[1] - prof[i - 1][1]) < 0.5 && p[0] - prof[i - 1][0] > 50) };
}
addons/trusscraft/lib/cutlist.js CadCraft 0.8.2 64-bit 5 calculations Reviewed

Tests tools/addons-test/browser-trusscraft-cadcraft.mjs

Chord pieces

64-bit

Joins chord members that run straight on through a joint into one piece of timber, with its length.

joined when acos(|u·v|) < 0.5°; len = √(Δx² + Δz²)

The angle between two members is acos of the absolute dot product of their unit vectors, clamped to 1. The piece length is Math.hypot between its two end joints.

  • addons/trusscraft/lib/cutlist.js
  • lines 19 to 45
  • CadCraft 0.8.2
/** Chords that run straight on through a node (same line, same role family) are one piece of timber. */
export function pieces(g, sizes) {
  const out = [], used = new Set();
  const chordRole = (r) => (r === 'tail' ? 'top' : r);
  for (const role of ['top', 'bottom']) {
    const ms = g.members.filter((m) => chordRole(m.role) === role).sort((p, q) => Math.min(g.nodes[p.a].x, g.nodes[p.b].x) - Math.min(g.nodes[q.a].x, g.nodes[q.b].x));
    const chains = [];
    for (const m of ms) {
      const mine = vec(g, m), dir = mine[0] < 0 ? [-mine[0], -mine[1]] : mine;
      const ch = chains.find((c) => (c.ends.includes(m.a) || c.ends.includes(m.b)) && acute(c.dir, mine) < 0.5);
      if (ch) {
        const shared = ch.ends.includes(m.a) ? m.a : m.b, other = shared === m.a ? m.b : m.a;
        ch.members.push(m); ch.ends = ch.ends.filter((e) => e !== shared).concat([other]);
      } else chains.push({ role, members: [m], ends: [m.a, m.b], dir });
    }
    for (const c of chains) {
      const [a, b] = c.ends.sort((p, q) => g.nodes[p].x - g.nodes[q].x);
      out.push({ role, size: sizes[role], members: c.members.map((m) => m.id), a, b, len: Math.hypot(g.nodes[b].x - g.nodes[a].x, g.nodes[b].z - g.nodes[a].z), marks: c.members.map((m) => m.mark) });
      c.members.forEach((m) => used.add(m.id));
    }
  }
  for (const m of g.members) if (!used.has(m.id)) out.push({ role: m.role, size: sizes[groupOf(m.role)], members: [m.id], a: m.a, b: m.b, len: m.len, marks: [m.mark] });
  const n = {};
  const pre = { top: 'TC', bottom: 'BC', web: 'W', post: 'P', stub: 'ST' };
  return out.sort((p, q) => Math.min(g.nodes[p.a].x, g.nodes[p.b].x) - Math.min(g.nodes[q.a].x, g.nodes[q.b].x) || p.len - q.len)
    .map((p) => { n[p.role] = (n[p.role] || 0) + 1; return { ...p, mark: pre[p.role] + n[p.role] }; });
}

Cut length and end angles

64-bit

The length to cut each piece and the angle of each end cut.

cut = joint length − set_A − set_B + (d ÷ 2)(tan A + tan B); web set = (host depth ÷ 2) ÷ max(0.2, sin φ)

The joint length, cut length and both angles are rounded to 0.1 (mm or degrees) in the returned rows. The splice test compares the unrounded cut length with the stock length. The sine in a web's setback is floored at 0.2.

  • addons/trusscraft/lib/cutlist.js
  • lines 47 to 76
  • CadCraft 0.8.2
export function cutList(g, sizes, { stock = 6000 } = {}) {
  const rows = [];
  for (const p of pieces(g, sizes)) {
    const size = sizeById(p.size) || sizeById('2x4'), notes = [];
    const chord = p.role === 'top' || p.role === 'bottom';
    const ends = [p.a, p.b].map((nid) => {
      const mem = g.members.filter((m) => p.members.includes(m.id) && (m.a === nid || m.b === nid))[0];
      const mine = vec(g, mem);
      if (chord) {
        // plumb cut at the heel, tail and peak: the end face is vertical, so it is off square by the member's own slope
        const slope = Math.atan2(Math.abs(mine[1]), Math.abs(mine[0])) * RAD;
        return { cut: slope, set: 0 };
      }
      const others = g.members.filter((m) => m.id !== mem.id && (m.a === nid || m.b === nid) && m.role !== 'tail');
      const host = others.filter((m) => m.role === 'top' || m.role === 'bottom').sort((x, y) => y.len - x.len)[0] || others.sort((x, y) => y.len - x.len)[0];
      if (!host) return { cut: 0, set: 0 };
      const phi = acute(mine, vec(g, host));
      const hostSize = sizeById(sizes[groupOf(host.role)]) || size;
      return { cut: Math.abs(90 - phi), set: (hostSize.d / 2) / Math.max(0.2, Math.sin(phi * D2R)) };
    });
    const nodeLen = p.len;
    let cutLen = nodeLen - ends[0].set - ends[1].set + (size.d / 2) * (Math.tan(ends[0].cut * D2R) + Math.tan(ends[1].cut * D2R));
    const tailPart = chord && p.members.some((id) => g.members[id].role === 'tail');
    if (tailPart) notes.push('includes the overhang tail');
    const splice = chord && cutLen > stock;
    if (splice) notes.push('longer than the ' + stock + ' mm stock length: spliced or a longer board');
    rows.push({ mark: p.mark, role: p.role, size: p.size, qty: 1, nodeLen: round1(nodeLen), cutLen: round1(cutLen), cutA: round1(ends[0].cut), cutB: round1(ends[1].cut), splice, notes: notes.join('; '), members: p.members });
  }
  return rows;
}

Member list values

64-bit

The length, angle, force and utilization shown for each member.

Length and angle are rounded to 0.1, force to 2 decimals of a kN and utilization to 2 decimals in the row. The pass or fail decision is made elsewhere on the unrounded utilization.

  • addons/trusscraft/lib/cutlist.js
  • lines 78 to 84
  • CadCraft 0.8.2
export function memberList(g, rows) {
  const by = new Map(rows.map((r) => [r.id, r]));
  return g.members.filter((m) => m.role !== 'tail').map((m) => {
    const r = by.get(m.id) || {};
    return { mark: m.mark, role: m.role, size: r.size || '', nodeLen: round1(m.len), angle: round1(m.angle), force: Number.isFinite(r.force) ? round1(r.force * 10) / 10 : null, kind: r.kind || '', util: Number.isFinite(r.util) ? Math.round(r.util * 100) / 100 : null };
  });
}

Joint schedule and plate choice

64-bit

The members meeting at each joint with their angles and forces, and the first plate of the table that fits.

plate: capacity ≥ largest |force| and min(w, h) ≥ min(deepest member + 25, 152)

Member angles and the largest force are rounded to 0.1 in the row; the plate is chosen with the unrounded force. The default plate table is labeled a placeholder in the output.

  • addons/trusscraft/lib/cutlist.js
  • lines 86 to 101
  • CadCraft 0.8.2
/** plates: { list: [{ id, w, h, kN }], label, placeholder: bool }  (the person's own table, or the labelled placeholder series). */
export function jointSchedule(g, sizes, res, plates = null) {
  const plateList = plates && plates.list && plates.list.length ? plates.list : PLATES, placeholder = !plates || plates.placeholder !== false;
  const out = [];
  for (const n of g.nodes) {
    const ms = g.members.filter((m) => m.role !== 'tail' && (m.a === n.id || m.b === n.id));
    if (ms.length < 2) continue;
    const dirs = ms.map((m) => { const v = vec(g, m); return m.a === n.id ? v : [-v[0], -v[1]]; });
    const list = ms.map((m, i) => ({ mark: m.mark, angle: round1(Math.atan2(dirs[i][1], dirs[i][0]) * RAD), force: res && res.forces ? res.forces.get(m.id) ?? null : null }));
    const maxForce = Math.max(0, ...list.map((l) => Math.abs(l.force || 0)));
    const dMax = Math.max(...ms.map((m) => (sizeById(sizes[groupOf(m.role)]) || { d: 89 }).d));
    const plate = plateList.find((p) => p.kN >= maxForce && Math.min(p.w, p.h) >= Math.min(dMax + 25, 152)) || null;
    out.push({ node: n.id, x: n.x, z: n.z, members: list, maxForce: round1(maxForce), plate: plate ? plate.id : null, plateKN: plate ? plate.kN : null, beyond: !plate, indicative: true, source: placeholder ? 'placeholder' : 'yours', tableLabel: placeholder ? 'Placeholder, not a manufacturer value' : (plates.label || 'Your plate table') });
  }
  return out;
}

Bird's mouth cut

64-bit

The plumb cut of a rafter seat and the depth left above it.

plumb = seat × tan(pitch); left = depth − plumb × cos(pitch)

The returned values are rounded to 0.1 mm. The pass test uses the unrounded depth left, with a margin of 1e-9.

  • addons/trusscraft/lib/cutlist.js
  • lines 103 to 106
  • CadCraft 0.8.2
export function birdsMouth({ pitch, depth, seat = 89, maxFrac = 1 / 3 }) {
  const t = Math.tan(pitch * D2R), plumb = seat * t, residual = depth - plumb * Math.cos(pitch * D2R);
  return { seat, plumb: round1(plumb), residual: round1(residual), limit: round1(depth * (1 - maxFrac)), ok: residual >= depth * (1 - maxFrac) - 1e-9 };
}
addons/trusscraft/lib/fab.js CadCraft 0.8.2 64-bit 2 calculations Reviewed

Tests tools/addons-test/browser-trusscraft-cadcraft.mjs

Jig drawing at full size

64-bit

The outline of every member on the 1:1 jig sheet.

corner = joint ± (−Δz, Δx) ÷ L × d ÷ 2

The sheet is sized in millimeters so it prints life size. Every outline coordinate is rounded to 0.1 mm before it is written to the SVG.

  • addons/trusscraft/lib/fab.js
  • lines 28 to 34
  • CadCraft 0.8.2
const X = (x) => f(x - x0), Z = (z) => f(zhi - z);
const out = [];
const rows = cutList(g, d.sizes, { stock: 1e9 }), js = jointSchedule(g, d.sizes, d.res, plates);
for (const m of g.members) {
  const a = g.nodes[m.a], b = g.nodes[m.b], size = sizeById(d.sizes[groupOf(m.role)]) || sizeById('2x4');
  const dx = b.x - a.x, dz = b.z - a.z, L = Math.hypot(dx, dz), nx = -dz / L * size.d / 2, nz = dx / L * size.d / 2;
  const pts = [[a.x + nx, a.z + nz], [b.x + nx, b.z + nz], [b.x - nx, b.z - nz], [a.x - nx, a.z - nz]].map((p) => X(p[0]) + ',' + Z(p[1] + raise)).join(' ');

Stock plan

64-bit

Nests the cut pieces of a truss set onto stock lengths and lists them as CSV.

Pieces go to the bar-nesting module with the cut-list length already rounded to 0.1 mm and a saw kerf of 3 mm by default. In the CSV the position along the bar is rounded to a whole millimeter.

  • addons/trusscraft/lib/fab.js
  • lines 70 to 87
  • CadCraft 0.8.2
export function stockPlan(items, { lengths = [6000], kerf = 3 } = {}) {
  const pieces = [];
  for (const it of items) {
    for (const r of cutList(it.design.g, it.design.sizes, { stock: 1e9 })) {
      const size = sizeById(r.size) || sizeById('2x4');
      pieces.push({ id: `${it.label}:${r.mark}`, length: r.cutLen, qty: it.qty, cuts: [90 - r.cutA, 90 - r.cutB], depth: size.d, group: r.size });
    }
  }
  const stock = lengths.filter((n) => n > 0).sort((a, b) => a - b).map((length) => ({ length }));
  const res = optimizeGroups(expandPieces(pieces), { stock: { '*': stock.length ? stock : [{ length: 6000 }] }, kerf });
  const rows = [['size', 'bar', 'stock mm', 'piece', 'length mm', 'angle A', 'angle B', 'position mm']];
  const unplaced = [];
  for (const gp of res.groups) {
    for (const b of gp.bars) for (const p of b.pieces) rows.push([gp.group, b.no, b.stock, p.pieceId, p.length, p.cuts[0], p.cuts[1], Math.round(p.pos)]);
    for (const u of gp.unplaced || []) unplaced.push({ size: gp.group, id: u.pieceId || u.id, length: u.length });
  }
  return { ...res, unplaced, csv: csv(rows), pieces: pieces.length };
}
addons/trusscraft/lib/bodies.js CadCraft 0.8.2 64-bit 4 calculations Reviewed

Tests tools/addons-test/browser-trusscraft-cadcraft.mjs

Member as a solid

64-bit

A closed box for one member from its two end points, depth and thickness, with its volume.

V = |Σ a · (b × c) ÷ 6| over the 12 triangles

The eight corners are built relative to the member's start point from unit axes, so the coordinates in the volume sum stay small. The sign of the sum turns the triangles outward and its absolute value is returned.

  • addons/trusscraft/lib/bodies.js
  • lines 18 to 34
  • CadCraft 0.8.2
export function boxMesh(a, b, depth, thick, planeDir) {
  const ax = unit(sub(b, a)), tv = unit(cross([0, 0, 1], [planeDir[0], planeDir[1], 0])), nv = unit(cross(tv, ax));   // nv: in the truss plane, across the member
  const len = Math.hypot(...sub(b, a)), positions = [];
  for (let e = 0; e < 2; e++) for (let s1 = -1; s1 <= 1; s1 += 2) for (let s2 = -1; s2 <= 1; s2 += 2) {
    positions.push(...[0, 1, 2].map((i) => e * len * ax[i] + s1 * (depth / 2) * nv[i] + s2 * (thick / 2) * tv[i]));
  }
  // index = e*4 + (s1>0)*2 + (s2>0)
  const quads = [[0, 1, 3, 2], [4, 6, 7, 5], [0, 4, 5, 1], [2, 3, 7, 6], [0, 2, 6, 4], [1, 5, 7, 3]];
  let indices = quads.flatMap(([p, q, r, s]) => [p, q, r, p, r, s]);
  let vol = 0;
  for (let i = 0; i < indices.length; i += 3) {
    const A = positions.slice(indices[i] * 3, indices[i] * 3 + 3), B = positions.slice(indices[i + 1] * 3, indices[i + 1] * 3 + 3), C = positions.slice(indices[i + 2] * 3, indices[i + 2] * 3 + 3);
    vol += (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;
  }
  if (vol < 0) indices = indices.flatMap((_, i) => (i % 3 === 0 ? [indices[i], indices[i + 2], indices[i + 1]] : []));
  return { positions, indices, volume: Math.abs(vol) };
}

Members placed in plan and height

64-bit

The 3D end points of every member of a laid-out truss.

p = a + x × (b − a) ÷ span; z = z₀ + node z + raise + base

The layout stores the span rounded to a whole millimeter and the ends a and b rounded to 0.1 mm, so (b − a) ÷ span is not exactly a unit vector and positions along the truss are scaled by |b − a| ÷ span.

  • addons/trusscraft/lib/bodies.js
  • lines 36 to 40
  • CadCraft 0.8.2
export function memberSegments(t, g, z0 = 0, sizes = {}) {
  const dx = (t.b[0] - t.a[0]) / t.span, dy = (t.b[1] - t.a[1]) / t.span;
  const p = (id) => { const n = g.nodes[id]; return [t.a[0] + dx * n.x, t.a[1] + dy * n.x, z0 + n.z + (g.raise || 0) + (t.zBase || 0)]; };
  return g.members.map((m) => ({ mark: m.mark, role: m.role, a: p(m.a), b: p(m.b), size: sizes[m.role === 'post' || m.role === 'stub' ? 'web' : m.role === 'tail' ? 'top' : m.role], dir: [dx, dy] }));
}

STL export

64-bit

The truss set written as an ASCII STL file.

text = round(v × 1000) ÷ 1000

Vertex coordinates are rounded to 3 decimals after the host's mapping function, which is 0.001 mm when the coordinates are millimeters. Facet normals, the unit cross product of each mapped triangle, are rounded the same way.

  • addons/trusscraft/lib/bodies.js
  • lines 67 to 79
  • CadCraft 0.8.2
const fmt = (v) => String(Math.round(v * 1000) / 1000);
export function stlText(bodies, map = (p) => p, name = 'trusscraft') {
  const L = ['solid ' + name];
  for (const b of bodies) {
    const P = []; for (let i = 0; i < b.positions.length; i += 3) P.push(map([b.positions[i], b.positions[i + 1], b.positions[i + 2]]));
    for (let i = 0; i < b.indices.length; i += 3) {
      const [A, B, C] = [P[b.indices[i]], P[b.indices[i + 1]], P[b.indices[i + 2]]], n = unit(cross(sub(B, A), sub(C, A)));
      L.push(`facet normal ${n.map(fmt).join(' ')}`, ' outer loop', ...[A, B, C].map((v) => `  vertex ${v.map(fmt).join(' ')}`), ' endloop', 'endfacet');
    }
  }
  L.push('endsolid ' + name);
  return L.join('\n') + '\n';
}

OBJ export

64-bit

The truss set written as an OBJ file.

Vertex coordinates are rounded to 3 decimals after the host's mapping function, as in the STL export. Face indexes are offset by the vertices already written.

  • addons/trusscraft/lib/bodies.js
  • lines 80 to 90
  • CadCraft 0.8.2
export function objText(bodies, map = (p) => p) {
  const L = ['# TrussCraft: not a substitute for engineered truss design'];
  let base = 0;
  for (const b of bodies) {
    L.push('o ' + b.name.replace(/\s+/g, '_'));
    for (let i = 0; i < b.positions.length; i += 3) L.push('v ' + map([b.positions[i], b.positions[i + 1], b.positions[i + 2]]).map(fmt).join(' '));
    for (let i = 0; i < b.indices.length; i += 3) L.push(`f ${b.indices[i] + 1 + base} ${b.indices[i + 1] + 1 + base} ${b.indices[i + 2] + 1 + base}`);
    base += b.positions.length / 3;
  }
  return L.join('\n') + '\n';
}
addons/trusscraft/lib/cadplan.js CadCraft 0.8.2 64-bit 4 calculations Reviewed

Tests tools/addons-test/browser-trusscraft-cadcraft.mjs

Millimeters to drawing coordinates

64-bit

Places truss drawings in a CadCraft drawing at a point and a scale.

X = at_x + x × k; Y = at_y + y × k

One multiplication and one addition per coordinate, where k is drawing units per millimeter. Text heights are multiplied by k.

  • addons/trusscraft/lib/cadplan.js
  • lines 26 to 36
  • CadCraft 0.8.2
function maker({ at = [0, 0], k = 1, st = null }) {
  const P = (x, y) => ({ x: at[0] + x * k, y: at[1] + y * k });
  return {
    P,
    line: (layer, x1, y1, x2, y2) => ({ type: 'line', layer, x1: at[0] + x1 * k, y1: at[1] + y1 * k, x2: at[0] + x2 * k, y2: at[1] + y2 * k }),
    poly: (layer, pts, closed = true) => ({ type: 'poly', layer, pts: pts.map(([x, y]) => P(x, y)), closed }),
    text: (layer, x, y, s, h, o = {}) => ({ type: 'text', layer, x: at[0] + x * k, y: at[1] + y * k, h: h * k, rot: o.rot || 0, s: String(s), ha: o.ha || 'center', va: o.va || 'middle' }),
    hatch: (layer, pts) => ({ type: 'hatch', layer, solid: true, loops: [pts.flatMap(([x, y]) => [at[0] + x * k, at[1] + y * k])] }),
    dim: (layer, x1, y1, x2, y2, lx, ly, rot = 0) => ({ type: 'dim', kind: 'linear', layer, color: null, p1: P(x1, y1), p2: P(x2, y2), loc: P(lx, ly), rot, text: '', st: { ...(st || {}) } }),
  };
}

Member outlines in the elevation

64-bit

The closed outline drawn for each member in the truss elevation.

corner = joint ± (−Δz, Δx) ÷ L × d ÷ 2

The outline is the centerline offset by half the member depth along its unit normal, unrounded. Every member is a rectangle from joint to joint; end cuts are not drawn.

  • addons/trusscraft/lib/cadplan.js
  • lines 43 to 44
  • CadCraft 0.8.2
const dx = b.x - a.x, dz = b.z - a.z, L = Math.hypot(dx, dz), nx = -dz / L * size.d / 2, nz = dx / L * size.d / 2;
out.push(M.poly('TRUSS-MEMBERS', [[a.x + nx, a.z + nz + raise], [b.x + nx, b.z + nz + raise], [b.x - nx, b.z - nz + raise], [a.x - nx, a.z - nz + raise]]));

Force labels in the force diagram

64-bit

The force written beside each member.

The label is the force rounded to 0.1 kN. The layer (tension or compression) is chosen from the sign of the unrounded force, with zero counted as tension.

  • addons/trusscraft/lib/cadplan.js
  • line 73
  • CadCraft 0.8.2
if (fr !== null && fr !== undefined && !tail) out.push(M.text('TRUSS-FORCES', (a.x + b.x) / 2, (a.z + b.z) / 2 + raise + th * 1.2, round1(fr) + ' kN', th * 0.8));

Schedule rows in the drawing

64-bit

The cut list and joint schedule as rows of a table drawn in CadCraft.

The rows are text made from the cut list and joint schedule, whose lengths, angles and forces are already rounded to 0.1.

  • addons/trusscraft/lib/cadplan.js
  • lines 111 to 120
  • CadCraft 0.8.2
export function scheduleRows(d) {
  const cuts = cutList(d.g, d.sizes, { stock: 1e9 }), js = jointSchedule(d.g, d.sizes, d.res, d.opts && d.opts.plates ? d.opts.plates : null);
  return [
    [t('cad.col.mark'), t('cad.col.size'), t('cad.col.cut'), t('cad.col.angles')],
    ...cuts.map((r) => [r.mark, r.size, String(r.cutLen), `${r.cutA} / ${r.cutB}`]),
    [],
    [t('cad.col.joint'), t('cad.col.force'), t('cad.col.plate'), t('cad.col.source')],
    ...js.map((j) => [String(j.node + 1), String(j.maxForce), j.plate || t('cad.beyond'), j.source || '']),
  ];
}
addons/trusscraft/lib/drawing.js CadCraft 0.8.2 64-bit 1 calculation Reviewed

Tests tools/addons-test/browser-trusscraft-cadcraft.mjs

Dimension labels on the elevation picture

64-bit

The span, overhang, pitch, rise and heel written on the truss elevation.

Span, overhang, rise and heel are written as whole millimeters (Math.round) and the pitch to 0.1°. The picture's own coordinates are rounded to 0.1 of a pixel.

  • addons/trusscraft/lib/drawing.js
  • lines 37 to 41
  • CadCraft 0.8.2
out.push(dim(0, g.span, yb, t('dwg.span') + ' ' + Math.round(g.span) + ' mm'));
if (g.overhang > 0) out.push(dim(-g.overhang, 0, yb + 18, t('dwg.overhang') + ' ' + Math.round(g.overhang) + ' mm'));
const peakZ = Math.max(...g.nodes.map((n) => n.z));
out.push(`<text x="${X(g.span / 2)}" y="${Z(peakZ + raise) - 20}" font-size="10" text-anchor="middle" fill="#06c">${esc(t('dwg.pitch'))} ${f(g.pitch)}° · ${esc(t('dwg.rise'))} ${Math.round(peakZ)} mm</text>`);
if (raise > 0) out.push(`<text x="${X(0) - 4}" y="${Z(raise / 2)}" font-size="9" text-anchor="end" fill="#06c">${esc(t('dwg.heel'))} +${Math.round(raise)}</text>`);

In detail

Method
Panel points from span, pitch and panel count; roof section by cutting each roof face polygon with the truss line; cut length from joint length, setbacks at the host chord and the plumb or butt cut angles; members as boxes with a volume from signed tetrahedra.
Number types
64-bit numbers; plan and elevation in millimeters, angles in degrees.
Tolerances
Joint merge: 0.5 mm in x and z. Shortest member: 1 mm. Profile simplification: 0.5 mm in height. Chord pieces joined when within 0.5° of straight. Section line crossing: determinant below 1e-12 skipped, edge parameter within 1e-9 of [0, 1]. Web setback: sine floored at 0.2.
Limits
Heel raise is a whole number of millimeters. Cut-list rows hold values rounded to 0.1 mm and 0.1°, and the stock plan nests those rounded lengths. Members are drawn and built as rectangles from joint to joint; end cuts are given as angles in the cut list, not modeled.

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