Resources Numerical accuracy Calculation policy

Pad daylight lines and the graded surface

Finds where the cut and fill slopes around a pad meet the existing ground, and builds the proposed surface from the pad, its slopes and the ground outside them.

How the precision is kept

Each slope line is found by stepping outward along a ray until the design slope crosses the ground, then bisecting. The distance along the ray is settled to 1e-7 project units; the step length decides which crossing is found. Rays are spaced along the edges and fanned round corners, so the daylight line is a polyline through sampled points. All arithmetic is 64-bit.

Calculations under this policy

7 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.

grading.js CivilCraft 0.8.1 64-bit 7 calculations Reviewed

Tests bin/test-civilcraft.mjs, bin/test-civilcraft-walls.mjs

One slope line to the ground

64-bit

The point where a cut or fill slope leaving the pad edge meets the surface.

f(d) = z + g·d − ground(p₀ + u·d); g = rate ÷ cut or −rate ÷ fill; find d with f(d) = 0

Marches in fixed steps until f changes sign, then bisects until the bracket is 1e-7 or 60 halvings, and returns the middle of the bracket, so the distance is within 5e-8 project units of the crossing that was bracketed. The tolerance is absolute. A ray that leaves the surface or reaches the far limit is returned as 'off'.

  • grading.js
  • lines 119 to 139
  • CivilCraft 0.8.1
/**
 * One slope line: from p0 (at design elevation z) outward along (ux, uy), up at `rate`/cut where the
 * ground is above z, down at `rate`/fill where it is below, to where it meets the surface.
 * → { x0, y0, x, y, z, d, kind: 'cut'|'fill'|'flat'|'off' }   ('off': left the surface first)
 */
export function castSlope(surface, p0, ux, uy, z, cut, fill, { rate = 1, step = 0.25, far = 1e4 } = {}) {
  const z0 = surface.elevationAt(p0.x, p0.y, 1e-6);
  if (z0 === null) return { x0: p0.x, y0: p0.y, x: p0.x, y: p0.y, z, d: 0, kind: 'off' };
  if (Math.abs(z0 - z) < 1e-9) return { x0: p0.x, y0: p0.y, x: p0.x, y: p0.y, z, d: 0, kind: 'flat' };
  const kind = z0 > z ? 'cut' : 'fill';
  const g = kind === 'cut' ? rate / cut : -rate / fill;   // design rise per unit outward
  const f = (d) => { const e = surface.elevationAt(p0.x + ux * d, p0.y + uy * d, 1e-6); return e === null ? null : z + g * d - e; };
  // f(0) has the sign of (design − ground); march until it changes, then bisect
  const s0 = Math.sign(f(0));
  let a = 0, b = step, fb = f(b);
  while (fb !== null && Math.sign(fb) === s0 && b < far) { a = b; b += step; fb = f(b); }
  if (fb === null || b >= far) return { x0: p0.x, y0: p0.y, x: p0.x + ux * a, y: p0.y + uy * a, z: z + g * a, d: a, kind: 'off' };
  for (let it = 0; it < 60 && b - a > 1e-7; it++) { const m = (a + b) / 2, fm = f(m); if (fm !== null && Math.sign(fm) === s0) a = m; else b = m; }
  const d = (a + b) / 2;
  return { x0: p0.x, y0: p0.y, x: p0.x + ux * d, y: p0.y + uy * d, z: z + g * d, d, kind };
}

Rays round the pad

64-bit

The start, direction and climb rate of every slope line: square to each edge, fanned at convex corners, along the bisector at concave corners.

turn = atan2(n₁ × n₂, n₁ · n₂); concave rate = cos(|turn| ÷ 2); wall mitre = thickness ÷ max(0.25, cos(|turn| ÷ 2))

Edge normals are divided by the edge length; consecutive corners closer than 1e-9 are removed first, so the length is not zero. The turn angle comes from atan2 of the cross and dot products of unit normals and is compared with ±1e-9 rad. Points along an edge and their elevations are linear interpolations at j ÷ m. A mitre between two walls is capped at 4 times the wall thickness.

  • grading.js
  • lines 61 to 87
  • CivilCraft 0.8.1
const edgeN = (i) => { const a = poly[i], b = poly[(i + 1) % n], L = Math.hypot(b.x - a.x, b.y - a.y); return { x: (b.y - a.y) / L, y: -(b.x - a.x) / L, L }; };   // outward (right of a counter-clockwise edge)
for (let i = 0; i < n; i++) {
  const v = poly[i], nIn = edgeN((i - 1 + n) % n), nOut = edgeN(i);
  const sIn = sideOf((i - 1 + n) % n), sOut = sideOf(i);   // 0.7.1 71.22
  const turn = Math.atan2(nIn.x * nOut.y - nIn.y * nOut.x, nIn.x * nOut.x + nIn.y * nOut.y);   // > 0 at a convex corner
  if (sIn.wall && sOut.wall && Math.abs(turn) > 1e-9) {   // two walls: a mitre
    const bx = nIn.x + nOut.x, by = nIn.y + nOut.y, L = Math.hypot(bx, by);
    rays.push(wall(v, bx / L, by / L, wallT / Math.max(0.25, Math.cos(Math.abs(turn) / 2)), zv(v), sOut.k));
  } else if (turn > 1e-9) {
    // a wall's end: its own ray, then the slope's fan round the corner (without the ray along the wall)
    const a0 = Math.atan2(nIn.y, nIn.x), k = Math.max(1, Math.ceil(turn / fanStep));
    if (sIn.wall) rays.push(wall(v, nIn.x, nIn.y, wallT, zv(v), sIn.k));
    for (let j = sIn.wall ? 1 : 0; j <= (sOut.wall ? k - 1 : k); j++) { const a = a0 + (turn * j) / k; rays.push(cast(v, Math.cos(a), Math.sin(a), 1, zv(v), j * 2 <= k ? (sIn.wall ? sOut : sIn) : (sOut.wall ? sIn : sOut))); }
    if (sOut.wall) rays.push(wall(v, nOut.x, nOut.y, wallT, zv(v), sOut.k));
  } else if (turn < -1e-9) {
    const bx = nIn.x + nOut.x, by = nIn.y + nOut.y, L = Math.hypot(bx, by);
    rays.push(cast(v, bx / L, by / L, Math.cos(Math.abs(turn) / 2), zv(v), sIn.wall ? sOut : sIn));
    concave.push({ i, v, bx: bx / L, by: by / L });
  } else rays.push(sOut.wall || sIn.wall ? wall(v, nOut.x, nOut.y, wallT, zv(v), sOut.wall ? sOut.k : sIn.k) : cast(v, nOut.x, nOut.y, 1, zv(v), sOut));   // in line: a wall runs to the corner
  // along the edge
  const b = poly[(i + 1) % n], m = Math.max(1, Math.ceil(nOut.L / sp));
  for (let j = 1; j < m; j++) {
    const t = j / m, p = { x: v.x + (b.x - v.x) * t, y: v.y + (b.y - v.y) * t }, ze = zv(v) + (zv(b) - zv(v)) * t;
    const r = sOut.wall ? wall(p, nOut.x, nOut.y, wallT, ze, sOut.k) : cast(p, nOut.x, nOut.y, 1, ze, sOut);
    r.edge = i; rays.push(r);
  }
}

Rays trimmed at a concave corner

64-bit

Drops edge rays that would reach past the line where two slopes meet in an inside corner.

side(x, y) = b_x·(y − v_y) − b_y·(x − v_x); drop when the ray's ends have opposite signs

A sign test on a cross product with a dead band of ±1e-9. The band is absolute; the cross product is a distance from the bisector because the bisector is a unit vector.

  • grading.js
  • lines 89 to 97
  • CivilCraft 0.8.1
for (const c of concave) {
  const side = (x, y) => c.bx * (y - c.v.y) - c.by * (x - c.v.x);
  for (let k = rays.length - 1; k >= 0; k--) {
    const r = rays[k];
    if (r.edge !== c.i && r.edge !== (c.i - 1 + n) % n) continue;
    const s0 = side(r.x0, r.y0), s1 = side(r.x, r.y);
    if ((s0 > 1e-9 && s1 < -1e-9) || (s0 < -1e-9 && s1 > 1e-9)) rays.splice(k, 1);
  }
}

Pad, slopes and daylight line as breaklines

64-bit

The lines that go into the proposed surface for one pad: its edge, its daylight line and every slope line longer than 1e-6.

Coordinates and elevations are copied from the daylight result without rounding. Slope lines of length 1e-6 or less are left out.

  • grading.js
  • lines 175 to 183
  • CivilCraft 0.8.1
function padPatch(surf, pad) {
  const dl = daylightPad(surf, pad, pad.options || {});
  if (dl.problems.length || !dl.rays.length) return { problems: dl.problems.length ? dl.problems : [t('grade.none')], region: null, points: [], breaklines: [], info: dl };
  const region = dl.daylight, padZ = Number(pad.z), bl = [], zv = (p) => (Number.isFinite(p.z) ? p.z : padZ);
  bl.push({ name: t('grade.bl.edge', { name: pad.name || t('scene.pad.default') }), pts: [...dl.poly, dl.poly[0]].map((p) => ({ x: p.x, y: p.y, z: zv(p) })) });
  bl.push({ name: t('grade.bl.daylight', { name: pad.name || t('scene.pad.default') }), pts: [...region, region[0]].map((p) => ({ x: p.x, y: p.y, z: p.z })) });
  for (const r of dl.rays) if (r.d > 1e-6) bl.push({ name: 'slope', pts: [{ x: r.x0, y: r.y0, z: r.z0 ?? padZ }, { x: r.x, y: r.y, z: r.z }] });
  return { problems: [], region, points: [], breaklines: bl, info: dl };
}

Proposed surface from ground and graded items

64-bit

Removes ground points and breaklines inside each graded region, adds the item's own, and triangulates again.

A ground point is removed when it is inside the region or within 1e-6 of its edge (absolute). The triangulation itself is done by the build function passed in (buildSurface in tin.js).

  • grading.js
  • lines 141 to 173
  • CivilCraft 0.8.1
/**
 * The base surface with every item graded in, in order (a later item grades against the result of the
 * earlier ones). An item is a pad ({ pts, z, cut, fill, name }) or anything with
 * `patch(surface) → { problems, region, points, breaklines, info }` (a corridor). The item's region
 * (its daylight polygon) replaces the base there: base points inside it and base breaklines touching it
 * are dropped, the item's own points and breaklines go in, and the lot is triangulated again by
 * `build(input)` (buildSurface, passed in so this module stays independent).
 * → { surface (null when nothing could be graded), report: { notes, errors, items: [{name, problems}] },
 *     regions (null for a failed item), items (each daylightPad result or patch info) }
 */
export function gradedSurface(base, basePoints, baseBreaklines, items, build) {
  const regions = [], report = { notes: [], errors: [], items: [] }, infos = [];
  let surf = base, points = basePoints.slice(), breaklines = baseBreaklines.slice(), any = false;
  for (const item of items) {
    const pt = item.patch ? item.patch(surf) : padPatch(surf, item);
    report.items.push({ name: item.name, problems: pt.problems });
    infos.push(pt.info);
    if (pt.problems.length || !pt.region || pt.region.length < 3) { regions.push(null); continue; }
    const region = pt.region;
    regions.push(region);
    // inside the region or on its edge (a ground point on the edge would pin the new surface to the old ground)
    const inside = (p) => pointInPoly(p.x, p.y, region) || nearPoly(p, region, 1e-6);
    points = points.filter((p) => !inside(p));
    breaklines = breaklines.filter((b) => b.pts.every((p) => !inside(p)) && !crossesPoly(b.pts, region));
    points.push(...pt.points);
    breaklines.push(...pt.breaklines);
    const built = build({ points, breaklines, boundary: null });
    if (!built.surface) { report.errors.push(...built.report.errors); return { surface: null, report, regions, items: infos }; }
    surf = built.surface; any = true;
    report.notes.push(...built.report.notes);
  }
  return { surface: any ? surf : null, report, regions, items: infos };
}

Do two segments cross

64-bit

The yes-or-no test used for self-crossing pads, self-crossing daylight lines and breaklines that cross a region.

o(p, q, r) = (q − p) × (r − p); cross when both pairs of signs are opposite

Four orientation values in ordinary 64-bit arithmetic, each compared with ±1e-12. The threshold is absolute on a value with units of length squared; the test is not an exact predicate, so near-touching segments at large coordinates can be decided either way.

  • grading.js
  • lines 236 to 240
  • CivilCraft 0.8.1
function properCross(a, b, c, d) {
  const o = (p, q, r) => (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
  const d1 = o(a, b, c), d2 = o(a, b, d), d3 = o(c, d, a), d4 = o(c, d, b);
  return ((d1 > 1e-12 && d2 < -1e-12) || (d1 < -1e-12 && d2 > 1e-12)) && ((d3 > 1e-12 && d4 < -1e-12) || (d3 < -1e-12 && d4 > 1e-12));
}

Distance from a point to a polygon edge

64-bit

Whether a ground point lies on a daylight line.

t = clamp((p − a)·(b − a) ÷ |b − a|², 0, 1); distance = |p − a − t·(b − a)|

The division by the squared edge length is skipped for a zero-length edge. The distance is compared with the tolerance passed in (1e-6 from gradedSurface).

  • grading.js
  • lines 241 to 248
  • CivilCraft 0.8.1
function nearPoly(p, poly, tol) {
  for (let i = 0; i < poly.length; i++) {
    const a = poly[i], b = poly[(i + 1) % poly.length], dx = b.x - a.x, dy = b.y - a.y, L = dx * dx + dy * dy;
    const t = L ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / L)) : 0;
    if (Math.hypot(p.x - a.x - dx * t, p.y - a.y - dy * t) <= tol) return true;
  }
  return false;
}

In detail

Method
Ray marching and bisection of (design elevation − ground elevation) along rays square to each pad edge, fanned every 10° at convex corners and along the bisector at concave corners.
Number types
64-bit numbers.
Tolerances
Bisection stops when the bracket is 1e-7 project units or after 60 halvings. March step = max(ray spacing ÷ 4, 0.05). Ray spacing default = max(pad size ÷ 20, 0.5). Corner turn threshold 1e-9 rad. Repeated corners closer than 1e-9 are dropped. Design equals ground when they differ by less than 1e-9. Ground points within 1e-6 of a daylight line are removed. Crossing tests use ±1e-12 on the cross product. All are absolute, in project units.
Limits
A slope that crosses the ground twice within one march step is not seen. The daylight line between rays is straight. Tolerances are absolute and do not scale with the size of the 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