tower / files / src / tour / geo.ts
geo.ts
| 1 | /** |
| 2 | * Geometry for the scenic tour, inside Tower. See world-generation.md §19. |
| 3 | * |
| 4 | * Two ways of saying where something is. On the floor, (z, a, altM): metres along the |
| 5 | * axis, metres around it, and height above the datum — what the world's features are |
| 6 | * written in. And in the tower's own frame, a vector in metres with the axis along z — |
| 7 | * what aiming, distances and the camera need, since the floor curves up all round and a |
| 8 | * straight line through the air is not a line on the map. |
| 9 | */ |
| 10 | import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js' |
| 11 | import { type Line, nearestA } from '../terrain/shapes.js' |
| 12 | |
| 13 | export interface At { z: number; a: number; altM: number } |
| 14 | |
| 15 | /** A vector in the tower's frame, metres: x and y across the axis, z along it. */ |
| 16 | export interface V3 { x: number; y: number; z: number } |
| 17 | |
| 18 | export const v3 = (x: number, y: number, z: number): V3 => ({ x, y, z }) |
| 19 | export const add = (p: V3, q: V3): V3 => v3(p.x + q.x, p.y + q.y, p.z + q.z) |
| 20 | export const sub = (p: V3, q: V3): V3 => v3(p.x - q.x, p.y - q.y, p.z - q.z) |
| 21 | export const scale = (p: V3, k: number): V3 => v3(p.x * k, p.y * k, p.z * k) |
| 22 | export const dot = (p: V3, q: V3): number => p.x * q.x + p.y * q.y + p.z * q.z |
| 23 | export const len = (p: V3): number => Math.hypot(p.x, p.y, p.z) |
| 24 | export const cross = (p: V3, q: V3): V3 => |
| 25 | v3(p.y * q.z - p.z * q.y, p.z * q.x - p.x * q.z, p.x * q.y - p.y * q.x) |
| 26 | export const norm = (p: V3): V3 => { const l = len(p) || 1; return scale(p, 1 / l) } |
| 27 | /** p + (q − p)·t. */ |
| 28 | export const mix = (p: V3, q: V3, t: number): V3 => add(p, scale(sub(q, p), t)) |
| 29 | /** Distance from the axis. */ |
| 30 | export const radial = (p: V3): number => Math.hypot(p.x, p.y) |
| 31 | |
| 32 | export const wrapA = (a: number): number => ((a % CIRCUMFERENCE_M) + CIRCUMFERENCE_M) % CIRCUMFERENCE_M |
| 33 | |
| 34 | /** A point over the floor, in the tower's frame. */ |
| 35 | export function toV(at: At): V3 { |
| 36 | const th = at.a / RADIUS_M, r = RADIUS_M - at.altM |
| 37 | return v3(r * Math.cos(th), r * Math.sin(th), at.z) |
| 38 | } |
| 39 | |
| 40 | /** And back. `a` comes out in [0, circumference). */ |
| 41 | export function fromV(p: V3): At { |
| 42 | return { z: p.z, a: wrapA(Math.atan2(p.y, p.x) * RADIUS_M), altM: RADIUS_M - radial(p) } |
| 43 | } |
| 44 | |
| 45 | /** Up (toward the axis), spinward and deepward, at a point. */ |
| 46 | export function basisAt(p: V3): { up: V3; spin: V3; deep: V3 } { |
| 47 | const th = Math.atan2(p.y, p.x) |
| 48 | return { up: v3(-Math.cos(th), -Math.sin(th), 0), spin: v3(-Math.sin(th), Math.cos(th), 0), deep: v3(0, 0, 1) } |
| 49 | } |
| 50 | |
| 51 | /** A direction as the interior view's heading and pitch, at the point it is seen from. */ |
| 52 | export function yawPitch(from: V3, dir: V3): { yaw: number; pitch: number } { |
| 53 | const { up, spin, deep } = basisAt(from) |
| 54 | const d = norm(dir) |
| 55 | return { yaw: Math.atan2(dot(d, spin), dot(d, deep)), pitch: Math.asin(Math.max(-1, Math.min(1, dot(d, up)))) } |
| 56 | } |
| 57 | |
| 58 | /** The direction a heading and pitch point, from a point. */ |
| 59 | export function dirOf(from: V3, yaw: number, pitch: number): V3 { |
| 60 | const { up, spin, deep } = basisAt(from) |
| 61 | return add(add(scale(deep, Math.cos(pitch) * Math.cos(yaw)), scale(spin, Math.cos(pitch) * Math.sin(yaw))), |
| 62 | scale(up, Math.sin(pitch))) |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * The roll that turns a camera looking along `dir` with the floor's up as its up into one |
| 67 | * whose up is `want`: the angle for three's rotateZ, about the camera's own view axis. |
| 68 | */ |
| 69 | export function rollTo(from: V3, dir: V3, want: V3): number { |
| 70 | const { up } = basisAt(from) |
| 71 | const f = norm(dir) |
| 72 | const right = norm(cross(f, up)) |
| 73 | const camUp = cross(right, f) |
| 74 | return Math.atan2(-dot(want, right), dot(want, camUp)) |
| 75 | } |
| 76 | |
| 77 | /** |
| 78 | * Heading and pitch from one point to another, on the curved floor. The floor curves up |
| 79 | * around the axis, so a summit 6 km spinward stands about 360 m higher in the eye's frame |
| 80 | * than its elevation says, and a pitch taken on a flat floor would miss it. |
| 81 | */ |
| 82 | export function lookAt(from: At, to: At): { yaw: number; pitch: number } { |
| 83 | const p = toV(from) |
| 84 | const q = toV({ ...to, a: nearestA(to.a, from.a) }) |
| 85 | return yawPitch(p, sub(q, p)) |
| 86 | } |
| 87 | |
| 88 | /** A point `s` metres along a line, clamped to its ends. */ |
| 89 | export function along(line: Line, s: number): { z: number; a: number } { |
| 90 | const n = line.s.length |
| 91 | if (s <= 0) return { z: line.pts[0]!, a: line.pts[1]! } |
| 92 | if (s >= line.s[n - 1]!) return { z: line.pts[2 * n - 2]!, a: line.pts[2 * n - 1]! } |
| 93 | let lo = 0, hi = n - 1 |
| 94 | while (hi - lo > 1) { |
| 95 | const mid = (lo + hi) >> 1 |
| 96 | if (line.s[mid]! <= s) lo = mid |
| 97 | else hi = mid |
| 98 | } |
| 99 | const t = (s - line.s[lo]!) / (line.s[hi]! - line.s[lo]! || 1) |
| 100 | return { |
| 101 | z: line.pts[2 * lo]! + (line.pts[2 * hi]! - line.pts[2 * lo]!) * t, |
| 102 | a: line.pts[2 * lo + 1]! + (line.pts[2 * hi + 1]! - line.pts[2 * lo + 1]!) * t, |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | /** A straight line from (z, a), heading `dirRad` (deepward toward spinward), `length` metres. */ |
| 107 | export function straight(z: number, a: number, dirRad: number, length: number): Line { |
| 108 | const pts: number[] = [], s: number[] = [] |
| 109 | for (let d = 0; d <= length; d += 50) { |
| 110 | pts.push(z + Math.cos(dirRad) * d, a + Math.sin(dirRad) * d) |
| 111 | s.push(d) |
| 112 | } |
| 113 | return { pts, s, box: { z0: 0, z1: 0, a0: 0, a1: 0 } } |
| 114 | } |
| 115 | |
| 116 | /** How far along a line its nearest point to (z, a) is. */ |
| 117 | export function sAlong(line: Line, z: number, a: number): number { |
| 118 | let best = 0, bestD = Infinity |
| 119 | for (let i = 0; i < line.s.length; i++) { |
| 120 | const d = Math.hypot(line.pts[2 * i]! - z, nearestA(line.pts[2 * i + 1]!, a) - a) |
| 121 | if (d < bestD) { bestD = d; best = i } |
| 122 | } |
| 123 | return line.s[best]! |
| 124 | } |
| 125 | |
| 126 | /** Smoothstep, for easing one aim into the next. */ |
| 127 | export const ease = (t: number): number => { const u = Math.max(0, Math.min(1, t)); return u * u * (3 - 2 * u) } |