River model: stations, cut sections and reach lengths
Builds the river model a water surface profile is computed from: river stations, cross-sections cut from the ground, bank positions, reach lengths, flows along the reach and the unit constants.
How the precision is kept
Stations, offsets and lengths are 64-bit closed-form results from the plan geometry. Two things are rounded before they are stored: the river station of a cut section and each section point's offset are rounded to 6 decimals, and a guessed boundary slope is rounded to 3 significant digits. The US-unit gravity and Manning constant are typed rounded values.
Open item. Derive the US-unit gravity and Manning constant from the exact SI values (9.80665 ÷ 0.3048 and 1 ÷ 0.3048^(1/3)) instead of the typed 32.174 and 1.486, and store cut-section stations and offsets unrounded.
Calculations under this policy
7 calculations in 2 source files. 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 gravity and the Manning equation constant every river calculation uses.
m: k = 1, g = 9.80665; ft: k = 1.486, g = 32.174
9.80665 m/s² is the defined standard value. The two US-unit values are typed rounded numbers: 9.80665 ÷ 0.3048 is 32.17404855…, and 1 ÷ 0.3048^(1/3) is 1.48592…, so results in feet carry these constants to 5 and 4 significant digits.
The station of a point, measured along the centerline from its downstream end, and its offset to the side.
rs = L − d
One 64-bit subtraction of the distance along the centerline from its total length; both come from terrain-section.js.
river.js
lines 199 to 202
CivilCraft 0.8.1
export function riverStation(centreline, p) {
const L = lineLength(centreline), at = locateOn(centreline, p);
return at ? { rs: L - at.d, off: at.off } : null;
}
Cross-section from a cut line
64-bit
Turns a line drawn across the river into a cross-section: station, ground points by offset, and first bank positions.
rs = round₆(L − d_crossing); o = round₆(offset); banks = lowest point ± bank distance, clipped to the section
The station and every point offset are rounded to 6 decimals with toFixed(6) before they are stored in the model; elevations are stored unrounded. At large station values 6 decimals is fewer than 15 significant digits.
river.js
lines 204 to 234
CivilCraft 0.8.1
export function sectionFromLine(surface, centreline, cut, opts = {}) {
if (!Array.isArray(cut) || cut.length < 2) return null;
const units = opts.units === 'ft' ? 'ft' : 'm', d = { ...RIVER_DEFAULTS(units), ...opts };
const L = lineLength(centreline);
let ln = cut.map((p) => ({ x: p.x, y: p.y }));
let hit = crossing(ln, centreline);
if (!hit) return null;
// left to right looking downstream: the first point must be to the LEFT of the centreline
const first = locateOn(centreline, ln[0]);
if (first && first.off > 0) { ln = ln.reverse(); hit = crossing(ln, centreline); }
const sec = cutSection(surface, ln, { origin: hit.da });
if (!sec.runs.length) return null;
const pts = flatten(sec.runs.length > 1 ? [sec.runs.reduce((a, b) => (b.length > a.length ? b : a))] : sec.runs);
const lo = pts[0].o, hi = pts[pts.length - 1].o, low = lowest([pts]);
const banks = [Math.max(lo, low.o - d.bank), Math.min(hi, low.o + d.bank)];
return {
id: opts.id || 'xs',
rs: Number((L - hit.db).toFixed(6)),
name: '',
line: ln,
lineAt0: hit.da,
pts: pts.map((p) => ({ o: Number(p.o.toFixed(6)), z: p.z })),
banks,
n: [{ o: lo, n: d.nOver }, { o: banks[0], n: d.n }, { o: banks[1], n: d.nOver }],
reach: { lob: null, ch: null, rob: null },
cc: d.cc,
ce: d.ce,
alpha: null,
gaps: sec.runs.length > 1,
};
}
Sections at a spacing along the centerline
64-bit
Cuts square cross-sections every set distance along the centerline and at both ends.
dₖ = k × spacing while k × spacing < L − 1e-9; rs = round₆(L − dₖ)
Distances are k × spacing, a single multiplication per section, so they do not accumulate error. The stored station is rounded to 6 decimals. The 1e-9 end test is an absolute length.
river.js
lines 236 to 249
CivilCraft 0.8.1
export function sectionsAlong(surface, centreline, opts = {}) {
const units = opts.units === 'ft' ? 'ft' : 'm', d = { ...RIVER_DEFAULTS(units), ...opts };
const L = lineLength(centreline);
if (!(L > 0) || !(d.spacing > 0)) return [];
const ds = [];
for (let k = 0; k * d.spacing < L - 1e-9 && ds.length < MAX_SECTIONS - 1; k++) ds.push(k * d.spacing);
ds.push(L);
const out = [];
for (const at of ds) {
const s = sectionFromLine(surface, centreline, squareAcross(centreline, at, d.left, d.right), { ...d, id: 'xs' + (out.length + 1) });
if (s) { s.rs = Number((L - at).toFixed(6)); out.push(s); }
}
return out;
}
Reach lengths between sections
64-bit
The channel and overbank distances from each section to the next one downstream.
channel = rsᵢ − rsᵢ₋₁; overbank = √(Δx² + Δy²) between the middles of the overbank parts of the two cut lines
The channel length is a difference of two stations, which were rounded to 6 decimals when the sections were cut. Overbank lengths use Math.hypot on plan coordinates and fall back to the channel length when a section has no cut line.
river.js
lines 263 to 276
CivilCraft 0.8.1
export function setReachLengths(model) {
const s = ordered(model);
for (let i = 0; i < s.length; i++) {
const me = s[i], dn = s[i - 1];
if (!dn) { me.reach = { lob: 0, ch: 0, rob: 0 }; continue; }
const ch = me.rs - dn.rs;
const side = (k) => {
const a = overbankMid(me, k), b = overbankMid(dn, k);
return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : ch;
};
me.reach = { lob: side('lob'), ch, rob: side('rob') };
}
return model;
}
Flow at a station
64-bit
The flow that applies at a station, from the profile's flow and its flow changes.
Picks the nearest flow change at or upstream of the station; the comparison allows 1e-9 (absolute) so a change placed at a section's own station applies there. The flow value is copied, not computed.
river.js
lines 278 to 283
CivilCraft 0.8.1
export function flowAt(profile, rs) {
let q = profile.q, best = Infinity;
// the nearest change at or upstream of rs holds
for (const c of profile.changes || []) if (c.rs >= rs - 1e-9 && c.rs < best) { best = c.rs; q = c.q; }
return q;
}
The slope offered for a normal-depth boundary when one is first chosen, from the lowest points of the end sections.
S = (z_min upstream − z_min downstream) ÷ (rs upstream − rs downstream), to 3 significant digits; 0.001 when S ≤ 1e-5
The slope is rounded to 3 significant digits with toPrecision(3) and that rounded number is stored in the model as the boundary slope. It is a starting value that the person can replace.
river-ui.js
lines 81 to 87
CivilCraft 0.8.1
function slopeGuess(model) {
const s = ordered(model);
if (s.length < 2) return 0.001;
const z = (x) => Math.min(...x.pts.map((p) => p.z));
const v = (z(s[s.length - 1]) - z(s[0])) / (s[s.length - 1].rs - s[0].rs);
return v > 1e-5 ? Number(v.toPrecision(3)) : 0.001;
}
In detail
Method
Station = centerline length minus the distance along it; overbank reach length = plan distance between the middles of the overbank parts of two cut lines; channel length = station difference.
Number types
64-bit numbers.
Tolerances
Section spacing loop stops 1e-9 before the end of the centerline; a flow change applies from 1e-9 downstream of its station; numbers read from a file must be finite and below 1e9 in size. Both 1e-9 values are absolute.
Limits
Stations and offsets of cut sections are kept to 6 decimals. Gravity is 9.80665 in meters (exact by definition) and 32.174 in feet (rounded); the Manning constant in feet is 1.486 (rounded).
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.