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.
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.
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.
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 ÷ π.
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.
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.
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.