Resources Numerical accuracy Calculation policy

Point cloud slice and scan alignment

Cuts a point cloud at a level's height for drawing over it, and finds the turn and the lift that set a scan square to the building and its floor on a level.

How the precision is kept

Cloud positions are read from the array the cloud is drawn from (32-bit, relative to the cloud's own origin, as this file's header states), taken to the plan in 64-bit and kept in Float64Array from there. The wall direction is worked relative to the slice's own middle. Its resolution is set by binning: 0.5° votes, then trial turns 0.02° apart; the floor height by 20 mm bands.

Calculations under this policy

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

cloud-slice.js BimCraft 0.8.1 64-bit 9 calculations Reviewed

Tests tools/bimcraft-test/draw06-test.mjs, tools/bimcraft-test/scan-walls-test.mjs

Cloud to plan matrix

64-bit

The one matrix that takes a cloud's points to a level's plan in millimeters.

M = storey⁻¹ · S · C⁻¹ · model⁻¹ · layer, S taking meters with Y up to millimeters with Z up

Four 4 × 4 products and three inverses (ifc-util.js mul4 and inv4) in 64-bit on plain arrays. The scale to millimeters is the whole number 1000 inside S.

  • cloud-slice.js
  • lines 32 to 36
  • BimCraft 0.8.1
export function planMatrix(layerMatrix, modelMatrix, C, storeyWorldMm) {
  const S = [1000, 0, 0, 0, 0, 0, 1000, 0, 0, -1000, 0, 0, 0, 0, 0, 1];   // as model-plan.js viewerToPlan
  const vToPlan = mul4(inv4(storeyWorldMm), mul4(S, inv4(C)));
  return mul4(vToPlan, mul4(inv4(modelMatrix), layerMatrix));
}

Slice of the cloud at the cut height

64-bit

The cloud's points near a level's cut height, in plan millimeters.

kept when |z − cut| ≤ thickness ÷ 2

Each point is read from pos, taken through M in 64-bit and stored in Float64Array; the file's header says pos is a Float32Array relative to the cloud's origin. Above 60,000 points the slice is thinned by taking evenly spaced points, so it is a sample.

  • cloud-slice.js
  • lines 38 to 56
  • BimCraft 0.8.1
export function slicePoints(pos, n, M, { cutZ = 1200, thick = 100, max = 60000 } = {}) {
  const half = thick / 2, keep = [];
  const m = M;
  for (let i = 0; i < n; i++) {
    const x = pos[i * 3], y = pos[i * 3 + 1], z = pos[i * 3 + 2];
    const pz = m[2] * x + m[6] * y + m[10] * z + m[14];
    if (pz < cutZ - half || pz > cutZ + half) continue;
    keep.push(i);
  }
  const step = keep.length > max ? keep.length / max : 1, cnt = Math.min(keep.length, max);
  const xy = new Float64Array(cnt * 2), zs = new Float64Array(cnt);
  for (let k = 0; k < cnt; k++) {
    const i = keep[Math.floor(k * step)], x = pos[i * 3], y = pos[i * 3 + 1], z = pos[i * 3 + 2];
    xy[k * 2] = m[0] * x + m[4] * y + m[8] * z + m[12];
    xy[k * 2 + 1] = m[1] * x + m[5] * y + m[9] * z + m[13];
    zs[k] = m[2] * x + m[6] * y + m[10] * z + m[14];
  }
  return { xy, n: cnt, zs, total: keep.length };
}

Nearest slice point

64-bit

The slice point nearest a plan point, for snapping a drawn point to the scan.

minimum of (x − xᵢ)² + (y − yᵢ)² within r

Squared distances are compared in 64-bit and the stored slice point is returned unchanged. The cell lookup uses a 32-bit hash of the cell indices; a collision only adds candidates, which are still tested by distance.

  • cloud-slice.js
  • lines 67 to 76
  • BimCraft 0.8.1
nearest(x, y, r) {
  const c = this.cell, i0 = Math.floor((x - r) / c), i1 = Math.floor((x + r) / c), j0 = Math.floor((y - r) / c), j1 = Math.floor((y + r) / c);
  let best = -1, bd = r * r;
  for (let i = i0; i <= i1; i++) for (let j = j0; j <= j1; j++) {
    const a = this.map.get(this.key(i, j));
    if (!a) continue;
    for (const k of a) { const dx = this.xy[k * 2] - x, dy = this.xy[k * 2 + 1] - y, d = dx * dx + dy * dy; if (d <= bd) { bd = d; best = k; } }
  }
  return best < 0 ? null : [this.xy[best * 2], this.xy[best * 2 + 1]];
}

Turn that sets the scan square

64-bit

The angle of the scan's walls against the plan's axes, and the point to turn about.

angle = first estimate + the trial turn in −1°…1° with the highest sharpness

The slice is first moved to its own mean, in a Float64Array, so squared coordinates stay small. The answer is the best of about 100 trial turns 0.02° apart, with the trial offset advanced by repeated addition of 0.02. The resolution is therefore 0.02°.

  • cloud-slice.js
  • lines 79 to 98
  • BimCraft 0.8.1
export function squareAngle(xy, n, { cell = 400 } = {}) {
  // the cells' lines give the angle to a fraction of a degree; then the turn within ±1° (0.02° steps) at which the points
  // pile up most sharply along X and along Y (walls square to the axes: 10 mm bins, the sum of squared counts) refines it.
  // Everything is worked relative to the slice's middle (o): squared plan millimetres of a cloud 460 km from the origin
  // (2e17) leave no digits for a wall's 5 mm of noise, and the turn was then guessed from rounding.
  if (!n) return null;
  let ox = 0, oy = 0;
  for (let i = 0; i < n; i++) { ox += xy[i * 2]; oy += xy[i * 2 + 1]; }
  ox /= n; oy /= n;
  const loc = new Float64Array(n * 2);
  for (let i = 0; i < n; i++) { loc[i * 2] = xy[i * 2] - ox; loc[i * 2 + 1] = xy[i * 2 + 1] - oy; }
  const first = angleOnce(loc, n, cell);
  if (!first) return null;
  let best = first.angle, bs = -1;
  for (let d = -1; d <= 1 + 1e-9; d += 0.02) {
    const sc = sharpness(loc, n, first.angle + d);
    if (sc > bs) { bs = sc; best = first.angle + d; }
  }
  return { angle: best, strength: first.strength, about: [first.about[0] + ox, first.about[1] + oy], walls: first.walls };
}

Sharpness of a trial turn

64-bit

Scores how well the points line up along X and Y after a trial turn.

score = Σ (count per 10 mm bin along X)² + Σ (count per 10 mm bin along Y)²

Points are turned by the trial angle in 64-bit and counted in 10 mm bins with Math.floor. The score is a sum of squared whole-number counts, which 64-bit numbers hold exactly at these sizes.

  • cloud-slice.js
  • lines 99 to 109
  • BimCraft 0.8.1
function sharpness(xy, n, deg, bin = 10) {
  const a = -deg * Math.PI / 180, c = Math.cos(a), s = Math.sin(a), hx = new Map(), hy = new Map();
  for (let i = 0; i < n; i++) {
    const x = xy[i * 2], y = xy[i * 2 + 1], kx = Math.floor((x * c - y * s) / bin), ky = Math.floor((x * s + y * c) / bin);
    hx.set(kx, (hx.get(kx) || 0) + 1); hy.set(ky, (hy.get(ky) || 0) + 1);
  }
  let sum = 0;
  for (const v of hx.values()) sum += v * v;
  for (const v of hy.values()) sum += v * v;
  return sum;
}

Wall direction from line fits

64-bit

A first estimate of the walls' direction from a line fitted to the points in each cell.

per cell: covariance c_xx, c_yy, c_xy; λ = tr ÷ 2 ± √(tr² ÷ 4 − det); angle = atan2(λ₁ − c_xx, c_xy), folded into 0…90°

Sums are kept relative to each cell's corner, so the variance (mean of squares minus squared mean) subtracts numbers no larger than the cell size squared. The square root's argument is clamped at zero. Votes go into 0.5° bins and the answer is the weighted mean of the votes within 1.5° of the strongest bin.

  • cloud-slice.js
  • lines 110 to 149
  • BimCraft 0.8.1
function angleOnce(xy, n, cell) {
  const cells = new Map();
  for (let i = 0; i < n; i++) {
    const i0 = Math.floor(xy[i * 2] / cell), j0 = Math.floor(xy[i * 2 + 1] / cell), k = i0 + ',' + j0;
    let s = cells.get(k); if (!s) { s = { n: 0, x0: i0 * cell, y0: j0 * cell, sx: 0, sy: 0, sxx: 0, syy: 0, sxy: 0 }; cells.set(k, s); }
    const x = xy[i * 2] - s.x0, y = xy[i * 2 + 1] - s.y0;   // within the cell: the sums keep every digit
    s.n++; s.sx += x; s.sy += y; s.sxx += x * x; s.syy += y * y; s.sxy += x * y;
  }
  const bins = new Float64Array(180);   // 0.5° bins over 90°
  const votes = [];   // [angle 0…90, weight, cell middle x, y] of each line-like cell
  let total = 0;
  for (const s of cells.values()) {
    if (s.n < 8) continue;
    const mx = s.sx / s.n, my = s.sy / s.n;
    const cxx = s.sxx / s.n - mx * mx, cyy = s.syy / s.n - my * my, cxy = s.sxy / s.n - mx * my;
    const tr = cxx + cyy, det = cxx * cyy - cxy * cxy, disc = Math.sqrt(Math.max(0, tr * tr / 4 - det));
    const l1 = tr / 2 + disc, l2 = tr / 2 - disc;
    if (!(l1 > 0) || l2 > l1 * 0.05) continue;   // not a line (a corner, clutter)
    let a = Math.atan2(l1 - cxx, cxy) * 180 / Math.PI;   // the principal axis
    if (Math.abs(cxy) < 1e-12) a = cxx >= cyy ? 0 : 90;
    a = ((a % 90) + 90) % 90;   // folded: walls square to each other vote together
    bins[Math.min(179, Math.floor(a * 2))] += s.n;
    votes.push([a, s.n, s.x0 + mx, s.y0 + my]);
    total += s.n;
  }
  if (!total) return null;
  // the strongest bin, smoothed over its neighbours (wrapping at 90°)
  let best = 0, bw = -1;
  for (let i = 0; i < 180; i++) { const w = bins[(i + 179) % 180] + 2 * bins[i] + bins[(i + 1) % 180]; if (w > bw) { bw = w; best = i; } }
  const wA = bins[(best + 179) % 180], wB = bins[best], wC = bins[(best + 1) % 180];
  // refined: the weighted mean of the votes within 1.5° of the peak (taken round it, so 89.9° and 0.1° average to 0); their
  // cells' middles, weighted the same, are the middle of the walls that set the angle
  const peak = (best + 0.5) / 2;
  let sw = 0, sa = 0, sx = 0, sy = 0;
  for (const [a, wt, x, y] of votes) { let d = a - peak; if (d > 45) d -= 90; if (d < -45) d += 90; if (Math.abs(d) <= 1.5) { sw += wt; sa += wt * d; sx += wt * x; sy += wt * y; } }
  let angle = peak + (sw ? sa / sw : 0);
  if (angle > 45) angle -= 90;
  if (angle <= -45) angle += 90;
  return { angle, strength: (wA + wB + wC) / total, about: sw ? [sx / sw, sy / sw] : [0, 0], walls: sw };
}

Floor height of a scan

64-bit

The height of the scan's floor above the level.

floor = mean z of the points in the fullest 20 mm band of the lowest third

Heights are computed in 64-bit into a Float64Array and counted in 20 mm bands with Math.floor, the counts in a Uint32Array. The result is the plain mean of the heights in the fullest band, summed in order.

  • cloud-slice.js
  • lines 151 to 163
  • BimCraft 0.8.1
export function floorZ(pos, n, M, { step = 20 } = {}) {
  if (!n) return null;
  const m = M, zs = new Float64Array(n);
  let lo = Infinity, hi = -Infinity;
  for (let i = 0; i < n; i++) { const x = pos[i * 3], y = pos[i * 3 + 1], z = pos[i * 3 + 2]; const pz = m[2] * x + m[6] * y + m[10] * z + m[14]; zs[i] = pz; if (pz < lo) lo = pz; if (pz > hi) hi = pz; }
  const top = lo + (hi - lo) / 3, nb = Math.max(1, Math.ceil((top - lo) / step) + 1), bins = new Uint32Array(nb);
  for (let i = 0; i < n; i++) if (zs[i] <= top) bins[Math.min(nb - 1, Math.floor((zs[i] - lo) / step))]++;
  let b = 0; for (let i = 1; i < nb; i++) if (bins[i] > bins[b]) b = i;
  // the band's own points: their middle height
  let s = 0, c = 0;
  for (let i = 0; i < n; i++) { const k = Math.floor((zs[i] - lo) / step); if (k === b) { s += zs[i]; c++; } }
  return c ? s / c : null;
}

Turn about a plan point

64-bit

The matrix that turns the scan by an angle about a point in the plan.

x' = c x − s y + (x₀ − c x₀ + s y₀); y' = s x + c y + (y₀ − s x₀ − c y₀)

Degrees to radians by × π ÷ 180, then one cos and one sin. The translation that keeps the pivot fixed subtracts nearly equal products of the pivot's coordinates for a small turn, so a pivot far from the origin costs digits in it.

  • cloud-slice.js
  • lines 165 to 168
  • BimCraft 0.8.1
export const rotZ = (deg, about = [0, 0]) => {
  const a = deg * Math.PI / 180, c = Math.cos(a), s = Math.sin(a), [x, y] = about;
  return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, x - c * x + s * y, y - s * x - c * y, 0, 1];
};

A plan change carried to the scene

64-bit

Re-expresses a turn or a lift made in the plan as a change of the cloud's own placement.

A' = M⁻¹ · A · M

Two 4 × 4 products and one inverse in 64-bit (ifc-util.js).

  • cloud-slice.js
  • line 170
  • BimCraft 0.8.1
export const planAdjust = (A, Mvp) => mul4(inv4(Mvp), mul4(A, Mvp));

In detail

Method
Slice: points within half the thickness of the cut height. Wall direction: a principal-axis line fit per 400 mm cell, votes folded into 0…90°, then the turn within ±1° that piles the points most sharply into 10 mm bins along X and Y. Floor: the fullest 20 mm band in the lowest third of the cloud.
Number types
Input positions 32-bit; 4 × 4 matrices in plain arrays; Float64Array for the slice and heights; Uint32Array for band counts.
Tolerances
Slice thickness 100 mm about the cut. A cell votes with 8 or more points and λ₂ ≤ 0.05 λ₁; |c_xy| < 1e-12 is treated as axis-aligned. Votes within 1.5° of the peak are averaged.
Limits
Above 60,000 points the slice is a sample. The wall direction is resolved to 0.02°. The floor height is the mean of one 20 mm band.

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