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];
}
+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();
+72 -3
View File
@@ -90,6 +90,38 @@ export interface Shell {
dispose(): void;
}
/**
* How far a slab is lifted per earlier slab it overlaps. See `liftOf`.
*
* 4 mm. Big enough to beat the depth buffer's resolution at office range — the
* near plane is 0.2 m and the camera orbits within about a hundred metres, so a
* 24-bit buffer resolves far finer than this — and small enough that a step
* between two floor finishes is not a step anybody can see or trip over.
*/
const SLAB_LIFT = 0.004;
/** Do two outlines' axis-aligned bounding boxes intersect? See `liftOf`. */
function boxesOverlap(a: readonly Point2[], b: readonly Point2[]): boolean {
const box = (points: readonly Point2[]) => {
let minX = Infinity;
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
for (const p of points) {
minX = Math.min(minX, p.x);
maxX = Math.max(maxX, p.x);
minZ = Math.min(minZ, p.z);
maxZ = Math.max(maxZ, p.z);
}
return { minX, maxX, minZ, maxZ };
};
const one = box(a);
const two = box(b);
// Touching edge-to-edge is not overlapping: the reference office's rooms abut
// along shared lines everywhere and must not all be lifted for it.
return one.minX < two.maxX && two.minX < one.maxX && one.minZ < two.maxZ && two.minZ < one.maxZ;
}
export function createShell(plan: Plan, options: ShellOptions): Shell {
const { materials } = options;
const parts = options.parts ?? sharedParts;
@@ -129,7 +161,7 @@ export function createShell(plan: Plan, options: ShellOptions): Shell {
buildWall(level.id, wallId, runs, holesByWall.get(wallId) ?? []);
}
for (const room of level.rooms) {
buildFloor(room);
buildFloor(room, liftOf(room, level.rooms));
buildCeiling(room);
}
if (drawOpenings) {
@@ -206,8 +238,45 @@ export function createShell(plan: Plan, options: ShellOptions): Shell {
}
}
function buildFloor(room: ResolvedRoom): void {
const geometry = slabGeometry(room.outline, room.y, true);
/**
* How far to lift a slab so it does not fight the ones it overlaps.
*
* The format explicitly permits overlapping rooms and resolves *later* ones
* first (`types.ts` on `Room.outline`, `Plan.roomAt`), so "a slab on top of
* another slab" is legal and is the natural way to author a hangar: one
* concrete floor with a carpeted meeting box and a timber galley laid on it.
* The reference office avoids it by notching every room around its neighbours,
* which works when the rooms tile the plate and cannot work at all when they
* are islands in the middle of it — a rectangle with holes in it is not a
* simple polygon.
*
* Two coplanar slabs at the same `y` is a z-fight, and which one wins is the
* GPU's business. So a room that overlaps earlier rooms is lifted by a hair
* per earlier room it overlaps, which makes the depth test agree with the
* ordering the format already documents.
*
* **A pack whose rooms do not overlap is lifted by nothing**, which is why
* this counts overlaps rather than simply using the room's index: indexing
* would raise the reference office's fifteenth room by a centimetre and a half
* for no reason at all.
*
* Bounding boxes rather than true polygon intersection, deliberately. It is
* conservative in the safe direction — two rooms whose boxes touch but whose
* outlines do not get a lift they did not need, which is invisible — and it is
* a handful of comparisons rather than a clipping library.
*/
function liftOf(room: ResolvedRoom, rooms: readonly ResolvedRoom[]): number {
let overlaps = 0;
for (const other of rooms) {
if (other === room) break;
if (Math.abs(other.y - room.y) > 1e-6) continue;
if (boxesOverlap(other.outline, room.outline)) overlaps += 1;
}
return overlaps * SLAB_LIFT;
}
function buildFloor(room: ResolvedRoom, lift: number): void {
const geometry = slabGeometry(room.outline, room.y + lift, true);
if (!geometry) return;
owned.push(geometry);
const mesh = new THREE.Mesh(geometry, materials.forSurface(room.floor, "carpet"));
+63
View File
@@ -184,9 +184,72 @@ export interface Office {
*/
viewpoints: Viewpoint[];
/**
* Where on the earth this building stands, if it stands anywhere.
*
* Optional, and its absence is a supported state rather than a gap: a pack
* with no `site` renders exactly as every pack did before this field existed,
* under the fixed interior rig. That matters because the format's promise is
* that you can author a floor plan without an account, a key or a coordinate.
*
* What it buys when you do supply it is the sun. `interiors/types.ts` used to
* say flatly that an office has no orientation on the earth, and that was
* true and also the thing standing between an office and real daylight: you
* cannot put the sun in the right place without knowing both where the
* building is and which way it faces.
*/
site?: OfficeSite;
meta?: OfficeMeta;
}
/**
* A building's address in the world, in the four numbers that change what you
* see out of the window.
*
* Deliberately not a street address. Nothing here is geocoded, nothing is looked
* up, and no network call can be made from any of it — see CONTRACT.md §8 for
* why a coordinate's provenance is a licensing question in this repo. These are
* numbers a pack author types, the same as every other number in a pack.
*/
export interface OfficeSite {
/** Degrees north. */
lat: number;
/** Degrees east. */
lng: number;
/**
* How far this pack's level-0 floor sits above **the ground outside**, in
* metres. Not above sea level.
*
* The renderer's question is "how high up am I", not "how tall is the tower",
* and this is the number that answers it: it is exactly where the horizon
* goes. An office on the 48th floor of a downtown tower is a couple of hundred
* metres here and a shed on reclaimed land is three, and that single
* difference is most of what makes the two feel like different places.
*/
elevation: number;
/**
* The compass bearing, in degrees clockwise from true north, that the pack's
* **Z direction** points along.
*
* `0` means the pack's "north" really is north, which is what the reference
* pack's comments have always assumed while being careful to say it was only a
* convenience. A building rotated to face the street sets this, and the sun
* then comes through the windows it actually comes through.
*
* Degrees clockwise from north, like `District.gridAngle` and unlike `Yaw` —
* a bearing reads better for a thing on a map, and radians-counter-clockwise
* reads better for a thing in a scene graph. The conversion happens once, at
* the point the two meet.
*/
heading: number;
/**
* What to call the place, for a caption. `null` or absent where the building
* has no name worth printing.
*/
label?: string;
}
/**
* Provenance for a pack, and nothing the renderer reads.
*