Resources Numerical accuracy Calculation policy

Freeform mesh objects: quantities and placement on the ground

Reads a mesh from an STL or OBJ file, works out its volume, surface area, footprint and height, and sets it on or into the terrain.

How the precision is kept

Mesh coordinates are held as 64-bit numbers in millimeters from the insertion point. A binary STL file stores every coordinate as a 32-bit float, so a mesh read from one carries the file's own 32-bit precision; ASCII STL and OBJ text is parsed to 64-bit. The footprint is the convex hull of the plan points, the base elevation comes from ground samples, and the volume below ground is a grid estimate.

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.

landscape/freeform.js CivilCraft 0.8.1 64-bit 5 calculations Reviewed

Tests bin/test-landscape.mjs

STL reader

64-bit

Turns a binary or ASCII STL file into a mesh with shared vertices, scaled to millimeters.

Binary STL coordinates are read with getFloat32, the format's own 32-bit storage, and then kept as 64-bit numbers; ASCII coordinates are converted from text to 64-bit. Vertices whose coordinates round to the same 1e-6 of a file unit are merged, and the first one's unrounded value times the scale is the one kept.

  • landscape/freeform.js
  • lines 38 to 58
  • CivilCraft 0.8.1
/** STL (binary or ASCII) → mesh, vertices merged within 1e-6 of a unit. `scale` = mm per file unit. */
export function readStl(bytes, { scale = 1 } = {}) {
  const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
  const pos = [], idx = [], seen = new Map();
  const add = (x, y, z) => { const k = `${Math.round(x * 1e6)},${Math.round(y * 1e6)},${Math.round(z * 1e6)}`; let i = seen.get(k); if (i === undefined) { i = pos.length / 3; seen.set(k, i); pos.push(x * scale, y * scale, z * scale); } idx.push(i); };
  const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
  const nb = u8.length >= 84 ? dv.getUint32(80, true) : -1;
  if (nb >= 0 && 84 + nb * 50 === u8.length) {
    if (nb > MAX_TRIS) throw new Error('too-big');
    for (let t = 0; t < nb; t++) { const o = 84 + t * 50 + 12; for (let v = 0; v < 3; v++) add(dv.getFloat32(o + v * 12, true), dv.getFloat32(o + v * 12 + 4, true), dv.getFloat32(o + v * 12 + 8, true)); }
  } else {
    const text = new TextDecoder().decode(u8);
    const re = /vertex\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)/g;
    let m;
    while ((m = re.exec(text))) { add(+m[1], +m[2], +m[3]); if (idx.length / 3 > MAX_TRIS) throw new Error('too-big'); }
    if (idx.length % 3) idx.length -= idx.length % 3;
  }
  const mesh = checkMesh({ positions: pos, indices: idx });
  if (!mesh) throw new Error('not-mesh');
  return mesh;
}

Volume, surface area, footprint and height

64-bit

The quantities of a mesh object that schedules and the IFC export report.

V = |Σ v₀·(v₁×v₂)| ÷ 6; A = Σ |(v₁−v₀)×(v₂−v₀)| ÷ 2; height = z max − z min

The volume sums signed terms measured from the mesh origin in triangle order, in mm³, then divides by 1e9; for a mesh that sits far from its origin the terms are large and nearly cancel. Volume is null unless every edge is used by exactly two triangles. The footprint is the area of the convex hull of the plan points.

  • landscape/freeform.js
  • lines 93 to 110
  • CivilCraft 0.8.1
/** Volume (divergence theorem: Σ v0·(v1×v2)/6, a closed outward mesh), surface area, plan footprint area, height — in m. */
export function meshQuantities(mesh) {
  const P = mesh.positions, I = mesh.indices;
  let vol = 0, area = 0, zmin = Infinity, zmax = -Infinity;
  const edges = new Map();
  for (let k = 0; k < I.length; k += 3) {
    const a = I[k] * 3, b = I[k + 1] * 3, c = I[k + 2] * 3;
    const ax = P[a], ay = P[a + 1], az = P[a + 2], bx = P[b], by = P[b + 1], bz = P[b + 2], cx = P[c], cy = P[c + 1], cz = P[c + 2];
    vol += (ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx)) / 6;
    const ux = bx - ax, uy = by - ay, uz = bz - az, vx = cx - ax, vy = cy - ay, vz = cz - az;
    area += Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx) / 2;
    for (const [p, q] of [[I[k], I[k + 1]], [I[k + 1], I[k + 2]], [I[k + 2], I[k]]]) { const key = p < q ? p + ',' + q : q + ',' + p; edges.set(key, (edges.get(key) || 0) + 1); }
  }
  for (let i = 2; i < P.length; i += 3) { zmin = Math.min(zmin, P[i]); zmax = Math.max(zmax, P[i]); }
  const closed = [...edges.values()].every((n) => n === 2);
  const hull = convexHull(planPoints(mesh));
  return { volume: closed ? Math.abs(vol) / 1e9 : null, area: area / 1e6, footprint: Math.abs(polyArea(hull)) / 1e6, height: (zmax - zmin) / 1000, zmin: zmin / 1000, closed };
}

Convex hull of the plan points

64-bit

The outline used as the object's footprint on the plan.

keep a turn when (a − o) × (b − o) > 0

Monotone chain on points sorted by x then y. The turn test is a plain 64-bit cross product compared with zero, without a tolerance or exact arithmetic, so nearly collinear points can be kept or dropped by rounding.

  • landscape/freeform.js
  • lines 113 to 120
  • CivilCraft 0.8.1
export function convexHull(pts) {
  const p = [...pts].sort((a, b) => a.x - b.x || a.y - b.y), cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
  if (p.length < 3) return p;
  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 (let i = p.length - 1; i >= 0; i--) { const q = p[i]; while (hi.length >= 2 && cross(hi[hi.length - 2], hi[hi.length - 1], q) <= 0) hi.pop(); hi.push(q); }
  return [...lo.slice(0, -1), ...hi.slice(0, -1)];
}

Mesh millimeters to project coordinates

64-bit

Places a mesh point on the site: scaled to project units, turned by the object's rotation and moved to its insertion point.

x = x₀ + (x·cos a − y·sin a) × k; k = 1 ÷ (1000 × meters per project unit)

The rotation in degrees is multiplied by Math.PI ÷ 180 and its cosine and sine are taken once. The scale uses unitM from planting.js, where a project foot is 1200 ÷ 3937 m.

  • landscape/freeform.js
  • lines 121 to 125
  • CivilCraft 0.8.1
/** mm (mesh) → project coordinates, turned by at.rot about the insertion point. */
function toWorld(rec, units) {
  const k = 1 / (1000 * unitM(units)), a = (rec.at.rot * Math.PI) / 180, c = Math.cos(a), s = Math.sin(a);
  return (x, y) => ({ x: rec.at.x + (x * c - y * s) * k, y: rec.at.y + (x * s + y * c) * k });
}

Base elevation and volume below ground

64-bit

The elevation the object's base sits at, its points in project coordinates, and an estimate of how much of it is below the existing ground.

follow: base = lowest ground sample; cut: base = typed level or mean of the samples; below ≈ Σ (ground − base) × cell²

The ground is sampled only at the footprint's corners, its edge midpoints and the insertion point, so a low spot between samples is missed. The below-ground volume is summed on a grid whose cell is the footprint's larger extent ÷ 40, with cell centers stepped by repeated addition; the code comment calls it an estimate.

  • landscape/freeform.js
  • lines 128 to 151
  • CivilCraft 0.8.1
/** Where it sits: follow = the lowest ground under the footprint (corners, edge midpoints, centre); cut = baseZ (or the mean). */
export function placed(rec, surface, units) {
  const fp = footprint(rec, units), um = unitM(units);
  const zs = [];
  if (surface) {
    for (let i = 0; i < fp.length; i++) { const p = fp[i], q = fp[(i + 1) % fp.length]; for (const r of [p, { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 }]) { const z = surface.elevationAt(r.x, r.y, 1e-6); if (z !== null) zs.push(z); } }
    const cz = surface.elevationAt(rec.at.x, rec.at.y, 1e-6); if (cz !== null) zs.push(cz);
  }
  const ground = zs.length ? { min: Math.min(...zs), mean: zs.reduce((a, b) => a + b, 0) / zs.length } : { min: 0, mean: 0 };
  const zmin = Math.min(...rec.mesh.positions.filter((_, i) => i % 3 === 2)) / 1000 / um;
  const base = rec.base === 'cut' ? rec.baseZ ?? ground.mean : ground.min;
  const w = toWorld(rec, units), P = rec.mesh.positions, positions = [];
  for (let i = 0; i < P.length; i += 3) { const q = w(P[i], P[i + 1]); positions.push(q.x, q.y, base + P[i + 2] / 1000 / um - zmin); }
  // below the existing ground (follow): sampled on a grid over the footprint — an estimate, as the spike says
  let below = 0;
  if (surface && rec.base === 'follow') {
    let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
    for (const p of fp) { x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y); x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y); }
    const cell = Math.max((x1 - x0) / 40, (y1 - y0) / 40, 1e-6);
    for (let x = x0 + cell / 2; x < x1; x += cell) for (let y = y0 + cell / 2; y < y1; y += cell) { if (!inside(x, y, fp)) continue; const g = surface.elevationAt(x, y, 1e-6); if (g !== null && g > base) below += (g - base) * cell * cell; }
    below *= um ** 3;
  }
  return { base, positions, footprint: fp, belowGround: below };
}

In detail

Method
Volume by the divergence theorem, Σ v₀·(v₁×v₂) ÷ 6, as the code comment says. Surface area from the cross product of each triangle's edges. Footprint by a monotone-chain convex hull. Base elevation from ground samples at the footprint's corners, edge midpoints and the insertion point.
Number types
64-bit numbers in plain arrays. Binary STL input is read with getFloat32.
Tolerances
Vertices are merged when their coordinates round to the same 1e-6 of a file unit. Coordinates of 1e7 or more are refused. The surface is asked for elevations with a tolerance argument of 1e-6.
Limits
The footprint of a concave shape is overstated by the convex hull. Volume is given only for a closed mesh (every edge shared by exactly two triangles). The below-ground volume uses a grid of about 40 cells across the footprint.

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