Works out equilibrium cant, cant deficiency and excess, the cant ramps and their gradients and rates, each rail's height along the line, the track cross section, and the quantities of rail, sleepers, ballast and sub-ballast.
How the precision is kept
The arithmetic is 64-bit and closed form. Gravity is written as 9.81 m/s², which carries into every equilibrium cant. Lengths in feet are converted with the US survey foot 1200 ÷ 3937. Ballast and sub-ballast volumes are average end areas between corridor sections, so they follow the section spacing. The limits are placeholders the person sets, as the file header states.
Open item. Use standard gravity 9.80665 m/s² in the equilibrium cant instead of 9.81.
Calculations under this policy
13 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.
g = 9.81; km/h per mph = 1.609344; meters per foot = 1200 ÷ 3937
9.81 is rounded; standard gravity is 9.80665, a difference of 3 parts in 10,000. 1.609344 is exact, and the foot is the US survey foot formed as the fraction 1200 ÷ 3937.
rail.js
lines 65 to 67
CivilCraft 0.8.1
const G = 9.81;
const KMH_PER_MPH = 1.609344;
const M_PER_FT = 1200 / 3937; // US survey foot, as units.js
Speed and length conversions
64-bit
Converts a speed to km/h, a project length to meters and millimeters to project units.
km/h = mph × 1.609344; m = ft × 1200 ÷ 3937; project = mm ÷ 1000 (÷ meters per foot)
One multiplication or division each with the constants above.
rail.js
lines 107 to 113
CivilCraft 0.8.1
export const kmh = (v, unit) => (fin(v) && v > 0 ? (unit === 'mph' ? v * KMH_PER_MPH : v) : null);
/** Project length → metres (US survey feet for ft projects). */
export const toM = (v, units) => (units === 'm' ? v : v * M_PER_FT);
/** A length in metres (a cant in mm ÷ a gradient in mm/m) → project units. */
const toProj = (m, units) => (units === 'm' ? m : m / M_PER_FT);
/** mm → project units. */
export const mmTo = (v, units) => (units === 'm' ? v / 1000 : v / 1000 / M_PER_FT);
Equilibrium cant
64-bit
The cant at which a train at the design speed puts equal load on both rails.
E = G × v² ÷ (g × R), v = V ÷ 3.6
One 64-bit expression in millimeters. The result carries the rounded g = 9.81. A speed or radius of zero or less returns 0 instead of dividing.
rail.js
lines 115 to 119
CivilCraft 0.8.1
export function equilibriumCant(vKmh, Rm, base = 1500) {
if (!(vKmh > 0) || !(Rm > 0)) return 0;
const v = vKmh / 3.6;
return (base * v * v) / (G * Rm);
}
Cant ramp shapes
64-bit
How the cant rises from 0 to full along a ramp for each transition kind.
linear u; Bloss u²(3 − 2u); Helmert 2u² then 1 − 2(1 − u)²; cosine (1 − cos πu) ÷ 2; sine u − sin(2πu) ÷ 2π
Each shape is a closed-form function of the position along the ramp, evaluated in 64-bit.
rail.js
lines 121 to 130
CivilCraft 0.8.1
/** The cant ramp's shape g(u) (0 → 1) for a transition kind: the cant follows the curvature. */
export function rampShape(kind) {
switch (kind) {
case 'bloss': return (u) => u * u * (3 - 2 * u);
case 'helmert': return (u) => (u <= 0.5 ? 2 * u * u : 1 - 2 * (1 - u) ** 2);
case 'sinehalf': return (u) => (1 - Math.cos(Math.PI * u)) / 2;
case 'sine': return (u) => u - Math.sin(2 * Math.PI * u) / (2 * Math.PI);
default: return (u) => u; // clothoid, cubic parabola, a ramp on the straight
}
}
Steepest slope of each ramp shape
64-bit
The factor that turns the average cant gradient of a ramp into its steepest gradient.
linear 1; Bloss 1.5; Helmert 2; cosine π ÷ 2; sine 2
These are the maxima of the derivatives of the shapes above: 1, 1.5 and 2 are exact, and π ÷ 2 is the 64-bit Math.PI halved.
/** Each rail's height (mm) over the profile for a signed cant c (+ = the right rail higher) on a curve whose outer side is `high`. */
function heights(E, high, pivot) {
const c = high === 'R' ? E : -E;
if (pivot === 'centre') return { L: -c / 2, R: c / 2 };
if (pivot === 'inner') return high === 'R' ? { L: 0, R: E } : { L: E, R: 0 }; // the inner rail stays; a negative cant lowers the outer
return { L: Math.max(0, -c), R: Math.max(0, c) }; // 'low': the lower rail stays on the profile
}
Cant ramp gradient and rate
64-bit
The steepest cant gradient of each ramp in mm per meter and the rate of change of cant in mm per second.
The ramp length is a difference of two stations converted to meters. A ramp of 1e-9 or less gives an infinite gradient rather than a division by zero. Where a curve has no transition, the ramp length is set from the gradient limit itself.
Compares the applied cant, the cant deficiency and the cant excess with the limits.
deficiency = E_eq − E; excess = E − E_eq(slowest train); flagged when over the limit + 1e-9
The comparisons allow an absolute 1e-9 mm. The warning text shows values rounded to 1 decimal, while the limit and value stored with the warning are the unrounded numbers.
The cant layout as constant and ramp segments, with each rail's height at both ends of each.
Segment stations are clipped to the alignment and pieces shorter than 1e-9 are dropped. Where one ramp is cut short by the one before, its start cant is evaluated from the ramp shape at the cut station.
rail.js
lines 199 to 215
CivilCraft 0.8.1
const seg = (from, to, type, e0, e1, high) => {
const a = Math.max(from, al.start), b = Math.min(to, al.end);
if (!(b - a > 1e-9)) return;
const h0 = heights(e0, high, plan.pivot), h1 = heights(e1, high, plan.pivot);
segments.push({ start: a, length: b - a, type, startCant: e0, endCant: e1, startLeft: h0.L, startRight: h0.R, endLeft: h1.L, endRight: h1.R, high });
};
for (const p of pieces) {
const from = Math.max(p.from, at);
if (from > at + 1e-9) seg(at, from, 'CONSTANTCANT', 0, 0, p.c.high);
if (p.to - from > 1e-9) {
// a ramp cut short by the one before starts where that one ended, at the cant this ramp has there
const g = p.shape ? rampShape(p.shape) : null, val = (s) => (g ? p.e0 + (p.e1 - p.e0) * g(Math.min(1, Math.max(0, (s - p.from) / (p.to - p.from)))) : p.e0);
seg(from, p.to, p.shape ? CANT_TYPES[p.shape] : 'CONSTANTCANT', val(from), val(p.to), p.c.high);
}
at = Math.max(at, p.to);
}
if (al.end > at + 1e-9) seg(at, al.end, 'CONSTANTCANT', 0, 0, curves[curves.length - 1]?.high || 'L');
Cant at a station
64-bit
The applied cant and each rail's height at any station.
u = (sta − ramp start) ÷ ramp length; E = cant × g(u) on the way in, cant × (1 − g(u)) on the way out
The ramp length in the divisor is floored at 1e-12. Station membership tests allow 1e-9.
rail.js
lines 219 to 236
CivilCraft 0.8.1
/** The applied cant and each rail's height at a station. */
export function cantAt(plan, sta) {
for (const s of plan?.segments || []) {
if (sta < s.start - 1e-9 || sta > s.start + s.length + 1e-9) continue;
if (s.type === 'CONSTANTCANT') return { E: s.startCant, L: s.startLeft, R: s.startRight };
const shape = Object.keys(CANT_TYPES).find((k) => CANT_TYPES[k] === s.type), g = rampShape(shape);
// the segment may be a ramp cut short: map back to the ramp's own u through its curve record
const c = plan.curves.find((x) => x.rampIn && sta >= x.rampIn.from - 1e-9 && sta <= x.rampIn.to + 1e-9 && CANT_TYPES[x.rampIn.shape] === s.type) || plan.curves.find((x) => x.rampOut && sta >= x.rampOut.from - 1e-9 && sta <= x.rampOut.to + 1e-9 && CANT_TYPES[x.rampOut.shape] === s.type);
let E;
if (c) {
const inn = c.rampIn && sta >= c.rampIn.from - 1e-9 && sta <= c.rampIn.to + 1e-9, r = inn ? c.rampIn : c.rampOut, u = (sta - r.from) / Math.max(r.len, 1e-12);
E = c.cant * (inn ? g(u) : 1 - g(u));
} else E = s.startCant + ((s.endCant - s.startCant) * (sta - s.start)) / Math.max(s.length, 1e-12);
const h = heights(E, s.high, plan.pivot);
return { E, L: h.L, R: h.R };
}
return { E: 0, L: 0, R: 0 };
}
Track cross section
64-bit
The rails, sleeper, ballast and sub-ballast outline square to the alignment, and the ballast and sub-ballast areas.
Offsets and heights are local to the track center, so the numbers are small. The divisor in the ballast toe distance is floored at 1e-6. Millimeter sizes are converted to project units by mmTo.
rail.js
lines 238 to 295
CivilCraft 0.8.1
/**
* The track's cross section square to the alignment (offsets o right +, z from the profile's elevation; project units). The rails'
* heads sit at hL, hR (mm) over the profile, ±base/2 from each track's centre; the sleeper lies parallel to them, the rail + pad
* below; the ballast bed is filled to `crib` under the sleepers' tops, its shoulder level out to `shoulder` past the sleeper end, then the
* ballast slope down to the sub-ballast, whose top falls away from the centre at `fall` and lies `ballast.depth` under the sleeper
* at the lowest low rail; the sub-ballast runs `cess` past the ballast toe, `sub.depth` thick. The formation (its bottom) is not
* tilted by the cant; the track is.
*/
export function trackShape(rail, { hL = 0, hR = 0 } = {}, units = 'm') {
const mm = (v) => mmTo(v, units), P = RAIL_PROFILES[rail.rail] || RAIL_PROFILES['60E1'];
const base = mm(baseOf(rail)), n = rail.tracks || 1, S = rail.sleeper, B = rail.ballast, SB = rail.sub;
const below = mm(P.h + (rail.pad || 0)); // rail head to the sleeper's top
const slope = (mm(hR) - mm(hL)) / base; // dz / do across the track
const tracks = [];
for (let k = 0; k < n; k++) {
const c = (k - (n - 1) / 2) * rail.centres;
const zl = mm(hL), plane = (o) => zl + (o - (c - base / 2)) * slope - below; // the sleeper's top
const a = c - S.length / 2, b = c + S.length / 2;
const lowO = hL <= hR ? c - base / 2 : c + base / 2;
tracks.push({ c, slope, below, rails: [{ o: c - base / 2, z: mm(hL) }, { o: c + base / 2, z: mm(hR) }], sleeper: [{ o: a, z: plane(a) }, { o: b, z: plane(b) }, { o: b, z: plane(b) - S.depth }, { o: a, z: plane(a) - S.depth }], plane, lowO, under: plane(lowO) - S.depth });
}
// the sub-ballast's top: z0 − |o|·fall, ballast.depth under the sleeper at each track's low rail (the deepest one decides)
const z0 = Math.min(...tracks.map((k) => k.under - B.depth + Math.abs(k.lowO) * SB.fall));
const sbTop = (o) => z0 - Math.abs(o) * SB.fall;
// the ballast top from the centre out, each side
const crib = B.crib || 0; // the ballast's top lies this far under the sleepers' tops (they stand proud of it)
const ends = tracks.flatMap((k) => [{ o: k.sleeper[0].o, z: k.sleeper[0].z - crib }, { o: k.sleeper[1].o, z: k.sleeper[1].z - crib }]);
const topAt0 = () => {
const on = tracks.find((k) => k.sleeper[0].o <= 1e-9 && k.sleeper[1].o >= -1e-9);
if (on) return on.plane(0) - crib;
const l = ends.filter((e) => e.o < 0).sort((p, q) => q.o - p.o)[0], r = ends.filter((e) => e.o > 0).sort((p, q) => p.o - q.o)[0];
return l && r ? l.z + ((r.z - l.z) * (0 - l.o)) / (r.o - l.o) : (l || r).z;
};
const top = {}, tz = topAt0();
for (const [side, sx] of [['L', -1], ['R', 1]]) {
const out = [{ o: 0, z: tz, code: 'CL' }];
const mine = ends.filter((e) => e.o * sx > 1e-9).sort((p, q) => Math.abs(p.o) - Math.abs(q.o));
mine.forEach((e, i) => out.push({ o: e.o, z: e.z, code: i ? 'SLP' + (i + 1) : 'SLP' }));
const last = mine.length ? mine[mine.length - 1] : { o: 0, z: tz };
const bsh = { o: last.o + sx * B.shoulder, z: last.z, code: 'BSH' };
// down the ballast slope to the sub-ballast top: bsh.z − d/slope = z0 − (|bsh.o| + d)·fall
const d = Math.max(0, (bsh.z - z0 + Math.abs(bsh.o) * SB.fall) / Math.max(1 / B.slope - SB.fall, 1e-6));
const toe = { o: bsh.o + sx * d, z: sbTop(bsh.o + sx * d), code: 'BTOE' };
const cess = { o: toe.o + sx * SB.cess, z: sbTop(toe.o + sx * SB.cess), code: 'CESS' };
out.push(bsh, toe, cess);
top[side] = out;
}
// the ballast: its top (toe, shoulder, sleeper ends …, shoulder, toe), its bottom along the sub-ballast top (with the crown)
const Lr = top.L.slice().reverse(), Rr = top.R;
const tL = Lr.filter((q) => q.code !== 'CESS'), tR = Rr.filter((q) => q.code !== 'CESS' && q.code !== 'CL');
const ballast = [...tL.map(({ o, z }) => ({ o, z })), ...tR.map(({ o, z }) => ({ o, z }))];
const toeL = top.L.find((q) => q.code === 'BTOE'), toeR = top.R.find((q) => q.code === 'BTOE');
ballast.push({ o: 0, z: z0 }); // the crown of the sub-ballast under it (after the right toe, before closing at the left toe)
const cL = top.L.find((q) => q.code === 'CESS'), cR = top.R.find((q) => q.code === 'CESS');
const sub = [{ o: cL.o, z: cL.z }, { o: 0, z: z0 }, { o: cR.o, z: cR.z }, { o: cR.o, z: cR.z - SB.depth }, { o: 0, z: z0 - SB.depth }, { o: cL.o, z: cL.z - SB.depth }];
void toeL; void toeR;
return { tracks: tracks.map(({ plane, lowO, under, ...k }) => k), top, ballast, sub, areas: { ballast: Math.abs(area(ballast)), sub: Math.abs(area(sub)) }, z0 };
}
Area of a section polygon
64-bit
The area of the ballast or sub-ballast outline in the cross section.
A = Σ (o_i·z_j − o_j·z_i) ÷ 2
The shoelace sum in offset and height from the profile, which are small local values, so the sum does not suffer from large coordinates in plan; the heights are elevations relative to the profile, not absolute.
rail.js
line 296
CivilCraft 0.8.1
function area(P) { let s = 0; for (let i = 0; i < P.length; i++) { const a = P[i], b = P[(i + 1) % P.length]; s += a.o * b.z - b.o * a.z; } return s / 2; }
Track quantities
64-bit
Track and rail lengths, sleeper count and volume, and gross and net ballast and sub-ballast volumes.
Volumes are average end areas between sections along the alignment's stations. Track and rail lengths are sums of plan chords between sections along each line, so they are slightly short on curves. The 1e-9 keeps a length that is an exact multiple of the spacing from losing a sleeper.
rail.js
lines 298 to 327
CivilCraft 0.8.1
/** Quantities from the corridor's sections (each with `track` from corridor.js): average end areas between sections, track and rail
* lengths along each track's own line (shorter inside a curve), sleepers every `spacing` along each track. */
export function trackQuantities(sections, rail, units = 'm') {
const secs = (sections || []).filter((s) => s.track);
const n = rail?.tracks || 1, out = { length: 0, tracks: [], sleepers: 0, railLength: 0, sleeperVolume: 0, ballastGross: 0, ballast: 0, sub: 0 };
if (secs.length < 2) return out;
out.length = secs[secs.length - 1].sta - secs[0].sta;
for (let i = 1; i < secs.length; i++) {
const a = secs[i - 1].track, b = secs[i].track, ds = secs[i].sta - secs[i - 1].sta;
out.ballastGross += ((a.shape.areas.ballast + b.shape.areas.ballast) / 2) * ds;
out.sub += ((a.shape.areas.sub + b.shape.areas.sub) / 2) * ds;
}
const S = rail.sleeper, world = (sec, o) => ({ x: sec.track.p.x + sec.track.r.x * o, y: sec.track.p.y + sec.track.r.y * o });
for (let k = 0; k < n; k++) {
let len = 0, rl = 0;
for (let i = 1; i < secs.length; i++) {
const A = secs[i - 1].track.shape.tracks[k], B = secs[i].track.shape.tracks[k];
const p = world(secs[i - 1], A.c), q = world(secs[i], B.c);
len += Math.hypot(q.x - p.x, q.y - p.y);
for (let j = 0; j < 2; j++) { const u = world(secs[i - 1], A.rails[j].o), w = world(secs[i], B.rails[j].o); rl += Math.hypot(w.x - u.x, w.y - u.y); }
}
const sleepers = Math.floor(len / S.spacing + 1e-9) + 1;
out.tracks.push({ c: secs[0].track.shape.tracks[k].c, length: len, sleepers, rail: rl });
out.sleepers += sleepers; out.railLength += rl;
}
out.sleeperVolume = out.sleepers * S.length * S.width * S.depth;
out.ballast = Math.max(0, out.ballastGross - out.sleepers * S.length * S.width * Math.max(0, S.depth - (rail.ballast?.crib || 0))); // the part of each sleeper in the bed
void units;
return out;
}
In detail
Method
Equilibrium cant E = G·v² ÷ (g·R) with G the distance between rail centers; ramp gradient = cant ÷ ramp length × the shape's peak slope; volumes by average end areas.
Number types
64-bit numbers.
Tolerances
Limit checks allow 1e-9. Segments shorter than 1e-9 are left out. Sleeper count = ⌊length ÷ spacing + 1e-9⌋ + 1. Ballast toe: the slope divisor is at least 1e-6.
Limits
g = 9.81 instead of 9.80665, a difference of 3 parts in 10,000 in every equilibrium cant. Volumes are average end areas. Warning texts round values to 1 or 2 decimals; the values kept with the warning are not rounded.
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.