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
+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. */