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