tower / files / src / tour / exterior-legs.ts
exterior-legs.ts
| 1 | /** |
| 2 | * The tour outside Tower: legs flown by spacecraft. See world-generation.md §19, D-085. |
| 3 | * |
| 4 | * Scene units are kilometres, as in the exterior view: Tower's axis along z, the sun cap at |
| 5 | * z = 0, the collector 320 km sunward of it. A leg is flown in one of two frames: |
| 6 | * |
| 7 | * - **space**, which does not turn: the hull wheels past a craft in it at 700 m/s at the |
| 8 | * skin, once every 7 min 29 s, because the craft is not going round with it; |
| 9 | * - **tower**, which turns with the hull: a craft matching its spin to dock sees the hull |
| 10 | * hold still and the stars wheel instead. Spacecraft dock anywhere on the outer shell |
| 11 | * (author, 2026-09-28), and a docked ship hangs from it, pressed outward at 1.02 g. |
| 12 | * |
| 13 | * No craft goes into the beam. Its cone runs from the collector's rim to the 3.2 km aperture |
| 14 | * in the sun cap, and every path keeps outside it by a margin the tests check. |
| 15 | * |
| 16 | * Pure functions: the exterior view plays these, and the tests fly them. |
| 17 | */ |
| 18 | import { |
| 19 | BEAM_RADIUS_M, COLLECTOR_DIAMETER_M, COLLECTOR_STANDOFF_M, HULL_DEPTH_M, LENGTH_M, RADIUS_M, |
| 20 | } from '../canon/constants.js' |
| 21 | import { hourWhenSunAt } from '../world/sky.js' |
| 22 | import type { World } from '../world/schema.js' |
| 23 | import { type V3, add, cross, dot, ease, len, mix, norm, radial, scale, sub, v3 } from './geo.js' |
| 24 | |
| 25 | const KM = 1000 |
| 26 | const R = RADIUS_M / KM |
| 27 | const L = LENGTH_M / KM |
| 28 | /** The outer skin: a kilometre out from the floor (D-044). */ |
| 29 | export const HULL_R = R + HULL_DEPTH_M / KM |
| 30 | export const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2 |
| 31 | export const COLLECTOR_Z = -COLLECTOR_STANDOFF_M / KM |
| 32 | const APERTURE_R = BEAM_RADIUS_M / KM |
| 33 | /** |
| 34 | * Where the exterior draws the sun, km: on the axis, far beyond the collector. Not the real |
| 35 | * 150 million km — a scene cannot hold that — but far enough that it sits where the sun |
| 36 | * would against everything nearer. |
| 37 | */ |
| 38 | export const SUN_Z = -14_000 |
| 39 | export const SUN_DRAWN_R = 300 |
| 40 | |
| 41 | /** The beam's radius at `z`: a cone from the collector's rim down to the aperture. */ |
| 42 | export function beamRadius(z: number): number { |
| 43 | if (z < COLLECTOR_Z || z > 0) return 0 |
| 44 | return APERTURE_R + (COLLECTOR_R - APERTURE_R) * (z / COLLECTOR_Z) |
| 45 | } |
| 46 | |
| 47 | export type Frame = 'space' | 'tower' |
| 48 | |
| 49 | /** A camera, in its leg's frame, km. */ |
| 50 | export interface ExtPose { eye: V3; look: V3; up: V3; fovDeg: number } |
| 51 | |
| 52 | /** A spacecraft to draw, in its leg's frame, km: +x of the model is `fwd`, +y is `up`. */ |
| 53 | export interface ExtCraft { pos: V3; fwd: V3; up: V3 } |
| 54 | |
| 55 | export interface ExtLeg { |
| 56 | id: string |
| 57 | title: string |
| 58 | vehicle: 'spacecraft' |
| 59 | /** The clock at the start of the leg, hours since day 0: what lights the inside. */ |
| 60 | hours: number |
| 61 | /** World seconds per second of the leg. The hull turns at this rate too. */ |
| 62 | rate: number |
| 63 | durationS: number |
| 64 | frame: Frame |
| 65 | pose: (t: number) => ExtPose |
| 66 | craft: (t: number) => ExtCraft[] |
| 67 | } |
| 68 | |
| 69 | export interface ExtWorld { world: World } |
| 70 | |
| 71 | interface ExtOpts { durationS: number; rng: () => number } |
| 72 | |
| 73 | const outward = (p: V3): V3 => norm(v3(p.x, p.y, 0)) |
| 74 | |
| 75 | /** Behind a craft, from what it is looking at, and a little above: the craft and the view in one frame. */ |
| 76 | function chase(craft: ExtCraft, target: V3, back: number, raise: number, fovDeg: number): ExtPose { |
| 77 | const aim = norm(sub(target, craft.pos)) |
| 78 | const eye = add(sub(craft.pos, scale(aim, back)), scale(craft.up, raise)) |
| 79 | return { eye, look: add(craft.pos, scale(aim, back * 3)), up: craft.up, fovDeg } |
| 80 | } |
| 81 | |
| 82 | /** A craft `t` into a leg, moving along `path` and pointed along its motion. */ |
| 83 | function flying(path: (t: number) => V3, t: number, up: (p: V3) => V3): ExtCraft { |
| 84 | const pos = path(t) |
| 85 | const d = sub(path(t + 0.05), path(t - 0.05)) |
| 86 | const fwd = len(d) > 1e-6 ? norm(d) : v3(0, 0, 1) |
| 87 | const u = up(pos) |
| 88 | return { pos, fwd, up: norm(sub(u, scale(fwd, dot(u, fwd)))) } |
| 89 | } |
| 90 | |
| 91 | /** An hour on day 0 with the sun well up, so the inside is lit when looked into. */ |
| 92 | const daytime = (w: ExtWorld): number => hourWhenSunAt(w.world.calendar, 0, 330_000) |
| 93 | |
| 94 | export interface ExtLegSpec { id: string; prepare: (w: ExtWorld, o: ExtOpts) => ExtLeg } |
| 95 | |
| 96 | export const EXTERIOR_LEGS: ExtLegSpec[] = [ |
| 97 | { |
| 98 | id: 'hull-skim', |
| 99 | prepare: (w, o) => { |
| 100 | const th = o.rng() * Math.PI * 2, r = HULL_R + 5 |
| 101 | const z0 = 40 + o.rng() * 300 |
| 102 | const path = (t: number) => v3(r * Math.cos(th), r * Math.sin(th), z0 + 3 * t) |
| 103 | return { |
| 104 | id: 'hull-skim', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: o.durationS, frame: 'space', |
| 105 | title: 'Skimming the hull, 5 km off, while Tower turns under the ship at 700 m/s', |
| 106 | craft: (t) => [flying(path, t, outward)], |
| 107 | pose: (t) => { |
| 108 | const c = flying(path, t, outward) |
| 109 | return chase(c, add(add(c.pos, v3(0, 0, 40)), scale(c.up, -9)), 0.35, 0.09, 60) |
| 110 | }, |
| 111 | } |
| 112 | }, |
| 113 | }, |
| 114 | { |
| 115 | id: 'hull-orbit', |
| 116 | prepare: (w, o) => { |
| 117 | const th0 = o.rng() * Math.PI * 2, r = HULL_R + 11 |
| 118 | const z = 60 + o.rng() * 420 |
| 119 | // Clockwise seen from the far cap: against the spin, so the hull comes the other way. |
| 120 | const path = (t: number) => { const th = th0 - (4 * t) / r; return v3(r * Math.cos(th), r * Math.sin(th), z) } |
| 121 | return { |
| 122 | id: 'hull-orbit', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: o.durationS, frame: 'space', |
| 123 | title: 'Round the hull against its spin: 700 m/s of Tower coming the other way', |
| 124 | craft: (t) => [flying(path, t, outward)], |
| 125 | pose: (t) => { |
| 126 | const c = flying(path, t, outward) |
| 127 | return chase(c, add(add(c.pos, scale(c.fwd, 40)), scale(c.up, -14)), 0.4, 0.1, 62) |
| 128 | }, |
| 129 | } |
| 130 | }, |
| 131 | }, |
| 132 | { |
| 133 | id: 'docking', |
| 134 | prepare: (w, o) => { |
| 135 | const th = o.rng() * Math.PI * 2, z = 60 + o.rng() * 420 |
| 136 | const out = v3(Math.cos(th), Math.sin(th), 0) |
| 137 | const pad = scale(out, HULL_R) |
| 138 | const D = o.durationS |
| 139 | // Down onto the shell, slowing to a stop 70 m off it, as the clamps take it. |
| 140 | const pos = (t: number) => add(add(pad, v3(0, 0, z)), scale(out, 0.07 + 3.2 * (1 - ease(t / D)))) |
| 141 | // Its feet are toward the hull: once docked, the spin presses it outward, away from it. |
| 142 | const craft = (t: number): ExtCraft => ({ pos: pos(t), fwd: v3(0, 0, 1), up: scale(out, -1) }) |
| 143 | const side = norm(cross(v3(0, 0, 1), out)) |
| 144 | const eye = add(add(add(pad, v3(0, 0, z - 0.32)), scale(out, 0.34)), scale(side, 0.3)) |
| 145 | return { |
| 146 | id: 'docking', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: D, frame: 'tower', |
| 147 | title: 'Docking: matching the spin to set down on the shell, where a ship hangs from its clamps at 1.02 g — the stars wheel, and the hull holds still', |
| 148 | craft: (t) => [craft(t)], |
| 149 | pose: (t) => ({ eye, look: mix(pos(t), add(pad, v3(0, 0, z)), 0.3), up: out, fovDeg: 55 }), |
| 150 | } |
| 151 | }, |
| 152 | }, |
| 153 | { |
| 154 | id: 'far-cap-peek', |
| 155 | prepare: (w, o) => { |
| 156 | const phi = o.rng() * Math.PI * 2 |
| 157 | const off = (k: number) => v3(k * Math.cos(phi), k * Math.sin(phi), 0) |
| 158 | const D = o.durationS |
| 159 | const path = (t: number) => add(off(40 - 22 * ease(t / D)), v3(0, 0, L + 200 - 140 * ease(t / D))) |
| 160 | const target = v3(0, 0, L - 80) |
| 161 | const up = (p: V3) => norm(cross(norm(sub(target, p)), norm(cross(v3(0, 0, 1), outward(p))))) |
| 162 | return { |
| 163 | id: 'far-cap-peek', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: D, frame: 'space', |
| 164 | title: 'Looking in through the far cap: the whole world inside, 540 km deep, lit by its own sun', |
| 165 | craft: (t) => [flying(path, t, up)], |
| 166 | pose: (t) => chase(flying(path, t, up), target, 0.5, 0.12, 50), |
| 167 | } |
| 168 | }, |
| 169 | }, |
| 170 | { |
| 171 | id: 'sun-cap-pass', |
| 172 | prepare: (w, o) => { |
| 173 | const phi = o.rng() * Math.PI * 2 |
| 174 | const u = v3(Math.cos(phi), Math.sin(phi), 0), s = v3(-Math.sin(phi), Math.cos(phi), 0) |
| 175 | const D = o.durationS |
| 176 | const path = (t: number) => add(add(scale(s, -70 + (140 * t) / D), scale(u, 48)), v3(0, 0, -45)) |
| 177 | return { |
| 178 | id: 'sun-cap-pass', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: D, frame: 'space', |
| 179 | title: 'Across the sun cap, clear of the beam: 54 TW going in through a hole 3.2 km wide', |
| 180 | craft: (t) => [flying(path, t, () => u)], |
| 181 | pose: (t) => chase(flying(path, t, () => u), v3(0, 0, 10), 0.4, 0.1, 58), |
| 182 | } |
| 183 | }, |
| 184 | }, |
| 185 | { |
| 186 | id: 'collector-rim', |
| 187 | prepare: (w, o) => { |
| 188 | const psi = o.rng() * Math.PI * 2 |
| 189 | const out = v3(Math.cos(psi), Math.sin(psi), 0) |
| 190 | const pivot = add(scale(out, COLLECTOR_R), v3(0, 0, COLLECTOR_Z)) |
| 191 | const D = o.durationS |
| 192 | // Over the rim from Tower's side to the sun's, 30 km out from its edge. |
| 193 | const path = (t: number) => { |
| 194 | const a = 1.35 - 2.7 * ease(t / D) |
| 195 | return add(pivot, add(scale(out, 30 * Math.cos(a)), v3(0, 0, 30 * Math.sin(a)))) |
| 196 | } |
| 197 | const centre = v3(0, 0, COLLECTOR_Z) |
| 198 | return { |
| 199 | id: 'collector-rim', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: D, frame: 'space', |
| 200 | title: 'Over the rim of the collector, 244 km across, onto its mirrored face', |
| 201 | craft: (t) => [flying(path, t, (p) => norm(sub(p, pivot)))], |
| 202 | pose: (t) => chase(flying(path, t, (p) => norm(sub(p, pivot))), centre, 0.6, 0.15, 64), |
| 203 | } |
| 204 | }, |
| 205 | }, |
| 206 | { |
| 207 | id: 'grand', |
| 208 | prepare: (w, o) => { |
| 209 | const phi0 = o.rng() * Math.PI * 2 |
| 210 | const eyeAt = (t: number) => { |
| 211 | const phi = phi0 + 0.004 * t |
| 212 | return v3(240 * Math.cos(phi), 240 * Math.sin(phi), 980 + 3 * t) |
| 213 | } |
| 214 | const frame = (t: number): ExtPose => { |
| 215 | const eye = eyeAt(t) |
| 216 | const marks = [v3(0, 0, L), v3(0, 0, 0), v3(0, 0, COLLECTOR_Z), v3(0, 0, SUN_Z)] |
| 217 | const dirs = marks.map((m) => norm(sub(m, eye))) |
| 218 | const look = add(eye, norm(dirs.reduce((a, b) => add(a, b), v3(0, 0, 0)))) |
| 219 | const centre = norm(sub(look, eye)) |
| 220 | // Wide enough for all of it, with the collector's whole disc and a margin. |
| 221 | const widest = Math.max(...dirs.map((d) => Math.acos(Math.min(1, dot(d, centre))))) |
| 222 | + Math.atan(COLLECTOR_R / len(sub(v3(0, 0, COLLECTOR_Z), eye))) |
| 223 | return { eye, look, up: v3(0, 1, 0), fovDeg: Math.min(70, Math.max(24, (widest * 2.3 * 180) / Math.PI)) } |
| 224 | } |
| 225 | return { |
| 226 | id: 'grand', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: o.durationS, frame: 'space', |
| 227 | title: 'Tower, its collector and the sun in one frame — from inside, the collector hides the sun from everyone', |
| 228 | craft: (t) => { |
| 229 | const p = frame(t) |
| 230 | const f = norm(sub(p.look, p.eye)) |
| 231 | const right = norm(cross(f, p.up)) |
| 232 | return [{ pos: add(add(add(p.eye, scale(f, 1.3)), scale(right, 0.32)), scale(p.up, -0.2)), fwd: f, up: p.up }] |
| 233 | }, |
| 234 | pose: frame, |
| 235 | } |
| 236 | }, |
| 237 | }, |
| 238 | { |
| 239 | id: 'beam-run', |
| 240 | prepare: (w, o) => { |
| 241 | const psi = o.rng() * Math.PI * 2 |
| 242 | const out = v3(Math.cos(psi), Math.sin(psi), 0) |
| 243 | const D = o.durationS |
| 244 | const path = (t: number) => { |
| 245 | const z = COLLECTOR_Z + 25 + (-COLLECTOR_Z - 60) * ease(t / D) |
| 246 | return add(scale(out, beamRadius(z) + 14), v3(0, 0, z)) |
| 247 | } |
| 248 | return { |
| 249 | id: 'beam-run', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: D, frame: 'space', |
| 250 | title: 'Down the beam from the collector to the sun cap, just outside it', |
| 251 | craft: (t) => [flying(path, t, () => out)], |
| 252 | pose: (t) => { |
| 253 | const c = flying(path, t, () => out) |
| 254 | return chase(c, v3(0, 0, 30), 0.6, 0.15, 62) |
| 255 | }, |
| 256 | } |
| 257 | }, |
| 258 | }, |
| 259 | { |
| 260 | id: 'pull-back', |
| 261 | prepare: (w, o) => { |
| 262 | const th = o.rng() * Math.PI * 2 |
| 263 | const out = v3(Math.cos(th), Math.sin(th), 0) |
| 264 | const D = o.durationS |
| 265 | const dist = (t: number) => 15 * 80 ** ease(t / D) |
| 266 | return { |
| 267 | id: 'pull-back', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: D, frame: 'space', |
| 268 | title: 'Pulling away: Tower is 540 km long, and the collector is bigger still', |
| 269 | craft: () => [], |
| 270 | pose: (t) => { |
| 271 | const u = ease(t / D) |
| 272 | const eye = add(scale(out, HULL_R + dist(t)), v3(0, 0, 270 - 120 * u)) |
| 273 | return { eye, look: v3(0, 0, 270 - 250 * u), up: v3(0, 0, 1), fovDeg: 55 } |
| 274 | }, |
| 275 | } |
| 276 | }, |
| 277 | }, |
| 278 | ] |
| 279 | |
| 280 | export function planExterior(w: ExtWorld, rng: () => number, durationS: (id: string) => number): ExtLeg[] { |
| 281 | return EXTERIOR_LEGS.map((spec) => spec.prepare(w, { durationS: durationS(spec.id), rng })) |
| 282 | } |
| 283 | |
| 284 | /** True when a point is clear of the beam's cone by `marginKm`. */ |
| 285 | export function clearOfBeam(p: V3, marginKm: number): boolean { |
| 286 | const r = beamRadius(p.z) |
| 287 | return r === 0 || radial(p) > r + marginKm |
| 288 | } |