Resources Numerical accuracy Calculation policy

Family geometry from parameters

Works out a family's solids, plan symbol and connectors from its parameters and formulas, and writes them to the IFC file.

How the precision is kept

Parameters and expressions are evaluated in 64-bit millimeters and are not rounded while the family is evaluated. Writing rounds lengths to 6 decimals of the file's unit, directions to 12 decimals and angles to 9. Arcs in a profile are exact arcs in IFC4 files (start, middle and end point); in IFC2X3 files, in footprints and in plan symbols they are chords of 15° or 7.5°, and there the chord count sets how closely the arc is followed.

Calculations under this policy

21 calculations in 3 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.

family-model.js BimCraft 0.8.1 64-bit 16 calculations Reviewed

Tests tools/bimcraft-test/families-test.mjs

A parameter's value as kept

64-bit

A typed or imported value made safe for its kind.

Lengths, angles and numbers are kept as given when finite and no larger than 1e7 in size; otherwise 0. Integers are rounded with Math.round, pens are rounded and held between 1 and 16, and Yes / No becomes 1 or 0.

  • family-model.js
  • lines 99 to 110
  • BimCraft 0.8.1
/** A parameter's value as its kind keeps it (untrusted): numbers finite and bounded, integers whole, pens 1–16, words cleaned. */
export function cleanValue(kind, value) {
  if (kind === 'text') return cleanText(value, 120);
  if (kind === 'material') { const s = String(value ?? '').trim(); return MATERIAL_RE.test(s) ? s : ''; }
  if (kind === 'fill') return FILL_KEYS.includes(value) ? value : 'none';
  const v = Number(value);
  if (!Number.isFinite(v) || Math.abs(v) > LIMITS.value) return kind === 'pen' ? 1 : 0;
  if (kind === 'yesno') return v ? 1 : 0;
  if (kind === 'integer') return Math.round(v);
  if (kind === 'pen') return Math.min(16, Math.max(1, Math.round(v)));
  return v;
}

When two sizes are the same

64-bit

The key that tells one size of a family from another.

key = every input value rounded to 0.001

Values are rounded to 3 decimals only for the comparison. Two sizes whose inputs differ by less than that are one size, and the element takes the values of the size that was made first.

  • family-model.js
  • lines 416 to 420
  • BimCraft 0.8.1
/** What makes two sizes the same: their input values to 0.001. */
export function sizeKey(fam, values) {
  const v = sizeValues(fam, values);
  return Object.keys(v).sort().map((k) => k + '=' + (typeof v[k] === 'string' ? JSON.stringify(v[k]) : round(v[k], 3))).join(';');
}

Parameters driven by formulas

64-bit

The value of every parameter that has a formula.

Formulas are evaluated in passes: one is evaluated once every name it uses has a value, and a pass that settles nothing is reported as a cycle. Results are kept unrounded in 64-bit.

  • family-model.js
  • lines 437 to 449
  • BimCraft 0.8.1
let pending = fam.params.filter((p) => p.formula);
const names = [...fam.params.filter((p) => !isWordKind(p.kind)).map((p) => p.key), ...Object.keys(env)];
while (pending.length) {
  const next = [];
  for (const p of pending) {
    const ps = parse(p.formula, names);
    if (!ps.ok) return { ok: false, error: err('fam.err.expr', { where: p.key, why: ps.error }) };
    if (ps.uses.some((u) => !(u in env))) { next.push(p); continue; }
    env[p.key] = evaluate(ps.ast, env);
  }
  if (next.length === pending.length) return { ok: false, error: err('fam.err.cycle', { name: next[0].key }) };
  pending = next;
}

A solid's numbers

64-bit

The evaluated box, profile, depth, axis, angle, path and radius of each solid, and the check that it is not flat.

box = (min, max) of each pair; a negative depth moves the start: at = at + depth, depth = −depth

Fields are evaluated in 64-bit and kept as they come. The flatness limits are absolute: 0.01 mm for a side, a depth, a radius or a path step, 1 mm² for a profile, and an angle must be above 0.01° and at most 360°.

  • family-model.js
  • lines 466 to 490
  • BimCraft 0.8.1
if (s.op === 'box') {
  const v = ['x0', 'y0', 'z0', 'x1', 'y1', 'z1'].map((k) => num(s[k], where + ' ' + k));
  e.box = [Math.min(v[0], v[3]), Math.min(v[1], v[4]), Math.min(v[2], v[5]), Math.max(v[0], v[3]), Math.max(v[1], v[4]), Math.max(v[2], v[5])];
  if (visible && (e.box[3] - e.box[0] < 0.01 || e.box[4] - e.box[1] < 0.01 || e.box[5] - e.box[2] < 0.01)) return { ok: false, error: err('fam.err.flat', { name: where }) };
} else if (s.op === 'extrude' || s.op === 'revolve') {
  e.plane = s.plane; e.at = num(s.at, where + ' at');
  e.profile = s.profile.map((p, i) => [num(p[0], where + ' ' + (i + 1)), num(p[1], where + ' ' + (i + 1)), p[2] || 0]);
  if (visible && Math.abs(polyArea(e.profile)) < 1) return { ok: false, error: err('fam.err.flat', { name: where }) };
  if (s.op === 'extrude') {
    e.depth = num(s.depth, where + ' depth');
    if (e.depth < 0) { e.at += e.depth; e.depth = -e.depth; }
    if (visible && e.depth < 0.01) return { ok: false, error: err('fam.err.flat', { name: where }) };
  } else {
    e.axis = s.axis.map((a, i) => num(a, where + ' axis ' + (i + 1)));
    e.angle = num(s.angle, where + ' angle');
    if (visible && !(e.angle > 0.01 && e.angle <= 360)) return { ok: false, error: err('fam.err.angle', { name: where }) };
    if (visible && !oneSide(e.profile, e.axis)) return { ok: false, error: err('fam.err.axis', { name: where }) };
  }
} else {
  e.path = s.path.map((p, i) => p.map((x) => num(x, where + ' ' + (i + 1))));
  e.radius = num(s.radius, where + ' radius');
  if (visible && !(e.radius > 0.01)) return { ok: false, error: err('fam.err.flat', { name: where }) };
  if (visible && e.path.some((p, i) => i && Math.hypot(p[0] - e.path[i - 1][0], p[1] - e.path[i - 1][1], p[2] - e.path[i - 1][2]) < 0.01)) return { ok: false, error: err('fam.err.flat', { name: where }) };
}
solids.set(s.id, e);

Copies in an array

64-bit

The solids of an array, each moved by a whole number of steps.

copy i is moved by (dx × i, dy × i, dz × i), count = round(count expression)

Each copy's offset is the step times its index, a single product, so the last copy does not carry rounding added up over the earlier ones. The count is rounded to a whole number and must be between 1 and 200.

  • family-model.js
  • lines 492 to 498
  • BimCraft 0.8.1
const n = Math.round(num(s.array.count, where + ' array-count'));
if (!(n >= 1 && n <= LIMITS.array)) return { ok: false, error: err('fam.err.arraycount', { name: where, max: LIMITS.array }) };
const d = ['dx', 'dy', 'dz'].map((k) => num(s.array[k], where + ' array ' + k));
if (solids.size + n - 1 > LIMITS.instances) return { ok: false, error: err('fam.err.toomany', { what: 'solids', n: LIMITS.instances }) };
const ids = [s.id];
for (let i = 1; i < n; i++) { const id = s.id + '~' + i; solids.set(id, shifted(e, id, d[0] * i, d[1] * i, d[2] * i)); ids.push(id); }
instances.set(s.id, ids);

A family placed inside another

64-bit

The position and quarter-turn of a nested family.

turns = rotation ÷ 90; accepted when round(turns × 10⁶) ÷ 10⁶ is a whole number; k = turns mod 4

The rotation must be a multiple of 90° to within 1e-6 of a quarter turn. The turn is then applied k times by rot90, which swaps and negates coordinates, so no sine or cosine is involved and the turned solid is exact.

  • family-model.js
  • lines 524 to 526
  • BimCraft 0.8.1
const at = p.at.map((x, i) => num(x, where + ' at ' + 'xyz'[i])), turns = p.rot !== undefined ? num(p.rot, where + ' rot') / 90 : 0;
if (!Number.isInteger(Math.round(turns * 1e6) / 1e6)) return { ok: false, error: err('fam.err.nestrot', { name: where }) };
const k = ((Math.round(turns) % 4) + 4) % 4;

Quarter turn of a solid

Exact

An evaluated solid turned 90° about the vertical axis.

(x, y) → (−y, x)

Only swaps and sign changes of 64-bit numbers, which are exact. A vertical-plane extrusion moves to the other vertical plane with its start at −(at + depth), one addition.

  • family-model.js
  • lines 593 to 607
  • BimCraft 0.8.1
/** An evaluated solid turned a quarter turn about z ((x, y) → (−y, x)); null for a revolve on a vertical plane (its direction would not carry over). */
function rot90(e) {
  const c = { ...e };
  if (e.op === 'box') { const b = e.box; c.box = [-b[4], b[0], b[2], -b[1], b[3], b[5]]; return c; }
  if (e.op === 'sweep') { c.path = e.path.map((p) => [-p[1], p[0], p[2]]); return c; }
  if (e.plane === 'xy') {
    c.profile = e.profile.map((p) => [-p[1], p[0], p[2]]);
    if (e.op === 'revolve') c.axis = [-e.axis[1], e.axis[0], -e.axis[3], e.axis[2]];
    return c;
  }
  if (e.op === 'revolve') return null;
  if (e.plane === 'xz') { c.plane = 'yz'; c.at = -(e.at + e.depth); return c; }   // volume y ∈ [at, at + depth] becomes x ∈ [−(at + depth), −at]
  c.plane = 'xz'; c.profile = e.profile.map((p) => [-p[0], p[1], -(p[2] || 0)]);   // yz: x' = −y mirrors u (arcs turn the other way), y' = x
  return c;
}

Move a solid

64-bit

An evaluated solid moved by an offset in the family's axes.

p → p + (dx, dy, dz), mapped to the profile plane's own axes

One addition per coordinate. A bulge is carried over unchanged.

  • family-model.js
  • lines 608 to 619
  • BimCraft 0.8.1
/** An evaluated solid moved by (dx, dy, dz) in the family's axes (a copy, under a new id). */
function shifted(e, id, dx, dy, dz) {
  const c = { ...e, id };
  if (e.op === 'box') { const b = e.box; c.box = [b[0] + dx, b[1] + dy, b[2] + dz, b[3] + dx, b[4] + dy, b[5] + dz]; return c; }
  if (e.op === 'sweep') { c.path = e.path.map((p) => [p[0] + dx, p[1] + dy, p[2] + dz]); return c; }
  // extrusions and revolves: the plane's own axes (xy: u = x, v = y; xz: u = x, v = z; yz: u = y, v = z) and what is normal to it
  const [du, dv, dn] = e.plane === 'xy' ? [dx, dy, dz] : e.plane === 'xz' ? [dx, dz, dy] : [dy, dz, dx];
  c.at = e.at + dn;
  c.profile = e.profile.map((p) => [p[0] + du, p[1] + dv, p[2]]);
  if (e.op === 'revolve') c.axis = [e.axis[0] + du, e.axis[1] + dv, e.axis[2] + du, e.axis[3] + dv];
  return c;
}

Area of a profile

64-bit

The area of a profile with its arcs, used to refuse a flat outline.

area = ½ Σ (xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ) with arcs flattened to chords

Arcs are flattened to chords of at most 15° first, so the area is that of the chord polygon. The code's comment says it is enough to tell a real outline from a flat one: the result is only compared with 1 mm².

  • family-model.js
  • lines 621 to 628
  • BimCraft 0.8.1
/** A profile's area with its arcs (each arc flattened to chords; enough to tell a real outline from a flat one). */
function polyArea(pts) {
  const flat = [];
  pts.forEach((p, i) => { flat.push(p); if (p[2]) flat.push(...arcPoints(p, pts[(i + 1) % pts.length], p[2])); });
  let a = 0;
  for (let i = 0; i < flat.length; i++) { const p = flat[i], q = flat[(i + 1) % flat.length]; a += p[0] * q[1] - q[0] * p[1]; }
  return a / 2;
}

Is the profile on one side of the axis

64-bit

Whether a revolve's profile stays on one side of its axis.

s = ((axis direction) × (p − axis start)) ÷ |axis|; sides counted beyond ±0.01 mm

The signed distance of each profile point from the axis line, divided by the axis length. An axis shorter than 1e-6 mm is refused, which guards the division. Points within 0.01 mm of the axis count as on it.

  • family-model.js
  • lines 629 to 636
  • BimCraft 0.8.1
/** Every profile point on one side of the revolve axis (or on it). */
function oneSide(pts, ax) {
  const dx = ax[2] - ax[0], dy = ax[3] - ax[1], L = Math.hypot(dx, dy);
  if (L < 1e-6) return false;
  let pos = 0, negs = 0;
  for (const p of pts) { const s = (dx * (p[1] - ax[1]) - dy * (p[0] - ax[0])) / L; if (s > 0.01) pos++; else if (s < -0.01) negs++; }
  return !(pos && negs) && (pos || negs);
}

Middle point of a profile arc

64-bit

The point halfway along an arc given by its two ends and a bulge.

sagitta = bulge × chord ÷ 2; middle = chord middle + sagitta × (dy, −dx) ÷ chord

Closed form from the bulge, with no trigonometry. The division by the chord length is not guarded in this function: two points at the same place give 0 ÷ 0.

  • family-model.js
  • lines 672 to 676
  • BimCraft 0.8.1
/** The arc's middle point: the chord's middle pushed out by the sagitta (bulge = tan(θ / 4), + = counter-clockwise). */
export function arcMid(a, b, bulge) {
  const dx = b[0] - a[0], dy = b[1] - a[1], c = Math.hypot(dx, dy), s = bulge * c / 2;
  return [(a[0] + b[0]) / 2 + dy / c * s, (a[1] + b[1]) / 2 - dx / c * s];
}

Chords of a profile arc

64-bit

The points inside an arc, for outlines that cannot hold a true arc.

θ = 4·atan(bulge); n = max(2, ⌈|θ| ÷ 15°⌉); center = circle through start, middle and end

The center is found from the three points by the circumcircle formula, whose terms are squares of the coordinates as given; far from the family's origin they are large and nearly cancel. A divisor below 1e-9 in absolute value returns no points. The chords span at most 15°.

  • family-model.js
  • lines 677 to 691
  • BimCraft 0.8.1
/** Points strictly inside an arc from a to b (chords for a polyline). */
export function arcPoints(a, b, bulge) {
  const th = 4 * Math.atan(bulge), n = Math.max(2, Math.ceil(Math.abs(th) / (Math.PI / 12)));
  const m = arcMid(a, b, bulge);
  // the circle through a, m, b
  const ax = a[0], ay = a[1], bx = m[0], by = m[1], cx = b[0], cy = b[1];
  const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
  if (Math.abs(d) < 1e-9) return [];
  const ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;
  const uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;
  const r = Math.hypot(ax - ux, ay - uy), a0 = Math.atan2(ay - uy, ax - ux);
  const out = [];
  for (let i = 1; i < n; i++) { const t = a0 + th * i / n; out.push([ux + r * Math.cos(t), uy + r * Math.sin(t)]); }
  return out;
}

Profile written to the file

64-bit

A closed profile as an IFC curve: lines and true arcs in IFC4, a polyline in IFC2X3.

In IFC4 an arc is written as three points (start, middle, end), each rounded to 6 decimals of the file's unit, so the arc stays an arc. In IFC2X3 the arcs are flattened to chords of at most 15° and the solid is that of the chord outline.

  • family-model.js
  • lines 645 to 671
  • BimCraft 0.8.1
/** A closed profile (mm; bulges = arcs): IFC4 IfcIndexedPolyCurve with IfcArcIndex segments, else an IfcPolyline. */
function profileCurve(w, pts, flipV) {
  const P = pts.map((p) => [p[0], flipV ? -p[1] : p[1], flipV ? -(p[2] || 0) : (p[2] || 0)]);
  const arcs = P.some((p) => p[2]);
  if (arcs && w.v4) {
    const list = [], segs = [];
    const add = (x, y) => { list.push([round(w.u(x)), round(w.u(y))]); return list.length; };
    const first = add(P[0][0], P[0][1]);
    for (let i = 0; i < P.length; i++) {
      const a = P[i], b = P[(i + 1) % P.length], i0 = i === 0 ? first : list.length;
      if (!a[2]) { const i1 = (i + 1) % P.length === 0 ? first : add(b[0], b[1]); segs.push(w.T('IFCLINEINDEX', [i0, i1])); continue; }
      const m = arcMid(a, b, a[2]);
      const im = add(m[0], m[1]), i1 = (i + 1) % P.length === 0 ? first : add(b[0], b[1]);
      segs.push(w.T('IFCARCINDEX', [i0, im, i1]));
    }
    const pl = w.make('IFCCARTESIANPOINTLIST2D', list);
    return w.make('IFCINDEXEDPOLYCURVE', w.H(pl), segs, false);
  }
  // straight pieces; arcs as chords (IFC2X3 has no arc segments in a profile's polyline), 24 to a full turn
  const flat = [];
  for (let i = 0; i < P.length; i++) {
    const a = P[i], b = P[(i + 1) % P.length];
    flat.push([a[0], a[1]]);
    if (a[2]) for (const q of arcPoints(a, b, a[2])) flat.push(q);
  }
  return w.polyline2(flat, true);
}

A solid written to the file

64-bit

One box, extrusion, revolve or swept solid as its IFC item.

revolve axis direction = (u₁ − u₀, v₁ − v₀) ÷ L; angle = degrees × π ÷ 180 (file in radians)

The revolve axis direction is rounded to 12 decimals, the angle to 9 decimals and lengths to 6 decimals of the file's unit. The division by the axis length is guarded earlier by the one-side check, which refuses an axis shorter than 1e-6 mm for a visible solid.

  • family-model.js
  • lines 701 to 718
  • BimCraft 0.8.1
function solidItem(w, s) {
  if (s.op === 'box') return w.box(...s.box);
  if (s.op === 'extrude') {
    const { frame, flipV } = planeFrame(w, s.plane, s.at);
    const prof = w.make('IFCARBITRARYCLOSEDPROFILEDEF', 'AREA', null, w.H(profileCurve(w, s.profile, flipV)));
    return w.extrudeAt(prof, frame, s.depth);
  }
  if (s.op === 'revolve') {
    const { frame, flipV } = planeFrame(w, s.plane, s.at);
    const prof = w.make('IFCARBITRARYCLOSEDPROFILEDEF', 'AREA', null, w.H(profileCurve(w, s.profile, flipV)));
    const [u0, v0, u1, v1] = s.axis, f = flipV ? -1 : 1, L = Math.hypot(u1 - u0, v1 - v0);
    const axis = w.make('IFCAXIS1PLACEMENT', w.H(w.point3(round(w.u(u0)), round(w.u(f * v0)), 0)), w.H(w.dir(round((u1 - u0) / L, 12), round(f * (v1 - v0) / L, 12), 0)));
    return w.make('IFCREVOLVEDAREASOLID', w.H(prof), w.H(frame), w.H(axis), round(s.angle * Math.PI / 180 * angleK(w), 9));
  }
  const line = w.polyline3(s.path);
  return w.v4 ? w.make('IFCSWEPTDISKSOLID', w.H(line), round(w.u(s.radius)), null, null, null)
    : w.make('IFCSWEPTDISKSOLID', w.H(line), round(w.u(s.radius)), null, 0, s.path.length - 1);
}

Plan outline written to the file

64-bit

The outline other programs see in plan: the family's symbol, or each solid's outline from above.

A box and a horizontal extrusion give their true outline, with arcs as chords of at most 15°. Every other solid gives the rectangle of its plan bounds, which for a revolve or a sweep is a rectangle round it, not its outline.

  • family-model.js
  • lines 960 to 980
  • BimCraft 0.8.1
function footprint(w, ev, fam) {
  if (fam?.symbol?.parts.length && ev.symbol?.length) {   // the family's own symbol is what other programs see in plan too
    const curves = symbolCurves(ev.symbol);
    if (curves.length) return w.make('IFCGEOMETRICCURVESET', curves.map((c) => w.H(w.polyline2(c.pts, c.closed))));
  }
  const loops = [];
  for (const s of ev.solids.values()) {
    if (!s.visible) continue;
    if (s.op === 'box') { const [x0, y0, , x1, y1] = s.box; loops.push([[x0, y0], [x1, y0], [x1, y1], [x0, y1]]); }
    else if (s.op === 'extrude' && s.plane === 'xy') {
      const flat = [];
      s.profile.forEach((a, i) => { flat.push([a[0], a[1]]); if (a[2]) flat.push(...arcPoints(a, s.profile[(i + 1) % s.profile.length], a[2])); });
      loops.push(flat);
    } else {
      const b = planBounds(s);
      if (b) loops.push([[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]);
    }
  }
  if (!loops.length) return null;
  return w.make('IFCGEOMETRICCURVESET', loops.map((l) => w.H(w.polyline2(l, true))));
}

Plan bounds of a solid

64-bit

The smallest plan rectangle round a solid.

revolve: R = largest distance of a profile point from the axis; bounds = axis start ± R, joined with the profile

Minimum and maximum by exact comparison. An extrusion's bounds use its profile points only, so an arc that bulges outward lies outside them. A revolve's bounds are the axis start plus and minus the largest distance of a profile point from the axis, joined with the profile points; the code's comment calls this conservative.

  • family-model.js
  • lines 981 to 997
  • BimCraft 0.8.1
/** A solid's plan bounds [x0, y0, x1, y1] (conservative for revolves: the profile swept a whole turn). */
export function planBounds(s) {
  if (s.op === 'box') return [s.box[0], s.box[1], s.box[3], s.box[4]];
  if (s.op === 'sweep') { const xs = s.path.map((p) => p[0]), ys = s.path.map((p) => p[1]); return [Math.min(...xs) - s.radius, Math.min(...ys) - s.radius, Math.max(...xs) + s.radius, Math.max(...ys) + s.radius]; }
  const P = s.profile.map((p) => planeTo3(s.plane, s.at, p[0], p[1]));
  if (s.op === 'extrude') {
    const d = s.plane === 'xy' ? [0, 0, s.depth] : s.plane === 'xz' ? [0, s.depth, 0] : [s.depth, 0, 0];
    const all = [...P, ...P.map((p) => [p[0] + d[0], p[1] + d[1], p[2] + d[2]])];
    return [Math.min(...all.map((p) => p[0])), Math.min(...all.map((p) => p[1])), Math.max(...all.map((p) => p[0])), Math.max(...all.map((p) => p[1]))];
  }
  const A = planeTo3(s.plane, s.at, s.axis[0], s.axis[1]), B = planeTo3(s.plane, s.at, s.axis[2], s.axis[3]);
  const L = Math.hypot(B[0] - A[0], B[1] - A[1], B[2] - A[2]) || 1, d = [(B[0] - A[0]) / L, (B[1] - A[1]) / L, (B[2] - A[2]) / L];
  let R = 0;
  for (const p of P) { const v = [p[0] - A[0], p[1] - A[1], p[2] - A[2]], k = v[0] * d[0] + v[1] * d[1] + v[2] * d[2]; R = Math.max(R, Math.hypot(v[0] - k * d[0], v[1] - k * d[1], v[2] - k * d[2])); }
  const xs = P.map((p) => p[0]), ys = P.map((p) => p[1]);
  return [Math.min(A[0] - R, ...xs), Math.min(A[1] - R, ...ys), Math.max(A[0] + R, ...xs), Math.max(A[1] + R, ...ys)];
}
family-symbol.js BimCraft 0.8.1 64-bit 3 calculations Reviewed

Tests tools/bimcraft-test/families-more-test.mjs

A plan symbol's numbers

64-bit

The evaluated lines, rectangles, circles, arcs, outlines and texts of a family's 2D symbol.

Each field is an expression evaluated in 64-bit and kept unrounded. A parameter printed inside a text is rounded to 3 decimals for the text only. Array copies are moved by the step times the index.

  • family-symbol.js
  • lines 102 to 134
  • BimCraft 0.8.1
/** The visible parts with their numbers worked out. `env` = the numbers, `texts` = the words (text, material and fill parameters). Throws ExprError with `where`. */
export function evalSymbol(sym, env, texts = {}, { rank = 2 } = {}) {
  const out = [];
  const num = (src, where) => {
    const p = parse(src);
    if (!p.ok) throw Object.assign(new ExprError(p.error), { where });
    try { return evaluate(p.ast, env); } catch (e) { if (e instanceof ExprError) e.where = where; throw e; }
  };
  const fillOf = (f) => (f === undefined ? 'none' : FILL_KEYS.includes(f) ? f : (FILL_KEYS.includes(texts[f]) ? texts[f] : 'none'));
  for (const p of sym?.parts || []) {
    const where = 'symbol ' + p.id;
    if (p.detail && detailRank(p.detail) > rank) continue;   // finer than asked for
    if (p.visible !== undefined && num(p.visible, where + ' visible') === 0) continue;
    const e = { id: p.id, kind: p.kind, pen: p.pen !== undefined ? clampPen(num(p.pen, where + ' pen')) : 3, fill: fillOf(p.fill) };
    if (p.zmin !== undefined) e.zmin = num(p.zmin, where + ' zmin');
    if (p.zmax !== undefined) e.zmax = num(p.zmax, where + ' zmax');
    for (const k of SYMBOL_FIELDS[p.kind] || []) e[k] = num(p[k], where + ' ' + k);
    if (p.kind === 'poly') { e.pts = p.pts.map((pt, i) => [num(pt[0], where + ' point ' + (i + 1)), num(pt[1], where + ' point ' + (i + 1))]); e.closed = p.closed; }
    if (p.kind === 'text') {
      e.rot = p.rot !== undefined ? num(p.rot, where + ' rot') : 0;
      e.text = p.text.replace(NAME_PLACEHOLDER, (m, name) => (name in texts ? texts[name] : name in env ? String(Math.round(env[name] * 1000) / 1000) : m));
    }
    if (p.array) {
      const n = Math.round(num(p.array.count, where + ' array-count'));
      if (!(n >= 1 && n <= SYMBOL_LIMITS.parts)) throw Object.assign(new ExprError('array-count'), { where: where + ' array-count' });
      const dx = num(p.array.dx, where + ' array-dx'), dy = num(p.array.dy, where + ' array-dy');
      for (let i = 0; i < n; i++) out.push(i === 0 ? e : moved(e, dx * i, dy * i));
      continue;
    }
    out.push(e);
  }
  return out;
}

Circles and arcs of a symbol

64-bit

A symbol's circle or arc as points.

n = max(2, ⌈sweep ÷ 7.5°⌉); pᵢ = (cx + r·cos aᵢ, cy + r·sin aᵢ)

Chords of at most 7.5°. This is also what the file's FootPrint keeps for a family with a symbol, so other programs see the chords, not the arc. Angles are degrees × π ÷ 180 with Math.PI.

  • family-symbol.js
  • lines 143 to 149
  • BimCraft 0.8.1
const arcPts = (cx, cy, r, a0, a1, step = 7.5) => {
  let sweep = a1 - a0;
  while (sweep <= 0) sweep += 360;
  const n = Math.max(2, Math.ceil(sweep / step)), pts = [];
  for (let i = 0; i <= n; i++) { const a = (a0 + sweep * i / n) * Math.PI / 180; pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]); }
  return pts;
};

A symbol placed in the plan

64-bit

The symbol turned by the element's rotation and moved to its place.

(u, v) → (x + u·cos a − v·sin a, y + u·sin a + v·cos a), a = rotation × π ÷ 180

One rotation and one translation per point in 64-bit. A rotation of 90° gives a cosine of about 6e-17 instead of 0, so a point 1000 mm out is off by about 6e-14 mm. The pen's line weight is 0.5 + 0.2 × pen, rounded to 2 decimals.

  • family-symbol.js
  • lines 182 to 213
  • BimCraft 0.8.1
/**
 * The plan's scene items for one placed element (model-planview.js scene.symbols): the symbol turned by `rot` degrees and put at (x, y), one
 * item per pen so each draws at its own weight; cut parts as lines, below as thin, overhead as dashed; a solid fill as `solid`, other fills as
 * hatch strokes (`hatch(ring, pattern)` → [x1, y1, x2, y2, …], from drafting.js); texts; the first item also carries `poly` (the symbol's
 * extent) so a click on the symbol picks the element under `key`. → [] when nothing shows.
 */
export function symbolScene(parts, { x = 0, y = 0, rot = 0 } = {}, cutZ, key, hatch = () => []) {
  const a = rot * Math.PI / 180, c = Math.cos(a), s = Math.sin(a);
  const T = ([u, v]) => [x + u * c - v * s, y + u * s + v * c];
  const byPen = new Map();
  const slot = (pen) => { if (!byPen.has(pen)) byPen.set(pen, { key, lines: [], thin: [], dashed: [], solid: [], texts: [], lw: Math.round((0.5 + pen * 0.2) * 100) / 100 }); return byPen.get(pen); };
  const all = [];
  for (const it of symbolGeometry(parts, cutZ)) {
    const S = slot(it.pen);
    if (it.kind === 'text') { const p = T(it.at); S.texts.push({ p, h: it.h, text: it.text, rot: rot + it.rot }); all.push(p); continue; }
    const pts = it.pts.map(T);
    all.push(...pts);
    const dest = it.rule === 'cut' ? S.lines : it.rule === 'below' ? S.thin : S.dashed;
    const n = it.kind === 'ring' ? pts.length : pts.length - 1;
    for (let i = 0; i < n; i++) { const p = pts[i], q = pts[(i + 1) % pts.length]; dest.push([p[0], p[1], q[0], q[1]]); }
    if (it.kind === 'ring' && it.fill !== 'none' && it.rule !== 'above') {
      if (it.fill === 'solid') S.solid.push(pts);
      else { const h = hatch(pts, it.fill); for (let i = 0; i + 3 < h.length; i += 4) S.thin.push([h[i], h[i + 1], h[i + 2], h[i + 3]]); }
    }
  }
  const out = [...byPen.values()];
  if (out.length && all.length) {
    const xs = all.map((p) => p[0]), ys = all.map((p) => p[1]), x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
    out[0].poly = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
  }
  return out;
}
family-ops.js BimCraft 0.8.1 64-bit 2 calculations Reviewed

Tests tools/bimcraft-test/families-test.mjs

Where a placed family stands

64-bit

A placed element's position and rotation in its level's plan.

x = M₁₂, y = M₁₃, z = M₁₄; rotation = atan2(M₁, M₀) × 180 ÷ π

Read from the element's placement relative to its level, as a 4 × 4 matrix. The rotation goes through atan2 and a conversion to degrees, so an element placed at 90° can read back as a value one rounding step away from 90.

  • family-ops.js
  • lines 127 to 145
  • BimCraft 0.8.1
/** Placed unhosted family elements, in their storey's plan (mm, degrees). */
export function readFamilyElements(w) {
  const idx = familyIndex(w), out = [];
  const so = w.storeyOf(), stl = new Map();   // one pass over the containment relations (0.7: a scan per element was quadratic: 2,000 elements took 1.6 s)
  for (const [tid, s] of idx.sizes) {
    const g = idx.groups.get(s.family);
    if (!g?.fam || g.fam.host === 'wall') continue;
    for (const id of idx.occ.get(tid) || []) {
      const l = w.api.GetLine(w.mid, id, false);
      if (!l?.ObjectPlacement) continue;
      const storey = so.get(id) ?? null;
      if (storey && !stl.has(storey)) stl.set(storey, w.api.GetLine(w.mid, storey, false));
      const st = storey ? stl.get(storey) : null;
      const M = st?.ObjectPlacement ? w.relMatrix(st.ObjectPlacement.value, l.ObjectPlacement.value) : null;
      out.push({ id, guid: val(l.GlobalId), family: s.family, typeId: tid, size: s.name, values: s.values, instance: readInstance(w, id, g.fam), storey, x: M ? M[12] : 0, y: M ? M[13] : 0, z: M ? M[14] : 0, rot: M ? Math.atan2(M[1], M[0]) * 180 / Math.PI : 0 });
    }
  }
  return out;
}

Connector written as a port

64-bit

A family's pipe or duct connector written on a placed element: its point, direction and nominal size.

The point and direction are the evaluated values; the direction is one of the six axis directions, so it is exact. The nominal size is rounded to 6 decimals of the file's length unit when written.

  • family-ops.js
  • lines 690 to 698
  • BimCraft 0.8.1
const across = Math.abs(c.dir[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1];
const plp = w.make('IFCLOCALPLACEMENT', w.H(lp), w.H(w.axisFrame(c.p, c.dir, across)));
const name = w.L(c.name || tr('fam.conn.' + c.domain));
const flow = c.flow === 'out' ? 'SOURCE' : 'SINK';
const pid = w.v4 ? w.make('IFCDISTRIBUTIONPORT', w.guid(), w.ownerHistory(), name, null, null, w.H(plp), null, flow, c.domain === 'pipe' ? 'PIPE' : 'DUCT', sys?.ifc || 'NOTDEFINED')
  : w.make('IFCDISTRIBUTIONPORT', w.guid(), w.ownerHistory(), name, null, null, w.H(plp), null, flow);
if (!w.v4) w.make('IFCRELCONNECTSPORTTOELEMENT', w.guid(), w.ownerHistory(), null, null, w.H(pid), w.H(id));
w.upsertPset(pid, CONNECTOR_PSET, [['Key', 'IFCIDENTIFIER', c.id], ['Domain', 'IFCLABEL', c.domain], ['System', 'IFCLABEL', c.system],
  ['Size', 'IFCPOSITIVELENGTHMEASURE', round(w.u(c.size))], ['Flow', 'IFCLABEL', c.flow], ...(fam.fixture ? [['Fixture', 'IFCLABEL', fam.fixture]] : [])]);

In detail

Method
Box, extrusion, revolve and sweep, joined or cut by Booleans, written as IfcExtrudedAreaSolid, IfcRevolvedAreaSolid, IfcSweptDiskSolid and IfcBooleanResult. A profile arc is given by a bulge = tan(θ ÷ 4). Formulas are evaluated by expr.js in the order their inputs become known. Nested families are turned in quarter turns by swapping coordinates.
Number types
64-bit numbers in millimeters and degrees.
Tolerances
A solid thinner than 0.01 mm, a profile under 1 mm² and a revolve angle of 0.01° or less are refused. A revolve profile may cross its axis by no more than 0.01 mm. Two sizes are the same size when every input agrees to 0.001. A nested family's rotation must be a multiple of 90° to 1e-6 of a quarter turn. Values beyond ±1e7 are refused.
Limits
IFC2X3 profiles and footprints flatten arcs to chords of at most 15° (24 to a full turn); plan symbols use 7.5°. Plan bounds ignore the arcs of an extrusion's profile and take a revolve as a full turn.

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