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.
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.
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 };
}
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.
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) };
}
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.
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.
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 };
}
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.
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';
}
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.
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.
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.
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.