Cleans boring logs, corrects blow counts, places borings along a section line and joins their layers into a soil profile.
How the precision is kept
64-bit arithmetic with one expression per value. Layer boundaries between borings are straight lines between the boring values; that is the model, and no smoothing or estimation is applied. Input limits are fixed ranges for the soil properties.
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 depth limits of a boring's layers after gaps and overlaps are removed.
A gap is closed by stretching the layer above down to the next layer's top; an overlap is cut at the lower layer's top. Depths are copied, not rounded. A layer left with no thickness is dropped.
geotech.js
lines 80 to 83
CivilCraft 0.8.1
const layers = (Array.isArray(o.layers) ? o.layers : []).slice(0, 80).map(layerOf).filter(Boolean).sort((a, b) => a.from - b.from);
// layers must be end to end: a gap is closed by stretching the layer above; an overlap is cut at the layer below's top
for (let i = 1; i < layers.length; i++) { if (layers[i].from > layers[i - 1].to) layers[i - 1].to = layers[i].from; else if (layers[i].from < layers[i - 1].to) layers[i - 1].to = layers[i].from; }
const kept = layers.filter((l) => l.to > l.from);
Layer elevations
64-bit
The top and bottom elevation and the thickness of each layer of a boring.
top = z − from; bottom = z − to; thickness = to − from
Three subtractions. The thickness is taken from the depths, not from the two elevations, so it does not lose digits when the ground elevation is large.
One product and one square root. The effective stress is floored at 1 kPa before the division, and CN is capped at 1.7.
geotech.js
lines 91 to 95
CivilCraft 0.8.1
// SPT corrections: Pa = 101.325 kPa. Defaults are the common US safe-hammer values; every factor is the user's.
export function sptCorrect(n, { er = 60, cb = 1, cs = 1, cr = 1, sigma = 100 } = {}) {
const n60 = n * (er / 60) * cb * cs * cr, cn = Math.min(1.7, Math.sqrt(101.325 / Math.max(sigma, 1)));
return { n60, cn, n160: n60 * cn };
}
Boring projected onto the section line
64-bit
The station along the section and the offset from it of a boring.
t = ((p − a)·(b − a)) ÷ |b − a|², clamped to 0…1; station = length so far + t·|b − a|
The dot product is of coordinate differences, not full-size coordinates. The length so far is a running sum over the line's segments. The nearest segment wins by a plain comparison of offsets.
geotech.js
lines 121 to 137
CivilCraft 0.8.1
/**
* The layers between borings along a line. Each boring's layers are named by `unit` (a unit is one stratum that runs between borings); a unit
* found in two neighbouring borings has its top and bottom joined by straight lines; a unit found in only one pinches out half way to the next boring.
* Borings are projected onto the line (station = along, offset = across); those further than `width` from the line are left out.
* line: [{ x, y }, …] (a polyline); returns { length, borings: [{ id, name, sta, off }], strata: [{ unit, soil, color, polygons: [[{ sta, z }]] }], ground: [{ sta, z }] }
*/
export function projectToLine(line, p) {
let best = null, run = 0;
for (let i = 0; i + 1 < line.length; i++) {
const a = line[i], b = line[i + 1], dx = b.x - a.x, dy = b.y - a.y, L2 = dx * dx + dy * dy, L = Math.sqrt(L2);
if (L2 === 0) continue;
const t = clamp(((p.x - a.x) * dx + (p.y - a.y) * dy) / L2, 0, 1), qx = a.x + t * dx, qy = a.y + t * dy, off = Math.hypot(p.x - qx, p.y - qy);
if (!best || off < best.off) best = { sta: run + t * L, off };
run += L;
}
return best;
}
Soil profile between borings
64-bit
The polygon of each soil unit between neighboring borings, the ground line and the water line.
Polygon corners are the borings' own stations and layer elevations, joined by straight lines. The order of the units is fixed by their first appearance in the borings sorted by station.
geotech.js
lines 139 to 171
CivilCraft 0.8.1
export function sectionProfile(borings, line, { width = Infinity } = {}) {
const pts = [];
for (const b of borings) { const q = projectToLine(line, b); if (q && q.off <= width) pts.push({ b, sta: q.sta, off: q.off }); }
pts.sort((p, q) => p.sta - q.sta);
let length = 0; for (let i = 0; i + 1 < line.length; i++) length += Math.hypot(line[i + 1].x - line[i].x, line[i + 1].y - line[i].y);
if (pts.length < 1) return { length, borings: [], strata: [], ground: [], problems: [{ code: 'geo.profile.none' }] };
// the order of the units, top to bottom, over all borings: first appearance fixes the position
const order = [];
for (const p of pts) for (const l of p.b.layers) if (!order.includes(l.unit)) order.push(l.unit);
const top = (p, unit) => { const l = p.b.layers.find((q) => q.unit === unit); return l ? { top: p.b.z - l.from, bottom: p.b.z - l.to, soil: l.soil } : null; };
const problems = [];
const strata = order.map((unit) => ({ unit, soil: null, polygons: [] }));
for (const s of strata) {
// where this unit exists: a run of consecutive borings
const at = pts.map((p) => top(p, s.unit));
s.soil = at.find(Boolean)?.soil || null;
// each pair of neighbours (and the free ends): a polygon between the two top lines and the two bottom lines
const edge = (i) => ({ lo: i > 0 ? (pts[i - 1].sta + pts[i].sta) / 2 : pts[i].sta, hi: i + 1 < pts.length ? (pts[i].sta + pts[i + 1].sta) / 2 : pts[i].sta });
for (let i = 0; i < pts.length; i++) {
// from this boring to the next
if (i + 1 < pts.length) {
const a = at[i], c = at[i + 1], sa = pts[i].sta, sb = pts[i + 1].sta, mid = (sa + sb) / 2;
if (a && c) s.polygons.push([{ sta: sa, z: a.top }, { sta: sb, z: c.top }, { sta: sb, z: c.bottom }, { sta: sa, z: a.bottom }]);
else if (a) s.polygons.push([{ sta: sa, z: a.top }, { sta: mid, z: a.top - (a.top - a.bottom) * 0 + 0 }, { sta: sa, z: a.bottom }].map((q, k) => (k === 1 ? { sta: mid, z: (a.top + a.bottom) / 2 } : q)));
else if (c) s.polygons.push([{ sta: sb, z: c.top }, { sta: mid, z: (c.top + c.bottom) / 2 }, { sta: sb, z: c.bottom }]);
}
}
void edge;
}
const ground = pts.map((p) => ({ sta: p.sta, z: p.b.z }));
const water = pts.filter((p) => p.b.water !== null).map((p) => ({ sta: p.sta, z: p.b.z - p.b.water }));
return { length, borings: pts.map((p) => ({ id: p.b.id, name: p.b.name, sta: p.sta, off: p.off, depth: p.b.depth, z: p.b.z })), strata, ground, water, problems };
}
Layer stack at a station
64-bit
The top and bottom of every soil unit at one station of the profile.
z = z_a + (s − s_a) ÷ (s_c − s_a) × (z_c − z_a)
Linear interpolation along each polygon edge that spans the station, with ±1e-9 allowed on the edge parameter; top and bottom are the highest and lowest of the cuts. Edges whose two stations differ by less than 1e-12 are skipped.
geotech.js
lines 173 to 191
CivilCraft 0.8.1
/** The elevation of the top of every unit at one station of a profile: [{ unit, soil, top, bottom }] top to bottom (the stack the slope code reads). */
export function stackAt(profile, sta) {
const out = [];
for (const s of profile.strata) {
for (const poly of s.polygons) {
const xs = poly.map((q) => q.sta), lo = Math.min(...xs), hi = Math.max(...xs);
if (sta < lo - 1e-9 || sta > hi + 1e-9) continue;
const cuts = [];
for (let i = 0; i < poly.length; i++) {
const a = poly[i], c = poly[(i + 1) % poly.length];
if (Math.abs(a.sta - c.sta) < 1e-12) continue;
const t = (sta - a.sta) / (c.sta - a.sta);
if (t >= -1e-9 && t <= 1 + 1e-9) cuts.push(a.z + t * (c.z - a.z));
}
if (cuts.length >= 2) { out.push({ unit: s.unit, soil: s.soil, top: Math.max(...cuts), bottom: Math.min(...cuts) }); break; }
}
}
return out.sort((p, q) => q.top - p.top);
}
In detail
Method
Layer elevations = ground elevation − depth. Borings are projected at right angles onto the section line. A unit present in two neighboring borings is joined by straight lines; a unit present in one pinches out half way to the next, at mid thickness. SPT: N60 = N·ER/60·CB·CS·CR, (N1)60 = CN·N60, CN = √(Pa ÷ σ′v) capped at 1.7 (the header cites Liao and Whitman 1986 and Youd et al. 2001).
Number types
64-bit numbers. Pa = 101.325 kPa, the exact standard atmosphere.
Tolerances
Station inside a polygon: ±1e-9. Vertical polygon edge: stations within 1e-12. Blow counts kept when within 1e-9 of the layer's depth range. All absolute.
Limits
Unit weight is clamped to 5 to 35 kN/m³, cohesion to 0 to 5000 kPa and friction angle to 0 to 60° when a boring is read. The effective stress in the blow count correction is floored at 1 kPa.
Approach: 64-bit, direct
Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.