Resources Numerical accuracy Calculation policy

Elevations and sections with hidden lines removed

Projects the model's triangles onto an elevation or section plane, cuts them at the section line, and keeps the edges that nothing nearer covers, as the linework drawn on sheets.

How the precision is kept

The triangles come from the 3D viewer's vertex buffer, which is 32-bit meters; they are converted to plan millimeters in a Float64Array and everything after that is 64-bit. Visibility is decided by sampling each edge every 0.75 pixel against a depth buffer whose longer side is 1600 pixels, so where a partly hidden line starts and stops is known to about one sample. The depth buffer is 32-bit and is used only for the visible or hidden decision.

Open item. Take the triangles for elevations and sections from 64-bit geometry instead of the viewer's 32-bit vertex buffer.

Calculations under this policy

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

res-views.js BimCraft 0.8.1 32-bit in part 11 calculations Reviewed

Tests tools/bimcraft-test/res-test.mjs, tools/bimcraft-test/views09-test.mjs

Model triangles in plan millimeters

32-bit in part

Collects every element's triangles from the loaded model and converts them from viewer meters to plan millimeters.

p′ = M × (x, y, z, 1)

The positions read here are the model batches' Float32Array (ifc-model.js stores them with Float32Array.from). They are multiplied by the 4×4 matrix in 64-bit and stored in a Float64Array, so the result has 64-bit storage but 32-bit input. Every line of an elevation or section starts from these coordinates.

  • res-views.js
  • lines 28 to 45
  • BimCraft 0.8.1
export function modelTriangles(model, M, { skip = null, classes = null } = {}) {
  const map = new Map();
  const ap = (x, y, z) => [M[0] * x + M[4] * y + M[8] * z + M[12], M[1] * x + M[5] * y + M[9] * z + M[13], M[2] * x + M[6] * y + M[10] * z + M[14]];
  for (const b of model.batches || []) {
    if (b.space) continue;
    const P = b.positions, I = b.indices, T = b.triIds, kb = model.keyBase || 0;
    for (let t = 0; t < I.length / 3; t++) {
      const key = T[t];
      if (skip && skip.has(key)) continue;
      const cls = model.classOf?.get(key - kb) || '';
      if (classes && !classes.test(cls)) continue;
      let e = map.get(key);
      if (!e) { e = { key, cls, list: [] }; map.set(key, e); }
      for (let k = 0; k < 3; k++) { const v = I[3 * t + k] * 3; e.list.push(...ap(P[v], P[v + 1], P[v + 2])); }   // M takes viewer metres to plan mm
    }
  }
  return [...map.values()].map((e) => ({ key: e.key, cls: e.cls, tris: Float64Array.from(e.list) }));
}

Projection and section cut

64-bit

Projects each triangle into view coordinates and finds where the section plane cuts it.

x = p · right; y = z; depth = p · dir; cut point = a + (b − a) × (c₀ − d_a) ÷ (d_b − d_a)

Two dot products per vertex with the unit view direction and its right-hand normal. A cut segment end is a linear interpolation along a triangle edge whose two ends lie strictly on opposite sides of the plane, so the divisor is never zero.

  • res-views.js
  • lines 96 to 104
  • BimCraft 0.8.1
for (let k = 0; k < 3; k++) { const x = T[i + 3 * k], y = T[i + 3 * k + 1], z = T[i + 3 * k + 2]; const d = x * dir[0] + y * dir[1]; t.push(x * right[0] + y * right[1], z, d); dd = Math.min(dd, d); DD = Math.max(DD, d); }
if (DD < c0 || dd > d1) continue;
if (cutting && dd < c0 && DD > c0) {
  // the cut through this triangle: a segment of the poché outline
  const pts = [];
  for (let k = 0; k < 3; k++) { const a = [t[3 * k], t[3 * k + 1], t[3 * k + 2]], b = [t[(3 * k + 3) % 9], t[(3 * k + 4) % 9], t[(3 * k + 5) % 9]]; if ((a[2] - c0) * (b[2] - c0) < 0) { const f = (c0 - a[2]) / (b[2] - a[2]); pts.push([a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f]); } }
  if (pts.length === 2) segs.push(pts);
}
for (const c of cutting ? clipTri(t, c0) : [t]) tri.push(...(Number.isFinite(d1) ? clipTri(c, d1, -1) : [c]));

Triangle clipped at a depth

64-bit

Keeps the part of a triangle behind the section plane, or in front of the far limit.

f = (d₀ − d_a) ÷ (d_b − d_a); new vertex = a + (b − a) × f

Linear interpolation on edges that cross the depth; the new vertex is given the depth d₀ itself, not an interpolated value. A vertex exactly on the plane counts as kept.

  • res-views.js
  • lines 47 to 63
  • BimCraft 0.8.1
/** A triangle (9 numbers, view coords x, y, depth) clipped to depth ≥ d0: 0, 1 or 2 triangles. */
function clipTri(t, d0, side = 1) {
  const v = [[t[0], t[1], t[2]], [t[3], t[4], t[5]], [t[6], t[7], t[8]]];
  const inside = v.map((p) => side * (p[2] - d0) >= 0);
  const n = inside.filter(Boolean).length;
  if (n === 3) return [t];
  if (n === 0) return [];
  const out = [];
  for (let i = 0; i < 3; i++) {
    const a = v[i], b = v[(i + 1) % 3];
    if (side * (a[2] - d0) >= 0) out.push(a);
    if ((side * (a[2] - d0) >= 0) !== (side * (b[2] - d0) >= 0)) { const f = (d0 - a[2]) / (b[2] - a[2]); out.push([a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, d0]); }
  }
  const res = [];
  for (let i = 1; i + 1 < out.length; i++) res.push([...out[0], ...out[i], ...out[i + 1]]);
  return res;
}

Depth buffer and the visibility test

64-bit

Rasterizes every triangle's depth onto a pixel grid and tests whether an edge sample is covered.

s = 1600 ÷ max(W, H); visible when d ≤ nearest depth in 3 × 3 pixels + tol; tol = max(3, 0.002 × depth range, 4 ÷ s)

The buffer is a Float32Array of depths in millimeters. It holds no result: it is compared with the 64-bit depth of each sample, with a tolerance of at least 3 mm. The pixel size is the view's longer side divided by 1600.

  • res-views.js
  • lines 130 to 144
  • BimCraft 0.8.1
const W = Math.max(1, x1 - x0), H = Math.max(1, y1 - y0), s = res / Math.max(W, H);
const nx = Math.ceil(W * s) + 3, ny = Math.ceil(H * s) + 3;
const Z = new Float32Array(nx * ny).fill(Infinity);
const X = (x) => (x - x0) * s + 1, Y = (y) => (y - y0) * s + 1;
for (const v of view) for (const t of v.tri) raster(Z, nx, ny, X(t[0]), Y(t[1]), t[2], X(t[3]), Y(t[4]), t[5], X(t[6]), Y(t[7]), t[8]);
const tol = Math.max(3, (dmax - dmin) * 0.002, 4 / s);   // a face at 70° to the view changes ~3 px of depth a pixel
const visible = (px, py, d) => {
  const i0 = Math.floor(px), j0 = Math.floor(py);
  // the nearest surface drawn round the point (empty pixels left out): an edge no farther than it is seen — so an
  // outline edge shows against empty paper, and one lying behind another face's outline does not (that face's own
  // edge is drawn there)
  let best = Infinity;
  for (let j = j0 - 1; j <= j0 + 1; j++) for (let i = i0 - 1; i <= i0 + 1; i++) { if (i < 0 || j < 0 || i >= nx || j >= ny) continue; const z = Z[j * nx + i]; if (z < best) best = z; }
  return d <= best + tol;
};

Triangle depth on the pixel grid

32-bit, display only

Writes a triangle's depth into each pixel whose center it covers, keeping the nearest.

d = w₀ d_a + w₁ d_b + w₂ d_c at the pixel center, barycentric weights

Barycentric interpolation at pixel centers with a −1e-6 allowance on the weights; the depth is then stored in the 32-bit buffer. A triangle whose pixel area is below 1e-12 marks its three edges instead. The values are used only to decide which edges are hidden.

  • res-views.js
  • lines 197 to 215
  • BimCraft 0.8.1
/** Rasterise a triangle (pixel x, y, depth) into the buffer, keeping the nearest depth. */
function raster(Z, nx, ny, ax, ay, ad, bx, by, bd, cx, cy, cd) {
  const minx = Math.max(0, Math.floor(Math.min(ax, bx, cx))), maxx = Math.min(nx - 1, Math.ceil(Math.max(ax, bx, cx)));
  const miny = Math.max(0, Math.floor(Math.min(ay, by, cy))), maxy = Math.min(ny - 1, Math.ceil(Math.max(ay, by, cy)));
  const den = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy);
  if (Math.abs(den) < 1e-12) {
    // edge-on: mark its outline pixels so it still hides what is behind it
    const mark = (x, y, d) => { const i = Math.round(x), j = Math.round(y); if (i >= 0 && j >= 0 && i < nx && j < ny && d < Z[j * nx + i]) Z[j * nx + i] = d; };
    for (const [p, q] of [[[ax, ay, ad], [bx, by, bd]], [[bx, by, bd], [cx, cy, cd]], [[cx, cy, cd], [ax, ay, ad]]]) { const L = Math.ceil(Math.hypot(q[0] - p[0], q[1] - p[1])) + 1; for (let k = 0; k <= L; k++) { const f = k / L; mark(p[0] + (q[0] - p[0]) * f, p[1] + (q[1] - p[1]) * f, p[2] + (q[2] - p[2]) * f); } }
    return;
  }
  for (let j = miny; j <= maxy; j++) for (let i = minx; i <= maxx; i++) {
    const px = i + 0.5, py = j + 0.5;
    const w0 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / den, w1 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / den, w2 = 1 - w0 - w1;
    if (w0 < -1e-6 || w1 < -1e-6 || w2 < -1e-6) continue;
    const d = w0 * ad + w1 * bd + w2 * cd, k = j * nx + i;
    if (d < Z[k]) Z[k] = d;
  }
}

Visible runs of an edge

64-bit

Samples each feature edge and keeps the stretches that are visible as drawn lines.

n = max(1, ⌈edge length in pixels ÷ 0.75⌉); samples at a + (b − a) × k ÷ n

A visible run starts and ends at sample points, so the end of a line that goes behind something is placed at the last visible sample, up to one step short of the true crossing. The step is 0.75 pixel. Runs of 1 mm or less are dropped.

  • res-views.js
  • lines 149 to 156
  • BimCraft 0.8.1
const L = Math.hypot((b[0] - a[0]) * s, (b[1] - a[1]) * s), n = Math.max(1, Math.ceil(L / 0.75));
let run = null;
for (let k = 0; k <= n; k++) {
  const f = k / n, x = a[0] + (b[0] - a[0]) * f, y = a[1] + (b[1] - a[1]) * f, d = a[2] + (b[2] - a[2]) * f;
  const vis = visible(X(x), Y(y), d);
  if (vis && !run) run = [x, y];
  if ((!vis || k === n) && run) { const ex = vis ? [x, y] : [a[0] + (b[0] - a[0]) * ((k - 1) / n), a[1] + (b[1] - a[1]) * ((k - 1) / n)]; if (Math.hypot(ex[0] - run[0], ex[1] - run[1]) > 1) lines.push([run[0], run[1], ex[0], ex[1], v.key]); run = null; }
}

Boundary and crease edges

64-bit

Finds the edges of an element's triangles that are open boundaries or creases sharper than 25 degrees.

crease when |n₁ · n₂| < cos 25°; vertices matched by round(2 × coordinate)

Vertices are matched on a 0.5 mm grid by rounding, so two vertices within 0.5 mm can still fall in different cells. Triangles whose normal is shorter than 1e-9 are skipped. The edge keeps the unrounded coordinates of the first triangle that has it.

  • res-views.js
  • lines 216 to 240
  • BimCraft 0.8.1
/** Boundary and crease edges of a triangle list (view coords): vertices merged within 0.5 mm. */
function featureEdges(tris) {
  const key = (x, y, d) => Math.round(x * 2) + ',' + Math.round(y * 2) + ',' + Math.round(d * 2);
  const edges = new Map();
  for (const t of tris) {
    const ux = t[3] - t[0], uy = t[4] - t[1], ud = t[5] - t[2], vx = t[6] - t[0], vy = t[7] - t[1], vd = t[8] - t[2];
    let nx = uy * vd - ud * vy, ny = ud * vx - ux * vd, nz = ux * vy - uy * vx;
    const L = Math.hypot(nx, ny, nz);
    if (L < 1e-9) continue;
    nx /= L; ny /= L; nz /= L;
    for (let k = 0; k < 3; k++) {
      const a = [t[3 * k], t[3 * k + 1], t[3 * k + 2]], b = [t[(3 * k + 3) % 9], t[(3 * k + 4) % 9], t[(3 * k + 5) % 9]];
      const ka = key(...a), kb = key(...b), id = ka < kb ? ka + '|' + kb : kb + '|' + ka;
      const e = edges.get(id);
      if (!e) edges.set(id, { a, b, n: [[nx, ny, nz]] }); else e.n.push([nx, ny, nz]);
    }
  }
  const out = [];
  for (const e of edges.values()) {
    if (e.n.length !== 2) { out.push([e.a, e.b]); continue; }
    const [p, q] = e.n, c = Math.abs(p[0] * q[0] + p[1] * q[1] + p[2] * q[2]);
    if (c < CREASE) out.push([e.a, e.b]);
  }
  return out;
}

Cut segments joined into loops

64-bit

Joins the segments where the section plane cuts an element into the closed outlines that are filled.

ends matched by round(2 × coordinate)

Ends are matched on a 0.5 mm grid; the loop keeps the unrounded coordinates. A chain of fewer than 3 points is dropped.

  • res-views.js
  • lines 241 to 262
  • BimCraft 0.8.1
/** Segments [[a, b]…] (2D) → closed loops where they join (ends within 0.5 mm). */
function chain(segs) {
  const k = (p) => Math.round(p[0] * 2) + ',' + Math.round(p[1] * 2);
  const adj = new Map();
  segs.forEach(([a, b], i) => { for (const [p, q] of [[a, b], [b, a]]) { const kk = k(p); if (!adj.has(kk)) adj.set(kk, []); adj.get(kk).push({ i, to: q }); } });
  const used = new Set(), loops = [];
  for (let s = 0; s < segs.length; s++) {
    if (used.has(s)) continue;
    used.add(s);
    const loop = [segs[s][0], segs[s][1]];
    let cur = segs[s][1];
    for (let g = 0; g < segs.length; g++) {
      const nx = (adj.get(k(cur)) || []).find((o) => !used.has(o.i));
      if (!nx) break;
      used.add(nx.i); cur = nx.to;
      if (k(cur) === k(loop[0])) break;
      loop.push(cur);
    }
    if (loop.length >= 3) loops.push(loop);
  }
  return loops;
}

Line cut to the view's frame

64-bit

Cuts a drawn line to the view's box.

Liang–Barsky: t₀, t₁ from q ÷ p on each side

A direction component below 1e-12 is treated as parallel to that side, with a 1e-9 allowance for a line lying on it. A line wholly inside is returned unchanged.

  • res-views.js
  • lines 165 to 176
  • BimCraft 0.8.1
/** A segment cut to a box (Liang–Barsky), or null. */
function clipSeg(l, b) {
  let t0 = 0, t1 = 1;
  const dx = l[2] - l[0], dy = l[3] - l[1];
  for (const [p, q] of [[-dx, l[0] - b.x0], [dx, b.x1 - l[0]], [-dy, l[1] - b.y0], [dy, b.y1 - l[1]]]) {
    if (Math.abs(p) < 1e-12) { if (q < -1e-9) return null; continue; }
    const r = q / p;
    if (p < 0) { if (r > t1) return null; if (r > t0) t0 = r; } else { if (r < t0) return null; if (r < t1) t1 = r; }
  }
  if (t0 === 0 && t1 === 1) return l;
  return [l[0] + dx * t0, l[1] + dy * t0, l[0] + dx * t1, l[1] + dy * t1, l[4]];
}

Cut outline cut to the view's frame

64-bit

Cuts a filled section outline to the view's box.

Sutherland–Hodgman against the four sides

Crossing points are linear interpolations along edges whose ends are on opposite sides, and the crossing is placed exactly on the side's coordinate. No tolerance is used.

  • res-views.js
  • lines 177 to 195
  • BimCraft 0.8.1
/** A polygon cut to a box (Sutherland–Hodgman). */
function clipPoly(poly, b) {
  let out = poly;
  for (const [inside, cross] of [
    [(p) => p[0] >= b.x0, (p, q) => [b.x0, p[1] + (q[1] - p[1]) * (b.x0 - p[0]) / (q[0] - p[0])]],
    [(p) => p[0] <= b.x1, (p, q) => [b.x1, p[1] + (q[1] - p[1]) * (b.x1 - p[0]) / (q[0] - p[0])]],
    [(p) => p[1] >= b.y0, (p, q) => [p[0] + (q[0] - p[0]) * (b.y0 - p[1]) / (q[1] - p[1]), b.y0]],
    [(p) => p[1] <= b.y1, (p, q) => [p[0] + (q[0] - p[0]) * (b.y1 - p[1]) / (q[1] - p[1]), b.y1]],
  ]) {
    const src = out; out = [];
    for (let i = 0; i < src.length; i++) {
      const p = src[i], q = src[(i + 1) % src.length], pi = inside(p), qi = inside(q);
      if (pi) out.push(p);
      if (pi !== qi) out.push(cross(p, q));
    }
    if (!out.length) break;
  }
  return out;
}

Collinear pieces joined

64-bit

Joins touching pieces of the same element's lines that run in the same direction into one line.

join when ends ≤ 0.5 mm apart and cos(angle) ≥ 0.99999

The joined line runs from the first piece's start to the second piece's end, so a gap of up to 0.5 mm and a bend of up to about 0.26 degrees between them are straightened out.

  • res-views.js
  • lines 263 to 285
  • BimCraft 0.8.1
/** Collinear touching segments of the same element joined (fewer, longer lines on paper). */
function mergeCollinear(lines) {
  const out = [];
  const byKey = new Map();
  for (const l of lines) { const k = l[4]; if (!byKey.has(k)) byKey.set(k, []); byKey.get(k).push(l); }
  for (const list of byKey.values()) {
    let changed = true;
    while (changed) {
      changed = false;
      for (let i = 0; i < list.length && !changed; i++) for (let j = i + 1; j < list.length && !changed; j++) {
        const A = list[i], B = list[j];
        for (const [p, q, r, t] of [[[A[0], A[1]], [A[2], A[3]], [B[0], B[1]], [B[2], B[3]]], [[A[0], A[1]], [A[2], A[3]], [B[2], B[3]], [B[0], B[1]]], [[A[2], A[3]], [A[0], A[1]], [B[0], B[1]], [B[2], B[3]]], [[A[2], A[3]], [A[0], A[1]], [B[2], B[3]], [B[0], B[1]]]]) {
          if (Math.hypot(q[0] - r[0], q[1] - r[1]) > 0.5) continue;
          const d1 = [q[0] - p[0], q[1] - p[1]], d2 = [t[0] - r[0], t[1] - r[1]], l1 = Math.hypot(...d1), l2 = Math.hypot(...d2);
          if (!l1 || !l2 || (d1[0] * d2[0] + d1[1] * d2[1]) / (l1 * l2) < 0.99999) continue;
          list[i] = [p[0], p[1], t[0], t[1], A[4]]; list.splice(j, 1); changed = true; break;
        }
      }
    }
    out.push(...list);
  }
  return out;
}

In detail

Method
Orthographic projection: x along the view's right, y up, depth along the view direction. Feature edges are boundaries and creases over 25 degrees. A depth buffer of every triangle, nearest depth kept; an edge sample is visible when it is no farther than the nearest surface in a 3 × 3 pixel neighborhood plus a tolerance.
Number types
Input positions: Float32Array (ifc-model.js). Working triangles: Float64Array. Depth buffer: Float32Array, used for comparisons only.
Tolerances
View direction must be a unit vector within 1e-8. Depth tolerance: max(3 mm, 0.002 × depth range, 4 pixels' worth of millimeters). Vertices and cut segment ends are matched on a 0.5 mm grid. Visible runs of 1 mm or less are dropped. Collinear pieces are joined when their ends are within 0.5 mm and their directions agree to a cosine of 0.99999.
Limits
Line ends at a hidden-line boundary are quantized to the sampling step, which is the view's longer side ÷ 1600 × 0.75. Triangles that are edge-on to the view mark only their outline in the depth buffer.

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