Resources Numerical accuracy Calculation policy

IFC swept solids, primitives and faceted shapes

Builds the meshes of extruded, revolved and swept solids, swept disks such as rebar and pipes, the basic primitives, and faceted boundary shapes.

How the precision is kept

Vertices are computed in 64-bit from the profile points and the file's directions, depths and angles. Straight extrusions of straight-edged profiles are exact up to rounding. Round shapes are faceted: 36 steps per full turn for revolutions, disks and cylinders, and a fixed 12 by 24 grid for a sphere, so their surface and volume are those of the faceted body.

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.

ifc/geo-solid.js BimCraft 0.8.1 64-bit 8 calculations Reviewed

Tests tools/ifc-test/test.mjs, tools/bimcraft-test/ifc-solids-test.mjs

Straight extrusion

64-bit

The solid of a profile swept along a direction by a depth, with end caps and sides.

top = bottom + direction × depth

Each top vertex is its bottom vertex plus one vector, in 64-bit; the direction is a unit vector as read. The face winding follows the sign of the direction's Z component.

  • ifc/geo-solid.js
  • lines 23 to 46
  • BimCraft 0.8.1
/** Extrude 2D shapes (z = 0) along dir × depth. Faces point outward whatever the direction's sign. */
export function extrudeShapes(shapes, dir, depth, endShapes = null) {
  const m = new Mesh();
  const d = scale(dir, depth);
  const up = d[2] >= 0;   // shapes are counter-clockwise seen from +z
  shapes.forEach((s, si) => {
    if (s.open) return;
    const e = endShapes && endShapes[si] && sameCounts(s, endShapes[si]) ? endShapes[si] : s;
    const bottom = [s.outer.map((p) => P3(p)), ...s.holes.map((h) => h.map((p) => P3(p)))];
    const top = [e.outer.map((p) => add(P3(p), d)), ...e.holes.map((h) => h.map((p) => add(P3(p), d)))];
    // bottom faces down (reversed), top faces up — swapped when extruding downward
    m.polygon(up ? bottom.map((r) => r.slice().reverse()) : bottom);
    m.polygon(up ? top : top.map((r) => r.slice().reverse()));
    for (let r = 0; r < bottom.length; r++) {
      const A = bottom[r], B = top[r], n = A.length;
      for (let i = 0; i < n; i++) {
        const j = (i + 1) % n;
        const q = [A[i], A[j], B[j], B[i]];
        quad(m, up ? q : q.slice().reverse());
      }
    }
  });
  return m;
}

Side face of a sweep

64-bit

Adds a four-sided face as one flat face or as two triangles.

The face is treated as flat when the normalized normals of its two triangles have a dot product above 0.9999, which allows about 0.8 degree of twist. An edge shorter than 1e-12 turns it into a single triangle.

  • ifc/geo-solid.js
  • lines 50 to 57
  • BimCraft 0.8.1
/** A planar or slightly twisted quad as two triangles (one polygon when planar). */
function quad(m, q) {
  const [a, b, c, d] = q;
  if (len(sub(a, b)) < 1e-12 || len(sub(c, d)) < 1e-12) { m.tri(a, c, d); return; }
  const n1 = cross(sub(b, a), sub(c, a)), n2 = cross(sub(c, a), sub(d, a));
  if (dot(norm(n1), norm(n2)) > 0.9999) m.polygon([q]);
  else { m.tri(a, b, c); m.tri(a, c, d); }
}

Revolved solid

64-bit

The solid of a profile turned about an axis by an angle.

p_k = o + R(axis, angle × k ÷ steps) · (p − o), steps = 36 per full turn

The angle is multiplied by the file's angle unit. Every ring is rotated from the original profile, not from the previous ring, so rotation errors do not build up. An angle within 1e-9 rad of a full turn is made exactly 2π and gets no end caps.

  • ifc/geo-solid.js
  • lines 68 to 106
  • BimCraft 0.8.1
/** IfcRevolvedAreaSolid: the profile turned about Axis (in the profile plane) by Angle. */
export function revolved(g, id) {
  const shapes = profileShapes(g, g.ref(g.a(id, 'SweptArea')));
  const ax = g.ref(g.a(id, 'Axis'));
  const o = g.point(g.ref(g.a(ax, 'Location')));
  const axis = g.dir(g.ref(g.a(ax, 'Axis')), [0, 1, 0]);
  let angle = g.num(g.a(id, 'Angle'), 0) * g.angleUnit;
  const full = Math.abs(angle) >= 2 * Math.PI - 1e-9;
  if (full) angle = 2 * Math.PI * Math.sign(angle || 1);
  const steps = arcSegments(Math.abs(angle), 1);
  const rot = (k) => { const R = rotation(axis, angle * k / steps); return (p) => add(o, apply(R, ...sub(P3(p), o))); };
  const m = new Mesh();
  for (const sh of shapes) {
    if (sh.open) continue;
    const rings = [sh.outer, ...sh.holes];
    // which way the profile leaves its plane: points move along sign(angle) × axis × (p − o); the profile is
    // counter-clockwise about +z, so that sign fixes the winding of the sides and caps
    const c = sh.outer.reduce((acc, p) => [acc[0] + p[0] / sh.outer.length, acc[1] + p[1] / sh.outer.length], [0, 0]);
    const s = Math.sign(angle) * cross(axis, sub(P3(c), o))[2] < 0 ? -1 : 1;
    for (const ring of rings) {
      const n = ring.length;
      for (let k = 0; k < steps; k++) {
        const fa = rot(k), fb = rot(k + 1);
        for (let i = 0; i < n; i++) {
          const j = (i + 1) % n;
          const q = [fa(ring[i]), fa(ring[j]), fb(ring[j]), fb(ring[i])];
          quad(m, s < 0 ? q.slice().reverse() : q);
        }
      }
    }
    if (!full) {
      const start = rings.map((r) => r.map(rot(0)));
      const end = rings.map((r) => r.map(rot(steps)));
      m.polygon(s < 0 ? start : start.map((r) => r.slice().reverse()));
      m.polygon(s < 0 ? end.map((r) => r.slice().reverse()) : end);
    }
  }
  return m.transform(g.axis2(g.ref(g.a(id, 'Position'))));
}

Frames along a path

64-bit

The tangent, normal and binormal at each point of a 3D polyline, and the miter stretch at each bend.

tangent = normalize(t_in + t_out); miter = 1 ÷ max(0.25, t_in · tangent)

The normal is carried from one point to the next and made square to the new tangent each time, so its direction is the result of a chain of rotations along the path. The miter factor is capped at 4.

  • ifc/geo-solid.js
  • lines 108 to 129
  • BimCraft 0.8.1
/** Frames along a 3D polyline by parallel transport: [{ p, t, n, b }]. */
export function frames(path) {
  const out = [];
  const n = path.length;
  if (n < 2) return out;
  let t0 = norm(sub(path[1], path[0]));
  let nrm = perp(t0);
  for (let i = 0; i < n; i++) {
    const tin = i > 0 ? norm(sub(path[i], path[i - 1])) : null;
    const tout = i < n - 1 ? norm(sub(path[i + 1], path[i])) : null;
    let t = tin && tout ? norm(add(tin, tout)) : tin || tout;
    if (!t[0] && !t[1] && !t[2]) t = tout || tin;
    // transport the normal from the previous tangent to this one
    if (i > 0) { const prev = out[i - 1].t; nrm = transport(nrm, prev, t); }
    else nrm = norm(sub(nrm, scale(t, dot(nrm, t))));
    const b = cross(t, nrm);
    // mitre: scale the section across the bend so the wall keeps its thickness
    const mit = tin && tout ? 1 / Math.max(0.25, dot(tin, t)) : 1;
    out.push({ p: path[i], t, n: nrm, b, mit, tin, tout });
  }
  return out;
}

Normal carried round a bend

64-bit

Turns the section's normal by the rotation that takes the previous tangent to the next.

angle = atan2(|a × b|, a · b); v' = R(a × b, angle) v, made square to b

The angle uses atan2 of the cross and dot products, which keeps its digits for both small and large turns. A cross product shorter than 1e-12 is treated as no turn.

  • ifc/geo-solid.js
  • lines 131 to 138
  • BimCraft 0.8.1
function transport(v, a, b) {
  const ax = cross(a, b), s = len(ax);
  if (s < 1e-12) return norm(sub(v, scale(b, dot(v, b))));
  const ang = Math.atan2(s, dot(a, b));
  const R = rotation(ax, ang);
  const w = apply(R, v[0], v[1], v[2]);
  return norm(sub(w, scale(b, dot(w, b))));
}

Swept disk

64-bit

A round bar or tube along a path, such as rebar, with an optional inner radius and bend radius.

section = 36-sided polygon of the radius; swept on the path's frames

The round section is a 36-sided polygon with its corners on the circle, so the tube's cross-section area is 0.9949 of the true one. Path points closer than 1e-9 are merged first.

  • ifc/geo-solid.js
  • lines 178 to 189
  • BimCraft 0.8.1
/** IfcSweptDiskSolid (and the polygonal variant with bends): a tube along the directrix. */
export function sweptDisk(g, id) {
  let path = curvePoints(g, g.ref(g.a(id, 'Directrix')));
  path = dedupe(path);
  const r = g.num(g.a(id, 'Radius'), 0);
  const ri = g.num(g.a(id, 'InnerRadius'), 0);
  const bend = g.num(g.a(id, 'FilletRadius'), 0);
  if (bend > 0 && path.length > 2) path = filletPath(path, bend);
  const circle = (rad) => { const n = arcSegments(2 * Math.PI, rad); return Array.from({ length: n }, (_, i) => [rad * Math.cos(2 * Math.PI * i / n), rad * Math.sin(2 * Math.PI * i / n)]); };
  const holes = ri > 0 && ri < r ? [circle(ri).reverse()] : [];
  return sweepRings(path, [{ outer: circle(r), holes }]);
}

Bends of a swept path

64-bit

Rounds the corners of a 3D polyline with arcs of a given radius.

d = min(r ÷ tan(half), 0.999 × each leg); center = corner + bisector × r' ÷ sin(half)

The corner angle and the arc's sweep are each an acos of a clamped dot product. The tangent distance is cut to 0.999 of the shorter leg, which reduces the bend radius without a note. Arc points are each rotated from the arc's start, not from the previous point.

  • ifc/geo-solid.js
  • lines 197 to 217
  • BimCraft 0.8.1
/** Round the corners of a 3D polyline with arcs of radius r (IfcSweptDiskSolidPolygonal bends). */
export function filletPath(path, r) {
  const out = [path[0]];
  for (let i = 1; i + 1 < path.length; i++) {
    const p = path[i], a = norm(sub(path[i - 1], p)), b = norm(sub(path[i + 1], p));
    const cosT = dot(a, b);
    const half = Math.acos(Math.max(-1, Math.min(1, cosT))) / 2;
    if (half < 1e-6 || half > Math.PI / 2 - 1e-6) { out.push(p); continue; }
    const d = Math.min(r / Math.tan(half), len(sub(path[i - 1], p)) * 0.999, len(sub(path[i + 1], p)) * 0.999);
    const rr = d * Math.tan(half);
    const s = add(p, scale(a, d)), e = add(p, scale(b, d));
    const c = add(p, scale(norm(add(a, b)), rr / Math.sin(half)));
    const u = norm(sub(s, c)), w = norm(sub(e, c));
    const sweep = Math.acos(Math.max(-1, Math.min(1, dot(u, w))));
    const ax = norm(cross(u, w));
    const n = Math.max(2, arcSegments(sweep, rr));
    for (let k = 0; k <= n; k++) { const R = rotation(ax, sweep * k / n); out.push(add(c, scale(apply(R, u[0], u[1], u[2]), rr))); }
  }
  out.push(path[path.length - 1]);
  return out;
}

Sphere

64-bit

The mesh of an IfcSphere.

(r sin θ cos φ, r sin θ sin φ, r cos θ), θ = π i ÷ 12, φ = 2π j ÷ 24

A fixed grid of 12 by 24 facets with corners on the sphere, whatever the radius. The faceted body encloses less volume than the sphere.

  • ifc/geo-solid.js
  • lines 295 to 302
  • BimCraft 0.8.1
} else if (t === 'IFCSPHERE') {
  const r = n('Radius'), st = 12, sl = 24;
  const pt = (i, j) => { const th = Math.PI * i / st, ph = 2 * Math.PI * j / sl; return [r * Math.sin(th) * Math.cos(ph), r * Math.sin(th) * Math.sin(ph), r * Math.cos(th)]; };
  for (let i = 0; i < st; i++) for (let j = 0; j < sl; j++) {
    const a = pt(i, j), b = pt(i + 1, j), c = pt(i + 1, j + 1), d = pt(i, j + 1);
    if (i > 0) m.tri(a, b, d);
    if (i < st - 1) m.tri(b, c, d);
  }
ifc/geo-brep.js BimCraft 0.8.1 64-bit 2 calculations Reviewed

Tests tools/ifc-test/test.mjs

Outer bound of a face

64-bit

Picks the largest loop of a face as its outer bound when the file marks none.

size = |Σ ((yₐ − y_b)(zₐ + z_b), (zₐ − z_b)(xₐ + x_b), (xₐ − x_b)(yₐ + y_b))| (twice the area)

The loops are compared by the length of their Newell sums, in 64-bit. The value only ranks the loops; it is not reported.

  • ifc/geo-brep.js
  • lines 68 to 75
  • BimCraft 0.8.1
function ringArea(r) {
  let x = 0, y = 0, z = 0;
  for (let i = 0; i < r.length; i++) {
    const a = r[i], b = r[(i + 1) % r.length];
    x += (a[1] - b[1]) * (a[2] + b[2]); y += (a[2] - b[2]) * (a[0] + b[0]); z += (a[0] - b[0]) * (a[1] + b[1]);
  }
  return Math.hypot(x, y, z);
}

Repeated points of a loop

64-bit

Drops consecutive points of a face loop that coincide, and a closing point equal to the first.

Points are merged only within 1e-12 (absolute, file units), so the loop keeps the file's own coordinates; near-duplicates farther apart than that stay as very short edges.

  • ifc/geo-brep.js
  • lines 86 to 91
  • BimCraft 0.8.1
function dedupe(pts) {
  const out = [];
  for (const p of pts) { const l = out[out.length - 1]; if (!l || len(sub(l, p)) > 1e-12) out.push(p); }
  if (out.length > 1 && len(sub(out[0], out[out.length - 1])) < 1e-12) out.pop();
  return out;
}

In detail

Method
Extrusion by adding direction × depth; revolution by rotating each profile point about the axis; sweeps on parallel-transport frames with mitered sections at bends.
Number types
64-bit numbers.
Tolerances
A revolution is a full turn when its angle is within 1e-9 rad of 2π. A side quad is one flat face when its two triangle normals agree to 0.9999. Path points closer than 1e-9 are merged; a bend with a half angle under 1e-6 rad is left sharp. All absolute.
Limits
Section stretch at a bend is capped at 4 times. Fillet radii along a path are reduced when a leg is too short. A sphere always has 12 by 24 facets.

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