Sets out paving, paths, walls, curbs, ramps and steps on the ground, checks grades, risers and goings, and totals their quantities.
How the precision is kept
Arithmetic is 64-bit. Paths and walls are worked out at stations along their line, with the ground read at each one, so steepest grade, face area and volume depend on the station spacing. Station positions are rounded to 6 decimals. The quantities handed to TakeoffCraft are rounded to 2 decimals.
Open item. Hand TakeoffCraft the unrounded quantities and leave rounding to the display.
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.
The plan length of a path, wall, curb or ramp line, and the point and direction at any distance along it.
L = Σ √(Δx² + Δy²)
Segment lengths come from Math.hypot and are added in order. A segment of 1e-9 or shorter is left out of the list of segments but its length is still added to the total.
landscape/hardscape.js
lines 111 to 118
CivilCraft 0.8.1
// ---- line helpers ----
function walk(P) {
const segs = [];
let L = 0;
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; }
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 }; };
return { segs, L, at };
}
Stations along a line
64-bit
The distances along a line where the ground is read and sections are built.
0, step, 2·step, … , L, and every bend; each rounded to 6 decimals
Stations are built by repeated addition of the step and then every one, including the end station and the bends, is rounded with Math.round(v × 1e6) ÷ 1e6. The last station can therefore differ from the line's length by up to 5e-7 project units.
landscape/hardscape.js
lines 119 to 125
CivilCraft 0.8.1
function stations(w, step) {
const out = [];
for (let s = 0; s < w.L; s += step) out.push(s);
out.push(w.L);
for (const g of w.segs) if (g.s0 > 0) out.push(g.s0);
return [...new Set(out.map((v) => Math.round(v * 1e6) / 1e6))].sort((a, b) => a - b);
}
Paving levels and falls
64-bit
The elevation of each corner of a paved area for a level surface, a cross fall, or a fall to a drain.
drain: z = z₀ + (pct ÷ 100) × distance to the drain; cross: the platform plane
The level is the typed one, else the sampled mean ground, else 0. With a drain every corner rises from the drain in proportion to its plan distance from Math.hypot, so the surface is defined at the corners only.
landscape/hardscape.js
lines 128 to 139
CivilCraft 0.8.1
// ---- paving ----
export function pavingSurface(p, surface) {
const z0 = p.level ?? meanGround(surface, p.pts) ?? 0;
const f = p.falls;
if (f.mode === 'cross') return { corners: planeZ(p.pts, z0, { pct: f.pct, dir: f.dir }), drain: null, z0 };
if (f.mode === 'drain' && f.drain) {
// the drain is the low point at the paving's level; every corner rises from it at the fall
const d = f.drain;
return { corners: p.pts.map((q) => ({ x: q.x, y: q.y, z: z0 + (f.pct / 100) * Math.hypot(q.x - d.x, q.y - d.y) })), drain: { x: d.x, y: d.y, z: z0 }, z0 };
}
return { corners: p.pts.map((q) => ({ x: q.x, y: q.y, z: z0 })), drain: null, z0 };
}
Path edges and steepest grade
64-bit
The center line and edges of a path on the ground, its length, and the steepest grade along it.
The ground is read at stations max(width ÷ 2, L ÷ 400) apart, and the steepest grade is the largest value between neighboring stations, so a short steep stretch between stations is averaged out. The divisor is floored at 1e-9. A stretch is flagged when its grade exceeds the limit by more than 1e-9.
landscape/hardscape.js
lines 199 to 220
CivilCraft 0.8.1
// ---- paths ----
export function pathStrip(path, surface) {
const w = walk(path.pts), out = { center: [], left: [], right: [], length: w.L, steepest: 0, steep: [] };
if (!w.segs.length) return out;
const step = Math.max(path.width / 2, w.L / 400);
let prev = null;
for (const s of stations(w, step)) {
const q = w.at(s), z = ground(surface, q.x, q.y);
if (z === null) continue;
const nx = -q.uy, ny = q.ux, h = path.width / 2, dz = (path.crossfall / 100) * path.width; // falls to the right side
out.center.push({ x: q.x, y: q.y, z, s });
out.left.push({ x: q.x + nx * h, y: q.y + ny * h, z: z + dz / 2 });
out.right.push({ x: q.x - nx * h, y: q.y - ny * h, z: z - dz / 2 });
if (prev) {
const g = Math.abs(z - prev.z) / Math.max(1e-9, s - prev.s) * 100;
if (g > out.steepest) out.steepest = g;
if (g > path.maxGrade + 1e-9) out.steep.push({ s: prev.s, s1: s, grade: g });
}
prev = { z, s };
}
return out;
}
Wall heights, face area and volume
64-bit
The top, ground and footing level of a wall at each station, with its maximum height, face area and volume.
Trapezoid sums between stations max(0.25 m or 0.75 ft, L ÷ 400) apart. Negative heights are counted as zero in the face area. The volume uses the wall thickness from footing level to top; the footing width and the batter are not in it. Math.floor with no tolerance decides the footing level, so a ground level exactly on a step can fall either way by rounding.
landscape/hardscape.js
lines 222 to 257
CivilCraft 0.8.1
// ---- walls ----
export function wallLayout(wl, surface, units) {
const D = hardDefaults(units), w = walk(wl.pts), out = { stations: [], maxHeight: 0, minHeight: Infinity, length: w.L, faceArea: 0, volume: 0, steps: 0, engineer: false, off: 0 };
if (!w.segs.length) return out;
const step = Math.max(units === 'm' ? 0.25 : 0.75, w.L / 400);
let lastFoot = null;
for (const s of stations(w, step)) {
const q = w.at(s);
let g = ground(surface, q.x, q.y);
if (g === null) { out.off++; continue; }
let top = wl.top ?? g + wl.height;
if (wl.grade) { // 0.8 LA.4: a wall that shapes the ground reads the surface it made: the top is the high side (or the typed top), the face the low side
const off = wl.thickness / 2 + (units === 'm' ? 0.05 : 0.15), gl = ground(surface, q.x - q.uy * off, q.y + q.ux * off), gr = ground(surface, q.x + q.uy * off, q.y - q.ux * off);
if (gl !== null && gr !== null) { top = wl.top ?? Math.max(gl, gr) + wl.coping.thickness; g = Math.min(gl, gr); }
} else if (wl.retain && wl.top == null) { // 0.8 LA.4: the top follows the high side of the ground, the face is what the low side shows
const off = wl.thickness / 2 + (units === 'm' ? 0.05 : 0.15), gl = ground(surface, q.x - q.uy * off, q.y + q.ux * off), gr = ground(surface, q.x + q.uy * off, q.y - q.ux * off);
if (gl !== null && gr !== null) { top = Math.max(gl, gr) + wl.coping.thickness; g = Math.min(gl, gr); }
}
const height = top - g;
// the footing's top sits `depth` below the ground, stepped down in whole `step`s so it is level between steps
const foot = Math.floor((g - wl.footing.depth) / wl.footing.step) * wl.footing.step;
if (lastFoot !== null && Math.abs(foot - lastFoot) > 1e-9) out.steps++;
lastFoot = foot;
out.stations.push({ s, x: q.x, y: q.y, ux: q.ux, uy: q.uy, ground: g, top, height, footing: foot });
out.maxHeight = Math.max(out.maxHeight, height);
out.minHeight = Math.min(out.minHeight, height);
}
for (let i = 1; i < out.stations.length; i++) {
const a = out.stations[i - 1], b = out.stations[i], ds = b.s - a.s;
out.faceArea += ds * (Math.max(0, a.height) + Math.max(0, b.height)) / 2;
out.volume += ds * wl.thickness * ((a.top - a.footing) + (b.top - b.footing)) / 2;
}
out.engineer = wl.wall === 'retaining' && out.maxHeight > D.wall.engineerFrom;
if (!out.stations.length) out.minHeight = 0;
return out;
}
Ramp grade and landings
64-bit
A ramp's rise, the number of runs and landings, its grade and the length it needs.
The ceiling subtracts 1e-9 first so a rise that is an exact multiple of the interval does not add a run. The grade is Infinity when the landings use up the whole length. The checks allow 1e-9 of slack, absolute.
The comfort constant is 0.63 m, or 2.083 ft in a feet project, which is 25 inches written to 4 digits. Tread nosing positions are built by adding the going repeatedly. The fit check allows 1e-9 of slack.
landscape/hardscape.js
lines 275 to 301
CivilCraft 0.8.1
export function stepsLayout(st, surface, units) {
const D = hardDefaults(units);
const [a, b] = st.pts, L = Math.hypot(b.x - a.x, b.y - a.y);
const zB = st.zBottom ?? ground(surface, a.x, a.y) ?? 0, zT = st.zTop ?? ground(surface, b.x, b.y) ?? 0;
const H = zT - zB;
if (!(H > 1e-6)) return { H, n: 0, riser: 0, going: 0, treads: 0, flights: 0, run: 0, length: L, fits: false, need: 0, checks: [{ key: 'height', ok: false, value: H }], treadLines: [], zB, zT };
// as BimCraft's stairs (res-stair.js): n = ⌈H / riser max⌉ risers of H / n, n − 1 treads (the top level is the last step);
// the going from the comfort rule 2R + G ≈ 630 mm, never under the minimum going
const n = Math.ceil(H / st.riserMax - 1e-9), riser = H / n;
const going = Math.max(st.goingMin, D.steps.comfort - 2 * riser);
const flights = Math.ceil(n / st.maxRisers), landings = flights - 1, treads = n - 1;
const run = treads * going + landings * (st.landing - going);
const ux = (b.x - a.x) / (L || 1), uy = (b.y - a.y) / (L || 1), nx = -uy, ny = ux, hw = st.width / 2;
const lines = [];
let s = 0;
for (let k = 1; k <= treads; k++) { // the nosing of each tread, measured from the bottom step's front edge
s += going;
if (landings && k % st.maxRisers === 0 && k < treads) s += st.landing - going;
lines.push([{ x: a.x + ux * s + nx * hw, y: a.y + uy * s + ny * hw }, { x: a.x + ux * s - nx * hw, y: a.y + uy * s - ny * hw }]);
}
const checks = [
{ key: 'riser', ok: riser <= st.riserMax + 1e-9, value: riser, limit: st.riserMax },
{ key: 'going', ok: going >= st.goingMin - 1e-9, value: going, limit: st.goingMin },
{ key: 'fits', ok: run <= L + 1e-9, value: run, limit: L },
];
return { H, n, riser, going, treads, flights, landings, run, length: L, fits: run <= L + 1e-9, need: Math.max(0, run - L), checks, treadLines: lines, zB, zT, ux, uy };
}
Ground strip under a retaining wall
64-bit
The two edges of the strip under a retaining wall, with the high side held at the ground or the typed top and the low side at the foot level.
high side = left when Σ (ground left − ground right) ≥ 0
The ground is read on each side at thickness ÷ 2 plus 0.05 m or 0.15 ft from the center line, at every station. Which side is high is decided once for the whole wall from the sign of a sum. A strip with a shoelace area under 1e-9 is refused.
landscape/hardscape.js
lines 304 to 335
CivilCraft 0.8.1
/**
* A retaining wall as a ground modifier (0.8 LA.4). The strip under the wall (its thickness wide) is rebuilt: the edge on the HIGH side (the side whose ground
* is higher over the whole wall, so it never flips along it) is held at the ground there, station by station, so the wall's top follows the high side; a typed
* `top` holds it at top minus the coping instead. The edge on the LOW side is held at `foot`, or at the ground there. The ground beyond the strip is not changed.
*/
export function wallPatch(h, surf, units) {
const fail = (key) => ({ problems: [t(key)], region: null, points: [], breaklines: [], info: null });
const w = walk(h.pts);
if (!w.segs.length) return fail('ls.h.err.wallshort');
const hw = h.thickness / 2, off = hw + (units === 'm' ? 0.05 : 0.15), rows = [];
let offCount = 0;
for (const s of stations(w, Math.max(units === 'm' ? 0.25 : 0.75, w.L / 400))) {
const q = w.at(s), nx = -q.uy, ny = q.ux, gl = ground(surf, q.x + nx * off, q.y + ny * off), gr = ground(surf, q.x - nx * off, q.y - ny * off);
if (gl === null || gr === null) { offCount++; continue; }
rows.push({ q, nx, ny, gl, gr });
}
if (rows.length < 2) return fail('ls.g.err.off');
const hiLeft = rows.reduce((v, r) => v + (r.gl - r.gr), 0) >= 0;
const left = [], right = [];
for (const r of rows) {
const hi = h.top != null ? h.top - h.coping.thickness : hiLeft ? r.gl : r.gr, lo = h.foot != null ? h.foot : hiLeft ? r.gr : r.gl;
left.push({ x: r.q.x + r.nx * hw, y: r.q.y + r.ny * hw, z: hiLeft ? hi : lo });
right.push({ x: r.q.x - r.nx * hw, y: r.q.y - r.ny * hw, z: hiLeft ? lo : hi });
}
const region = [...left, ...right.slice().reverse()].map((q) => ({ x: q.x, y: q.y }));
let a = 0;
for (let i = 0; i < region.length; i++) { const p = region[i], q = region[(i + 1) % region.length]; a += p.x * q.y - q.x * p.y; }
if (Math.abs(a) / 2 < 1e-9) return fail('ls.g.err.flat');
const mean = (arr) => arr.reduce((v, q) => v + q.z, 0) / arr.length;
return { problems: [], region, points: [], breaklines: [{ name: h.name + ' L', pts: left }, { name: h.name + ' R', pts: right }, { name: h.name + ' start', pts: [left[0], right[0]] }, { name: h.name + ' end', pts: [left.at(-1), right.at(-1)] }],
info: { kind: 'wall', hiLeft, high: mean(hiLeft ? left : right), low: mean(hiLeft ? right : left), off: offCount } };
}
Hardscape quantities for takeoff
64-bit
The areas, layer volumes and lengths of paving, paths, walls, curbs and ramps passed to TakeoffCraft.
qty = round(v × 100) ÷ 100; layer volume = area × thickness (÷ 27 for cubic yards)
Every quantity is rounded to 2 decimals before it is handed over, so the receiving app does not get the full value. The 27 cubic feet per cubic yard is exact. Path and ramp areas are plan length × width, not the area of the strip polygon.
landscape/hardscape.js
lines 490 to 505
CivilCraft 0.8.1
export function hardQuantities(ls, surface, units) {
const m = units === 'm', A = m ? 'm²' : 'SF', L = m ? 'm' : 'LF', V = m ? 'm³' : 'CY', vPer = m ? 1 : 27, r2 = (v) => Math.round(v * 100) / 100; // i18n-ignore: TakeoffCraft's unit symbols
const out = [];
for (const h of ls?.hardscape || []) {
if (h.kind === 'paving') {
const a = Math.abs(area(h.pts));
out.push({ key: 'hard:' + h.id + ':area', name: t('ls.h.q.paving', { name: h.name }), unit: A, qty: r2(a) });
h.layers.forEach((l, i) => out.push({ key: 'hard:' + h.id + ':layer' + i, name: t('ls.h.q.layer', { name: h.name, layer: l.name }), unit: V, qty: r2((a * l.thickness) / vPer) }));
} else if (h.kind === 'path') { const st = pathStrip(h, surface); out.push({ key: 'hard:' + h.id + ':area', name: t('ls.h.q.path', { name: h.name }), unit: A, qty: r2(st.length * h.width) }); }
else if (h.kind === 'wall') { const w = wallLayout(h, surface, units); out.push({ key: 'hard:' + h.id + ':face', name: t('ls.h.q.wallface', { name: h.name }), unit: A, qty: r2(w.faceArea) }, { key: 'hard:' + h.id + ':len', name: t('ls.h.q.walllen', { name: h.name }), unit: L, qty: r2(w.length) }); }
else if (h.kind === 'kerb') out.push({ key: 'hard:' + h.id + ':len', name: t('ls.h.q.kerb', { name: h.name }), unit: L, qty: r2(walk(h.pts).L) });
else if (h.kind === 'steps') { const s = stepsLayout(h, surface, units); out.push({ key: 'hard:' + h.id + ':risers', name: t('ls.h.q.steps', { name: h.name }), unit: 'EA', qty: s.n }); } // i18n-ignore: a unit symbol
else if (h.kind === 'ramp') { const r = rampLayout(h, surface, units); out.push({ key: 'hard:' + h.id + ':area', name: t('ls.h.q.ramp', { name: h.name }), unit: A, qty: r2(r.length * h.width) }); }
}
return out;
}
In detail
Method
Stations at a fixed step plus every bend. Wall face area and volume by the trapezoid rule between stations. Steps: n = ⌈H ÷ riser max⌉ risers of H ÷ n and a going from the comfort rule 2R + G, as the code comments say. Ramp grade over the sloped length left after landings.
Number types
64-bit numbers in plain objects.
Tolerances
Segments of 1e-9 or shorter are dropped. Stations rounded to 1e-6. Ceilings use a slack of 1e-9 (risers, ramp runs). Grade, width, riser, going and fit checks use an absolute slack of 1e-9. A height under 1e-6 gives no steps. Footing steps are counted when the footing level changes by more than 1e-9.
Limits
Path steepest grade and wall heights are sampled: path step max(width ÷ 2, L ÷ 400), wall step max(0.25 m or 0.75 ft, L ÷ 400). Path and ramp areas are plan length × width. The comfort constant is 0.63 m or 2.083 ft.
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.