Resources Numerical accuracy Calculation policy

Best route across the terrain

Finds the least-cost path between two points on the ground within a steepest grade and a tightest curve, and reports its length, steepest grade, smallest radius and earthwork.

How the precision is kept

The search samples the ground on a square grid and holds the grid elevations, the area factors and the accumulated cost of every state in Float32Array. The grade limit test during the search and the returned cost therefore use 32-bit values. The path points are cell centers computed in 64-bit, each point's elevation is read again from the surface in 64-bit, and the reported length, steepest grade, radius, cut and fill are recomputed in 64-bit from the finished path.

Open item. Hold the grid elevations and the accumulated cost in Float64Array so the grade limit test and the returned cost are 64-bit.

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.

route.js CivilCraft 0.8.1 32-bit in part 5 calculations Reviewed

Tests bin/test-civilcraft-route.mjs

Least-cost search

32-bit in part

Searches the grid for the cheapest allowed path and returns its points and total cost.

grade = |z₂ − z₁| ÷ length ≤ limit; cost = length × unit cost × factor × (1 + w·(grade ÷ limit)²) + area × length × earth cost

Grid elevations (z), area factors (fac) and accumulated costs (g) are Float32Array. The grade limit is tested on the difference of two 32-bit elevations, each new total is compared with and stored in the 32-bit array, and the returned cost is read back from it, so that cost carries about 7 digits. Cell indexes are truncated with a bitwise OR; these are indexes, not measured values.

  • route.js
  • lines 62 to 121
  • CivilCraft 0.8.1
const N = nx * ny, z = new Float32Array(N), fac = new Float32Array(N).fill(1);
for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) {
  const x = b.minX + (i + 0.5) * c, y = b.minY + (j + 0.5) * c, v = ground.elevationAt(x, y);
  z[j * nx + i] = v === null || v === undefined || !Number.isFinite(v) ? NaN : v;
}
const mark = (list, fn) => { for (const it of list || []) { const poly = it.poly || it; if (!poly || poly.length < 3) continue; for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) if (inPoly(poly, b.minX + (i + 0.5) * c, b.minY + (j + 0.5) * c)) fn(j * nx + i, it); } };
mark(opts.avoid, (k, it) => { fac[k] *= it.factor || 2; });
mark(opts.prefer, (k, it) => { fac[k] *= it.factor || 0.5; });
mark(opts.forbid, (k) => { fac[k] = Infinity; });
const cellOf = (p) => { const i = Math.floor((p.x - b.minX) / c), j = Math.floor((p.y - b.minY) / c); return i >= 0 && j >= 0 && i < nx && j < ny ? j * nx + i : -1; };
const s0 = cellOf(from), t0 = cellOf(to);
if (s0 < 0 || t0 < 0 || Number.isNaN(z[s0]) || Number.isNaN(z[t0])) return { found: false, reason: 'off-ground' };
if (!Number.isFinite(fac[s0]) || !Number.isFinite(fac[t0])) return { found: false, reason: 'forbidden-end' };
// allowed turns per incoming heading
const maxTurn = (l1, l2) => (o.minRadius > 0 ? (l1 + l2) / (2 * o.minRadius) + 1e-9 : Math.PI / 2 + 1e-9);
const turns = dirs.map((d1) => {
  const list = [];
  dirs.forEach((d2, k) => {
    let da = Math.abs(d2.a - d1.a); if (da > Math.PI) da = 2 * Math.PI - da;
    if (da <= maxTurn(d1.n * c, d2.n * c)) list.push(k);
  });
  return list;
});
const minFac = Math.min(1, ...[...(opts.prefer || [])].map((p) => p.factor || 0.5));
const tx = b.minX + ((t0 % nx) + 0.5) * c, ty = b.minY + (Math.floor(t0 / nx) + 0.5) * c;
const hOf = (k) => o.unitCost * minFac * Math.hypot(b.minX + ((k % nx) + 0.5) * c - tx, b.minY + (Math.floor(k / nx) + 0.5) * c - ty);
const g = new Float32Array(N * H).fill(Infinity), prev = new Int32Array(N * H).fill(-1), heap = new Heap();
for (let h = 0; h < H; h++) { g[s0 * H + h] = 0; heap.push(hOf(s0), s0 * H + h); }
const zAt = (x, y) => { const v = ground.elevationAt(x, y); return v === null || v === undefined ? NaN : v; };
const endTol = K * c * 1.0001;
let goal = -1, expanded = 0;
while (heap.size) {
  const s = heap.pop(), cell = (s / H) | 0, h = s - cell * H, gs = g[s];
  const cx = b.minX + ((cell % nx) + 0.5) * c, cy = b.minY + (Math.floor(cell / nx) + 0.5) * c;
  if (Math.hypot(cx - to.x, cy - to.y) <= Math.max(endTol * 0.5, c * 0.75) && Number.isFinite(gs)) { goal = s; break; }
  expanded++;
  const ci = cell % nx, cj = (cell - ci) / nx;
  for (const k2 of turns[h]) {
    const d = dirs[k2], ni = ci + d.dx, nj = cj + d.dy;
    if (ni < 0 || nj < 0 || ni >= nx || nj >= ny) continue;
    const nc = nj * nx + ni;
    if (Number.isNaN(z[nc]) || !Number.isFinite(fac[nc])) continue;
    const len = d.n * c, grade = Math.abs(z[nc] - z[cell]) / len;
    if (grade > o.maxGrade) continue;
    const f = (fac[cell] + fac[nc]) / 2;
    let cost = len * o.unitCost * f * (1 + o.gradeWeight * (grade / o.maxGrade) ** 2);
    if (o.width > 0 && o.earthCost > 0) {   // cut and fill the roadway's width needs across the slope at the middle of the step
      const mx = (cx + b.minX + (ni + 0.5) * c) / 2, my = (cy + b.minY + (nj + 0.5) * c) / 2, ux = -d.dy / d.n, uy = d.dx / d.n, w = o.width / 2;
      const zl = zAt(mx + ux * w, my + uy * w), zr = zAt(mx - ux * w, my - uy * w);
      if (Number.isFinite(zl) && Number.isFinite(zr)) {
        const s = Math.abs(zl - zr) / o.width, den = 1 - o.sideSlope * s;
        const area = den > 0.05 ? (o.width * o.width * s) / 4 / den : (o.width * o.width * 0.05) / 4 / 0.05;   // a slope steeper than the sides can hold: cap, very costly
        cost += area * len * o.earthCost;
      }
    }
    const ns = nc * H + k2, ng = gs + cost;
    if (ng < g[ns]) { g[ns] = ng; prev[ns] = s; heap.push(ng + hOf(nc), ns); }
  }
}
if (goal < 0) return { found: false, reason: expanded >= N * H * 0.98 ? 'unreachable' : (o.minRadius > 0 ? 'unreachable-curve' : 'unreachable'), expanded, cell: c, K };

Path points and their elevations

32-bit in part

Turns the chain of grid cells into the route's points, with the two ends at the picked points and collinear points removed.

x = minX + (i + 0.5) × cell; collinear when |cross product| < 1e-9 × cell²

Coordinates are 64-bit cell centers. Each point's elevation is read from the surface in 64-bit; only when the surface returns no finite value there is the 32-bit grid elevation used instead. The collinear test scales with the cell size.

  • route.js
  • lines 122 to 137
  • CivilCraft 0.8.1
const cells = [];
for (let s = goal; s >= 0; s = prev[s]) cells.push((s / H) | 0);
cells.reverse();
const pts = [{ x: from.x, y: from.y }];
for (let k = 1; k < cells.length - 1; k++) pts.push({ x: b.minX + ((cells[k] % nx) + 0.5) * c, y: b.minY + (Math.floor(cells[k] / nx) + 0.5) * c });
const last = pts[pts.length - 1], snapped = Math.hypot(to.x - last.x, to.y - last.y);
pts.push({ x: to.x, y: to.y });
// tidy: drop points that lie on a straight line
const path = [];
for (const p of pts) {
  const zz = zAt(p.x, p.y), q = { x: p.x, y: p.y, z: Number.isFinite(zz) ? zz : z[cellOf(p)] };
  if (path.length >= 2) { const a = path[path.length - 2], m = path[path.length - 1]; if (Math.abs((m.x - a.x) * (q.y - a.y) - (m.y - a.y) * (q.x - a.x)) < 1e-9 * c * c) { path[path.length - 1] = q; continue; } }
  path.push(q);
}
const st = routeStats(path, ground, o);
return { found: true, path, cost: g[goal], snapped, cell: c, K, expanded, ...st };

Route length, steepest grade and earthwork

64-bit

The length, steepest grade, cut and fill along a finished route.

length = Σ √(Δx² + Δy²); grade = |Δz| ÷ l; s = |z_left − z_right| ÷ width; area = width²·s ÷ 4 ÷ max(0.05, 1 − sideSlope·s); volume += area × l ÷ n

64-bit throughout, with elevations read from the surface. Length and volumes are running sums added in path order. The earthwork is a cross-slope estimate sampled about every roadway width along each leg, at most 50 samples per leg; the denominator is floored at 0.05 so a slope the sides cannot hold gives a capped area.

  • route.js
  • lines 140 to 158
  • CivilCraft 0.8.1
export function routeStats(path, ground, o = {}) {
  let length = 0, maxGrade = 0, cut = 0, fill = 0;
  for (let i = 1; i < path.length; i++) {
    const a = path[i - 1], b = path[i], l = Math.hypot(b.x - a.x, b.y - a.y);
    length += l;
    if (l > 0) maxGrade = Math.max(maxGrade, Math.abs(b.z - a.z) / l);
    if (o.width > 0 && l > 0) {   // earthwork along the line: sample every ~ width
      const n = Math.max(1, Math.ceil(l / Math.max(o.width, l / 50))), ux = -(b.y - a.y) / l, uy = (b.x - a.x) / l, w = o.width / 2;
      for (let s = 0; s < n; s++) {
        const t = (s + 0.5) / n, x = a.x + (b.x - a.x) * t, y = a.y + (b.y - a.y) * t, zl = ground.elevationAt(x + ux * w, y + uy * w), zr = ground.elevationAt(x - ux * w, y - uy * w), zc = ground.elevationAt(x, y);
        if (![zl, zr, zc].every((v) => v !== null && v !== undefined && Number.isFinite(v))) continue;
        const s2 = Math.abs(zl - zr) / o.width, den = Math.max(0.05, 1 - (o.sideSlope ?? 3) * s2), area = (o.width * o.width * s2) / 4 / den;
        // the roadway sits at the line's own profile (straight between the ends); the half of the cross-slope above it is cut, below it fill
        if (zc >= a.z + (b.z - a.z) * t) cut += (area * l) / n; else fill += (area * l) / n;
      }
    }
  }
  return { length, maxGrade, minRadius: minRadiusOf(path), earthwork: cut + fill, cut, fill };
}

Curve radius at each bend

64-bit

The turn angle and the radius implied at each bend of the route.

turn = atan2 of the leg after − atan2 of the leg before, wrapped to (−π, π]; radius = (l₁ + l₂) ÷ (2|turn|)

64-bit; angles from Math.atan2, so no acos of a rounded value. A turn smaller than 1e-9 radians is skipped, which also keeps the division from blowing up.

  • route.js
  • lines 160 to 172
  • CivilCraft 0.8.1
export function routeCurves(path) {
  const out = [];
  let d = 0;
  for (let i = 1; i < path.length - 1; i++) {
    const a = path[i - 1], b = path[i], c = path[i + 1], l1 = Math.hypot(b.x - a.x, b.y - a.y), l2 = Math.hypot(c.x - b.x, c.y - b.y);
    d += l1;
    const t1 = Math.atan2(b.y - a.y, b.x - a.x), t2 = Math.atan2(c.y - b.y, c.x - b.x);
    let turn = t2 - t1; while (turn > Math.PI) turn -= 2 * Math.PI; while (turn < -Math.PI) turn += 2 * Math.PI;
    if (Math.abs(turn) < 1e-9) continue;
    out.push({ at: d, x: b.x, y: b.y, turn, radius: (l1 + l2) / (2 * Math.abs(turn)) });
  }
  return out;
}

Route profile

64-bit

Distance along the route against elevation, for the profile drawing and the point table.

dᵢ = dᵢ₋₁ + √(Δx² + Δy²)

A running 64-bit sum of Math.hypot leg lengths in path order.

  • route.js
  • lines 176 to 180
  • CivilCraft 0.8.1
export function routeProfile(path) {
  const out = []; let d = 0;
  for (let i = 0; i < path.length; i++) { if (i) d += Math.hypot(path[i].x - path[i - 1].x, path[i].y - path[i - 1].y); out.push({ d, z: path[i].z }); }
  return out;
}

In detail

Method
A* search over (cell, heading) states; the file cites Hart, Nilsson and Raphael 1968. A turn is allowed when the deflection is at most (l₁ + l₂) ÷ (2R). Step cost = length × area factor × (1 + weight × (grade ÷ limit)²) plus cut and fill across the slope.
Number types
Float32Array for grid elevations, area factors and accumulated cost; Int32Array for back pointers; 64-bit numbers for the path, its statistics and the profile.
Tolerances
Turn limit plus 1e-9 radians; points are dropped as collinear when the cross product is below 1e-9 × cell²; a turn below 1e-9 radians is not counted as a curve; the cross-slope denominator is floored at 0.05; the goal is reached within max(0.5 × K × cell × 1.0001, 0.75 × cell) of the end point.
Limits
The route follows grid cell centers, so its accuracy is set by the cell size (by default 1/120 of the straight distance, enlarged by 1.25 until the state count fits). Earthwork is a cross-slope estimate sampled along the line, not a surface-to-surface volume. The grade between two cells is tested on 32-bit elevations.

Approach: 32-bit step, flagged

One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.

Every policy that takes this approach