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