Resources Numerical accuracy Calculation policy

Railway turnouts, crossovers and clearance envelopes

Works out the geometry of a turnout and a crossover from the frog number, the speed on the diverging route, the extra width a vehicle needs on a curve, and whether two clearance outlines overlap.

How the precision is kept

Closed-form 64-bit geometry from the frog number, gauge and switch. The closure radius divides by cos β − cos θ, a difference of two cosines close to 1 for the small angles of a turnout, which loses leading digits. The speed formula uses the constant 127.12, set so that a 1500 mm rail spacing gives the conventional 11.8. The least track spacing is found by 60 halvings.

Open item. Compute cos β − cos θ as 2·sin((θ + β) ÷ 2)·sin((θ − β) ÷ 2) so the closure radius keeps its digits for small angles.

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.

turnout.js CivilCraft 0.8.1 64-bit 7 calculations Reviewed

Tests bin/test-civilcraft-turnout.mjs

Frog angle from the frog number

64-bit

The angle at which the diverging track leaves, from the frog number.

θ = 2·atan(1 ÷ (2N))

One 64-bit arctangent of an exact quotient.

  • turnout.js
  • lines 2 to 25
  • CivilCraft 0.8.1
/**
 * turnout.js — railway turnouts, crossovers and clearance envelopes (0.8 CV.1, on rail.js's cant and track shape). No DOM, no strings, no imports. Lengths in
 * the caller's unit except where a name says mm; speeds km/h. Tested by bin/test-civilcraft-turnout.mjs. Every limit is a PLACEHOLDER the user sets from their
 * railway's standard (docs/spikes/CV.1-rail.md): the geometry below is the plan of a turnout, not a manufacturer's drawing, and a built turnout follows that drawing.
 *
 * Published maths (nothing copied; Hay, Railroad Engineering, ch. 7; Profillidis, Railway Management and Engineering, ch. on turnouts):
 *  - Frog number N is half the cotangent of half the frog angle: N = ½·cot(θ/2), so θ = 2·atan(1/(2N)).
 *  - A straight switch of length s at the switch angle β, then a circular closure of radius R to the theoretical frog point, where the diverging track is the
 *    gauge g off the straight track and leaves at the frog angle θ:  g = s·sinβ + R·(cosβ − cosθ),  lead L = s·cosβ + R·(sinθ − sinβ). Here the diverging track
 *    is its centreline and the offset g is the centre-to-centre spacing at the frog point (gauge, as the rails meet there).
 *  - Crossover between parallel tracks c apart: two leads and a straight at θ between: c = 2g + t·sinθ.
 *  - Speed on the diverging route: the equilibrium speed on a curve of radius R with cant E (mm) and cant deficiency D (mm): V = √(R·(E + D) / (G/g_acc)) in the
 *    usual form V(km/h) = √(R(m)·(E + D)(mm) / 11.8) with the rail-centre distance 1500 mm.
 *  - Curve clearance (versine of a chord): a vehicle of overall length L on bogie centres b: the middle moves in b²/(8R), the ends out (L² − b²)/(8R).
 *
 *   frogAngle(n) / frogNumber(theta)
 *   turnout({ n, gauge, switchLength, switchAngle, side: 'L'|'R', step }) → { theta, R, lead, tfp: {x,y}, heel, centre: [{x,y}], length }     x along the main track from the switch toe, y toward `side`
 *   crossover({ n, gauge, switchLength, switchAngle, spacing }) → { turnout, straight, length, ok }
 *   divergingSpeed(R, { cant = 0, deficiency = 100, rail = 1500 }) → km/h
 *   curveAllowance({ radius, length, bogie }) → { inside, outside }
 *   envelopeAt({ outline: [{o,z}], radius, cant, base, length, bogie, curve: 'L'|'R' }) → polygon [{o,z}]
 *   clashes(polygon, obstacles[{pts|poly}]) → indexes;  minSpacing(polyA, polyB) → the least track-centre spacing at which two envelopes do not overlap
 */
export const frogAngle = (n) => 2 * Math.atan(1 / (2 * n));

Turnout radius, lead and centerline

64-bit

The closure radius, the lead and the centerline points of the diverging track.

R = (g − s·sin β) ÷ (cos β − cos θ); lead = s·cos β + R·(sin θ − sin β); arc length = R·(θ − β)

cos β − cos θ subtracts two values close to 1 (for a number 9 frog and the default switch angle the difference is about 0.006), so the radius and the lead lose leading digits to cancellation. Centerline points are computed from the angle a = β + (θ − β)·i ÷ n, not by adding steps, so they do not drift. An asin of heel spread over switch length is not clamped; a value above 1 fails the geometry test that follows.

  • turnout.js
  • lines 28 to 41
  • CivilCraft 0.8.1
export function turnout({ n, gauge = 1.435, switchLength = 6.1, switchAngle = null, side = 'L', step = 0.25, heelSpread = null }) {
  const theta = frogAngle(n);
  const beta = switchAngle !== null ? switchAngle : heelSpread !== null ? Math.asin(heelSpread / switchLength) : 0.0143;   // placeholder switch angle (≈ 0°49′)
  const denom = Math.cos(beta) - Math.cos(theta);
  if (!(gauge > switchLength * Math.sin(beta)) || !(denom > 0) || beta >= theta) return { ok: false, problems: [{ code: 'tur.geometry', theta, beta }] };
  const R = (gauge - switchLength * Math.sin(beta)) / denom, lead = switchLength * Math.cos(beta) + R * (Math.sin(theta) - Math.sin(beta));
  const sg = side === 'R' ? -1 : 1, pts = [{ x: 0, y: 0 }];
  const ns = Math.max(1, Math.ceil(switchLength / step));
  for (let i = 1; i <= ns; i++) { const s = (switchLength * i) / ns; pts.push({ x: s * Math.cos(beta), y: sg * s * Math.sin(beta) }); }
  const hx = switchLength * Math.cos(beta), hy = switchLength * Math.sin(beta), arc = R * (theta - beta), na = Math.max(2, Math.ceil(arc / step));
  for (let i = 1; i <= na; i++) { const a = beta + ((theta - beta) * i) / na; pts.push({ x: hx + R * (Math.sin(a) - Math.sin(beta)), y: sg * (hy + R * (Math.cos(beta) - Math.cos(a))) }); }
  const tfp = pts[pts.length - 1];
  return { ok: true, n, theta, beta, R, lead, tfp, heel: { x: hx, y: sg * hy }, centre: pts, length: switchLength + arc, side, gauge, problems: [] };
}

Crossover between parallel tracks

64-bit

The straight between two turnouts that join parallel tracks, and the overall length.

straight = (spacing − 2g) ÷ sin θ; length = 2 × lead + straight × cos θ

64-bit; the division by sin θ is safe because the turnout's geometry test requires θ above the switch angle. A negative straight marks the spacing as too small.

  • turnout.js
  • lines 43 to 47
  • CivilCraft 0.8.1
export function crossover({ spacing, ...o }) {
  const t = turnout(o); if (!t.ok) return { ok: false, turnout: t, problems: t.problems };
  const straight = (spacing - 2 * t.gauge) / Math.sin(t.theta);
  return { ok: straight >= 0, turnout: t, straight, length: 2 * t.lead + straight * Math.cos(t.theta), spacing, problems: straight >= 0 ? [] : [{ code: 'tur.spacing', min: 2 * t.gauge }] };
}

Speed on the diverging route

64-bit

The speed for a radius, cant and cant deficiency.

V = √(R·(E + D) ÷ (rail ÷ 127.12)) km/h; rail = 1500 mm gives 11.8

The typed constant 127.12 reproduces the conventional 11.8 for a 1500 mm rail spacing (1500 ÷ 127.12 = 11.80). With g = 9.80665 the factor g × 3.6² is 127.094, so the result follows the rounded conventional form, about 0.01 % from the unrounded one.

  • turnout.js
  • line 49
  • CivilCraft 0.8.1
export function divergingSpeed(R, { cant = 0, deficiency = 100, rail = 1500 } = {}) { return Math.sqrt((R * (cant + deficiency)) / (rail / 127.12)); }   // 1500 mm → 11.8

Vehicle throw on a curve

64-bit

How far the middle of a vehicle moves in and its ends move out on a curve.

inside = b² ÷ (8R); outside = (L² − b²) ÷ (8R)

The versine approximation for a chord, in 64-bit; it is close when the radius is much larger than the vehicle. The radius is not tested for zero here; the caller only uses it for a finite radius.

  • turnout.js
  • line 51
  • CivilCraft 0.8.1
export function curveAllowance({ radius, length, bogie }) { return { inside: (bogie * bogie) / (8 * radius), outside: (length * length - bogie * bogie) / (8 * radius) }; }

Clearance outline on a canted curve

64-bit

The vehicle or structure outline tilted by the cant and widened by the curve throw.

tilt = asin(min(1, cant ÷ base)); rotate each point about the low rail; shift by the inside or outside throw

The asin argument is capped at 1 from above; a negative cant is not capped from below. Each point is one 64-bit rotation and a shift.

  • turnout.js
  • lines 53 to 69
  • CivilCraft 0.8.1
/**
 * The structure gauge or vehicle outline at a station of a curve. `outline` is a polygon in the section: o to the right of the track centre, z above the top of rail.
 * The envelope is tilted about the lower rail by the cant (mm over `base` mm) and the points toward the inside of the curve are pushed in by the inside allowance, those toward the
 * outside pushed out by the outside allowance (the whole vehicle outline: both applied to the side they affect).
 */
export function envelopeAt({ outline, radius = Infinity, cant = 0, base = 1500, length = 0, bogie = 0, curve = 'R', lowRail = null }) {
  const tilt = Math.asin(Math.min(1, cant / base)), cos = Math.cos(tilt), sin = Math.sin(tilt);
  // curve to the right: the cant raises the left rail, so the section tilts to the right (clockwise); the inside of the curve is the right
  const sg = curve === 'R' ? 1 : -1, pivot = lowRail ?? (sg * base / 2);
  const al = Number.isFinite(radius) && length ? curveAllowance({ radius, length, bogie }) : { inside: 0, outside: 0 };
  return outline.map((p) => {
    const dx = p.o - pivot, ro = pivot + dx * cos + sg * p.z * sin, rz = p.z * cos - sg * dx * sin;
    // the allowance moves a point further from the track centre on its own side: toward the curve's centre by the middle's throw, away from it by the ends'
    const toInside = sg * p.o > 0, shift = p.o === 0 ? 0 : toInside ? sg * al.inside : -sg * al.outside;
    return { o: ro + shift, z: rz };
  });
}

Least track spacing for two outlines

64-bit

The smallest center spacing at which two clearance outlines do not overlap.

bisection on the shift between 0 and 20, 60 halvings

Sixty halvings of a 20 unit bracket bring it below the spacing of 64-bit numbers at that size, so the result is limited by the overlap test, which is plain 64-bit point-in-polygon and edge crossing with an absolute 1e-14 parallel test.

  • turnout.js
  • lines 75 to 81
  • CivilCraft 0.8.1
/** Least centre spacing for two envelopes (A to the left, B shifted right) that do not overlap, by bisection. */
export function minSpacing(A, B, hi = 20) {
  const shift = (P, d) => P.map((p) => ({ o: p.o + d, z: p.z }));
  if (overlap(A, shift(B, hi))) return Infinity;
  let lo = 0, up = hi; for (let i = 0; i < 60; i++) { const m = (lo + up) / 2; if (overlap(A, shift(B, m))) lo = m; else up = m; }
  return up;
}

In detail

Method
θ = 2·atan(1 ÷ (2N)); g = s·sin β + R·(cos β − cos θ); lead = s·cos β + R·(sin θ − sin β); V = √(R·(E + D) ÷ 11.8); curve throw b² ÷ (8R) and (L² − b²) ÷ (8R). The file cites Hay, Railroad Engineering, ch. 7, and Profillidis, Railway Management and Engineering.
Number types
64-bit numbers.
Tolerances
Outline edges count as parallel when |denominator| < 1e-14 (absolute); the spacing search does 60 halvings from 0 to 20.
Limits
The default switch angle 0.0143 radians is a placeholder. 127.12 is not g × 3.6² (127.094 for g = 9.80665), so speeds follow the conventional 11.8 form. The curve throw is the versine approximation. The file calls every limit a placeholder to set from the railway's standard.

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.

Every policy that takes this approach