Turns the curves and cross-section profiles of an IFC file into polylines: arcs, circles, ellipses, trimmed curves, B-splines, offsets, and the standard steel shapes with their fillets.
How the precision is kept
Points are computed in 64-bit from the file's values, and points the file states (ends, trim points, polyline vertices) are kept exactly. Curved parts are replaced by chords: 36 segments per full turn of a circle or ellipse, and 12 points per knot span of a B-spline. That sampling, not the arithmetic, sets how closely a length, area or volume taken from the result matches the true curve.
Open item. Let the number of segments grow with the radius, or keep arcs as arcs for measuring, so a large circle is not a 36-sided polygon.
Calculations under this policy
10 calculations in 2 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.
The count depends on the sweep only: 36 for a full turn, 9 for a right angle, whatever the radius. The radius is used only to allow a single segment when it is zero.
The circular arc of an indexed poly-curve from its start, middle and end points.
center = a + ((|ab|² (ac × n)) + (|ac|² (n × ab))) ÷ (2 |n|²), n = ab × ac
The center comes from cross products of the two chords, so nearly collinear points divide by a very small |n|² and give a center with few good digits; exactly that case is cut off only when sin² of the angle is under 1e-18. The start and end points are the file's own; the points between are computed at equal angles.
ifc/geo-curve.js
lines 74 to 100
BimCraft 0.8.1
/** The circular arc through three points (start, middle, end), tessellated; straight when they are collinear. */
export function arc3(a, b, c) {
if (!a || !b || !c) return [a, b, c].filter(Boolean);
// circle centre in the plane of the three points
const ab = sub(b, a), ac = sub(c, a);
const n = [ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2], ab[0] * ac[1] - ab[1] * ac[0]];
const nn = dot(n, n);
if (nn < 1e-18 * Math.max(1, dot(ab, ab) * dot(ac, ac))) return [a, c];
const t1 = scale([ac[1] * n[2] - ac[2] * n[1], ac[2] * n[0] - ac[0] * n[2], ac[0] * n[1] - ac[1] * n[0]], dot(ab, ab));
const t2 = scale([n[1] * ab[2] - n[2] * ab[1], n[2] * ab[0] - n[0] * ab[2], n[0] * ab[1] - n[1] * ab[0]], dot(ac, ac));
const ctr = add(a, scale(add(t1, t2), 1 / (2 * nn)));
const r = len(sub(a, ctr));
const u = norm(sub(a, ctr)), w = norm(n), v = [w[1] * u[2] - w[2] * u[1], w[2] * u[0] - w[0] * u[2], w[0] * u[1] - w[1] * u[0]];
const ang = (p) => { const d = sub(p, ctr); return Math.atan2(dot(d, v), dot(d, u)); };
let tb = ang(b), tc = ang(c);
if (tb < 0) tb += 2 * Math.PI;
if (tc < 0) tc += 2 * Math.PI;
const sweep = tc; // counter-clockwise about n from a to c passes b by construction of n
const segs = arcSegments(sweep, r);
const out = [a];
for (let i = 1; i < segs; i++) {
const th = sweep * i / segs;
out.push(add(ctr, add(scale(u, r * Math.cos(th)), scale(v, r * Math.sin(th)))));
}
out.push(c);
return out;
}
Circle and ellipse points
64-bit
Points on a circle or ellipse between two parameters.
p = M · (r₁ cos θ, r₂ sin θ, 0), θ = a₀ + sweep × i ÷ n
Each point is computed from its own angle, so errors do not build up along the curve. All points lie on the curve; the chords between them lie inside it.
ifc/geo-curve.js
lines 130 to 145
BimCraft 0.8.1
/** A conic between parameters a0 and a1 (radians), counter-clockwise; `full` closes it without repeating the start. */
function conic(g, id, a0, a1, full = false) {
const t = g.t(id);
const m = g.axis2(g.ref(g.a(id, 'Position')));
const r1 = g.num(g.a(id, t === 'IFCCIRCLE' ? 'Radius' : 'SemiAxis1'), 0);
const r2 = t === 'IFCCIRCLE' ? r1 : g.num(g.a(id, 'SemiAxis2'), 0);
const sweep = a1 - a0;
const n = arcSegments(sweep, Math.max(r1, r2));
const out = [];
const last = full ? n - 1 : n;
for (let i = 0; i <= last; i++) {
const th = a0 + sweep * i / n;
out.push(apply(m, r1 * Math.cos(th), r2 * Math.sin(th), 0));
}
return out;
}
Trimmed curve
64-bit
The part of a line, circle, ellipse or other curve between two trims given as parameters or points.
conic: angle = parameter × angle unit, or atan2 of the trim point in the conic's frame
Parameters are multiplied by the file's angle unit. The end angle is moved by whole turns until it is past the start by more than 1e-12 rad, so two trims that coincide give a full turn. A trim point's angle is an atan2 of two projections, each divided by its radius.
ifc/geo-curve.js
lines 166 to 205
BimCraft 0.8.1
function trimmedCurve(g, id, depth) {
const basis = g.ref(g.a(id, 'BasisCurve'));
const bt = g.t(basis);
const sense = g.bool(g.a(id, 'SenseAgreement'), true);
const master = g.enm(g.a(id, 'MasterRepresentation'));
const pick = (trim) => {
const list = Array.isArray(trim) ? trim : [trim];
let par = null, pt = null;
for (const v of list) {
if (v && typeof v === 'object' && v.typed === 'IFCPARAMETERVALUE') par = g.num(v.value);
else if (typeof v === 'number') par = v;
else if (v && v.ref !== undefined && g.t(v.ref) === 'IFCCARTESIANPOINT') pt = g.point(v.ref);
}
if (master === 'CARTESIAN' && pt) return { pt };
if (par !== null) return { par };
return pt ? { pt } : {};
};
const t1 = pick(g.a(id, 'Trim1')), t2 = pick(g.a(id, 'Trim2'));
if (bt === 'IFCCIRCLE' || bt === 'IFCELLIPSE') {
const ang = (tr) => (tr.par !== undefined ? tr.par * g.angleUnit : tr.pt ? paramOf(g, basis, tr.pt) : 0);
let a0 = ang(t1), a1 = ang(t2);
if (sense) { while (a1 <= a0 + 1e-12) a1 += 2 * Math.PI; return conic(g, basis, a0, a1); }
while (a1 >= a0 - 1e-12) a1 -= 2 * Math.PI;
return conic(g, basis, a0, a1);
}
if (bt === 'IFCLINE') {
const p = (tr) => (tr.pt ? tr.pt : linePoint(g, basis, tr.par ?? 0));
const a = p(t1), b = p(t2);
return sense ? [a, b] : [b, a];
}
// another bounded curve: cut its points at the trimming points (parameters along it are not defined here)
let pts = curvePoints(g, basis, depth + 1);
if (t1.pt && t2.pt && pts.length > 1) {
const near = (q) => { let best = 0, bd = Infinity; pts.forEach((p, k) => { const d = len(sub(p, q)); if (d < bd) { bd = d; best = k; } }); return best; };
let i = near(t1.pt), j = near(t2.pt);
pts = i <= j ? pts.slice(i, j + 1) : pts.slice(j, i + 1).reverse();
pts[0] = t1.pt; pts[pts.length - 1] = t2.pt;
} else if (pts.length) g.note('A trimmed ' + (g.s.pretty(bt) || 'curve') + ' is drawn untrimmed');
return sense ? pts : pts.slice().reverse();
}
B-spline curve points
64-bit
Points on a B-spline curve, rational or not, at 12 evenly spaced parameters per knot span.
de Boor: d_j = (1 − a) d_{j−1} + a d_j, a = (u − k_i) ÷ (k_{i+p+1−r} − k_i); point = (x, y, z) ÷ w
Evaluation is the de Boor recurrence in homogeneous coordinates; a zero knot difference uses a = 0. The division by the weight is not guarded. The last point is replaced by the last control point, and the sample count is 12 per span whatever the span's length or curvature.
ifc/geo-curve.js
lines 219 to 264
BimCraft 0.8.1
export function sampleBSpline(p, ctrl, knots, w = null, perSpan = 12) {
const n = ctrl.length;
if (n < 2 || knots.length !== n + p + 1) return ctrl.slice();
// degree 1 is the polyline through its control points (weights do not bend it)
if (p === 1) return ctrl.map((c) => c.slice());
// control points in order along one straight line: the curve is that segment (as its neighbours' plain lines draw it)
{
const a = ctrl[0], b = ctrl[n - 1], d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const L = Math.hypot(d[0], d[1], d[2]);
if (L > 0) {
let straight = true, last = -1e-12;
for (const c of ctrl) {
const t = ((c[0] - a[0]) * d[0] + (c[1] - a[1]) * d[1] + (c[2] - a[2]) * d[2]) / (L * L);
const off = Math.hypot(c[0] - a[0] - d[0] * t, c[1] - a[1] - d[1] * t, c[2] - a[2] - d[2] * t);
if (off > L * 1e-9 || t < last - 1e-12) { straight = false; break; }
last = t;
}
if (straight) return [a.slice(), b.slice()];
}
}
const H = ctrl.map((c, i) => { const wi = w ? w[i] ?? 1 : 1; return [c[0] * wi, c[1] * wi, c[2] * wi, wi]; });
const lo = knots[p], hi = knots[n];
const out = [];
const evalAt = (u) => {
let k = p;
while (k < n - 1 && u >= knots[k + 1]) k++;
const d = [];
for (let j = 0; j <= p; j++) d.push(H[k - p + j].slice());
for (let r = 1; r <= p; r++) {
for (let j = p; j >= r; j--) {
const i = k - p + j;
const den = knots[i + p + 1 - r] - knots[i];
const a = den ? (u - knots[i]) / den : 0;
for (let c = 0; c < 4; c++) d[j][c] = (1 - a) * d[j - 1][c] + a * d[j][c];
}
}
const h = d[p];
return [h[0] / h[3], h[1] / h[3], h[2] / h[3]];
};
const spans = [];
for (let k = p; k < n; k++) if (knots[k + 1] > knots[k]) spans.push([knots[k], knots[k + 1]]);
for (const [a, b] of spans) for (let s = 0; s < perSpan; s++) out.push(evalAt(a + (b - a) * s / perSpan));
out.push(evalAt(hi - (hi - lo) * 1e-12));
out[out.length - 1] = H[n - 1][3] ? [H[n - 1][0] / H[n - 1][3], H[n - 1][1] / H[n - 1][3], H[n - 1][2] / H[n - 1][3]] : out[out.length - 1];
return out;
}
Offset of a polyline
64-bit
A 2D polyline moved to its left by a distance, with mitered corners.
p' = p + n × d ÷ max(0.2, n · n₁), n = normalized sum of the two edge normals
At a corner the offset point lies on the bisector at d ÷ cos of half the turn, which puts it exactly on both offset edges. The divisor is floored at 0.2, so at very sharp corners the point is at most 5 × d from the corner and no longer on the offset edges.
ifc/geo-curve.js
lines 272 to 286
BimCraft 0.8.1
/** Offset of a 2D polyline to the left by d (mitred corners). */
export function offsetPolyline(pts, d) {
if (pts.length < 2 || !d) return pts.slice();
const out = [];
const nrm = (a, b) => { const l = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; return [-(b[1] - a[1]) / l, (b[0] - a[0]) / l]; };
for (let i = 0; i < pts.length; i++) {
const n0 = i > 0 ? nrm(pts[i - 1], pts[i]) : null, n1 = i < pts.length - 1 ? nrm(pts[i], pts[i + 1]) : null;
let n = n0 && n1 ? [n0[0] + n1[0], n0[1] + n1[1]] : n0 || n1;
const l = Math.hypot(n[0], n[1]) || 1;
n = [n[0] / l, n[1] / l];
const c = n0 && n1 ? Math.max(0.2, n[0] * n1[0] + n[1] * n1[1]) : 1;
out.push([pts[i][0] + n[0] * d / c, pts[i][1] + n[1] * d / c, pts[i][2] || 0]);
}
return out;
}
The outline of a circular, hollow circular or elliptical profile.
(r₁ cos a, r₂ sin a), a = 2π i ÷ 36
A 36-sided polygon with its corners on the curve. Its area is 0.9949 of the circle's, so a round bar or pipe extruded from it has about 0.5 % less volume than the true shape.
ifc/geo-profile.js
lines 114 to 119
BimCraft 0.8.1
function circle(r1, r2 = r1) {
const n = arcSegments(2 * Math.PI, Math.max(r1, r2));
const out = [];
for (let i = 0; i < n; i++) { const a = 2 * Math.PI * i / n; out.push([r1 * Math.cos(a), r2 * Math.sin(a)]); }
return out;
}
Fillets and edge radii
64-bit
Replaces corners of a profile outline with arcs of given radii tangent to both edges.
d = r ÷ tan(half); center = corner + bisector × r ÷ sin(half), half = half the corner angle
The corner angle is an acos of a clamped dot product. The tangent distance is cut to 0.999 of the shorter adjoining edge, which reduces the radius without a note. Corners with a half angle under 1e-6 rad or within 1e-9 of a right angle (a straight line) are left sharp. Each arc has at least 2 segments.
ifc/geo-profile.js
lines 121 to 149
BimCraft 0.8.1
/** Replace each corner i of a closed polygon with an arc of radius r[i] tangent to both edges (0 = sharp). */
export function rounded(pts, r) {
const out = [];
const n = pts.length;
for (let i = 0; i < n; i++) {
const rad = r[i] || 0;
const p = pts[i];
if (rad <= 0) { out.push(p); continue; }
const a = pts[(i + n - 1) % n], b = pts[(i + 1) % n];
const u = unit([a[0] - p[0], a[1] - p[1]]), v = unit([b[0] - p[0], b[1] - p[1]]);
const cosT = u[0] * v[0] + u[1] * v[1];
const half = Math.acos(Math.max(-1, Math.min(1, cosT))) / 2;
if (half < 1e-6 || Math.abs(half - Math.PI / 2) < 1e-9) { out.push(p); continue; }
const d = rad / Math.tan(half);
const la = Math.hypot(a[0] - p[0], a[1] - p[1]), lb = Math.hypot(b[0] - p[0], b[1] - p[1]);
const dd = Math.min(d, la * 0.999, lb * 0.999);
const rr = dd * Math.tan(half);
const s = [p[0] + u[0] * dd, p[1] + u[1] * dd], e = [p[0] + v[0] * dd, p[1] + v[1] * dd];
const bis = unit([u[0] + v[0], u[1] + v[1]]);
const c = [p[0] + bis[0] * rr / Math.sin(half), p[1] + bis[1] * rr / Math.sin(half)];
let a0 = Math.atan2(s[1] - c[1], s[0] - c[0]), a1 = Math.atan2(e[1] - c[1], e[0] - c[0]);
let sweep = a1 - a0;
while (sweep > Math.PI) sweep -= 2 * Math.PI;
while (sweep < -Math.PI) sweep += 2 * Math.PI;
const k = Math.max(2, arcSegments(Math.abs(sweep), rr));
for (let j = 0; j <= k; j++) { const t = a0 + sweep * j / k; out.push([c[0] + rr * Math.cos(t), c[1] + rr * Math.sin(t)]); }
}
return out;
}
I-shape outline
64-bit
The outline of a symmetric or asymmetric I-section with flange slopes, fillets and edge radii.
inner flange face = thickness + tan(slope) × distance from the quarter point of the outstand
Twelve corner points from the stated depth, widths and thicknesses, halved exactly. The slope is multiplied by the file's angle unit before the tangent is taken. Fillets are then added by the rounding function.
Drops repeated points of a profile curve and its closing point.
Consecutive points are merged when both coordinates differ by 1e-12 or less, and the last point is dropped when it is within 1e-9 of the first. Both limits are absolute, in file units.
ifc/geo-profile.js
lines 94 to 99
BimCraft 0.8.1
function flat(pts) {
const out = [];
for (const p of pts) { const l = out[out.length - 1]; if (!l || Math.abs(l[0] - p[0]) > 1e-12 || Math.abs(l[1] - p[1]) > 1e-12) out.push([p[0], p[1]]); }
if (out.length > 2) { const a = out[0], b = out[out.length - 1]; if (Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9) out.pop(); }
return out;
}
In detail
Method
Circle through three points for arc segments; conics from their placement and radii; de Boor evaluation of B-splines in homogeneous coordinates; mitered offsets; fillets as tangent arcs.
Number types
64-bit numbers.
Tolerances
Three points are collinear when sin² of their angle is under 1e-18. Repeated points are dropped at 1e-9 or 1e-12 (absolute, file units). Trim angles are compared at 1e-12 rad. A B-spline is a straight segment when its control points are within 1e-9 of its length from the chord.
Limits
A 36-sided polygon has 0.9949 of the area of its circle and 0.9987 of its circumference, and its sides lie up to 0.0038 × radius inside the circle. The segment count does not grow with the radius. Offsets are capped at 5 × the distance at sharp corners.
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.