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
+149 -4
View File
@@ -88,6 +88,27 @@ import type { Office, Point2, Presence, Viewpoint } from "./types.ts";
// the resolver. `Plan` is where depth is *applied*; this is where it is chosen.
export type { Depth } from "./plan.ts";
/**
* How wide the horizon plane is, in metres.
*
* Twelve kilometres across, which is far enough that the fog has long since
* saturated before its edge — so the plane never ends anywhere you can see, and
* the far plane never has to be honest about where the ground stops.
*/
const HORIZON_EXTENT = 12_000;
/**
* How much darker the ground is than the air in front of it.
*
* There has to be *some* difference or there is no horizon: paint the ground the
* fog colour exactly and the two meet invisibly, which at noon is a white void
* with a building in it. Half is enough to read as land under sky at every hour
* without ever reading as a painted floor — and because the fog then blends the
* two with distance, the line lands where the haze runs out rather than at an
* arbitrary radius.
*/
const HORIZON_DARKEN = 0.5;
export interface OfficeSceneOptions {
/**
* The renderer's canvas. Orbit input and pointer coordinates are read against
@@ -137,8 +158,37 @@ export interface OfficeSceneOptions {
* there, and this one says only that there is a there.
*/
onPlacePick?: (place: Pin | null) => void;
/** Overrides the fixed interior rig. Must carry `sky: null` and `fog: null`. */
/**
* Overrides the fixed interior rig.
*
* It used to have to carry `sky: null` and `fog: null`, because a room has
* walls and no horizon. That is still true of a room with no `site` — but a
* pack that says where it stands gets a real sun, a sky behind the glazing and
* a fog that starts outside the building. See `horizon` below, and CONTRACT.md
* §4, which anticipated exactly this and called it a later refinement.
*
* A `fog` whose `near` is inside the building will fog the building. That is
* the one way to get this badly wrong, and it is the caller's job not to,
* because only the caller knows the scale it is working in.
*/
lighting?: LightingState;
/**
* Put the ground back, this many metres below the level-0 floor.
*
* Absent, and the office floats in a flat colour exactly as it always has.
* Present, and the scene gets one very large horizontal plane at `-drop` and
* the building reads as being *up* — which, for a floor plate two hundred
* metres in the air, is most of the point of siting it at all.
*
* One plane, not a city. A cropped piece of the real terrain was the obvious
* alternative and is a much bigger thing: `blocks.ts` bakes its lot size in
* scene units at the city's ~94 m per unit, so a crop cannot simply be
* rebuilt at an office's 1 m per unit — it has to be built at city scale and
* then scaled into the room, which is a project rather than a detail. A plane
* plus honest fog gets the horizon, the haze and the sense of height, and
* those are the three things you actually feel.
*/
horizon?: { drop: number };
/** Defaults to false — the lid comes off, because that is the whole view. */
showCeilings?: boolean;
/** Fade the walls you are looking through. Defaults to true. */
@@ -207,12 +257,29 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
// tuning by hand per pack.
const span = Math.max(plan.bounds.width, plan.bounds.depth, 8);
/**
* The depth range, which the horizon changes and nothing else does.
*
* A room needs 5 cm to 300 m. A room with fifteen kilometres of ground under
* it needs the far plane out past the ground — and a 0.05 m near plane against
* an 8 km far plane is a depth ratio of 160,000, which spends the whole buffer
* on the first metre and z-fights every contact shadow in the building.
*
* So the near plane moves with the far one. 0.2 m is still well inside
* `minDistance` (1.2 m), so nothing the camera can actually reach is clipped,
* and the ratio comes back to 40,000 — which a 24-bit buffer holds without
* complaint. The fog saturates a long way before the plane's edge, so the far
* plane never has to be honest about where the ground stops.
*/
const far = options.horizon ? HORIZON_EXTENT * 0.7 : 300;
const near = options.horizon ? 0.2 : 0.05;
const kit = createSceneKit({
scene,
dom: options.dom,
fov: 50,
near: 0.05,
far: 300,
near,
far,
minDistance: 1.2,
maxDistance: span * 1.8,
// Just short of horizontal, so the camera cannot get under the floor slab
@@ -233,7 +300,16 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
});
kit.applyLighting(options.lighting ?? officeInterior());
if (options.background !== null) {
/**
* The sky wins over the flat colour when there is one.
*
* `applyLighting` writes `scene.background` itself when the state carries a
* non-null `sky`, so setting a colour here afterwards would overwrite the
* gradient it just built — the office would compute a sky and then paint over
* it, which is a bug that looks exactly like the sky not working.
*/
const hasSky = (options.lighting ?? officeInterior()).sky !== null;
if (options.background !== null && !hasSky) {
// A room has walls and no horizon, so nothing here computes a sky
// (CONTRACT.md §4) — but with the ceilings off you are looking at the
// building from outside it, and the outside cannot be nothing. One flat
@@ -245,6 +321,70 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
: new THREE.Color(materials.palette.floorSlab).multiplyScalar(0.45);
}
/**
* The ground, a long way down.
*
* Deliberately vast and deliberately plain. Its job is to end the sky in a
* horizon line and to give the eye something that is obviously *below* the
* floor you are standing on; anything more detailed at this distance is
* detail the fog eats before it reaches the camera.
*
* `MeshBasicMaterial` rather than a lit one, because a plane this size lit by
* a directional sun bands horribly across its own width, and because what it
* should read as is the far ground already washed out by fifteen kilometres of
* air — which is a fog colour, not a surface colour. The fog does the work.
*/
let horizonPlane: THREE.Mesh | null = null;
if (options.horizon) {
const geometry = new THREE.PlaneGeometry(HORIZON_EXTENT, HORIZON_EXTENT);
geometry.rotateX(-Math.PI / 2);
const material = new THREE.MeshBasicMaterial({
// Recoloured on every `setLighting` — see `paintHorizon`. The value here is
// only what it looks like for the one frame before the first rig lands.
color: new THREE.Color(materials.palette.floorSlab).multiplyScalar(0.5),
// The one thing it must do: take the fog, so it fades into the sky at the
// horizon instead of ending in a hard edge halfway up the frame.
fog: true,
depthWrite: true,
});
horizonPlane = new THREE.Mesh(geometry, material);
horizonPlane.name = "horizon";
horizonPlane.position.y = -options.horizon.drop;
// Nothing casts onto it and it receives nothing — it is scenery, and a
// shadow map stretched over fifteen kilometres would resolve nothing anyway.
horizonPlane.receiveShadow = false;
horizonPlane.castShadow = false;
// Its bounding sphere is enormous and always in view; testing it every frame
// is pure cost.
horizonPlane.frustumCulled = false;
scene.add(horizonPlane);
}
/**
* Keep the ground the colour of the air in front of it.
*
* The ground below a tower is not a surface you see, it is fifteen kilometres
* of atmosphere you see *through*, and the colour of that is the fog colour —
* which tracks the clock, so this has to be repainted rather than picked once.
*
* It was picked once, from the floor slab, and the result was the bug this
* exists to fix: a pale concrete sheet twelve kilometres across, sitting 188 m
* below the camera and therefore **nearer than the fog begins**, so it arrived
* at full strength and filled the frame behind the building at midnight.
*
* Slightly darker than the fog rather than equal to it, so there is still a
* horizon: the ground reads as ground near the building and converges on the
* sky at the distance where the fog saturates, which is what distance actually
* looks like.
*/
function paintHorizon(state: LightingState) {
if (!horizonPlane) return;
const material = horizonPlane.material as THREE.MeshBasicMaterial;
const source = state.fog?.color ?? state.hemisphere.ground;
material.color.setHex(source).multiplyScalar(HORIZON_DARKEN);
}
paintHorizon(options.lighting ?? officeInterior());
const shell: Shell = createShell(plan, { materials });
const furnishings: Furnishings = createFurnishings(plan, {
materials,
@@ -534,6 +674,7 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
},
setLighting(state) {
kit.applyLighting(state);
paintHorizon(state);
},
// Stepping back out to the city should retire the hover with it, or the
// detail card for whoever the pointer was over survives the journey.
@@ -544,6 +685,10 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
updateOcclusion();
},
dispose() {
if (horizonPlane) {
horizonPlane.geometry.dispose();
(horizonPlane.material as THREE.Material).dispose();
}
if (disposed) return;
disposed = true;
presence?.dispose();