1
0

The office learns where it stands, and the sky stops being a backdrop

**Aircraft actually move now, and the reason they did not is the headline.**
`HttpFlights` holds a frozen snapshot between network refreshes and is
polled at 1 Hz, so a live feed handed the layer the same position five to
fifteen times and then jumped. `span` therefore measured the poll interval
rather than the gap between the two positions that differ, the teleport
test saw an airliner covering 36 units in a "second" against a ceiling of
8, and **every live track's history was wiped on every refresh** — so no
aircraft on the deployed site could ever grow a trail, however long
TRAIL_POINTS was set. Skipping the repeat fixes the motion and the trail
at once. Trails then go to 72 points / 240 s, which is about seventy
seconds of flying.

Three more defects in the same file, found while looking: trail
truncation dropped the segments nearest the aircraft (leaving a streak
with no aeroplane attached), MAX_TRACKS was declared and never enforced,
and one missing target deleted its whole trail. The buffer now uploads
only what it wrote, rather than 46 MB/s of untouched array.

**You can get above the constellation.** Dome to 1.05 board *radii* and
the orbit to 2.0 spans. Radii, not spans: scene space is centred on the
city and the Bay Area board runs forty kilometres down the peninsula, so
the furthest corner is 0.94 spans out where the half-diagonal is 0.65 —
sized off the half-diagonal the dome sat inside its own city. The far
plane goes to 4 spans to stop clipping the sky from off-centre chapters,
and `PointsMaterial` defaults `fog: true`, which was quietly dimming the
whole constellation with the city's haze.

**An office can say where it stands.** `Office.site` — lat, lng, height
above the ground outside, and the compass bearing the pack's −Z points
along — and with one it gets the same sun the city does, a sky, and a
horizon at `-elevation`. CONTRACT §4 reserved this as "a later
refinement"; it is taken up rather than overturned, and `daylight.ts`
computes no light of its own. It does the two things a room needs that a
map does not: turn the sun into the building's frame, and move the fog
outdoors before it greys out the far wall.

Two buildings now, and they are deliberately unalike: Lumbridge HQ 188 m
up a Transbay tower facing 205°, and **Frontier Valley**, a startup in a
hangar at Alameda Point — one room, 54 x 30 m, nine metres to the
trusses, four metres above reclaimed ground.

Floor-to-floor in the reference pack is now 16.8 m: the interstitial is
ten times a real one, so the space between the slabs is somewhere things
can hang. It is frankly not architecture, `PLENUM` is the one number to
change, and the file says so.

Also fixed, all found by review rather than by looking at the screen:

  - `sun.shadow.camera.updateProjectionMatrix()` was never called, so
    three's default ±5 unit box has been in force this whole time and
    every `shadowExtent` this repo passes — including the city's ±752 —
    has been silently ignored.
  - A missing aircraft was kept alive by the new grace period and *drawn*,
    so it froze in mid-air at full opacity for 32 s.
  - Frontier Valley's mezzanine was a `Room`, which carries no height: its
    slab lay on the concrete, its chairs floated 4.4 m over it, and its
    balustrade fenced off a patch of ground floor. It is a `Level`.
  - Overlapping floor slabs z-fought. The format permits overlap and
    resolves later-first, so `shell.ts` now lifts a slab a hair per
    earlier slab it overlaps — and by nothing at all in a pack, like the
    reference office, whose rooms only ever abut.
  - `switchOffice` bypassed the `entering` guard (leaking a whole scene
    per double-click) and tore down the old room before knowing the new
    one would load, with no way back.

Known and not fixed: raising MAX_SPAN to 30 s doubles the worst-case
re-base snap when a feed's gap shortens. It is bounded, pre-existing in
kind, and the fix wants carrying the live head into the next leg.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 23:30:45 -07:00
parent d8afc42d15
commit 06455f7424
15 changed files with 2061 additions and 75 deletions
+207 -11
View File
@@ -434,16 +434,54 @@ export interface FlightLayer {
* How many observations a trail remembers, and how long it may hold one.
*
* Both limits are needed. The count keeps the shared vertex buffer bounded, and
* the age keeps a slow feed from drawing a trail across the entire bay: at
* `AdsbFlights`'s eight-second interval, twenty samples is nearly three minutes
* of flying, which is most of a leg.
* the age keeps a slow feed from drawing a trail across the entire bay.
*
* These were 20 and 45, which made a trail about twenty seconds long — enough to
* say "this thing is moving" and not enough to say where it came from. At 72
* points the simulator, polled at 1 Hz, draws about seventy seconds of flying:
* a quarter to a half of one of `sample.ts`'s legs, so an arrival trails a
* visible curve down the approach rather than a tick behind it.
*
* The count is what binds for a 1 Hz source; the age binds for a slow one. 240 s
* is four minutes, which at a live feed's 515 s refresh is 1648 samples — so
* a real ADS-B track fills a good part of the buffer without either limit
* cutting it short.
*
* The cost is bounded and was measured rather than guessed: the trail is one
* preallocated `LineSegments` and therefore one draw call at any length, and the
* buffer goes from 210 KiB to 756 KiB. What actually scales is the per-vertex
* work in `rebuildTrails`, which is why `writeTrailVertex` now uploads only the
* range it wrote instead of the whole array.
*/
const TRAIL_POINTS = 20;
const TRAIL_SECONDS = 45;
const TRAIL_POINTS = 72;
const TRAIL_SECONDS = 240;
/** Ceiling on tracks that get a trail, so the buffer can be allocated once. */
/**
* How far two positions may differ and still be "the same one repeated".
*
* Scene units. See `samePosition`, and the note on the repeat check in
* `update` for why a repeated observation must not become a sample.
*/
const SAME_POSITION_EPSILON = 1e-4;
/**
* Ceiling on tracks that get a trail, so the buffer can be allocated once.
*
* It is a real ceiling now. It was declared and then referenced only by the
* buffer sizing, so `tracks` grew without limit and `rebuildTrails` silently
* ran out of vertices — which mattered the moment the godmode dial could put
* four hundred aircraft in the sky. Tracks past this many still get a dart;
* what they do not get is a trail, which is the graceful half to drop.
*/
const MAX_TRACKS = 192;
/**
* How long a track survives not being in a snapshot before it is forgotten.
*
* Two refreshes of a slow live feed. See the note where it is used.
*/
const TRACK_GRACE_SECONDS = 32;
/**
* Opacity at the head of a trail, fading to nothing at the tail. Well under 1
* on purpose: the trail is context for the dart, not a second subject, and a
@@ -460,7 +498,14 @@ const TRAIL_ALPHA = 0.55;
* the real gap was four times that gives an aircraft that darts and then waits.
*/
const MIN_SPAN = 0.2;
const MAX_SPAN = 15;
/**
* 30 rather than 15, because the repeat check in `update` changed what this
* measures. It used to bound a poll interval; it now bounds the gap between two
* positions that actually differ, and for a live feed that gap *is* the server's
* cache TTL — 5 to 15 seconds, plus whatever the network adds. Clamping at 15
* would have made every slow refresh look like a dart-and-wait again.
*/
const MAX_SPAN = 30;
/**
* Above this, a step is a teleport rather than a flight.
@@ -516,6 +561,16 @@ interface Track {
band: number;
/** Interpolated position, reused rather than reallocated every frame. */
head: THREE.Vector3;
/**
* When this track first went missing from a snapshot, or `0` while it is
* present. See `TRACK_GRACE_SECONDS`.
*/
missingSince: number;
/**
* Missing, and finished moving toward wherever it was last seen — so no
* longer drawn, while its history is still held. See `tick`.
*/
stale: boolean;
/** Altitude at `head`, which is what the dart's colour is chosen from. */
headAltitude: number;
}
@@ -629,11 +684,48 @@ export function createFlightLayer(world: World): FlightLayer {
band: -1,
head: position.clone(),
headAltitude: a.altitude,
missingSince: 0,
stale: false,
};
tracks.set(a.id, track);
}
const previous = track.samples[track.samples.length - 1];
/**
* A source repeating itself is not a new observation, and treating it as
* one is what stopped live traffic from ever moving.
*
* `HttpFlights` is polled at 1 Hz and holds a *frozen* snapshot between
* network refreshes, which the server caches for 515 s. So a live feed
* hands over the identical position five to fifteen times in a row and
* then jumps. Pushing each repeat as its own sample had two consequences,
* and both of them were visible on the deployed site:
*
* 1. `span` measured the poll interval (~1 s) rather than the gap between
* the two positions that actually differ (515 s). The teleport test
* below then saw an airliner covering 36 units in a "second" against a
* ceiling of 8 and **wiped the entire trail on every refresh** — so no
* live aircraft could ever grow a trail at all, however large
* `TRAIL_POINTS` was set.
* 2. The trail filled with a dozen coincident points, so the spine had no
* length and the dart sat still and then jumped.
*
* Skipping the repeat fixes both at once: `span` becomes the real gap, the
* jump test sees ~2.5 units/s and passes, and the interpolation in `tick`
* spreads the movement smoothly across the whole refresh interval.
*
* Compared on position rather than on an observation timestamp because a
* `FlightSource` is not required to carry one — `Aircraft` has no `at`
* field, and the two real sources disagree about whether they could
* supply one honestly.
*/
if (previous && samePosition(previous, sample)) {
// Nothing to record. The head keeps interpolating toward the newest
// distinct sample, which is what makes the motion continuous.
continue;
}
if (previous) {
// The clamp is load-bearing on both ends. Two polls arriving in the same
// millisecond — a manual refresh, a tab waking up — divide by nearly
@@ -666,8 +758,27 @@ export function createFlightLayer(world: World): FlightLayer {
trim(track, now);
}
/**
* An aircraft missing from one snapshot has not landed.
*
* This used to delete the track the instant an id was absent, which meant a
* single dropped target — an ADS-B receiver losing a line of sight for one
* refresh, which is routine — threw away its entire history and rebuilt it
* from nothing. At a 20-point trail that cost twenty seconds; at 72 it would
* cost over a minute, so the longer trails are what made this worth fixing
* rather than merely worth noticing.
*
* `ADSB_HOLD_SECONDS` covers the whole feed going dark. This covers one
* target going quiet inside an otherwise healthy snapshot, which is a
* different failure and needs a different answer.
*/
for (const [id, track] of tracks) {
if (seen.has(id)) continue;
if (seen.has(id)) {
track.missingSince = 0;
continue;
}
if (track.missingSince === 0) track.missingSince = now;
if (now - track.missingSince < TRACK_GRACE_SECONDS) continue;
group.remove(track.mesh);
tracks.delete(id);
}
@@ -675,6 +786,22 @@ export function createFlightLayer(world: World): FlightLayer {
tick();
}
/**
* Whether a feed has handed back the same position it did last time.
*
* Scene units, and the tolerance is deliberately tiny: this is asking "did the
* source repeat itself", not "did it move much". A genuinely stationary
* aircraft on a taxiway still reports jitter well above this, and if it did
* not, an aircraft that is not moving has nothing to draw a trail from anyway.
*/
function samePosition(a: TrailSample, b: TrailSample): boolean {
return (
Math.abs(a.position.x - b.position.x) < SAME_POSITION_EPSILON &&
Math.abs(a.position.z - b.position.z) < SAME_POSITION_EPSILON &&
Math.abs(a.altitude - b.altitude) < 1
);
}
/** Forget history that is too old or too long to be worth drawing. */
function trim(track: Track, now: number) {
while (track.samples.length > TRAIL_POINTS) track.samples.shift();
@@ -696,6 +823,26 @@ export function createFlightLayer(world: World): FlightLayer {
const from = track.samples[n - 2] ?? to;
const alpha = from === to ? 1 : clamp((now - to.at) / track.span, 0, 1);
/**
* A missing aircraft stops being drawn once it has finished arriving.
*
* The grace period holds a track's *history* across a dropped refresh,
* which is the point of it — but holding the history is not the same as
* going on drawing the aeroplane. Left drawn, a target that genuinely
* left the feed froze in mid-air at full opacity with its whole trail
* attached, for the full thirty-two seconds, indistinguishable from an
* aircraft that had stopped flying. The godmode dial made it unmissable:
* turning 400 fabricated aircraft down to zero left 400 darts hanging.
*
* Gated on having run out of interpolation rather than simply on being
* missing, so a target absent for one refresh keeps moving to where it was
* last seen and never blinks — and if it comes back, it resumes its trail
* instead of rebuilding it.
*/
track.stale = track.missingSince !== 0 && now - to.at > track.span;
track.mesh.visible = !track.stale;
if (track.stale) continue;
track.head.lerpVectors(from.position, to.position, alpha);
track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha;
@@ -728,12 +875,39 @@ export function createFlightLayer(world: World): FlightLayer {
*/
function rebuildTrails() {
let vertex = 0;
let drawn = 0;
for (const track of tracks.values()) {
// Past the ceiling, an aircraft keeps its dart and loses its trail. The
// buffer was sized for this many and the constant meant nothing until now.
if (drawn >= MAX_TRACKS) break;
// Before `drawn`, so an expiring ghost does not hold a trail slot ahead of
// a genuinely new arrival — `tracks` is walked in insertion order, and the
// ghosts are the oldest entries in it.
if (track.stale) continue;
const spine = track.samples.length - 1;
if (spine < 1) continue;
drawn += 1;
const points = spine + 1; // the spine, plus the head
for (let i = 1; i < points; i++) {
/**
* Where to start drawing this track, so that a buffer that cannot hold
* everything loses the **oldest** segments rather than the newest.
*
* The loop writes tail-first, so the previous `break`-when-full dropped
* the segments nearest the aircraft. That is the worst possible end to
* lose: it left a streak floating in open air with no aeroplane attached
* to it, which reads as a rendering fault rather than as a shortened
* trail. It was invisible at 78 aircraft and unmissable the moment the
* godmode dial put four hundred in the sky.
*
* Clamping the start index instead means a crowded sky draws shorter
* trails, every one of them still joined to its dart.
*/
const budget = Math.max(0, (maxVertices - vertex) / 2);
if (budget < 1) break;
const first = Math.max(1, points - Math.floor(budget));
for (let i = first; i < points; i++) {
if (vertex + 2 > maxVertices) break;
const a = track.samples[i - 1];
if (!a) continue;
@@ -749,8 +923,30 @@ export function createFlightLayer(world: World): FlightLayer {
}
}
trailGeo.setDrawRange(0, vertex);
trailGeo.attributes.position!.needsUpdate = true;
trailGeo.attributes.color!.needsUpdate = true;
/**
* Upload only the vertices actually written this frame.
*
* `needsUpdate` alone re-sends the entire `Float32Array` — three.js takes an
* empty update range to mean "all of it" — which was 12.9 MB/s at 60 Hz with
* a 20-point trail and would have been 46 MB/s at 72 for a scene that
* normally holds seven aircraft and writes under 2% of the buffer. The rest
* of the array is stale data nothing draws, because `setDrawRange` already
* bounds what is read.
*
* `clearUpdateRanges` first, or the ranges accumulate frame on frame and the
* saving disappears within a second.
*/
const positionAttr = trailGeo.attributes.position as THREE.BufferAttribute;
const colorAttr = trailGeo.attributes.color as THREE.BufferAttribute;
positionAttr.clearUpdateRanges();
colorAttr.clearUpdateRanges();
if (vertex > 0) {
positionAttr.addUpdateRange(0, vertex * 3);
colorAttr.addUpdateRange(0, vertex * 4);
positionAttr.needsUpdate = true;
colorAttr.needsUpdate = true;
}
}
function writeTrailVertex(index: number, position: THREE.Vector3, altitude: number, fade: number) {
+40 -13
View File
@@ -309,21 +309,37 @@ export interface SatelliteLayer {
}
/**
* The dome's radius, as a fraction of the board's longest side.
* The dome's radius, as a fraction of **how far the board reaches from the scene
* origin** — `boardRadius` in `scene.ts`, not the board's width.
*
* Three constraints, and the middle one is the one that is easy to miss:
* That distinction was got wrong once and is worth stating plainly. Scene space
* is centred on `city.center`, and the Bay Area board runs forty kilometres down
* the peninsula from there, so its furthest corner is 0.94 spans from the origin
* while its half-diagonal is 0.65. A dome sized at 0.7 *spans* therefore sat
* **inside the southern third of its own city** — satellites rendering below the
* terrain and behind the hills, which looks like a depth bug and is a units bug.
*
* 1. Outside the city, or the buildings occlude the sky.
* 2. **Outside the camera's maximum orbit**, which `scene.ts` sets at
* `boardSpan * 1.5`. This was 1.2 first, which put the far end of the zoom
* *outside the dome* — pull back far enough and you were looking at the sky
* from space, with half the constellation behind the camera. A sky you can
* leave is not a sky.
* 3. Inside the far plane, which `scene.ts` sets at `boardSpan * 3`.
* **You are meant to be able to get above this.** That is a deliberate reversal:
* an earlier version put the dome at 2.0 spans specifically so the camera could
* never leave it, on the theory that a sky you can step outside of is not a sky.
* That theory loses to the thing people actually want to look at — a
* constellation is an object, and the view of it from above, with the city
* underneath, is the shot. Standing inside a sphere of dots is a planetarium.
*
* 2.0 sits in the middle of the only window that satisfies all three.
* So the geometry is worked out from the other end. The board's half-diagonal is
* 0.649 spans for San Francisco and 0.635 for the Southland, so 0.70 clears
* every corner of the city while sitting well inside the camera's new 2.0-span
* orbit. At the far end of the zoom the whole dome is in frame at this fov and
* the board still fills about two thirds of the screen height.
*
* Two bugs died with the old number, and both were invisible from the default
* pose. At 2.0 the dome reached 3.5 spans from the far side of the orbit against
* a far plane at 3.0, so roughly a sixth of the sky was being **clipped**; and
* everything past 2.8 spans was **fully fogged** by the city's own linear fog.
* The band where the constellation was both unclipped and unfogged did not
* overlap the band where it fitted on screen at all.
*/
const DOME_RADIUS_FACTOR = 2.0;
const DOME_RADIUS_FACTOR = 1.05;
/**
* How large a dot is drawn, in **pixels**, at any camera distance.
@@ -386,11 +402,11 @@ const HORIZON_FADE_DEG = 8;
*/
const SHADOW_ALPHA = 0.16;
export function createSatelliteLayer(boardSpan: number): SatelliteLayer {
export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
const group = new THREE.Group();
group.name = "satellites";
const radius = boardSpan * DOME_RADIUS_FACTOR;
const radius = boardRadius * DOME_RADIUS_FACTOR;
const positions = new Float32Array(MAX_DOTS * 3);
const colors = new Float32Array(MAX_DOTS * 4);
@@ -418,6 +434,17 @@ export function createSatelliteLayer(boardSpan: number): SatelliteLayer {
// and additive blending is what makes a lit satellite read as a light source
// rather than as a grey sticker.
blending: THREE.AdditiveBlending,
/**
* Satellites are not in the weather.
*
* `PointsMaterial` defaults `fog: true`, and the city runs a linear fog
* whose far plane is 2.8 board spans — so every dot was being mixed toward
* the fog colour by distance, and the constellation dimmed as the camera
* pulled back, exactly when more of it came into view. Haze is a property of
* the twelve kilometres of air a city sits in; an object 550 km up is on the
* far side of all of it.
*/
fog: false,
});
const points = new THREE.Points(geo, material);
+48 -3
View File
@@ -194,14 +194,59 @@ export async function createScene(
const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng);
const boardSpan = Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ));
/**
* How far the board reaches **from the scene origin**, which is not the same
* as how big it is.
*
* Scene space is centred on `city.center` — the city — and the Bay Area board
* runs forty kilometres down the peninsula from there, so the origin is
* nowhere near the middle of it. The furthest corner is 0.94 spans out where
* the half-diagonal is only 0.65, and anything sized off the half-diagonal is
* therefore too small by half.
*
* The satellite dome is centred on the origin, because that is where its look
* angles were computed for, so this is the radius it has to clear.
*/
const boardRadius = Math.max(
Math.hypot(westX, northZ),
Math.hypot(eastX, northZ),
Math.hypot(westX, southZ),
Math.hypot(eastX, southZ),
);
const kit = createSceneKit({
scene,
dom: stage.renderer.domElement,
fov: 42,
near: 0.1,
far: boardSpan * 3,
/**
* Everything, at the worst pose, plus room.
*
* The furthest thing from the camera is the back of the satellite dome seen
* from a chapter at the far end of the board: `maxChapterOffset` (0.94
* spans) + `maxDistance` (2.0) + the dome radius (about 0.99) — call it 3.9.
* At 3.0 the sky was being clipped from any off-centre chapter, which is
* most of them.
*
* It also now sits at the fog's far plane, which is the honest statement of
* the invariant: nothing should be cut off before it has fully faded out.
*/
far: boardSpan * 4,
minDistance: Math.max(4, boardSpan * 0.02),
maxDistance: boardSpan * 1.5,
/**
* Far enough out to be **above the satellites**, which is what the extra
* half-span buys.
*
* The satellite dome is at 0.7 spans (`engine/satellites.ts`), so at 1.5 the
* camera was always inside the constellation and could only ever look up
* through it. At 2.0 the far end of the zoom is outside it looking down, with
* the whole dome in frame at this field of view and the board still filling
* about two thirds of the screen.
*
* The far plane already covers it: the furthest thing from the camera is then
* the back of the dome at 2.7 spans, against `far` at 3.0.
*/
maxDistance: boardSpan * 2.0,
shadowExtent: boardSpan * 0.75,
shadowFar: boardSpan * 2.2,
});
@@ -235,7 +280,7 @@ export async function createScene(
let satelliteLayer: SatelliteLayer | null = null;
if (options.satellites) {
satelliteLayer = createSatelliteLayer(boardSpan);
satelliteLayer = createSatelliteLayer(boardRadius);
scene.add(satelliteLayer.group);
}
+17
View File
@@ -246,6 +246,23 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
sun.shadow.camera.top = extent;
sun.shadow.camera.bottom = -extent;
sun.shadow.bias = options.shadowBias ?? -0.0012;
/**
* Without this line, none of the six numbers above exist.
*
* `OrthographicCamera` bakes its frustum into a projection matrix in the
* constructor, and mutating `.left`/`.right`/`.near`/`.far` afterwards does
* nothing until the matrix is rebuilt. three.js rebuilds it for a *spot*
* light's shadow (`SpotLightShadow.updateMatrices` recomputes when the fov or
* far changes) and **not** for a directional light's — `LightShadow.
* updateMatrices` only does `lookAt` and `updateMatrixWorld`.
*
* So every caller here was configuring a shadow camera that was still
* three's default `(-5, 5, 5, -5, 0.5, 500)`: a ten-unit box. The city asks
* for `shadowExtent: boardSpan * 0.75`, which is ±752 units on the Bay Area
* board, and got ±5 — a shadow map covering a square about the size of one
* house, somewhere near the origin. The office asks for ±34 m and got ±5 m.
*/
sun.shadow.camera.updateProjectionMatrix();
const hemisphere = new THREE.HemisphereLight(0xffffff, 0x808080, 1);
const ambient = new THREE.AmbientLight(0xffffff, 0.3);
scene.add(sun, hemisphere, ambient);