fuzzyworld

tower / cascadia / files / src / canon / geometry.ts

Branch cascadia · default branch · all branches

geometry.ts

4.8 KB · history · blame

1/**
2 * Surface coordinates and the cylinder's geometry. See design.md §1.7.
3 *
4 * The canonical surface coordinate is (theta, z): theta radians around the
5 * cylinder, z metres from the sun cap. A cylinder is a developable surface, so the
6 * unrolled map is exact — no projection distortion, and surface geodesics are
7 * straight lines on it. That is a real advantage over mapping a globe.
8 */
9import { RADIUS_M, LENGTH_M, CIRCUMFERENCE_M } from './constants.js'
10import {
11 slatAngularWidth, slatCount, slatSpacing,
12} from './slats.js'
13
14export interface SurfacePoint { theta: number; z: number }
15export type Vec3 = readonly [number, number, number]
16
17const TAU = 2 * Math.PI
18
19/** Wrap an angle into [0, 2π). */
20export function normalizeTheta(theta: number): number {
21 return ((theta % TAU) + TAU) % TAU
22}
23
24/** Shortest signed angular difference from `a` to `b`, in (-π, π]. */
25export function deltaTheta(a: number, b: number): number {
26 let d = normalizeTheta(b) - normalizeTheta(a)
27 if (d > Math.PI) d -= TAU
28 if (d <= -Math.PI) d += TAU
29 return d
30}
31
32/** 3D position of a surface point, optionally lifted `height` metres off the floor. */
33export function toCartesian(p: SurfacePoint, height = 0): Vec3 {
34 const r = RADIUS_M - height
35 return [r * Math.cos(p.theta), r * Math.sin(p.theta), p.z]
36}
37
38/**
39 * Distance walked across the floor between two points. Exact, because unrolling a
40 * cylinder preserves lengths — the geodesic is a straight line on the map.
41 */
42export function surfaceDistance(a: SurfacePoint, b: SurfacePoint): number {
43 return Math.hypot(deltaTheta(a.theta, b.theta) * RADIUS_M, b.z - a.z)
44}
45
46/** Straight-line distance through the interior — a chord, always shorter. */
47export function directDistance(a: SurfacePoint, b: SurfacePoint): number {
48 const [ax, ay, az] = toCartesian(a)
49 const [bx, by, bz] = toCartesian(b)
50 return Math.hypot(bx - ax, by - ay, bz - az)
51}
52
53/** Height above the floor of the highest point on the chord between two points. */
54export function chordApexHeight(a: SurfacePoint, b: SurfacePoint): number {
55 return RADIUS_M * (1 - Math.cos(Math.abs(deltaTheta(a.theta, b.theta)) / 2))
56}
57
58// ------------------------------------------------------------------------- slats
59// All of these follow the runtime slat count (src/canon/slats.ts), so the whole
60// app answers coherently when the author changes it.
61
62/** Centre angle of slat `i`. Slat 0 sits between land strips 0 and 1. */
63export function slatCentre(i: number): number {
64 return normalizeTheta((i + 0.5) * slatSpacing())
65}
66
67/** Centre angle of land strip `i`. Strip 0 is centred on theta = 0 (design.md §1.7). */
68export function landStripCentre(i: number): number {
69 return normalizeTheta(i * slatSpacing())
70}
71
72/** True if `theta` falls on a transparent slat rather than on land. */
73export function isSlat(theta: number): boolean {
74 const half = slatAngularWidth() / 2
75 for (let i = 0; i < slatCount(); i++) {
76 if (Math.abs(deltaTheta(slatCentre(i), theta)) <= half) return true
77 }
78 return false
79}
80
81/** Index of the land strip containing `theta`, or null if it is on a slat. */
82export function landStripIndex(theta: number): number | null {
83 if (isSlat(theta)) return null
84 if (slatCount() === 0) return 0
85 return Math.round(normalizeTheta(theta) / slatSpacing()) % slatCount()
86}
87
88/** True if walking from `a` to `b` crosses at least one slat. */
89export function routeCrossesSlat(a: SurfacePoint, b: SurfacePoint, samples = 256): boolean {
90 if (slatCount() === 0) return false
91 const d = deltaTheta(a.theta, b.theta)
92 for (let i = 0; i <= samples; i++) {
93 if (isSlat(a.theta + (d * i) / samples)) return true
94 }
95 return false
96}
97
98// --------------------------------------------------------------- map projection
99/** Map coordinates in metres: x along the axis, y around the circumference. */
100export interface MapPoint { x: number; y: number }
101
102/**
103 * Where the map is cut open, as an angle.
104 *
105 * The map shows the surface once, not tiled, so it needs an edge — and the edge
106 * has to fall somewhere. Land strip 0 is centred on theta = 0, so cutting there
107 * would slice a whole strip in half across the top and bottom margins. Cutting at
108 * a slat centre instead leaves all eight land strips whole and splits only a band
109 * of stars, which carries no geography. This is the same reasoning that puts
110 * Earth's map edge at the antimeridian.
111 */
112export const MAP_CUT_WITHOUT_SLATS = Math.PI
113
114export const mapThetaOrigin = (): number =>
115 slatCount() === 0 ? MAP_CUT_WITHOUT_SLATS : slatCentre(slatCount() - 1)
116
117export const toMap = (p: SurfacePoint): MapPoint => ({
118 x: p.z,
119 y: normalizeTheta(p.theta - mapThetaOrigin()) * RADIUS_M,
120})
121export const fromMap = (m: MapPoint): SurfacePoint => ({
122 theta: normalizeTheta(m.y / RADIUS_M + mapThetaOrigin()),
123 z: Math.min(LENGTH_M, Math.max(0, m.x)),
124})
125
126export const MAP_WIDTH_M = LENGTH_M
127export const MAP_HEIGHT_M = CIRCUMFERENCE_M