Resources Numerical accuracy Calculation policy

Structural members and their cut list

Places steel sections along sketch lines, works out the miter, butt and fitted cuts where members meet, and lists each piece's length and end angles in the cut list.

How the precision is kept

Member lines, frames and cut planes are 64-bit. The cut-list length of a member is measured as the extent of its finished solid along its line, read from the vertex array that manifold-3d's getMesh returns; the manifold-3d 3.5.4 type file in assets/vendor declares that array as Float32Array. The length is then rounded to 0.1 mm and the end angles to 0.01° before they are stored in the cut list.

Open item. Compute each member's cut length from the 64-bit line ends and cut planes instead of the mesh vertices, and keep it unrounded until it is shown.

Calculations under this policy

10 calculations in 1 source file. 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.

weldment.js PartCraft 0.8.1 32-bit in part 10 calculations Reviewed

Tests tools/partcraft-test/test-weldment.mjs

Section outline at its anchor

64-bit

The section's outline moved so the chosen anchor point sits on the member's line, with its width, height and radius.

w = u_max − u_min; h = v_max − v_min; R = max |point|

The bounding box is a minimum and maximum over the outer loop and the anchor a corner, edge middle or center of it; each outline point is shifted by one subtraction. R is the farthest outline point from the anchor, by Math.hypot.

  • weldment.js
  • lines 60 to 70
  • PartCraft 0.8.1
/** The section's loops with the anchor point at (0, 0), and its bounding box. */
function profileLoops(p, anchor) {
  const loops = outline(p, 4);
  let lo = [Infinity, Infinity], hi = [-Infinity, -Infinity];
  for (const [u, v] of loops[0]) { lo = [Math.min(lo[0], u), Math.min(lo[1], v)]; hi = [Math.max(hi[0], u), Math.max(hi[1], v)]; }
  const au = anchor[1] === 'l' ? lo[0] : anchor[1] === 'r' ? hi[0] : (lo[0] + hi[0]) / 2;
  const av = anchor[0] === 't' ? hi[1] : anchor[0] === 'b' ? lo[1] : (lo[1] + hi[1]) / 2;
  const a = anchor === 'c' ? [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2] : anchor === 'ml' ? [lo[0], (lo[1] + hi[1]) / 2] : anchor === 'mr' ? [hi[0], (lo[1] + hi[1]) / 2] : [au, av];
  const out = loops.map((l) => l.map(([u, v]) => [u - a[0], v - a[1]]));
  return { loops: out, w: hi[0] - lo[0], h: hi[1] - lo[1], R: Math.max(...out[0].map(([u, v]) => Math.hypot(u, v))) };
}

Member orientation

64-bit

The across and depth directions of a member's section about its line.

v = up − (up·d) d, normalized; u = v × d; then rotated about d

World +Y is the up reference unless the line is within |d_y| > 0.999 of vertical, then +Z, so the projection never normalizes a near-zero vector. Both axes are normalized again after the optional rotation.

  • weldment.js
  • lines 72 to 79
  • PartCraft 0.8.1
/** The member's frame: u across, v the section's depth, d along the line (right-handed: u = v × d). */
function memberFrame(d, rotate) {
  const up = Math.abs(d[1]) > 0.999 ? [0, 0, 1] : [0, 1, 0];
  let v = V.norm(V.sub(up, V.mul(d, V.dot(up, d))));
  let u = V.cross(v, d);
  if (rotate) { const R = V.rotationAbout([0, 0, 0], d, rotate); u = V.applyDir(R, u); v = V.applyDir(R, v); }
  return { u: V.norm(u), v: V.norm(v) };
}

When line ends meet

64-bit

The distance within which two line ends count as one joint.

tol = 10⁻⁶ × max(1, largest |end point|, longest member)

The tolerance scales with the size and position of the frame, with a floor of 1e-6 mm. The same tolerance decides whether an end lies on another member's middle.

  • weldment.js
  • lines 104 to 106
  • PartCraft 0.8.1
let size = 0;
for (const m of mem) size = Math.max(size, V.len(m.a), V.len(m.b), m.L);
const tol = 1e-6 * Math.max(1, size);

How far a member runs past a corner

64-bit

The extra length a raw member needs so a miter cut still has material.

ext = min(50 R, R ÷ max(tan(angle ÷ 2), 0.02)) + R

The acos argument is clamped to [−1, 1] and the tangent is floored at 0.02, so the division is guarded. This length only sizes the raw extrusion; the cut plane sets the finished end.

  • weldment.js
  • line 127
  • PartCraft 0.8.1
const extFor = (cosA) => { const half = Math.acos(Math.max(-1, Math.min(1, cosA))) / 2; return Math.min(50 * R, R / Math.max(Math.tan(half), 0.02)) + R; };

Miter cut plane

64-bit

The plane halfway between two members that both are cut on.

n = (d_in + d_out) ÷ |d_in + d_out|; offset = n·P

The normal is the normalized sum of the two unit directions. Directions that fold back are refused beforehand (dot product below −1 + 1e-9), which keeps the sum away from zero length; for a very sharp fold the sum is still small and loses digits.

  • weldment.js
  • lines 138 to 141
  • PartCraft 0.8.1
const n = V.norm(V.add(dIn, dOut)), ext = extFor(V.dot(dIn, dOut));
// A keeps n·x ≤ n·P, B keeps n·x ≥ n·P
A.m.ends[A.e] = { kind: 'miter', ext, plane: { n: V.mul(n, -1), off: -V.dot(n, nd.P) }, cut: n };
B.m.ends[B.e] = { kind: 'miter', ext, plane: { n, off: V.dot(n, nd.P) }, cut: n };

Butt cut planes

64-bit

Where the through member ends and where the other is cut square against it.

reach = max |(u U + v V)·d| over the outline; offsets = ∓(d·P) ∓ reach

The reach is the largest projection of the other member's outline onto this member's direction, a maximum of 64-bit dot products over the outline points.

  • weldment.js
  • lines 145 to 148
  • PartCraft 0.8.1
const reachB = Math.max(...prof.loops[0].map(([u, v]) => Math.abs(V.dot(V.add(V.mul(B.m.u, u), V.mul(B.m.v, v)), dIn))));
const reachA = Math.max(...prof.loops[0].map(([u, v]) => Math.abs(V.dot(V.add(V.mul(A.m.u, u), V.mul(A.m.v, v)), dOut))));
A.m.ends[A.e] = { kind: 'butt', ext: reachB + R, plane: { n: V.mul(dIn, -1), off: -V.dot(dIn, nd.P) - reachB }, cut: dIn };
B.m.ends[B.e] = { kind: 'butt', ext: 0, plane: { n: dOut, off: V.dot(dOut, nd.P) + reachA }, cut: dOut };

Gusset plate

64-bit

The triangular plate in a corner and the angle listed for it.

corners (0, 0), (s, 0), (s d_out·x, s d_out·y); angle = acos(x·d_out)

The plate's plane normal is the normalized cross product of the two directions; corners within 1e-6 of straight are skipped, so it is never near zero. The acos argument is clamped to [−1, 1].

  • weldment.js
  • lines 228 to 237
  • PartCraft 0.8.1
const nG = V.norm(V.cross(dIn, dOut));
const x = V.mul(dIn, -1), y = V.norm(V.cross(nG, x));
const s = f.gussets.size;
const q2 = [V.dot(V.mul(dOut, s), x), V.dot(V.mul(dOut, s), y)];
const tri = [[0, 0], [s, 0], q2];
const area = (tri[1][0] - tri[0][0]) * (tri[2][1] - tri[0][1]) - (tri[2][0] - tri[0][0]) * (tri[1][1] - tri[0][1]);
const loop = area > 0 ? tri : [tri[0], tri[2], tri[1]];
const g = T(T(T(new M.CrossSection([loop], 'Positive')).extrude(f.gussets.t)).transform(V.basisMatrix(x, y, nG, V.sub(nd.P, V.mul(nG, f.gussets.t / 2)))));
tools.push({ m: g, op: 'add' });
gussets.push({ size: s, t: f.gussets.t, angle: Math.acos(Math.max(-1, Math.min(1, V.dot(x, dOut)))) * 180 / Math.PI });

Cut length and end angles of a member

32-bit in part

The length of each finished piece along its line and the angle of each end cut, as stored in the cut list.

length = round((max s − min s) × 10) ÷ 10, s = (vertex − a)·d; angle = round(acos(|cut·d|) × 180 ÷ π × 100) ÷ 100

The extent is taken over the vertices of the member's solid as returned by manifold-3d's getMesh; the manifold-3d 3.5.4 type file in assets/vendor declares vertProperties as Float32Array. The stored length is rounded to 0.1 mm and the stored angles to 0.01° with Math.round; the angle's acos argument is capped at 1.

  • weldment.js
  • lines 240 to 247
  • PartCraft 0.8.1
const members = mem.map((m) => {
  const gm = m.solid.getMesh();
  const np = gm.numProp, P = gm.vertProperties;
  let lo = Infinity, hi = -Infinity;
  for (let k = 0; k < P.length; k += np) { const s = (P[k] - m.a[0]) * m.d[0] + (P[k + 1] - m.a[1]) * m.d[1] + (P[k + 2] - m.a[2]) * m.d[2]; if (s < lo) lo = s; if (s > hi) hi = s; }
  const ang = (e) => { const end = m.ends[e]; if (!end.cut) return 0; return Math.round(Math.acos(Math.min(1, Math.abs(V.dot(end.cut, m.d)))) * 180 / Math.PI * 100) / 100; };
  return { i: m.i, length: Math.round((hi - lo) * 10) / 10, angles: [ang(0), ang(1)], ends: m.ends.map((e, k) => (e.kind === 'cope' ? 'fit' : ang(k) > 0.005 ? 'angle' : 'square')) };
});

Cut list rows

32-bit in part

Groups identical pieces and counts them.

same piece when section, length to one decimal, both angles and both end kinds match

Pieces are grouped by a text key that includes the length written with toFixed(1), so the quantity of a row depends on the rounded length. The row keeps the first piece's stored length, which is the 0.1 mm value measured from the mesh vertices.

  • weldment.js
  • lines 255 to 268
  • PartCraft 0.8.1
/** The cut list: identical pieces grouped. → rows [{ kind, section, length, angles, ends, qty, items }] (items = member numbers). */
export function cutList(info) {
  if (!info) return [];
  const rows = new Map();
  const add = (key, row, item) => { const r = rows.get(key); if (r) { r.qty++; r.items.push(item); } else rows.set(key, { ...row, qty: 1, items: [item] }); };
  for (const m of info.members) {
    const [a0, a1] = m.angles[0] <= m.angles[1] ? m.angles : [m.angles[1], m.angles[0]];
    const [k0, k1] = m.angles[0] <= m.angles[1] ? m.ends : [m.ends[1], m.ends[0]];
    add(['m', info.section, m.length.toFixed(1), a0, a1, k0, k1].join('|'), { kind: 'member', section: info.display, length: m.length, angles: [a0, a1], ends: [k0, k1] }, m.i + 1);
  }
  for (const c of info.caps) add(['c', c.t, c.w.toFixed(1), c.h.toFixed(1)].join('|'), { kind: 'cap', section: '', length: 0, t: c.t, w: c.w, h: c.h }, c.member + 1);
  for (const g of info.gussets) add(['g', g.size, g.t, g.angle.toFixed(1)].join('|'), { kind: 'gusset', section: '', length: 0, t: g.t, size: g.size, angle: g.angle }, 0);
  return [...rows.values()];
}

Cut list CSV

32-bit in part

Writes the cut list as CSV text.

member: length with one decimal; cap: w × h with one decimal

Lengths and cap sizes are written with toFixed(1); angles are written as stored (two decimals). The member length exported is the rounded value measured from the mesh vertices.

  • weldment.js
  • lines 271 to 281
  • PartCraft 0.8.1
/** CSV of the cut list. labels: { head: [7 column names], member, cap, gusset, square, angle, fit } (the page's words). */
export function cutListCsv(rows, labels) {
  const out = [labels.head.map(csvCell).join(',')];
  rows.forEach((r, n) => {
    const what = r.kind === 'member' ? r.section : r.kind === 'cap' ? labels.cap : labels.gusset;
    const size = r.kind === 'member' ? r.length.toFixed(1) : r.kind === 'cap' ? `${r.w.toFixed(1)} x ${r.h.toFixed(1)} x ${r.t}` : `${r.size} x ${r.size} x ${r.t}`;   // i18n-ignore: a size written into a CSV
    const ends = r.kind === 'member' ? r.ends.map((e, k) => (e === 'fit' ? labels.fit : e === 'angle' ? `${r.angles[k]}°` : labels.square)).join(' / ') : '';
    out.push([n + 1, r.qty, labels[r.kind], what, size, ends, r.items.filter(Boolean).join(' ')].map(csvCell).join(','));
  });
  return out.join('\n') + '\n';
}

In detail

Method
Section outline from shapes/sections.js extruded along each line by manifold-3d and trimmed by planes. Miter: the plane through the node normal to the sum of the two directions. Butt: the first member runs to the second's far extent, the second is cut square at the first's near extent. T and three-way joints: cut against the other member's outer envelope.
Number types
64-bit numbers for lines, frames, planes and angles; the member solid's vertices are read from manifold-3d's mesh vertex array; cut-list lengths and angles are rounded 64-bit numbers.
Tolerances
Line ends meet within 1e-6 × max(1, the largest coordinate or member length). A line shorter than 1e-6 is refused. Members that fold back (dot product below −1 + 1e-9) are refused. A gusset is made when |cos| of the corner is below 1 − 1e-6. The run-out past a node is at most 51 × the section's outer radius. Cut list: length rounded to 0.1 mm, angles to 0.01°, an end is called square when its angle is not above 0.005°; identical pieces are grouped by the length written with one decimal.
Limits
Members whose lengths differ by less than the 0.1 mm rounding are grouped as identical pieces. Cap sizes in the CSV are written with one decimal.

Approach: 32-bit step, flagged

One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.

Every policy that takes this approach