Resources Numerical accuracy Calculation policy

Thinning point clouds to a spacing

Keeps one point per grid cell when a large point cloud is imported, and estimates how many points an import will keep.

How the precision is kept

Points are kept in Float64Array. In the lowest and nearest modes a kept point is one of the input points, unchanged. In average mode x, y and z are summed in arrival order and divided by the count. Cell indexes are floored from 64-bit quotients and combined into one whole-number key.

Calculations under this policy

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

thin.js CivilCraft 0.8.1 64-bit 4 calculations Reviewed

Tests bin/test-civilcraft-terrain.mjs

One point into its cell

64-bit

Decides whether an incoming point is kept, replaces the cell's point, or is dropped.

i = ⌊(x − oₓ) ÷ cell⌋; key = i × 2³² + (j + 2³¹); nearest: d² = (fₓ − i − ½)² + (f_y − j − ½)²

The key is exact because |i| < 2²¹ keeps i × 2³² + j below 2⁵³. Lowest and nearest replace the stored point by a whole input point. Average adds x, y and z to running sums in the order the points arrive.

  • thin.js
  • lines 40 to 66
  • CivilCraft 0.8.1
add(x, y, z, cls) {
  this.seen++;
  if (this.classes && !this.classes.has(cls)) { this.filtered++; return false; }
  const r = this.region;
  if (r && (x < r.minX || x > r.maxX || y < r.minY || y > r.maxY)) { this.outside++; return false; }
  if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false;
  const fx = (x - this.ox) / this.cell, fy = (y - this.oy) / this.cell;
  const ix = Math.floor(fx), iy = Math.floor(fy);
  if (Math.abs(ix) >= 2097152 || Math.abs(iy) >= 2147483647) { this.outside++; return false; }   // keys stay exact (origin near the data)
  const key = ix * 4294967296 + (iy + 2147483648);
  let k = this.map.get(key);
  if (k === undefined) {
    if (this.count >= this.max) { this.capped = true; this.dropped++; return false; }
    k = this.count++;
    if (k >= this.cap) this.grow();
    this.map.set(key, k);
    this.X[k] = x; this.Y[k] = y; this.Z[k] = z;
    this.W[k] = this.mode === 'average' ? 1 : this.mode === 'nearest' ? (fx - ix - 0.5) ** 2 + (fy - iy - 0.5) ** 2 : 0;
    return true;
  }
  if (this.mode === 'lowest') { if (z < this.Z[k]) { this.X[k] = x; this.Y[k] = y; this.Z[k] = z; } }
  else if (this.mode === 'nearest') {
    const d = (fx - ix - 0.5) ** 2 + (fy - iy - 0.5) ** 2;
    if (d < this.W[k]) { this.X[k] = x; this.Y[k] = y; this.Z[k] = z; this.W[k] = d; }
  } else { this.X[k] += x; this.Y[k] += y; this.Z[k] += z; this.W[k] += 1; }
  return true;
}

The thinned points

64-bit

Returns one point per occupied cell.

average mode: x̄ = Σx ÷ n, ȳ = Σy ÷ n, z̄ = Σz ÷ n

For lowest and nearest the stored coordinates are returned as they came in. For average each sum is divided by the count once.

  • thin.js
  • lines 67 to 71
  • CivilCraft 0.8.1
points() {
  const out = new Array(this.count), avg = this.mode === 'average';
  for (let k = 0; k < this.count; k++) out[k] = avg ? { x: this.X[k] / this.W[k], y: this.Y[k] / this.W[k], z: this.Z[k] / this.W[k] } : { x: this.X[k], y: this.Y[k], z: this.Z[k] };
  return out;
}

Grid step for a spacing

64-bit

How many grid nodes to skip so an elevation grid is thinned to about the spacing asked for.

n = max(1, round(spacing ÷ cell size))

The ratio is rounded to a whole step. The header says nodes are skipped and never moved, so kept elevations are the file's own.

  • thin.js
  • line 74
  • CivilCraft 0.8.1
export const gridStep = (spacing, cellSize) => (spacing > 0 && cellSize > 0 ? Math.max(1, Math.round(spacing / cellSize)) : 1);

Estimate of points kept

64-bit

Tells the import dialog about how many points a thinning will keep.

kept ≈ Σ min(points × f, cell area × f ÷ spacing² + 1), f = part of the coarse cell inside the area

A 64-bit sum over the coarse cells of counts (128 × 128 by default), rounded to a whole number at the end. It is an estimate for the dialog, not a count of the import.

  • thin.js
  • lines 97 to 120
  • CivilCraft 0.8.1
/**
 * spacing: thinning cell, in the file's units (convert first); classes: Set | null; box: the area in the file's
 * coordinates (null = all). Returns { points (to keep, about), inArea (points of those classes in the area) }.
 */
estimate({ spacing, classes = null, box = null }) {
  const n = this.n, per = new Float64Array(n * n);
  for (const [c, a] of this.byClass) if (!classes || classes.has(c)) for (let k = 0; k < a.length; k++) per[k] += a[k];
  let keep = 0, inArea = 0;
  const cellArea = this.w * this.h;
  for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) {
    const v = per[j * n + i];
    if (!v) continue;
    let f = 1;
    if (box) {
      const x0 = this.box.minX + i * this.w, y0 = this.box.minY + j * this.h;
      const ox = Math.max(0, Math.min(box.maxX, x0 + this.w) - Math.max(box.minX, x0)), oy = Math.max(0, Math.min(box.maxY, y0 + this.h) - Math.max(box.minY, y0));
      f = (ox * oy) / cellArea;
      if (!f) continue;
    }
    inArea += v * f;
    keep += Math.min(v * f, (cellArea * f) / (spacing * spacing) + 1);
  }
  return { points: Math.round(keep), inArea: Math.round(inArea) };
}

In detail

Method
Square cells of the given spacing anchored at an origin; one stored point per occupied cell, chosen by mode: lowest, nearest the cell center, or the average.
Number types
Float64Array for x, y, z and the per-cell weight; whole-number keys in a Map; Uint32Array counts in the coverage estimate.
Limits
Average mode sums raw coordinates, so with large coordinates and many points in a cell the mean loses low digits. A cell index beyond ±2,097,152 in x is refused so the key stays an exact whole number. The estimate assumes points are spread evenly inside each coarse cell.

Approach: 64-bit, direct

Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.

Every policy that takes this approach