Resources Numerical accuracy Calculation policy

Wall faces and plan frames from element meshes

Finds the faces of walls in plan so a placed item can sit against one, and the turned box of copied elements that the paste ghost and wall snap use.

How the precision is kept

Wall faces and the box of copied elements are measured from each element's mesh as the IFC engine hands it over. The engine's vertex array is a Float32Array (ifc/geo-mesh.js interleaved) that holds each shape before its placement is applied; the placement, the conversion to millimeters and all later arithmetic are 64-bit. A point snapped to a wall face, and the paste anchor, therefore start from 32-bit vertex values and end in a placement that is saved.

Open item. Read wall and element geometry for snapping from 64-bit coordinates instead of the engine's 32-bit vertex array.

Calculations under this policy

5 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-place.js BimCraft 0.8.1 32-bit in part 5 calculations Reviewed

Tests bin/test-bimcraft.mjs

Wall faces in plan

32-bit in part

The two long faces of each wall as plan segments in model millimeters, with a normal pointing away from the wall.

Each wall's triangles come from productTriangles, which reads the engine's 32-bit vertex array and places it in 64-bit; the points are then multiplied by the millimeters per file unit. The normal is the edge turned 90° and divided by its length, flipped to point away from the rectangle's center.

  • ifc-place.js
  • lines 611 to 640
  • BimCraft 0.8.1
/** Faces of the walls on a storey (or every wall), as plan segments in model mm. */
wallFaces(storey = null) {
  const so = this.storeyOf(), out = [];
  const C = this.api.GetCoordinationMatrix(this.mid);
  const Ci = inv4(C);
  let walls = [];
  try { walls = ids(this.api, this.mid, 'IFCWALL', true); } catch (e) { walls = []; }
  if (!walls.length) walls = [...ids(this.api, this.mid, 'IFCWALL'), ...ids(this.api, this.mid, 'IFCWALLSTANDARDCASE')];
  for (const w of new Set(walls)) {
    if (storey !== null && so.get(w) !== storey) continue;
    let tri;
    try { tri = productTriangles(this.api, this.mid, w, this.unitMm, Ci); } catch (e) { continue; }
    const pts = [];
    for (let i = 0; i < tri.positions.length; i += 3) pts.push([tri.positions[i] * this.unitMm, tri.positions[i + 1] * this.unitMm]);
    const rect = minAreaRect(pts);
    if (!rect) continue;
    const [p0, p1, p2, p3] = rect, len01 = dist(p0, p1), len12 = dist(p1, p2);
    // The two long sides are the faces; normals point away from the wall's centre line.
    const sides = len01 >= len12 ? [[p0, p1], [p2, p3]] : [[p1, p2], [p3, p0]];
    const cx = (p0[0] + p2[0]) / 2, cy = (p0[1] + p2[1]) / 2;
    for (const [a, b] of sides) {
      const dx = b[0] - a[0], dy = b[1] - a[1], l = Math.hypot(dx, dy);
      if (l < 1) continue;
      let n = [-dy / l, dx / l];
      if ((a[0] - cx) * n[0] + (a[1] - cy) * n[1] < 0) n = [-n[0], -n[1]];
      out.push({ a, b, n, wall: w });
    }
  }
  return out;
}

Smallest rectangle round a wall's plan points

64-bit

The minimum-area bounding rectangle of a set of plan points.

for each hull edge direction u: area = (max u − min u) × (max v − min v); keep the smallest

Points that round to the same whole millimeter are merged first, keeping one of them unrounded. The hull's turn test is a 64-bit cross product compared with zero, with no tolerance, so nearly collinear points are decided by rounding error.

  • ifc-place.js
  • lines 825 to 848
  • BimCraft 0.8.1
/** Minimum-area bounding rectangle of plan points (rotating calipers over the hull edges) → 4 corners. */
function minAreaRect(points) {
  const p = [...new Map(points.map(([x, y]) => [Math.round(x) + ',' + Math.round(y), [x, y]])).values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
  if (p.length < 3) return null;
  const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
  const lo = [], hi = [];
  for (const q of p) { while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], q) <= 0) lo.pop(); lo.push(q); }
  for (const q of p.slice().reverse()) { while (hi.length >= 2 && cross(hi[hi.length - 2], hi[hi.length - 1], q) <= 0) hi.pop(); hi.push(q); }
  const h = lo.slice(0, -1).concat(hi.slice(0, -1));
  if (h.length < 3) return null;
  let best = null;
  for (let i = 0; i < h.length; i++) {
    const a = h[i], b = h[(i + 1) % h.length], l = dist(a, b);
    if (l < 1e-9) continue;
    const ux = (b[0] - a[0]) / l, uy = (b[1] - a[1]) / l;
    let minU = Infinity, maxU = -Infinity, minV = Infinity, maxV = -Infinity;
    for (const q of h) { const u = q[0] * ux + q[1] * uy, v = -q[0] * uy + q[1] * ux; minU = Math.min(minU, u); maxU = Math.max(maxU, u); minV = Math.min(minV, v); maxV = Math.max(maxV, v); }
    const area = (maxU - minU) * (maxV - minV);
    if (!best || area < best.area) best = { area, ux, uy, minU, maxU, minV, maxV };
  }
  const { ux, uy, minU, maxU, minV, maxV } = best;
  const at = (u, v) => [u * ux - v * uy, u * uy + v * ux];
  return [at(minU, minV), at(maxU, minV), at(maxU, maxV), at(minU, maxV)];
}

Snapping an item to the nearest wall face

32-bit in part

The position and rotation that put an item's back against the nearest wall face.

t = clamp((p − a)·(b − a) ÷ |b − a|², 0, 1); q = a + t (b − a); position = q + n × maxY; rot = atan2(−n_y, −n_x) − 90°

The faces come from wallFaces, so the snapped position starts from 32-bit vertex values; the projection itself is 64-bit. The division by the squared face length is not guarded here, and wallFaces drops faces shorter than 1 mm. The rotation is rounded to 0.001°.

  • ifc-place.js
  • lines 709 to 725
  • BimCraft 0.8.1
/** Back of the item against the nearest wall face: rotation so the front (−Y) points along the face normal. */
export function snapToWall(p, item, faces) {
  let best = null;
  for (const f of faces) {
    const dx = f.b[0] - f.a[0], dy = f.b[1] - f.a[1], l2 = dx * dx + dy * dy;
    const t = Math.max(0, Math.min(1, ((p[0] - f.a[0]) * dx + (p[1] - f.a[1]) * dy) / l2));
    const q = [f.a[0] + t * dx, f.a[1] + t * dy], d = dist(p, q);
    if ((p[0] - q[0]) * f.n[0] + (p[1] - q[1]) * f.n[1] < -1) continue;   // behind this face (inside the wall or beyond)
    if (!best || d < best.d) best = { d, q, f };
  }
  if (!best) return null;
  const n = best.f.n;
  const rot = Math.atan2(-n[1], -n[0]) / D2R - 90;
  const b = planBounds(item.plan) || (item.body && item.body.type === 'box' ? { maxY: item.body.y + item.body.d } : { maxY: 0 });
  const maxY = b.maxY || 0;
  return { x: best.q[0] + n[0] * maxY, y: best.q[1] + n[1] * maxY, rot: Math.round(norm(rot) * 1000) / 1000, face: best.f, distance: best.d };
}

Plan frame of copied elements

32-bit in part

The turn of the first copied element and the box of all copied meshes measured in those turned axes.

rot = atan2(W[1], W[0]) in degrees, rounded to 6 decimals; box from orientedBox

The turn is read from the element's 64-bit world matrix and rounded to 0.000001°. The box points come from productTriangles, which reads the engine's 32-bit vertex array. A direction shorter than 1e-6 counts as no turn.

  • ifc-place.js
  • lines 642 to 667
  • BimCraft 0.8.1
/**
 * The plan frame Copy gives an elements clip (bimcraft.js copyElements), so the ghost, wall snap and paste turn with the
 * element and not with the model's axes — a building turned in its site (true north, shared coordinates: Revit exports)
 * has every door and wall at an angle to them. rot = the first top-level element's own X axis in plan (degrees, model
 * axes), else the storey's, else 0; box = its meshes (openings and spaces left out) measured in those axes, round the
 * centre (x, y) in model mm, z from zBase (the storey's elevation). → { rot, x, y, box: { x0, y0, z0, x1, y1, z1 } } | null
 * (no geometry, or more than `maxPoints` vertices: the page then measures the viewer's box, turned 0°).
 */
elementsFrame(snap, { zBase = 0, maxPoints = 2e6 } = {}) {
  const api = this.api, mid = this.mid;
  const turn = (w) => (w && Math.hypot(w[0], w[1]) > 1e-6 ? Math.atan2(w[1], w[0]) / D2R : null);
  const top = snap.elements.find((e) => e.top);
  let rot = turn(top?.world);
  if (rot === null && snap.storey) { const st = api.GetLine(mid, snap.storey, false); rot = st?.ObjectPlacement ? turn(api.GetWorldTransformMatrix(mid, st.ObjectPlacement.value)) : null; }
  rot = Math.round(norm(rot ?? 0) * 1e6) / 1e6;
  const Ci = inv4(api.GetCoordinationMatrix(mid)), pts = [];
  for (const e of snap.elements) {
    if (/^Ifc(OpeningElement|OpeningStandardCase|Space)$/i.test(e.line?.constructor?.name || '')) continue;
    let tri;
    try { tri = productTriangles(api, mid, e.id, this.unitMm, Ci); } catch (err) { continue; }
    if (pts.length + tri.positions.length > maxPoints * 3) return null;
    for (let i = 0; i < tri.positions.length; i++) pts.push(tri.positions[i] * this.unitMm);
  }
  const b = orientedBox(pts, rot);
  return b ? { rot, x: b.x, y: b.y, box: { ...b.box, z0: b.box.z0 - zBase, z1: b.box.z1 - zBase } } : null;
}

Box of points in turned plan axes

64-bit

The bounds of a point list in plan axes turned by an angle, and the box center in model millimeters.

u = x cos r + y sin r; v = −x sin r + y cos r; center = ((u₀ + u₁) ÷ 2, (v₀ + v₁) ÷ 2) turned back

One cosine and sine, then minimum and maximum of the turned coordinates in 64-bit. The box's far sides are raised so that it is at least 1 mm wide and deep.

  • ifc-place.js
  • lines 689 to 707
  • BimCraft 0.8.1
/**
 * Bounds of points [x, y, z, x, y, z, …] (model mm, Z up) in plan axes turned `rotDeg`: → { x, y (the box centre in
 * model mm), box: { x0, y0, z0, x1, y1, z1 } (x/y round that centre in the turned axes, z as given) } | null.
 */
export function orientedBox(points, rotDeg) {
  if (!points || points.length < 3) return null;
  const r = rotDeg * D2R, c = Math.cos(r), s = Math.sin(r);
  let u0 = Infinity, u1 = -Infinity, v0 = Infinity, v1 = -Infinity, z0 = Infinity, z1 = -Infinity;
  for (let i = 0; i + 2 < points.length; i += 3) {
    const x = points[i], y = points[i + 1], z = points[i + 2];
    const u = x * c + y * s, v = -x * s + y * c;
    if (u < u0) u0 = u; if (u > u1) u1 = u; if (v < v0) v0 = v; if (v > v1) v1 = v; if (z < z0) z0 = z; if (z > z1) z1 = z;
  }
  const cu = (u0 + u1) / 2, cv = (v0 + v1) / 2;
  return {
    x: cu * c - cv * s, y: cu * s + cv * c,
    box: { x0: u0 - cu, y0: v0 - cv, z0, x1: Math.max(u1 - cu, u0 - cu + 1), y1: Math.max(v1 - cv, v0 - cv + 1), z1: Math.max(z1, z0) },
  };
}

In detail

Method
Wall: convex hull of its plan points (monotone chain), then the minimum-area rectangle over the hull edges; the two long sides are the faces. Snap: nearest point on a face segment, the item's back moved out along the face normal.
Number types
Engine vertices in Float32Array; the rest in 64-bit numbers.
Tolerances
Plan points merged on a 1 mm grid before the hull. Faces shorter than 1 mm dropped. A point more than 1 mm behind a face ignores that face. Hull edges shorter than 1e-9 mm skipped. Snap rotation rounded to 0.001°, frame rotation to 0.000001°.
Checked by
bin/test-bimcraft.mjs runs wallFaces, snapToWall and orientedBox on a turned building in Node.
Limits
A wall is treated as one rectangle in plan, so a curved or L-shaped wall gives the rectangle round it. The box of copied elements is never thinner than 1 mm.

Approach: 32-bit step, flagged

One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.

Every policy that takes this approach