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:
+191
-8
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user