fuzzyworld

tower / files / src / world / photos.ts

photos.ts

Blame: the commit that last changed each line · file · history

3cc2b5bd 2026-10-04 Pavel Safronov1/**
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 */
10import { LENGTH_M } from '../canon/constants.js'
11import type { HullMode } from '../views/exterior.js'
12import { type Dial, isDial } from './sky.js'
13
14const HULLS = ['true', 'rock', 'pipes', 'rockpipes', 'xray'] as const satisfies readonly HullMode[]
15
16type Vec3 = [number, number, number]
17
18/** Where the exterior's camera is: kilometres, in the frame the hull turns in. */
19export 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. */
33export 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
48interface 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
61export type ExteriorPhoto = Common & ExteriorShot & { view: 'exterior'; hull: HullMode }
62export type InteriorPhoto = Common & InteriorShot & { view: 'interior' }
63export type Photo = ExteriorPhoto | InteriorPhoto
64/** A shot as taken, before it has been given its name. */
65export type Framing = Omit<ExteriorPhoto, 'id'> | Omit<InteriorPhoto, 'id'>
66
67const 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. */
70export 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 */
77export 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}