tower / interior-flight / files / src / world / instruments.ts
Branch interior-flight · default branch · all branches
instruments.ts
| 1 | /** |
| 2 | * Measuring instruments. See design.md §3.3. |
| 3 | * |
| 4 | * These are the reason writer mode exists: the author asks questions like "can the |
| 5 | * far cap be seen from the midpoint?" and needs an answer that is computed rather |
| 6 | * than guessed. Two such answers became canon in design.md §1.8. |
| 7 | */ |
| 8 | import { extinctionAtRadius } from '../canon/atmosphere.js' |
| 9 | import { |
| 10 | type SurfacePoint, toCartesian, surfaceDistance, directDistance, |
| 11 | chordApexHeight, routeCrossesSlat, landStripIndex, |
| 12 | } from '../canon/geometry.js' |
| 13 | |
| 14 | export type Clarity = 'crisp' | 'hazy' | 'ghost' | 'invisible' |
| 15 | |
| 16 | export interface Sightline { |
| 17 | surfaceDistanceM: number |
| 18 | directDistanceM: number |
| 19 | /** How high above the floor the sightline rises. Why looking up beats looking along. */ |
| 20 | apexHeightM: number |
| 21 | opticalDepth: number |
| 22 | transmission: number |
| 23 | clarity: Clarity |
| 24 | } |
| 25 | |
| 26 | /** |
| 27 | * Rayleigh optical depth along the chord between two surface points, integrated |
| 28 | * through Tower's rotational density profile. |
| 29 | */ |
| 30 | export function opticalDepth(a: SurfacePoint, b: SurfacePoint, samples = 512): number { |
| 31 | const p0 = toCartesian(a) |
| 32 | const p1 = toCartesian(b) |
| 33 | const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]) |
| 34 | if (len === 0) return 0 |
| 35 | const step = len / samples |
| 36 | let tau = 0 |
| 37 | for (let i = 0; i < samples; i++) { |
| 38 | const t = (i + 0.5) / samples |
| 39 | const x = p0[0] + (p1[0] - p0[0]) * t |
| 40 | const y = p0[1] + (p1[1] - p0[1]) * t |
| 41 | tau += extinctionAtRadius(Math.hypot(x, y)) * step |
| 42 | } |
| 43 | return tau |
| 44 | } |
| 45 | |
| 46 | export function clarityOf(opticalDepthValue: number): Clarity { |
| 47 | if (opticalDepthValue < 0.5) return 'crisp' |
| 48 | if (opticalDepthValue < 1.5) return 'hazy' |
| 49 | if (opticalDepthValue < 3.5) return 'ghost' |
| 50 | return 'invisible' |
| 51 | } |
| 52 | |
| 53 | export function sightline(a: SurfacePoint, b: SurfacePoint): Sightline { |
| 54 | const tau = opticalDepth(a, b) |
| 55 | return { |
| 56 | surfaceDistanceM: surfaceDistance(a, b), |
| 57 | directDistanceM: directDistance(a, b), |
| 58 | apexHeightM: chordApexHeight(a, b), |
| 59 | opticalDepth: tau, |
| 60 | transmission: Math.exp(-tau), |
| 61 | clarity: clarityOf(tau), |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | export interface Travel { |
| 66 | distanceM: number |
| 67 | crossesSlat: boolean |
| 68 | /** Journey time in seconds at the given speed. */ |
| 69 | durationS: number |
| 70 | } |
| 71 | |
| 72 | export function travel(a: SurfacePoint, b: SurfacePoint, speedMS: number): Travel { |
| 73 | const distanceM = surfaceDistance(a, b) |
| 74 | return { distanceM, crossesSlat: routeCrossesSlat(a, b), durationS: distanceM / speedMS } |
| 75 | } |
| 76 | |
| 77 | /** Which land strip a point sits on, or null for a slat. Useful for place metadata. */ |
| 78 | export { landStripIndex } |