fuzzyworld

tower / commit

feat(canon): derive Tower's physics from four free parameters

ec7e78a8f91d57d464f8d31a40f670da8c670e06 · Pavel Safronov · 2026-09-06 20:14:03 -0700 · parents 1de9dc7cf76b

feat(canon): derive Tower's physics from four free parameters

Only radius, length, slat count and the land-area target are chosen.
Everything else — spin period, rim speed, slat width, beam power, swarm
count, air density — is computed, so changing the radius changes the whole
world coherently instead of leaving stale numbers behind.

The tests pin every derived figure to the number printed in design.md, which
makes the docs and the code fail together rather than drift apart. One of
them asserts that the map's cut angle matches content/geography.json: the
app cut at theta = 0 while the terrain rendered against pi, which would have
put the raster half a world out of register with its own place markers.

Slat count is the one canon value adjustable at runtime, 0 to 16. Canon is
0 — a sealed hull — but the control stays as an exploration tool, and the
total slat width is fixed by the land-area target so land remains exactly
Wisconsin's 140,268 km2 at every count from 1 up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 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)
+  })
+})