Resources Numerical accuracy Calculation policy

MEP equipment, ports and refrigerant line sets

Builds generic mechanical equipment from typed sizes, places its pipe and duct ports, sizes refrigerant line sets from a capacity table and routes them between units.

How the precision is kept

Sizes are 64-bit millimeters, but several values are rounded to 0.001 mm before they are stored: typed lengths, mesh vertices, port positions and the placement written to the property set. Pipe port sizes are the table's outside diameter in inches times the exact 25.4. Line set sizes come from a table of typical values, which the file's header says are not any maker's data.

Calculations under this policy

12 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.

mep/equipment.js BimCraft 0.8.1 64-bit 5 calculations Reviewed

Tests tools/bimcraft-test/mep07-test.mjs, tools/bimcraft-test/hvac-test.mjs

Typed sizes of a piece of equipment

64-bit

Checks the values a person typed and gives the sizes the equipment is built from.

length = round(v, 3), 10 ≤ v ≤ 20000

A length outside 10 to 20000 mm is refused with a message, not replaced. An accepted length is rounded to 3 decimals of a millimeter before anything is built from it.

  • mep/equipment.js
  • lines 205 to 218
  • BimCraft 0.8.1
export function equipmentValues(kind, values = {}) {
  const def = EQUIPMENT[kind];
  if (!def) refuse('mep.eq.err.kind');
  const out = {};
  for (const [k, type, d] of def.fields) {
    let v = values[k] ?? d;
    if (type === 'len') { v = Number(v); if (!(v >= 10 && v <= 20000)) refuse('mep.eq.err.value', { what: k }); v = round(v, 3); }
    else if (type === 'nps') { v = String(v); if (!NPS.includes(v)) refuse('mep.eq.err.value', { what: k }); }
    else if (type.startsWith('sys:')) { v = String(v); if (!(type === 'sys:pipe' ? PIPE_SYSTEMS : DUCT_SYSTEMS)[v]) refuse('mep.eq.err.value', { what: k }); }
    else if (type.startsWith('choice:')) { v = String(v); if (!type.slice(7).split(',').includes(v)) refuse('mep.eq.err.value', { what: k }); }
    out[k] = v;
  }
  return out;
}

Equipment solids as a mesh

64-bit

Turns the boxes and cylinders of a piece of equipment into one triangle mesh.

cylinder ring = o + u r cos a + w r sin a, a = 2π i ÷ 16; each coordinate rounded to 3 decimals

Cylinders have 16 flat sides with corners on the circle. Every vertex coordinate is rounded to 0.001 mm as it is stored. The cylinder's axis length divides without a guard, so a cylinder of zero length would give coordinates that are not numbers.

  • mep/equipment.js
  • lines 226 to 244
  • BimCraft 0.8.1
/** Closed solids → one mesh { positions, indices } (outward faces). */
function meshOf(solids) {
  const P = [], I = [];
  const v = (q) => { P.push(round(q[0], 3), round(q[1], 3), round(q[2], 3)); return P.length / 3 - 1; };
  for (const s of solids) {
    if (s.t === 'box') {
      const [x0, y0, z0] = s.a, [x1, y1, z1] = s.b;
      const c = [[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], [x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1]].map(v);
      for (const [a, b, cc, d] of [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]]) I.push(c[a], c[b], c[cc], c[a], c[cc], c[d]);
    } else {
      const ax = sub(s.p1, s.p0), L = len(ax), d = ax.map((x) => x / L);
      const u = norm(Math.abs(d[2]) < 0.9 ? cross([0, 0, 1], d) : cross([1, 0, 0], d)), w = cross(d, u);
      const ring = (o) => Array.from({ length: CYL_N }, (_, i) => { const a = 2 * Math.PI * i / CYL_N; return v(add(o, add(mul(u, s.r * Math.cos(a)), mul(w, s.r * Math.sin(a))))); });
      const A = ring(s.p0), B = ring(s.p1), ca = v(s.p0), cb = v(s.p1);
      for (let i = 0; i < CYL_N; i++) { const j = (i + 1) % CYL_N; I.push(A[i], A[j], B[j], A[i], B[j], B[i], ca, A[j], A[i], cb, B[i], B[j]); }
    }
  }
  return { type: 'mesh', positions: P, indices: I };
}

Port positions of placed equipment

64-bit

Where each port of a placed piece sits on its level and the way it faces.

at = (x, y, z) + R(rot) · local; R turns about Z by rot × π ÷ 180

The local port point and axis are rotated with one cosine and sine and added to the placement in 64-bit. For a turn of 90 degrees the cosine is about 6e-17 instead of 0, so an axis that should be exactly along X or Y differs in its last digits.

  • mep/equipment.js
  • lines 282 to 288
  • BimCraft 0.8.1
/** Where each port of a placed piece sits, in its storey's plan mm, and the way it faces. */
export function portsAt(eq) {
  const { built } = buildEquipment(eq.kind, eq.values);
  const a = (eq.rot || 0) * D2R, c = Math.cos(a), s = Math.sin(a);
  const R = (q) => [q[0] * c - q[1] * s, q[0] * s + q[1] * c, q[2]];
  return built.ports.map((p) => { const q = R(p.at); return { ...p, at: [eq.x + q[0], eq.y + q[1], (eq.z || 0) + q[2]], axis: R(p.axis), local: p }; });
}

Port sizes written to the model

64-bit

The width and height stored on each port.

stored = round(size in the file's unit)

The size in millimeters goes through the writer's unit conversion and its rounding function with the default number of decimals. The conversion and the rounding are in model-write.js, which this review did not cover.

  • mep/equipment.js
  • line 337
  • BimCraft 0.8.1
const Lm = (k, v) => [k, 'IFCLENGTHMEASURE', round(w.u(v || 0))];

Placement stored with the equipment

64-bit

The position and turn kept in the equipment's property set, from which it is read back and rebuilt.

X, Y, Z = round(value, 3); Rotation = round(degrees, 6)

The position is rounded to 0.001 mm and the rotation to 0.000001 degree before they are stored. The equipment is rebuilt from these stored values, so they are what defines its place after the file is reopened.

  • mep/equipment.js
  • lines 383 to 388
  • BimCraft 0.8.1
function writeSets(w, id, eq, item) {
  w.upsertPset(id, EQ_PSET, [['Kind', 'IFCLABEL', eq.kind], ['Values', 'IFCTEXT', JSON.stringify(eq.values)], ['X', 'IFCLENGTHMEASURE', round(eq.x, 3)], ['Y', 'IFCLENGTHMEASURE', round(eq.y, 3)],
    ['Z', 'IFCLENGTHMEASURE', round(eq.z || 0, 3)], ['Rotation', 'IFCPLANEANGLEMEASURE', round(eq.rot || 0, 6)], ['Tag', 'IFCLABEL', eq.tag || ''], ['Generic', 'IFCBOOLEAN', true]]);
  w.upsertPset(id, PSETS.library, w.libraryProps(item, { library: 'cadcraft-generic' }));
  w.upsertPset(id, PSETS.equipment, w.equipmentProps(item));
}
mep/hvac.js BimCraft 0.8.1 64-bit 6 calculations Reviewed

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

Line set sizes for a capacity

Exact

The liquid and gas tube sizes for the cooling capacity a refrigerant line carries.

row = first with capacity ≤ upTo + 10⁻⁹; beyond the table: the last row

A lookup in a table of typical values; nothing is interpolated. The slack of 1e-9 kW keeps a capacity equal to a row's limit in that row. A capacity above every row silently takes the largest sizes.

  • mep/hvac.js
  • lines 76 to 80
  • BimCraft 0.8.1
export function lineSetFor(kw, rows = TABLE) {
  const k = Number(kw);
  const r = rows.find((x) => k <= x.upTo + 1e-9) || rows[rows.length - 1];
  return { liquid: r.liquid, gas: r.gas, upTo: r.upTo };
}

A line set table someone typed

64-bit

Checks a custom table of capacities and tube sizes before it is used and saved.

upTo = round(upTo × 100) ÷ 100, 0 < upTo ≤ 2000, rising

Each capacity limit is rounded to 0.01 kW before it is kept. The order test is made on the unrounded value against the previous rounded one.

  • mep/hvac.js
  • lines 55 to 67
  • BimCraft 0.8.1
/** A table someone typed: rows with a capacity and two tube sizes, capacities rising. → { rows } or { error: key, row }. */
export function cleanLineSetTable(rows) {
  if (!Array.isArray(rows) || !rows.length || rows.length > 40) return { error: 'sx.su888.table.err.rows' };
  const out = [];
  for (let i = 0; i < rows.length; i++) {
    const r = rows[i] || {}, upTo = Number(r.upTo);
    if (!(upTo > 0 && upTo <= 2000)) return { error: 'sx.su888.table.err.kw', row: i + 1 };
    if (out.length && !(upTo > out[out.length - 1].upTo)) return { error: 'sx.su888.table.err.order', row: i + 1 };
    if (!ACR[r.liquid] || !ACR[r.gas]) return { error: 'sx.su888.table.err.size', row: i + 1 };
    out.push({ upTo: Math.round(upTo * 100) / 100, liquid: String(r.liquid), gas: String(r.gas) });
  }
  return { rows: out };
}

Refrigerant tube diameter

64-bit

The outside diameter in millimeters of a refrigerant tube from its inch size.

od = table inches × 25.4

One multiplication by the exact 25.4; the inch value comes from the tube table in mep/tables-acr.js.

  • mep/hvac.js
  • line 101
  • BimCraft 0.8.1
const acrOd = (name) => ACR[name][0] * IN;

Route of a line between two ports

64-bit

An orthogonal path from one port to another: straight out of each, up to a common height, then across.

points rounded to 3 decimals; common height = max of the two leads + lift + 0, 300 or 600

The first candidate that passes the soundness test is used, and each coordinate of its points is rounded to 0.001 mm. These points become the stored route of the run.

  • mep/hvac.js
  • lines 284 to 295
  • BimCraft 0.8.1
function routeOut(a, axA, b, axB, lift, dA, dB) {
  const A1 = a.map((v, i) => v + axA[i] * dA), B1 = b.map((v, i) => v + axB[i] * dB);
  for (const extra of [0, 300, 600]) {
    const zc = Math.max(A1[2], B1[2]) + lift + extra;
    for (const xFirst of [true, false]) {
      const mid = xFirst ? [B1[0], A1[1], zc] : [A1[0], B1[1], zc];
      const pts = tidy([a, A1, [A1[0], A1[1], zc], mid, [B1[0], B1[1], zc], B1, b]);
      if (sound(pts)) return pts.map((p) => p.map((v) => Math.round(v * 1000) / 1000));
    }
  }
  return null;
}

Cleaning a route

64-bit

Drops repeated points and corners that go straight through.

merge if |p − q| ≤ 0.5; straight if |d₁ · d₂ − |d₁||d₂|| < 10⁻⁶ |d₁||d₂|

The merge distance of 0.5 mm is absolute. The straightness test is scaled by the two leg lengths, so it is a test on the angle: about 0.08 degree.

  • mep/hvac.js
  • lines 249 to 258
  • BimCraft 0.8.1
/** Clean a path: same points and straight-through corners dropped. */
function tidy(pts) {
  const out = [];
  for (const p of pts) if (!out.length || len(sub(p, out[out.length - 1])) > 0.5) out.push(p);
  for (let i = out.length - 2; i >= 1; i--) {
    const d1 = sub(out[i], out[i - 1]), d2 = sub(out[i + 1], out[i]);
    if (Math.abs(dot(d1, d2) - len(d1) * len(d2)) < 1e-6 * len(d1) * len(d2)) out.splice(i, 1);
  }
  return out;
}

Soundness of a route

64-bit

Rejects a route that doubles back, has an inner leg shorter than 100 mm, or has a leg that is not along one axis.

A leg counts as along an axis when at most one of its components exceeds 0.5 mm, and as doubling back when the cosine with the previous leg is below −0.99. The limits are fixed numbers in millimeters.

  • mep/hvac.js
  • lines 259 to 269
  • BimCraft 0.8.1
/** A path that turns back on itself, or has an inner leg too short for two elbows, is no good. */
function sound(pts) {
  for (let i = 1; i < pts.length; i++) {
    const d = sub(pts[i], pts[i - 1]);
    if (i > 1 && i < pts.length - 1 && len(d) < MIN_LEG) return false;
    if (i > 1) { const e = sub(pts[i - 1], pts[i - 2]); if (dot(d, e) < -0.99 * len(d) * len(e)) return false; }
    // only X, Y, Z legs (ports face along an axis)
    if ([Math.abs(d[0]) > 0.5, Math.abs(d[1]) > 0.5, Math.abs(d[2]) > 0.5].filter(Boolean).length > 1) return false;
  }
  return pts.length >= 2;
}
mep/ports.js BimCraft 0.8.1 64-bit 1 calculation Reviewed

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

Position and direction of a port read from the model

64-bit

A port's origin and the way it faces, in its level's axes in millimeters.

at = round(M₁₂…₁₄ × 1000) ÷ 1000; out = round(z ÷ |z| × 10⁹) ÷ 10⁹

The position is rounded to 0.001 mm and each component of the unit direction to 9 decimals as it is read. These rounded values are what the connection check, runs that snap to ports and the add-on API receive.

  • mep/ports.js
  • lines 110 to 120
  • BimCraft 0.8.1
/** The port's origin and its Z axis in the storey's axes (mm), from its placement chain. */
function placeOf(w, p, storeyLp) {
  const lp = p.ObjectPlacement?.value;
  if (!lp) return { at: null, out: null };
  try {
    const M = storeyLp ? w.relMatrix(storeyLp, lp) : scaled(w.api.GetWorldTransformMatrix(w.mid, lp), w.unitMm);
    const r = (x) => Math.round(x * 1000) / 1000;
    const L = Math.hypot(M[8], M[9], M[10]) || 1;
    return { at: [r(M[12]), r(M[13]), r(M[14])], out: [M[8] / L, M[9] / L, M[10] / L].map((x) => Math.round(x * 1e9) / 1e9) };
  } catch (e) { return { at: null, out: null }; }
}

In detail

Method
Boxes and 16-sided cylinders; ports turned with the equipment about Z; table lookup by cooling capacity; orthogonal routes out of each port, up to a common height and across.
Number types
64-bit numbers.
Tolerances
A typed length must be from 10 to 20000 mm. Route points within 0.5 mm are merged, and a corner is straight when the cosine of its turn is within 1e-6 of 1. A capacity matches a table row with a slack of 1e-9 kW.
Limits
A 16-sided cylinder has 0.9745 of the cross-section area of its circle. A capacity above the table's last row takes the last row's sizes.

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