tower / commit
feat(canon): derive Tower's physics from four free parameters
src/canon/atmosphere.ts | 35 +++++++++++ src/canon/constants.ts | 93 +++++++++++++++++++++++++++++ src/canon/geometry.ts | 127 +++++++++++++++++++++++++++++++++++++++ src/canon/slats.ts | 67 +++++++++++++++++++++ test/canon.test.ts | 155 ++++++++++++++++++++++++++++++++++++++++++++++++ test/geometry.test.ts | 83 ++++++++++++++++++++++++++ 6 files changed, 560 insertions(+) diff --git a/src/canon/atmosphere.ts b/src/canon/atmosphere.ts new file mode 100644 index 0000000..3771fc1 --- /dev/null +++ b/src/canon/atmosphere.ts @@ -0,0 +1,35 @@ +/** + * Tower's atmosphere. See design.md §1.5 and §1.8. + * + * Gravity falls linearly to zero on the axis, so the air is far deeper than Earth's + * and the density profile is set by the rotational potential rather than by a + * constant g. This is what makes a sightline across the interior clearer than one + * of the same length along the floor — the defining optical fact about Tower. + */ +import { + OMEGA_RAD_S, RADIUS_M, SPECIFIC_GAS_CONSTANT, TEMPERATURE_K, + FLOOR_PRESSURE_PA, RAYLEIGH_FLOOR_PER_M, +} from './constants.js' + +/** Rotational potential per unit mass between the floor and radius `s`. */ +export function potentialAtRadius(s: number): number { + return (OMEGA_RAD_S ** 2 * (RADIUS_M ** 2 - s ** 2)) / 2 +} + +/** Air density at radius `s` from the axis, relative to floor density. */ +export function relativeDensityAtRadius(s: number): number { + return Math.exp(-potentialAtRadius(s) / (SPECIFIC_GAS_CONSTANT * TEMPERATURE_K)) +} + +/** Absolute pressure (Pa) at radius `s`. 5.2 kPa on the axis — thin, but real air. */ +export function pressureAtRadius(s: number): number { + return FLOOR_PRESSURE_PA * relativeDensityAtRadius(s) +} + +export const relativeDensityAtHeight = (h: number) => relativeDensityAtRadius(RADIUS_M - h) +export const pressureAtHeight = (h: number) => pressureAtRadius(RADIUS_M - h) + +/** Rayleigh extinction coefficient (per metre) at radius `s`. */ +export function extinctionAtRadius(s: number): number { + return RAYLEIGH_FLOOR_PER_M * relativeDensityAtRadius(s) +} diff --git a/src/canon/constants.ts b/src/canon/constants.ts new file mode 100644 index 0000000..d87dbc3 --- /dev/null +++ b/src/canon/constants.ts @@ -0,0 +1,93 @@ +/** + * Tower canon. See design.md §1. + * + * Per decisions.md D-007, only the values in FREE PARAMETERS are chosen. Everything + * below them is derived, so changing the radius changes the whole world coherently. + * test/canon.test.ts pins every derived figure to the number printed in design.md. + */ + +// ---------------------------------------------------------------- free parameters +export const RADIUS_M = 50_000 +export const LENGTH_M = 540_000 +/** + * Canon default: **none**. The hull is sealed (design.md §1.4, D-017). + * The 0–16 control in src/canon/slats.ts is retained as an exploration tool for + * the author, not because canon is undecided. + */ +export const SLAT_COUNT_DEFAULT = 0 +export const SLAT_COUNT_MIN = 0 +export const SLAT_COUNT_MAX = 16 +/** Wisconsin's land area. Slats are sized to leave exactly this much land (D-002). */ +export const TARGET_LAND_AREA_M2 = 140_268e6 + +// ------------------------------------------------------------------------ physics +export const G0 = 9.80665 +export const SOLAR_CONSTANT_W_M2 = 1361 +/** Speed of light, for radiation pressure. */ +export const C_M_S = 2.998e8 + +// ----------------------------------------------------------------------- geometry +export const CIRCUMFERENCE_M = 2 * Math.PI * RADIUS_M +export const INNER_AREA_M2 = CIRCUMFERENCE_M * LENGTH_M + +// ------------------------------------------------------------- spin (design.md §1.2) +/** Angular velocity giving exactly 1 g at the floor. */ +export const OMEGA_RAD_S = Math.sqrt(G0 / RADIUS_M) +export const SPIN_PERIOD_S = (2 * Math.PI) / OMEGA_RAD_S +export const RIM_SPEED_M_S = OMEGA_RAD_S * RADIUS_M +/** Gravity at radius `s` from the axis. Zero on the axis, 1 g at the floor. */ +export const gravityAtRadius = (s: number): number => OMEGA_RAD_S ** 2 * s +/** Gravity at height `h` above the floor. */ +export const gravityAtHeight = (h: number): number => gravityAtRadius(RADIUS_M - h) + +// ------------------------------------------------------------ slats (design.md §1.4) +/** + * Total slat width is fixed by the land-area target (D-002) and does NOT depend on + * how many slats that width is divided into. So changing the slat count changes + * how wide each one is, and leaves Tower's land area exactly Wisconsin's. + */ +export const SLAT_TOTAL_WIDTH_M = (INNER_AREA_M2 - TARGET_LAND_AREA_M2) / LENGTH_M +/** Land area with at least one slat. With none, the land is the whole surface. */ +export const LAND_AREA_M2 = CIRCUMFERENCE_M * LENGTH_M - SLAT_TOTAL_WIDTH_M * LENGTH_M + +// ------------------------------------------------------- sun system (design.md §1.3) +export const BEAM_CIRCUMFERENCE_M = 10_000 +export const BEAM_RADIUS_M = BEAM_CIRCUMFERENCE_M / (2 * Math.PI) +export const BEAM_AREA_M2 = Math.PI * BEAM_RADIUS_M ** 2 +/** Odeillo-class, and 11% of the étendue limit. See D-003. */ +export const BEAM_CONCENTRATION = 5_000 +export const BEAM_INTENSITY_W_M2 = SOLAR_CONSTANT_W_M2 * BEAM_CONCENTRATION +export const BEAM_POWER_W = BEAM_AREA_M2 * BEAM_INTENSITY_W_M2 +/** Thermodynamic ceiling on passive concentration at 1 AU (D-003). */ +export const ETENDUE_LIMIT = 46_223 + +export const COLLECTOR_EFFICIENCY = 0.85 +export const COLLECTOR_AREA_M2 = BEAM_POWER_W / SOLAR_CONSTANT_W_M2 / COLLECTOR_EFFICIENCY +export const COLLECTOR_DIAMETER_M = 2 * Math.sqrt(COLLECTOR_AREA_M2 / Math.PI) + +// ---------------------------------------------------- diffuser swarm (design.md §1.3.1) +export const MIRRORLET_EDGE_M = 0.012 +export const MIRRORLET_AREA_M2 = MIRRORLET_EDGE_M ** 2 +export const MIRRORLET_MASS_KG = 2.0e-6 +/** Projected mirror area needed to divert the whole beam, tilted ~45°. */ +export const SWARM_AREA_M2 = BEAM_AREA_M2 * Math.SQRT2 +export const SWARM_COUNT = SWARM_AREA_M2 / MIRRORLET_AREA_M2 +export const SWARM_MASS_KG = SWARM_COUNT * MIRRORLET_MASS_KG +export const SWARM_OPTICAL_DEPTH = (SWARM_COUNT / (BEAM_AREA_M2 * LENGTH_M)) * MIRRORLET_AREA_M2 * LENGTH_M +/** Green-light diffraction limit of one mirrorlet — sets Tower's aiming resolution. */ +export const MIRRORLET_DIFFRACTION_RAD = 550e-9 / MIRRORLET_EDGE_M +export const AIM_SPOT_AT_FLOOR_M = MIRRORLET_DIFFRACTION_RAD * RADIUS_M + +// --------------------------------------------------- illumination (design.md §1.3) +/** Mean insolation settings, W/m² over the whole inner surface. */ +export const BRIGHTNESS_TWILIGHT_W_M2 = 30 +export const BRIGHTNESS_TEMPERATE_W_M2 = 200 +export const BRIGHTNESS_FULL_W_M2 = BEAM_POWER_W / INNER_AREA_M2 + +// --------------------------------------------------- atmosphere (design.md §1.5) +export const SPECIFIC_GAS_CONSTANT = 287 +export const TEMPERATURE_K = 288 +export const FLOOR_PRESSURE_PA = 101_325 +export const FLOOR_DENSITY_KG_M3 = FLOOR_PRESSURE_PA / (SPECIFIC_GAS_CONSTANT * TEMPERATURE_K) +/** Rayleigh volume scattering coefficient at floor density, 550 nm. */ +export const RAYLEIGH_FLOOR_PER_M = 1.16e-5 diff --git a/src/canon/geometry.ts b/src/canon/geometry.ts new file mode 100644 index 0000000..59a4173 --- /dev/null +++ b/src/canon/geometry.ts @@ -0,0 +1,127 @@ +/** + * Surface coordinates and the cylinder's geometry. See design.md §1.7. + * + * The canonical surface coordinate is (theta, z): theta radians around the + * cylinder, z metres from the sun cap. A cylinder is a developable surface, so the + * unrolled map is exact — no projection distortion, and surface geodesics are + * straight lines on it. That is a real advantage over mapping a globe. + */ +import { RADIUS_M, LENGTH_M, CIRCUMFERENCE_M } from './constants.js' +import { + slatAngularWidth, slatCount, slatSpacing, +} from './slats.js' + +export interface SurfacePoint { theta: number; z: number } +export type Vec3 = readonly [number, number, number] + +const TAU = 2 * Math.PI + +/** Wrap an angle into [0, 2π). */ +export function normalizeTheta(theta: number): number { + return ((theta % TAU) + TAU) % TAU +} + +/** Shortest signed angular difference from `a` to `b`, in (-π, π]. */ +export function deltaTheta(a: number, b: number): number { + let d = normalizeTheta(b) - normalizeTheta(a) + if (d > Math.PI) d -= TAU + if (d <= -Math.PI) d += TAU + return d +} + +/** 3D position of a surface point, optionally lifted `height` metres off the floor. */ +export function toCartesian(p: SurfacePoint, height = 0): Vec3 { + const r = RADIUS_M - height + return [r * Math.cos(p.theta), r * Math.sin(p.theta), p.z] +} + +/** + * Distance walked across the floor between two points. Exact, because unrolling a + * cylinder preserves lengths — the geodesic is a straight line on the map. + */ +export function surfaceDistance(a: SurfacePoint, b: SurfacePoint): number { + return Math.hypot(deltaTheta(a.theta, b.theta) * RADIUS_M, b.z - a.z) +} + +/** Straight-line distance through the interior — a chord, always shorter. */ +export function directDistance(a: SurfacePoint, b: SurfacePoint): number { + const [ax, ay, az] = toCartesian(a) + const [bx, by, bz] = toCartesian(b) + return Math.hypot(bx - ax, by - ay, bz - az) +} + +/** Height above the floor of the highest point on the chord between two points. */ +export function chordApexHeight(a: SurfacePoint, b: SurfacePoint): number { + return RADIUS_M * (1 - Math.cos(Math.abs(deltaTheta(a.theta, b.theta)) / 2)) +} + +// ------------------------------------------------------------------------- slats +// All of these follow the runtime slat count (src/canon/slats.ts), so the whole +// app answers coherently when the author changes it. + +/** Centre angle of slat `i`. Slat 0 sits between land strips 0 and 1. */ +export function slatCentre(i: number): number { + return normalizeTheta((i + 0.5) * slatSpacing()) +} + +/** Centre angle of land strip `i`. Strip 0 is centred on theta = 0 (design.md §1.7). */ +export function landStripCentre(i: number): number { + return normalizeTheta(i * slatSpacing()) +} + +/** True if `theta` falls on a transparent slat rather than on land. */ +export function isSlat(theta: number): boolean { + const half = slatAngularWidth() / 2 + for (let i = 0; i < slatCount(); i++) { + if (Math.abs(deltaTheta(slatCentre(i), theta)) <= half) return true + } + return false +} + +/** Index of the land strip containing `theta`, or null if it is on a slat. */ +export function landStripIndex(theta: number): number | null { + if (isSlat(theta)) return null + if (slatCount() === 0) return 0 + return Math.round(normalizeTheta(theta) / slatSpacing()) % slatCount() +} + +/** True if walking from `a` to `b` crosses at least one slat. */ +export function routeCrossesSlat(a: SurfacePoint, b: SurfacePoint, samples = 256): boolean { + if (slatCount() === 0) return false + const d = deltaTheta(a.theta, b.theta) + for (let i = 0; i <= samples; i++) { + if (isSlat(a.theta + (d * i) / samples)) return true + } + return false +} + +// --------------------------------------------------------------- map projection +/** Map coordinates in metres: x along the axis, y around the circumference. */ +export interface MapPoint { x: number; y: number } + +/** + * Where the map is cut open, as an angle. + * + * The map shows the surface once, not tiled, so it needs an edge — and the edge + * has to fall somewhere. Land strip 0 is centred on theta = 0, so cutting there + * would slice a whole strip in half across the top and bottom margins. Cutting at + * a slat centre instead leaves all eight land strips whole and splits only a band + * of stars, which carries no geography. This is the same reasoning that puts + * Earth's map edge at the antimeridian. + */ +export const MAP_CUT_WITHOUT_SLATS = Math.PI + +export const mapThetaOrigin = (): number => + slatCount() === 0 ? MAP_CUT_WITHOUT_SLATS : slatCentre(slatCount() - 1) + +export const toMap = (p: SurfacePoint): MapPoint => ({ + x: p.z, + y: normalizeTheta(p.theta - mapThetaOrigin()) * RADIUS_M, +}) +export const fromMap = (m: MapPoint): SurfacePoint => ({ + theta: normalizeTheta(m.y / RADIUS_M + mapThetaOrigin()), + z: Math.min(LENGTH_M, Math.max(0, m.x)), +}) + +export const MAP_WIDTH_M = LENGTH_M +export const MAP_HEIGHT_M = CIRCUMFERENCE_M diff --git a/src/canon/slats.ts b/src/canon/slats.ts new file mode 100644 index 0000000..eefa17d --- /dev/null +++ b/src/canon/slats.ts @@ -0,0 +1,67 @@ +/** + * Slat count, the one canon parameter adjustable at runtime. See design.md §1.4. + * + * D-002 fixes the *total* slat width from the land-area target, so the count only + * decides how that width is divided up: Tower's land stays exactly Wisconsin's land + * area at any count from 1 to 16. At 0 there are no slats at all — the land becomes + * the entire inner surface and nobody in Tower can see a star. That is a different + * world, and the app says so rather than silently rounding the coincidence away. + */ +import { + CIRCUMFERENCE_M, INNER_AREA_M2, LAND_AREA_M2, RADIUS_M, + SLAT_COUNT_DEFAULT, SLAT_COUNT_MAX, SLAT_COUNT_MIN, SLAT_TOTAL_WIDTH_M, +} from './constants.js' + +const STORAGE_KEY = 'tower.slatCount' +const TAU = 2 * Math.PI + +let count = SLAT_COUNT_DEFAULT +const listeners = new Set<() => void>() + +/** Restore the author's chosen count. Safe where localStorage is unavailable. */ +export function loadSlatCount(): void { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw !== null) count = clamp(Number(raw)) + } catch { + // Private window or blocked site data: keep the canon default. + } +} + +const clamp = (n: number): number => + Math.min(SLAT_COUNT_MAX, Math.max(SLAT_COUNT_MIN, Math.round(Number.isFinite(n) ? n : SLAT_COUNT_DEFAULT))) + +export const slatCount = (): number => count + +export function setSlatCount(n: number): void { + const next = clamp(n) + if (next === count) return + count = next + try { + localStorage.setItem(STORAGE_KEY, String(count)) + } catch { + // Not persisting is survivable; the session still works. + } + for (const fn of listeners) fn() +} + +export function onSlatCountChange(fn: () => void): () => void { + listeners.add(fn) + return () => listeners.delete(fn) +} + +/** Width of one slat. Zero when there are none. */ +export const slatWidth = (): number => (count === 0 ? 0 : SLAT_TOTAL_WIDTH_M / count) +export const slatAngularWidth = (): number => slatWidth() / RADIUS_M +/** Angle from one slat to the next. Zero when there are none. */ +export const slatSpacing = (): number => (count === 0 ? 0 : TAU / count) + +/** Width of one land strip. With no slats the strip is the whole circumference. */ +export const landStripWidth = (): number => + count === 0 ? CIRCUMFERENCE_M : (CIRCUMFERENCE_M - SLAT_TOTAL_WIDTH_M) / count + +/** Total habitable land. Constant at Wisconsin's land area for any count ≥ 1. */ +export const landArea = (): number => (count === 0 ? INNER_AREA_M2 : LAND_AREA_M2) + +/** How many distinct land strips exist. With no slats, one continuous surface. */ +export const landStripCount = (): number => (count === 0 ? 1 : count) diff --git a/test/canon.test.ts b/test/canon.test.ts new file mode 100644 index 0000000..78ec12a --- /dev/null +++ b/test/canon.test.ts @@ -0,0 +1,155 @@ +/** + * Canon is derived from four free parameters (decisions.md D-007), so these tests + * pin every derived figure to the number printed in design.md. If a constant is + * ever retuned, the docs and these expectations must move together. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import * as C from '../src/canon/constants.js' +import { pressureAtRadius, relativeDensityAtRadius } from '../src/canon/atmosphere.js' +import { + landArea, landStripCount, landStripWidth, setSlatCount, slatCount, slatWidth, +} from '../src/canon/slats.js' +import { + MAP_CUT_WITHOUT_SLATS, isSlat, landStripIndex, mapThetaOrigin, slatCentre, +} from '../src/canon/geometry.js' +import { readFileSync } from 'node:fs' + +const near = (actual: number, expected: number, tolerance: number) => + expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance) + +describe('§1.1 dimensions', () => { + it('circumference is 314.16 km', () => near(C.CIRCUMFERENCE_M, 314_159, 5)) + it('inner surface is Wisconsin total area, 169,646 km²', () => + near(C.INNER_AREA_M2 / 1e6, 169_646, 5)) + it('land is Wisconsin land area, 140,268 km²', () => + near(C.LAND_AREA_M2 / 1e6, 140_268, 5)) +}) + +describe('§1.2 spin', () => { + it('gives exactly 1 g at the floor', () => near(C.gravityAtHeight(0), C.G0, 1e-9)) + it('ω = 0.014005 rad/s', () => near(C.OMEGA_RAD_S, 0.014005, 1e-6)) + it('spin period is 448.6 s', () => near(C.SPIN_PERIOD_S, 448.6, 0.1)) + it('rim speed is 700.2 m/s', () => near(C.RIM_SPEED_M_S, 700.2, 0.1)) + it('gravity falls linearly to zero on the axis', () => { + near(C.gravityAtRadius(0), 0, 1e-12) + near(C.gravityAtHeight(1000) / C.G0, 0.98, 0.001) + }) +}) + +describe('§1.4 the hull is sealed by default (D-017)', () => { + afterEach(() => setSlatCount(C.SLAT_COUNT_DEFAULT)) + + it('defaults to no slats at all', () => { + expect(C.SLAT_COUNT_DEFAULT).toBe(0) + expect(slatCount()).toBe(0) + }) + it('makes the whole inner surface land — Wisconsin’s total area', () => + near(landArea() / 1e6, 169_646, 5)) +}) + +describe('§1.4 slats, when the author explores them', () => { + beforeEach(() => setSlatCount(8)) + afterEach(() => setSlatCount(C.SLAT_COUNT_DEFAULT)) + + it('is 8 slats, 6.80 km wide', () => { + expect(slatCount()).toBe(8) + near(slatWidth(), 6800, 15) + }) + it('land strips are 32.5 km wide', () => near(landStripWidth(), 32_470, 30)) + it('slat and land widths tile the circumference', () => + near(slatCount() * (slatWidth() + landStripWidth()), C.CIRCUMFERENCE_M, 1)) +}) + +describe('§1.4 slat count is adjustable from 0 to 16', () => { + afterEach(() => setSlatCount(C.SLAT_COUNT_DEFAULT)) + + it('keeps land at Wisconsin’s land area for every count from 1 up', () => { + for (let n = 1; n <= C.SLAT_COUNT_MAX; n++) { + setSlatCount(n) + near(landArea() / 1e6, 140_268, 5) + near(slatCount() * (slatWidth() + landStripWidth()), C.CIRCUMFERENCE_M, 1) + } + }) + + it('divides a fixed total width, so more slats means narrower ones', () => { + setSlatCount(4) + const wide = slatWidth() + setSlatCount(16) + // The total is what is fixed, so 16 narrow slats sum to the same as 4 wide ones. + near(slatWidth() * 16, wide * 4, 1) + near(slatWidth() * 4, wide, 1) + near(slatWidth(), 3400, 10) + near(landStripWidth(), 16_235, 20) + }) + + it('makes one unbroken surface at zero, and says so about the land area', () => { + setSlatCount(0) + expect(slatCount()).toBe(0) + expect(landStripCount()).toBe(1) + expect(slatWidth()).toBe(0) + near(landStripWidth(), C.CIRCUMFERENCE_M, 1) + // With no slats the D-002 coincidence no longer holds — land is everything. + near(landArea() / 1e6, 169_646, 5) + for (const theta of [0, 1, 3, 5, 6.28]) { + expect(isSlat(theta)).toBe(false) + expect(landStripIndex(theta)).toBe(0) + } + // With no slats there is no empty theta, so the cut goes opposite the capital. + expect(mapThetaOrigin()).toBe(Math.PI) + }) + + it('clamps out-of-range and non-finite input rather than corrupting canon', () => { + for (const [input, expected] of [[-5, 0], [99, 16], [3.4, 3], [NaN, 0]] as const) { + setSlatCount(C.SLAT_COUNT_DEFAULT) + setSlatCount(input) + expect(slatCount()).toBe(expected) + } + }) + + it('cuts the map at a slat centre at every count, so no strip is split', () => { + for (let n = 1; n <= C.SLAT_COUNT_MAX; n++) { + setSlatCount(n) + expect(isSlat(mapThetaOrigin())).toBe(true) + near(mapThetaOrigin(), slatCentre(n - 1), 1e-9) + } + }) +}) + +describe('§1.3 sun system', () => { + it('beam radius is 1592 m', () => near(C.BEAM_RADIUS_M, 1592, 1)) + it('beam carries 54.3 TW', () => near(C.BEAM_POWER_W / 1e12, 54.3, 0.2)) + it('full brightness is Earth clear-sky noon, 320 W/m² mean', () => + near(C.BRIGHTNESS_FULL_W_M2, 320, 2)) + it('collector is 244 km across', () => near(C.COLLECTOR_DIAMETER_M / 1000, 244, 1)) + it('stays inside the étendue limit — D-003 is no longer a physics break', () => { + expect(C.BEAM_CONCENTRATION).toBeLessThan(C.ETENDUE_LIMIT) + near(C.BEAM_CONCENTRATION / C.ETENDUE_LIMIT, 0.11, 0.005) + }) +}) + +describe('§1.3.1 diffuser swarm', () => { + it('is 78 billion mirrorlets', () => near(C.SWARM_COUNT / 1e9, 78, 1)) + it('masses 158 tonnes in total', () => near(C.SWARM_MASS_KG / 1000, 158, 2)) + it('has optical depth 1.41, leaving 24% of light for the far cap', () => { + near(C.SWARM_OPTICAL_DEPTH, 1.41, 0.02) + near(Math.exp(-C.SWARM_OPTICAL_DEPTH), 0.24, 0.01) + }) + it('aims to a 2.3 m spot on the floor — human-scale privacy', () => + near(C.AIM_SPOT_AT_FLOOR_M, 2.3, 0.05)) +}) + +describe('§1.5 atmosphere', () => { + it('the axis holds 5.2 kPa — thin, but real air', () => + near(pressureAtRadius(0) / 1000, 5.2, 0.1)) + it('is 5.2% of floor density on the axis', () => + near(relativeDensityAtRadius(0), 0.052, 0.001)) + it('is full density at the floor', () => + near(relativeDensityAtRadius(C.RADIUS_M), 1, 1e-12)) +}) + +describe('the map cut agrees with the terrain the tool renders', () => { + it('matches content/geography.json, or the raster lands half a world out', () => { + const geo = JSON.parse(readFileSync('content/geography.json', 'utf8')) + near(geo.render.mapThetaOrigin, MAP_CUT_WITHOUT_SLATS, 1e-5) + }) +}) diff --git a/test/geometry.test.ts b/test/geometry.test.ts new file mode 100644 index 0000000..1bcd303 --- /dev/null +++ b/test/geometry.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { CIRCUMFERENCE_M, LENGTH_M, RADIUS_M } from '../src/canon/constants.js' +import { setSlatCount, slatCount } from '../src/canon/slats.js' +import { SLAT_COUNT_DEFAULT } from '../src/canon/constants.js' +import * as G from '../src/canon/geometry.js' + +describe('theta wrapping', () => { + it('normalizes into [0, 2π)', () => { + expect(G.normalizeTheta(-0.5)).toBeCloseTo(2 * Math.PI - 0.5, 10) + expect(G.normalizeTheta(7 * Math.PI)).toBeCloseTo(Math.PI, 10) + }) + it('takes the short way across the seam', () => { + expect(G.deltaTheta(0.1, 2 * Math.PI - 0.1)).toBeCloseTo(-0.2, 10) + expect(G.deltaTheta(2 * Math.PI - 0.1, 0.1)).toBeCloseTo(0.2, 10) + }) +}) + +describe('distances', () => { + it('measures surface distance across the seam, not the long way round', () => { + const a = { theta: 0.05, z: 0 } + const b = { theta: 2 * Math.PI - 0.05, z: 0 } + expect(G.surfaceDistance(a, b)).toBeCloseTo(0.1 * RADIUS_M, 6) + }) + it('halfway round is half the circumference on the surface', () => { + const d = G.surfaceDistance({ theta: 0, z: 0 }, { theta: Math.PI, z: 0 }) + expect(d).toBeCloseTo(CIRCUMFERENCE_M / 2, 6) + }) + it('the chord straight overhead is the diameter, and shorter than walking', () => { + const a = { theta: 0, z: 0 } + const b = { theta: Math.PI, z: 0 } + expect(G.directDistance(a, b)).toBeCloseTo(2 * RADIUS_M, 6) + expect(G.directDistance(a, b)).toBeLessThan(G.surfaceDistance(a, b)) + }) + it('rises the full radius to reach the point directly overhead', () => { + expect(G.chordApexHeight({ theta: 0, z: 0 }, { theta: Math.PI, z: 0 })).toBeCloseTo(RADIUS_M, 6) + expect(G.chordApexHeight({ theta: 0, z: 0 }, { theta: 0, z: 1000 })).toBeCloseTo(0, 6) + }) +}) + +describe('slats and land strips', () => { + // Canon is a sealed hull (D-017); these exercise the exploration mode. + beforeEach(() => setSlatCount(8)) + afterEach(() => setSlatCount(SLAT_COUNT_DEFAULT)) + + it('puts land strip centres on land and slat centres on slats', () => { + for (let i = 0; i < slatCount(); i++) { + expect(G.isSlat(G.landStripCentre(i))).toBe(false) + expect(G.isSlat(G.slatCentre(i))).toBe(true) + expect(G.landStripIndex(G.landStripCentre(i))).toBe(i) + expect(G.landStripIndex(G.slatCentre(i))).toBeNull() + } + }) + it('reports a slat crossing when moving between adjacent strips', () => { + expect(G.routeCrossesSlat( + { theta: G.landStripCentre(0), z: 0 }, + { theta: G.landStripCentre(1), z: 0 }, + )).toBe(true) + }) + it('reports no crossing when moving along one strip', () => { + expect(G.routeCrossesSlat( + { theta: G.landStripCentre(0), z: 0 }, + { theta: G.landStripCentre(0), z: 200_000 }, + )).toBe(false) + }) +}) + +describe('map projection', () => { + it('round-trips surface points exactly — a cylinder unrolls without distortion', () => { + for (const p of [ + { theta: 0, z: 0 }, + { theta: 1.234, z: 271_000 }, + { theta: 2 * Math.PI - 1e-9, z: LENGTH_M }, + ]) { + const back = G.fromMap(G.toMap(p)) + expect(back.theta).toBeCloseTo(G.normalizeTheta(p.theta), 9) + expect(back.z).toBeCloseTo(p.z, 6) + } + }) + it('spans the world', () => { + expect(G.MAP_WIDTH_M).toBe(LENGTH_M) + expect(G.MAP_HEIGHT_M).toBeCloseTo(CIRCUMFERENCE_M, 9) + }) +})