fuzzyworld

tower / files / test / celestial.test.ts

celestial.test.ts

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

e331ec2a 2026-09-28 Pavel Safronov1import { describe, expect, it } from 'vitest'
2import {
42b4554e 2026-09-29 Pavel Safronov3 STAR_FACE_SIZE, bakeStars, equatorial, moonPhase, skyFrame, sunLongitude,
e331ec2a 2026-09-28 Pavel Safronov4} from '../src/world/celestial.js'
5import catalog from '../src/data/bright-stars.json'
6
7const DEG = Math.PI / 180
8const apply = (m: number[], v: number[]) =>
9 [0, 1, 2].map((r) => m[3 * r]! * v[0]! + m[3 * r + 1]! * v[1]! + m[3 * r + 2]! * v[2]!)
10const angleDeg = (a: number[], b: number[]) =>
11 Math.acos(Math.min(1, a[0]! * b[0]! + a[1]! * b[1]! + a[2]! * b[2]!)) / DEG
12/** Precession from J2000 to mid-2026, in ecliptic longitude: the sun's offset from the catalogue's frame. */
13const P2026 = (50.29 / 3600) * 26.4
14/** A J2000 unit vector from ecliptic longitude and latitude, degrees. */
15const ecliptic = (lon: number, lat: number) => {
16 const e = 23.4392911 * DEG
17 const x = Math.cos(lat * DEG) * Math.cos(lon * DEG)
18 const y = Math.cos(lat * DEG) * Math.sin(lon * DEG)
19 const z = Math.sin(lat * DEG)
20 return [x, y * Math.cos(e) - z * Math.sin(e), y * Math.sin(e) + z * Math.cos(e)]
21}
22
23describe('the sky behind Tower (D-075)', () => {
24 it("puts the sun where the almanac does", () => {
25 // 2026's March equinox, 14:46 UTC, and June solstice, 08:24 UTC.
26 expect(sunLongitude(new Date(Date.UTC(2026, 2, 20, 14, 46))) / DEG).toBeCloseTo(0, 1)
27 expect(sunLongitude(new Date(Date.UTC(2026, 5, 21, 8, 24))) / DEG).toBeCloseTo(90, 1)
28 })
29
30 it('is a rotation, not a mirror', () => {
31 const m = skyFrame(new Date(Date.UTC(2026, 8, 27)))
32 const det = m[0]! * (m[4]! * m[8]! - m[5]! * m[7]!)
33 - m[1]! * (m[3]! * m[8]! - m[5]! * m[6]!)
34 + m[2]! * (m[3]! * m[7]! - m[4]! * m[6]!)
35 expect(det).toBeCloseTo(1, 9)
36 })
37
38 it('points the sun cap at the sun and the far cap away from it', () => {
39 const at = new Date(Date.UTC(2026, 5, 21, 8, 24))
40 const m = skyFrame(at)
41 expect(angleDeg(apply(m, [0, 0, -1]), ecliptic(90 - P2026, 0))).toBeLessThan(0.02)
42 expect(angleDeg(apply(m, [0, 0, 1]), ecliptic(270 - P2026, 0))).toBeLessThan(0.02)
43 })
44
45 it('keeps ecliptic north up, whatever the day', () => {
46 for (const month of [0, 3, 6, 9]) {
47 const m = skyFrame(new Date(Date.UTC(2026, month, 1)))
48 expect(angleDeg(apply(m, [0, 1, 0]), ecliptic(0, 90))).toBeLessThan(1e-6)
49 }
50 })
51
52 it('shows Orion from the far cap in December', () => {
53 // At the December solstice the far cap looks at RA 6h, Dec +23°: Betelgeuse is 16° off.
54 const m = skyFrame(new Date(Date.UTC(2026, 11, 21, 20, 50)))
55 const betelgeuse = equatorial(88.79, 7.41)
56 expect(angleDeg(apply(m, [0, 0, 1]), betelgeuse)).toBeLessThan(17)
57 expect(angleDeg(apply(m, [0, 0, -1]), betelgeuse)).toBeGreaterThan(163)
58 })
59})
60
61describe('the star catalogue', () => {
62 const stars = catalog.stars
63 it('holds the naked-eye sky, Sirius brightest', () => {
64 expect(stars.length / 4).toBeGreaterThan(9000)
65 let brightest = 0
66 for (let i = 4; i < stars.length; i += 4) if (stars[i + 2]! < stars[brightest + 2]!) brightest = i
67 // Sirius: RA 6h 45m 09s, Dec −16° 43′, V −1.46.
68 expect(stars[brightest]).toBeCloseTo(101.29, 1)
69 expect(stars[brightest + 1]).toBeCloseTo(-16.72, 1)
70 expect(stars[brightest + 2]).toBeCloseTo(-1.46, 2)
71 })
72})
73
74describe('the star bake', () => {
75 const S = STAR_FACE_SIZE
76 const texel = (t: Float32Array, face: number, cu: number, cv: number) =>
77 Array.from(t.subarray(((face * S + cv) * S + cu) * 4, ((face * S + cv) * S + cu) * 4 + 4))
78
79 it('files a star under its face and cell', () => {
80 // RA 0, Dec 0 is +x, the middle of face 0.
81 const t = bakeStars([0, 0, 6.5, 0.4])
82 const [u, v, flux, bv] = texel(t, 0, S / 2, S / 2)
83 expect([u, v]).toEqual([0, 0])
84 expect(flux).toBeCloseTo(1, 6)
85 expect(bv).toBeCloseTo(0.4, 6)
86 })
87
88 it('bakes a star by an edge into both faces, once each', () => {
89 // Just past 45° from +x toward +y: on face 2 (+y), in face 0's border.
90 const t = bakeStars([45.05, 0, 1, 0])
91 let copies = 0
92 for (let i = 2; i < t.length; i += 4) if (t[i]! > 0) copies++
93 expect(copies).toBe(2)
94 })
95
96 it("adds a close double's light and keeps the brighter's place", () => {
97 const t = bakeStars([0, 0, 6.5, 1.5, 0.01, 0, 5.0, -0.1])
98 const lit = [] as number[][]
99 for (let i = 0; i < t.length; i += 4) if (t[i + 2]! > 0) lit.push(Array.from(t.subarray(i, i + 4)))
100 expect(lit).toHaveLength(1)
101 expect(lit[0]![2]).toBeCloseTo(1 + 10 ** 0.6, 4)
102 expect(lit[0]![3]).toBeCloseTo(-0.1, 6)
103 })
104})
42b4554e 2026-09-29 Pavel Safronov105
106describe("Earth's Moon's phase", () => {
107 it('is new, full and half at the moments the almanac says', () => {
108 expect(moonPhase(new Date('2000-01-06T18:14Z')).fraction).toBeLessThan(0.001)
109 expect(moonPhase(new Date('2024-04-23T23:49Z')).fraction).toBeGreaterThan(0.999)
110 const first = moonPhase(new Date('2024-04-15T19:13Z'))
111 expect(first.fraction).toBeCloseTo(0.5, 1)
112 expect(first.waxing).toBe(true)
113 const last = moonPhase(new Date('2024-05-01T11:27Z'))
114 expect(last.fraction).toBeCloseTo(0.5, 1)
115 expect(last.waxing).toBe(false)
116 })
117})