Builds a triangulated surface from survey points and breaklines, then its elevations, contours and slopes.
How the precision is kept
Coordinates are 64-bit and are moved to a local origin first, so state-plane values in the millions keep their digits. The two decisions that shape the mesh (which side of an edge a point is on, and whether a point is inside a triangle's circle) are computed exactly with adaptive-precision arithmetic (robust-predicates 3.0.3). Points closer than 0.0001 project units in plan are one vertex.
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.
Whether three points turn counter-clockwise, clockwise, or lie on one line.
sign of (b − a) × (c − a)
orient2d returns a value whose sign is always the true sign, whatever the coordinates. The sign is turned over here because the library counts y downward.
Ordinary 64-bit arithmetic in local coordinates. A point within 1e-9 of the surface edge is still placed in the nearest triangle, with its weights clamped to the edge.
tin.js
lines 511 to 534
CivilCraft 0.8.1
/** The triangle under (local) x, y and its barycentric weights, or null. */
locate(lx, ly, tol = 1e-9) {
const g = this.grid(), { X, Y, tris } = this;
if (lx < g.b.minX - tol || lx > g.b.maxX + tol || ly < g.b.minY - tol || ly > g.b.maxY + tol) return null;
const i = clampI((lx - g.b.minX) / g.cw, g.nx), j = clampI((ly - g.b.minY) / g.ch, g.ny), k = j * g.nx + i;
let best = null, bestMin = -Infinity;
for (let s = g.start[k]; s < g.start[k + 1]; s++) {
const t = g.items[s], a = tris[3 * t], b = tris[3 * t + 1], c = tris[3 * t + 2];
const d = (Y[b] - Y[c]) * (X[a] - X[c]) + (X[c] - X[b]) * (Y[a] - Y[c]);
if (d === 0) continue;
const w1 = ((Y[b] - Y[c]) * (lx - X[c]) + (X[c] - X[b]) * (ly - Y[c])) / d;
const w2 = ((Y[c] - Y[a]) * (lx - X[c]) + (X[a] - X[c]) * (ly - Y[c])) / d;
const w3 = 1 - w1 - w2, m = Math.min(w1, w2, w3);
if (m >= 0) return { t, w: [w1, w2, w3] };
if (m > bestMin) { bestMin = m; best = { t, w: [w1, w2, w3] }; }
}
if (best && tol > 0) {
// within tol of the triangle in plan (points on the outer edge)
const t = best.t, a = tris[3 * t], b = tris[3 * t + 1], c = tris[3 * t + 2];
const scale = Math.max(Math.hypot(X[b] - X[a], Y[b] - Y[a]), Math.hypot(X[c] - X[b], Y[c] - Y[b]), Math.hypot(X[a] - X[c], Y[a] - Y[c]));
if (-bestMin * scale <= tol) { const w = best.w.map((v) => Math.max(0, v)); const s = w[0] + w[1] + w[2]; return { t, w: w.map((v) => v / s) }; }
}
return null;
}
Elevation at a point
64-bit
The surface elevation at an easting and northing.
z = w₁·z₁ + w₂·z₂ + w₃·z₃
The local origin is subtracted before the triangle is looked up. The elevation is linear across each triangle.
tin.js
lines 536 to 541
CivilCraft 0.8.1
elevationAt(x, y, tol = 1e-9) {
const r = this.locate(x - this.ox, y - this.oy, tol);
if (!r) return null;
const { tris, Z } = this, t = r.t;
return r.w[0] * Z[tris[3 * t]] + r.w[1] * Z[tris[3 * t + 1]] + r.w[2] * Z[tris[3 * t + 2]];
}
Slope of a triangle
64-bit
Rise over run and the downhill direction of one triangle.
n = u × v; slope = √((n_x ÷ n_z)² + (n_y ÷ n_z)²)
Taken from the plane through the three corners. No finite differences.
tin.js
lines 543 to 551
CivilCraft 0.8.1
/** Triangle t's plane: slope (rise/run), downhill direction (radians), gradient. */
slopeOf(t) {
const { X, Y, Z, tris } = this;
const a = tris[3 * t], b = tris[3 * t + 1], c = tris[3 * t + 2];
const ux = X[b] - X[a], uy = Y[b] - Y[a], uz = Z[b] - Z[a], vx = X[c] - X[a], vy = Y[c] - Y[a], vz = Z[c] - Z[a];
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
const gx = -nx / nz, gy = -ny / nz; // dz/dx, dz/dy
return { slope: Math.hypot(gx, gy), dir: Math.atan2(-gy, -gx), gx, gy };
}
Plan and surface area
64-bit
The surface's area in plan and along the slope, with its elevation range.
area in plan = Σ |n_z| ÷ 2; surface area = Σ |u × v| ÷ 2
Summed triangle by triangle in local coordinates with ordinary 64-bit addition.
tin.js
lines 457 to 478
CivilCraft 0.8.1
stats() {
if (this._stats) return this._stats;
const { X, Y, Z, tris, ox, oy } = this;
const used = this.usedVertices();
let n = 0, minZ = Infinity, maxZ = -Infinity, sum = 0, minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let i = 0; i < X.length; i++) {
if (!used[i]) continue;
n++; sum += Z[i];
if (Z[i] < minZ) minZ = Z[i]; if (Z[i] > maxZ) maxZ = Z[i];
if (X[i] < minX) minX = X[i]; if (X[i] > maxX) maxX = X[i]; if (Y[i] < minY) minY = Y[i]; if (Y[i] > maxY) maxY = Y[i];
}
let a2 = 0, a3 = 0;
for (let t = 0; t < tris.length; t += 3) {
const a = tris[t], b = tris[t + 1], c = tris[t + 2];
const ux = X[b] - X[a], uy = Y[b] - Y[a], uz = Z[b] - Z[a], vx = X[c] - X[a], vy = Y[c] - Y[a], vz = Z[c] - Z[a];
const cx = uy * vz - uz * vy, cy = uz * vx - ux * vz, cz = ux * vy - uy * vx;
a2 += Math.abs(cz) / 2;
a3 += Math.hypot(cx, cy, cz) / 2;
}
this._stats = { points: n, triangles: this.nT, minZ, maxZ, meanZ: sum / n, area2d: a2, area3d: a3, bounds: { minX: minX + ox, minY: minY + oy, maxX: maxX + ox, maxY: maxY + oy } };
return this._stats;
}
In detail
Method
Delaunay triangulation by Delaunator 5.1.0. Breaklines are forced in by edge flipping (Sloan, 1993) and the Delaunay property is restored around them. Crossing breaklines are split where they cross.
Number types
64-bit arrays for x, y and z. 32-bit integers are used only to index triangles.
Tolerances
Points within 0.0001 project units in plan merge into one vertex. A merged point whose elevations differ by more than 0.001 is counted in the report, and crossing breaklines that differ by more than 0.01 are reported.
Checked by
The CivilCraft test suite builds surfaces and checks their triangles, elevations and contours.
Limits
The orientation and in-circle tests are exact. Elevations, contours and slopes computed on the finished triangles are ordinary 64-bit arithmetic.
Approach: Exact predicates
Coordinates are 64-bit. The yes-or-no decisions that shape the geometry (which side of a line, inside or outside a circle) are computed exactly, so rounding cannot flip one.