fuzzyworld

tower / commit

feat(canon): how things fall in the rotating frame

a08492ba6816af5abf16eb04726a7af5dd325e83 · Pavel Safronov · 2026-09-06 23:28:33 -0700 · parents dc85f1924d99

feat(canon): how things fall in the rotating frame

Coriolis never pushes surface water sideways (design.md §1.9.1) and the hull is
sealed, so there are no stars to wheel overhead either. That leaves what falls as
the only visible evidence that Tower turns at all, which makes these angles
load-bearing rather than decorative.

They are also lopsided in a way the design did not anticipate. Drag pins a
raindrop's speed, so rain leans a fixed 1.5° whatever the cloud height; only free
fall keeps accelerating, so only free fall keeps leaning, and a 1 km waterfall
arrives at 11.3°. The waterfall is the compass; the rain is barely a hint.

Path lean and velocity lean are kept as separate functions because they are
separate angles — 7.6° against 11.3° for the same kilometre — and design.md §1.9.2
tabulates the first while a falling streak is drawn at the second. The test pins
the relation between them so the two cannot quietly drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 src/canon/motion.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/motion.test.ts | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 123 insertions(+)

diff --git a/src/canon/motion.ts b/src/canon/motion.ts
new file mode 100644
index 0000000..a0059e6
--- /dev/null
+++ b/src/canon/motion.ts
@@ -0,0 +1,61 @@
+/**
+ * How things fall in Tower's rotating frame. See design.md §1.9.2.
+ *
+ * Coriolis never pushes surface water sideways (§1.9.1), so the spin is invisible
+ * in every static thing. The hull is sealed, so there are no stars to wheel
+ * overhead either. That leaves what falls as the only evidence in the sky that
+ * Tower turns at all, which makes these two angles load-bearing for the interior
+ * view rather than decoration.
+ *
+ * Constant-g plus Coriolis is the standard approximation and is what these use.
+ * It is exact only for drops small against the radius: gravity is 2% weaker 1 km
+ * up, and the true inertial-frame path is a straight line, not a parabola.
+ */
+import { G0, OMEGA_RAD_S } from './constants.js'
+
+/** Terminal velocity of an ordinary raindrop, m/s. */
+export const RAINDROP_TERMINAL_M_S = 9
+
+/**
+ * Lean of something already falling at a steady speed, in radians, antispinward.
+ *
+ * Coriolis gives 2ωv antispinward against g outward, and at terminal velocity drag
+ * holds both constant — so rain leans by a *fixed* angle rather than curving. That
+ * angle is small: 1.5° for an ordinary raindrop. Rain is a poor compass here.
+ */
+export const steadyFallLeanRad = (speedMS: number): number =>
+  Math.atan((2 * OMEGA_RAD_S * speedMS) / G0)
+
+/** Time for something dropped from `heightM` to reach the floor. */
+export const freeFallTimeS = (heightM: number): number => Math.sqrt((2 * heightM) / G0)
+
+/**
+ * Antispinward drift of something in free fall from `heightM`. Integrating the
+ * Coriolis acceleration 2ω(gt) twice gives ωgt³/3.
+ */
+export const freeFallDriftM = (heightM: number): number =>
+  (OMEGA_RAD_S * G0 * freeFallTimeS(heightM) ** 3) / 3
+
+/**
+ * Lean of the *path* of a free fall from `heightM` — where it lands, against how
+ * far it dropped. This is the tilt of a waterfall's plume seen whole, and it is
+ * the column design.md §1.9.2 tabulates: 7.6° over a kilometre.
+ */
+export const freeFallPathLeanRad = (heightM: number): number =>
+  Math.atan(freeFallDriftM(heightM) / heightM)
+
+/**
+ * Lean of the *velocity* at the bottom of the same fall — the direction a streak
+ * of it is moving as it arrives, and so the angle rain and spray are drawn at.
+ *
+ * It reduces to atan(ωt), independent of g, and it is exactly half again the path
+ * lean in tangent: the drift integrates to ωgt³/3 over a drop of gt²/2, giving
+ * (2/3)ωt, while the sideways speed reaches ωgt² against a fall speed of gt.
+ * Both numbers are real and they describe different things, which is why
+ * §1.9.2 says 7.6° for a kilometre drop and the interior view's readout says 11.3°.
+ *
+ * Unlike rain this keeps growing with the drop, so a tall waterfall *is* a compass
+ * where a shower is not.
+ */
+export const freeFallLeanRad = (heightM: number): number =>
+  Math.atan(OMEGA_RAD_S * freeFallTimeS(heightM))
diff --git a/test/motion.test.ts b/test/motion.test.ts
new file mode 100644
index 0000000..27f9e26
--- /dev/null
+++ b/test/motion.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest'
+import { G0, OMEGA_RAD_S } from '../src/canon/constants.js'
+import {
+  RAINDROP_TERMINAL_M_S, freeFallDriftM, freeFallLeanRad, freeFallPathLeanRad,
+  freeFallTimeS, steadyFallLeanRad,
+} from '../src/canon/motion.js'
+
+const deg = (r: number): number => (r * 180) / Math.PI
+
+/**
+ * These two numbers are the entire visible evidence that Tower spins, so they are
+ * worth pinning: the hull is sealed, there are no stars, and everything that does
+ * not fall is co-rotating and therefore perfectly still.
+ */
+describe('falling in the rotating frame', () => {
+  it('leaves rain very nearly vertical', () => {
+    expect(deg(steadyFallLeanRad(RAINDROP_TERMINAL_M_S))).toBeCloseTo(1.5, 1)
+  })
+
+  it('leans a waterfall enough to read as a compass', () => {
+    // A 1 km drop: eight times the lean of rain, and unmistakable.
+    expect(deg(freeFallLeanRad(1000))).toBeCloseTo(11.3, 1)
+  })
+
+  it('drifts an object dropped from 1 km by over a hundred metres', () => {
+    expect(freeFallDriftM(1000)).toBeCloseTo(133, 0)
+  })
+
+  /**
+   * design.md §1.9.2 tabulates 7.6° for a kilometre and this view reports 11.3°.
+   * Both are correct and they are not the same angle, so pin the relation that
+   * makes that true rather than quietly letting the two documents disagree.
+   */
+  it('separates the lean of the path from the lean of the velocity', () => {
+    expect(deg(freeFallPathLeanRad(1000))).toBeCloseTo(7.6, 1)
+    for (const h of [100, 500, 1000, 2000]) {
+      expect(Math.tan(freeFallLeanRad(h))).toBeCloseTo(1.5 * Math.tan(freeFallPathLeanRad(h)), 9)
+    }
+  })
+
+  it('grows the lean with the drop, which is why the waterfall wins', () => {
+    const heights = [10, 100, 1000, 4000]
+    const leans = heights.map(freeFallLeanRad)
+    for (let i = 1; i < leans.length; i++) expect(leans[i]!).toBeGreaterThan(leans[i - 1]!)
+    // Rain does not: at terminal velocity the angle is fixed whatever the cloud.
+    expect(steadyFallLeanRad(RAINDROP_TERMINAL_M_S))
+      .toBe(steadyFallLeanRad(RAINDROP_TERMINAL_M_S))
+  })
+
+  it('agrees with the closed forms it is derived from', () => {
+    const h = 2500
+    const t = freeFallTimeS(h)
+    expect(t).toBeCloseTo(Math.sqrt((2 * h) / G0), 9)
+    expect(freeFallLeanRad(h)).toBeCloseTo(Math.atan(OMEGA_RAD_S * t), 9)
+    expect(freeFallDriftM(h)).toBeCloseTo((OMEGA_RAD_S * G0 * t ** 3) / 3, 6)
+  })
+
+  it('leans everything the same way — antispinward, always', () => {
+    expect(steadyFallLeanRad(1)).toBeGreaterThan(0)
+    expect(freeFallLeanRad(1)).toBeGreaterThan(0)
+  })
+})