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
+191 -8
View File
@@ -16,8 +16,10 @@ import {
createAtmosphere,
observe,
PACIFIC_MARINE_LAYER,
type Atmosphere,
type WeatherObservation,
} from "./engine/atmosphere.ts";
import { officeDaylight } from "./interiors/daylight.ts";
import { createScene, type SceneHandle } from "./engine/scene.ts";
import {
regionOf,
@@ -79,6 +81,27 @@ const CITIES: { id: string; label: string; city: City }[] = [
{ id: "socal", label: "SoCal", city: SOCAL },
];
/**
* The buildings this page can walk into.
*
* Two of them, and the second one is why this is a table rather than the single
* hardcoded `import("./offices/lumbridge-hq.ts")` it replaces. They are
* deliberately unalike — a two-storey tower floor 188 m above Transbay, and a
* hangar four metres above reclaimed ground at Alameda Point — because the
* thing worth showing is that one engine and one format render both, and that
* `OfficeSite` is what makes them feel like different places rather than the
* same room with different furniture.
*
* The loaders stay lazy. Every pack is a chunk this page does not fetch until
* somebody opens that door, which is the arithmetic `loadOffice` explains — and
* a second pack eagerly imported would put its furniture in the entry bundle
* for every visitor who never opens it.
*/
const OFFICES: { id: string; label: string; load: () => Promise<{ default: Office }> }[] = [
{ id: "lumbridge-hq", label: "Lumbridge HQ", load: () => import("./offices/lumbridge-hq.ts") },
{ id: "frontier-valley", label: "Frontier Valley", load: () => import("./offices/frontier-valley.ts") },
];
const canvas = document.querySelector<HTMLCanvasElement>("#scene");
if (!canvas) throw new Error("#scene canvas missing");
@@ -223,6 +246,24 @@ let poseEditor: PoseEditor | null = null;
*/
let officePack: Office | null = null;
let officeMaterials: MaterialRegistry | null = null;
/**
* Which building the door leads to. Changed by the picker while you are inside.
*
* `officePack` is the pack for *this* id and is rebuilt on a switch rather than
* memoised forever, which is the one-line difference from the arrangement that
* could only ever hold one office.
*/
let officeId = OFFICES[0]?.id ?? "lumbridge-hq";
/**
* The office's own sky, when its pack says where it stands.
*
* A second `Atmosphere` rather than the city's, because the two are at different
* scales and in different places: the city's is built with `metresPerUnit` near
* 94 and a fog measured in board spans, and an office runs at 1 m per unit
* fifty metres across. `null` for a pack with no `site`, which keeps the fixed
* interior rig and is a supported state rather than a gap.
*/
let officeAtmosphere: Atmosphere | null = null;
/**
* The plan panel's other occupant.
*
@@ -357,8 +398,38 @@ function currentWeather(): WeatherObservation | null {
return weatherOverride ?? weatherWatch?.current().value ?? null;
}
/**
* The office's rig for the instant being rendered, in the building's own frame.
*
* Same clock, same weather and same `Atmosphere` machinery the city runs on —
* which is the entire point. An office that dimmed on a schedule of its own
* would be a second sun, and CONTRACT.md §4 exists to say there is one.
*
* `officeDaylight` does the two things a room needs and a map does not: it turns
* the sun into the building's frame using `site.heading`, so the light comes
* through the windows the pack actually has, and it moves the fog outdoors.
*/
function officeLighting(site: NonNullable<Office["site"]>) {
const env = observe(site.lat, site.lng, currentInstant(), currentWeather());
// `officeAtmosphere` is built alongside the pack; falling back to the fixed
// rig here would be a flicker rather than a fix, so this is only ever called
// where one exists.
const state = officeAtmosphere?.apply(env);
return state ? officeDaylight(state, site) : undefined;
}
function updateSun() {
const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO;
// The office follows the same clock, and follows it whether or not you are
// standing in it — walking back in to a room lit for an hour ago is the
// failure this avoids.
const site = officePack?.site;
if (office && site && officeAtmosphere) {
const state = officeLighting(site);
if (state) office.setLighting(state);
}
if (!city || !atmosphere) return;
const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather());
city.setLighting(atmosphere.apply(env));
@@ -417,6 +488,7 @@ async function mountCity(id: string) {
officePlan = null;
office?.dispose();
office = null;
officeAtmosphere = null;
inside = false;
minimap?.dispose();
minimap = null;
@@ -562,7 +634,13 @@ async function mountCity(id: string) {
atmosphere = createAtmosphere({
lng: entry.city.center.lng,
metresPerUnit: city.world.metresPerUnit,
clearFog: { near: span * 1.15, far: span * 2.8 },
// Pushed out with the camera. `scene.ts` now lets the orbit reach 2.0 spans
// so the viewer can get above the satellite dome, and at the old far of 2.8
// the board sat at 51% fog from that pose — the whole city washing out at
// exactly the moment the shot is meant to be the city under the
// constellation. 3.9 keeps it near a fifth, which is the haze it had at the
// old limit.
clearFog: { near: span * 1.15, far: span * 3.9 },
// The floor on how far you can see, and it has to know how big the board
// is. `minVisibilityM` defaults to 4.5 km, which is honest weather and
// completely wrong here: this board is ninety-four kilometres across, so
@@ -703,9 +781,42 @@ async function enterOffice() {
if (!built || !city) return;
const { createOfficeScene, createOfficeMinimap, pack, materials } = built;
const depth = access.can.officeDepth;
/**
* The building's own sky, built before the scene because the scene wants the
* opening rig and the horizon drop at construction.
*
* `metresPerUnit: 1` — an office is authored in metres, and this is the only
* thing `Atmosphere` needs to know about scale. The fog it computes from
* that is then thrown away and replaced by `officeDaylight`, because a fog
* sized for a fifty-metre room is a fog inside the room.
*/
officeAtmosphere = pack.site
? createAtmosphere({
lng: pack.site.lng,
metresPerUnit: 1,
/**
* No marine layer, deliberately — unlike the city, which gets one.
*
* The model is a fact about sea level on this coast, and both of these
* buildings look *out over* it: one from 188 m up a tower, one across
* an estuary. Handing it to the office put the observer inside the fog
* it describes, which greyed the sky to near-white at one in the
* afternoon and took the view with it. The city keeps its fog; the
* rooms that look at the city do not stand in it.
*/
marineLayer: null,
})
: null;
office = createOfficeScene(pack, {
dom: city.stage.renderer.domElement,
background: 0x11161c,
// Only when there is no sky to put behind it. A sited office computes a
// gradient and a horizon; painting the old flat colour over that is the
// bug that looks exactly like the sky not working.
...(pack.site ? {} : { background: 0x11161c }),
...(pack.site ? { lighting: officeLighting(pack.site) } : {}),
...(pack.site ? { horizon: { drop: pack.site.elevation } } : {}),
depth,
materials,
// Ignored entirely at `"public"` depth, where no layer is built to colour.
@@ -870,13 +981,18 @@ async function loadOffice(): Promise<{
// furniture catalogue this chunk exists to hold back — asking for it here
// costs nothing beyond the module itself, and asking for it anywhere else
// would cost the whole catalogue in the entry chunk.
const entry = OFFICES.find((o) => o.id === officeId) ?? OFFICES[0];
if (!entry) return null;
const [interiors, pack, assets, plan] = await Promise.all([
import("./interiors/officeScene.ts"),
import("./offices/lumbridge-hq.ts"),
entry.load(),
import("./assets/materials.ts"),
import("./engine/officeMinimap.ts"),
]);
officePack ??= pack.default;
// Assigned rather than memoised with `??=`: the memo was what made this
// single-office forever, quietly serving the first pack fetched for every
// later request whatever id was asked for.
officePack = pack.default;
officeMaterials ??= new assets.MaterialRegistry({ quality: "high" });
return {
createOfficeScene: interiors.createOfficeScene,
@@ -969,23 +1085,90 @@ function showDetail(text: string | null) {
body.textContent = text ?? "";
}
/**
* The two-button strip above the legend: cities outside, buildings inside.
*
* One control that answers "which of these am I in", pointed at whichever list
* is currently the answer. A second, separate office strip was the obvious
* alternative and is worse: it would sit dead and greyed out for the entire time
* anybody is looking at the city, which is most of the time.
*/
function renderCityPicker() {
if (!cityNav) return;
cityNav.replaceChildren();
for (const c of CITIES) {
const entries = inside
? OFFICES.map((o) => ({ id: o.id, label: o.label, active: o.id === officeId }))
: CITIES.map((c) => ({ id: c.id, label: c.label, active: c.id === cityId }));
for (const entry of entries) {
const b = document.createElement("button");
// `aria-pressed` rather than a class, because that is what these are: two
// buttons of which exactly one is on. The stylesheet keys off the attribute
// so the visual state and the announced state cannot drift apart.
b.className = "city";
b.type = "button";
b.setAttribute("aria-pressed", String(c.id === cityId && !inside));
b.textContent = c.label;
b.addEventListener("click", () => switchCity(c.id));
b.setAttribute("aria-pressed", String(entry.active));
b.textContent = entry.label;
b.addEventListener("click", () => {
if (inside) void switchOffice(entry.id);
else switchCity(entry.id);
});
cityNav.append(b);
}
}
/**
* Walk out of one building and into another.
*
* A full teardown and rebuild rather than a swap, because everything an office
* scene holds is derived from its pack: the shell, the plan panel, the camera
* limits, the horizon drop and the light rig. The expensive part — the texture
* registry — is deliberately *not* rebuilt, which is the same trick that makes
* signing in cheap: `officeMaterials` outlives every scene that borrows it, so
* a switch costs geometry and not the thing that draws the wood grain.
*/
async function switchOffice(id: string) {
// `entering` is the same guard `toggleOffice` uses, and this has to share it.
// Without it, two clicks inside the loading window build two office scenes and
// the first is parked on the stage with nothing holding a reference to it —
// a whole `OfficeScene`, its geometry and its plan panel, leaked per click.
if (entering || id === officeId || !OFFICES.some((o) => o.id === id)) return;
const previous = officeId;
officeId = id;
// The roster belongs to the building you have left.
stopWatchingOccupancy();
officePlan?.dispose();
officePlan = null;
office?.dispose();
office = null;
officeAtmosphere = null;
entering = true;
try {
await building("Opening the door…", () => enterOffice());
} finally {
entering = false;
}
/**
* The old room is already gone by the time we find out whether the new one
* arrived, and a failed chunk fetch is a real case — `loadOffice` says so on
* the card and returns null.
*
* So there is no room on the stage and `inside` still claims there is. Put the
* viewer back in the city rather than in an empty scene, and put the door back
* on the building they came from, or the picker is wedged pointing at an
* office that will not open.
*/
if (office || !city) return;
officeId = previous;
inside = false;
city.stage.setScene(city.stageScene);
showPlan();
renderLegend();
}
/** One legend for both places — a city chapter and an office viewpoint are both `View`s. */
function renderLegend() {
renderCityPicker();