tower / files / src / world / photos.ts
photos.ts
| 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 | } |