tower / files / src / tour / exterior-legs.ts
exterior-legs.ts
99a2821b 2026-09-29 Pavel Safronov | 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 | } |