Resources Numerical accuracy Calculation policy

IFC import

Turns the steel members, plates, holes, copes, bolts, grids and levels of an IFC file into a DetailCraft model.

How the precision is kept

Placements are composed in 64-bit in the file's own units and scaled to millimeters at the end. What is stored is then rounded: member ends, grid lines, levels, hole positions and bolt heads to 0.001 mm, rotations to 0.001 degree, hole and bolt diameters to 0.1 mm, cope sizes to 0.5 mm. A profile within 0.5 mm of a catalog shape on every dimension takes the catalog's dimensions.

Open item. Keep imported coordinates and rotations unrounded, or round them only where the file's own precision is known.

Calculations under this policy

14 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-import.js DetailCraft 0.8.1 64-bit 14 calculations Reviewed

Tests bin/test-detailcraft.mjs

Length unit to millimeters

64-bit

The factor that turns the file's lengths into millimeters.

toMm = prefix × 1000; converted units: toMm = factor × prefix × 1000

The SI prefixes are decimal constants (0.1, 0.01, 1e-3 …) multiplied by 1000 in 64-bit. A foot or inch file uses the conversion factor written in the file itself, so its accuracy is the file's.

  • ifc-import.js
  • lines 74 to 87
  • DetailCraft 0.8.1
let toMm = 1000, unitName = 'metre';
const prefix = { EXA: 1e18, PETA: 1e15, TERA: 1e12, GIGA: 1e9, MEGA: 1e6, KILO: 1e3, HECTO: 100, DECA: 10, DECI: 0.1, CENTI: 0.01, MILLI: 1e-3, MICRO: 1e-6, NANO: 1e-9 };
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; unitName = (u.Prefix ? String(val(u.Prefix)).toLowerCase() : '') + 'metre'; }
    else if (u.ConversionFactor) {
      const mwu = line(ref(u.ConversionFactor));
      const f = Number(val(mwu?.ValueComponent));
      const base = siScale(line(ref(mwu?.UnitComponent)));
      if (Number.isFinite(f) && base !== null) { toMm = f * base * 1000; unitName = String(val(u.Name) || t('ifcimp.unit.converted')).toLowerCase(); }

A placement's axes

64-bit

The three axes and origin of an IFC axis placement.

Z = unit(Axis); X = unit(Ref − Z·(Ref·Z)); Y = Z × X

The reference direction is made square to the axis by subtracting its part along it, then normalized. If what is left is shorter than 0.5 a fallback direction is used.

  • ifc-import.js
  • lines 95 to 108
  • DetailCraft 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 };
};

Placements combined

64-bit

One placement expressed inside another.

X′ = A·X, Y′ = A·Y, Z′ = A·Z, O′ = A·O + O_A

Sums of scaled axes in 64-bit. The axes are not made square again after each step, so a long chain of placements carries the rounding of every step; the result is checked later when the member's frame is rebuilt.

  • ifc-import.js
  • line 54
  • DetailCraft 0.8.1
const compose = (A, B) => ({ X: applyDir(A, B.X), Y: applyDir(A, B.Y), Z: applyDir(A, B.Z), O: apply(A, B.O) });

A rectangle from a closed outline

64-bit

Recognizes a four-point outline as a rectangle and reads its center, axes and size.

square when |ê_i·ê_{i+1}| ≤ 1e-4; center = mean of the four points

A closing point within 1e-6 file units of the first is dropped. Width and height are the lengths of the first two edges times the unit factor.

  • ifc-import.js
  • lines 193 to 209
  • DetailCraft 0.8.1
/** A closed IfcPolyline / IfcIndexedPolyCurve that is a rectangle: { c, X, Y, w, h } (mm) or null. */
function rectangleOf(id) {
  const cv = line(id);
  if (!cv) return null;
  let pts = [];
  if (clsName(cv) === 'IfcPolyline') pts = (cv.Points || []).map((p) => coords(ref(p)));
  else if (clsName(cv) === 'IfcIndexedPolyCurve' && !cv.Segments) {
    const list = line(ref(cv.Points));
    pts = (list?.CoordList || []).map((p) => p.map((v) => Number(val(v))).concat([0]).slice(0, 3));
  } else return null;
  if (pts.length > 1 && len(sub(pts[0], pts[pts.length - 1])) < 1e-6) pts.pop();
  if (pts.length !== 4) return null;
  const e = pts.map((p, i) => sub(pts[(i + 1) % 4], p));
  for (let i = 0; i < 4; i++) if (Math.abs(dot(norm(e[i]), norm(e[(i + 1) % 4]))) > 1e-4) return null;
  const c = mul(pts.reduce((s, p) => add(s, p), [0, 0, 0]), 0.25);
  return { c, X: norm(e[0]), Y: norm(cross([0, 0, 1], norm(e[0]))), w: len(e[0]) * toMm, h: len(e[1]) * toMm };   // c in file units, like any placement
}

Matching a profile to the catalog

64-bit

Decides whether a profile from the file is one of the catalog shapes.

match when every |catalog − file| ≤ 0.5 mm

On a match the member gets the catalog shape, so each of its dimensions can differ from the file's by up to 0.5 mm. The fillet radius is not compared.

  • ifc-import.js
  • lines 211 to 227
  • DetailCraft 0.8.1
/** Catalog name for profile dims of a kind, or null. */
function catalogMatch(kind, d, pname) {
  const fit = (p) => {
    switch (kind) {
      case 'I': return p.kind === 'I' && close(p.d, d.d) && close(p.bf, d.bf) && close(p.tw, d.tw) && close(p.tf, d.tf);
      case 'C': return p.kind === 'C' && close(p.d, d.d) && close(p.bf, d.bf) && close(p.tw, d.tw) && close(p.tf, d.tf);
      case 'L': return p.kind === 'L' && close(p.b, d.b) && close(p.t, d.t);
      case 'RHS': return p.kind === 'RHS' && close(p.t, d.t) && ((close(p.h, d.h) && close(p.b, d.b)) || (close(p.h, d.b) && close(p.b, d.h)));
      case 'CHS': return p.kind === 'CHS' && close(p.D, d.D) && close(p.t, d.t);
      default: return false;
    }
  };
  const byName = pname && CATALOG.get(pname.toUpperCase().replace(/\s+/g, ''));
  if (byName && fit(byName)) return byName.name;
  for (const p of CATALOG.values()) if (fit(p)) return p.name;
  return null;
}

Reusing a custom profile

64-bit

Decides whether two profiles from the file are the same custom profile.

same when all dimensions agree after rounding to 0.01 mm

The lookup key rounds each dimension to two decimals with Math.round. Profiles that agree to that key share the first one's unrounded dimensions.

  • ifc-import.js
  • lines 476 to 489
  • DetailCraft 0.8.1
/** A custom profile for dims: reuses one with the same dims; name from the file (made unique). Returns its name or null. */
function custom(customs, pname, p, fallback) {
  const key = JSON.stringify(Object.entries(p).map(([k, v]) => [k, typeof v === 'number' ? Math.round(v * 100) / 100 : v]));
  if (customs.has(key)) return customs.get(key).profile.name;
  let base = cleanText(pname || fallback, 34) || fallback;
  let name = base, i = 2;
  const taken = (nm) => CATALOG.has(nm.toUpperCase()) || /^PL\s*\d/i.test(nm) || [...customs.values()].some((c) => c.profile.name.toUpperCase() === nm.toUpperCase());
  if (taken(name)) name = base + ' (file)';
  while (taken(name)) name = base + ' (' + i++ + ')';
  const r = cleanCustom({ ...p, name });
  if (!r.ok) return null;
  customs.set(key, r);
  return r.profile.name;
}

Level heights

64-bit

The height of each storey, from its placement or its Elevation.

z = round(z × toMm × 1000) ÷ 1000

The height is rounded to 0.001 mm with Math.round before it is stored.

  • ifc-import.js
  • lines 243 to 247
  • DetailCraft 0.8.1
let z = M ? M.O[2] * toMm : null;
if ((z === null || (z === 0 && s.Elevation)) && s.Elevation !== null && s.Elevation !== undefined) z = Number(val(s.Elevation)) * toMm;
let nm = cleanText(val(s.Name) || '', 40) || t('ifcimp.level.default', { n: levels.length + 1 });
while (levels.some((l) => l.name === nm)) nm = nm.slice(0, 36) + ' (' + (levels.length + 1) + ')';
if (Number.isFinite(z)) levels.push({ name: nm, z: round3(z) });

Grid lines

64-bit

Which grid axes become numbered or lettered grid lines, and where.

x line when |d_y| > 0.9999; y line when |d_x| > 0.9999; position rounded to 0.001 mm

An axis up to about 0.8 degrees off X or Y is still taken as a grid line, at the coordinate of its first point. Lines closer than 0.5 mm to one already kept are dropped.

  • ifc-import.js
  • lines 261 to 265
  • DetailCraft 0.8.1
const dvec = norm(sub(pts[pts.length - 1], pts[0]));
const tag = cleanText(val(ax.AxisTag) || '', 8) || '?';
if (Math.abs(dvec[1]) > 0.9999) gx.push({ label: tag, at: round3(pts[0][0]) });
else if (Math.abs(dvec[0]) > 0.9999) gy.push({ label: tag, at: round3(pts[0][1]) });
else skewAxes++;

Member line from the extrusion

64-bit

The start and end of a member from its extruded solid.

start = O × toMm; end = start + ext·depth

The origin is transformed in file units and then scaled. The extrusion must be square to the profile within 1e-4, as a cosine; a mirrored placement swaps start and end.

  • ifc-import.js
  • lines 303 to 311
  • DetailCraft 0.8.1
const O = mul(apply(Msolid, prof.pos.O), toMm);                    // the profile's origin (section center), mm
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-4 || Math.abs(dot(ext, V)) > 1e-4) { skip(cls, t('ifcimp.why.not-square')); continue; }
if (!(depth > 0)) { skip(cls, t('ifcimp.why.zero-length')); continue; }
// DetailCraft's frame: (u, v, x) right-handed; a mirrored placement is walked the other way
let start = O, end = add(O, mul(ext, depth));
if (dot(cross(U, V), ext) < 0) [start, end] = [end, start];

Plate from a rectangular solid

64-bit

The thickness, width and length of a plate from the three sizes of its box.

thickness = smallest size; length = largest; plate when thickness < 0.25 × middle size

The plate's thickness and width are stored through a profile name written with two decimals, so they are rounded to 0.01 mm.

  • ifc-import.js
  • lines 315 to 323
  • DetailCraft 0.8.1
const sizes = [['x', prof.dims.x, U], ['y', prof.dims.y, V], ['z', depth, ext]].sort((a, c) => a[1] - c[1]);
const [thin, mid2, long] = sizes;
if (type === 'plate' || thin[1] < 0.25 * mid2[1]) {
  const c = add(O, mul(ext, depth / 2));                      // box center
  const xDir = long[2], vDir = thin[2];
  const uDir = cross(vDir, xDir);
  start = sub(c, mul(xDir, long[1] / 2)); end = add(c, mul(xDir, long[1] / 2));
  U = uDir; V = vDir;
  profileName = plateName(thin[1], mid2[1]);

Member rotation and stored coordinates

64-bit

The rotation that puts the section the way the file has it, and the values stored for the member.

rot = atan2(U·v₀, U·u₀) × 180 ÷ π; stored: ends to 0.001 mm, rotation to 0.001°

After the rotation is found, the frame is rebuilt with it and must agree with the file's axes within 1e-4, else the member is left out. Start, end and rotation are then rounded to three decimals with Math.round before they are stored.

  • ifc-import.js
  • lines 350 to 355
  • DetailCraft 0.8.1
const f0 = frame(start, end, 0);
const rot = Math.atan2(dot(U, f0.v), dot(U, f0.u)) * 180 / Math.PI;
const f1 = frame(start, end, rot);
if (len(sub(f1.u, U)) > 1e-4 || len(sub(f1.v, V)) > 1e-4) { skip(cls, t('ifcimp.why.axes')); continue; }
if ([...start, ...end].some((v) => !Number.isFinite(v) || Math.abs(v) > 1e7)) { skip(cls, t('ifcimp.why.far')); continue; }
members.push({ type, profile: profileName, material: cleanText(materialOf.get(id) || '', 30), start: start.map(round3), end: end.map(round3), rotation: round3(((rot % 360) + 360) % 360), position, name: cleanText(val(el.Name) || val(el.Tag) || '', 40), cls, clipped: b.clipped, src: id });

Holes from round openings

64-bit

The face, position and diameter of each hole read from an opening.

d = round(2·r × 10) ÷ 10; x, y rounded to 0.001 mm

The hole's diameter is rounded to 0.1 mm, so a 17.46 mm hole (11/16 in) is stored as 17.5. A hole within 0.5 mm in position and 0.05 mm in diameter of one already there is not added twice.

  • ifc-import.js
  • lines 423 to 432
  • DetailCraft 0.8.1
const mid = add(e.O, mul(e.ext, e.depth / 2)), l = toLocal(f, mid, voff);
const k = pr.kind;
let face = null, y = 0;
if (Math.abs(ax) > 0.02) { skip(cls, t('ifcimp.why.hole-slanted')); continue; }
if (k === 'CHS') { skip(cls, t('ifcimp.why.hole-tube')); continue; }
if (Math.abs(au) > 0.98 && k !== 'PL') { face = 'web'; y = l.v; }
else if (Math.abs(av) > 0.98) { if (k === 'PL') face = 'plate'; else if (k === 'L') face = 'bottom'; else face = l.v > 0 ? 'top' : 'bottom'; y = l.u; }
if (!face) { skip(cls, t('ifcimp.why.hole-face')); continue; }
if (l.x < -0.5 || l.x > f.len + 0.5) { skip(cls, t('ifcimp.why.hole-off')); continue; }
const d = Math.round(2 * e.prof.r * 10) / 10, x = round3(l.x), yy = round3(y);

Copes from rectangular openings

64-bit

The length and depth of a cope read from a box-shaped opening.

length = round(reach × 2) ÷ 2; depth = round(reach × 2) ÷ 2

Both values are rounded to the nearest 0.5 mm. Whether the box reaches an end, the top or the bottom is decided with 0.5 mm allowances.

  • ifc-import.js
  • lines 445 to 449
  • DetailCraft 0.8.1
const atStart = lo('x') <= 0.5 && hi('x') > 0.5, atEnd = hi('x') >= L - 0.5 && lo('x') < L - 0.5;
const fromTop = hi('v') >= h2 - 0.5 && lo('v') > -h2 + 0.5, fromBottom = lo('v') <= -h2 + 0.5 && hi('v') < h2 - 0.5;
if (!okKind || lo('u') > -w2 + 0.5 || hi('u') < w2 - 0.5 || atStart === atEnd || fromTop === fromBottom) { skip(cls, t('ifcimp.why.opening-rect')); continue; }
const length = Math.round((atStart ? Math.min(hi('x'), L) : L - Math.max(lo('x'), 0)) * 2) / 2;
const depth = Math.round((fromTop ? h2 - lo('v') : hi('v') + h2) * 2) / 2;

Bolts from fasteners

64-bit

The diameter, length, head point and direction of each bolt read from the file.

d, length rounded to 0.1 mm; head rounded to 0.001 mm

Diameter and length are rounded to one decimal with Math.round before the bolt is added. The model then rounds the bolt again when it stores it.

  • ifc-import.js
  • lines 459 to 464
  • DetailCraft 0.8.1
const d = fe.NominalDiameter !== undefined && fe.NominalDiameter !== null ? n(fe.NominalDiameter) : e.prof?.kind === 'circle' ? 2 * e.prof.r : NaN;
const L = fe.NominalLength !== undefined && fe.NominalLength !== null ? n(fe.NominalLength) : e.why ? NaN : e.depth;
if (!(d > 0) || !(L > 0)) { skip(cls, t('ifcimp.why.bolt-size')); continue; }
const kind = /ANCHOR/i.test(String(val(fe.PredefinedType) || '')) ? 'ANCHORBOLT' : 'BOLT';
const grade = cleanText(val(fe.ObjectType) || materialOf.get(fid) || '', 20);
try { tx.addBolt({ d: Math.round(d * 10) / 10, length: Math.round(L * 10) / 10, grade, kind, head: head.map(round3), axis }); got(cls); }

In detail

Method
Local placements composed as axes plus origin; the member axis is the extrusion; the rotation is found with Math.atan2 and then checked by rebuilding the frame.
Number types
64-bit numbers; stored values rounded as listed.
Tolerances
Catalog match 0.5 mm per dimension. Extrusion square to its profile within 1e-4 (cosine). Rebuilt axes within 1e-4. Grid lines parallel to X or Y within 0.9999 (cosine). Rectangle corners square within 1e-4. Top or bottom position within 0.01 mm. Holes: axis within 0.98 of a face normal and under 0.02 along the member.
Limits
Scaled mapped items, tapered and clipped solids are left out or simplified and said in the report. The unit factor is the product of the file's own conversion factor and a decimal prefix.

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