fuzzyworld

tower / files / src / terrain / rails.ts

rails.ts

5.0 KB · history · blame

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 */
17import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js'
18import type { Railway, World } from '../world/schema.js'
19import { type Line, smoothLine } from './shapes.js'
20
21/** Metres between samples of the deck. */
22export const RAIL_STEP_M = 50
23/** How far either side the deck looks for the highest ground, metres. */
24const REACH_M = 1000
25/** How far either side the deck is eased over, metres. No more than REACH_M, or it can dip. */
26const EASE_M = 900
27/** The deck stands this far above the highest ground it clears, metres: ballast and sleepers. */
28export const DECK_CLEAR_M = 1.2
29
30export 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
43export 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. */
55function 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). */
62export 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
68export 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. */
100export 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. */
121export 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}