From 06455f74249308b04a66dc9f5214b823fb3a4f03 Mon Sep 17 00:00:00 2001 From: Kartios Date: Thu, 6 Aug 2026 23:30:45 -0700 Subject: [PATCH] The office learns where it stands, and the sky stops being a backdrop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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) --- CONTRACT.md | 25 +- src/engine/flights.ts | 218 ++++++++++- src/engine/satellites.ts | 53 ++- src/engine/scene.ts | 51 ++- src/engine/scenekit.ts | 17 + src/interiors/daylight.ts | 123 ++++++ src/interiors/officeScene.ts | 153 +++++++- src/interiors/shell.ts | 75 +++- src/interiors/types.ts | 63 +++ src/main.ts | 199 +++++++++- src/offices/README.md | 35 +- src/offices/frontier-valley.ts | 692 +++++++++++++++++++++++++++++++++ src/offices/lumbridge-hq.ts | 64 ++- src/test/daylight.test.ts | 154 ++++++++ src/test/office.test.ts | 214 +++++++++- 15 files changed, 2061 insertions(+), 75 deletions(-) create mode 100644 src/interiors/daylight.ts create mode 100644 src/offices/frontier-valley.ts create mode 100644 src/test/daylight.test.ts diff --git a/CONTRACT.md b/CONTRACT.md index 68a9537..7641935 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -154,9 +154,28 @@ constructed and mutated the same three lights. LightingState`, which the scene applies. One direction, no write-backs. - Solar position is computed locally with **no network** — a NOAA/Meeus implementation in `src/engine/solar.ts`, dependency-free. -- An **office gets no Atmosphere**: `fog: null`, no `scene.background` drive, - interior lighting is its own fixed rig. Daylight through windows is a later - refinement, not a v1 coupling. +- An **office with no `site` gets no Atmosphere**: `fog: null`, no + `scene.background` drive, interior lighting is its own fixed rig. This is + 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 diff --git a/src/engine/flights.ts b/src/engine/flights.ts index 49375c9..0018694 100644 --- a/src/engine/flights.ts +++ b/src/engine/flights.ts @@ -434,16 +434,54 @@ export interface FlightLayer { * 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 - * the age keeps a slow feed from drawing a trail across the entire bay: at - * `AdsbFlights`'s eight-second interval, twenty samples is nearly three minutes - * of flying, which is most of a leg. + * the age keeps a slow feed from drawing a trail across the entire bay. + * + * 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 5–15 s refresh is 16–48 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_SECONDS = 45; +const TRAIL_POINTS = 72; +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; +/** + * 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 * 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. */ 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. @@ -516,6 +561,16 @@ interface Track { band: number; /** Interpolated position, reused rather than reallocated every frame. */ 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. */ headAltitude: number; } @@ -629,11 +684,48 @@ export function createFlightLayer(world: World): FlightLayer { band: -1, head: position.clone(), headAltitude: a.altitude, + missingSince: 0, + stale: false, }; tracks.set(a.id, track); } 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 5–15 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 (5–15 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) { // 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 @@ -666,8 +758,27 @@ export function createFlightLayer(world: World): FlightLayer { 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) { - 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); tracks.delete(id); } @@ -675,6 +786,22 @@ export function createFlightLayer(world: World): FlightLayer { 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. */ function trim(track: Track, now: number) { 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 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.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha; @@ -728,12 +875,39 @@ export function createFlightLayer(world: World): FlightLayer { */ function rebuildTrails() { let vertex = 0; + let drawn = 0; 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; if (spine < 1) continue; + drawn += 1; 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 7–8 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; const a = track.samples[i - 1]; if (!a) continue; @@ -749,8 +923,30 @@ export function createFlightLayer(world: World): FlightLayer { } } 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) { diff --git a/src/engine/satellites.ts b/src/engine/satellites.ts index 7e8aaf4..9b2a02f 100644 --- a/src/engine/satellites.ts +++ b/src/engine/satellites.ts @@ -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. - * 2. **Outside the camera's maximum orbit**, which `scene.ts` sets at - * `boardSpan * 1.5`. This was 1.2 first, which put the far end of the zoom - * *outside the dome* — pull back far enough and you were looking at the sky - * from space, with half the constellation behind the camera. A sky you can - * leave is not a sky. - * 3. Inside the far plane, which `scene.ts` sets at `boardSpan * 3`. + * **You are meant to be able to get above this.** That is a deliberate reversal: + * an earlier version put the dome at 2.0 spans specifically so the camera could + * never leave it, on the theory that a sky you can step outside of is not a sky. + * That theory loses to the thing people actually want to look at — a + * constellation is an object, and the view of it from above, with the city + * underneath, is the shot. Standing inside a sphere of dots is a planetarium. * - * 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. @@ -386,11 +402,11 @@ const HORIZON_FADE_DEG = 8; */ const SHADOW_ALPHA = 0.16; -export function createSatelliteLayer(boardSpan: number): SatelliteLayer { +export function createSatelliteLayer(boardRadius: number): SatelliteLayer { const group = new THREE.Group(); group.name = "satellites"; - const radius = boardSpan * DOME_RADIUS_FACTOR; + const radius = boardRadius * DOME_RADIUS_FACTOR; const positions = new Float32Array(MAX_DOTS * 3); 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 // rather than as a grey sticker. 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); diff --git a/src/engine/scene.ts b/src/engine/scene.ts index 44200fc..7223986 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -194,14 +194,59 @@ export async function createScene( const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng); 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({ scene, dom: stage.renderer.domElement, fov: 42, 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), - 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, shadowFar: boardSpan * 2.2, }); @@ -235,7 +280,7 @@ export async function createScene( let satelliteLayer: SatelliteLayer | null = null; if (options.satellites) { - satelliteLayer = createSatelliteLayer(boardSpan); + satelliteLayer = createSatelliteLayer(boardRadius); scene.add(satelliteLayer.group); } diff --git a/src/engine/scenekit.ts b/src/engine/scenekit.ts index 892c5fe..78fb408 100644 --- a/src/engine/scenekit.ts +++ b/src/engine/scenekit.ts @@ -246,6 +246,23 @@ export function createSceneKit(options: SceneKitOptions): SceneKit { sun.shadow.camera.top = extent; sun.shadow.camera.bottom = -extent; 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 ambient = new THREE.AmbientLight(0xffffff, 0.3); scene.add(sun, hemisphere, ambient); diff --git a/src/interiors/daylight.ts b/src/interiors/daylight.ts new file mode 100644 index 0000000..42fdb24 --- /dev/null +++ b/src/interiors/daylight.ts @@ -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]; +} diff --git a/src/interiors/officeScene.ts b/src/interiors/officeScene.ts index 6aad42c..3dd84a8 100644 --- a/src/interiors/officeScene.ts +++ b/src/interiors/officeScene.ts @@ -88,6 +88,27 @@ import type { Office, Point2, Presence, Viewpoint } from "./types.ts"; // the resolver. `Plan` is where depth is *applied*; this is where it is chosen. export type { Depth } from "./plan.ts"; +/** + * How wide the horizon plane is, in metres. + * + * Twelve kilometres across, which is far enough that the fog has long since + * saturated before its edge — so the plane never ends anywhere you can see, and + * the far plane never has to be honest about where the ground stops. + */ +const HORIZON_EXTENT = 12_000; + +/** + * How much darker the ground is than the air in front of it. + * + * There has to be *some* difference or there is no horizon: paint the ground the + * fog colour exactly and the two meet invisibly, which at noon is a white void + * with a building in it. Half is enough to read as land under sky at every hour + * without ever reading as a painted floor — and because the fog then blends the + * two with distance, the line lands where the haze runs out rather than at an + * arbitrary radius. + */ +const HORIZON_DARKEN = 0.5; + export interface OfficeSceneOptions { /** * The renderer's canvas. Orbit input and pointer coordinates are read against @@ -137,8 +158,37 @@ export interface OfficeSceneOptions { * there, and this one says only that there is a there. */ onPlacePick?: (place: Pin | null) => void; - /** Overrides the fixed interior rig. Must carry `sky: null` and `fog: null`. */ + /** + * Overrides the fixed interior rig. + * + * It used to have to carry `sky: null` and `fog: null`, because a room has + * walls and no horizon. That is still true of a room with no `site` — but a + * pack that says where it stands gets a real sun, a sky behind the glazing and + * a fog that starts outside the building. See `horizon` below, and CONTRACT.md + * §4, which anticipated exactly this and called it a later refinement. + * + * A `fog` whose `near` is inside the building will fog the building. That is + * the one way to get this badly wrong, and it is the caller's job not to, + * because only the caller knows the scale it is working in. + */ lighting?: LightingState; + /** + * Put the ground back, this many metres below the level-0 floor. + * + * Absent, and the office floats in a flat colour exactly as it always has. + * Present, and the scene gets one very large horizontal plane at `-drop` and + * the building reads as being *up* — which, for a floor plate two hundred + * metres in the air, is most of the point of siting it at all. + * + * One plane, not a city. A cropped piece of the real terrain was the obvious + * alternative and is a much bigger thing: `blocks.ts` bakes its lot size in + * scene units at the city's ~94 m per unit, so a crop cannot simply be + * rebuilt at an office's 1 m per unit — it has to be built at city scale and + * then scaled into the room, which is a project rather than a detail. A plane + * plus honest fog gets the horizon, the haze and the sense of height, and + * those are the three things you actually feel. + */ + horizon?: { drop: number }; /** Defaults to false — the lid comes off, because that is the whole view. */ showCeilings?: boolean; /** Fade the walls you are looking through. Defaults to true. */ @@ -207,12 +257,29 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): // tuning by hand per pack. const span = Math.max(plan.bounds.width, plan.bounds.depth, 8); + /** + * The depth range, which the horizon changes and nothing else does. + * + * A room needs 5 cm to 300 m. A room with fifteen kilometres of ground under + * it needs the far plane out past the ground — and a 0.05 m near plane against + * an 8 km far plane is a depth ratio of 160,000, which spends the whole buffer + * on the first metre and z-fights every contact shadow in the building. + * + * So the near plane moves with the far one. 0.2 m is still well inside + * `minDistance` (1.2 m), so nothing the camera can actually reach is clipped, + * and the ratio comes back to 40,000 — which a 24-bit buffer holds without + * complaint. The fog saturates a long way before the plane's edge, so the far + * plane never has to be honest about where the ground stops. + */ + const far = options.horizon ? HORIZON_EXTENT * 0.7 : 300; + const near = options.horizon ? 0.2 : 0.05; + const kit = createSceneKit({ scene, dom: options.dom, fov: 50, - near: 0.05, - far: 300, + near, + far, minDistance: 1.2, maxDistance: span * 1.8, // Just short of horizontal, so the camera cannot get under the floor slab @@ -233,7 +300,16 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): }); kit.applyLighting(options.lighting ?? officeInterior()); - if (options.background !== null) { + /** + * The sky wins over the flat colour when there is one. + * + * `applyLighting` writes `scene.background` itself when the state carries a + * non-null `sky`, so setting a colour here afterwards would overwrite the + * gradient it just built — the office would compute a sky and then paint over + * it, which is a bug that looks exactly like the sky not working. + */ + const hasSky = (options.lighting ?? officeInterior()).sky !== null; + if (options.background !== null && !hasSky) { // A room has walls and no horizon, so nothing here computes a sky // (CONTRACT.md §4) — but with the ceilings off you are looking at the // building from outside it, and the outside cannot be nothing. One flat @@ -245,6 +321,70 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): : new THREE.Color(materials.palette.floorSlab).multiplyScalar(0.45); } + /** + * The ground, a long way down. + * + * Deliberately vast and deliberately plain. Its job is to end the sky in a + * horizon line and to give the eye something that is obviously *below* the + * floor you are standing on; anything more detailed at this distance is + * detail the fog eats before it reaches the camera. + * + * `MeshBasicMaterial` rather than a lit one, because a plane this size lit by + * a directional sun bands horribly across its own width, and because what it + * should read as is the far ground already washed out by fifteen kilometres of + * air — which is a fog colour, not a surface colour. The fog does the work. + */ + let horizonPlane: THREE.Mesh | null = null; + if (options.horizon) { + const geometry = new THREE.PlaneGeometry(HORIZON_EXTENT, HORIZON_EXTENT); + geometry.rotateX(-Math.PI / 2); + const material = new THREE.MeshBasicMaterial({ + // Recoloured on every `setLighting` — see `paintHorizon`. The value here is + // only what it looks like for the one frame before the first rig lands. + color: new THREE.Color(materials.palette.floorSlab).multiplyScalar(0.5), + // The one thing it must do: take the fog, so it fades into the sky at the + // horizon instead of ending in a hard edge halfway up the frame. + fog: true, + depthWrite: true, + }); + horizonPlane = new THREE.Mesh(geometry, material); + horizonPlane.name = "horizon"; + horizonPlane.position.y = -options.horizon.drop; + // Nothing casts onto it and it receives nothing — it is scenery, and a + // shadow map stretched over fifteen kilometres would resolve nothing anyway. + horizonPlane.receiveShadow = false; + horizonPlane.castShadow = false; + // Its bounding sphere is enormous and always in view; testing it every frame + // is pure cost. + horizonPlane.frustumCulled = false; + scene.add(horizonPlane); + } + + /** + * Keep the ground the colour of the air in front of it. + * + * The ground below a tower is not a surface you see, it is fifteen kilometres + * of atmosphere you see *through*, and the colour of that is the fog colour — + * which tracks the clock, so this has to be repainted rather than picked once. + * + * It was picked once, from the floor slab, and the result was the bug this + * exists to fix: a pale concrete sheet twelve kilometres across, sitting 188 m + * below the camera and therefore **nearer than the fog begins**, so it arrived + * at full strength and filled the frame behind the building at midnight. + * + * Slightly darker than the fog rather than equal to it, so there is still a + * horizon: the ground reads as ground near the building and converges on the + * sky at the distance where the fog saturates, which is what distance actually + * looks like. + */ + function paintHorizon(state: LightingState) { + if (!horizonPlane) return; + const material = horizonPlane.material as THREE.MeshBasicMaterial; + const source = state.fog?.color ?? state.hemisphere.ground; + material.color.setHex(source).multiplyScalar(HORIZON_DARKEN); + } + paintHorizon(options.lighting ?? officeInterior()); + const shell: Shell = createShell(plan, { materials }); const furnishings: Furnishings = createFurnishings(plan, { materials, @@ -534,6 +674,7 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): }, setLighting(state) { kit.applyLighting(state); + paintHorizon(state); }, // Stepping back out to the city should retire the hover with it, or the // detail card for whoever the pointer was over survives the journey. @@ -544,6 +685,10 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): updateOcclusion(); }, dispose() { + if (horizonPlane) { + horizonPlane.geometry.dispose(); + (horizonPlane.material as THREE.Material).dispose(); + } if (disposed) return; disposed = true; presence?.dispose(); diff --git a/src/interiors/shell.ts b/src/interiors/shell.ts index 301d697..975f97a 100644 --- a/src/interiors/shell.ts +++ b/src/interiors/shell.ts @@ -90,6 +90,38 @@ export interface Shell { dispose(): void; } +/** + * How far a slab is lifted per earlier slab it overlaps. See `liftOf`. + * + * 4 mm. Big enough to beat the depth buffer's resolution at office range — the + * near plane is 0.2 m and the camera orbits within about a hundred metres, so a + * 24-bit buffer resolves far finer than this — and small enough that a step + * between two floor finishes is not a step anybody can see or trip over. + */ +const SLAB_LIFT = 0.004; + +/** Do two outlines' axis-aligned bounding boxes intersect? See `liftOf`. */ +function boxesOverlap(a: readonly Point2[], b: readonly Point2[]): boolean { + const box = (points: readonly Point2[]) => { + let minX = Infinity; + let maxX = -Infinity; + let minZ = Infinity; + let maxZ = -Infinity; + for (const p of points) { + minX = Math.min(minX, p.x); + maxX = Math.max(maxX, p.x); + minZ = Math.min(minZ, p.z); + maxZ = Math.max(maxZ, p.z); + } + return { minX, maxX, minZ, maxZ }; + }; + const one = box(a); + const two = box(b); + // Touching edge-to-edge is not overlapping: the reference office's rooms abut + // along shared lines everywhere and must not all be lifted for it. + return one.minX < two.maxX && two.minX < one.maxX && one.minZ < two.maxZ && two.minZ < one.maxZ; +} + export function createShell(plan: Plan, options: ShellOptions): Shell { const { materials } = options; const parts = options.parts ?? sharedParts; @@ -129,7 +161,7 @@ export function createShell(plan: Plan, options: ShellOptions): Shell { buildWall(level.id, wallId, runs, holesByWall.get(wallId) ?? []); } for (const room of level.rooms) { - buildFloor(room); + buildFloor(room, liftOf(room, level.rooms)); buildCeiling(room); } if (drawOpenings) { @@ -206,8 +238,45 @@ export function createShell(plan: Plan, options: ShellOptions): Shell { } } - function buildFloor(room: ResolvedRoom): void { - const geometry = slabGeometry(room.outline, room.y, true); + /** + * How far to lift a slab so it does not fight the ones it overlaps. + * + * The format explicitly permits overlapping rooms and resolves *later* ones + * first (`types.ts` on `Room.outline`, `Plan.roomAt`), so "a slab on top of + * another slab" is legal and is the natural way to author a hangar: one + * concrete floor with a carpeted meeting box and a timber galley laid on it. + * The reference office avoids it by notching every room around its neighbours, + * which works when the rooms tile the plate and cannot work at all when they + * are islands in the middle of it — a rectangle with holes in it is not a + * simple polygon. + * + * Two coplanar slabs at the same `y` is a z-fight, and which one wins is the + * GPU's business. So a room that overlaps earlier rooms is lifted by a hair + * per earlier room it overlaps, which makes the depth test agree with the + * ordering the format already documents. + * + * **A pack whose rooms do not overlap is lifted by nothing**, which is why + * this counts overlaps rather than simply using the room's index: indexing + * would raise the reference office's fifteenth room by a centimetre and a half + * for no reason at all. + * + * Bounding boxes rather than true polygon intersection, deliberately. It is + * conservative in the safe direction — two rooms whose boxes touch but whose + * outlines do not get a lift they did not need, which is invisible — and it is + * a handful of comparisons rather than a clipping library. + */ + function liftOf(room: ResolvedRoom, rooms: readonly ResolvedRoom[]): number { + let overlaps = 0; + for (const other of rooms) { + if (other === room) break; + if (Math.abs(other.y - room.y) > 1e-6) continue; + if (boxesOverlap(other.outline, room.outline)) overlaps += 1; + } + return overlaps * SLAB_LIFT; + } + + function buildFloor(room: ResolvedRoom, lift: number): void { + const geometry = slabGeometry(room.outline, room.y + lift, true); if (!geometry) return; owned.push(geometry); const mesh = new THREE.Mesh(geometry, materials.forSurface(room.floor, "carpet")); diff --git a/src/interiors/types.ts b/src/interiors/types.ts index abdcd4f..c88e6c0 100644 --- a/src/interiors/types.ts +++ b/src/interiors/types.ts @@ -184,9 +184,72 @@ export interface Office { */ viewpoints: Viewpoint[]; + /** + * Where on the earth this building stands, if it stands anywhere. + * + * Optional, and its absence is a supported state rather than a gap: a pack + * with no `site` renders exactly as every pack did before this field existed, + * under the fixed interior rig. That matters because the format's promise is + * that you can author a floor plan without an account, a key or a coordinate. + * + * What it buys when you do supply it is the sun. `interiors/types.ts` used to + * say flatly that an office has no orientation on the earth, and that was + * true and also the thing standing between an office and real daylight: you + * cannot put the sun in the right place without knowing both where the + * building is and which way it faces. + */ + site?: OfficeSite; + meta?: OfficeMeta; } +/** + * A building's address in the world, in the four numbers that change what you + * see out of the window. + * + * Deliberately not a street address. Nothing here is geocoded, nothing is looked + * up, and no network call can be made from any of it — see CONTRACT.md §8 for + * why a coordinate's provenance is a licensing question in this repo. These are + * numbers a pack author types, the same as every other number in a pack. + */ +export interface OfficeSite { + /** Degrees north. */ + lat: number; + /** Degrees east. */ + lng: number; + /** + * How far this pack's level-0 floor sits above **the ground outside**, in + * metres. Not above sea level. + * + * The renderer's question is "how high up am I", not "how tall is the tower", + * and this is the number that answers it: it is exactly where the horizon + * goes. An office on the 48th floor of a downtown tower is a couple of hundred + * metres here and a shed on reclaimed land is three, and that single + * difference is most of what makes the two feel like different places. + */ + elevation: number; + /** + * The compass bearing, in degrees clockwise from true north, that the pack's + * **−Z direction** points along. + * + * `0` means the pack's "north" really is north, which is what the reference + * pack's comments have always assumed while being careful to say it was only a + * convenience. A building rotated to face the street sets this, and the sun + * then comes through the windows it actually comes through. + * + * Degrees clockwise from north, like `District.gridAngle` and unlike `Yaw` — + * a bearing reads better for a thing on a map, and radians-counter-clockwise + * reads better for a thing in a scene graph. The conversion happens once, at + * the point the two meet. + */ + heading: number; + /** + * What to call the place, for a caption. `null` or absent where the building + * has no name worth printing. + */ + label?: string; +} + /** * Provenance for a pack, and nothing the renderer reads. * diff --git a/src/main.ts b/src/main.ts index 133f713..ac1cae1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,8 +16,10 @@ import { createAtmosphere, observe, PACIFIC_MARINE_LAYER, + type Atmosphere, type WeatherObservation, } from "./engine/atmosphere.ts"; +import { officeDaylight } from "./interiors/daylight.ts"; import { createScene, type SceneHandle } from "./engine/scene.ts"; import { regionOf, @@ -79,6 +81,27 @@ const CITIES: { id: string; label: string; city: City }[] = [ { 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("#scene"); if (!canvas) throw new Error("#scene canvas missing"); @@ -223,6 +246,24 @@ let poseEditor: PoseEditor | null = null; */ let officePack: Office | 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. * @@ -357,8 +398,38 @@ function currentWeather(): WeatherObservation | 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) { + 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() { 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; const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather()); city.setLighting(atmosphere.apply(env)); @@ -417,6 +488,7 @@ async function mountCity(id: string) { officePlan = null; office?.dispose(); office = null; + officeAtmosphere = null; inside = false; minimap?.dispose(); minimap = null; @@ -562,7 +634,13 @@ async function mountCity(id: string) { atmosphere = createAtmosphere({ lng: entry.city.center.lng, 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 // is. `minVisibilityM` defaults to 4.5 km, which is honest weather and // completely wrong here: this board is ninety-four kilometres across, so @@ -703,9 +781,42 @@ async function enterOffice() { if (!built || !city) return; const { createOfficeScene, createOfficeMinimap, pack, materials } = built; 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, { 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, materials, // 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 // costs nothing beyond the module itself, and asking for it anywhere else // 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([ import("./interiors/officeScene.ts"), - import("./offices/lumbridge-hq.ts"), + entry.load(), import("./assets/materials.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" }); return { createOfficeScene: interiors.createOfficeScene, @@ -969,23 +1085,90 @@ function showDetail(text: string | null) { 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() { if (!cityNav) return; 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"); // `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 // so the visual state and the announced state cannot drift apart. b.className = "city"; b.type = "button"; - b.setAttribute("aria-pressed", String(c.id === cityId && !inside)); - b.textContent = c.label; - b.addEventListener("click", () => switchCity(c.id)); + b.setAttribute("aria-pressed", String(entry.active)); + b.textContent = entry.label; + b.addEventListener("click", () => { + if (inside) void switchOffice(entry.id); + else switchCity(entry.id); + }); 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. */ function renderLegend() { renderCityPicker(); diff --git a/src/offices/README.md b/src/offices/README.md index 6afe40c..c5f5563 100644 --- a/src/offices/README.md +++ b/src/offices/README.md @@ -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)`. - 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 -convenience for reading your own file. Offices get no `Atmosphere`, no sun and -no sky (CONTRACT.md §4); interior lighting is a fixed rig owned by the scene. +By default an office has no orientation on the earth, and calling an edge +"north" is a convenience for reading your own file. A pack with no `site` gets +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. diff --git a/src/offices/frontier-valley.ts b/src/offices/frontier-valley.ts new file mode 100644 index 0000000..6c80587 --- /dev/null +++ b/src/offices/frontier-valley.ts @@ -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 north–south 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; diff --git a/src/offices/lumbridge-hq.ts b/src/offices/lumbridge-hq.ts index 662b89a..b3b3cb2 100644 --- a/src/offices/lumbridge-hq.ts +++ b/src/offices/lumbridge-hq.ts @@ -118,16 +118,36 @@ const DEPTH = 18.0; const CEILING = 2.8; /** - * Floor to floor. Not the same as `CEILING` and the difference is not slack: - * 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 - * stair can plausibly climb to. + * The gap between one storey's ceiling and the next one's floor. + * + * In a real building this is 1.4 m of structure, ducts, trays and raised floor, + * 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 * are authored in their own floor's frame and neither has to know about the * other. */ -const STOREY = 4.2; +const STOREY = CEILING + PLENUM; /** * The commons is open to both storeys, so its walls are two rooms tall. @@ -1420,7 +1440,7 @@ const PROPS: Prop[] = [ 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. * * 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. * `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. * * 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 // half off the right-hand edge, which is a poor introduction to the largest // 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: - "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", @@ -2081,7 +2104,7 @@ const VIEWPOINTS: Viewpoint[] = [ // there is something underneath. focus: { at: { x: 37.0, z: 9.0 }, distance: 9, height: 1.8, rotation: 1.4 }, 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", levels: [LEVEL_1, LEVEL_2], 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: { 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.", diff --git a/src/test/daylight.test.ts b/src/test/daylight.test.ts new file mode 100644 index 0000000..0d23f34 --- /dev/null +++ b/src/test/daylight.test.ts @@ -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); + }); +}); diff --git a/src/test/office.test.ts b/src/test/office.test.ts index 7b9095e..8773a23 100644 --- a/src/test/office.test.ts +++ b/src/test/office.test.ts @@ -16,7 +16,7 @@ * 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 * 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 * balustrade and a decorative rail is whether it is in that list — and both * look identical in a screenshot. @@ -26,6 +26,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { Plan } from "../interiors/plan.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 }); @@ -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( - plan.levels.map((l) => [l.id, l.floorY]), - [ - ["level-1", 0], - ["level-2", 4.2], - ], + /** + * The relationship, not the number. + * + * Floor-to-floor is deliberately exaggerated in this pack — see `PLENUM` in + * `lumbridge-hq.ts` — and an assertion on the literal would have to be edited + * 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) { assert.ok( 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", () => { - const level1 = plan.levels.find((l) => l.id === "level-1"); - assert.ok(level1); - // 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. - // + const [level1, level2] = plan.levels; + assert.ok(level1 && level2); + + // 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 - // door in it also yields a `lintel` over the opening — 4.6 m of wall above a - // 2.4 m head, which is correct and is not the wall's height. + // door in it also yields a `lintel` over the opening — correct, and not the + // wall's height. const wingWalls = level1.runs.filter( (run) => run.wallId.startsWith("wing-") && run.role === "solid", ); assert.ok(wingWalls.length > 0, "the wing has no walls"); 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(); + 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}`); } }); });