tower / files / src / terrain / rails.ts
rails.ts
99a2821b 2026-09-29 Pavel Safronov | 1 | /** |
| 2 | * Railway lines: where the track runs, and how high. See design.md §1.11, D-086. | |
| 3 | * | |
| 4 | * A line is authored as a handful of waypoints (world.json, kind `railway`) and smoothed | |
| 5 | * like a river. Its **grade** is its own: Tower does not cut the ground for it, so the deck | |
| 6 | * rides over the land at the height of the highest ground within a kilometre either side, | |
| 7 | * eased along the line — which puts it on the ground across level country and on a viaduct | |
| 8 | * over anything it would otherwise have to dip into. The deck is never below the ground, so | |
| 9 | * a train never runs through a hill. | |
| 10 | * | |
| 11 | * A line whose last waypoint comes back round to its first, a whole circumference on, is a | |
| 12 | * loop, and its windows wrap. | |
| 13 | * | |
| 14 | * Pure functions over a ground function, like the scenic tour (tour/), so the interior view, | |
| 15 | * the tour and the tests all lay the same track. | |
| 16 | */ | |
| 17 | import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js' | |
| 18 | import type { Railway, World } from '../world/schema.js' | |
| 19 | import { type Line, smoothLine } from './shapes.js' | |
| 20 | ||
| 21 | /** Metres between samples of the deck. */ | |
| 22 | export const RAIL_STEP_M = 50 | |
| 23 | /** How far either side the deck looks for the highest ground, metres. */ | |
| 24 | const REACH_M = 1000 | |
| 25 | /** How far either side the deck is eased over, metres. No more than REACH_M, or it can dip. */ | |
| 26 | const EASE_M = 900 | |
| 27 | /** The deck stands this far above the highest ground it clears, metres: ballast and sleepers. */ | |
| 28 | export const DECK_CLEAR_M = 1.2 | |
| 29 | ||
| 30 | export interface RailLine { | |
| 31 | id: string | |
| 32 | name: string | |
| 33 | speedMS: number | |
| 34 | loop: boolean | |
| 35 | line: Line | |
| 36 | lengthM: number | |
| 37 | /** Height of the rails above the datum at each sample, metres. */ | |
| 38 | deckM: Float64Array | |
| 39 | /** Height of the ground or water under each sample, metres. */ | |
| 40 | groundM: Float64Array | |
| 41 | } | |
| 42 | ||
| 43 | export interface RailFix { | |
| 44 | z: number | |
| 45 | /** Around, metres, unwrapped along the line. */ | |
| 46 | a: number | |
| 47 | altM: number | |
| 48 | /** Radians from deepward toward spinward. */ | |
| 49 | heading: number | |
| 50 | /** Rise over run, along the direction of travel. */ | |
| 51 | grade: number | |
| 52 | } | |
| 53 | ||
| 54 | /** True when the waypoints go the whole way round and meet themselves. */ | |
| 55 | function isLoop(points: readonly [number, number][]): boolean { | |
| 56 | if (points.length < 3) return false | |
| 57 | const [t0, z0] = points[0]!, [t1, z1] = points[points.length - 1]! | |
| 58 | return Math.abs(z1 - z0) < 1 && Math.abs(Math.abs(t1 - t0) * RADIUS_M - CIRCUMFERENCE_M) < 10 | |
| 59 | } | |
| 60 | ||
| 61 | /** Every railway in the world, with its deck laid over `ground` (metres above the datum). */ | |
| 62 | export function railLines(world: World, ground: (theta: number, z: number) => number): RailLine[] { | |
| 63 | return world.features | |
| 64 | .filter((f): f is Railway => f.kind === 'railway' && f.geometry.type === 'polyline') | |
| 65 | .map((f) => layRail(f, ground)) | |
| 66 | } | |
| 67 | ||
| 68 | export function layRail(f: Railway, ground: (theta: number, z: number) => number): RailLine { | |
| 69 | const points = f.geometry.type === 'polyline' ? f.geometry.points : [] | |
| 70 | const loop = isLoop(points) | |
| 71 | const line = smoothLine(points, RAIL_STEP_M) | |
| 72 | const n = line.s.length | |
| 73 | const g = new Float64Array(n) | |
| 74 | for (let i = 0; i < n; i++) g[i] = ground(line.pts[2 * i + 1]! / RADIUS_M, line.pts[2 * i]!) | |
| 75 | // A loop's last sample is its first again; windows wrap over the rest. | |
| 76 | const m = loop ? n - 1 : n | |
| 77 | const idx = (i: number): number => (loop ? ((i % m) + m) % m : Math.max(0, Math.min(n - 1, i))) | |
| 78 | const window = (src: Float64Array, r: number, pick: 'max' | 'mean'): Float64Array => { | |
| 79 | const out = new Float64Array(n) | |
| 80 | for (let i = 0; i < n; i++) { | |
| 81 | let v = pick === 'max' ? -Infinity : 0 | |
| 82 | for (let k = -r; k <= r; k++) { | |
| 83 | const x = src[idx(i + k)]! | |
| 84 | v = pick === 'max' ? Math.max(v, x) : v + x | |
| 85 | } | |
| 86 | out[i] = pick === 'max' ? v : v / (2 * r + 1) | |
| 87 | } | |
| 88 | return out | |
| 89 | } | |
| 90 | const high = window(g, Math.round(REACH_M / RAIL_STEP_M), 'max') | |
| 91 | const deck = window(high, Math.round(EASE_M / RAIL_STEP_M), 'mean') | |
| 92 | for (let i = 0; i < n; i++) deck[i] = deck[i]! + DECK_CLEAR_M | |
| 93 | return { | |
| 94 | id: f.id, name: f.name, speedMS: f.speedMS, loop, line, | |
| 95 | lengthM: line.s[n - 1]!, deckM: deck, groundM: g, | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 99 | /** Where the track is `s` metres along the line. A loop's `s` wraps; a line's is clamped. */ | |
| 100 | export function railAt(r: RailLine, s: number): RailFix { | |
| 101 | const { line } = r | |
| 102 | const n = line.s.length | |
| 103 | const len = r.lengthM | |
| 104 | const u = r.loop ? ((s % len) + len) % len : Math.max(0, Math.min(len, s)) | |
| 105 | const i = Math.min(n - 2, Math.floor(u / RAIL_STEP_M)) | |
| 106 | const t = Math.max(0, Math.min(1, (u - line.s[i]!) / (line.s[i + 1]! - line.s[i]! || 1))) | |
| 107 | const z0 = line.pts[2 * i]!, a0 = line.pts[2 * i + 1]! | |
| 108 | const z1 = line.pts[2 * i + 2]!, a1 = line.pts[2 * i + 3]! | |
| 109 | const h0 = r.deckM[i]!, h1 = r.deckM[i + 1]! | |
| 110 | const run = Math.hypot(z1 - z0, a1 - a0) || 1 | |
| 111 | return { | |
| 112 | z: z0 + (z1 - z0) * t, | |
| 113 | a: a0 + (a1 - a0) * t, | |
| 114 | altM: h0 + (h1 - h0) * t, | |
| 115 | heading: Math.atan2(a1 - a0, z1 - z0), | |
| 116 | grade: (h1 - h0) / run, | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | /** The steepest grade anywhere on a line, rise over run. */ | |
| 121 | export function maxGrade(r: RailLine): number { | |
| 122 | let g = 0 | |
| 123 | for (let i = 1; i < r.deckM.length; i++) g = Math.max(g, Math.abs(r.deckM[i]! - r.deckM[i - 1]!) / RAIL_STEP_M) | |
| 124 | return g | |
| 125 | } |