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) {