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
+22 -3
View File
@@ -154,9 +154,28 @@ constructed and mutated the same three lights.
LightingState`, which the scene applies. One direction, no write-backs. LightingState`, which the scene applies. One direction, no write-backs.
- Solar position is computed locally with **no network** — a NOAA/Meeus - Solar position is computed locally with **no network** — a NOAA/Meeus
implementation in `src/engine/solar.ts`, dependency-free. implementation in `src/engine/solar.ts`, dependency-free.
- An **office gets no Atmosphere**: `fog: null`, no `scene.background` drive, - An **office with no `site` gets no Atmosphere**: `fog: null`, no
interior lighting is its own fixed rig. Daylight through windows is a later `scene.background` drive, interior lighting is its own fixed rig. This is
refinement, not a v1 coupling. still the default and still the promise — a pack can be authored, rendered and
shared without owning a coordinate, an account or a network.
- **An office that declares a `site` gets the same sun the city does.** This is
the "later refinement" this clause reserved, taken up rather than a reversal of
it: `Atmosphere` is still the sole light owner and there is still one direction
of flow. `interiors/daylight.ts` adapts what `apply()` returned; it computes no
light of its own.
Two things are true only indoors, and they are the whole of the adapter:
- **The building is rotated.** `Atmosphere` works in the city's frame, where
Z is north because a city pack is a map. `OfficeSite.heading` is the bearing
the pack's Z actually points along, and the sun is turned by it — otherwise
"the daylight side" in a pack's comments is a label rather than a fact.
- **The fog starts outside.** A city fog beginning 1,150 units away is fine at
94 m per unit and is *inside the room* at 1 m per unit. The colour is kept
and the distances are replaced.
A sited office also gets a `sky` and a ground plane at `-site.elevation`, which
is what makes 188 m up a tower feel different from 4 m above an airfield.
## 5. One server ## 5. One server
+207 -11
View File
@@ -434,16 +434,54 @@ export interface FlightLayer {
* How many observations a trail remembers, and how long it may hold one. * How many observations a trail remembers, and how long it may hold one.
* *
* Both limits are needed. The count keeps the shared vertex buffer bounded, and * Both limits are needed. The count keeps the shared vertex buffer bounded, and
* the age keeps a slow feed from drawing a trail across the entire bay: at * the age keeps a slow feed from drawing a trail across the entire bay.
* `AdsbFlights`'s eight-second interval, twenty samples is nearly three minutes *
* of flying, which is most of a leg. * These were 20 and 45, which made a trail about twenty seconds long — enough to
* say "this thing is moving" and not enough to say where it came from. At 72
* points the simulator, polled at 1 Hz, draws about seventy seconds of flying:
* a quarter to a half of one of `sample.ts`'s legs, so an arrival trails a
* visible curve down the approach rather than a tick behind it.
*
* The count is what binds for a 1 Hz source; the age binds for a slow one. 240 s
* is four minutes, which at a live feed's 515 s refresh is 1648 samples — so
* a real ADS-B track fills a good part of the buffer without either limit
* cutting it short.
*
* The cost is bounded and was measured rather than guessed: the trail is one
* preallocated `LineSegments` and therefore one draw call at any length, and the
* buffer goes from 210 KiB to 756 KiB. What actually scales is the per-vertex
* work in `rebuildTrails`, which is why `writeTrailVertex` now uploads only the
* range it wrote instead of the whole array.
*/ */
const TRAIL_POINTS = 20; const TRAIL_POINTS = 72;
const TRAIL_SECONDS = 45; const TRAIL_SECONDS = 240;
/** Ceiling on tracks that get a trail, so the buffer can be allocated once. */ /**
* How far two positions may differ and still be "the same one repeated".
*
* Scene units. See `samePosition`, and the note on the repeat check in
* `update` for why a repeated observation must not become a sample.
*/
const SAME_POSITION_EPSILON = 1e-4;
/**
* Ceiling on tracks that get a trail, so the buffer can be allocated once.
*
* It is a real ceiling now. It was declared and then referenced only by the
* buffer sizing, so `tracks` grew without limit and `rebuildTrails` silently
* ran out of vertices — which mattered the moment the godmode dial could put
* four hundred aircraft in the sky. Tracks past this many still get a dart;
* what they do not get is a trail, which is the graceful half to drop.
*/
const MAX_TRACKS = 192; const MAX_TRACKS = 192;
/**
* How long a track survives not being in a snapshot before it is forgotten.
*
* Two refreshes of a slow live feed. See the note where it is used.
*/
const TRACK_GRACE_SECONDS = 32;
/** /**
* Opacity at the head of a trail, fading to nothing at the tail. Well under 1 * Opacity at the head of a trail, fading to nothing at the tail. Well under 1
* on purpose: the trail is context for the dart, not a second subject, and a * on purpose: the trail is context for the dart, not a second subject, and a
@@ -460,7 +498,14 @@ const TRAIL_ALPHA = 0.55;
* the real gap was four times that gives an aircraft that darts and then waits. * the real gap was four times that gives an aircraft that darts and then waits.
*/ */
const MIN_SPAN = 0.2; const MIN_SPAN = 0.2;
const MAX_SPAN = 15; /**
* 30 rather than 15, because the repeat check in `update` changed what this
* measures. It used to bound a poll interval; it now bounds the gap between two
* positions that actually differ, and for a live feed that gap *is* the server's
* cache TTL — 5 to 15 seconds, plus whatever the network adds. Clamping at 15
* would have made every slow refresh look like a dart-and-wait again.
*/
const MAX_SPAN = 30;
/** /**
* Above this, a step is a teleport rather than a flight. * Above this, a step is a teleport rather than a flight.
@@ -516,6 +561,16 @@ interface Track {
band: number; band: number;
/** Interpolated position, reused rather than reallocated every frame. */ /** Interpolated position, reused rather than reallocated every frame. */
head: THREE.Vector3; head: THREE.Vector3;
/**
* When this track first went missing from a snapshot, or `0` while it is
* present. See `TRACK_GRACE_SECONDS`.
*/
missingSince: number;
/**
* Missing, and finished moving toward wherever it was last seen — so no
* longer drawn, while its history is still held. See `tick`.
*/
stale: boolean;
/** Altitude at `head`, which is what the dart's colour is chosen from. */ /** Altitude at `head`, which is what the dart's colour is chosen from. */
headAltitude: number; headAltitude: number;
} }
@@ -629,11 +684,48 @@ export function createFlightLayer(world: World): FlightLayer {
band: -1, band: -1,
head: position.clone(), head: position.clone(),
headAltitude: a.altitude, headAltitude: a.altitude,
missingSince: 0,
stale: false,
}; };
tracks.set(a.id, track); tracks.set(a.id, track);
} }
const previous = track.samples[track.samples.length - 1]; const previous = track.samples[track.samples.length - 1];
/**
* A source repeating itself is not a new observation, and treating it as
* one is what stopped live traffic from ever moving.
*
* `HttpFlights` is polled at 1 Hz and holds a *frozen* snapshot between
* network refreshes, which the server caches for 515 s. So a live feed
* hands over the identical position five to fifteen times in a row and
* then jumps. Pushing each repeat as its own sample had two consequences,
* and both of them were visible on the deployed site:
*
* 1. `span` measured the poll interval (~1 s) rather than the gap between
* the two positions that actually differ (515 s). The teleport test
* below then saw an airliner covering 36 units in a "second" against a
* ceiling of 8 and **wiped the entire trail on every refresh** — so no
* live aircraft could ever grow a trail at all, however large
* `TRAIL_POINTS` was set.
* 2. The trail filled with a dozen coincident points, so the spine had no
* length and the dart sat still and then jumped.
*
* Skipping the repeat fixes both at once: `span` becomes the real gap, the
* jump test sees ~2.5 units/s and passes, and the interpolation in `tick`
* spreads the movement smoothly across the whole refresh interval.
*
* Compared on position rather than on an observation timestamp because a
* `FlightSource` is not required to carry one — `Aircraft` has no `at`
* field, and the two real sources disagree about whether they could
* supply one honestly.
*/
if (previous && samePosition(previous, sample)) {
// Nothing to record. The head keeps interpolating toward the newest
// distinct sample, which is what makes the motion continuous.
continue;
}
if (previous) { if (previous) {
// The clamp is load-bearing on both ends. Two polls arriving in the same // The clamp is load-bearing on both ends. Two polls arriving in the same
// millisecond — a manual refresh, a tab waking up — divide by nearly // millisecond — a manual refresh, a tab waking up — divide by nearly
@@ -666,8 +758,27 @@ export function createFlightLayer(world: World): FlightLayer {
trim(track, now); trim(track, now);
} }
/**
* An aircraft missing from one snapshot has not landed.
*
* This used to delete the track the instant an id was absent, which meant a
* single dropped target — an ADS-B receiver losing a line of sight for one
* refresh, which is routine — threw away its entire history and rebuilt it
* from nothing. At a 20-point trail that cost twenty seconds; at 72 it would
* cost over a minute, so the longer trails are what made this worth fixing
* rather than merely worth noticing.
*
* `ADSB_HOLD_SECONDS` covers the whole feed going dark. This covers one
* target going quiet inside an otherwise healthy snapshot, which is a
* different failure and needs a different answer.
*/
for (const [id, track] of tracks) { for (const [id, track] of tracks) {
if (seen.has(id)) continue; if (seen.has(id)) {
track.missingSince = 0;
continue;
}
if (track.missingSince === 0) track.missingSince = now;
if (now - track.missingSince < TRACK_GRACE_SECONDS) continue;
group.remove(track.mesh); group.remove(track.mesh);
tracks.delete(id); tracks.delete(id);
} }
@@ -675,6 +786,22 @@ export function createFlightLayer(world: World): FlightLayer {
tick(); tick();
} }
/**
* Whether a feed has handed back the same position it did last time.
*
* Scene units, and the tolerance is deliberately tiny: this is asking "did the
* source repeat itself", not "did it move much". A genuinely stationary
* aircraft on a taxiway still reports jitter well above this, and if it did
* not, an aircraft that is not moving has nothing to draw a trail from anyway.
*/
function samePosition(a: TrailSample, b: TrailSample): boolean {
return (
Math.abs(a.position.x - b.position.x) < SAME_POSITION_EPSILON &&
Math.abs(a.position.z - b.position.z) < SAME_POSITION_EPSILON &&
Math.abs(a.altitude - b.altitude) < 1
);
}
/** Forget history that is too old or too long to be worth drawing. */ /** Forget history that is too old or too long to be worth drawing. */
function trim(track: Track, now: number) { function trim(track: Track, now: number) {
while (track.samples.length > TRAIL_POINTS) track.samples.shift(); while (track.samples.length > TRAIL_POINTS) track.samples.shift();
@@ -696,6 +823,26 @@ export function createFlightLayer(world: World): FlightLayer {
const from = track.samples[n - 2] ?? to; const from = track.samples[n - 2] ?? to;
const alpha = from === to ? 1 : clamp((now - to.at) / track.span, 0, 1); const alpha = from === to ? 1 : clamp((now - to.at) / track.span, 0, 1);
/**
* A missing aircraft stops being drawn once it has finished arriving.
*
* The grace period holds a track's *history* across a dropped refresh,
* which is the point of it — but holding the history is not the same as
* going on drawing the aeroplane. Left drawn, a target that genuinely
* left the feed froze in mid-air at full opacity with its whole trail
* attached, for the full thirty-two seconds, indistinguishable from an
* aircraft that had stopped flying. The godmode dial made it unmissable:
* turning 400 fabricated aircraft down to zero left 400 darts hanging.
*
* Gated on having run out of interpolation rather than simply on being
* missing, so a target absent for one refresh keeps moving to where it was
* last seen and never blinks — and if it comes back, it resumes its trail
* instead of rebuilding it.
*/
track.stale = track.missingSince !== 0 && now - to.at > track.span;
track.mesh.visible = !track.stale;
if (track.stale) continue;
track.head.lerpVectors(from.position, to.position, alpha); track.head.lerpVectors(from.position, to.position, alpha);
track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha; track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha;
@@ -728,12 +875,39 @@ export function createFlightLayer(world: World): FlightLayer {
*/ */
function rebuildTrails() { function rebuildTrails() {
let vertex = 0; let vertex = 0;
let drawn = 0;
for (const track of tracks.values()) { for (const track of tracks.values()) {
// Past the ceiling, an aircraft keeps its dart and loses its trail. The
// buffer was sized for this many and the constant meant nothing until now.
if (drawn >= MAX_TRACKS) break;
// Before `drawn`, so an expiring ghost does not hold a trail slot ahead of
// a genuinely new arrival — `tracks` is walked in insertion order, and the
// ghosts are the oldest entries in it.
if (track.stale) continue;
const spine = track.samples.length - 1; const spine = track.samples.length - 1;
if (spine < 1) continue; if (spine < 1) continue;
drawn += 1;
const points = spine + 1; // the spine, plus the head const points = spine + 1; // the spine, plus the head
for (let i = 1; i < points; i++) { /**
* Where to start drawing this track, so that a buffer that cannot hold
* everything loses the **oldest** segments rather than the newest.
*
* The loop writes tail-first, so the previous `break`-when-full dropped
* the segments nearest the aircraft. That is the worst possible end to
* lose: it left a streak floating in open air with no aeroplane attached
* to it, which reads as a rendering fault rather than as a shortened
* trail. It was invisible at 78 aircraft and unmissable the moment the
* godmode dial put four hundred in the sky.
*
* Clamping the start index instead means a crowded sky draws shorter
* trails, every one of them still joined to its dart.
*/
const budget = Math.max(0, (maxVertices - vertex) / 2);
if (budget < 1) break;
const first = Math.max(1, points - Math.floor(budget));
for (let i = first; i < points; i++) {
if (vertex + 2 > maxVertices) break; if (vertex + 2 > maxVertices) break;
const a = track.samples[i - 1]; const a = track.samples[i - 1];
if (!a) continue; if (!a) continue;
@@ -749,8 +923,30 @@ export function createFlightLayer(world: World): FlightLayer {
} }
} }
trailGeo.setDrawRange(0, vertex); trailGeo.setDrawRange(0, vertex);
trailGeo.attributes.position!.needsUpdate = true;
trailGeo.attributes.color!.needsUpdate = true; /**
* Upload only the vertices actually written this frame.
*
* `needsUpdate` alone re-sends the entire `Float32Array` — three.js takes an
* empty update range to mean "all of it" — which was 12.9 MB/s at 60 Hz with
* a 20-point trail and would have been 46 MB/s at 72 for a scene that
* normally holds seven aircraft and writes under 2% of the buffer. The rest
* of the array is stale data nothing draws, because `setDrawRange` already
* bounds what is read.
*
* `clearUpdateRanges` first, or the ranges accumulate frame on frame and the
* saving disappears within a second.
*/
const positionAttr = trailGeo.attributes.position as THREE.BufferAttribute;
const colorAttr = trailGeo.attributes.color as THREE.BufferAttribute;
positionAttr.clearUpdateRanges();
colorAttr.clearUpdateRanges();
if (vertex > 0) {
positionAttr.addUpdateRange(0, vertex * 3);
colorAttr.addUpdateRange(0, vertex * 4);
positionAttr.needsUpdate = true;
colorAttr.needsUpdate = true;
}
} }
function writeTrailVertex(index: number, position: THREE.Vector3, altitude: number, fade: number) { function writeTrailVertex(index: number, position: THREE.Vector3, altitude: number, fade: number) {
+40 -13
View File
@@ -309,21 +309,37 @@ export interface SatelliteLayer {
} }
/** /**
* The dome's radius, as a fraction of the board's longest side. * The dome's radius, as a fraction of **how far the board reaches from the scene
* origin** — `boardRadius` in `scene.ts`, not the board's width.
* *
* Three constraints, and the middle one is the one that is easy to miss: * That distinction was got wrong once and is worth stating plainly. Scene space
* is centred on `city.center`, and the Bay Area board runs forty kilometres down
* the peninsula from there, so its furthest corner is 0.94 spans from the origin
* while its half-diagonal is 0.65. A dome sized at 0.7 *spans* therefore sat
* **inside the southern third of its own city** — satellites rendering below the
* terrain and behind the hills, which looks like a depth bug and is a units bug.
* *
* 1. Outside the city, or the buildings occlude the sky. * **You are meant to be able to get above this.** That is a deliberate reversal:
* 2. **Outside the camera's maximum orbit**, which `scene.ts` sets at * an earlier version put the dome at 2.0 spans specifically so the camera could
* `boardSpan * 1.5`. This was 1.2 first, which put the far end of the zoom * never leave it, on the theory that a sky you can step outside of is not a sky.
* *outside the dome* — pull back far enough and you were looking at the sky * That theory loses to the thing people actually want to look at — a
* from space, with half the constellation behind the camera. A sky you can * constellation is an object, and the view of it from above, with the city
* leave is not a sky. * underneath, is the shot. Standing inside a sphere of dots is a planetarium.
* 3. Inside the far plane, which `scene.ts` sets at `boardSpan * 3`.
* *
* 2.0 sits in the middle of the only window that satisfies all three. * So the geometry is worked out from the other end. The board's half-diagonal is
* 0.649 spans for San Francisco and 0.635 for the Southland, so 0.70 clears
* every corner of the city while sitting well inside the camera's new 2.0-span
* orbit. At the far end of the zoom the whole dome is in frame at this fov and
* the board still fills about two thirds of the screen height.
*
* Two bugs died with the old number, and both were invisible from the default
* pose. At 2.0 the dome reached 3.5 spans from the far side of the orbit against
* a far plane at 3.0, so roughly a sixth of the sky was being **clipped**; and
* everything past 2.8 spans was **fully fogged** by the city's own linear fog.
* The band where the constellation was both unclipped and unfogged did not
* overlap the band where it fitted on screen at all.
*/ */
const DOME_RADIUS_FACTOR = 2.0; const DOME_RADIUS_FACTOR = 1.05;
/** /**
* How large a dot is drawn, in **pixels**, at any camera distance. * How large a dot is drawn, in **pixels**, at any camera distance.
@@ -386,11 +402,11 @@ const HORIZON_FADE_DEG = 8;
*/ */
const SHADOW_ALPHA = 0.16; const SHADOW_ALPHA = 0.16;
export function createSatelliteLayer(boardSpan: number): SatelliteLayer { export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
const group = new THREE.Group(); const group = new THREE.Group();
group.name = "satellites"; group.name = "satellites";
const radius = boardSpan * DOME_RADIUS_FACTOR; const radius = boardRadius * DOME_RADIUS_FACTOR;
const positions = new Float32Array(MAX_DOTS * 3); const positions = new Float32Array(MAX_DOTS * 3);
const colors = new Float32Array(MAX_DOTS * 4); const colors = new Float32Array(MAX_DOTS * 4);
@@ -418,6 +434,17 @@ export function createSatelliteLayer(boardSpan: number): SatelliteLayer {
// and additive blending is what makes a lit satellite read as a light source // and additive blending is what makes a lit satellite read as a light source
// rather than as a grey sticker. // rather than as a grey sticker.
blending: THREE.AdditiveBlending, blending: THREE.AdditiveBlending,
/**
* Satellites are not in the weather.
*
* `PointsMaterial` defaults `fog: true`, and the city runs a linear fog
* whose far plane is 2.8 board spans — so every dot was being mixed toward
* the fog colour by distance, and the constellation dimmed as the camera
* pulled back, exactly when more of it came into view. Haze is a property of
* the twelve kilometres of air a city sits in; an object 550 km up is on the
* far side of all of it.
*/
fog: false,
}); });
const points = new THREE.Points(geo, material); const points = new THREE.Points(geo, material);
+48 -3
View File
@@ -194,14 +194,59 @@ export async function createScene(
const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng); const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng);
const boardSpan = Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ)); const boardSpan = Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ));
/**
* How far the board reaches **from the scene origin**, which is not the same
* as how big it is.
*
* Scene space is centred on `city.center` — the city — and the Bay Area board
* runs forty kilometres down the peninsula from there, so the origin is
* nowhere near the middle of it. The furthest corner is 0.94 spans out where
* the half-diagonal is only 0.65, and anything sized off the half-diagonal is
* therefore too small by half.
*
* The satellite dome is centred on the origin, because that is where its look
* angles were computed for, so this is the radius it has to clear.
*/
const boardRadius = Math.max(
Math.hypot(westX, northZ),
Math.hypot(eastX, northZ),
Math.hypot(westX, southZ),
Math.hypot(eastX, southZ),
);
const kit = createSceneKit({ const kit = createSceneKit({
scene, scene,
dom: stage.renderer.domElement, dom: stage.renderer.domElement,
fov: 42, fov: 42,
near: 0.1, near: 0.1,
far: boardSpan * 3, /**
* Everything, at the worst pose, plus room.
*
* The furthest thing from the camera is the back of the satellite dome seen
* from a chapter at the far end of the board: `maxChapterOffset` (0.94
* spans) + `maxDistance` (2.0) + the dome radius (about 0.99) — call it 3.9.
* At 3.0 the sky was being clipped from any off-centre chapter, which is
* most of them.
*
* It also now sits at the fog's far plane, which is the honest statement of
* the invariant: nothing should be cut off before it has fully faded out.
*/
far: boardSpan * 4,
minDistance: Math.max(4, boardSpan * 0.02), minDistance: Math.max(4, boardSpan * 0.02),
maxDistance: boardSpan * 1.5, /**
* Far enough out to be **above the satellites**, which is what the extra
* half-span buys.
*
* The satellite dome is at 0.7 spans (`engine/satellites.ts`), so at 1.5 the
* camera was always inside the constellation and could only ever look up
* through it. At 2.0 the far end of the zoom is outside it looking down, with
* the whole dome in frame at this field of view and the board still filling
* about two thirds of the screen.
*
* The far plane already covers it: the furthest thing from the camera is then
* the back of the dome at 2.7 spans, against `far` at 3.0.
*/
maxDistance: boardSpan * 2.0,
shadowExtent: boardSpan * 0.75, shadowExtent: boardSpan * 0.75,
shadowFar: boardSpan * 2.2, shadowFar: boardSpan * 2.2,
}); });
@@ -235,7 +280,7 @@ export async function createScene(
let satelliteLayer: SatelliteLayer | null = null; let satelliteLayer: SatelliteLayer | null = null;
if (options.satellites) { if (options.satellites) {
satelliteLayer = createSatelliteLayer(boardSpan); satelliteLayer = createSatelliteLayer(boardRadius);
scene.add(satelliteLayer.group); scene.add(satelliteLayer.group);
} }
+17
View File
@@ -246,6 +246,23 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
sun.shadow.camera.top = extent; sun.shadow.camera.top = extent;
sun.shadow.camera.bottom = -extent; sun.shadow.camera.bottom = -extent;
sun.shadow.bias = options.shadowBias ?? -0.0012; sun.shadow.bias = options.shadowBias ?? -0.0012;
/**
* Without this line, none of the six numbers above exist.
*
* `OrthographicCamera` bakes its frustum into a projection matrix in the
* constructor, and mutating `.left`/`.right`/`.near`/`.far` afterwards does
* nothing until the matrix is rebuilt. three.js rebuilds it for a *spot*
* light's shadow (`SpotLightShadow.updateMatrices` recomputes when the fov or
* far changes) and **not** for a directional light's — `LightShadow.
* updateMatrices` only does `lookAt` and `updateMatrixWorld`.
*
* So every caller here was configuring a shadow camera that was still
* three's default `(-5, 5, 5, -5, 0.5, 500)`: a ten-unit box. The city asks
* for `shadowExtent: boardSpan * 0.75`, which is ±752 units on the Bay Area
* board, and got ±5 — a shadow map covering a square about the size of one
* house, somewhere near the origin. The office asks for ±34 m and got ±5 m.
*/
sun.shadow.camera.updateProjectionMatrix();
const hemisphere = new THREE.HemisphereLight(0xffffff, 0x808080, 1); const hemisphere = new THREE.HemisphereLight(0xffffff, 0x808080, 1);
const ambient = new THREE.AmbientLight(0xffffff, 0.3); const ambient = new THREE.AmbientLight(0xffffff, 0.3);
scene.add(sun, hemisphere, ambient); scene.add(sun, hemisphere, ambient);
+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. // the resolver. `Plan` is where depth is *applied*; this is where it is chosen.
export type { Depth } from "./plan.ts"; 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 { export interface OfficeSceneOptions {
/** /**
* The renderer's canvas. Orbit input and pointer coordinates are read against * 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. * there, and this one says only that there is a there.
*/ */
onPlacePick?: (place: Pin | null) => void; 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; 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. */ /** Defaults to false — the lid comes off, because that is the whole view. */
showCeilings?: boolean; showCeilings?: boolean;
/** Fade the walls you are looking through. Defaults to true. */ /** 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. // tuning by hand per pack.
const span = Math.max(plan.bounds.width, plan.bounds.depth, 8); 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({ const kit = createSceneKit({
scene, scene,
dom: options.dom, dom: options.dom,
fov: 50, fov: 50,
near: 0.05, near,
far: 300, far,
minDistance: 1.2, minDistance: 1.2,
maxDistance: span * 1.8, maxDistance: span * 1.8,
// Just short of horizontal, so the camera cannot get under the floor slab // 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()); 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 // 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 // (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 // 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); : 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 shell: Shell = createShell(plan, { materials });
const furnishings: Furnishings = createFurnishings(plan, { const furnishings: Furnishings = createFurnishings(plan, {
materials, materials,
@@ -534,6 +674,7 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
}, },
setLighting(state) { setLighting(state) {
kit.applyLighting(state); kit.applyLighting(state);
paintHorizon(state);
}, },
// Stepping back out to the city should retire the hover with it, or the // 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. // detail card for whoever the pointer was over survives the journey.
@@ -544,6 +685,10 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
updateOcclusion(); updateOcclusion();
}, },
dispose() { dispose() {
if (horizonPlane) {
horizonPlane.geometry.dispose();
(horizonPlane.material as THREE.Material).dispose();
}
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
presence?.dispose(); presence?.dispose();
+72 -3
View File
@@ -90,6 +90,38 @@ export interface Shell {
dispose(): void; 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 { export function createShell(plan: Plan, options: ShellOptions): Shell {
const { materials } = options; const { materials } = options;
const parts = options.parts ?? sharedParts; 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) ?? []); buildWall(level.id, wallId, runs, holesByWall.get(wallId) ?? []);
} }
for (const room of level.rooms) { for (const room of level.rooms) {
buildFloor(room); buildFloor(room, liftOf(room, level.rooms));
buildCeiling(room); buildCeiling(room);
} }
if (drawOpenings) { 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; if (!geometry) return;
owned.push(geometry); owned.push(geometry);
const mesh = new THREE.Mesh(geometry, materials.forSurface(room.floor, "carpet")); const mesh = new THREE.Mesh(geometry, materials.forSurface(room.floor, "carpet"));
+63
View File
@@ -184,9 +184,72 @@ export interface Office {
*/ */
viewpoints: Viewpoint[]; 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; 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. * Provenance for a pack, and nothing the renderer reads.
* *
+191 -8
View File
@@ -16,8 +16,10 @@ import {
createAtmosphere, createAtmosphere,
observe, observe,
PACIFIC_MARINE_LAYER, PACIFIC_MARINE_LAYER,
type Atmosphere,
type WeatherObservation, type WeatherObservation,
} from "./engine/atmosphere.ts"; } from "./engine/atmosphere.ts";
import { officeDaylight } from "./interiors/daylight.ts";
import { createScene, type SceneHandle } from "./engine/scene.ts"; import { createScene, type SceneHandle } from "./engine/scene.ts";
import { import {
regionOf, regionOf,
@@ -79,6 +81,27 @@ const CITIES: { id: string; label: string; city: City }[] = [
{ id: "socal", label: "SoCal", city: SOCAL }, { 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"); const canvas = document.querySelector<HTMLCanvasElement>("#scene");
if (!canvas) throw new Error("#scene canvas missing"); if (!canvas) throw new Error("#scene canvas missing");
@@ -223,6 +246,24 @@ let poseEditor: PoseEditor | null = null;
*/ */
let officePack: Office | null = null; let officePack: Office | null = null;
let officeMaterials: MaterialRegistry | 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. * The plan panel's other occupant.
* *
@@ -357,8 +398,38 @@ function currentWeather(): WeatherObservation | null {
return weatherOverride ?? weatherWatch?.current().value ?? 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() { function updateSun() {
const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO; 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; if (!city || !atmosphere) return;
const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather()); const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather());
city.setLighting(atmosphere.apply(env)); city.setLighting(atmosphere.apply(env));
@@ -417,6 +488,7 @@ async function mountCity(id: string) {
officePlan = null; officePlan = null;
office?.dispose(); office?.dispose();
office = null; office = null;
officeAtmosphere = null;
inside = false; inside = false;
minimap?.dispose(); minimap?.dispose();
minimap = null; minimap = null;
@@ -562,7 +634,13 @@ async function mountCity(id: string) {
atmosphere = createAtmosphere({ atmosphere = createAtmosphere({
lng: entry.city.center.lng, lng: entry.city.center.lng,
metresPerUnit: city.world.metresPerUnit, 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 // 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 // is. `minVisibilityM` defaults to 4.5 km, which is honest weather and
// completely wrong here: this board is ninety-four kilometres across, so // completely wrong here: this board is ninety-four kilometres across, so
@@ -703,9 +781,42 @@ async function enterOffice() {
if (!built || !city) return; if (!built || !city) return;
const { createOfficeScene, createOfficeMinimap, pack, materials } = built; const { createOfficeScene, createOfficeMinimap, pack, materials } = built;
const depth = access.can.officeDepth; 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, { office = createOfficeScene(pack, {
dom: city.stage.renderer.domElement, 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, depth,
materials, materials,
// Ignored entirely at `"public"` depth, where no layer is built to colour. // 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 // furniture catalogue this chunk exists to hold back — asking for it here
// costs nothing beyond the module itself, and asking for it anywhere else // costs nothing beyond the module itself, and asking for it anywhere else
// would cost the whole catalogue in the entry chunk. // 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([ const [interiors, pack, assets, plan] = await Promise.all([
import("./interiors/officeScene.ts"), import("./interiors/officeScene.ts"),
import("./offices/lumbridge-hq.ts"), entry.load(),
import("./assets/materials.ts"), import("./assets/materials.ts"),
import("./engine/officeMinimap.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" }); officeMaterials ??= new assets.MaterialRegistry({ quality: "high" });
return { return {
createOfficeScene: interiors.createOfficeScene, createOfficeScene: interiors.createOfficeScene,
@@ -969,23 +1085,90 @@ function showDetail(text: string | null) {
body.textContent = text ?? ""; 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() { function renderCityPicker() {
if (!cityNav) return; if (!cityNav) return;
cityNav.replaceChildren(); 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"); const b = document.createElement("button");
// `aria-pressed` rather than a class, because that is what these are: two // `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 // buttons of which exactly one is on. The stylesheet keys off the attribute
// so the visual state and the announced state cannot drift apart. // so the visual state and the announced state cannot drift apart.
b.className = "city"; b.className = "city";
b.type = "button"; b.type = "button";
b.setAttribute("aria-pressed", String(c.id === cityId && !inside)); b.setAttribute("aria-pressed", String(entry.active));
b.textContent = c.label; b.textContent = entry.label;
b.addEventListener("click", () => switchCity(c.id)); b.addEventListener("click", () => {
if (inside) void switchOffice(entry.id);
else switchCity(entry.id);
});
cityNav.append(b); 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. */ /** One legend for both places — a city chapter and an office viewpoint are both `View`s. */
function renderLegend() { function renderLegend() {
renderCityPicker(); renderCityPicker();
+32 -3
View File
@@ -66,9 +66,38 @@ going *down* is what makes the winding rule counter-intuitive:
a rectangle, and which has a *negative* shoelace area over `(x, z)`. a rectangle, and which has a *negative* shoelace area over `(x, z)`.
- Get it wrong and nothing breaks: `Plan` silently re-winds a reversed polygon. - Get it wrong and nothing breaks: `Plan` silently re-winds a reversed polygon.
An office has no orientation on the earth. Calling an edge "north" is a By default an office has no orientation on the earth, and calling an edge
convenience for reading your own file. Offices get no `Atmosphere`, no sun and "north" is a convenience for reading your own file. A pack with no `site` gets
no sky (CONTRACT.md §4); interior lighting is a fixed rig owned by the scene. no `Atmosphere`, no sun and no sky (CONTRACT.md §4); interior lighting is a
fixed rig owned by the scene, and that is a supported, permanent state — you can
author a whole building without owning a coordinate.
Say where the building stands and the convenience becomes a fact:
```ts
site: {
lat: 37.7756,
lng: -122.3186,
elevation: 4, // metres above the ground OUTSIDE, not above sea level
heading: 0, // compass bearing, in degrees, that the pack's Z points along
label: "Alameda Point",
},
```
Then the pack gets the real sun for that place at the app's clock, a sky, and a
horizon `elevation` metres below the level-0 floor. **`heading` is the field that
matters most and the easiest to leave wrong**: it is what decides which of your
walls the light actually comes through. `0` means your "north" really is north.
`elevation` is the other one worth thinking about, because it is what the horizon
is measured from — the difference between an office on the 48th floor and a shed
on an airfield is one number, and it is this one. Both shipped packs are worked
examples: `lumbridge-hq.ts` is 188 m up and rotated 205°, `frontier-valley.ts` is
4 m up and square to the compass.
Nothing here is geocoded and nothing can be. These are numbers you type, like
every other number in a pack — see CONTRACT.md §8 for why a coordinate's
provenance is a licensing question in this repo.
## Rooms are slabs. Walls are segments. ## Rooms are slabs. Walls are segments.
+692
View File
@@ -0,0 +1,692 @@
/**
* Frontier Valley — a startup in a hangar at Alameda Point.
*
* The second office pack, and it exists to prove the format describes more than
* one kind of building. `lumbridge-hq.ts` is the dev kit: a corridor, fifteen
* rooms, a grid, everything a commercial floor plate has. This is the opposite
* building in every respect that matters, and the contrast is the point.
*
* ### Why a hangar, and why here
*
* Alameda Point is the former Naval Air Station Alameda, decommissioned in 1997:
* a mile of runway, a seaplane lagoon, and a row of enormous steel-framed
* hangars that have spent the last quarter century being rented to distilleries,
* film crews and companies that need a very large room cheaply. Putting a
* startup in one is not a conceit — it is the single most characteristic thing
* that happens on that piece of land.
*
* It also gives the site field something to say. `lumbridge-hq` is 188 m up a
* tower with the horizon far below it; this is **four metres above reclaimed
* ground on a flat island**, looking across the estuary at the city that the
* other office is inside. Same engine, same clock, same sun — and the two feel
* nothing like each other, which is the whole argument for `OfficeSite`.
*
* ### One level, one room, and a mezzanine that is furniture
*
* A hangar is a single volume. There is no corridor here because there is
* nothing to connect: the meeting rooms are freestanding boxes dropped on the
* slab, with their own low lids, and everything else is open floor. That is
* expressible in this format without a single new feature, which is worth
* knowing — the format was written against a cellular office and turns out to
* describe a shed just as well.
*
* ### The coordinate frame
*
* Metres, `1 unit = 1 m`, floor on the XZ plane with +Y up. Origin at the
* north-west corner. +X east, +Z south, so in plan view +Z runs down the page.
* The one difference from the reference pack is that here "north" is a genuine
* claim: `site.heading` is 0, because the hangars at Alameda Point really are
* laid out square to the compass along the old runways.
*/
import type {
AssetId,
DeskBank,
Level,
Office,
Opening,
Outline,
Point2,
Prop,
Room,
Seat,
Viewpoint,
Wall,
Yaw,
Zone,
} from "../interiors/types.ts";
// ---- The shed -------------------------------------------------------------
/**
* A hangar, in three numbers.
*
* 54 x 30 m is a small one by Alameda standards — the surviving hangars on the
* seaplane lagoon run to twice this — but it is a plausible sublet, and it is
* already three times the floor area of the reference office's desk floor with
* nothing standing in it.
*/
const WIDTH = 54.0;
const DEPTH = 30.0;
/**
* Nine metres to the underside of the trusses.
*
* This is the number that makes it a hangar rather than a warehouse-themed
* office. A commercial storey is 2.8 m; at 9 m the roof is somewhere you look
* *up* at, the meeting boxes read as objects standing in a room rather than as
* rooms carved out of one, and a mezzanine is a thing you can put under it.
*/
const RIDGE = 9.0;
/** The freestanding boxes get their own low lids. A booth in a shed is a box. */
const BOX_CEILING = 3.0;
const EXT_THICKNESS = 0.35;
const INT_THICKNESS = 0.12;
const EXT_FACE = EXT_THICKNESS / 2;
const INT_FACE = INT_THICKNESS / 2;
const DISPLAY_OFFSET = 0.07;
const BOARD_OFFSET = 0.06;
/**
* The mezzanine deck.
*
* It is a `Level`, and getting there took one wrong turn worth recording. A deck
* inside a single volume reads like furniture, so it was first authored as a
* `Room` on the ground floor with everything on it carrying `elevation: 4.4`.
* That is not expressible: a `Room` is a floor *finish* and has no height, so
* the deck's slab lay flat on the concrete while its chairs floated four and a
* half metres over it, its glass balustrade fenced off a patch of ground-floor
* slab, and anybody sitting at `mezz-01` stood on the concrete underneath their
* own chair.
*
* A `Level` is the only thing in this format that carries a floor height, so the
* deck is one — with a storey height of `RIDGE - MEZZ_HEIGHT`, because what is
* above it is the same roof.
*/
const MEZZ_W = 38.0;
const MEZZ_N = 20.4;
const MEZZ_HEIGHT = 4.4;
// The three freestanding boxes along the west end.
const BOX_1_W = 3.0;
const BOX_1_E = 10.2;
const BOX_2_W = 11.4;
const BOX_2_E = 17.4;
const BOX_N = 3.0;
const BOX_S = 9.6;
// ---- Yaw ------------------------------------------------------------------
const NORTH: Yaw = 0;
const EAST: Yaw = -Math.PI / 2;
const SOUTH: Yaw = Math.PI;
const WEST: Yaw = Math.PI / 2;
// ---- Assets ---------------------------------------------------------------
const DESK: AssetId = "tera:desk.workstation";
const PEDESTAL: AssetId = "tera:desk.pedestal";
const TASK_CHAIR: AssetId = "tera:seat.task-chair";
const LOUNGE_CHAIR: AssetId = "tera:seat.lounge";
const MEETING_TABLE: AssetId = "tera:table.meeting";
const SIDE_TABLE: AssetId = "tera:table.side";
const SHELF: AssetId = "tera:storage.shelf";
const LOCKER: AssetId = "tera:storage.locker";
const DISPLAY: AssetId = "tera:screen.wall-display";
const PLANT: AssetId = "tera:plant.potted";
const TREE: AssetId = "tera:plant.tall";
const PENDANT: AssetId = "tera:light.pendant";
const RUG: AssetId = "tera:rug";
const WHITEBOARD: AssetId = "tera:whiteboard";
const CONCRETE = "tera:concrete.polished";
const WOOD = "tera:wood.plank";
const CARPET_ACCENT = "tera:carpetAccent.broadloom";
const PAINT = "tera:paint.matt";
const ACCENT_PAINT = "tera:plasterAccent.deep";
const GLASS = "tera:glass.curtain";
const STEEL = "tera:steel.panel";
const FELT = "tera:felt.acoustic";
// ---- Helpers --------------------------------------------------------------
function rect(x0: number, z0: number, x1: number, z1: number): Outline {
return [
{ x: x0, z: z0 },
{ x: x0, z: z1 },
{ x: x1, z: z1 },
{ x: x1, z: z0 },
];
}
function grid(x0: number, z0: number, columns: number, rows: number, dx: number, dz: number): Point2[] {
const points: Point2[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < columns; c++) points.push({ x: x0 + c * dx, z: z0 + r * dz });
}
return points;
}
function scatter(
prefix: string,
kind: AssetId,
points: Point2[],
opts: { rotation?: Yaw; elevation?: number; colorKey?: string } = {},
): Prop[] {
return points.map((position, i) => ({
id: `${prefix}-${String(i + 1).padStart(2, "0")}`,
kind,
position,
rotation: opts.rotation ?? NORTH,
elevation: opts.elevation,
colorKey: opts.colorKey,
}));
}
function doorway(start: number, width = 0.9, head = 2.1): Opening {
return { kind: "door", start, width, sill: 0, head };
}
function pane(start: number, width: number, sill = 0.9, head = 2.2): Opening {
return { kind: "window", start, width, sill, head };
}
function archway(start: number, width: number, head = 2.4): Opening {
return { kind: "arch", start, width, sill: 0, head };
}
// ---- Rooms ----------------------------------------------------------------
const ROOMS: Room[] = [
/**
* The slab. One room, fifty-four by thirty, notched around nothing.
*
* Every other "room" in this pack sits on top of this one as a different floor
* finish, and they are authored *after* it so `Plan.roomAt` resolves them
* first. That is the one ordering rule in the format and it is doing real work
* here: in the reference office the floor is a jigsaw of abutting slabs, and
* here it is one slab with rugs on it.
*/
{
id: "floor",
name: "The Floor",
outline: rect(0, 0, WIDTH, DEPTH),
floor: CONCRETE,
ceiling: null,
},
{
id: "box-standup",
name: "Standup",
outline: rect(BOX_1_W, BOX_N, BOX_1_E, BOX_S),
floor: CARPET_ACCENT,
ceiling: { height: BOX_CEILING, surface: FELT },
},
{
id: "box-quiet",
name: "The Quiet Box",
outline: rect(BOX_2_W, BOX_N, BOX_2_E, BOX_S),
floor: CARPET_ACCENT,
ceiling: { height: BOX_CEILING, surface: FELT },
},
{
id: "workshop",
name: "The Bench",
// Against the east gable, where the big door is. A hardware startup in a
// hangar puts the thing it is building next to the way out.
outline: rect(42.0, 3.0, WIDTH - 0.4, 16.0),
floor: CONCRETE,
ceiling: null,
},
{
id: "galley",
name: "The Galley",
outline: rect(3.0, 22.0, 16.0, DEPTH - 0.4),
floor: WOOD,
ceiling: null,
},
];
// ---- Walls ----------------------------------------------------------------
const WALLS: Wall[] = [
// -- The envelope, nine metres of it --------------------------------------
{
id: "gable-west",
from: { x: 0, z: 0 },
to: { x: 0, z: DEPTH },
thickness: EXT_THICKNESS,
height: RIDGE,
surface: STEEL,
openings: [doorway(13.6, 1.8, 2.4), pane(4.0, 6.0, 3.6, 7.2), pane(19.0, 6.0, 3.6, 7.2)],
},
{
id: "gable-east",
from: { x: WIDTH, z: 0 },
to: { x: WIDTH, z: DEPTH },
thickness: EXT_THICKNESS,
height: RIDGE,
surface: STEEL,
// The hangar door. Twelve metres of it, full height, because that is what a
// hangar is — and it is an `arch` rather than a `door` so the collider
// knows you can walk out onto the apron through it.
openings: [archway(6.0, 12.0, 6.4)],
},
{
id: "eave-north",
from: { x: 0, z: 0 },
to: { x: WIDTH, z: 0 },
thickness: EXT_THICKNESS,
height: RIDGE,
surface: STEEL,
// Clerestory glazing high on the north side: the light a shed actually
// wants, and the sill is well above head height so none of it is a doorway.
openings: [
pane(4.0, 9.0, 5.4, 8.2),
pane(16.0, 9.0, 5.4, 8.2),
pane(28.0, 9.0, 5.4, 8.2),
pane(40.0, 9.0, 5.4, 8.2),
],
},
{
id: "eave-south",
from: { x: 0, z: DEPTH },
to: { x: WIDTH, z: DEPTH },
thickness: EXT_THICKNESS,
height: RIDGE,
surface: STEEL,
// The lagoon side, and the only wall with glazing you can see out of at
// standing height. This is the view the whole building is arranged around.
openings: [
pane(3.0, 10.0, 0.9, 3.4),
pane(16.0, 10.0, 0.9, 3.4),
pane(30.0, 8.0, 0.9, 3.4),
doorway(44.0, 1.8, 2.4),
],
},
// -- The freestanding boxes -----------------------------------------------
//
// Three metres tall inside a nine-metre volume, which is what makes them read
// as objects on the floor. Their fourth sides are open — a box you can see
// into is a box people use.
{
id: "standup-north",
from: { x: BOX_1_W, z: BOX_N },
to: { x: BOX_1_E, z: BOX_N },
height: BOX_CEILING,
surface: ACCENT_PAINT,
},
{
id: "standup-west",
from: { x: BOX_1_W, z: BOX_N },
to: { x: BOX_1_W, z: BOX_S },
height: BOX_CEILING,
surface: ACCENT_PAINT,
},
{
id: "standup-south",
from: { x: BOX_1_W, z: BOX_S },
to: { x: BOX_1_E, z: BOX_S },
height: BOX_CEILING,
surface: GLASS,
openings: [archway(2.4, 2.4, 2.4)],
},
{
id: "quiet-north",
from: { x: BOX_2_W, z: BOX_N },
to: { x: BOX_2_E, z: BOX_N },
height: BOX_CEILING,
surface: ACCENT_PAINT,
},
{
id: "quiet-east",
from: { x: BOX_2_E, z: BOX_N },
to: { x: BOX_2_E, z: BOX_S },
height: BOX_CEILING,
surface: ACCENT_PAINT,
},
{
id: "quiet-south",
from: { x: BOX_2_W, z: BOX_S },
to: { x: BOX_2_E, z: BOX_S },
height: BOX_CEILING,
surface: GLASS,
openings: [doorway(2.6, 0.9, 2.1)],
},
];
// ---- Desks ----------------------------------------------------------------
/**
* Two long benches down the middle of the shed, and nothing against a wall.
*
* A hangar's walls are its least valuable surface — they are steel, they are
* cold, and the good light comes from above. So the desks sit in the volume and
* the edges are left for the things that want an edge.
*/
const DESK_BANKS: DeskBank[] = [
{
id: "fv",
desk: DESK,
chair: TASK_CHAIR,
origin: { x: 22.0, z: 5.2 },
rotation: 0,
columns: 7,
rows: 2,
pitch: 1.7,
rowPitch: 0.85,
facingRows: true,
},
{
id: "fv-b",
desk: DESK,
chair: TASK_CHAIR,
origin: { x: 22.0, z: 10.4 },
rotation: 0,
columns: 7,
rows: 2,
pitch: 1.7,
rowPitch: 0.85,
facingRows: true,
seatPrefix: "fv-b",
},
];
const SEATS: Seat[] = [
{ id: "standup-01", position: { x: 5.0, z: 5.6 }, facing: EAST, pose: "stand" },
{ id: "standup-02", position: { x: 8.2, z: 5.6 }, facing: WEST, pose: "stand" },
{ id: "standup-03", position: { x: 6.6, z: 4.4 }, facing: SOUTH, pose: "stand" },
{ id: "quietbox-01", position: { x: 13.0, z: 5.4 }, facing: EAST, pose: "sit" },
{ id: "quietbox-02", position: { x: 15.8, z: 5.4 }, facing: WEST, pose: "sit" },
{ id: "galley-01", position: { x: 6.0, z: 25.0 }, facing: SOUTH, pose: "stand" },
{ id: "galley-02", position: { x: 7.6, z: 25.0 }, facing: SOUTH, pose: "stand" },
{ id: "galley-03", position: { x: 9.2, z: 25.0 }, facing: SOUTH, pose: "stand" },
// On the table's south long side, not on the tabletop: the table is centred on
// z 27.0 and is 1.2 m deep, so a seat at 27.0 was inside it.
{ id: "galley-04", position: { x: 12.4, z: 28.0 }, facing: NORTH, pose: "sit" },
{ id: "galley-05", position: { x: 14.0, z: 28.0 }, facing: NORTH, pose: "sit" },
{ id: "bench-01", position: { x: 44.4, z: 6.0 }, facing: EAST, pose: "stand" },
{ id: "bench-02", position: { x: 44.4, z: 8.4 }, facing: EAST, pose: "stand" },
{ id: "bench-03", position: { x: 44.4, z: 10.8 }, facing: EAST, pose: "stand" },
];
// ---- Props ----------------------------------------------------------------
const PROPS: Prop[] = [
// -- The desk floor -------------------------------------------------------
...scatter("fv-ped", PEDESTAL, grid(22.0, 6.8, 7, 1, 1.7, 0)),
...scatter("fv-ped-b", PEDESTAL, grid(22.0, 12.0, 7, 1, 1.7, 0)),
/**
* The lights, hung from the trusses at seven metres.
*
* The one place this pack really needs the ceiling-fixture exception: a
* pendant is authored with its origin at the mounting plane and its geometry
* below, so 7.2 is a real height in a 9 m shed and not an offset from a lid
* that does not exist.
*/
...scatter("truss-light", PENDANT, grid(8.0, 5.0, 6, 4, 9.0, 7.0), { elevation: 7.2 }),
// -- The boxes ------------------------------------------------------------
{ id: "standup-board", kind: WHITEBOARD, position: { x: 6.6, z: BOX_N + INT_FACE + BOARD_OFFSET }, rotation: NORTH, elevation: 1.0 },
{ id: "standup-rug", kind: RUG, position: { x: 6.6, z: 6.3 }, rotation: NORTH, colorKey: "team" },
{ id: "quiet-table", kind: MEETING_TABLE, position: { x: 14.4, z: 5.4 }, rotation: NORTH },
...scatter("quiet-chair", TASK_CHAIR, [
{ x: 13.0, z: 5.4 },
{ x: 15.8, z: 5.4 },
]),
{ id: "quiet-display", kind: DISPLAY, position: { x: 14.4, z: BOX_N + INT_FACE + DISPLAY_OFFSET }, rotation: NORTH, elevation: 1.15 },
// -- The bench ------------------------------------------------------------
// Four, starting at z 1.1: the gable is solid only for z 0..6, and the hangar
// door's arch runs from 6.0 to 18.0 — a five-shelf run from 4.0 put three of
// them standing in the open doorway.
...scatter("bench-shelf", SHELF, grid(WIDTH - EXT_FACE - 0.2, 1.1, 1, 4, 0, 1.2), { rotation: EAST }),
{ id: "bench-board", kind: WHITEBOARD, position: { x: 43.0, z: 3.0 + BOARD_OFFSET }, rotation: NORTH, elevation: 1.0 },
...scatter("bench-locker", LOCKER, grid(42.4, 13.0, 3, 1, 1.1, 0), { rotation: WEST }),
// -- The galley -----------------------------------------------------------
{ id: "galley-rug", kind: RUG, position: { x: 9.0, z: 26.4 }, rotation: NORTH, colorKey: "social" },
{ id: "galley-table", kind: MEETING_TABLE, position: { x: 13.2, z: 27.0 }, rotation: NORTH },
...scatter("galley-lounge", LOUNGE_CHAIR, [
{ x: 6.0, z: 26.8 },
{ x: 8.4, z: 26.8 },
{ x: 10.8, z: 26.8 },
]),
{ id: "galley-side", kind: SIDE_TABLE, position: { x: 7.2, z: 25.8 }, rotation: NORTH },
...scatter("galley-plant", TREE, [
{ x: 4.0, z: 23.4 },
{ x: 15.0, z: 23.4 },
]),
// -- The floor, kept mostly empty -----------------------------------------
//
// Three trees and nothing else. The shed's argument is the volume, and a
// fifty-four-metre room with forty objects in it is a fifty-four-metre room
// you cannot see across.
...scatter("floor-tree", TREE, [
{ x: 20.0, z: 20.0 },
{ x: 30.0, z: 24.0 },
{ x: 36.0, z: 4.0 },
]),
...scatter("floor-plant", PLANT, [
{ x: 19.6, z: 3.2 },
{ x: 34.0, z: 14.0 },
]),
];
// ---- Zones ----------------------------------------------------------------
const ZONES: Zone[] = [
{ id: "zone-build", name: "Build", outline: rect(42.0, 3.0, WIDTH - 0.4, 16.0), colorKey: "team" },
{ id: "zone-galley", name: "Galley", outline: rect(3.0, 22.0, 16.0, DEPTH - 0.4), colorKey: "social" },
];
// ---- Viewpoints -----------------------------------------------------------
const VIEWPOINTS: Viewpoint[] = [
{
id: "hangar",
number: "01",
label: "The Hangar",
shortLabel: "Hangar",
levelId: "level-1",
focus: { at: { x: 27, z: 15 }, distance: 52, height: 26, rotation: 0.6 },
description:
"Fifty-four metres by thirty, nine to the trusses, one room. Everything Frontier Valley has is on this slab or on the deck at the far end.",
},
{
id: "benches",
number: "02",
label: "The Benches",
shortLabel: "Benches",
levelId: "level-1",
focus: { at: { x: 27.4, z: 8.0 }, distance: 15, height: 4.6, rotation: 0.4 },
description:
"Twenty-eight seats in two runs down the middle of the floor, under the trusses. Nothing is against a wall — in a shed the walls are the worst surface in the building.",
},
{
id: "big-door",
number: "03",
label: "The Big Door",
shortLabel: "Big Door",
levelId: "level-1",
// Low and looking east, straight out through the twelve-metre opening.
focus: { at: { x: 46, z: 9.5 }, distance: 18, height: 3.0, rotation: 1.5 },
description:
"Twelve metres of hangar door, open onto the apron, with the bench inside it. The reason a company rents one of these rather than a floor of an office block.",
},
{
id: "mezzanine",
number: "04",
label: "The Mezzanine",
shortLabel: "Mezzanine",
levelId: "level-mezz",
// `height` is metres above *this* level's floor, so 3.0 here is standing on
// the deck rather than 3 m off the concrete.
focus: { at: { x: 46, z: 25 }, distance: 13, height: 3.0, rotation: 2.4 },
description:
"A deck four and a half metres up with another four and a half above it. Its own level, because a level is the only thing in this format that carries a floor height — and without one its chairs floated over bare concrete.",
},
{
id: "galley",
number: "05",
label: "The Galley",
shortLabel: "Galley",
levelId: "level-1",
focus: { at: { x: 9.5, z: 26 }, distance: 12, height: 3.2, rotation: 3.4 },
description:
"The lagoon side, and the only glazing you can see out of standing up. The whole social end of the building is arranged around one row of windows.",
},
];
// ---- The pack -------------------------------------------------------------
// ---- The mezzanine, as its own level --------------------------------------
//
// Authored in its own floor's frame, with the deck at zero. `Plan` adds
// `MEZZ_HEIGHT` to every coordinate here exactly once.
const MEZZ_ROOMS: Room[] = [
{
id: "mezzanine",
name: "The Mezzanine",
outline: rect(MEZZ_W, MEZZ_N, WIDTH - 0.4, DEPTH - 0.4),
floor: WOOD,
ceiling: null,
},
];
/**
* The balustrade, and — as in the reference pack's gallery — a **wall** and not
* a prop, because `Plan` derives the walk collider from the wall list and the
* drop is 4.4 m onto polished concrete. No openings: that is what a balustrade
* is.
*/
const MEZZ_WALLS: Wall[] = [
{
id: "mezz-west",
from: { x: MEZZ_W, z: MEZZ_N },
to: { x: MEZZ_W, z: DEPTH - 0.4 },
height: 1.1,
surface: GLASS,
},
{
id: "mezz-north",
from: { x: MEZZ_W, z: MEZZ_N },
to: { x: WIDTH - 0.4, z: MEZZ_N },
height: 1.1,
surface: GLASS,
},
];
const MEZZ_SEATS: Seat[] = [
{ id: "mezz-01", position: { x: 44.0, z: 23.0 }, facing: NORTH, pose: "sit" },
{ id: "mezz-02", position: { x: 46.4, z: 23.0 }, facing: NORTH, pose: "sit" },
{ id: "mezz-03", position: { x: 48.8, z: 23.0 }, facing: NORTH, pose: "sit" },
];
const MEZZ_PROPS: Prop[] = [
{ id: "mezz-table", kind: MEETING_TABLE, position: { x: 46.4, z: 24.2 }, rotation: NORTH },
...scatter("mezz-chair", TASK_CHAIR, [
{ x: 44.0, z: 23.0 },
{ x: 46.4, z: 23.0 },
{ x: 48.8, z: 23.0 },
]),
...scatter("mezz-lounge", LOUNGE_CHAIR, [
{ x: 44.0, z: 27.4 },
{ x: 47.0, z: 27.4 },
], { rotation: SOUTH }),
{ id: "mezz-rug", kind: RUG, position: { x: 45.5, z: 27.4 }, rotation: NORTH, colorKey: "social" },
// Half-depth plus a 25 mm reveal off the wall face, which is how the reference
// pack stands the same asset against a wall — `EXT_FACE + 0.2` is the shelf's
// *clearance* and leaves it floating 0.42 m out in the room.
...scatter("mezz-shelf", SHELF, grid(WIDTH - EXT_FACE - 0.2, 22.0, 1, 3, 0, 1.2), {
rotation: EAST,
}),
];
const MEZZ_ZONES: Zone[] = [
{ id: "zone-mezz", name: "Mezzanine", outline: rect(MEZZ_W, MEZZ_N, WIDTH - 0.4, DEPTH - 0.4), colorKey: "social" },
];
const LEVEL_MEZZ: Level = {
id: "level-mezz",
name: "The Mezzanine",
elevation: MEZZ_HEIGHT,
// What is above the deck is the same roof, so its storey height is whatever is
// left of the shed.
wallHeight: RIDGE - MEZZ_HEIGHT,
wallThickness: INT_THICKNESS,
wallSurface: PAINT,
floorplan: {
rooms: MEZZ_ROOMS,
walls: MEZZ_WALLS,
props: MEZZ_PROPS,
seats: MEZZ_SEATS,
zones: MEZZ_ZONES,
},
};
const LEVEL_1: Level = {
id: "level-1",
name: "The Slab",
elevation: 0,
wallHeight: RIDGE,
wallThickness: INT_THICKNESS,
wallSurface: PAINT,
floorplan: {
rooms: ROOMS,
walls: WALLS,
props: PROPS,
deskBanks: DESK_BANKS,
seats: SEATS,
zones: ZONES,
},
};
export const FRONTIER_VALLEY: Office = {
id: "frontier-valley",
name: "Frontier Valley",
levels: [LEVEL_1, LEVEL_MEZZ],
viewpoints: VIEWPOINTS,
/**
* Alameda Point, on the estuary side of Alameda Island.
*
* Typed by hand from the street grid of the former Naval Air Station, not
* geocoded — CONTRACT.md §8. Four metres of elevation because this is
* reclaimed flat ground and the slab is barely above it, which is the exact
* opposite of `lumbridge-hq`'s 188 m and is the whole point of having both.
*
* `heading: 0` is a real claim rather than the reference pack's convenience:
* the hangars here are laid out square to the old runways, which run close
* enough to northsouth that calling the clerestory wall "north" is true.
* So the high glazing really does take north light and the lagoon really is
* to the south.
*/
site: {
lat: 37.7756,
lng: -122.3186,
elevation: 4,
heading: 0,
label: "Alameda Point",
},
meta: {
description:
"A startup in a hangar at Alameda Point: one room, fifty-four by thirty, nine metres to the trusses. The second pack, and the one that shows the format describes a shed as well as it describes a corridor.",
author: "Lumbridge",
license: "CC0-1.0",
version: "1.0.0",
updated: "2026-08-07",
},
};
export default FRONTIER_VALLEY;
+54 -10
View File
@@ -118,16 +118,36 @@ const DEPTH = 18.0;
const CEILING = 2.8; const CEILING = 2.8;
/** /**
* Floor to floor. Not the same as `CEILING` and the difference is not slack: * The gap between one storey's ceiling and the next one's floor.
* 4.2 m is 2.8 m of room plus 1.4 m of structure, services and raised floor, *
* which is what a real storey costs and what puts level 2's slab at a height a * In a real building this is 1.4 m of structure, ducts, trays and raised floor,
* stair can plausibly climb to. * and you never see it. Here it is **ten times that**, and the exaggeration is
* deliberate and is the whole reason it has a name of its own.
*
* The brief was to be able to see the space between the floors and eventually to
* run things through it — cable trays, fibre, the drops that land on the ceiling
* of the floor below and the floor of the one above. At 1.4 m there is nowhere
* to put any of that and nothing to look at; at 14 m the interstitial is a
* storey in its own right, the two slabs read as two separate places, and the
* commons becomes a genuine shaft rather than a tall room.
*
* It is frankly not architecture. A 16.8 m floor-to-floor is a hangar, the stair
* in the east wing is no longer a stair anybody could climb, and the gallery
* looks down from the height of a five-storey building. That is an accepted
* cost of a deliberately unreal building, and **this is the one number to change
* if it reads as too much** — everything else in the file is measured off it.
*/
const PLENUM = 14.0;
/**
* Floor to floor. Not the same as `CEILING` and the difference is not slack: it
* is the room plus the interstitial above it.
* *
* `Plan` adds this to every coordinate on level 2 exactly once, so both levels * `Plan` adds this to every coordinate on level 2 exactly once, so both levels
* are authored in their own floor's frame and neither has to know about the * are authored in their own floor's frame and neither has to know about the
* other. * other.
*/ */
const STOREY = 4.2; const STOREY = CEILING + PLENUM;
/** /**
* The commons is open to both storeys, so its walls are two rooms tall. * The commons is open to both storeys, so its walls are two rooms tall.
@@ -1420,7 +1440,7 @@ const PROPS: Prop[] = [
elevation: 1.1, elevation: 1.1,
}, },
/** /**
* Pendants at 6.4 m, hanging into the void from the roof rather than from a * Pendants at 19 m, hanging into the void from the roof rather than from a
* ceiling at 2.8 m that is not there. * ceiling at 2.8 m that is not there.
* *
* This is the one place in the pack where the ceiling-fixture exception earns * This is the one place in the pack where the ceiling-fixture exception earns
@@ -1498,7 +1518,7 @@ const ZONES: Zone[] = [
* *
* **Every coordinate below is in level 2's own frame**, with the floor at zero. * **Every coordinate below is in level 2's own frame**, with the floor at zero.
* `Plan` adds `STOREY` to all of them exactly once when it resolves the level, * `Plan` adds `STOREY` to all of them exactly once when it resolves the level,
* which is what lets this section be read without holding 4.2 in your head, and * which is what lets this section be read without holding 16.8 in your head, and
* what lets both levels sit in one scene group at the origin. * what lets both levels sit in one scene group at the origin.
* *
* It stops at `BLOCK_E`. The wing below is open to the roof, so there is no slab * It stops at `BLOCK_E`. The wing below is open to the roof, so there is no slab
@@ -2004,9 +2024,12 @@ const VIEWPOINTS: Viewpoint[] = [
// wing did: an establishing shot that still framed 34 m put the commons // wing did: an establishing shot that still framed 34 m put the commons
// half off the right-hand edge, which is a poor introduction to the largest // half off the right-hand edge, which is a poor introduction to the largest
// room in the building. // room in the building.
focus: { at: { x: 24.0, z: 9.0 }, distance: 44, height: 19, rotation: 0.55 }, // Higher and further back than it was, because the building got taller
// rather than wider: at a 14 m interstitial the roof is 19.6 m up, and a
// camera at 19 m was level with it and looking at the parapet.
focus: { at: { x: 24.0, z: 9.0 }, distance: 58, height: 34, rotation: 0.55 },
description: description:
"Forty-eight metres by eighteen, two storeys, glazed along the north edge and open to the roof at the east end. Everything in this building is somewhere in this frame.", "Forty-eight metres by eighteen, two storeys a long way apart, glazed along the north edge and open to the roof at the east end. Everything in this building is somewhere in this frame.",
}, },
{ {
id: "reception", id: "reception",
@@ -2081,7 +2104,7 @@ const VIEWPOINTS: Viewpoint[] = [
// there is something underneath. // there is something underneath.
focus: { at: { x: 37.0, z: 9.0 }, distance: 9, height: 1.8, rotation: 1.4 }, focus: { at: { x: 37.0, z: 9.0 }, distance: 9, height: 1.8, rotation: 1.4 },
description: description:
"The balcony over the commons, four metres of it, with a glass balustrade and nothing below for another four. The reason the void is a volume rather than a tall room.", "The balcony over the commons, four metres of it, with a glass balustrade and nothing below for another seventeen. The reason the void is a volume rather than a tall room.",
}, },
]; ];
@@ -2135,6 +2158,27 @@ export const LUMBRIDGE_HQ: Office = {
name: "Lumbridge HQ", name: "Lumbridge HQ",
levels: [LEVEL_1, LEVEL_2], levels: [LEVEL_1, LEVEL_2],
viewpoints: VIEWPOINTS, viewpoints: VIEWPOINTS,
/**
* High in a downtown tower, on the same board the city view draws.
*
* The coordinate is a block in the Transbay cluster, typed by hand from the
* street grid — not geocoded, for the reason CONTRACT.md §8 gives at length.
* `elevation` is what makes it a *tower*: 188 m is around the forty-eighth
* floor, so the horizon sits a long way down and the fog outside the glass is
* the fog you look across a city at rather than through.
*
* `heading: 205` turns the glazed façade — `ext-north`, the wall this pack has
* always called the daylight side — to face south-south-west. That is the one
* number that decides whether the sun ever actually enters the building, and
* pointing the glass at the afternoon is the whole reason the field exists.
*/
site: {
lat: 37.7897,
lng: -122.3972,
elevation: 188,
heading: 205,
label: "Transbay, San Francisco",
},
meta: { meta: {
description: description:
"The reference office: two levels around a double-height commons, twenty-six rooms, a hundred and twenty-six seats. Copy this file, change the numbers, keep the seat ids.", "The reference office: two levels around a double-height commons, twenty-six rooms, a hundred and twenty-six seats. Copy this file, change the numbers, keep the seat ids.",
+154
View File
@@ -0,0 +1,154 @@
/**
* Turning the sun into a building's frame.
*
* This is eleven lines of trigonometry with four chances to get a sign wrong,
* and every one of them produces a scene that renders perfectly and is lit from
* the wrong side. There is no visual tell: an office lit from the east at
* sunset looks exactly as plausible as one lit from the west, unless you happen
* to know which wall the pack put its windows in.
*
* So the cases below are all stated as compass facts "the sun is due east, the
* building faces east, therefore the sun is straight ahead" rather than as
* expected numbers, because a number copied out of a failing run is not a test.
*
* The frame, for reading these: **Z is north, +X is east**, in both the world
* and the building. `heading` is the bearing the building's Z points along.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { intoBuildingFrame, officeDaylight } from "../interiors/daylight.ts";
import type { LightingState } from "../engine/types.ts";
import type { OfficeSite } from "../interiors/types.ts";
/** Unit vector pointing at a compass bearing, in the frame described above. */
function bearing(deg: number): [number, number, number] {
const r = (deg * Math.PI) / 180;
return [Math.sin(r), 0, -Math.cos(r)];
}
/** The bearing a frame-vector points at, back out again. */
function bearingOf([x, , z]: [number, number, number]): number {
return (((Math.atan2(x, -z) * 180) / Math.PI) + 360) % 360;
}
function close(actual: number, expected: number, message: string) {
const delta = Math.abs(((actual - expected + 540) % 360) - 180);
assert.ok(delta < 1e-6, `${message}: got ${actual}, expected ${expected}`);
}
describe("rotating the sun into the building's frame", () => {
it("changes nothing for a building whose north really is north", () => {
for (const deg of [0, 45, 90, 180, 270]) {
const v = bearing(deg);
assert.deepEqual(intoBuildingFrame(v, 0), v);
}
});
it("puts a sun dead ahead when the building faces it", () => {
// Building faces east; sun is due east. In the building's own frame that is
// straight out of the front, which is bearing 0 — its own "north".
close(bearingOf(intoBuildingFrame(bearing(90), 90)), 0, "sun should be ahead");
});
it("puts a sun behind when the building faces away from it", () => {
// Building faces north, sun due south.
close(bearingOf(intoBuildingFrame(bearing(180), 0)), 180, "sun should be behind");
// Building faces south, sun due north — the same physical arrangement.
close(bearingOf(intoBuildingFrame(bearing(0), 180)), 180, "sun should be behind");
});
it("subtracts the heading, rather than adding it", () => {
// The sign error that produces a plausible-looking, mirrored building. A sun
// at 90 seen from a building facing 30 is 60 off its nose, not 120.
close(bearingOf(intoBuildingFrame(bearing(90), 30)), 60, "heading should subtract");
});
it("leaves the sun's height alone", () => {
// Rotating about the vertical cannot change how high the sun is. A rig that
// got this wrong would have the sun rise and set as the building turned.
const up: [number, number, number] = [0.3, 0.9, 0.31];
for (const heading of [0, 37, 90, 205, 359]) {
assert.equal(intoBuildingFrame(up, heading)[1], 0.9);
}
});
it("preserves length, so the direction stays a direction", () => {
const v: [number, number, number] = [0.48, 0.72, -0.5];
const length = Math.hypot(...v);
for (const heading of [17, 205, 300]) {
const out = intoBuildingFrame(v, heading);
assert.ok(Math.abs(Math.hypot(...out) - length) < 1e-12);
}
});
it("round-trips: rotating by h and then by -h is the identity", () => {
const v: [number, number, number] = [0.2, 0.83, -0.52];
const there = intoBuildingFrame(v, 205);
const back = intoBuildingFrame(there, -205);
for (let i = 0; i < 3; i += 1) {
assert.ok(Math.abs((back[i] as number) - (v[i] as number)) < 1e-12);
}
});
});
describe("moving the weather outdoors", () => {
const site: OfficeSite = { lat: 37.79, lng: -122.4, elevation: 188, heading: 0 };
const cityState: LightingState = {
sun: { direction: [0.3, 0.9, 0.31], color: 0xffffff, intensity: 1 },
hemisphere: { sky: 0x8899aa, ground: 0x404040, intensity: 1 },
ambient: { color: 0xffffff, intensity: 0.3 },
sky: { top: 0x223344, horizon: 0x99aabb },
// A city fog, in city units: this would sit well inside a 50 m room.
fog: { color: 0xaabbcc, near: 1150, far: 2800 },
};
/**
* The failure this guards is not subtle once you see it and is invisible until
* you do: an office rendered with a fog that starts 1,150 *metres* away is
* fine, and one rendered with a fog that starts at 1,150 *scene units* of a
* city is fine too but an office is 1 unit to the metre, so a city fog
* dropped into one greys out the far wall and everybody standing at it.
*/
it("starts the fog outside the building", () => {
const out = officeDaylight(cityState, site);
assert.ok(out.fog, "an office with a site keeps its fog");
assert.ok(out.fog.near > 60, `fog starts at ${out.fog.near} m, inside the building`);
assert.ok(out.fog.far > out.fog.near);
});
it("keeps the colour the atmosphere chose", () => {
assert.equal(officeDaylight(cityState, site).fog?.color, 0xaabbcc);
});
/**
* The sky is a screen-space gradient and the ground plane converges on the fog
* colour, so the two meet at whatever screen row the world horizon lands on
* which moves as the camera orbits. Unless they meet with the *same* colour,
* that line is a visible step across the frame.
*/
it("pins the sky's horizon to the fog colour, so the join is invisible", () => {
const out = officeDaylight(cityState, site);
assert.equal(out.sky?.horizon, cityState.fog?.color);
});
it("leaves the top of the sky to the atmosphere", () => {
assert.equal(officeDaylight(cityState, site).sky?.top, cityState.sky?.top);
});
it("keeps a skyless state skyless", () => {
assert.equal(officeDaylight({ ...cityState, sky: null }, site).sky, null);
});
it("keeps a fogless state fogless", () => {
const clear = officeDaylight({ ...cityState, fog: null }, site);
assert.equal(clear.fog, null);
});
it("does not mutate what it was given", () => {
const before = JSON.stringify(cityState);
officeDaylight(cityState, { ...site, heading: 205 });
assert.equal(JSON.stringify(cityState), before);
});
});
+197 -17
View File
@@ -16,7 +16,7 @@
* room behind it is sealed, and the only evidence is a line in * room behind it is sealed, and the only evidence is a line in
* `plan.problems` that nothing reads. That exact bug happened once while the * `plan.problems` that nothing reads. That exact bug happened once while the
* wing was being written. * wing was being written.
* - **The balustrade.** The gallery is 4.2 m above terrazzo. `Plan` derives * - **The balustrade.** The gallery is 16.8 m above terrazzo. `Plan` derives
* the walk collider from the wall list, so the difference between a * the walk collider from the wall list, so the difference between a
* balustrade and a decorative rail is whether it is in that list and both * balustrade and a decorative rail is whether it is in that list and both
* look identical in a screenshot. * look identical in a screenshot.
@@ -26,6 +26,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { Plan } from "../interiors/plan.ts"; import { Plan } from "../interiors/plan.ts";
import LUMBRIDGE_HQ from "../offices/lumbridge-hq.ts"; import LUMBRIDGE_HQ from "../offices/lumbridge-hq.ts";
import FRONTIER_VALLEY from "../offices/frontier-valley.ts";
const plan = new Plan(LUMBRIDGE_HQ, { warn: false }); const plan = new Plan(LUMBRIDGE_HQ, { warn: false });
@@ -38,13 +39,28 @@ describe("the reference pack resolves cleanly", () => {
); );
}); });
it("has both storeys, at floor-to-floor and not floor-to-ceiling", () => { /**
assert.deepEqual( * The relationship, not the number.
plan.levels.map((l) => [l.id, l.floorY]), *
[ * Floor-to-floor is deliberately exaggerated in this pack see `PLENUM` in
["level-1", 0], * `lumbridge-hq.ts` and an assertion on the literal would have to be edited
["level-2", 4.2], * every time somebody dials it, which makes it a change-detector rather than a
], * test. What must stay true is that level 2 sits a clear interstitial *above*
* level 1's ceiling, and never at or below it: `elevation` is floor-to-floor,
* and setting it to the ceiling height is the classic way to bury one storey's
* slab inside the one below.
*/
it("stacks the storeys floor-to-floor, not floor-to-ceiling", () => {
const [first, second] = plan.levels;
assert.ok(first && second, "both storeys should resolve");
assert.equal(first.id, "level-1");
assert.equal(second.id, "level-2");
assert.equal(first.floorY, 0);
const ceiling = LUMBRIDGE_HQ.levels[0]?.wallHeight ?? 0;
assert.ok(
second.floorY > ceiling,
`level 2's slab at ${second.floorY} m is inside level 1, whose ceiling is at ${ceiling} m`,
); );
}); });
@@ -188,7 +204,7 @@ describe("the gallery balustrade", () => {
for (const [name, from, to] of edges) { for (const [name, from, to] of edges) {
assert.ok( assert.ok(
plan.blocked("level-2", from, to, 0.3), plan.blocked("level-2", from, to, 0.3),
`the ${name} edge of the gallery lets a walker off a 4.2 m drop`, `the ${name} edge of the gallery lets a walker off a 16.8 m drop`,
); );
} }
}); });
@@ -218,20 +234,184 @@ describe("the commons is open to both storeys", () => {
}); });
it("is enclosed to two storeys rather than one", () => { it("is enclosed to two storeys rather than one", () => {
const level1 = plan.levels.find((l) => l.id === "level-1"); const [level1, level2] = plan.levels;
assert.ok(level1); assert.ok(level1 && level2);
// 7.0 m: one storey of 4.2 plus one room of 2.8. Asserted against the walls
// as resolved, so a level default leaking through would be caught. // The height the void *should* be, derived rather than typed: everything up
// // to level 2's slab, plus level 2's own room. That is what "open to both
// storeys" means, and it stays true whatever the interstitial is set to.
const ceiling = LUMBRIDGE_HQ.levels[1]?.wallHeight ?? 0;
const expected = level2.floorY + ceiling;
// `solid` only. The splitting pass emits a run per piece, so a wall with a // `solid` only. The splitting pass emits a run per piece, so a wall with a
// door in it also yields a `lintel` over the opening — 4.6 m of wall above a // door in it also yields a `lintel` over the opening — correct, and not the
// 2.4 m head, which is correct and is not the wall's height. // wall's height.
const wingWalls = level1.runs.filter( const wingWalls = level1.runs.filter(
(run) => run.wallId.startsWith("wing-") && run.role === "solid", (run) => run.wallId.startsWith("wing-") && run.role === "solid",
); );
assert.ok(wingWalls.length > 0, "the wing has no walls"); assert.ok(wingWalls.length > 0, "the wing has no walls");
for (const run of wingWalls) { for (const run of wingWalls) {
assert.equal(run.top - run.bottom, 7, `${run.wallId} is ${run.top - run.bottom} m tall`); assert.equal(
run.top - run.bottom,
expected,
`${run.wallId} is ${run.top - run.bottom} m tall, not the ${expected} m the void needs`,
);
}
});
});
/**
* The second pack, held to the same bar as the first.
*
* A shipped pack that nothing asserts on is a pack that quietly stops resolving
* the first time somebody edits a shared constant. It gets the two checks that
* catch that clean resolution and a walkable building plus the one thing
* that is specific to it: it is a *hangar*, and the point of shipping it is that
* the format describes one room fifty-four metres across as readily as it
* describes a corridor with fifteen doors off it.
*/
describe("the Frontier Valley pack", () => {
const fv = new Plan(FRONTIER_VALLEY, { warn: false });
it("resolves with no problems", () => {
assert.deepEqual(
fv.problems.map((p) => `${p.where}: ${p.message} (${p.action})`),
[],
);
});
/**
* The deck is a level, and that is the fix for a real defect rather than a
* modelling preference: authored as a `Room` it had no floor height, so its
* slab lay on the concrete while its furniture floated 4.4 m over it.
*/
it("puts the mezzanine on its own level, at deck height", () => {
assert.deepEqual(
fv.levels.map((l) => [l.id, l.floorY]),
[
["level-1", 0],
["level-mezz", 4.4],
],
);
});
it("has unique seat ids, which must not collide with the other pack's", () => {
const mine = fv.allSeats().map((s) => s.id);
assert.equal(new Set(mine).size, mine.length);
// Not a hard requirement of the format — ids are unique per *building* — but
// a deployment serving both from one presence API would find out the hard
// way, and the two packs cost nothing by staying disjoint.
const theirs = new Set(plan.allSeats().map((s) => s.id));
const shared = mine.filter((id) => theirs.has(id));
assert.deepEqual(shared, [], "seat ids shared between the two shipped packs");
});
it("can be walked from the personnel door to every room", () => {
const STEP = 0.1;
const RADIUS = 0.3;
const b = fv.bounds;
const cols = Math.ceil(b.width / STEP);
const rows = Math.ceil(b.depth / STEP);
const at = (c: number, r: number) => ({ x: b.minX + c * STEP, z: b.minZ + r * STEP });
const room: (string | null)[] = new Array(cols * rows).fill(null);
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
room[r * cols + c] = fv.roomAt("level-1", at(c, r))?.id ?? null;
}
}
// Just inside the west gable's personnel door.
const sc = Math.round((1.0 - b.minX) / STEP);
const sr = Math.round((14.4 - b.minZ) / STEP);
assert.ok(room[sr * cols + sc], "the way in is not inside a room");
const seen = new Uint8Array(cols * rows);
const queue: [number, number][] = [[sc, sr]];
seen[sr * cols + sc] = 1;
while (queue.length > 0) {
const [c, r] = queue.pop() as [number, number];
for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nc = c + (dc as number);
const nr = r + (dr as number);
if (nc < 0 || nr < 0 || nc >= cols || nr >= rows) continue;
const i = nr * cols + nc;
if (seen[i] === 1 || room[i] === null) continue;
if (fv.blocked("level-1", at(c, r), at(nc, nr), RADIUS)) continue;
seen[i] = 1;
queue.push([nc, nr]);
}
}
const reached = new Set<string>();
for (let i = 0; i < seen.length; i += 1) {
const id = room[i];
if (seen[i] === 1 && id != null) reached.add(id);
}
// Level 1 only. The mezzanine is a level of its own now and is reached by a
// stair this pack does not model, so a flood fill across the slab cannot and
// should not walk onto it.
const sealed = fv.levels[0]!.rooms.map((r) => r.id).filter((id) => !reached.has(id));
assert.deepEqual(sealed, [], "sealed rooms in the hangar");
});
it("guards the mezzanine edge, which is a 4.4 m drop onto concrete", () => {
// On the deck's own level — the balustrade belongs to it, not to the slab
// below, which is the whole point of the deck being a level.
assert.ok(fv.blocked("level-mezz", { x: 39.0, z: 25.0 }, { x: 37.0, z: 25.0 }, 0.3));
assert.ok(fv.blocked("level-mezz", { x: 45.0, z: 21.0 }, { x: 45.0, z: 19.0 }, 0.3));
});
/**
* And the deck is genuinely up there. `Plan` resolves a seat's `y` from its
* level's floor, so this is the assertion that would have caught the original
* defect: authored as a ground-floor room, every one of these was at y = 0
* with a chair drawn 4.4 m above it.
*/
it("stands its occupants on the deck rather than on the concrete", () => {
for (const id of ["mezz-01", "mezz-02", "mezz-03"]) {
assert.equal(fv.seat(id)?.y, 4.4, `${id} is not on the deck`);
}
});
});
/**
* Both shipped packs declare where they stand, and the two are deliberately
* nothing alike which is the entire argument for the field existing.
*/
describe("the sites", () => {
it("are both declared", () => {
assert.ok(LUMBRIDGE_HQ.site, "Lumbridge HQ has no site");
assert.ok(FRONTIER_VALLEY.site, "Frontier Valley has no site");
});
it("put one high in the air and one on the ground", () => {
assert.ok(
(LUMBRIDGE_HQ.site?.elevation ?? 0) > 100,
"the tower office should be a long way up",
);
assert.ok(
(FRONTIER_VALLEY.site?.elevation ?? 999) < 20,
"the hangar should be near the ground",
);
});
it("carry headings inside the compass", () => {
for (const pack of [LUMBRIDGE_HQ, FRONTIER_VALLEY]) {
const h = pack.site?.heading ?? 0;
assert.ok(h >= 0 && h < 360, `${pack.id} has a heading of ${h}`);
}
});
it("are both on the board the city view draws", () => {
// Not a format requirement — an office may stand anywhere — but both of
// these are meant to be places in *this* product's San Francisco, and a
// coordinate typo that put one in Nevada would otherwise render fine.
for (const pack of [LUMBRIDGE_HQ, FRONTIER_VALLEY]) {
const site = pack.site;
assert.ok(site);
assert.ok(site.lat > 37.2 && site.lat < 38.2, `${pack.id} latitude ${site.lat}`);
assert.ok(site.lng > -122.8 && site.lng < -121.8, `${pack.id} longitude ${site.lng}`);
} }
}); });
}); });