Resources Numerical accuracy Calculation policy

IFC alignment curves and stations

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.

ifc/geo-align.js BimCraft 0.8.1 64-bit 7 calculations Reviewed

Tests tools/ifc-test/test.mjs, bin/test-civilcraft-ifc.mjs

Point on a clothoid

64-bit

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.

z = z_lo + (z_hi − z_lo) × (d − d_lo) ÷ (d_hi − d_lo)

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.

p = p_{i−1} + (p_i − p_{i−1}) × (d − d_{i−1}) ÷ (d_i − d_{i−1})

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.

Every policy that takes this approach