Resources Numerical accuracy Calculation policy

Building mass areas, levels and base earthwork

Works out a building mass's footprint area, gross floor area, height, floor levels, the ground under it and the cut and fill to set it on the slope.

How the precision is kept

Areas, heights and levels are closed form in 64-bit. The ground under the footprint is sampled on a 40 × 40 grid over its bounds, and the cut and fill are sums over those samples with each sample standing for an equal share of the exact footprint area, so the earthwork follows the sampling.

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.

massing.js CivilCraft 0.8.1 64-bit 9 calculations Reviewed

Tests bin/test-civilcraft-massing.mjs

Footprint area

64-bit

The signed area of a mass outline.

A = Σ (x_i·y_j − x_j·y_i) ÷ 2

The shoelace sum on absolute project coordinates with no shift to a local origin: each product is about the square of the coordinate size, so with coordinates in the millions the products are near 1e12 to 1e13 and the area is resolved to about a thousandth of a square unit per corner rather than to 15 digits.

  • massing.js
  • line 56
  • CivilCraft 0.8.1
export function signedArea(pts) { let a = 0; for (let i = 0; i < pts.length; i++) { const p = pts[i], q = pts[(i + 1) % pts.length]; a += p.x * q.y - q.x * p.y; } return a / 2; }

Gross floor area and height

64-bit

The floor area of all floors and the height of the mass.

GFA = |A| × floors; height = floors × floor height

One multiplication each by the whole-number floor count.

  • massing.js
  • lines 57 to 59
  • CivilCraft 0.8.1
export const massArea = (m) => Math.abs(signedArea(m.pts));
export const massGfa = (m) => massArea(m) * m.floors;
export const massHeight = (m) => m.floors * m.floorHeight;

Centroid

64-bit

The center of area of the outline.

c_x = Σ (x_i + x_j)·k_ij ÷ 6A, k_ij = x_i·y_j − x_j·y_i

The standard polygon centroid on absolute coordinates; the sums involve cubes of the coordinate size and cancel more heavily than the area does. When |A| is under 1e-12 the mean of the corners is returned instead.

  • massing.js
  • lines 60 to 66
  • CivilCraft 0.8.1
export function centroid(pts) {
  const A = signedArea(pts);
  if (Math.abs(A) < 1e-12) return { x: pts.reduce((s, p) => s + p.x, 0) / pts.length, y: pts.reduce((s, p) => s + p.y, 0) / pts.length };
  let cx = 0, cy = 0;
  for (let i = 0; i < pts.length; i++) { const p = pts[i], q = pts[(i + 1) % pts.length], k = p.x * q.y - q.x * p.y; cx += (p.x + q.x) * k; cy += (p.y + q.y) * k; }
  return { x: cx / (6 * A), y: cy / (6 * A) };
}

Ground under a mass

64-bit

The lowest, highest and mean ground elevation under the footprint, and the samples used for earthwork.

sample (i, j) at x₀ + (i + 0.5)·dx, y₀ + (j + 0.5)·dy; dA = |A| ÷ samples; mean = Σ z ÷ n

Sample positions come from whole-number indexes. Each sample is given an equal share of the exact footprint area, which corrects the total area but not the shape at the edge. The mean is over grid samples only; the corners add to the lowest and highest. Samples off the surface count in the area share but not in the mean.

  • massing.js
  • lines 69 to 94
  • CivilCraft 0.8.1
/** Ground heights under the footprint: a grid of `cells` × `cells` over its bounds (cells inside it) plus its corners.
 *  elevAt(x, y) → z | null. Each sample carries the area it stands for (dA), so volumes are sums. */
export function groundUnder(m, elevAt, { cells = 40 } = {}) {
  const P = m.pts;
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
  for (const p of P) { 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 nx = cells, ny = cells, dx = (x1 - x0) / nx, dy = (y1 - y0) / ny;
  const samples = [];
  let missing = 0;
  for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) {
    const x = x0 + (i + 0.5) * dx, y = y0 + (j + 0.5) * dy;
    if (!pointInPoly(x, y, P)) continue;
    const z = elevAt(x, y);
    if (fin(z)) samples.push({ x, y, z }); else missing++;
  }
  // the cells cover the footprint to within a cell's width; scale their area to the exact footprint area
  const A = massArea(m), n = samples.length + missing;
  const cellArea = n ? A / n : 0;
  for (const s of samples) s.dA = cellArea;
  let min = Infinity, max = -Infinity, sum = 0, w = 0;
  const corners = P.map((p) => ({ x: p.x, y: p.y, z: elevAt(p.x, p.y) })).filter((c) => fin(c.z));
  for (const s of samples) { min = Math.min(min, s.z); max = Math.max(max, s.z); sum += s.z; w++; }
  for (const c of corners) { min = Math.min(min, c.z); max = Math.max(max, c.z); }
  if (!w) return corners.length ? { min, max, mean: corners.reduce((s, c) => s + c.z, 0) / corners.length, n: corners.length, missing: P.length - corners.length, cellArea, samples: [] } : null;
  return { min, max, mean: sum / w, n: w, missing, cellArea, samples };
}

Ground floor level

64-bit

The level the mass is set at: the mean ground, the highest ground (podium) or the lowest (cut in).

A choice among the three sampled values; no arithmetic.

  • massing.js
  • lines 96 to 99
  • CivilCraft 0.8.1
export function baseLevel(m, ground) {
  if (!ground) return 0;
  return m.base === 'podium' ? ground.max : m.base === 'cut' ? ground.min : ground.mean;
}

Cut and fill under a mass

64-bit

The earth to remove above the ground floor level and to place below it.

fill = Σ (z₀ − z)·dA where z < z₀; cut = Σ (z − z₀)·dA otherwise

Two running sums of same-sign terms over the samples, in 64-bit. The accuracy is that of the 40 × 40 sampling.

  • massing.js
  • lines 101 to 105
  • CivilCraft 0.8.1
export function baseVolumes(m, ground, z0) {
  let fill = 0, cut = 0;
  for (const s of ground?.samples || []) { if (s.z < z0) fill += (z0 - s.z) * s.dA; else cut += (s.z - z0) * s.dA; }
  return { fill, cut };
}

Floor levels

64-bit

The elevation of each floor and its top.

z_i = z₀ + i × floor height

Each level is one multiplication by the whole-number floor index and one addition, not a running sum.

  • massing.js
  • lines 115 to 117
  • CivilCraft 0.8.1
export function levels(m, z0) {
  return Array.from({ length: m.floors }, (_, i) => ({ n: i + 1, name: t('ms.level', { n: i + 1 }), z: z0 + i * m.floorHeight, top: z0 + (i + 1) * m.floorHeight, h: m.floorHeight }));
}

Floors for a pulled height

Exact

The floor count nearest to a height set by push and pull.

floors = clamp(round(H ÷ floor height), 1, 200)

The result is a whole number; the height the person pulled to is replaced by floors × floor height.

  • massing.js
  • lines 119 to 120
  • CivilCraft 0.8.1
/** Push/pull: the floor count whose height is nearest to H (at least one floor). */
export function setHeight(m, H) { return Math.max(1, Math.min(MAX_FLOORS, Math.round(H / m.floorHeight))); }

Triangles of a footprint

64-bit

Splits a simple polygon into triangles for the pad top and the roof.

Ear clipping with cross products compared against ±1e-12 (absolute, length squared) on absolute coordinates. When no ear is found the flattest corner is dropped so the loop ends, which leaves out a sliver.

  • massing.js
  • lines 123 to 150
  • CivilCraft 0.8.1
/** Ear clipping of a simple polygon, any winding; triangles as indices into pts, counter-clockwise. */
export function triangulate(pts) {
  const n = pts.length;
  const idx = pts.map((_, i) => i);
  if (signedArea(pts) < 0) idx.reverse();
  const out = [];
  const cross = (a, b, c) => (pts[b].x - pts[a].x) * (pts[c].y - pts[a].y) - (pts[b].y - pts[a].y) * (pts[c].x - pts[a].x);
  const inTri = (p, a, b, c) => cross(a, b, p) >= -1e-12 && cross(b, c, p) >= -1e-12 && cross(c, a, p) >= -1e-12;
  let guard = 0;
  while (idx.length > 3 && guard++ < n * n) {
    let clipped = false;
    for (let k = 0; k < idx.length; k++) {
      const a = idx[(k + idx.length - 1) % idx.length], b = idx[k], c = idx[(k + 1) % idx.length];
      if (cross(a, b, c) <= 1e-12) continue;   // reflex or flat
      let ear = true;
      for (const p of idx) { if (p === a || p === b || p === c) continue; if (inTri(p, a, b, c)) { ear = false; break; } }
      if (!ear) continue;
      out.push(a, b, c); idx.splice(k, 1); clipped = true; break;
    }
    if (!clipped) { // a degenerate sliver: drop the flattest corner so the loop always ends
      let best = 0, bv = Infinity;
      for (let k = 0; k < idx.length; k++) { const v = Math.abs(cross(idx[(k + idx.length - 1) % idx.length], idx[k], idx[(k + 1) % idx.length])); if (v < bv) { bv = v; best = k; } }
      idx.splice(best, 1);
    }
  }
  if (idx.length === 3 && cross(idx[0], idx[1], idx[2]) > 1e-12) out.push(idx[0], idx[1], idx[2]);
  return out;
}

In detail

Method
Shoelace area and centroid; ground sampled at cell centers inside the footprint; volume = Σ |z₀ − z| × (footprint area ÷ samples); ear-clipping triangulation.
Number types
64-bit numbers.
Tolerances
A mass with an area under 1e-9 is refused. Centroid falls back to the mean of the corners when |A| < 1e-12. Ear clipping uses 1e-12 on cross products. All absolute.
Limits
The mean ground and the base volumes depend on the 40 × 40 sampling. Samples where the surface has no elevation still take a share of the area but add no volume. The shoelace sums use absolute project coordinates.

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