Resources Numerical accuracy Calculation policy

Alignment lines and circular curves

Works out a horizontal alignment from its points of intersection: tangents, circular curves, curve facts, stations, and the station and offset of any point.

How the precision is kept

Lines and arcs are closed-form 64-bit expressions from the PI coordinates and radius. Stations accumulate by adding element lengths in order. The comparisons use small absolute tolerances (1e-9 to 1e-12) that are not scaled to the size of the coordinates.

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.

alignment.js CivilCraft 0.8.1 64-bit 7 calculations Reviewed

Tests bin/test-civilcraft.mjs, bin/test-civilcraft-spirals.mjs, bin/test-civilcraft-rail.mjs, bin/test-civilcraft-staking.mjs

Angle to (−π, π]

64-bit

Brings a direction difference into one turn, so a deflection is read the short way round.

One remainder by 2π (the 64-bit value of Math.PI × 2) and at most one addition. The deflection at every PI passes through it.

  • alignment.js
  • line 37
  • CivilCraft 0.8.1
export const normAng = (a) => { a %= TAU; if (a <= -Math.PI) a += TAU; if (a > Math.PI) a -= TAU; return a; };

Deflection, tangent lengths and curve center at a PI

64-bit

The deflection angle at each PI, the tangent length of a plain curve, and for a curve with transitions the center and the two tangent lengths.

Δ = a₂ − a₁; T = R·tan(Δ/2); center from n₁·c = R + p₁, n₂·c = R + p₂; T₁ = k₁ − u₁·c, T₂ = u₂·c + k₂

Directions come from atan2 of coordinate differences. The 2×2 solve for the center divides by the cross product of the two normals, which is sin Δ; deflections below 1e-9 rad or within 1e-6 rad of π are excluded before it, so the divisor cannot be zero.

  • alignment.js
  • lines 116 to 130
  • CivilCraft 0.8.1
const a1 = Math.atan2(Q[i].y - Q[i - 1].y, Q[i].x - Q[i - 1].x), a2 = Math.atan2(Q[i + 1].y - Q[i].y, Q[i + 1].x - Q[i].x);
const d = normAng(a2 - a1);
if (r <= 0 || Math.abs(d) < 1e-9) { curves[i] = { r: 0, d, T: 0, T1: 0, T2: 0, a1, a2 }; continue; }
if (Math.abs(Math.abs(d) - Math.PI) < 1e-6) { errors.push(t('al.err.back', { pi: i + 1 })); curves[i] = { r: 0, d, T: 0, T1: 0, T2: 0, a1, a2 }; continue; }
const ls1 = finPos(Q[i].ls1) || finPos(Q[i].ls), ls2 = finPos(Q[i].ls2) || finPos(Q[i].ls);
if (!ls1 && !ls2) { const T = r * Math.tan(Math.abs(d) / 2); curves[i] = { r, d, T, T1: T, T2: T, a1, a2, ls1: 0, ls2: 0 }; continue; }
// 0.7 D7.35: spirals. The circle's centre sits (r + p) from each tangent, on the inside; the spiral's end is k along the tangent from the foot.
const tr = trOf(Q[i].tr), S1 = spiralConstants(r, ls1, tr), S2 = spiralConstants(r, ls2, tr);   // tr: 0.7.1 71.26
if (S1.th + S2.th > Math.abs(d) + 1e-12) { errors.push(t('al.err.spiral', { pi: i + 1, ls: +(ls1 + ls2).toFixed(2) })); curves[i] = { r: 0, d, T: 0, T1: 0, T2: 0, a1, a2 }; continue; }
const sg = d > 0 ? 1 : -1, u1 = { x: Math.cos(a1), y: Math.sin(a1) }, u2 = { x: Math.cos(a2), y: Math.sin(a2) };
const n1 = { x: -sg * u1.y, y: sg * u1.x }, n2 = { x: -sg * u2.y, y: sg * u2.x };
const det = n1.x * n2.y - n1.y * n2.x, b1 = r + S1.p, b2 = r + S2.p;
const cx = (b1 * n2.y - n1.y * b2) / det, cy = (n1.x * b2 - b1 * n2.x) / det;   // the centre, relative to the PI
const T1 = S1.k - (u1.x * cx + u1.y * cy), T2 = u2.x * cx + u2.y * cy + S2.k;
curves[i] = { r, d, T: Math.max(T1, T2), T1, T2, a1, a2, ls1, ls2, S1, S2, cx, cy, tr };

Circular curve and its facts

64-bit

The arc's center, start angle, length, external, middle ordinate and chord, and the PC and PT stations.

L = R·Δ; E = R·(1 ÷ cos(Δ/2) − 1); M = R·(1 − cos(Δ/2)); chord = 2R·sin(Δ/2)

E and M each subtract two numbers that are nearly equal when the deflection is small, so they carry fewer digits than L and the chord for a flat curve. The PT is laid off from the PI along the outgoing tangent, the center from the PC along the normal.

  • alignment.js
  • lines 151 to 161
  • CivilCraft 0.8.1
const ccw = c.d > 0, r = c.r, delta = Math.abs(c.d);
const nx = ccw ? -uy : uy, ny = ccw ? ux : -ux;   // toward the centre
const ctr = { x: end.x + nx * r, y: end.y + ny * r };
const dir2 = c.a2, pt = { x: Q[i].x + Math.cos(dir2) * T, y: Q[i].y + Math.sin(dir2) * T };
const a0 = Math.atan2(end.y - ctr.y, end.x - ctr.x);
const L = r * delta;
elements.push({
  type: 'arc', p0: end, p1: pt, c: ctr, r, ccw, delta, a0, sta, len: L, pi: { x: Q[i].x, y: Q[i].y }, piIndex: i,
  T, E: r * (1 / Math.cos(delta / 2) - 1), M: r * (1 - Math.cos(delta / 2)), chord: 2 * r * Math.sin(delta / 2), dirIn: dir, dirOut: dir2,
});
records.push({ piIndex: i, r, ccw, d: c.d, ls1: 0, ls2: 0, theta1: 0, theta2: 0, Lc: L, T1: T, T2: T, Es: r * (1 / Math.cos(delta / 2) - 1), ts: sta, sc: sta, cs: sta + L, st: sta + L });

Transition, curve, transition at one PI

64-bit

The circular arc left between two transitions and the stations TS, SC, CS and ST.

Δc = |Δ| − θ₁ − θ₂; Lc = R·Δc

The arc is built forward from the end of the entry transition; the exit transition is built backward from the ST, which is laid off from the PI. The arc's end and the exit transition's start are therefore computed by two separate paths and are not forced to the same coordinates. An arc with Δc of 1e-12 rad or less is left out.

  • alignment.js
  • lines 174 to 193
  • CivilCraft 0.8.1
const delta = Math.abs(c.d) - c.S1.th - c.S2.th, sc = { sta, x: p.x, y: p.y };
rec.sc = sta;
let outStart = p;
if (delta > 1e-12) {
  const nx = ccw ? -Math.sin(dirNow) : Math.sin(dirNow), ny = ccw ? Math.cos(dirNow) : -Math.cos(dirNow);
  const ctr = { x: p.x + nx * r, y: p.y + ny * r }, a0 = Math.atan2(p.y - ctr.y, p.x - ctr.x), L = r * delta;
  const a1 = a0 + sg * delta, pe = { x: ctr.x + Math.cos(a1) * r, y: ctr.y + Math.sin(a1) * r };
  const dirOut = dirNow + sg * delta, Tc = r * Math.tan(delta / 2);
  elements.push({
    type: 'arc', p0: p, p1: pe, c: ctr, r, ccw, delta, a0, sta, len: L, pi: { x: p.x + Math.cos(dirNow) * Tc, y: p.y + Math.sin(dirNow) * Tc }, piIndex: i,
    T: Tc, E: r * (1 / Math.cos(delta / 2) - 1), M: r * (1 - Math.cos(delta / 2)), chord: 2 * r * Math.sin(delta / 2), dirIn: dirNow, dirOut, spIn: c.ls1, spOut: c.ls2,
  });
  sta += L; outStart = pe;
}
rec.Lc = Math.max(0, delta) * r; rec.cs = sta;
if (c.ls2 > 0) {
  const so = spiralElement('out', { p1: st, dir1: c.a2, r, Ls: c.ls2, ccw, sta, piIndex: i, tr: c.tr });
  elements.push(so); sta += so.len;
}
rec.st = sta;

Point and direction at a station

64-bit

The coordinates and forward direction on the alignment at a station.

line: p₀ + s·(cos dir, sin dir); arc: c + R·(cos a, sin a), a = a₀ ± s ÷ R

The distance into the element is the station minus the element's start station, a difference of two running sums. On an arc the point is computed from the center, so it stays on the circle to rounding.

  • alignment.js
  • lines 257 to 265
  • CivilCraft 0.8.1
pointAt(sta) {
  const e = this.elementAt(sta);
  if (!e) return null;
  const s = sta - e.sta;
  if (e.type === 'line') return { x: e.p0.x + Math.cos(e.dir) * s, y: e.p0.y + Math.sin(e.dir) * s, dir: e.dir };
  if (e.type === 'spiral') return spiralAt(e, Math.min(e.len, Math.max(0, s)));   // 0.7 D7.35
  const sw = (e.ccw ? 1 : -1) * s / e.r, a = e.a0 + sw;
  return { x: e.c.x + Math.cos(a) * e.r, y: e.c.y + Math.sin(a) * e.r, dir: a + (e.ccw ? Math.PI / 2 : -Math.PI / 2) };
}

Station and offset of a point

64-bit

The nearest station on the alignment to a point, and how far left or right of it the point lies.

line: s = (p − p₀)·u clamped to the element; arc: s = R × swept angle; offset = (p − foot) × direction

Lines project by a dot product, arcs by atan2 about the center; transitions go through a sampled search. Between elements the nearer foot wins by more than 1e-12 (absolute). The offset is a cross product of a short difference with a unit direction.

  • alignment.js
  • lines 267 to 300
  • CivilCraft 0.8.1
/** Nearest station and offset (right = +). `outside` is true when the point is beyond either end. */
stationOffset(x, y) {
  let best = null;
  for (const e of this.elements) {
    let s, px, py, dir;
    if (e.type === 'line') {
      const ux = Math.cos(e.dir), uy = Math.sin(e.dir);
      s = Math.max(0, Math.min(e.len, (x - e.p0.x) * ux + (y - e.p0.y) * uy));
      px = e.p0.x + ux * s; py = e.p0.y + uy * s; dir = e.dir;
    } else if (e.type === 'spiral') {
      const q = spiralNearest(e, x, y);   // 0.7 D7.35
      s = q.s; px = q.x; py = q.y; dir = q.dir;
    } else {
      const a = Math.atan2(y - e.c.y, x - e.c.x);
      let sw = ((a - e.a0) * (e.ccw ? 1 : -1)) % TAU;
      if (sw < 0) sw += TAU;
      if (sw > e.delta) sw = sw - e.delta < TAU - sw ? e.delta : 0;   // off the arc: its nearer end
      s = sw * e.r;
      const aa = e.a0 + (e.ccw ? sw : -sw);
      px = e.c.x + Math.cos(aa) * e.r; py = e.c.y + Math.sin(aa) * e.r; dir = aa + (e.ccw ? Math.PI / 2 : -Math.PI / 2);
    }
    const d = Math.hypot(x - px, y - py);
    if (!best || d < best.d - 1e-12) {
      const off = (x - px) * Math.sin(dir) - (y - py) * Math.cos(dir);   // + to the right
      best = { d, sta: e.sta + s, off, x: px, y: py, dir };
    }
  }
  if (!best) return null;
  // beyond the ends: the projection is not perpendicular
  const along = (p, dir) => (x - p.x) * Math.cos(dir) + (y - p.y) * Math.sin(dir);
  const p0 = this.pointAt(this.start), p1 = this.pointAt(this.end);
  best.outside = (best.sta <= this.start + 1e-9 && along(p0, p0.dir) < -1e-9) || (best.sta >= this.end - 1e-9 && along(p1, p1.dir) > 1e-9);
  return { sta: best.sta, off: best.off, x: best.x, y: best.y, outside: best.outside, dist: best.d };
}

Station ticks

64-bit

The points at every whole multiple of the station interval.

station = k × step, k whole

Each station is a whole number times the step, not a running sum, so tick stations do not drift. The first and last k are found with a 1e-9 allowance; more than 20,000 ticks returns none.

  • alignment.js
  • lines 313 to 320
  • CivilCraft 0.8.1
ticks(step) {
  if (!(step > 0) || !this.elements.length) return [];
  const out = [];
  const k0 = Math.ceil(this.start / step - 1e-9), k1 = Math.floor(this.end / step + 1e-9);
  if (k1 - k0 > 20000) return [];
  for (let k = k0; k <= k1; k++) { const sta = k * step; const p = this.pointAt(sta); out.push({ sta, ...p }); }
  return out;
}

In detail

Method
Arc-definition curve formulas, as the file header states: T = R·tan(Δ/2), L = R·Δ, E = R(sec(Δ/2) − 1), M = R(1 − cos(Δ/2)), chord = 2R·sin(Δ/2). With transitions, the circle center is the intersection of two lines offset R + p from the tangents.
Number types
64-bit numbers throughout.
Tolerances
Repeated PI: closer than 1e-9. Angle point: deflection below 1e-9 rad. Reversal: deflection within 1e-6 rad of π. Transitions too long: θ₁ + θ₂ above Δ + 1e-12. Tangent overlap: need above length + 1e-9. Elements shorter than 1e-9 are dropped. Element lookup by station: 1e-9. All absolute.
Limits
E and M subtract nearly equal numbers for a small deflection. Stations are a running sum of element lengths, so rounding accumulates along a long alignment.

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