Resources Numerical accuracy Calculation policy

Why a curve does not fit, and the radius or length that would

When an alignment's curves or a profile's vertical curves overlap, works out by how much and offers the largest radius or curve length that fits.

How the precision is kept

Closed-form 64-bit geometry: the turn at each point from two atan2 calls, tangent length T = r·tan(|Δ| ÷ 2), and room as differences of plan distances. The radius or length that is offered is rounded down to 0.01 (a minimum is rounded up to 0.01) so that it still fits after rounding.

Calculations under this policy

5 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.

solve-why.js CivilCraft 0.8.1 64-bit 5 calculations Reviewed

Tests bin/test-civilcraft-why.mjs

Turn angle at a point

64-bit

The signed turn of the line at a point of intersection.

Δ = atan2 of the leg after − atan2 of the leg before, wrapped to (−π, π]

Two Math.atan2 calls and a subtraction; no acos or asin, so there is no argument to clamp.

  • solve-why.js
  • lines 24 to 30
  • CivilCraft 0.8.1
const ang = (a, b, c) => {
  const a1 = Math.atan2(b.y - a.y, b.x - a.x), a2 = Math.atan2(c.y - b.y, c.x - b.x);
  let d = a2 - a1;
  while (d > Math.PI) d -= 2 * Math.PI;
  while (d < -Math.PI) d += 2 * Math.PI;
  return d;
};

Tangent length of each curve

64-bit

The turn and the tangent length each curve takes on either side of its point.

T = r × tan(|Δ| ÷ 2)

One 64-bit tangent and multiplication. A turn within 1e-6 radians of a full reversal is flagged and gets no tangent length, which keeps tan from being evaluated near π ÷ 2.

  • solve-why.js
  • lines 35 to 43
  • CivilCraft 0.8.1
/** Each PI's turn (radians, signed) and tangent length for its radius; the first and last PI have none. */
function curvesOf(pis) {
  return pis.map((p, i) => {
    if (i === 0 || i === pis.length - 1) return { d: 0, r: 0, T: 0, tanHalf: 0 };
    const d = ang(pis[i - 1], p, pis[i + 1]), r = Number(p.r) > 0 ? Number(p.r) : 0, tanHalf = Math.tan(Math.abs(d) / 2);
    const back = Math.abs(Math.abs(d) - Math.PI) < 1e-6;
    return { d, r, T: back || Math.abs(d) < 1e-9 ? 0 : r * tanHalf, tanHalf, back };
  });
}

Largest radius that fits

64-bit

The largest radius at a point that fits between its neighbors' curves as they are.

room = min(d_before − T_before, d_after − T_after); r_max = room ÷ tan(|Δ| ÷ 2)

Plan distances from Math.hypot minus the neighbors' tangent lengths. A straight run (turn below 1e-9 radians) returns infinity before the division.

  • solve-why.js
  • lines 45 to 52
  • CivilCraft 0.8.1
/** The largest radius at inner PI i that fits with its neighbours as they are (see the header). */
export function maxRadius(pis, i) {
  const C = curvesOf(pis), c = C[i];
  if (!c || i === 0 || i === pis.length - 1 || c.back) return 0;
  if (Math.abs(c.d) < 1e-9) return Infinity;
  const room = Math.min(dist(pis[i - 1], pis[i]) - C[i - 1].T, dist(pis[i], pis[i + 1]) - C[i + 1].T);
  return room <= 0 ? 0 : room / c.tanHalf;
}

Overlapping curves and the fixes offered

64-bit

Finds neighboring curves whose tangents overlap and offers radii that fit.

overlap when Tₐ + T_b > L + 1e-9; both scaled by f = L ÷ (Tₐ + T_b); offered radius = floor(r × 100 + 1e-9) ÷ 100

The comparison is 64-bit with an absolute 1e-9. Every offered radius is rounded down to 0.01, so the value a person accepts is a multiple of 0.01 that fits, not the exact limit; a design minimum is rounded up to 0.01.

  • solve-why.js
  • lines 54 to 80
  • CivilCraft 0.8.1
/** Why the alignment's curves have no solution, with the radii that would fit (see the header). */
export function curveWhy(pis, { minR = 0 } = {}) {
  const P = (pis || []).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
  if (P.length < 3) return [];
  const C = curvesOf(P), out = [];
  C.forEach((c, i) => { if (c.back && c.r > 0) out.push({ kind: 'back', pi: i }); });
  for (let a = 0; a + 1 < P.length; a++) {
    const b = a + 1, L = dist(P[a], P[b]), Ta = C[a].T, Tb = C[b].T;
    if (Ta + Tb <= L + 1e-9) continue;
    const inner = (i) => i > 0 && i < P.length - 1 && !C[i].back && Math.abs(C[i].d) > 1e-9;
    // largest radius at one PI with the other curve kept — limited by its OTHER tangent too
    const fix = (i, other) => {
      if (!inner(i)) return null;
      const here = L - other, far = i === a ? dist(P[a - 1], P[a]) - C[a - 1].T : dist(P[b], P[b + 1]) - C[b + 1].T;
      const room = Math.min(here, far);
      return room > 0 ? down(room / C[i].tanHalf) : null;
    };
    const f = L / (Ta + Tb);
    out.push({ kind: 'overlap', a, b, dist: L, need: Ta + Tb, Ta, Tb, fixA: fix(a, Tb), fixB: fix(b, Ta), both: { ra: inner(a) ? down(C[a].r * f) : 0, rb: inner(b) ? down(C[b].r * f) : 0 } });
  }
  if (minR > 0) C.forEach((c, i) => {
    if (i === 0 || i === P.length - 1 || !(c.r > 0) || c.r >= minR - 1e-9 || c.back) return;
    const maxR = maxRadius(P, i);
    out.push({ kind: 'min', pi: i, r: c.r, minR, maxR, fix: minR <= maxR + 1e-9 ? up(minR) : null });
  });
  return out;
}

Vertical curves that do not fit

64-bit

Finds vertical curves longer than the room between their neighbors and offers the longest length that fits.

A = (g₂ − g₁) × 100; L = K × |A| when given by K; room = 2 × min(back, forward); offered L = floor(room × 100 + 1e-9) ÷ 100; K = L ÷ |A|

Grades are 64-bit quotients of elevation and station differences. The offered length is rounded down to 0.01 and the K value is computed from that rounded length.

  • solve-why.js
  • lines 82 to 102
  • CivilCraft 0.8.1
/** Why vertical curves do not fit, with the longest length that would (see the header). */
export function verticalWhy(pvisIn) {
  const pvis = (pvisIn || []).filter((p) => Number.isFinite(p.sta) && Number.isFinite(p.z)).map((p, i) => ({ ...p, i0: i })).sort((a, b) => a.sta - b.sta);
  const out = [];
  let prevEnd = pvis.length ? pvis[0].sta : 0;
  for (let i = 1; i + 1 < pvis.length; i++) {
    const v = pvis[i], g1 = (v.z - pvis[i - 1].z) / (v.sta - pvis[i - 1].sta), g2 = (pvis[i + 1].z - v.z) / (pvis[i + 1].sta - v.sta);
    const A = (g2 - g1) * 100;
    let L = Number(v.L) || 0;
    if (!L && Number(v.K) > 0) L = Number(v.K) * Math.abs(A);
    if (!(L > 0) || Math.abs(A) < 1e-9) continue;
    const back = v.sta - Math.max(pvis[i - 1].sta, prevEnd), fwd = pvis[i + 1].sta - v.sta;
    const room = 2 * Math.min(back, fwd);
    if (L > room + 1e-9) {
      const fix = room > 0 ? down(room) : null;
      out.push({ kind: 'vc', pvi: i, index: v.i0, L, room, fix, K: fix ? fix / Math.abs(A) : null, side: back < fwd ? 'before' : 'past' });
      prevEnd = v.sta + (fix ?? 0) / 2;
    } else prevEnd = v.sta + L / 2;
  }
  return out;
}

In detail

Method
Tangent length T = r·tan(|Δ| ÷ 2); two neighboring curves fit when Tₐ + T_b ≤ distance between the points; a vertical curve fits when its length ≤ twice the shorter distance to its neighbors.
Number types
64-bit numbers.
Tolerances
Overlap and length tests allow 1e-9 (absolute, project units); a turn within 1e-6 radians of π is a reversal; a turn below 1e-9 radians is straight; a grade change below 1e-9 percent has no vertical curve; the rounding to 0.01 nudges by 1e-9 so a value already on a 0.01 step is not moved.
Limits
Offered values are multiples of 0.01 project units, not the exact limit.

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