fuzzyworld

tower / files / test / geometry.test.ts

geometry.test.ts

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

ec7e78a8 2026-09-07 Pavel Safronov1import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2import { CIRCUMFERENCE_M, LENGTH_M, RADIUS_M } from '../src/canon/constants.js'
3import { setSlatCount, slatCount } from '../src/canon/slats.js'
4import { SLAT_COUNT_DEFAULT } from '../src/canon/constants.js'
5import * as G from '../src/canon/geometry.js'
6
7describe('theta wrapping', () => {
8 it('normalizes into [0, 2π)', () => {
9 expect(G.normalizeTheta(-0.5)).toBeCloseTo(2 * Math.PI - 0.5, 10)
10 expect(G.normalizeTheta(7 * Math.PI)).toBeCloseTo(Math.PI, 10)
11 })
12 it('takes the short way across the seam', () => {
13 expect(G.deltaTheta(0.1, 2 * Math.PI - 0.1)).toBeCloseTo(-0.2, 10)
14 expect(G.deltaTheta(2 * Math.PI - 0.1, 0.1)).toBeCloseTo(0.2, 10)
15 })
16})
17
18describe('distances', () => {
19 it('measures surface distance across the seam, not the long way round', () => {
20 const a = { theta: 0.05, z: 0 }
21 const b = { theta: 2 * Math.PI - 0.05, z: 0 }
22 expect(G.surfaceDistance(a, b)).toBeCloseTo(0.1 * RADIUS_M, 6)
23 })
24 it('halfway round is half the circumference on the surface', () => {
25 const d = G.surfaceDistance({ theta: 0, z: 0 }, { theta: Math.PI, z: 0 })
26 expect(d).toBeCloseTo(CIRCUMFERENCE_M / 2, 6)
27 })
28 it('the chord straight overhead is the diameter, and shorter than walking', () => {
29 const a = { theta: 0, z: 0 }
30 const b = { theta: Math.PI, z: 0 }
31 expect(G.directDistance(a, b)).toBeCloseTo(2 * RADIUS_M, 6)
32 expect(G.directDistance(a, b)).toBeLessThan(G.surfaceDistance(a, b))
33 })
34 it('rises the full radius to reach the point directly overhead', () => {
35 expect(G.chordApexHeight({ theta: 0, z: 0 }, { theta: Math.PI, z: 0 })).toBeCloseTo(RADIUS_M, 6)
36 expect(G.chordApexHeight({ theta: 0, z: 0 }, { theta: 0, z: 1000 })).toBeCloseTo(0, 6)
37 })
38})
39
40describe('slats and land strips', () => {
41 // Canon is a sealed hull (D-017); these exercise the exploration mode.
42 beforeEach(() => setSlatCount(8))
43 afterEach(() => setSlatCount(SLAT_COUNT_DEFAULT))
44
45 it('puts land strip centres on land and slat centres on slats', () => {
46 for (let i = 0; i < slatCount(); i++) {
47 expect(G.isSlat(G.landStripCentre(i))).toBe(false)
48 expect(G.isSlat(G.slatCentre(i))).toBe(true)
49 expect(G.landStripIndex(G.landStripCentre(i))).toBe(i)
50 expect(G.landStripIndex(G.slatCentre(i))).toBeNull()
51 }
52 })
53 it('reports a slat crossing when moving between adjacent strips', () => {
54 expect(G.routeCrossesSlat(
55 { theta: G.landStripCentre(0), z: 0 },
56 { theta: G.landStripCentre(1), z: 0 },
57 )).toBe(true)
58 })
59 it('reports no crossing when moving along one strip', () => {
60 expect(G.routeCrossesSlat(
61 { theta: G.landStripCentre(0), z: 0 },
62 { theta: G.landStripCentre(0), z: 200_000 },
63 )).toBe(false)
64 })
65})
66
67describe('map projection', () => {
68 it('round-trips surface points exactly — a cylinder unrolls without distortion', () => {
69 for (const p of [
70 { theta: 0, z: 0 },
71 { theta: 1.234, z: 271_000 },
72 { theta: 2 * Math.PI - 1e-9, z: LENGTH_M },
73 ]) {
74 const back = G.fromMap(G.toMap(p))
75 expect(back.theta).toBeCloseTo(G.normalizeTheta(p.theta), 9)
76 expect(back.z).toBeCloseTo(p.z, 6)
77 }
78 })
79 it('spans the world', () => {
80 expect(G.MAP_WIDTH_M).toBe(LENGTH_M)
81 expect(G.MAP_HEIGHT_M).toBeCloseTo(CIRCUMFERENCE_M, 9)
82 })
83})