Landscape grading: platforms, swales, slope classes and ponding
Shapes the proposed ground from platforms, berms, swales and free grading areas, and reads the surface for slope classes and ponds.
How the precision is kept
All arithmetic is 64-bit and the surface arrays read here are the terrain model's own. Several results rest on samples: a platform's default level is the mean of ground elevations at its corners and edge midpoints, a swale is built at stations along its line, and a grading area's edge is sampled at a spacing tied to its size. Ponding is worked out on the surface's vertices, so its area and volume depend on the triangle size.
Open item. Scale the ponding threshold to the project unit, since 1e-3 is used for both feet and meters.
Calculations under this policy
6 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 elevation of each corner of a platform that falls at a given percent in a given direction.
z = z₀ − (pct ÷ 100) × ((x − cx)·cos a + (y − cy)·sin a)
The plane passes through the mean of the corner coordinates at z₀, which is not the area centroid for an irregular outline. The direction in degrees is multiplied by Math.PI ÷ 180.
landscape/grade.js
lines 85 to 91
CivilCraft 0.8.1
/** A sloped platform's corner elevations: a plane through the centroid at z0 falling `pct` % towards `dir`. */
export function planeZ(pts, z0, fall) {
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length, cy = pts.reduce((s, p) => s + p.y, 0) / pts.length;
if (!fall) return pts.map((p) => ({ x: p.x, y: p.y, z: z0 }));
const a = (fall.dir * Math.PI) / 180, g = fall.pct / 100;
return pts.map((p) => ({ x: p.x, y: p.y, z: z0 - g * ((p.x - cx) * Math.cos(a) + (p.y - cy) * Math.sin(a)) }));
}
Mean ground elevation under an outline
64-bit
The level used for a platform, berm or paving when none is typed.
mean of ground elevations at every vertex and edge midpoint
A plain average of at most two samples per edge, added in order; samples that fall off the surface are left out. It is a sampled mean along the outline, not an average over the area inside.
landscape/grade.js
lines 92 to 101
CivilCraft 0.8.1
/** The mean ground elevation along an outline (vertices and edge midpoints), or null when it is off the surface. */
export function meanGround(surface, pts) {
if (!surface) return null;
let s = 0, n = 0;
for (let i = 0; i < pts.length; i++) {
const a = pts[i], b = pts[(i + 1) % pts.length];
for (const q of [a, { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }]) { const z = surface.elevationAt(q.x, q.y, 1e-6); if (z !== null) { s += z; n++; } }
}
return n ? s / n : null;
}
Swale sections along a line
64-bit
The bottom elevations and the left and right daylight lines of a swale, as a patch of the proposed surface.
z bottom = z start − depth − (fall ÷ 100) × s, or ground − depth, never rising; step = max(0.5 m or 1.5 ft, min(L ÷ 200, max(bottom, 1 m or 3 ft)))
Stations are built by adding the step repeatedly, with one more at each bend and at the end. The ground is read at each station only, so the bottom follows the samples. The reported fall is (first bottom − last bottom) ÷ L over the full line length.
landscape/grade.js
lines 103 to 143
CivilCraft 0.8.1
/** A swale as a gradedSurface patch: a flat-bottomed channel (or a V when bottom = 0) along the line, its sides cast to the ground. */
export function swalePatch(surface, sw, units) {
const P = sw.pts, problems = [];
let L = 0;
const segs = [];
for (let i = 0; i + 1 < P.length; i++) { const l = Math.hypot(P[i + 1].x - P[i].x, P[i + 1].y - P[i].y); if (l > 1e-9) segs.push({ a: P[i], b: P[i + 1], l, s0: L }); L += l; }
if (!segs.length) return { problems: [t('ls.g.err.short')], region: null, points: [], breaklines: [], info: null };
const step = Math.max(units === 'm' ? 0.5 : 1.5, Math.min(L / 200, Math.max(sw.bottom, units === 'm' ? 1 : 3)));
const stations = [];
for (let s = 0; s < L; s += step) stations.push(s);
stations.push(L);
for (const g of segs) if (g.s0 > 0) stations.push(g.s0); // every bend gets a section
stations.sort((a, b) => a - b);
const at = (s) => { const g = segs.find((q) => s <= q.s0 + q.l + 1e-9) || segs[segs.length - 1]; const f = (s - g.s0) / g.l; return { x: g.a.x + f * (g.b.x - g.a.x), y: g.a.y + f * (g.b.y - g.a.y), ux: (g.b.x - g.a.x) / g.l, uy: (g.b.y - g.a.y) / g.l }; };
const g0 = surface.elevationAt(P[0].x, P[0].y, 1e-6);
if (g0 === null) return { problems: [t('ls.g.err.off')], region: null, points: [], breaklines: [], info: null };
const left = [], right = [], bl = [], br = [], cl = [], rays = [];
let last = Infinity;
for (const s of stations) {
const q = at(s), gz = surface.elevationAt(q.x, q.y, 1e-6);
if (gz === null) { problems.push(t('ls.g.err.offat', { s: s.toFixed(1) })); continue; }
let zb = sw.fall ? g0 - sw.depth - (sw.fall / 100) * s : gz - sw.depth;
if (!sw.fall) zb = Math.min(zb, last); // following the ground, the bottom never runs uphill
last = zb;
const nx = -q.uy, ny = q.ux, h = sw.bottom / 2;
const pl = { x: q.x + nx * h, y: q.y + ny * h, z: zb }, pr = { x: q.x - nx * h, y: q.y - ny * h, z: zb };
const rl = castSlope(surface, pl, nx, ny, zb, sw.side, sw.side), rr = castSlope(surface, pr, -nx, -ny, zb, sw.side, sw.side);
left.push({ x: rl.x, y: rl.y, z: rl.z }); right.push({ x: rr.x, y: rr.y, z: rr.z });
bl.push(pl); br.push(pr); cl.push({ x: q.x, y: q.y, z: zb });
rays.push(rl, rr);
}
if (left.length < 2) return { problems: problems.length ? problems : [t('ls.g.err.off')], region: null, points: [], breaklines: [], info: null };
const region = [...left, ...right.slice().reverse()];
if (Math.abs(polyArea(region)) < 1e-9) return { problems: [t('ls.g.err.flat')], region: null, points: [], breaklines: [], info: null };
const breaklines = [{ name: 'swale-left', pts: left }, { name: 'swale-right', pts: right }];
if (sw.bottom > 1e-6) breaklines.push({ name: 'swale-bottom-left', pts: bl }, { name: 'swale-bottom-right', pts: br });
else breaklines.push({ name: 'swale-bottom', pts: cl });
breaklines.push({ name: 'swale-end', pts: [left[0], ...(sw.bottom > 1e-6 ? [bl[0], br[0]] : [cl[0]]), right[0]] });
breaklines.push({ name: 'swale-end', pts: [left.at(-1), ...(sw.bottom > 1e-6 ? [bl.at(-1), br.at(-1)] : [cl.at(-1)]), right.at(-1)] });
return { problems: [], region, points: [], breaklines, info: { kind: 'swale', bottom: cl, left, right, rays, length: L, fallAt: cl.length > 1 ? (cl[0].z - cl.at(-1).z) / L : 0, offNotes: problems } };
}
Edge of a free grading area
64-bit
The outline of a grading area with ground elevations, plus the spot elevations and contours inside it.
edge spacing = √|area| ÷ 20
Each edge is divided into equal parts no longer than √|area| ÷ 20 and the existing ground is read at each point. Spots and contour points are kept only when a point-in-polygon test puts them inside.
landscape/grade.js
lines 145 to 157
CivilCraft 0.8.1
/** A grading area: inside its outline the surface is rebuilt from the edge (on the ground below it), the spots and contours. */
function areaPatch(surface, a) {
const edge = [];
for (let i = 0; i < a.pts.length; i++) {
const p = a.pts[i], q = a.pts[(i + 1) % a.pts.length];
const n = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.y - p.y) / Math.max(1e-6, Math.sqrt(Math.abs(polyArea(a.pts))) / 20)));
for (let k = 0; k < n; k++) { const x = p.x + ((q.x - p.x) * k) / n, y = p.y + ((q.y - p.y) * k) / n; const z = surface.elevationAt(x, y, 1e-6); if (z === null) return { problems: [t('ls.g.err.off')], region: null, points: [], breaklines: [], info: null }; edge.push({ x, y, z }); }
}
const inside = (p) => pointInPoly(p.x, p.y, a.pts);
const spots = a.spots.filter(inside).map((s) => ({ x: s.x, y: s.y, z: s.z }));
const contours = a.contours.map((c) => ({ name: 'contour ' + c.z, pts: c.pts.filter(inside).map((p) => ({ x: p.x, y: p.y, z: c.z })) })).filter((c) => c.pts.length >= 2);
return { problems: [], region: a.pts.map((p) => ({ x: p.x, y: p.y })), points: spots, breaklines: [{ name: a.name, pts: [...edge, edge[0]] }, ...contours], info: { kind: 'area', spots: spots.length, contours: contours.length } };
}
Triangles and area by slope class
64-bit
Sorts the surface's triangles into slope bands and totals the plan area in each.
class = number of limits the slope exceeds; area = |(b − a) × (d − a)| ÷ 2
The slope comes from the surface's slopeOf. A slope must exceed a limit by more than 1e-12 to move up a class. Areas are computed in the surface's local coordinates, before the origin offset is added back, and summed in triangle order.
landscape/grade.js
lines 183 to 196
CivilCraft 0.8.1
/** Triangles by gradient: cls 0 = flatter than limits[0], k = steeper than limits[k-1] (limits as fractions, e.g. 0.05, 0.333). */
export function slopeClasses(surface, limits = [0.05, 1 / 3]) {
const { X, Y, tris, nT, ox, oy } = surface, counts = new Array(limits.length + 1).fill(0), out = [], area = new Array(limits.length + 1).fill(0);
for (let k = 0; k < nT; k++) {
const s = surface.slopeOf(k).slope;
let c = 0;
while (c < limits.length && s > limits[c] + 1e-12) c++;
const a = tris[3 * k], b = tris[3 * k + 1], d = tris[3 * k + 2];
const ar = Math.abs((X[b] - X[a]) * (Y[d] - Y[a]) - (Y[b] - Y[a]) * (X[d] - X[a])) / 2;
counts[c]++; area[c] += ar;
out.push({ cls: c, slope: s, pts: [{ x: X[a] + ox, y: Y[a] + oy }, { x: X[b] + ox, y: Y[b] + oy }, { x: X[d] + ox, y: Y[d] + oy }] });
}
return { counts, area, tris: out };
}
Ponds: depth, area and volume
64-bit
Where water would stand on the surface, and each pond's depth, area, volume and spill level.
W(v) = max(Z(v), lowest level reached from the edge); volume = Σ triangle area × mean depth of its 3 corners
Water levels are a Float64Array. A vertex is wet when W − Z exceeds 1e-3, an absolute value used for feet and meters alike. Only triangles with all three corners in the pond add area and volume, so both are low by the rim triangles.
landscape/grade.js
lines 215 to 259
CivilCraft 0.8.1
/** Ponding: every vertex filled to the lowest level water can drain out at (priority-flood from the surface's outer edge,
* Barnes, Lehman & Mulla 2014); a pond is a connected group of vertices filled above their ground by more than `tol`. */
export function ponding(surface, { tol = 1e-3 } = {}) {
const { X, Y, Z, tris, ox, oy } = surface, n = X.length;
const used = surface.usedVertices();
const adj = Array.from({ length: n }, () => []), edgeCount = new Map();
const key = (a, b) => (a < b ? a * n + b : b * n + a);
for (let k = 0; k < tris.length; k += 3) for (let e = 0; e < 3; e++) {
const a = tris[k + e], b = tris[k + ((e + 1) % 3)];
adj[a].push(b);
const kk = key(a, b);
edgeCount.set(kk, (edgeCount.get(kk) || 0) + 1);
}
const W = new Float64Array(n).fill(Infinity), done = new Uint8Array(n);
// a binary heap of [level, vertex]
const heap = [];
const push = (lv, v) => { heap.push([lv, v]); let i = heap.length - 1; while (i > 0) { const p = (i - 1) >> 1; if (heap[p][0] <= heap[i][0]) break; [heap[p], heap[i]] = [heap[i], heap[p]]; i = p; } };
const pop = () => { const top = heap[0], last = heap.pop(); if (heap.length) { heap[0] = last; let i = 0; for (;;) { const l = 2 * i + 1, r = l + 1; let m = i; if (l < heap.length && heap[l][0] < heap[m][0]) m = l; if (r < heap.length && heap[r][0] < heap[m][0]) m = r; if (m === i) break; [heap[m], heap[i]] = [heap[i], heap[m]]; i = m; } } return top; };
for (const [kk, c] of edgeCount) if (c === 1) { const a = Math.floor(kk / n), b = kk % n; for (const v of [a, b]) if (!done[v]) { done[v] = 1; W[v] = Z[v]; push(Z[v], v); } } // the outer edge drains
while (heap.length) {
const [lv, v] = pop();
for (const u of adj[v]) if (!done[u] && used[u]) { done[u] = 1; W[u] = Math.max(Z[u], lv); push(W[u], u); }
}
const seen = new Uint8Array(n), ponds = [];
const triArea = (a, b, c) => Math.abs((X[b] - X[a]) * (Y[c] - Y[a]) - (Y[b] - Y[a]) * (X[c] - X[a])) / 2;
const wet = (v) => W[v] - Z[v] > tol;
for (let v = 0; v < n; v++) {
if (seen[v] || !used[v] || !wet(v)) continue;
const verts = [], stack = [v];
seen[v] = 1;
while (stack.length) { const a = stack.pop(); verts.push(a); for (const u of adj[a]) if (!seen[u] && wet(u)) { seen[u] = 1; stack.push(u); } }
const set = new Set(verts);
let depth = 0, area = 0, volume = 0, lx = 0, ly = 0;
for (const a of verts) { depth = Math.max(depth, W[a] - Z[a]); lx += X[a]; ly += Y[a]; }
for (let k = 0; k < tris.length; k += 3) { // triangles with all three corners wet: area and (mean depth × area) volume
const a = tris[k], b = tris[k + 1], c = tris[k + 2];
if (!set.has(a) && !set.has(b) && !set.has(c)) continue;
const ar = triArea(a, b, c), d = ((W[a] - Z[a]) + (W[b] - Z[b]) + (W[c] - Z[c])) / 3;
if (set.has(a) && set.has(b) && set.has(c)) { area += ar; volume += ar * Math.max(0, d); }
}
ponds.push({ depth, area, volume, spill: W[verts[0]], x: lx / verts.length + ox, y: ly / verts.length + oy, verts });
}
ponds.sort((a, b) => b.volume - a.volume || b.depth - a.depth);
return { ponds, fill: W };
}
In detail
Method
Platform plane through the mean of the corners. Swale sections at stations with side slopes cast to the ground by castSlope in grading.js. Ponding by priority-flood from the surface's outer edge; the code comment cites Barnes, Lehman and Mulla 2014.
Number types
64-bit numbers; Float64Array for the filled water levels; Uint8Array flags.
Tolerances
Swale segments of 1e-9 or shorter are ignored; a swale region with area under 1e-9 is refused; a bottom width over 1e-6 counts as flat-bottomed. Slope classes use a slack of 1e-12 on each limit. A vertex is wet when the water stands more than 1e-3 above it, the same number in feet and in meters. Elevation lookups pass a tolerance of 1e-6.
Limits
Pond area and volume count only triangles with all three corners wet, so the pond's rim triangles are left out. The mean ground under a platform comes from 2 samples per edge.
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.