Resources Numerical accuracy Calculation policy

Frame model from a building model

Turns the model's beams, columns, braces and concrete slabs and walls into the lines, sections, joints, supports and loads the frame analysis solves.

How the precision is kept

IFC placements and profiles are read in the file's unit as 64-bit numbers and scaled to millimeters. Curved outlines (circles, fillets, rounded corners) are replaced by polygons, and section properties come from those polygons. Joining members into a frame uses fixed tolerances of 10 mm and reach distances that scale with the section size.

Open item. Give round and filleted profiles their exact area and second moments instead of the polygon's; a 64-sided circle is 0.16 % under in area.

Calculations under this policy

17 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.

analysis-bim.js BimCraft 0.8.1 64-bit 17 calculations Reviewed

Tests tools/frame/test-bimcraft.mjs, tools/frame/test-bimcraft-concrete.mjs, tools/bimcraft-test/su878-test.mjs

Length unit of the file

64-bit

The factor that takes the file's lengths to millimeters.

toMm = prefix × 1000, or factor × base × 1000 for a converted unit

SI prefixes are decimal literals (1e3 to 1e-6) multiplied by 1000. A converted unit such as the foot uses the factor exactly as the file states it, read with Number(). Only the first unit assignment in the file is read.

  • analysis-bim.js
  • lines 98 to 111
  • BimCraft 0.8.1
let toMm = 1000, unit = 'metre';
const prefix = { KILO: 1e3, HECTO: 100, DECA: 10, DECI: 0.1, CENTI: 0.01, MILLI: 1e-3, MICRO: 1e-6 };
const siScale = (u) => (u && val(u.Name) === 'METRE' ? (u.Prefix ? prefix[val(u.Prefix)] ?? 1 : 1) : null);
for (const ua of ids(W.IFCUNITASSIGNMENT).slice(0, 1)) for (const r of line(ua).Units || []) {
  const u = line(ref(r));
  if (!u || val(u.UnitType) !== 'LENGTHUNIT') continue;
  const s = siScale(u);
  if (s !== null) { toMm = s * 1000; unit = (u.Prefix ? String(val(u.Prefix)).toLowerCase() : '') + 'metre'; }
  else if (u.ConversionFactor) {
    const mwu = line(ref(u.ConversionFactor));
    const f = Number(val(mwu?.ValueComponent)), base = siScale(line(ref(mwu?.UnitComponent)));
    if (Number.isFinite(f) && base !== null) { toMm = f * base * 1000; unit = String(val(u.Name) || '').toLowerCase(); }
  }
}

Axis placement

64-bit

The three axes and the origin of one IFC placement.

X = norm(X − Z (X·Z)); Y = Z × X

Directions are normalized with Math.hypot. X is made square to Z by subtracting its component along Z; the fallback axis is taken only when what is left is exactly zero, so a reference direction nearly parallel to the axis is normalized from a remainder of rounding size.

  • analysis-bim.js
  • lines 115 to 125
  • BimCraft 0.8.1
const axis3 = (id) => {
  const a = line(id);
  if (!a) return I4();
  const O = coords(ref(a.Location));
  if (clsName(a) === 'IfcAxis2Placement2D') { const X = a.RefDirection ? norm(dirOf(ref(a.RefDirection))) : [1, 0, 0]; return { X, Y: [-X[1], X[0], 0], Z: [0, 0, 1], O }; }
  const Z = a.Axis ? norm(dirOf(ref(a.Axis))) : [0, 0, 1];
  let X = a.RefDirection ? dirOf(ref(a.RefDirection)) : (Math.abs(Z[0]) > 0.9 ? [0, 0, 1] : [1, 0, 0]);
  X = norm(sub(X, mul(Z, dot(X, Z))));
  if (len(X) < 0.5) X = norm(cross(Math.abs(Z[2]) < 0.9 ? [0, 0, 1] : [0, 1, 0], Z));
  return { X, Y: cross(Z, X), Z, O };
};

Placement chain

64-bit

An element's position and axes in the model, through every placement it is relative to.

M = M_parent ∘ M_local

Each local placement is composed onto its parent's in 64-bit (three axis vectors and an origin), to a depth of at most 50, and cached. The composed axes are not normalized again. Coordinates stay in the file's unit here.

  • analysis-bim.js
  • lines 127 to 139
  • BimCraft 0.8.1
const placement = (id, depth = 0) => {
  if (!id || depth > 50) return I4();
  if (placeCache.has(id)) return placeCache.get(id);
  const lp = line(id);
  let M = I4();
  if (lp && clsName(lp) === 'IfcLocalPlacement') {
    const local = lp.RelativePlacement ? axis3(ref(lp.RelativePlacement)) : I4();
    const parent = ref(lp.PlacementRelTo);
    M = parent ? compose(placement(parent, depth + 1), local) : local;
  }
  placeCache.set(id, M);
  return M;
};

Rounded rectangle outline

64-bit

The outline of a hollow rectangular section with rounded corners.

Each rounded corner is drawn with 8 chords in place of the arc, with the points on the arc. The radius is clamped to half the smaller side.

  • analysis-bim.js
  • lines 199 to 208
  • BimCraft 0.8.1
/** A centred w × h rectangle with corners rounded to r (8 steps a corner). */
const rounded = (w, h, r) => {
  r = Math.max(0, Math.min(r, w / 2, h / 2));
  if (!r) return [[-w / 2, -h / 2], [w / 2, -h / 2], [w / 2, h / 2], [-w / 2, h / 2]];
  const out = [];
  for (const [cx, cy, a0] of [[w / 2 - r, -h / 2 + r, -Math.PI / 2], [w / 2 - r, h / 2 - r, 0], [-w / 2 + r, h / 2 - r, Math.PI / 2], [-w / 2 + r, -h / 2 + r, Math.PI]]) {
    for (let k = 0; k <= 8; k++) { const a = a0 + Math.PI / 2 * k / 8; out.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]); }
  }
  return out;
};

Circle outline

64-bit

The outline of a round bar or tube.

pᵢ = r (cos 2πi ÷ 64, sin 2πi ÷ 64)

A circle becomes a 64-sided polygon with its corners on the circle. The section properties are taken from this outline (sectionOf in frame/frame-model.js runs polySection on the loops), so a round bar's area is 32 sin(2π ÷ 64) r² = 3.1365 r², 0.16 % under π r².

  • analysis-bim.js
  • line 209
  • BimCraft 0.8.1
const circle = (r, k = 64, cx = 0, cy = 0) => Array.from({ length: k }, (_, i) => [cx + r * Math.cos(2 * Math.PI * i / k), cy + r * Math.sin(2 * Math.PI * i / k)]);

I-shape outline

64-bit

The outline of an I section with its root fillets.

The four root fillets are 6 chords each; the flange and web corners are exact. The outline is centered on the section's depth and width, with the web along Y.

  • analysis-bim.js
  • lines 348 to 360
  • BimCraft 0.8.1
/** An I-shape's outline with its four root fillets (arcs in 6 steps), centred, web along Y. */
function iLoop(d, bf, tw, tf, r) {
  const x = tw / 2, y = d / 2 - tf, pts = [];
  const arc = (cx, cy, a0, a1) => { for (let k = 0; k <= 6; k++) { const t = a0 + (a1 - a0) * k / 6; pts.push([cx + r * Math.cos(t), cy + r * Math.sin(t)]); } };
  pts.push([-bf / 2, -d / 2], [bf / 2, -d / 2], [bf / 2, -y]);
  if (r > 0) arc(x + r, -y + r, -Math.PI / 2, -Math.PI); else pts.push([x, -y]);
  if (r > 0) arc(x + r, y - r, Math.PI, Math.PI / 2); else pts.push([x, y]);
  pts.push([bf / 2, y], [bf / 2, d / 2], [-bf / 2, d / 2], [-bf / 2, y]);
  if (r > 0) arc(-x - r, y - r, Math.PI / 2, 0); else pts.push([-x, y]);
  if (r > 0) arc(-x - r, -y + r, 0, -Math.PI / 2); else pts.push([-x, -y]);
  pts.push([-bf / 2, -y]);
  return pts;
}

Is this I-shape a rolled section

64-bit

Matches an I-shape's name and sizes against the section table, to decide whether it is treated as rolled or welded.

|a − b| ≤ max(0.5 mm, 0.01 × b) for d, bf, tw, tf

Each of the four sizes must be within 1 % of the table row, with a floor of 0.5 mm. The tolerance is relative with an absolute minimum.

  • analysis-bim.js
  • lines 69 to 87
  • BimCraft 0.8.1
/**
 * Is this IFC I-shape a rolled section? (0.7 SU.878, honesty-fixes.) IFC has no "rolled" flag, so a profile counts as rolled
 * only when its name (ProfileName, else the element's ObjectType) is a hot-rolled I-shape of the shared section table
 * (shapes/sections.js: AISC W and S, EN 10365 IPE, HEA, HEB; "IPE 300", "W12x26", "HE 200 A" all match) AND the file's
 * d, bf, tw and tf agree with that row within 1 % (at least 0.5 mm). Anything else (a plate girder, a custom name, a
 * renamed or edited size) stays welded, which the Design checks report as "not checked". Exported for the tests.
 */
export function rolledIShape(names, dims) {
  for (const raw of names) {
    if (typeof raw !== 'string' || !raw.trim()) continue;
    let key = raw.replace(/\s+/g, '').toUpperCase();
    key = key.replace(/^HE(\d+)([AB])$/, 'HE$2$1');            // "HE200A" (the older European way) → "HEA200"
    const row = findSection(key);
    if (!row || row.kind !== 'I' || !['W', 'S', 'IPE', 'HEA', 'HEB'].includes(row.family)) continue;
    const near = (a, b) => Number.isFinite(a) && Math.abs(a - b) <= Math.max(0.5, 0.01 * b);
    if (near(dims.d, row.d) && near(dims.bf, row.bf) && near(dims.tw, row.tw) && near(dims.tf, row.tf)) return row.name;
  }
  return null;
}

Member axis, length and section

64-bit

The line a member is analyzed on and the section properties it carries.

axis from the section centroid c₀ to c₀ + depth × ext; skipped when |ext·U| or |ext·V| > 0.001

The centroid is found in millimeters, divided by the unit factor to pass through the placement, and multiplied back. The extrusion must be square to the profile within 1e-3 on a dot product of unit vectors (about 0.06°). Section properties come from the polygon outline; J for an arbitrary outline is an estimate.

  • analysis-bim.js
  • lines 282 to 300
  • BimCraft 0.8.1
const Msolid = compose(compose(Mel, b.M), b.solid.Position ? axis3(ref(b.solid.Position)) : I4());
const Mprof = compose(Msolid, prof.pos);
const ext = norm(applyDir(Msolid, dirOf(ref(b.solid.ExtrudedDirection))));
const depth = Number(val(b.solid.Depth)) * toMm;
let U = norm(Mprof.X), V = norm(Mprof.Y);
if (Math.abs(dot(ext, U)) > 1e-3 || Math.abs(dot(ext, V)) > 1e-3) { skipped.push({ id, gid, cls, why: 'not-square' }); continue; }
if (!(depth > 0)) { skipped.push({ id, gid, cls, why: 'zero-length' }); continue; }
// section about its centroid; the axis through the centroid
const s = polySection(prof.loops);
const c0 = mul(apply(Mprof, [s.cu / toMm, s.cv / toMm, 0]), toMm);   // the centroid on the profile's plane, mm
let a = c0, e = add(c0, mul(ext, depth));
if (dot(cross(U, V), ext) < 0) { [a, e] = [e, a]; }
const loops = prof.loops.map((l) => l.map(([x, y]) => [x - s.cu, y - s.cv]));
const rec = { kind: prof.kind, ...(prof.dims || {}), loops, name: prof.name || prof.kind };
const sec = sectionOf(rec, prof.name || '');
let J = sec.J;
if (prof.kind === 'POLY' || !Number.isFinite(J)) J = torsionJ({ kind: 'POLY', loops });
const bb = loops[0].reduce((q, [x, y]) => [Math.min(q[0], x), Math.max(q[1], x), Math.min(q[2], y), Math.max(q[3], y)], [Infinity, -Infinity, Infinity, -Infinity]);
const size = Math.max(bb[1] - bb[0], bb[3] - bb[2]);

Concrete wall as a line, thickness and height

64-bit

Reads a concrete wall's plan outline as a center line and a thickness for the plate model.

turn the outline by −θ of its longest edge; thickness = y₁ − y₀; kept when |area| ≥ 0.9 × box area

The outline is rotated so its longest edge lies along X and the wall is read from its bounding box: the center line at mid thickness, the thickness from the box. An outline that fills less than 90 % of that box, or has a hole, is left out.

  • analysis-bim.js
  • lines 335 to 343
  • BimCraft 0.8.1
const L0 = prof.loops[0];
let best = null;
for (let i = 0; i < L0.length; i++) { const q = L0[i], n2 = L0[(i + 1) % L0.length], d = Math.hypot(n2[0] - q[0], n2[1] - q[1]); if (!best || d > best.d) best = { d, th: Math.atan2(n2[1] - q[1], n2[0] - q[0]) }; }
const c = Math.cos(-best.th), sn = Math.sin(-best.th), R = L0.map(([x, y]) => [x * c - y * sn, x * sn + y * c]);
const x0 = Math.min(...R.map((q) => q[0])), x1 = Math.max(...R.map((q) => q[0])), y0 = Math.min(...R.map((q) => q[1])), y1 = Math.max(...R.map((q) => q[1]));
if (Math.abs(shoelace(R)) < 0.9 * (x1 - x0) * (y1 - y0) || prof.loops.length > 1) { skipped.push({ id, gid, cls, why: 'plate-shape' }); continue; }
const back = ([x, y]) => [x * Math.cos(best.th) - y * Math.sin(best.th), x * Math.sin(best.th) + y * Math.cos(best.th)];
const ym = (y0 + y1) / 2, a2 = world(Mprof, back([x0, ym])), b2 = world(Mprof, back([x1, ym]));
walls.push({ ...ref0, from: [a2[0], a2[1]], to: [b2[0], b2[1]], z0: Math.min(zA, zB), z1: Math.max(zA, zB), t: y1 - y0 });

Closest points of a line and a segment

64-bit

Where a member's own line passes nearest another member's axis, and how far apart they are there.

s = (b e − c d) ÷ (a c − b²); t = (a e − b d) ÷ (a c − b²)

The lines count as parallel when a c − b² < 1e-12 × a c, a test scaled to the two lengths. a c − b² subtracts nearly equal numbers for nearly parallel lines, so s and t lose digits just above that threshold. The distance is the length between the two closest points.

  • analysis-bim.js
  • lines 362 to 374
  • BimCraft 0.8.1
/** Closest points of the line p + s·o and the segment q0–q1: { s, t (0…1 along the segment, unclamped), d }. */
function closest(p, o, q0, q1) {
  const w = sub(q1, q0), r = sub(p, q0);
  const a = dot(o, o), b = dot(o, w), c = dot(w, w), dd = dot(o, r), e = dot(w, r);
  const den = a * c - b * b;
  if (den < 1e-12 * a * c) {        // parallel: the end against the segment's nearer end
    const t = Math.max(0, Math.min(1, e / c));
    const q = add(q0, mul(w, t));
    return { s: dot(sub(q, p), o) / a, t, d: len(sub(add(p, mul(o, dot(sub(q, p), o) / a)), q)) };
  }
  const s = (b * e - c * dd) / den, t = (a * e - b * dd) / den;
  return { s, t, d: len(sub(add(p, mul(o, s)), add(q0, mul(w, t)))) };
}

Member ends carried onto the nearest axis

64-bit

Moves each member end along its own line onto the axis of the member it meets.

accepted when d ≤ 0.6 × max(size) + 25 mm and |s| ≤ 0.6 × size + 150 mm; score = d + 0.01 |s|

The tolerances are in millimeters: a share of the section sizes plus a fixed part. The snapped point is on the other member's axis with t clamped to 0…1. An end moves only when the move is over 0.5 mm.

  • analysis-bim.js
  • lines 383 to 403
  • BimCraft 0.8.1
const ends = elements.map((el) => [el.a.slice(), el.b.slice()]);
let snapped = 0;
elements.forEach((el, k) => {
  for (const end of [0, 1]) {
    const E = end ? el.b : el.a, o = norm(end ? sub(el.b, el.a) : sub(el.a, el.b));
    let best = null;
    elements.forEach((ot, j) => {
      if (j === k) return;
      const segLen = len(sub(ot.b, ot.a));
      const reach = 0.6 * ot.size + 150, perp = 0.6 * Math.max(ot.size, el.size) + 25, overT = (0.6 * el.size + 150) / segLen;
      const c = closest(E, o, ot.a, ot.b);
      if (c.d > perp || c.s > reach || c.s < -reach || c.t < -overT || c.t > 1 + overT) return;
      const t = Math.max(0, Math.min(1, c.t));
      const q = add(ot.a, mul(sub(ot.b, ot.a), t));
      const score = c.d + 0.01 * Math.abs(c.s);
      if (!best || score < best.score) best = { q, score };
    });
    if (best && len(sub(best.q, E)) > 0.5) { ends[k][end] = best.q; snapped++; }
  }
});
// 2. nodes and splits

Node merging

64-bit

Decides whether a point is a new joint or an existing one.

same node when |p − node| ≤ 10 mm

A point within 10 mm of an existing node becomes that node, the first one found, at that node's coordinates. A joint can therefore sit up to 10 mm from the member end it stands for, and the result depends on the order the points arrive in.

  • analysis-bim.js
  • line 405
  • BimCraft 0.8.1
const nodeAt = (p) => { for (const nd of nodes) if (len(sub(nd.p, p)) <= MERGE_TOL) return nd; const nd = { id: 'n' + (nodes.length + 1), p: [...p] }; nodes.push(nd); return nd; };

Where members are split

64-bit

The stations along a member at which other members or point loads meet it.

s = (Q − A)·u; split when 10 mm < s < L − 10 mm and |A + s u − Q| ≤ 10 mm

SPLIT_TOL and MERGE_TOL are 10 mm, absolute. Cuts closer than 10 mm to the previous one are dropped and the last cut is set to the member's full length. Members shorter than 1 mm are skipped.

  • analysis-bim.js
  • lines 415 to 426
  • BimCraft 0.8.1
const d = sub(b, a), L = len(d);
if (L < 1) { skipped.push({ id: el.gid, why: 'short' }); return; }
const ux = mul(d, 1 / L);
const cuts = [0, L];
for (const q of points) {
  const s = dot(sub(q, a), ux);
  if (s <= SPLIT_TOL || s >= L - SPLIT_TOL) continue;
  if (len(sub(add(a, mul(ux, s)), q)) <= SPLIT_TOL) cuts.push(s);
}
cuts.sort((x, y) => x - y);
const at = cuts.filter((s, i) => i === 0 || s - cuts[i - 1] > MERGE_TOL);
if (L - at[at.length - 1] > MERGE_TOL) at.push(L); else at[at.length - 1] = L;

Shared section records

64-bit

Groups members that have the same section so the model lists each section once.

Members whose name, A, Iy, Iz and J agree to 8 significant digits (toPrecision(8)) share one section record, holding the values of the first member that had it. The stored values themselves are not rounded. A cracked-stiffness factor multiplies Iy and Iz only.

  • analysis-bim.js
  • lines 432 to 433
  • BimCraft 0.8.1
const skey = el.sec.name + '|' + [el.sec.A, el.sec.Iy, el.sec.Iz, el.sec.J].map((v) => v.toPrecision(8)).join('|') + (cf === 1 ? '' : '|' + cf);
if (!sections.has(skey)) sections.set(skey, { id: 's' + (sections.size + 1), name: el.sec.name || 's' + (sections.size + 1), A: el.sec.A, Iy: el.sec.Iy * cf, Iz: el.sec.Iz * cf, J: el.sec.J, ...(el.sec.shape ? { shape: el.sec.shape } : {}) });

Section roll angle

64-bit

How far the drawn section is turned about the member's axis from the default local axes.

roll = atan2(U·e_z0, U·e_y0) × 180 ÷ π + principal turn

A member counts as vertical when |u_z| > 0.999999 (within about 0.08° of plumb) and then takes X as its reference axis. The angle is the atan2 of two dot products, turned to degrees by × 180 ÷ π.

  • analysis-bim.js
  • lines 437 to 439
  • BimCraft 0.8.1
const ez0 = Math.abs(ux[2]) > 0.999999 ? [1, 0, 0] : norm([-ux[2] * ux[0], -ux[2] * ux[1], 1 - ux[2] * ux[2]]);
const ey0 = cross(ez0, ux);
const roll = Math.atan2(dot(el.U, ez0), dot(el.U, ey0)) * 180 / Math.PI + (el.sec.roll || 0);

Supports at column feet

64-bit

Puts a support under every column that nothing continues below.

A column's lower end gets a support when no member at that node goes more than 10 mm below it. The foot is matched to a node within 10 mm. Heights are compared in millimeters in 64-bit.

  • analysis-bim.js
  • lines 456 to 467
  • BimCraft 0.8.1
const lowest = new Map();
for (const mb of members) { const za = byId.get(mb.i).p[2], zb = byId.get(mb.j).p[2]; lowest.set(mb.i, Math.min(lowest.get(mb.i) ?? Infinity, zb)); lowest.set(mb.j, Math.min(lowest.get(mb.j) ?? Infinity, za)); }
const baseFix = A.bases === 'pinned' ? [true, true, true, false, false, true] : [true, true, true, true, true, true];
const supports = new Map();
let nBases = 0;
elements.forEach((el, k) => {
  if (el.type !== 'column') return;
  const [a, b] = ends[k];
  const foot = a[2] <= b[2] ? a : b;
  const nd = nodes.find((x) => len(sub(x.p, foot)) <= MERGE_TOL);
  if (nd && !supports.has(nd.id) && lowest.get(nd.id) >= nd.p[2] - MERGE_TOL) { supports.set(nd.id, baseFix); nBases++; }
});

Loads on split members

64-bit

Places point loads and cuts distributed loads onto the pieces a member was split into.

w(s) = w + (w₂ − w)(s − a) ÷ (b − a)

A distributed load is cut at the member's split points and its intensity interpolated linearly at each cut, so the pieces join without a step. Pieces shorter than 1e-6 mm are dropped; a point load goes on the piece that contains it within 1e-9 mm.

  • analysis-bim.js
  • lines 480 to 483
  • BimCraft 0.8.1
if (l.type === 'point') { const pc = list.find((q) => l.a >= q.s0 - 1e-9 && l.a <= q.s1 + 1e-9) || list[list.length - 1]; loads.push({ type: 'point', member: members[pc.k].id, dir: l.dir, P: l.P, a: Math.max(0, l.a - pc.s0) }); continue; }
const Lm = list[list.length - 1].s1, a0 = l.a ?? 0, b0 = l.b ?? Lm, w2 = l.w2 ?? l.w;
const wAt = (s) => (b0 > a0 ? l.w + (w2 - l.w) * (s - a0) / (b0 - a0) : l.w);
for (const q of list) { const s0 = Math.max(a0, q.s0), s1 = Math.min(b0, q.s1); if (s1 - s0 > 1e-6) loads.push({ type: 'udl', member: members[q.k].id, dir: l.dir, w: wAt(s0), w2: wAt(s1), a: s0 - q.s0, b: s1 - q.s0 }); }

In detail

Method
Each member is one extruded profile; its axis is the extrusion through the section's centroid. Ends are carried onto the nearest other axis, points within 10 mm become one node, members are split where another meets them. Section properties from the outline (frame/frame-model.js polySection).
Number types
64-bit numbers in plain arrays; lengths in millimeters after one scale factor.
Tolerances
Node merge and split 10 mm. End snap: across 0.6 × larger section size + 25 mm, along 0.6 × size + 150 mm; moves under 0.5 mm ignored. Extrusion square to the profile within a dot product of 1e-3. Parallel lines when a c − b² < 1e-12 × a c. Rolled shape match 1 % or 0.5 mm. Vertical when |u_z| > 0.999999. Sections shared when equal to 8 significant digits.
Limits
Circles are 64-sided polygons, fillets 6 chords, rounded corners 8 chords. Arcs in arbitrary outlines are chords. Material stiffness is chosen from the material's name. The torsion constant of an arbitrary outline is an estimate.

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