tower / files / src / world / photos.ts
photos.ts
3cc2b5bd 2026-10-04 Pavel Safronov | 1 | /** |
| 2 | * Photographs (D-099): a view, posed and timed, kept as numbers so the same picture can be | |
| 3 | * rendered again from whatever the world and the renderer have become since. | |
| 4 | * | |
| 5 | * `content/photos.json` is the list. A shot is saved into it from the running app — P, or | |
| 6 | * the button, on a dev server only — and `npm run photos` renders every one to | |
| 7 | * `photos/<id>.png` (tools/photos.mjs). The numbers are plain and meant to be edited by | |
| 8 | * hand as well: nudge an hour, a pitch or a field of view and render again. | |
| 9 | */ | |
| 10 | import { LENGTH_M } from '../canon/constants.js' | |
| 11 | import type { HullMode } from '../views/exterior.js' | |
| 12 | import { type Dial, isDial } from './sky.js' | |
| 13 | ||
| 14 | const HULLS = ['true', 'rock', 'pipes', 'rockpipes', 'xray'] as const satisfies readonly HullMode[] | |
| 15 | ||
| 16 | type Vec3 = [number, number, number] | |
| 17 | ||
| 18 | /** Where the exterior's camera is: kilometres, in the frame the hull turns in. */ | |
| 19 | export interface ExteriorShot { | |
| 20 | position: Vec3 | |
| 21 | /** The point the camera orbits and looks at. */ | |
| 22 | target: Vec3 | |
| 23 | /** How far round the hull has turned, radians. */ | |
| 24 | phase: number | |
| 25 | /** | |
| 26 | * Vertical field of view, degrees. The view's own is 42 and it has no zoom, so a saved | |
| 27 | * shot always says 42; a smaller number typed here frames the shot tighter. | |
| 28 | */ | |
| 29 | fovDeg: number | |
| 30 | } | |
| 31 | ||
| 32 | /** Where the interior's observer stands and looks. */ | |
| 33 | export interface InteriorShot { | |
| 34 | theta: number | |
| 35 | /** Metres from the sun cap. */ | |
| 36 | z: number | |
| 37 | /** Eye height above the ground, metres. */ | |
| 38 | eyeM: number | |
| 39 | /** Heading, radians clockwise from deepward: 0 faces the far cap, pi the sun cap. */ | |
| 40 | yaw: number | |
| 41 | /** Elevation, radians. */ | |
| 42 | pitch: number | |
| 43 | fovDeg: number | |
| 44 | /** Phase of the sky through the far cap, radians: where the stars have wheeled to. */ | |
| 45 | spin: number | |
| 46 | } | |
| 47 | ||
| 48 | interface Common { | |
| 49 | /** The file's name, less its extension: lower-case letters, digits and hyphens. */ | |
| 50 | id: string | |
| 51 | /** Anything worth remembering about the shot. Never read by the tools. */ | |
| 52 | note?: string | |
| 53 | /** The world clock, hours since day 0 began: the sun, the weather and the ships follow it. */ | |
| 54 | hours: number | |
| 55 | /** The Light. */ | |
| 56 | dial: Dial | |
| 57 | /** Width over height of the frame the shot was composed in. */ | |
| 58 | aspect: number | |
| 59 | } | |
| 60 | ||
| 61 | export type ExteriorPhoto = Common & ExteriorShot & { view: 'exterior'; hull: HullMode } | |
| 62 | export type InteriorPhoto = Common & InteriorShot & { view: 'interior' } | |
| 63 | export type Photo = ExteriorPhoto | InteriorPhoto | |
| 64 | /** A shot as taken, before it has been given its name. */ | |
| 65 | export type Framing = Omit<ExteriorPhoto, 'id'> | Omit<InteriorPhoto, 'id'> | |
| 66 | ||
| 67 | const ID = /^[a-z0-9]+(-[a-z0-9]+)*$/ | |
| 68 | ||
| 69 | /** A name as typed, made into an id: "Far cap, from the Capital" is far-cap-from-the-capital. */ | |
| 70 | export const photoId = (name: string): string => | |
| 71 | name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') | |
| 72 | ||
| 73 | /** | |
| 74 | * One entry of the list, every field checked. Throws with what is wrong: an entry is typed | |
| 75 | * by hand as often as it is saved, and the id becomes a file name. | |
| 76 | */ | |
| 77 | export function parsePhoto(raw: unknown): Photo { | |
| 78 | if (typeof raw !== 'object' || raw === null) throw new Error('a photo is an object') | |
| 79 | const o = raw as Record<string, unknown> | |
| 80 | const id = o['id'] | |
| 81 | if (typeof id !== 'string' || !ID.test(id)) { | |
| 82 | throw new Error(`a photo's id is lower-case letters, digits and hyphens, not ${JSON.stringify(id)}`) | |
| 83 | } | |
| 84 | const num = (key: string, lo = -Infinity, hi = Infinity): number => { | |
| 85 | const v = o[key] | |
| 86 | if (typeof v !== 'number' || !Number.isFinite(v) || v < lo || v > hi) { | |
| 87 | throw new Error(`${id}: ${key} is ${JSON.stringify(v)}`) | |
| 88 | } | |
| 89 | return v | |
| 90 | } | |
| 91 | const vec = (key: string): Vec3 => { | |
| 92 | const v = o[key] | |
| 93 | if (!Array.isArray(v) || v.length !== 3 || !v.every((n) => typeof n === 'number' && Number.isFinite(n))) { | |
| 94 | throw new Error(`${id}: ${key} is three numbers`) | |
| 95 | } | |
| 96 | return [v[0], v[1], v[2]] | |
| 97 | } | |
| 98 | const dial = o['dial'] | |
| 99 | if (!isDial(dial)) throw new Error(`${id}: dial is ${JSON.stringify(dial)}`) | |
| 100 | const note = o['note'] | |
| 101 | if (note !== undefined && typeof note !== 'string') throw new Error(`${id}: note is text`) | |
| 102 | const common: Common = { | |
| 103 | id, ...(note === undefined ? {} : { note }), | |
| 104 | hours: num('hours', 0), dial, aspect: num('aspect', 0.2, 5), | |
| 105 | } | |
| 106 | if (o['view'] === 'exterior') { | |
| 107 | const hull = o['hull'] | |
| 108 | if (!HULLS.includes(hull as HullMode)) throw new Error(`${id}: hull is ${JSON.stringify(hull)}`) | |
| 109 | return { | |
| 110 | ...common, view: 'exterior', hull: hull as HullMode, | |
| 111 | position: vec('position'), target: vec('target'), phase: num('phase'), | |
| 112 | fovDeg: num('fovDeg', 1, 150), | |
| 113 | } | |
| 114 | } | |
| 115 | if (o['view'] === 'interior') { | |
| 116 | return { | |
| 117 | ...common, view: 'interior', | |
| 118 | theta: num('theta'), z: num('z', 0, LENGTH_M), eyeM: num('eyeM', 0), | |
| 119 | yaw: num('yaw'), pitch: num('pitch', -1.55, 1.55), fovDeg: num('fovDeg', 1, 150), | |
| 120 | spin: num('spin'), | |
| 121 | } | |
| 122 | } | |
| 123 | throw new Error(`${id}: view is exterior or interior, not ${JSON.stringify(o['view'])}`) | |
| 124 | } |