fuzzyworld

tower / files / src / tour / exterior-legs.ts

exterior-legs.ts

12.4 KB · history · blame

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 */
18import {
19 BEAM_RADIUS_M, COLLECTOR_DIAMETER_M, COLLECTOR_STANDOFF_M, HULL_DEPTH_M, LENGTH_M, RADIUS_M,
20} from '../canon/constants.js'
21import { hourWhenSunAt } from '../world/sky.js'
22import type { World } from '../world/schema.js'
23import { type V3, add, cross, dot, ease, len, mix, norm, radial, scale, sub, v3 } from './geo.js'
24
25const KM = 1000
26const R = RADIUS_M / KM
27const L = LENGTH_M / KM
28/** The outer skin: a kilometre out from the floor (D-044). */
29export const HULL_R = R + HULL_DEPTH_M / KM
30export const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2
31export const COLLECTOR_Z = -COLLECTOR_STANDOFF_M / KM
32const 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 */
38export const SUN_Z = -14_000
39export const SUN_DRAWN_R = 300
40
41/** The beam's radius at `z`: a cone from the collector's rim down to the aperture. */
42export 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
47export type Frame = 'space' | 'tower'
48
49/** A camera, in its leg's frame, km. */
50export 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`. */
53export interface ExtCraft { pos: V3; fwd: V3; up: V3 }
54
55export 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
69export interface ExtWorld { world: World }
70
71interface ExtOpts { durationS: number; rng: () => number }
72
73const 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. */
76function 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. */
83function 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. */
92const daytime = (w: ExtWorld): number => hourWhenSunAt(w.world.calendar, 0, 330_000)
93
94export interface ExtLegSpec { id: string; prepare: (w: ExtWorld, o: ExtOpts) => ExtLeg }
95
96export 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
280export 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`. */
285export function clearOfBeam(p: V3, marginKm: number): boolean {
286 const r = beamRadius(p.z)
287 return r === 0 || radial(p) > r + marginKm
288}