Reads the alignment curves of an IFC 4.3 file as sampled 3D polylines, finds the point at a station, places elements along them and builds solids swept between cross sections.
How the precision is kept
Each curve segment is sampled in 64-bit at one point per 2 file units, at most 400 per segment. Distance along the alignment is the sum of the chords between those samples, and a station is found by linear interpolation between two samples. The sampling sets the accuracy: on a curve the chord sum is shorter than the arc, and a clothoid is integrated with Simpson's rule.
Open item. Set the sample step from the radius and a stated chord tolerance in real length, not from 2 file units.
Calculations under this policy
7 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 point at a length along a clothoid from its origin.
x = ∫₀ˢ cos(k u²) du, y = ∫₀ˢ sin(k u²) du, k = sign(A) ÷ (2A²); Simpson's rule
The integrals are evaluated with Simpson's rule on n intervals, n = twice the count of half-unit steps in s, at least 8 and at most 4000. The step is measured in file units, so the integration is coarser in a meter file than in a millimeter file for the same curve. A clothoid constant of 0 is replaced by 1.
ifc/geo-align.js
lines 51 to 67
BimCraft 0.8.1
if (t === 'IFCCLOTHOID') {
const M = g.axis2(g.ref(g.a(id, 'Position')));
const A = g.num(g.a(id, 'ClothoidConstant'), 1) || 1;
// heading θ(s) = sign(A) s² / (2A²); x = ∫cos θ, y = ∫sin θ (Simpson's rule, fine steps)
const k = Math.sign(A) / (2 * A * A);
return (s) => {
const n = Math.max(8, Math.min(2000, Math.ceil(Math.abs(s) / 0.5)) * 2);
const h = s / n;
let x = 0, y = 0;
for (let i = 0; i <= n; i++) {
const w = i === 0 || i === n ? 1 : i % 2 ? 4 : 2;
const u = i * h, th = k * u * u;
x += w * Math.cos(th); y += w * Math.sin(th);
}
return apply(M, x * h / 3, y * h / 3, 0);
};
}
Points of a curve segment
64-bit
Samples one alignment segment from its parent curve and places it at the segment's start.
n = max(2, min(400, ceil(|length| ÷ 2))); p_i = T · f(start + length × i ÷ n)
The parent curve is evaluated at each sample directly, so errors do not build up along the segment. The frame at the start uses a forward difference for the tangent over max(1e-6, length × 1e-6), and the placing matrix is the segment's placement times the inverse of that frame.
ifc/geo-align.js
lines 80 to 102
BimCraft 0.8.1
/** Points of one IfcCurveSegment, placed: the parent's point at SegmentStart lands on Placement, heading along its x. */
function segmentPoints(g, seg) {
const start = measure(g, g.a(seg, 'SegmentStart')), length = measure(g, g.a(seg, 'SegmentLength'));
const P = g.axis2(g.ref(g.a(seg, 'Placement')));
if (!length) return [];
const f = parentCurve(g, g.ref(g.a(seg, 'ParentCurve')));
const n = Math.max(2, Math.min(400, Math.ceil(Math.abs(length) / 2)));
if (!f) return [apply(P, 0, 0, 0), apply(P, Math.abs(length), 0, 0)];
const sgn = Math.sign(length);
const p0 = f(start);
const e = Math.max(1e-6, Math.abs(length) * 1e-6);
let tx = norm(scale(sub(f(start + sgn * e), p0), 1));
if (!len(tx)) tx = [1, 0, 0];
// the parent's frame at the start (x along the way it is run, y to its left in its plane)
const z = [0, 0, 1];
let y = norm(cross(z, tx));
if (!len(y)) y = [0, 1, 0];
const F0 = frame(p0, tx, y, cross(tx, y));
const T = mul(P, invert(F0) || F0);
const out = [];
for (let i = 0; i <= n; i++) out.push(apply(T, ...f(start + length * i / n)));
return out;
}
Distance along the alignment
64-bit
The horizontal distance of each sampled point from the start.
d_i = d_{i−1} + √(Δx² + Δy²)
A running sum of horizontal chord lengths. On curved segments the chords are shorter than the arc, so stations fall slightly short of the lengths the file states for its segments.
ifc/geo-align.js
lines 113 to 117
BimCraft 0.8.1
function stations(pts) {
const d = [0];
for (let i = 1; i < pts.length; i++) d.push(d[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]));
return d;
}
Height from the vertical profile
64-bit
The height of each horizontal point from the alignment's vertical profile.
The profile is itself a sampled polyline of (distance, height), sorted by distance, and heights between its points are linear. A vertical curve is therefore a chain of chords; a zero interval uses a divisor of 1.
ifc/geo-align.js
lines 132 to 140
BimCraft 0.8.1
const zAt = (d) => {
if (!prof.length) return 0;
if (d <= prof[0][0]) return prof[0][1];
let lo = 0, hi = prof.length - 1;
if (d >= prof[hi][0]) return prof[hi][1];
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (prof[m][0] <= d) lo = m; else hi = m; }
const f = (d - prof[lo][0]) / ((prof[hi][0] - prof[lo][0]) || 1);
return prof[lo][1] + (prof[hi][1] - prof[lo][1]) * f;
};
Point at a station
64-bit
The position and direction on a curve at a distance along it.
The point is interpolated linearly between the two samples that bracket the distance, and the direction is that chord's direction. A distance beyond the end is extrapolated along the last chord.
ifc/geo-align.js
lines 151 to 161
BimCraft 0.8.1
/** The point and unit tangent at a horizontal distance along a curve (alignment curves; others by arc length). */
export function pointAlong(g, id, d) {
const C = isSegmented(g, id) ? segmentedCurve(g, id) : (() => { const p = curvePoints(g, id); return { pts: p, dist: stations(p) }; })();
const { pts, dist } = C;
if (pts.length < 2) return { p: pts[0] || [0, 0, 0], t: [1, 0, 0] };
let i = 1;
while (i < pts.length - 1 && dist[i] < d) i++;
const f = (d - dist[i - 1]) / ((dist[i] - dist[i - 1]) || 1);
const p = add(pts[i - 1], scale(sub(pts[i], pts[i - 1]), f));
return { p, t: norm(sub(pts[i], pts[i - 1])) };
}
Placement along an alignment
64-bit
The matrix of an element placed at a station with lateral, vertical and longitudinal offsets.
origin = p + left × lateral + (0, 0, vertical) + heading × longitudinal
The heading is the horizontal part of the chord direction, normalized, and left is that heading turned a quarter turn. Offsets are added in 64-bit in file units.
ifc/geo-align.js
lines 163 to 179
BimCraft 0.8.1
/** IfcAxis2PlacementLinear → matrix: the station's point plus offsets, x along the curve (level), z up (or as given). */
export function linearPlacement(g, id) {
const loc = g.ref(g.a(id, 'Location'));
if (g.t(loc) !== 'IFCPOINTBYDISTANCEEXPRESSION') return null;
const basis = g.ref(g.a(loc, 'BasisCurve'));
const d = g.num(g.a(loc, 'DistanceAlong'), 0);
const { p, t } = pointAlong(g, basis, d);
const th = norm([t[0], t[1], 0]);
const left = [-th[1], th[0], 0];
const o = add(add(add(p, scale(left, g.num(g.a(loc, 'OffsetLateral'), 0))), [0, 0, g.num(g.a(loc, 'OffsetVertical'), 0)]), scale(th, g.num(g.a(loc, 'OffsetLongitudinal'), 0)));
const zAxis = g.dir(g.ref(g.a(id, 'Axis')), [0, 0, 1]);
const ref = g.dir(g.ref(g.a(id, 'RefDirection')), null);
let x = ref || th;
x = norm(sub(x, scale(zAxis, dot(x, zAxis))));
if (!len(x)) x = th;
return frame(o, x, cross(zAxis, x), zAxis);
}
Solid between cross sections
64-bit
The solid of an IfcSectionedSolidHorizontal: cross sections placed across the alignment at their stations and joined.
ring point = o + x × ((1 − f) a_x + f b_x) + y × ((1 − f) a_y + f b_y), f = (d − d₀) ÷ (d₁ − d₀)
Between two stated sections a ring is added at every sampled point of the alignment, with the outline interpolated linearly point by point. Sections with different point counts are joined from the first one's points.
ifc/geo-align.js
lines 181 to 221
BimCraft 0.8.1
/** IfcSectionedSolidHorizontal: the cross sections placed across the directrix at their stations, lofted between. */
export function sectionedSolid(g, id) {
const dir = g.ref(g.a(id, 'Directrix'));
const profs = g.refs(g.a(id, 'CrossSections')).map((p) => profileShapes(g, p)[0]).filter(Boolean);
const pos = g.refs(g.a(id, 'CrossSectionPositions'));
const m = new Mesh();
if (profs.length < 2 || pos.length !== profs.length) { g.note('A sectioned solid without matching sections is not drawn'); return m; }
const st = pos.map((q) => g.num(g.a(g.ref(g.a(q, 'Location')), 'DistanceAlong'), 0));
const C = segmentedCurve(g, dir);
// the frame at a station: x to the left (level), y up, z along the alignment
const at = (d) => {
const { p, t } = pointAlong(g, dir, d);
const th = norm([t[0], t[1], 0]);
return { o: p, x: [-th[1], th[0], 0], y: [0, 0, 1] };
};
const rings = [];
for (let k = 0; k + 1 < profs.length; k++) {
const A = profs[k].outer, B = profs[k + 1].outer;
if (A.length !== B.length) { g.note('Sections with different numbers of points are lofted from the first one'); }
const d0 = st[k], d1 = st[k + 1];
const between = C.dist.filter((d) => d > d0 && d < d1);
const ds = [d0, ...between, d1];
for (const d of ds) {
if (rings.length && rings[rings.length - 1].d === d) continue;
const f = d1 > d0 ? (d - d0) / (d1 - d0) : 0;
const F = at(d);
const ring = A.map((a, i) => { const b = B[i] || a; const x = a[0] + (b[0] - a[0]) * f, y = a[1] + (b[1] - a[1]) * f; return add(F.o, add(scale(F.x, x), scale(F.y, y))); });
rings.push(Object.assign(ring, { d }));
}
}
if (rings.length < 2) return m;
const n = rings[0].length;
for (let k = 0; k + 1 < rings.length; k++) {
const A = rings[k], B = rings[k + 1];
for (let i = 0; i < n; i++) { const j = (i + 1) % n; m.polygon([[A[i], A[j], B[j], B[i]]]); }
}
m.polygon([rings[0].slice().reverse()]);
m.polygon([rings[rings.length - 1].slice()]);
if (m.volume() < 0) m.flip();
return m;
}
In detail
Method
Lines, circles and polynomial curves in closed form; clothoid by Simpson's rule on x = ∫cos θ, y = ∫sin θ, θ = s² ÷ 2A²; vertical profile by linear interpolation of its sampled points.
Number types
64-bit numbers.
Tolerances
Sampled points closer than 1e-9 are merged (absolute, file units). The start tangent of a segment is a difference over max(1e-6, length × 1e-6).
Limits
A chord of length c on a radius r lies up to about c² ÷ (8 r) from the arc. The sample step is in file units, so a file in millimeters is sampled every 2 mm and a file in meters every 2 m, until the cap of 400 points widens the step. Cant is not applied. Spirals other than the clothoid are drawn as their chord.
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.