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
+123
View File
@@ -0,0 +1,123 @@
/**
* Real daylight for a building that knows where it stands.
*
* CONTRACT.md §4 gives lighting one owner — `Atmosphere` — and says an office
* gets a fixed rig instead, with daylight through the windows named as "a later
* refinement, not a v1 coupling". This is that refinement, and it is written to
* keep the rule it is extending: **nothing here computes light.** `Atmosphere`
* still owns that. This takes the `LightingState` it produced for a place on the
* earth and answers the two questions an interior asks that a city never does.
*
* ### One: which way is the building pointing
*
* `Atmosphere` works in the city's frame, where Z is true north because a city
* pack is a map. An office is a *building*, and buildings are rotated to face
* streets. `OfficeSite.heading` is the compass bearing the pack's Z actually
* points along, and until the sun is turned by it, "the daylight side" in a
* pack's comments is a label rather than a fact — the light would come through
* whichever wall the author happened to draw at the top of the page.
*
* ### Two: where does the weather start
*
* A city's fog begins a kilometre away and that is fine, because a city is
* ninety kilometres across. An office is fifty metres across, and the same fog
* would sit *inside the room*, greying out the far wall and the people at it.
* So the colour is kept and the distances are replaced: clear air out to the
* building's own scale, haze beyond it, saturated long before the horizon plane
* ends. That is what turns a flat backdrop into a view.
*
* A pack with no `site` never reaches this file and keeps the fixed rig, which
* is the promise the format makes: you can author a floor plan without owning a
* coordinate.
*/
import type { LightingState } from "../engine/types.ts";
import type { OfficeSite } from "./types.ts";
/**
* Where the clear air ends and the haze begins, in metres from the camera.
*
* It has to clear the whole *camera orbit*, and the orbit is centred on an
* authored viewpoint's target rather than on the middle of the building. The
* camera pulls back to 1.8 spans — 97 m for the 54 m hangar — and from an
* off-centre target the far roof corner is another fifty or so beyond that, so
* the real worst case is about 149 m rather than the 110 m the building's width
* alone suggests. 150 m clears it, and clears the tower pack's 137 m with room
* to spare.
*
* Panning moves the target, so no finite number is a guarantee. It does not need
* to be: the fog ramps at 0.025 % per metre, so being a few metres inside it is
* imperceptible rather than a visible grey wall.
*/
const FOG_NEAR_M = 150;
/**
* Where haze becomes total. Well inside `HORIZON_EXTENT` in `officeScene.ts`, on
* purpose: the ground plane has to reach full fog colour before its own edge, or
* the horizon ends in a visible seam rather than in distance.
*/
const FOG_FAR_M = 4200;
/**
* Turn a city-frame lighting state into an office-frame one.
*
* The input is whatever `Atmosphere.apply()` produced for `site.lat/lng` at the
* instant being rendered. The output differs in exactly two ways — the sun is
* rotated into the building's frame, and the fog is moved outdoors — and is
* otherwise the same object's values, because everything else `Atmosphere`
* decided is as true inside a building as outside one.
*/
export function officeDaylight(state: LightingState, site: OfficeSite): LightingState {
const fog =
state.fog === null ? null : { color: state.fog.color, near: FOG_NEAR_M, far: FOG_FAR_M };
return {
...state,
sun: { ...state.sun, direction: intoBuildingFrame(state.sun.direction, site.heading) },
fog,
/**
* The sky's horizon stop is pinned to the fog colour, which is what makes
* the horizon a horizon instead of a seam.
*
* The sky is a **screen-space** gradient: `applyLighting` paints it top to
* bottom of the viewport, and it does not tilt with the camera. The ground
* plane, meanwhile, converges on the fog colour at the distance the fog
* saturates. So the two meet at whatever screen row the world horizon
* happens to fall on — which moves every time you orbit — and unless the
* colours they meet with are the same, that line is a visible step.
*
* Matching them makes the join invisible wherever it lands, with no
* per-frame work and no second piece of geometry. It costs the sky a little
* of the atmosphere's chosen horizon tint; a step across the middle of the
* frame costs more.
*/
sky: state.sky === null || fog === null ? state.sky : { ...state.sky, horizon: fog.color },
};
}
/**
* Rotate a world-frame direction into the building's frame.
*
* The world frame is the city's: **Z is true north, +X is east**. The building
* frame is the pack's, whose Z points along the compass bearing `heading`.
*
* Both frames measure a bearing as `atan2(x, z)`, so a direction at world
* bearing `B` is at building bearing `B heading`, and expanding
* `sin(B h)` and `cos(B h)` gives the two lines below. It is a rotation of
* `heading` about +Y, written out rather than delegated to a `Vector3` because
* this module deliberately imports no three.js — the same reason `plan.ts`
* imports none, and what keeps it testable without a GL context.
*
* `y` is untouched: rotating about the vertical cannot change how high the sun
* is, only where on the compass it sits.
*/
export function intoBuildingFrame(
direction: [number, number, number],
heading: number,
): [number, number, number] {
const [x, y, z] = direction;
const h = (heading * Math.PI) / 180;
const cos = Math.cos(h);
const sin = Math.sin(h);
return [x * cos + z * sin, y, -x * sin + z * cos];
}