1
0

The city points at its own buildings, and the sky stops depending on an API

**Clouds were invisible to everyone who had not wired up NWS.** The layer
took `currentWeather()?.cloudCover ?? 0`, and `currentWeather()` is null on
any deployment without a weather source — which is the default, and the
exact configuration this repo is held to: a stranger clones it, runs one
command, and gets a city with no account and no key. Their sky was
permanently, silently empty. `atmosphere.ts` already models a sky when
nobody has observed one; it now models cover too, an observed reading
still wins outright, and the clouds are there on a bare clone.

**Both offices are pins on the city, and clicking one walks you in.** Each
pack has carried a real `site` since the sun needed one, and that
coordinate was known to the lighting and to nothing else — a visitor
looking at the board had no way to tell that two of those buildings are
ones they can go inside. The coordinates move to a tiny eagerly-imported
`offices/sites.ts` that the packs import *from*, because a pack is a 25 kB
lazy chunk and the board wants its pins long before anybody opens a door.
A test asserts the pack and the table hold the **same object**, not merely
equal values: a drifted coordinate would put the marker on one building
and the sun on another and both would look entirely plausible.

**Aircraft bank into their turns.** The roll channel existed and was never
written, so every turn was flat. Bank comes from the coordinated-turn
relation against the measured turn rate, damped by a first-order lag so it
settles rather than oscillates, and clamped at 30° like a real limiter.
Six regression tests, because roll is the one channel that feeds itself —
position and heading are recomputed from the last two observations and
wash out a bad value, while a NaN in the roll would persist for the life
of the track.

That fed straight into a real defect: `AdsbFlights` substituted
`heading: 0` for records with no `track` field, which is harmless for a
symmetrical dart and is a **sustained full-scale artefact** once aircraft
bank — a target whose real heading is 200° reported as 0° reads as a 160°
turn and pins the roll at its limiter for as long as it is in the feed.
Those records are dropped now. An aeroplane the feed will not give a
heading for is one this layer cannot draw honestly.

**The office empties out overnight.** A full complement of seated people
at one in the morning, under house lights that came on because the sun is
down, was the least believable thing left in the room once the clock
became real. A live roster always wins — an API that says the building is
empty is telling the truth about the building.

**Robots go somewhere.** They pick real addresses — a seat, a room — and
turn to face the seat when they arrive, rather than stopping at a random
angle. Godmode gets an office section: house lights forced on or off or
following the sun, robots and ceilings toggled, with a readout.

**The bundle is split.** Entry chunk 758 kB to 208 kB, with three.js and
satellite.js in a vendor chunk that survives an app deploy instead of
being re-downloaded on every one. Rollup's 500 kB warning still fires and
should — it now points at three.js, where it is true, instead of at our
code, where it was pointing at three.js all along.

Reviewers caught two false geography claims in the new prose ("both
shipped buildings stand in San Francisco" — one is across the estuary at
Alameda Point) and several miscounted figures. Fixed. In a codebase where
the comments are the design record, those are defects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 04:18:00 -07:00
parent 51979feea0
commit 2d87d9f354
14 changed files with 2304 additions and 72 deletions
+263 -1
View File
@@ -27,6 +27,14 @@
* that goes flat grey the moment the wifi drops would fail that in the most
* visible way possible.
*
* There is a second output alongside the rig, `cloudCover`, and it is here for
* the same reason. It answers "how much sky has cloud in it" rather than "what
* does that cloud do to the light" — the number a cloud layer draws from, not a
* light — and it falls back to the same local climatology when nothing was
* observed. See `modelledCloudCover`: a caller that reads
* `weather?.cloudCover ?? 0` instead gets an empty sky on every deployment
* without a weather API, which is the default one.
*
* Wiring one to a city, in full:
*
* ```ts
@@ -465,6 +473,15 @@ export interface AtmosphereOptions {
export interface Atmosphere {
/** The rig this observation implies. Pure; the caller applies the result. */
apply(env: Environment): LightingState;
/**
* How much of the sky has cloud in it, 0..1 — observed if anyone observed it,
* modelled from this place and this instant if nobody did. Pure, like `apply`.
*
* The cause, not the consequence: what a cloud layer needs in order to draw
* the right amount of cloud, as distinct from everything in `LightingState`,
* which is what that cloud does to the light once it is there.
*/
cloudCover(env: Environment): number;
}
// ---- Constants ------------------------------------------------------------
@@ -868,7 +885,40 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
};
}
return { apply };
/**
* The sky's own cover, 0..1, for whatever wants to draw it.
*
* **A method on `Atmosphere` rather than a field of `LightingState` or a
* second return from `apply`.** The rig is a set of consequences — three
* lights, a gradient and a fog — and CONTRACT.md §4's one-way rule survives
* only while causes travel in an `Environment` and consequences travel in a
* `LightingState`. A cover parked on the rig is an invitation for the next
* module along to read a cause back out of a light, which is the shape this
* file exists to prevent. It is not a field of `Environment` either: an
* `Environment` is what was *observed*, `observe()` is handed nothing but a
* place and an instant, and a modelled number sitting in the observation is
* exactly the confusion that `WeatherObservation`'s `null`-means-unreported
* rule is careful about.
*
* Which leaves a method, and the closure is the reason it is a good one: the
* answer needs `lng`, because the marine layer's clock runs on apparent solar
* time, and it needs this city's `marineLayer`, because the fog is a fact
* about one coast. Both are already held here. A free function would have to
* be handed both at every call site, and the call site that matters already
* has an `Atmosphere` in scope.
*/
function cloudCover(env: Environment): number {
// **An observation wins outright, and nothing below is allowed to argue
// with it.** `WeatherObservation.cloudCover` is a measured fraction — never
// `null`, unlike the fields that can go unreported — so the presence of an
// observation at all is the whole test. A *reported* clear sky ends the
// argument here exactly as it does for obscuration in `apply`: the model
// below is what to do when nobody was asked, not a second opinion.
if (env.weather) return clamp(env.weather.cloudCover, 0, 1);
return modelledCloudCover(marineOptions, env, lng);
}
return { apply, cloudCover };
}
// ---- The sun's direction, and the shadow camera ---------------------------
@@ -1377,6 +1427,183 @@ function applyObscuration(
return fogColor;
}
// ---- Modelled cloud cover -------------------------------------------------
/**
* The most sky the generic term is ever allowed to cover.
*
* Scattered to broken, never overcast, and the cap is a statement about what
* this module is entitled to claim. It knows one climate in detail — the coast
* `MarineLayerOptions` describes — and for every other city it has a longitude
* and a sun angle. That is enough to say "there is usually some cloud about,
* more of it in the afternoon"; it is not enough to close a stranger's sky over
* a city it has never been told anything about. An overcast is a real event with
* a real cause, and if a deployment wants one rendered it can report one, at
* which point `cloudCover` hands the report straight through.
*/
const SYNOPTIC_MAX_COVER = 0.55;
/**
* The slow term: `[period in days, amplitude, phase in turns]`.
*
* Weather systems arrive, cover the sky for a day or two and leave, and that is
* the variation a viewer notices across a week. Three cosines of deliberately
* incommensurate period are the cheapest thing that produces it while staying
* *smooth* — every requirement on this number at once. Continuous in time, and
* in every derivative, so a clock that scrubs forward never steps. Deterministic
* from the instant alone, so two people looking at the same city at the same
* moment on different machines see the same sky, with no seed to agree on and
* nothing stored. And non-repeating on any timescale anyone will watch: as
* tenths of a day the three periods are 29, 67 and 151, all prime, so the
* combined pattern closes after 29 × 67 × 151 tenths — 29,339.3 days, a little
* over eighty years. A single period would come back around inside a fortnight
* and be recognised.
*
* The phases are there only so the three do not all start aligned at the Unix
* epoch, which is a real instant the clock can be scrubbed to.
*
* The amplitudes sum to 1, which is what lets `synopticCover` rescale without a
* second constant to keep in step.
*/
const SYNOPTIC_WAVES: readonly (readonly [number, number, number])[] = [
[2.9, 0.5, 0.13],
[6.7, 0.32, 0.61],
[15.1, 0.18, 0.29],
];
/**
* Exponent leaning the slow term back toward a clear sky.
*
* Three cosines summed and rescaled pile up around their own midpoint. Over a
* year of hourly samples the unshaped term runs 0.20 at the tenth percentile,
* 0.50 at the median and 0.80 at the ninetieth — a sky that is half covered
* half the time, which is not weather, it is a permanent haze the eye stops
* seeing after a minute. Raising it moves the middle down and leaves both ends
* alone: the shaped term still reaches its cap on the days all three waves
* agree and still reaches zero, but the same samples now run 0.11 / 0.38 /
* 0.73. Multiplied out through `SYNOPTIC_MAX_COVER` and `cumulusDiurnal`, a
* city with no marine layer spends a year at a median cover of 0.14, a
* ninetieth percentile of 0.30 and a maximum of 0.53 — some cloud up there
* nearly always, a busy sky now and again, and never a lid.
*/
const SYNOPTIC_SHAPE = 1.4;
/**
* Apparent solar hour the diurnal term peaks at, and how far it falls overnight.
*
* Cumulus over land is built by the ground under it: the surface heats, the
* heat takes time to get into the air above it, and the cloud that results
* peaks well after noon and thins out overnight. Mid-afternoon here rather than
* noon is that lag — and it is the reason this cannot be driven off the sun's
* elevation, which is the obvious idea and is symmetric about noon. Elevation
* alone makes nine in the morning and three in the afternoon the same sky, and
* they are not the same sky. Apparent solar hours are asymmetric about noon and
* are what `DIURNAL` already runs the marine layer's clock on; see `solarHours`
* for why that is also the only clock available offline.
*
* A floor rather than zero because not all cloud is convective. A sky that
* emptied completely every night and refilled every morning would be a stronger
* claim than this module has any way to support, and the marine layer — which
* does exactly the opposite, being thickest before dawn — is the standing proof
* that it would be wrong somewhere. Where it sits is a compromise between that
* and the requirement that a day not be flat: 0.4 leaves the afternoon two and
* a half times the pre-dawn sky at most, which is a shape you can watch arrive
* without it ever emptying.
*/
const CUMULUS_PEAK_SOLAR_HOUR = 15;
const CUMULUS_NIGHT_FLOOR = 0.4;
/**
* Cloud cover with nobody to ask: 0..1, from the calendar, the clock and the
* coast.
*
* This is the offline path and the offline path is the *default* one. A city
* with no weather API configured — no account, no key, no network, which is the
* case this whole engine is written around — has no observation to draw a sky
* from, and a caller that reads a cover of 0 out of that draws no cloud, ever.
* The fix belongs in this file because this file already models a sky nobody
* observed: it is where the marine layer lives, and the marine layer is the same
* argument already won once for fog.
*
* Two terms, and the split is the point:
*
* - **The coast, when there is one.** `marineStrength` is season × clock ×
* wind and is already what the fog is computed from; it is reused rather
* than paraphrased, so the deck a viewer sees and the grey the rig goes are
* the same event and cannot drift apart. Its strength reads directly as a
* cover because that is what it physically is — an advected stratus deck at
* full strength is a covered sky. It carries the season and the burn-off
* clock with it, which is why a June morning here comes out closed in and a
* December one does not.
* - **Everywhere else.** A slow synoptic drift over days, modulated by the
* afternoon build of cumulus over warm ground. Capped well short of
* overcast — see `SYNOPTIC_MAX_COVER` — because unlike the marine layer it
* is not a fact about anywhere in particular.
*
* The two combine by random overlap: two decks placed independently of one
* another leave `(1 - a)(1 - b)` of the sky clear between them. Plain `max` was
* the alternative and swallows the weaker layer whole, so a summer morning in
* San Francisco would render identically whether or not there was anything else
* in the sky that week.
*
* **Nothing here reaches the light rig, deliberately.** `apply` still reads its
* `cloud` from `env.weather` alone and every keyframe, curve and constant above
* is untouched — the rig is tuned and deployed and this is an output, not a new
* input. It would also be wrong twice over: the marine half of this number
* already reaches the rig as `obscuration`, so feeding it back in as `cloud`
* would count the same deck against the sun twice.
*/
function modelledCloudCover(
layer: MarineLayerOptions | null,
env: Environment,
lng: number,
): number {
const marine = layer ? marineStrength(layer, env, lng) : 0;
// UTC milliseconds, so the phase is an absolute instant rather than anything
// to do with the viewer's timezone — two people in different zones are
// looking at the same sky and must be given the same number for it.
const days = env.time.getTime() / MS_PER_DAY;
const background =
SYNOPTIC_MAX_COVER *
synopticCover(days) *
cumulusDiurnal(solarHours(env.time, lng, env.sun.equationOfTime));
return clamp(1 - (1 - marine) * (1 - background), 0, 1);
}
/** The slow term, 0..1. See `SYNOPTIC_WAVES` and `SYNOPTIC_SHAPE`. */
function synopticCover(days: number): number {
let sum = 0;
for (const [period, amplitude, phase] of SYNOPTIC_WAVES) {
sum += amplitude * Math.cos(2 * Math.PI * (days / period + phase));
}
// The amplitudes sum to 1, so `sum` lands in -1..1 and the base in 0..1.
// Clamped even so, and the reason is the exponent: it is fractional, and a
// base a single float error *below* zero raised to a fractional power is
// `NaN` rather than a small number. Neither 0.32 nor 0.18 is exact in binary,
// so "the amplitudes sum to 1" is true of the decimals and not quite of the
// doubles. One `NaN` leaving here is a cloud layer that silently stops
// drawing at one instant on one machine, which is the least debuggable
// failure available to a function this small.
return clamp((sum + 1) / 2, 0, 1) ** SYNOPTIC_SHAPE;
}
/**
* The afternoon build, as a multiplier on the slow term rather than a term of
* its own: a cloudy week is cloudier in the afternoon, and a clear week is
* still clear at four o'clock.
*
* A cosine rather than a table like `DIURNAL`, because this one has to close the
* loop at midnight and a table has to be trusted to. Periodic by construction
* means there is no midnight seam to get wrong later.
*/
function cumulusDiurnal(hours: number): number {
const turns = mod(hours - CUMULUS_PEAK_SOLAR_HOUR, 24) / 24;
const bump = 0.5 * (1 + Math.cos(2 * Math.PI * turns));
return CUMULUS_NIGHT_FLOOR + (1 - CUMULUS_NIGHT_FLOOR) * bump;
}
// ---- Time -----------------------------------------------------------------
/**
@@ -1603,4 +1830,39 @@ function wrapSigned(x: number, period: number): number {
* them; by -7.6° it is 0.572 and #8ea0d3 from the east. The shadows swing
* across the city over about half an hour, which is not an artefact — it is
* what actually happens, and on the one night a month it happens on.
*
* **`cloudCover`, with no weather at all**, which is the case it exists for. San
* Francisco with `PACIFIC_MARINE_LAYER`, 21 June 2026, midnight to 23:00 PDT:
*
* ```
* 0.83 0.83 0.83 0.83 0.84 0.85 0.86 0.84 0.82 0.73 0.57 0.46
* 0.28 0.27 0.25 0.23 0.30 0.43 0.54 0.72 0.76 0.81 0.81 0.82
* ```
*
* A June night closed in at over four fifths, burning back to under a quarter
* by mid-afternoon and shut again by nine — which is the marine layer's own
* diurnal curve arriving in the sky as well as in the fog, and is the day that
* city actually has in June. The 08:00 in that row is 0.82; the same hour four
* months later, on 21 October, is 0.12, and solar noon on 21 December 0.04:
* out of season the layer is
* not there, and neither is the cloud — the same October the fog notes above
* are careful about, arriving here for the same reason and out of the same
* curve. Los Angeles, same 24 hours and no marine layer, runs 0.15 down
* to 0.09 before dawn and back to 0.14 through the afternoon — the generic term
* alone, which is a few clouds about and a slight afternoon build, and never
* pretends to be more than that.
*
* Over a full year sampled hourly, San Francisco's modelled cover runs a median
* of 0.27 and a ninetieth percentile of 0.76, and touches 1.00 at the peak of
* the fog season; Los Angeles runs 0.14 and 0.30 against a maximum of 0.53,
* which is `SYNOPTIC_MAX_COVER` very nearly reached. Neither produces a
* non-finite value or leaves 0..1 anywhere in that year, including at 69.65 N,
* where the sun never sets and `solarHours` is doing the only clock there is.
*
* The largest change in one minute anywhere in that year is 0.0065, at San
* Francisco's steepest burn-off. There is nothing in this to step on: a scrubbed
* clock moves the sky the way an advancing one does.
*
* And with a station reporting, the model does not get a vote: the same June
* morning that models 0.82 returns exactly 0.05 when the observation says 0.05.
*/
+213 -3
View File
@@ -386,6 +386,11 @@ export class AdsbFlights implements FlightSource {
const body = (await res.json()) as { ac?: RawAircraft[] };
this.held = (body.ac ?? [])
.filter((a) => typeof a.lat === "number" && typeof a.lon === "number")
// See the note on `heading` below: a record with no track is dropped
// rather than zeroed, and dropping it here keeps the map's return type
// honest instead of widening `Aircraft.heading` to admit a null that no
// consumer could do anything sensible with.
.filter((a) => typeof a.track === "number")
// The endpoint takes a radius and is trusted to honour it, but a
// receiver feeding one of these networks hears whatever it hears and
// some deployments serve the lot. Anything outside the region projects
@@ -398,7 +403,23 @@ export class AdsbFlights implements FlightSource {
lng: a.lon as number,
// Feed reports feet; the scene works in metres.
altitude: typeof a.alt_baro === "number" ? a.alt_baro * 0.3048 : 3000,
heading: typeof a.track === "number" ? a.track : 0,
/**
* A missing track is *skipped*, not zeroed.
*
* ADS-B carries position-only records — surface vehicles, TIS-B and
* MLAT-derived targets — with no `track` field at all, and they pass
* every other filter here. Substituting `0` used to be harmless
* because a wrong heading only pointed a symmetrical dart the wrong
* way. It stopped being harmless when aircraft learned to bank: a
* target whose real heading is 200° reported as 0° looks like a 160°
* turn, which pins the roll at its 30° limit and holds it there for as
* long as the target is in the feed — a sustained, full-scale artefact
* produced entirely by invented data.
*
* Dropped in the filter above. An aeroplane this feed will not say
* the heading of is one this layer cannot draw honestly.
*/
heading: a.track as number,
}));
this.heldAt = nowSeconds();
return this.held;
@@ -557,6 +578,74 @@ const COLOR_BANDS = 12;
/** Steepest nose-up or nose-down attitude an aircraft is drawn at, in radians. */
const MAX_PITCH = 0.42;
/**
* Steepest bank an aircraft is drawn at, in radians — thirty degrees.
*
* A transport aircraft in normal operation does not go past this: airline
* procedure and autopilot bank limiters sit at 2530°, and steeper than that
* is a manoeuvre rather than a turn.
*
* The cap is not really here for the aeroplanes, though — it is here for the
* data. A reported heading that jumps forty degrees because a receiver lost a
* target and reacquired it is, at this end of the wire, indistinguishable from
* a genuine turn, and it arrives with the position barely moved, so the
* teleport test in `update` does not catch it — that test is about distance and
* this is a lie about attitude. Without a ceiling, one such sample knife-edges
* an airliner over the city, and a frame of that is worse than the flat turns
* this whole channel exists to replace.
*/
const MAX_BANK = (30 * Math.PI) / 180;
/**
* Nominal true airspeed over g, in seconds. The one constant in the bank.
*
* An aircraft in a coordinated turn holds `tan(bank) = ω·V / g`: the
* horizontal component of lift is the only thing turning it, so the bank
* needed for a given rate of turn rises with how fast the aeroplane is going.
* That relation is what `bankAngle` evaluates, with V fixed at 200 m/s
* (389 kt) rather than measured.
*
* **Fixed rather than measured, deliberately.** The speed is derivable from
* the same pair of samples the turn rate comes from — ground distance over
* `span`, which `climbAngle` already computes half of — but both terms then
* carry a 1/span, so their product carries 1/span², and position noise on a
* live feed would swing the bank about on its own. A constant is wrong by a
* factor the eye cannot read; noise is wrong in a way it can.
*
* 200 m/s is a compromise and knowingly one. `syntheticRoutes` flies its legs
* at eight seconds a nautical mile, which is about 450 kt and is cruise; an
* arrival on final is doing a third of that. Sitting between them draws
* terminal-area turns a little flatter than they fly and high-level ones a
* little steeper — the cheap direction to be wrong in, because the steep case
* saturates against `MAX_BANK` and the flat case still reads as a bank.
*
* V/g works out at 20.4 s, which puts a half-standard-rate turn (1.5 °/s, the
* airline norm at altitude) at 28° and reaches the 30° ceiling at 1.62 °/s.
*/
const SPEED_OVER_G = 200 / 9.80665;
/**
* Time constant for how quickly the bank follows the turn, in seconds.
*
* The roll is not set to the geometric answer, it is eased toward it: each new
* leg closes a fraction `1 - exp(-span / ROLL_SETTLE_SECONDS)` of the gap. A
* first-order lag, and nothing with a second derivative in it, because a
* first-order lag cannot overshoot. The requirement is that a straight leg
* settles to wings-level and *stays* there; a spring-and-damper would sit
* rocking about zero for several seconds after every turn, which is a worse
* artefact than the flat turns it would have been introduced to fix.
*
* Expressed as a time constant rather than as a per-update fraction because
* `span` is not one quantity here: it is a second for the simulator and five
* to fifteen for a live feed — see the repeat check in `update`. A flat "close
* 30% of the gap each time" would be about three seconds of lag on one source
* and forty-five on the other. At 2.5 s a 1 Hz simulator covers 63% of a roll
* in 2.5 s and 90% in 5.8, while a 10 s live refresh takes 98% of it in a
* single step — which is right, because on that source `tick` is already
* spreading the movement across ten seconds of interpolation.
*/
const ROLL_SETTLE_SECONDS = 2.5;
interface TrailSample {
position: THREE.Vector3;
altitude: number;
@@ -574,6 +663,24 @@ interface Track {
span: number;
/** Climb angle of the current leg, radians, positive nose-up. */
pitch: number;
/**
* Bank at the two ends of the current leg, radians, positive right-wing-down.
*
* Two numbers rather than one because the roll is interpolated across the leg
* exactly as the position, the altitude and the heading are. A single value
* would step once per poll, and a step is *more* conspicuous on the roll
* channel than on the others: yaw and position move continuously either side
* of it so the discontinuity is small, whereas a bank that arrives all at once
* is an aircraft snapping onto its wingtip. On a live feed that would be a
* visible flick every five to fifteen seconds, on every aircraft that is
* turning.
*
* `rollFrom` is simply what `rollTo` was on the previous leg, so the two
* always meet and the interpolation is continuous across a poll even though
* the target it is chasing is not.
*/
rollFrom: number;
rollTo: number;
/** Which cached material is on the mesh, so a band change is the only write. */
band: number;
/** Interpolated position, reused rather than reallocated every frame. */
@@ -698,6 +805,11 @@ export function createFlightLayer(world: World): FlightLayer {
samples: [],
span: MIN_SPAN,
pitch: 0,
// Wings level: a track with one observation has no pair of headings to
// have turned between, and `tick` reads both of these on its very first
// frame, so neither may start undefined.
rollFrom: 0,
rollTo: 0,
band: -1,
head: position.clone(),
headAltitude: a.altitude,
@@ -765,9 +877,21 @@ export function createFlightLayer(world: World): FlightLayer {
track.samples.length = 0;
track.head.copy(position);
track.pitch = 0;
// The attitude is history too. A simulated route that has just looped
// was, one poll ago, banked into whatever its last leg was doing, and
// that leg is now several hundred units away and belongs to a
// different flight — carrying the bank across the wrap would put the
// aircraft on its ear at the start of a dead-straight departure. Both
// ends are cleared so the interpolation has nothing left to run out.
track.rollFrom = 0;
track.rollTo = 0;
} else {
track.span = span;
track.pitch = climbAngle(world, previous, sample);
// Where the last leg's roll finished is where this one's begins, which
// is what makes the bank continuous across a poll boundary.
track.rollFrom = track.rollTo;
track.rollTo = bankAngle(previous, sample, span, track.rollTo);
}
}
@@ -873,6 +997,37 @@ export function createFlightLayer(world: World): FlightLayer {
// Negative, because rotating the nose (+z) about +x by a positive angle
// pushes it down.
track.mesh.rotation.x = -track.pitch;
/**
* Bank, and the sign of it is the entire point of the channel.
*
* `rotation.order` is "YXZ", so z is applied first and therefore turns in
* the *body* frame — about whatever axis the nose has ended up on rather
* than about the world's z. That is what makes this a roll at all instead
* of a lean, and it is why one sign works at every heading.
*
* Which sign: the nose is modelled along +Z and the aircraft's own up is
* +Y — `aircraftGeometry` builds the fin in the x = 0 plane reaching up to
* y = +0.098, so there is no ambiguity about which way is up on this mesh
* and therefore none about which way its wings go. A positive rotation
* about +Z takes +Y to (sin θ, cos θ, 0), so the up vector tilts toward
* local X; and local X is the aircraft's right-hand side, because
* right = nose × up = (+Z) × (+Y) = X.
* **Positive `rotation.z` drops the right wing.**
*
* A compass heading increasing is a turn to the right — north through east
* — and `headingDelta` is positive for exactly that. The two conventions
* already agree, which is why there is no negation here, unlike on the two
* channels above.
*
* Verified against three.js rather than reasoned about alone, because
* getting this backwards is the failure everybody sees and nobody can
* name: with rotation.y = π (heading 000) and rotation.z = +30°, the
* mesh's world up comes out (0.5, 0.866, 0) — tilted toward +x, and +x is
* east, which is the right hand of a northbound aircraft. At heading 090
* the same roll tilts it to (0, 0.866, 0.5), toward +z, which is south and
* is the right hand of an eastbound one.
*/
track.mesh.rotation.z = track.rollFrom + (track.rollTo - track.rollFrom) * alpha;
const band = bandFor(track.headAltitude);
if (band !== track.band) {
@@ -1016,6 +1171,62 @@ function climbAngle(world: World, from: TrailSample, to: TrailSample): number {
return clamp(Math.atan2(to.altitude - from.altitude, horizontal), -MAX_PITCH, MAX_PITCH);
}
/**
* How far to bank for the turn between two observations, in radians.
*
* Three things are happening here and each is load-bearing.
*
* **The rate uses the clamped `span`, not the real gap between the samples.**
* That looks like a bug and is not: `span` is the time this layer is going to
* spend *rendering* the heading change, and the bank has to match the turn the
* viewer is watching rather than the one that happened. A feed that stalled for
* two minutes and came back a hundred and eighty degrees round has its recovery
* drawn by `tick` as a thirty-second turn, and an aircraft pivoting through half
* the compass in thirty seconds with its wings level is precisely the tell this
* function exists to remove.
*
* **`headingDelta` takes the short way round**, so 358° → 002° is +4° over the
* span and not 356°. Without that, a track crossing north would slam to the
* ceiling in the wrong direction for one leg and then unwind — the same wrap
* that `interpolateHeading` was already written for, on a channel where it would
* be far more obvious.
*
* **The lag is applied here rather than in `tick`**, so it advances once per
* observation and in proportion to how much time that observation covered.
* Putting it on the frame instead would make the settling rate depend on the
* frame rate, and a 144 Hz monitor would bank aircraft differently from a 30 Hz
* one.
*
* The `Number.isFinite` guard is not defensive padding. Unlike the yaw, which
* `tick` recomputes from the samples every frame and which therefore repairs
* itself, the roll is *state* — it is fed its own previous value. One
* non-numeric heading from a feed would not cost a frame, it would poison the
* track for as long as it lives, because every later value is computed from this
* one. Returning `current` costs a leg of staleness and nothing else.
*/
function bankAngle(from: TrailSample, to: TrailSample, span: number, current: number): number {
const radiansPerSecond = (headingDelta(from.heading, to.heading) * Math.PI) / 180 / span;
if (!Number.isFinite(radiansPerSecond)) return current;
const target = clamp(Math.atan(radiansPerSecond * SPEED_OVER_G), -MAX_BANK, MAX_BANK);
return current + (target - current) * (1 - Math.exp(-span / ROLL_SETTLE_SECONDS));
}
/**
* The signed shortest angle from one compass heading to another, in degrees.
*
* Positive is a turn to the right: clockwise on the compass, north toward east.
* The result is in [180, 180) — an exact reversal comes out as a left turn,
* arbitrarily, because two headings 180° apart carry no information about which
* way the aircraft went round.
*
* The yaw and the bank both need this and they have to agree about it. If one of
* them took the long way round, a track crossing north would spin one way while
* banking the other.
*/
function headingDelta(from: number, to: number): number {
return ((((to - from) % 360) + 540) % 360) - 180;
}
/**
* Blend two compass headings the short way round.
*
@@ -1024,8 +1235,7 @@ function climbAngle(world: World, from: TrailSample, to: TrailSample): number {
* could have.
*/
function interpolateHeading(from: number, to: number, t: number): number {
const delta = (((to - from) % 360) + 540) % 360 - 180;
return from + delta * t;
return from + headingDelta(from, to) * t;
}
/** 0 on the deck, 1 at cruise. Curved, because the low end is where the eye is. */