From db074e9cf7c6c82245bd3c6c1e461ebe2240f54b Mon Sep 17 00:00:00 2001 From: Kartios Date: Fri, 21 Aug 2026 19:44:24 -0700 Subject: [PATCH] feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build the studios needed, across eight workstreams and one strict file partition. **The render rig was the quality ceiling.** The renderer ran three's NoToneMapping default while atmosphere drove the sun to 2.35 and assets set emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is why walls blew out and every fitting looked like a white rectangle. ACES filmic tone mapping and an explicit output colour space land in `stage.ts`, and the atmosphere intensity table and palette headroom are re-tuned against the new curve rather than left tuned for the clipping we removed. `engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally, so nothing binary is committed. There was no environment map anywhere before, so every `metalness > 0` role had nothing to reflect and rendered dull grey — a defect the code already documented against itself in `office/optimus.ts`, where a whole material role was abandoned over it, and worked around in `modelX.ts` with a fake emissive that this change deletes. Atmosphere remains the sole light owner; the rig derives from the `LightingState` it already produced. **Studio hardware exists.** There was no device concept anywhere in the product: no type, no route, no state. `devices/types.ts` fixes a declaration/state/ capability/command contract that a smart light, a thermostat, a door sensor and a charger all fit without a schema change, and both studios now carry a desk mic and a computer speaker with deterministic simulated behaviour behind an adapter seam a real API can occupy later. Reads are the demo and are open; commands are a signed-in action and are kept off the read body entirely, because a shared cache replaying a GET that turned a microphone on is exactly what the fail-closed cache default exists to prevent. **The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the response was served publicly cacheable, and the attribution hardcoded adsb.lol regardless of where the endpoint pointed — one env var away from republishing non-redistributable data under an open-terms credit. The host is now allowlisted, the credit is derived from the host actually configured, public cacheability is conditional on redistributability, and a refused endpoint demotes to simulated flights and says so in `degraded[]`. The gate is on the source, not the feature: live aircraft and their detail cards stay open to anonymous visitors. **The LA studio was never the smaller pack** — 16 rooms and 248 props against SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were ceiling troffers, it bound no props to seats, placed none of the habitat kit, and 12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather than more instances, because `furnish.ts` draws once per kind and folds colour into the batch key, so repeat instances add nothing the eye can read. **The interface stops being forty imperative mutations.** Every visibility decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the chrome has coverage for the first time. Deleted: ~100 lines of CSS and two bindings targeting elements that no longer exist, and a `body:has()` rule that shifted the desktop layout by 160px for touch controls hidden there. Fixed: the office picker tabs that drew their label and their badge on top of each other. Added: a first-run flow, because the product is two verbs and neither was ever stated on screen. Mobile is designed on its own terms instead of being the desktop with things hidden — the plan view comes back, and the keyboard-only shortcuts button is replaced by touch controls. `arena/studioOps.ts` frames the whole thing as the multi-variable environment it is, wrapping the same simulators the renderer drives rather than a headless copy. Also removed `input/vehicle.ts`, which nothing but its own test imported. Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six matrix cells, no-binaries, provenance, dependency licences, zero-config boot and arena source hashes all green. Co-Authored-By: Claude Opus 5 (1M context) --- ARCHITECTURE.md | 47 +- ARENA.md | 277 ++- index.html | 1104 +++++----- login.html | 80 +- package.json | 2 +- scripts/check-arena-source-hashes.mjs | 28 + scripts/ui-smoke.mjs | 512 +++++ server/src/app.ts | 34 +- server/src/config.ts | 156 +- server/src/devices/index.ts | 178 ++ server/src/devices/sim.ts | 166 ++ server/src/devices/store.ts | 189 ++ server/src/flights/adsb.ts | 58 +- server/src/flights/index.ts | 22 +- server/src/flights/licence.ts | 296 +++ server/src/media/bindings.ts | 28 +- server/src/media/index.ts | 2 +- server/src/routes/devices.ts | 175 ++ server/src/routes/flights.ts | 17 +- server/src/routes/health.ts | 28 +- server/src/routes/media.ts | 4 +- server/src/services.ts | 3 + server/src/test/adsbLicence.test.ts | 346 ++++ server/src/test/devices.test.ts | 572 ++++++ src/access.ts | 73 +- src/adapters/README.md | 69 +- src/adapters/http.ts | 472 ++++- src/arena/base.ts | 40 +- src/arena/californiaFlight.ts | 42 +- src/arena/checksum.ts | 131 +- src/arena/crowNav.ts | 42 +- src/arena/drive101.ts | 30 +- src/arena/index.ts | 93 +- src/arena/officeJobs.ts | 85 +- src/arena/officeNav.ts | 47 +- src/arena/rollout.ts | 97 + src/arena/scenarios.ts | 43 +- src/arena/sourceHashes.ts | 20 +- src/arena/spaces.ts | 246 +++ src/arena/studioOps.ts | 1914 +++++++++++++++++ src/arena/types.ts | 73 + src/assets/materials.ts | 202 +- src/assets/office/common.ts | 86 +- src/assets/office/desks.ts | 4 + src/assets/office/devices.ts | 467 +++++ src/assets/office/greenery.ts | 165 +- src/assets/office/habitat.ts | 37 +- src/assets/office/index.ts | 59 +- src/assets/office/optimus.ts | 37 +- src/assets/office/screens.ts | 181 +- src/assets/office/storage.ts | 19 +- src/assets/office/studio.ts | 1406 +++++++++++++ src/assets/office/surfaces.ts | 101 +- src/assets/office/tables.ts | 11 +- src/assets/palette.ts | 56 +- src/assets/parts.ts | 131 +- src/assets/textures.ts | 978 ++++++++- src/assets/vehicles/index.ts | 3 + src/assets/vehicles/modelX.ts | 1470 ++++++++++--- src/devices/adapter.ts | 402 ++++ src/devices/index.ts | 64 + src/devices/sim.ts | 536 +++++ src/devices/types.ts | 544 +++++ src/engine/atmosphere.ts | 110 +- src/engine/environmentRig.ts | 557 +++++ src/engine/flights.ts | 208 +- src/engine/officeExterior.ts | 490 +++++ src/engine/roadTraffic.ts | 10 +- src/engine/satellites.ts | 42 +- src/engine/scene.ts | 137 +- src/engine/stage.ts | 80 + src/engine/starlinkMesh.ts | 31 +- src/engine/structures.ts | 331 ++- src/engine/terrain.ts | 30 +- src/engine/types.ts | 11 +- src/index.ts | 20 +- src/input/vehicle.ts | 81 - src/interiors/devices.ts | 275 +++ src/interiors/officeScene.ts | 403 +++- src/interiors/plan.ts | 371 +++- src/interiors/types.ts | 68 + src/main.ts | 2166 ++++++++++---------- src/offices/README.md | 232 ++- src/offices/frontier-valley.ts | 9 +- src/offices/lumbridge-hq.ts | 79 +- src/offices/mateo-court.ts | 1166 ++++++++++- src/offices/operations/mateo-court.ts | 14 +- src/offices/sites.ts | 49 + src/profile/webcamPanel.ts | 26 +- src/server/wire.ts | 188 +- src/test/arena.test.ts | 71 +- src/test/arena/.gitkeep | 5 + src/test/arena/checksumHardening.test.ts | 157 ++ src/test/arena/registry.test.ts | 181 ++ src/test/arena/spaces.test.ts | 267 +++ src/test/arena/studioOps.test.ts | 657 ++++++ src/test/assets/.gitkeep | 5 + src/test/assets/deviceAssets.test.ts | 219 ++ src/test/assets/fakeCanvas.ts | 110 + src/test/assets/habitatKit.test.ts | 217 ++ src/test/assets/modelXGeometry.test.ts | 354 ++++ src/test/assets/screenContent.test.ts | 228 +++ src/test/assets/studioAssets.test.ts | 219 ++ src/test/data/.gitkeep | 5 + src/test/data/adapters.test.ts | 558 +++++ src/test/data/deviceLayer.test.ts | 327 +++ src/test/data/deviceTypes.test.ts | 371 ++++ src/test/data/devicesSim.test.ts | 377 ++++ src/test/data/wireContract.test.ts | 380 ++++ src/test/freewayWorld.test.ts | 64 +- src/test/integration/barrel.test.ts | 138 ++ src/test/integration/sceneWiring.test.ts | 652 ++++++ src/test/office.test.ts | 56 +- src/test/packs/.gitkeep | 5 + src/test/packs/arrivalAnchors.test.ts | 131 ++ src/test/packs/deviceDeclarations.test.ts | 375 ++++ src/test/packs/mateoContent.test.ts | 379 ++++ src/test/packs/packRegression.test.ts | 127 ++ src/test/render/.gitkeep | 5 + src/test/render/environmentRig.test.ts | 355 ++++ src/test/render/materialRoles.test.ts | 366 ++++ src/test/render/structuresBatching.test.ts | 197 ++ src/test/render/textureMaps.test.ts | 508 +++++ src/test/render/toneMapping.test.ts | 214 ++ src/test/ui/.gitkeep | 5 + src/test/ui/chromeState.test.ts | 791 +++++++ src/test/ui/devicePanel.test.ts | 233 +++ src/test/ui/fakeDom.ts | 354 ++++ src/test/ui/mount.test.ts | 517 +++++ src/test/ui/onboarding.test.ts | 184 ++ src/test/ui/shortcuts.test.ts | 246 +++ src/test/ui/stylesheet.test.ts | 228 +++ src/test/ui/tokens.test.ts | 115 ++ src/test/vehicle/.gitkeep | 5 + src/test/vehicle/metreScale.test.ts | 301 +++ src/test/vehicle/officeExterior.test.ts | 380 ++++ src/test/vehicle/telemetry.test.ts | 332 +++ src/test/vehicleInput.test.ts | 48 - src/tools/godmode.ts | 72 +- src/transport/exteriorVehicle.ts | 383 ++++ src/transport/vehicleController.ts | 24 +- src/transport/vehicleSim.ts | 30 +- src/transport/vehicleTelemetry.ts | 571 ++++++ src/ui/chromeState.ts | 807 ++++++++ src/ui/devicePanel.ts | 449 ++++ src/ui/hud.ts | 319 +++ src/ui/mount.ts | 750 +++++++ src/ui/onboarding.ts | 464 +++++ src/ui/shortcuts.ts | 666 ++++++ src/ui/tokens.ts | 285 +++ 150 files changed, 36237 insertions(+), 2586 deletions(-) create mode 100644 scripts/ui-smoke.mjs create mode 100644 server/src/devices/index.ts create mode 100644 server/src/devices/sim.ts create mode 100644 server/src/devices/store.ts create mode 100644 server/src/flights/licence.ts create mode 100644 server/src/routes/devices.ts create mode 100644 server/src/test/adsbLicence.test.ts create mode 100644 server/src/test/devices.test.ts create mode 100644 src/arena/rollout.ts create mode 100644 src/arena/spaces.ts create mode 100644 src/arena/studioOps.ts create mode 100644 src/assets/office/devices.ts create mode 100644 src/assets/office/studio.ts create mode 100644 src/devices/adapter.ts create mode 100644 src/devices/index.ts create mode 100644 src/devices/sim.ts create mode 100644 src/devices/types.ts create mode 100644 src/engine/environmentRig.ts create mode 100644 src/engine/officeExterior.ts delete mode 100644 src/input/vehicle.ts create mode 100644 src/interiors/devices.ts create mode 100644 src/test/arena/.gitkeep create mode 100644 src/test/arena/checksumHardening.test.ts create mode 100644 src/test/arena/registry.test.ts create mode 100644 src/test/arena/spaces.test.ts create mode 100644 src/test/arena/studioOps.test.ts create mode 100644 src/test/assets/.gitkeep create mode 100644 src/test/assets/deviceAssets.test.ts create mode 100644 src/test/assets/fakeCanvas.ts create mode 100644 src/test/assets/habitatKit.test.ts create mode 100644 src/test/assets/modelXGeometry.test.ts create mode 100644 src/test/assets/screenContent.test.ts create mode 100644 src/test/assets/studioAssets.test.ts create mode 100644 src/test/data/.gitkeep create mode 100644 src/test/data/adapters.test.ts create mode 100644 src/test/data/deviceLayer.test.ts create mode 100644 src/test/data/deviceTypes.test.ts create mode 100644 src/test/data/devicesSim.test.ts create mode 100644 src/test/data/wireContract.test.ts create mode 100644 src/test/integration/barrel.test.ts create mode 100644 src/test/integration/sceneWiring.test.ts create mode 100644 src/test/packs/.gitkeep create mode 100644 src/test/packs/arrivalAnchors.test.ts create mode 100644 src/test/packs/deviceDeclarations.test.ts create mode 100644 src/test/packs/mateoContent.test.ts create mode 100644 src/test/packs/packRegression.test.ts create mode 100644 src/test/render/.gitkeep create mode 100644 src/test/render/environmentRig.test.ts create mode 100644 src/test/render/materialRoles.test.ts create mode 100644 src/test/render/structuresBatching.test.ts create mode 100644 src/test/render/textureMaps.test.ts create mode 100644 src/test/render/toneMapping.test.ts create mode 100644 src/test/ui/.gitkeep create mode 100644 src/test/ui/chromeState.test.ts create mode 100644 src/test/ui/devicePanel.test.ts create mode 100644 src/test/ui/fakeDom.ts create mode 100644 src/test/ui/mount.test.ts create mode 100644 src/test/ui/onboarding.test.ts create mode 100644 src/test/ui/shortcuts.test.ts create mode 100644 src/test/ui/stylesheet.test.ts create mode 100644 src/test/ui/tokens.test.ts create mode 100644 src/test/vehicle/.gitkeep create mode 100644 src/test/vehicle/metreScale.test.ts create mode 100644 src/test/vehicle/officeExterior.test.ts create mode 100644 src/test/vehicle/telemetry.test.ts delete mode 100644 src/test/vehicleInput.test.ts create mode 100644 src/transport/exteriorVehicle.ts create mode 100644 src/transport/vehicleTelemetry.ts create mode 100644 src/ui/chromeState.ts create mode 100644 src/ui/devicePanel.ts create mode 100644 src/ui/hud.ts create mode 100644 src/ui/mount.ts create mode 100644 src/ui/onboarding.ts create mode 100644 src/ui/shortcuts.ts create mode 100644 src/ui/tokens.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2f3f6a3..ba0e066 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -87,7 +87,10 @@ tera/ ├── src/media/ # separate screen signaling, ICE and texture lifecycles ├── src/profile/ # local profile plus ephemeral webcam consent/capture ├── src/adapters/ # generic HTTP and sample-data boundaries -└── src/main.ts # the standalone demo app +├── src/devices/ # smart hardware: types, a fixed-step simulator, the feed seam +├── src/arena/ # the RL environments, wrapping the same simulators the renderer drives +├── src/ui/ # every interface decision, and the one module that writes to the page +└── src/main.ts # the standalone demo app — assembly, and nothing else ``` The split that matters: **`engine` never imports `cities`, and neither imports @@ -101,6 +104,27 @@ and a city share the projection, camera, lighting and render loop, and differ only in what they put in the scene. That is why `scene.ts` owns the loop and knows nothing about terrain specifically. +`ui` is the newest of these and the split inside it is the one worth knowing. +`ui/chromeState.ts` is a **pure function**: hand it a plain object describing what +the application is, get a plain object back describing what the interface should +be. `ui/mount.ts` is the only module in the product that writes to the DOM, and it +writes exactly what that function returned. `main.ts` therefore makes one call — +`chrome.apply(chromeState(inputs))` — where it used to make about forty +`element.hidden = someCondition` decisions with the condition and the write on the +same line, none of which could be reached without a WebGL context. The rule that +keeps it that way: a new piece of chrome is a field in `ChromeInputs`, a decision +in `chromeState` and a write in `mount`, and never a fortieth mutation in the app. + +`devices` follows the same shape one level down, and for the reason §7 gives about +presence: a `DeviceDeclaration` is **authored** — it says a microphone exists, what +it can be asked to do and which prop is its hardware — and lives in the office +pack, public by construction. A `DeviceState` is what that microphone is *hearing*, +never appears in a pack, and arrives from a route that can refuse an anonymous +caller. `src/interiors/devices.ts` is the render layer for the first and +`src/devices/sim.ts` is the state machine behind the second; the arena imports the +state machine directly, so what a policy is trained against and what a viewer is +looking at are the same code. + `transport` also stays outside the renderer. A `TransportPack` is plain JSON and `VehicleSimulation` returns plain geographic poses. Three.js enters only in the `roadTraffic` render layer, which projects poses through the active `World`. @@ -372,6 +396,27 @@ boards; walkable offices; procedural actors, vehicles and aircraft; optional authoritative realtime; local webcam faces; and separately authorized office screen signaling. `BUILD_PLAN.md` is the milestone evidence ledger. +Four things landed together and are worth naming, because each crosses a boundary +this document describes: + +- **One environment map for the page.** `src/engine/environmentRig.ts` derives a + sky (city) or a room (office) from the `LightingState` that `Atmosphere` already + decided, so the eleven metallic material roles and the Model X's clearcoat have + something to reflect. It sits at the *end* of §4's one-way street: it is handed a + decision and never makes one, and it constructs no light. One rig is built beside + the one renderer and shared by every scene; each scene releases itself from it on + dispose. +- **The interface is a pure function plus an applier.** See §2 above. +- **Smart hardware in the studios**, declared in packs and simulated in the tab + when a deployment has no device source — the same anon-first fallback + `SimulatedFlights` and `sample.ts` already make. +- **A Model X on each studio's arrival apron**, driven by + `src/transport/vehicleTelemetry.ts`, and overhead traffic in a sited office's + sky drawn from the same `FlightSource` the board outside is polling. The + aircraft are placed on a dome at their true bearing and elevation rather than at + their true range: an airliner at twenty kilometres is past a room-scale far + plane, and a map symbol drawn in 3-D is what §4's traffic has always been. + The source code is Apache-2.0. `src/assets/LICENSE-ART` additionally dedicates the artistic output of the procedural asset library under CC0-1.0. `PROVENANCE.json`, the dependency allowlist and SPDX SBOM gate the distribution. diff --git a/ARENA.md b/ARENA.md index 2676c4d..7c1dace 100644 --- a/ARENA.md +++ b/ARENA.md @@ -1,9 +1,9 @@ # Tera Arena environments `@lumbridge/tera/arena` is Tera's renderer-independent RL boundary. It imports -no Three.js scene, canvas, DOM input, network client, or asset code. The five -shipped environments wrap the same fixed-step controllers and plans used by the -interactive client: +no Three.js scene, canvas, DOM input, network client, or asset code — there is a +test that greps for it. The six shipped environments wrap the same fixed-step +controllers and plans used by the interactive client: | Environment | Existing simulator | Goal | Safety terminal | |---|---|---|---| @@ -12,6 +12,11 @@ interactive client: | `office-jobs-v1` | `Plan` + `robotRoutes` + `robotActivity` | complete an authored simulated patrol/delivery/inspection job | collision stall or repeated invalid interaction | | `crow-nav-v1` | `ActorController` in crow flight mode | reach a 3D waypoint | altitude/horizontal envelope contact | | `california-flight-v1` | `AircraftController` | reach a geographic/altitude waypoint | California flight-envelope contact | +| `studio-ops-v1` | all of `Plan` + `robotActivity` + `createSimulatedDevices` + `createSimulatedVehicleTelemetry` + `solarPosition` | run the studio: complete the robot's job without wasting the microphone, the reserve or the departure | collision stall, invalid interaction, reserve exhaustion, missed departure | + +The first five isolate one controller each. `studio-ops-v1` is the one where the +variables are coupled, and it is the frame the whole product is presented in: +see [studio-ops-v1](#studio-ops-v1) below. ## Contract @@ -68,6 +73,110 @@ own package or service. action and observation fields, component reward meanings, public split ids, fixed step, maximum steps, safety terminals, and baseline claims. +### Selection changed in v2, and every manifest's version says so + +`ArenaScenarioRegistry` used to resolve a bare `{ split }` request with +`candidates[seed % candidates.length]`, which binds every seed anybody has run +to a scenario's *position in an array literal*. Inserting a scenario in the +middle of a split — the most ordinary edit there is, and one a reviewer reads as +purely additive — silently remapped every seed past it. Nothing failed; the +numbers in a results table just stopped meaning what they meant. + +Selection is now highest-random-weight over the scenario **id**: each candidate +scores `deriveArenaSeed(seed, "::")` and the highest wins. +Adding a scenario moves only the seeds the new id actually wins, removing one +moves only the seeds it held, and reordering the literal moves nothing at all. +Explicit ids were never affected and are not affected now. + +Because that changes which scenario an existing seed resolves to, the five +environments that shipped at `version: 1` are at `version: 2`. Snapshots and +traces taken under v1 are rejected rather than silently resumed into a different +task, which is exactly what `version` is for. + +## Spaces, vectors and rollouts + +`observationFields` tells a reader what an environment sees. It does not tell a +trainer how many numbers that is, so every consumer wrote its own encoder +against the environment's TypeScript source — a fork of the observation contract +kept outside this repository and silently invalidated by any field added to it. + +Each manifest now also carries `observationSpace` and `actionSpace`: the same +fields, in the same order, each with an encoding. + +```ts +import { + ARENA_ENVIRONMENTS, + flattenObservation, + observationWidth, + rollout, + structureAction, +} from "@lumbridge/tera/arena"; + +const env = ARENA_ENVIRONMENTS["studio-ops-v1"](); +const { observation } = env.reset(115, { split: "train" }); + +flattenObservation("studio-ops-v1", observation).length === observationWidth("studio-ops-v1"); +env.step(structureAction("studio-ops-v1", policyOutputVector)); + +const { total, steps, final } = rollout(env, (obs) => myPolicy(obs), { + seed: 115, + scenario: { split: "dev" }, +}); +``` + +| `kind` | Slots | Encoding | +|---|---|---| +| `float` | 1 | the **clamped raw value**, with `low`/`high` published so a trainer can normalise it its own way | +| `bool` | 1 | `0` or `1` | +| `enum` | one per `values` | one-hot; a value outside the vocabulary is all zeros, which reads as "none of these" rather than colliding with the first member | +| `id` | 1 | a stable hash in `[0, 1)` for an open vocabulary — a change detector, not something to learn from | + +`low` and `high` are *declared bounds*, not guarantees. Quietly wrong bounds +should show up as a saturated input rather than as a silently rescaled one, +which is why floats are not normalised here. + +`ARENA_ENVIRONMENTS` is the env-id → constructor registry the catalogue was +missing: a harness handed `"drive-101-v1"` off a config file previously had to +keep its own switch. Constructors rather than instances, because an episode is +state and two rollouts in flight need two objects. + +`rollout(env, policy, { seed, scenario, maxSteps })` is the loop every consumer +— including this repository's own test file — used to write by hand. + +## Determinism across machines, and the arithmetic it forbids + +Traces are verified by exact equality, and a verifier runs on hardware the +producer never saw. `Math.sin`, `Math.atan2`, `Math.pow` and `Math.hypot` are +not required by IEEE-754 or ECMA-262 to be correctly rounded — only `+`, `-`, +`*`, `/` and `Math.sqrt` are — so two conforming engines can disagree in the +last place. `studio-ops-v1` was the first environment to put a solar position +and a set of slant ranges into that comparison, and an unguarded float there is +the worst failure this package can have: silent, indistinguishable from fraud, +and only ever somebody else's problem. + +Two defences, and both are needed: + +1. `canonicalJson` quantises every non-integer it hashes to + `ARENA_CHECKSUM_DECIMALS` (9) places, and `replay` compares rewards the same + way rather than with `!==`. +2. The environment quantises at the point a transcendental is *called*, so the + rounded value is the one that propagates — `quantizeObservable` in + `studioOps.ts`. Rounding only at the checksum cannot rescue a simulation that + has already accumulated a divergence, because the difference then grows with + every step instead of staying in the last place. + +`canonicalJson` also **throws** on a `Map`, a `Set`, a `Date`, a typed array or +any other non-plain object. It used to read their own enumerable keys — of which +those have none — and emit `{}`, so a populated `Map` checksummed identically to +an empty object and to every other `Map`. `ResolvedRobotOperations` already +holds `ReadonlyMap`s. + +`restore()` now also checks `sourceHashes`. `envHash` covers the *manifest*, +which is semantics and does not move when the physics under it does: edit a +walker's collision epsilon, leave the manifest alone, and a snapshot taken +before the edit used to restore cleanly and resume into a different simulation. +`replay` always guarded this; a checkpoint is exactly as dangerous as a trace. + ## Reward and baseline policy Rewards are counterweighted rather than progress-only. Progress and sparse @@ -91,13 +200,169 @@ future seeded schedule entries are not leaked. SF and LA definitions are explicit same-floor scenarios tied to resolved room/prop anchors. They are demonstration data and never claim to describe live staff or company operations. +## studio-ops-v1 + +The environment the product is framed as. Every other one isolates a controller; +this one is a studio, and the reason it exists is that the variables are +coupled. + +### What it wraps + +Nothing here is a headless reimplementation. That is the whole claim: + +| Wrapped | Where the browser uses the same object | +|---|---| +| `Plan(LUMBRIDGE_HQ)` / `Plan(MATEO_COURT)` | `interiors/officeScene.ts` builds the room from it | +| `resolveRobotOperations` + `createRobotActivity` | the robots a visitor watches working | +| `createSimulatedDevices` | the mic and speaker panel, and the LEDs on the hardware | +| `createSimulatedVehicleTelemetry` | the Model X parked on the apron | +| `solarPosition` | `engine/atmosphere.ts`'s light rig | + +A second implementation "for the trainer" would be a simulation nobody can look +at, optimised against a picture nobody can reproduce. The point of building an +environment inside a renderer is that the thing being optimised is the thing +being shown. + +The one thing it does *not* wrap is `engine/flights.ts`, because that module +imports three.js on its first line and `src/arena/` may not. The overflight +schedule here is deliberately less than that module — a pass time, a miss +distance, an altitude and a speed, and no callsign, registration or route — +because all the reward needs is a slant range and anything more would look like +a claim about a real flight. + +### Observation — 44 fields in five groups + +- **the agent and its job** (18): `officeId`, `levelId`, `x`, `z`, `mode`, + `phase`, `jobKind`, `payload`, `battery`, `jobProgress`, `nextStationId`, + `nextX`, `nextZ`, `deltaX`, `deltaZ`, `distanceToNextM`, `canInteract`, + `blockedStreak` +- **the sky over the roof** (10): `hourOfDay`, `sunAltitudeDeg`, + `sunAzimuthDeg`, `cloudCover`, `precipitation`, `visibilityKm`, `windKph`, + `windDirDeg`, `weatherCondition`, `weatherReported` +- **the hardware on the desk** (8): `micPowered`, `micGainDb`, `micLevelDb`, + `micMuted`, `speakerPowered`, `speakerVolume`, `speakerPlaying`, + `deskOccupied` +- **the car on the apron** (4): `vehicleSocPct`, `vehicleCabinC`, + `vehiclePluggedIn`, `vehicleReadyByDeparture` +- **traffic overhead** (2): `aircraftOverheadCount`, `nearestAircraftSlantM` +- **the shared reserve and the clock** (2): `energyReservePct`, + `stepsToDeparture` + +The last two are additions to the field list the build spec fixed, and they are +there for one reason: a terminal an agent cannot see coming is not a task, it is +a trap. `energyReservePct` is what `battery-depleted` counts down and +`stepsToDeparture` is what `departure-missed` counts down. + +`deskOccupied` is an **observation about the robot**, not a claim about a +person. The only body in the building is the one the policy is driving, and it +is fed to `createSimulatedDevices` through the `setOccupancy` input that module +documents — the same input a deployment with a real presence source would use. + +### Action + +One struct: `{ x, z, interact, micGain, micMute, speakerVolume, speakerPlay, +vehiclePrecondition, vehicleCharge }`, every field clamped in +`normalizeAction`, with `micGain` and `speakerVolume` clamped to the same +`DEVICE_RANGES` the browser's panel clamps to. + +Device *power* is deliberately not an action. A studio's rig being on is a fact +about the episode rather than a decision inside it; both devices are powered at +`reset` and the mute is the lever. An environment whose optimal policy opens +with two mandatory "turn it on" presses is one whose first two steps carry no +information. + +### Reward — what it optimises + +Thirteen components, summed by `base.ts` and never authored as a total. + +| Component | Pays for | Pulled against by | +|---|---|---| +| `navigation`, `job`, `success` | reaching and working the authored station | `time`, `control` | +| `audioReady` | a live, correctly-gained mic and quiet monitoring **at an occupied desk** | `audioWaste`, `energy`, `noise` | +| `audioWaste` | — | a hot mic or a playing speaker at an *empty* desk | +| `energy` | — | every kilowatt drawn, as a share of the reserve, priced up as `cloudCover` rises | +| `vehicleReady` | closing the gap to a charged, comfortable car, plus a bonus at the departure | `energy`: the apron post draws from the same reserve | +| `noise` | — | playback under an aircraft, in wind, or into a live microphone | +| `collision`, `interaction`, `safety` | — | the four failure modes | + +The properties that keep it from being gamed: + +- **`vehicleReady` is potential-based.** It pays the *change* in a bounded + readiness rather than the level, so it telescopes over an episode and + plugging/unplugging round-trips to zero instead of paying twice. +- **Parking at the desk with a hot mic loses.** `audioReady` is smaller per step + than `time` plus the reserve draw, so an agent that stops working to collect + it finishes below an agent that does the job. +- **`audioWaste` is twice `audioReady`.** Leaving the mic live for the whole + episode to catch the short window where it pays is a net loss. +- **`noise` needs the sky.** Monitoring is worth `0.004` a step and bleed into a + live mic costs `0.009` a unit of volume, so there is an optimum in the volume + knob rather than a binary; an aircraft directly overhead costs `0.05` and + buries the gain at any volume. A policy cannot decide whether to press play + without reading `nearestAircraftSlantM` and `windKph`. +- **Charging is cheap in time and expensive in reserve.** Reaching the departure + target costs about a fifth of the whole allowance. Leaving the car plugged in + past the target pays nothing and keeps charging. + +### Terminals + +`job-complete` is the goal. `collision-stall`, `wrong-interaction-limit`, +`battery-depleted` (the studio's reserve, or the robot's own pack) and +`departure-missed` are the four safety terminals, and a test reaches each of +them with a targeted policy. `max-steps` truncates at 1200 steps — 120 seconds +of simulated time at the 0.1 s fixed step. + +The reserve is the episode's energy **allowance**, not a claim about a +building's battery: two minutes is far too short for a real site battery to +matter, so the allowance is sized so that a studio at rest cannot come close to +exhausting it and a studio charging a car off it runs out with a couple of +hundred steps to spare. That is a design decision about where the trade-off +should bite, stated rather than dressed up as a specification. + +### Weather and traffic are scenario parameters + +`{ startEpochMs, weatherProfileId, cloudCoverBase, precipitationBase, +windKphBase, windDirDeg, visibilityKm, ambientC, aircraftScheduleSeed }` live in +the scenario, are covered by `scenario.hash`, and evolve by a pure function of +(scenario, step). Four oscillators and a schedule of overflights; no fetch +anywhere. A network read inside `step()` would make `replay()` impossible and +every published trace unverifiable a day later. + +A real NWS observation may be the *source* of a scenario — captured once, frozen +into those parameters, and marked `weatherReported: true`. **None of the shipped +scenarios is such a capture**: they are invented profiles and every one of them +reports `weatherReported: false`. The field exists so an operator who does +freeze an observation has somewhere honest to record it. + +### The evaluation boundary, said again + +`studio-ops-v1` is the environment somebody will most want to hold out, so it is +worth repeating what the section above says: **a private eval published in the +client package is not private.** The five scenarios here are public fixtures for +smoke-proofing and demonstration. An operator running a real evaluation should +keep its scenario definitions in its own package or service and instantiate this +same contract against them; nothing in `tera.arena/v1` requires a scenario to +have shipped in this repository. + +Two more honest notes about the shipped fixtures. Only two of the five have a +desk the robot can actually occupy — `sf-studio-monitor` is the one station in +either pack that stands at a microphone's seat — so in the other three the +correct play is to mute and get on with the job, which is a real operating case +and is why they are included. And `dev-la-marine-layer-loft-delivery` runs on +mateo-court's level 2, where every device is on level 1: its desk is never +occupied, on purpose. + ## Adding an environment Keep the environment under `src/arena/`, wrap an existing renderer-neutral controller or plan, expose train/dev scenario ids and a manifest, and add it to -`ARENA_MANIFESTS`. Do not import a scene adapter to obtain simulation state. -Update `scripts/check-arena-source-hashes.mjs`, pin the new SHA-256 values, and -add the same determinism, floor, scripted, safety, snapshot and replay proofs. +`ARENA_MANIFESTS` **and** `ARENA_ENVIRONMENTS`. Declare `observationSpace` and +`actionSpace` alongside the field lists. Do not import a scene adapter, three.js +or the network to obtain simulation state. Quantise anything a transcendental +produced before it is observed or rewarded on. Update +`scripts/check-arena-source-hashes.mjs` with every file the environment wraps, +pin the new SHA-256 values, and add the same determinism, floor, scripted, +safety, snapshot and replay proofs. Run the complete gate: diff --git a/index.html b/index.html index 94c2897..d0a761c 100644 --- a/index.html +++ b/index.html @@ -82,7 +82,7 @@ * the bottom of the body can be on screen before a single module has been * fetched, let alone before the California heightfield has been built. * - * Two constraints shape every rule below. + * Three constraints shape every rule below. * * The first is that this chrome floats over a *photographic* background * that changes from a bright noon sky to a nearly black one and back. A @@ -95,44 +95,71 @@ * * The second is that there is exactly one accent. `#f2b134` is the * identity and it is spent only on things that are *active* — the current - * chapter, the current city, the office door, a focus ring. The moment it + * chapter, the current board, the office door, a focus ring. The moment it * also means "heading" and "border" and "hover" it stops meaning * anything, which is what a second pass at this looked like before it was * pulled back. + * + * The third arrived with this pass and is the one that changed the layout + * rather than the paint. The left column was **seven unrelated dark + * cards** stacked on top of each other — a title card, a board strip, a + * primary button, two secondary buttons, a chapter list and a caption — + * with no hierarchy between them, three different button treatments, four + * different internal paddings and two different widths. Seven surfaces + * that are always all present are not seven cards; they are one panel with + * six sections in it, and that is what it is now: one glass surface, one + * padding, hairline rules between titled groups, and exactly two button + * treatments — filled amber for the door, ghosted for everything else. + * + * ---- Tokens ---------------------------------------------------------- + * The block below is generated from `src/ui/tokens.ts` and asserted + * against it by `src/test/ui/tokens.test.ts`. Do not hand-edit a value + * here: change it there and the test will tell you if the two have + * drifted. Four other modules inject their own stylesheets at runtime + * (`tools/godmode.ts`, `profile/webcamPanel.ts`, `profile/editor.ts`, + * `media/officeScreenPanel.ts`) and all four read these same names through + * `var(--x, fallback)`, which is why the names are worth owning in one + * place at all. + * + * The `--z-*` scale in particular is the *whole* stacking order of the + * product, in one list, for the first time: chrome, dock, play, + * instrument, modal, alert, boot. Before it there were seven raw literals + * across five files and two collisions nobody had chosen. There are no + * bare `z-index` numbers anywhere in this file or in those four modules + * any more, and a test checks it. */ :root { --amber: #f2b134; --amber-lit: #ffc555; --amber-ink: #ffd68a; - - /* One glass recipe, two weights. The strong one is for things that must - be read over the brightest part of the sky: the boot card and the - shortcuts sheet. */ --glass: rgba(9, 13, 18, 0.62); --glass-strong: rgba(9, 13, 18, 0.86); --glass-inset: rgba(255, 255, 255, 0.05); --hairline: rgba(255, 255, 255, 0.11); --blur: blur(14px) saturate(1.2); --shadow: 0 6px 22px rgba(3, 6, 10, 0.45); - - /* Four steps of ink and no more. Anything that wanted a fifth was - saying something the type scale should have said instead. */ --ink: rgba(255, 255, 255, 0.78); --ink-2: rgba(255, 255, 255, 0.56); --ink-3: rgba(255, 255, 255, 0.4); --ink-4: rgba(255, 255, 255, 0.26); - - /* 4px rhythm. Every gap, pad and offset below is a multiple of it, so - the columns line up without anyone having to nudge a value. */ --s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 24px; - + --s6: 32px; --r: 8px; --r-sm: 5px; + --r-pill: 999px; --t: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --tap: 44px; + --z-chrome: 3; + --z-dock: 4; + --z-play: 5; + --z-instrument: 6; + --z-modal: 10; + --z-alert: 12; + --z-boot: 20; } * { box-sizing: border-box; } @@ -202,148 +229,184 @@ outline-offset: 2px; } - /* ---- Left column ------------------------------------------------------ */ + /* ---- The left column -------------------------------------------------- + One surface, six groups, one padding. See the third constraint at the + top of this stylesheet for what this replaced and why. */ #panel { position: fixed; top: 0; left: 0; + z-index: var(--z-chrome); padding: var(--s4); - width: min(19.5rem, calc(100vw - var(--s4) * 2)); + width: min(20.5rem, calc(100vw - var(--s4) * 2)); max-height: 100dvh; + display: flex; + flex-direction: column; + pointer-events: none; + transition: transform var(--t), opacity var(--t); + } + .panel-card { + pointer-events: auto; + display: flex; + flex-direction: column; + min-height: 0; + max-height: calc(100dvh - var(--s4) * 2); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.18) transparent; + padding: 0; + } + + /* Every group is the same box: the same inline padding, the same block + padding, a hairline above it. Four independently-tuned paddings is what + made the old column look assembled rather than designed. */ + .panel-group { + padding: var(--s3); + border-top: 1px solid var(--hairline); + display: flex; + flex-direction: column; + gap: var(--s2); + } + .panel-group[hidden] { display: none; } + .panel-head { + padding: var(--s3); display: flex; flex-direction: column; gap: var(--s1); - pointer-events: none; - transition: transform var(--t), opacity var(--t); } - #panel > * { pointer-events: auto; } + /* The title group has nothing above it, so nothing rules it off. */ + .panel-head + .panel-group { border-top: 1px solid var(--hairline); } + .group-title { + margin: 0; + color: var(--ink-4); + } h1 { margin: 0; - font-size: 11px; - letter-spacing: 0.2em; + font-size: 13px; + letter-spacing: 0.16em; text-transform: uppercase; color: var(--amber); } - #subtitle { margin: var(--s1) 0 0; font-size: 10px; letter-spacing: 0.06em; color: var(--ink-3); } + #subtitle { margin: 0; color: var(--ink-3); } .clock { - margin: var(--s2) 0 0; + margin: 0; font-size: 10px; letter-spacing: 0.06em; color: var(--ink-2); font-variant-numeric: tabular-nums; } + #clock:empty { display: none; } /* The `#hour` scrubber and its `now` button used to live here. They were god-only, and the godmode panel now owns the same override with a date as well as an hour — two writers for one value, of which this was the one that silently threw the date away. `main.ts` says the rest. */ - .cities { display: flex; gap: var(--s1); } - .city { + /* ---- The board strip -------------------------------------------------- + One segmented control, pointed at whichever list is currently the answer + to "which of these am I in": worlds outside a building, buildings inside + one. + + THE OVERLAP BUG, which was on every screenshot of every studio: an + office tab is a name plus an ACTIVE/BUILDING badge, and it was laid out + as `grid-template-columns: minmax(0, 1fr) auto` with nothing clipping + the first column. `minmax(0, 1fr)` lets a track shrink below its + content, and a `` with no `overflow` happily paints outside its + track — so at the ~85px each tab gets in a three-up strip, the name was + drawn straight over the badge in all three tabs. Two columns was the + wrong shape for the space anyway. It is one column and two rows now: + name over badge, the name ellipsised, and there is no width at which the + two can reach each other. */ + .boards { display: flex; gap: var(--s1); } + .board { flex: 1; + min-width: 0; + display: grid; + gap: 1px; + text-align: left; font: inherit; font-size: 11px; - padding: var(--s2) var(--s1); + padding: var(--s2); cursor: pointer; border: 1px solid var(--hairline); border-radius: var(--r-sm); - background: var(--glass); - backdrop-filter: var(--blur); - -webkit-backdrop-filter: var(--blur); - box-shadow: var(--shadow); + background: rgba(255, 255, 255, 0.04); color: var(--ink-2); - transition: background var(--t), color var(--t); + transition: background var(--t), color var(--t), border-color var(--t); } + .board__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .board__status { color: var(--ink-4); } @media (hover: hover) { - .city:hover { background: rgba(40, 48, 58, 0.7); color: var(--ink); } + .board:hover { background: rgba(255, 255, 255, 0.1); color: var(--ink); } } - .city[aria-pressed="true"] { background: rgba(242, 177, 52, 0.22); color: var(--amber-ink); } - .office-choice { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: var(--s1); - text-align: left; + .board[aria-pressed="true"] { + background: rgba(242, 177, 52, 0.2); + border-color: rgba(242, 177, 52, 0.4); + color: var(--amber-ink); } - .office-choice__status { - font-size: 8px; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--ink-3); - } - .office-choice[aria-pressed="true"] .office-choice__status { color: var(--amber-ink); } + .board[aria-pressed="true"] .board__status { color: var(--amber-ink); } - .enter { - font: inherit; - font-size: 12px; - padding: var(--s2) var(--s3); - cursor: pointer; - text-align: left; - border: 1px solid transparent; - border-radius: var(--r-sm); - color: #10161d; - background: var(--amber); - font-weight: 600; - box-shadow: var(--shadow); - transition: background var(--t); - } - @media (hover: hover) { .enter:hover { background: var(--amber-lit); } } - - .walk { + /* ---- Actions ---------------------------------------------------------- + TWO treatments, not three. Filled amber is the door and only the door — + one filled thing on screen, and it is the verb the whole product is + about. Everything else is the same ghost button at the same height, so + "walk", "fly", "screens" and "hardware" read as siblings instead of as + three unrelated inventions. */ + .actions { display: flex; flex-direction: column; gap: var(--s1); } + .act { font: inherit; font-size: 11px; + min-height: 36px; padding: var(--s2) var(--s3); cursor: pointer; text-align: left; - border: 1px solid rgba(242, 177, 52, 0.48); + border: 1px solid rgba(242, 177, 52, 0.4); border-radius: var(--r-sm); color: var(--amber-ink); - background: rgba(242, 177, 52, 0.1); - box-shadow: var(--shadow); + background: rgba(242, 177, 52, 0.08); transition: background var(--t), border-color var(--t); } @media (hover: hover) { - .walk:hover { background: rgba(242, 177, 52, 0.2); border-color: var(--amber); } + .act:hover { background: rgba(242, 177, 52, 0.18); border-color: var(--amber); } } - .walk[aria-pressed="true"] { background: rgba(242, 177, 52, 0.25); } + .act[aria-pressed="true"] { background: rgba(242, 177, 52, 0.25); } + .act--primary { + font-size: 12px; + font-weight: 600; + min-height: 40px; + border-color: transparent; + color: #10161d; + background: var(--amber); + } + @media (hover: hover) { .act--primary:hover { background: var(--amber-lit); } } - /* The public-office note. Amber-edged rather than amber-filled: it is an - explanation, not an action, and the one filled amber thing on screen - should stay the door. */ - .badge { + /* ---- Notes ------------------------------------------------------------ + An explanation, not an action: amber-edged rather than amber-filled, so + the one filled amber thing on screen stays the door. */ + .note { margin: 0; - border-left: 2px solid rgba(242, 177, 52, 0.55); - border-radius: var(--r-sm); - padding: var(--s2) var(--s3); + border-left: 2px solid rgba(242, 177, 52, 0.5); + padding-left: var(--s2); font-size: 10px; line-height: 1.6; color: var(--ink-2); } - .badge a { color: var(--amber-ink); } + .note a { color: var(--amber-ink); } - #chapters { - display: flex; - flex-direction: column; - gap: 1px; - background: var(--glass); - backdrop-filter: var(--blur); - -webkit-backdrop-filter: var(--blur); - border: 1px solid var(--hairline); - box-shadow: var(--shadow), inset 0 1px 0 var(--glass-inset); - border-radius: var(--r); - padding: var(--s1); - } + /* ---- Chapters --------------------------------------------------------- */ + .chapters { display: flex; flex-direction: column; gap: 1px; } .chapter { display: flex; align-items: baseline; gap: var(--s2); - padding: var(--s2) var(--s2); + padding: var(--s2); background: none; border: 0; border-radius: var(--r-sm); @@ -359,23 +422,22 @@ } .chapter[aria-pressed="true"] { background: rgba(242, 177, 52, 0.2); color: var(--amber-ink); } .num { font-size: 9px; letter-spacing: 0.1em; opacity: 0.55; font-variant-numeric: tabular-nums; } - - #blurb { margin: 0; color: var(--ink-2); } + #blurb { margin: 0; color: var(--ink-3); font-size: 10px; line-height: 1.6; } /* The panel toggle only exists on a narrow screen, where it doubles as the - header — hence the city name in it. */ + header — hence the board name in it. */ .panel-toggle { - display: none; position: fixed; top: calc(var(--s3) + env(safe-area-inset-top)); left: var(--s3); - z-index: 4; + z-index: var(--z-dock); align-items: center; gap: var(--s2); font: inherit; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; + min-height: 40px; padding: var(--s2) var(--s3); cursor: pointer; color: var(--amber); @@ -386,27 +448,142 @@ border-radius: var(--r-sm); box-shadow: var(--shadow); } + .panel-toggle:not([hidden]) { display: flex; } .panel-toggle .glyph { font-size: 13px; line-height: 1; } - /* ---- Top right: who you are, and the plan ------------------------------ - The tier badge is its own fixed card rather than the last child of - `.corner`, which it used to be. Two reasons, and the second is a bug: - a phone turns the plan into a bottom sheet and the badge must not go - down there with it, and `M` — which hides the plan — was also hiding - the one line that says whether you are signed in. */ - .corner { + /* ---- Top right: one column, four things ------------------------------- + The tier badge, the presence pill, the camera indicator and the plan + used to be four separately-positioned `fixed` elements whose vertical + relationship was maintained by four hand-summed magic numbers: + `+2.4rem`, `+2.5rem`, `+4.55rem`, and a `body.presence-on` rule that + moved a fifth. Every one of them had to be re-derived by hand whenever + any card's padding changed, and the webcam indicator's copy lived in a + different file entirely. + + They are one flex column now. Nothing needs to know what is above it. */ + .topright { position: fixed; - /* Clear of the tier badge above it, which is one line of 9px caps in a - card: 8 + 14 + 8 of padding and leading, two hairlines, and the 4px - rhythm's worth of gap. */ - top: calc(var(--s4) + env(safe-area-inset-top) + 2.4rem); + top: calc(var(--s4) + env(safe-area-inset-top)); right: var(--s4); - width: 15rem; + z-index: var(--z-chrome); + width: min(15rem, 46vw); display: flex; flex-direction: column; align-items: stretch; gap: var(--s1); + pointer-events: none; } + .topright > * { pointer-events: auto; } + /* + * The one exception to the stacking order, and it is a decision rather than + * an accident this time. A "camera active" indicator that a dialog can + * cover turns "your webcam is live" into a fact the interface knows and the + * person does not, so while — and only while — the indicator is actually + * shown, this column rises above the modal layer. The moment the camera + * stops, so does this. + */ + body:has(.tera-webcam-indicator:not([hidden])) .topright { z-index: var(--z-alert); } + + .tier { + margin: 0; + padding: var(--s2) var(--s3); + display: flex; + align-items: baseline; + gap: var(--s2); + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-3); + } + .tier .who { + flex: 1; + color: var(--ink-2); + text-transform: none; + letter-spacing: 0.04em; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .tier[data-tier="god"] .who { color: var(--amber-ink); } + .tier a { color: var(--amber-ink); text-transform: none; letter-spacing: 0.04em; } + /* + * What signing in ADDS, rather than what you are missing. + * + * "PUBLIC VIEW · Sign in" was the entire signed-out story on this page, and + * it names the visitor rather than the offer — which on a product whose + * first owner decision is "the signed-out visitor is the audience we design + * for, not a degraded tier" is exactly the wrong sentence in exactly the + * most-read corner. This line is the offer. It is one clause, it is quiet, + * and it disappears the moment there is nothing to add. + */ + .tier-adds { + margin: 0; + padding: var(--s2) var(--s3); + font-size: 9px; + line-height: 1.65; + letter-spacing: 0.03em; + color: var(--ink-3); + } + .profile-trigger { + min-height: 26px; + padding: 3px var(--s2); + color: var(--amber-ink); + background: rgba(242, 177, 52, 0.1); + border: 1px solid rgba(242, 177, 52, 0.3); + border-radius: var(--r-sm); + font: inherit; + font-size: 9px; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + } + .profile-trigger:hover { background: rgba(242, 177, 52, 0.2); } + .profile-overlay { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: grid; + place-items: center; + padding: var(--s4); + background: rgba(5, 8, 11, 0.68); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + } + + #presence-host:empty { display: none; } + /* The wrapper is a mount point, not a box. `display: contents` lets the + camera indicator itself be the column's flex child, so the column's gap + does not reserve a row for an indicator that is not showing — which is + what an empty wrapper would do, and what an `:empty` rule cannot fix + once the (hidden) element has been mounted into it. */ + #webcam-face-indicator { display: contents; } + .tera-presence { + display: flex; + align-items: center; + gap: var(--s2); + padding: 5px var(--s2); + color: var(--ink-3); + background: rgba(8, 12, 17, 0.76); + border: 1px solid var(--hairline); + border-radius: var(--r-sm); + box-shadow: var(--shadow); + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); + font-size: 9px; + letter-spacing: 0.04em; + } + .tera-presence[data-presence="live"] { color: var(--ink-2); } + .tera-presence__retry { + padding: 2px 6px; + color: var(--amber-ink); + background: transparent; + border: 1px solid rgba(242, 177, 52, 0.3); + border-radius: 3px; + font: inherit; + cursor: pointer; + } + #minimap { padding: var(--s1); display: flex; flex-direction: column; gap: var(--s1); } /* A real height, always. The canvas is pinned to 100%/100% inline by `minimap.ts`, and an `auto` height here puts back the feedback loop that @@ -433,96 +610,7 @@ text-overflow: ellipsis; } - .tier { - position: fixed; - top: calc(var(--s4) + env(safe-area-inset-top)); - right: var(--s4); - z-index: 3; - margin: 0; - max-width: min(15rem, 50vw); - padding: var(--s2) var(--s3); - display: flex; - align-items: baseline; - justify-content: space-between; - gap: var(--s2); - font-size: 9px; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--ink-3); - } - .tier .who { - color: var(--ink-2); - text-transform: none; - letter-spacing: 0.04em; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .tier.god .who { color: var(--amber-ink); } - .tier a { color: var(--amber-ink); text-transform: none; letter-spacing: 0.04em; } - #presence-host { - position: fixed; - top: calc(var(--s4) + env(safe-area-inset-top) + 2.5rem); - right: var(--s4); - z-index: 3; - max-width: min(15rem, 65vw); - } - #presence-host:empty { display: none; } - .tera-presence { - display: flex; - align-items: center; - gap: var(--s2); - padding: 5px var(--s2); - color: var(--ink-3); - background: rgba(8, 12, 17, 0.76); - border: 1px solid var(--hairline); - border-radius: var(--r-sm); - box-shadow: var(--shadow); - backdrop-filter: var(--blur); - -webkit-backdrop-filter: var(--blur); - font-size: 9px; - letter-spacing: 0.04em; - } - .tera-presence[data-presence="live"] { color: var(--ink-2); } - .tera-presence__retry { - padding: 2px 6px; - color: var(--amber-ink); - background: transparent; - border: 1px solid rgba(242, 177, 52, 0.3); - border-radius: 3px; - font: inherit; - cursor: pointer; - } - body.presence-on .corner { - top: calc(var(--s4) + env(safe-area-inset-top) + 4.55rem); - } - .profile-trigger { - min-height: 28px; - padding: 4px 8px; - color: var(--amber-ink); - background: rgba(242, 177, 52, 0.1); - border: 1px solid rgba(242, 177, 52, 0.3); - border-radius: var(--r-sm); - font: inherit; - letter-spacing: 0.06em; - text-transform: uppercase; - cursor: pointer; - } - .profile-trigger:hover { background: rgba(242, 177, 52, 0.2); } - .profile-overlay { - position: fixed; - inset: 0; - z-index: 20; - display: grid; - place-items: center; - padding: var(--s4); - background: rgba(5, 8, 11, 0.68); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); - } - - /* ---- Bottom right: what you picked, and how to drive ------------------ */ + /* ---- Bottom right: what you picked, and one hint ---------------------- */ .rail { position: fixed; right: var(--s4); @@ -530,16 +618,12 @@ where `.rail { bottom: 16px }` puts the `?` button underneath the home indicator — a control you can see and cannot press. */ bottom: calc(var(--s4) + env(safe-area-inset-bottom)); - z-index: 3; + z-index: var(--z-chrome); display: flex; flex-direction: column; align-items: flex-end; - gap: var(--s1); - /* Wide enough that the key hints stay on one line at desktop widths — - a shortcut list that wraps reads as five separate notes rather than - one row — and narrow enough that a long detail card never becomes a - second panel. */ - max-width: min(30rem, calc(100vw - var(--s4) * 2)); + gap: var(--s2); + max-width: min(26rem, calc(100vw - var(--s4) * 2)); } #detail { max-width: 100%; @@ -567,48 +651,80 @@ transition: color var(--t); } @media (hover: hover) { .detail-close:hover { color: var(--ink); } } + + /* ---- The aircraft card ------------------------------------------------ + Clicking a plane used to produce the same one-line string a company + marker produced, so the callsign, transponder hex, altitude and heading + the ADS-B feed had already parsed were thrown away one line before they + could be shown. This is that data, and it is open to everyone: ADS-B is + an unencrypted broadcast, so there is nothing here an account could + grant access to. */ + .aircraft-card { display: flex; flex-direction: column; gap: var(--s1); } + .aircraft-card__title { + margin: 0; + font-size: 14px; + letter-spacing: 0.1em; + color: var(--amber-ink); + font-variant-numeric: tabular-nums; + } + .aircraft-card__subtitle { margin: 0; color: var(--ink-4); } + .aircraft-card__rows { + margin: var(--s1) 0 0; + display: grid; + grid-template-columns: auto 1fr; + gap: 2px var(--s3); + font-size: 10px; + } + .aircraft-card__rows dt { color: var(--ink-4); text-transform: uppercase; letter-spacing: 0.08em; font-size: 9px; } + .aircraft-card__rows dd { margin: 0; color: var(--ink); font-variant-numeric: tabular-nums; } + .aircraft-card__credit { margin: var(--s1) 0 0; font-size: 9px; color: var(--ink-4); } + + /* ---- The rail hint ---------------------------------------------------- + This was a `.card` holding five permanent key hints — `1–9 chapters · + choose 2 or 3 to follow · [ ] city · O office · M plan` — a paragraph of + monospace glass in the corner of a 3D scene, which never changed and + four fifths of which was irrelevant to whatever you were doing. + + It is at most two chips now, chosen for the mode you are actually in by + `railHints()` in `src/ui/shortcuts.ts`, with no card around them: keycaps + over the scene, a text shadow instead of a fill, and the full reference + one press of `?` away. */ .hint { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: var(--s1) var(--s3); - padding: var(--s2) var(--s3); font-size: 10px; letter-spacing: 0.04em; - color: var(--ink-3); + color: var(--ink-2); + text-shadow: 0 1px 3px rgba(3, 6, 10, 0.8); } + .hint__chip { display: inline-flex; align-items: center; gap: 5px; } + .hint__label { color: var(--ink-3); } kbd { font: inherit; font-size: 9px; padding: 1px 4px; border: 1px solid var(--hairline); border-radius: 3px; - background: rgba(255, 255, 255, 0.08); + background: rgba(8, 12, 17, 0.7); color: var(--ink-2); } - /* The plan toggle is for pointers, and only for pointers. - Where there is a keyboard the `M` hint two elements to the left is the - affordance and a button saying the same thing is a second row of glass - for nothing. Where there is not — a phone, a tablet, a touchscreen - kiosk — the hints are hidden or meaningless and this is the only way in. - Keyed off the pointer rather than off the width because the defect it - fixes is about fingers: an 820px iPad has no `M` either, and it lands on - the desktop layout by design (see `deviceProfile` in `stage.ts`). */ - #plan-toggle { display: none; } - @media (pointer: coarse), (max-width: 600px) { - #plan-toggle { display: inline-flex; align-items: center; } - } + .rail-buttons { display: flex; align-items: center; gap: var(--s1); } .help { font: inherit; font-size: 10px; letter-spacing: 0.04em; - padding: 2px 6px; + min-height: 30px; + padding: 2px var(--s2); cursor: pointer; border: 1px solid var(--hairline); border-radius: var(--r-sm); - background: rgba(255, 255, 255, 0.06); + background: var(--glass); + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); color: var(--ink-2); transition: background var(--t), color var(--t); } @@ -616,23 +732,22 @@ .help:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); } } /* A toggle has to look like its state, or it is a button that appears to do - nothing on every second press. Same treatment the chapter and city + nothing on every second press. Same treatment the chapter and board buttons take, for the same reason and from the same attribute. */ .help[aria-pressed="true"] { background: rgba(242, 177, 52, 0.2); color: var(--amber-ink); } /* ---- Bottom left: where the numbers came from ------------------------- - Shown only when the data is live — see `renderSource` in main.ts for why - the sample-data half of this was retired. The disclosure it used to - carry did not go into a README nobody opens: it is on the boot card - everyone passes through and in the `?` card, which is one keypress away - from any state the app can be in. The `?` card is also where the live - sources' own credit lines land; see `#credits`. */ + Shown only when the data is live — see `chromeState` for why the + sample-data half of this was retired. The disclosure it used to carry + did not go into a README nobody opens: it is on the boot card everyone + passes through and in the `?` card, which is one keypress away from any + state the app can be in. */ .source { position: fixed; left: var(--s4); bottom: calc(var(--s4) + env(safe-area-inset-bottom)); margin: 0; - z-index: 3; + z-index: var(--z-chrome); max-width: calc(100vw - var(--s4) * 2); font-size: 10px; letter-spacing: 0.04em; @@ -655,7 +770,7 @@ display: none; position: fixed; inset: 0; - z-index: 5; + z-index: var(--z-play); background: rgba(4, 7, 11, 0.45); } @@ -663,7 +778,7 @@ .overlay { position: fixed; inset: 0; - z-index: 10; + z-index: var(--z-modal); display: flex; align-items: center; justify-content: center; @@ -673,23 +788,46 @@ } .sheet { background: var(--glass-strong); - width: min(26rem, 100%); + width: min(30rem, 100%); max-height: calc(100dvh - var(--s5) * 2); overflow-y: auto; padding: var(--s4); } .sheet h2 { margin: 0 0 var(--s3); font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--amber); } - /* The Touch section, under the keyboard one. */ - .sheet h2:not(:first-child) { margin-top: var(--s4); } - .keys { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: var(--s2) var(--s3); - align-items: baseline; font-size: 11px; } - .keys dt { text-align: right; } + .sheet-section { + margin: var(--s4) 0 var(--s2); + color: var(--ink-4); + } + .sheet-section:first-child { margin-top: 0; } + .keys { margin: 0; display: grid; grid-template-columns: minmax(5.5rem, auto) 1fr; + gap: var(--s2) var(--s3); align-items: baseline; font-size: 11px; } + .keys dt { text-align: right; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 3px; } + .keys dt.keys-gesture { color: var(--ink-2); text-align: right; display: block; } .keys dd { margin: 0; color: var(--ink-2); } + .keys-also { color: var(--ink-4); } + .sheet-note { margin: var(--s4) 0 0; font-size: 10px; line-height: 1.6; color: var(--ink-4); } + /* One notch up from the note above it, and a hairline off it. A credit + line is somebody's licence term rather than this project's small print, + and two identical grey paragraphs read as one. */ + .sheet-credits { + margin-top: var(--s3); + padding-top: var(--s3); + border-top: 1px solid var(--hairline); + color: var(--ink-3); + } + .sheet-degraded { + margin: 0; + padding-left: var(--s4); + font-size: 10px; + line-height: 1.7; + color: #ffb98a; + } .sheet-close { margin-top: var(--s4); font: inherit; font-size: 11px; + min-height: var(--tap); padding: var(--s2) var(--s3); cursor: pointer; width: 100%; @@ -714,7 +852,7 @@ .boot { position: fixed; inset: 0; - z-index: 20; + z-index: var(--z-boot); display: flex; align-items: center; justify-content: center; @@ -749,27 +887,13 @@ .boot-step { margin: 0; font-size: 10px; letter-spacing: 0.06em; color: var(--ink-2); min-height: 1.4em; } .boot-note { margin: var(--s4) 0 0; font-size: 10px; line-height: 1.6; color: var(--ink-4); } - /* One notch up from the note above it, and a hairline off it. A credit - line is somebody's licence term rather than this project's small print, - and two identical grey paragraphs read as one. */ - .credits { - margin-top: var(--s3); - padding-top: var(--s3); - border-top: 1px solid var(--hairline); - color: var(--ink-3); - } - /* ---- Touch driving --------------------------------------------------- - Keyboard and standard gamepads need no chrome. Coarse pointers do, and - only while a route chapter owns the follow camera. Full words rather - than mystery glyphs: the controls disappear everywhere space is tight - except on the devices that cannot drive without them. */ - .drive-controls, .walk-controls { display: none; } + /* ---- The play HUD and the mode dock ----------------------------------- */ .play-hud { position: fixed; top: calc(var(--s4) + env(safe-area-inset-top)); left: 50%; - z-index: 4; + z-index: var(--z-play); min-width: min(27rem, calc(100vw - 12rem)); max-width: calc(100vw - var(--s4) * 2); transform: translateX(-50%); @@ -795,29 +919,40 @@ .play-hud-primary { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .play-hud-status { text-align: right; color: var(--ink-3); } .play-hud-status.warning { color: #ff9d72; } + + /* + * The mode dock, which is the single most important control on the page + * and used to read as an isolated pill with no relationship to anything. + * It is still a pill — that is right for a segmented control that must not + * cover the scene — but it is now the only thing at the bottom centre, it + * is sized for a thumb at every width, and `chromeState` hides it entirely + * when `overview` is the only mode available rather than rendering one + * already-pressed button that does nothing. + */ .mode-dock { position: fixed; left: 50%; bottom: calc(var(--s4) + env(safe-area-inset-bottom)); - z-index: 4; + z-index: var(--z-dock); transform: translateX(-50%); display: flex; align-items: center; gap: 3px; padding: 4px; border: 1px solid var(--hairline); - border-radius: 999px; + border-radius: var(--r-pill); background: rgba(8, 12, 17, 0.82); box-shadow: var(--shadow); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur); } + .mode-dock[hidden] { display: none; } .mode-dock button { - min-width: 44px; + min-width: var(--tap); min-height: 38px; - padding: 5px 10px; + padding: 5px 12px; border: 0; - border-radius: 999px; + border-radius: var(--r-pill); color: var(--ink-3); background: transparent; font: inherit; @@ -831,95 +966,22 @@ background: rgba(242, 177, 52, 0.2); } .mode-dock button[hidden] { display: none; } - @media (pointer: coarse) { - .drive-controls:not([hidden]), .walk-controls:not([hidden]) { - position: fixed; - left: 50%; - bottom: calc(var(--s3) + env(safe-area-inset-bottom)); - transform: translateX(-50%); - z-index: 5; - width: min(23rem, calc(100vw - var(--s4) * 2)); - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: var(--s1); - padding: var(--s1); - border: 1px solid var(--hairline); - border-radius: var(--r); - background: var(--glass-strong); - backdrop-filter: var(--blur); - -webkit-backdrop-filter: var(--blur); - box-shadow: var(--shadow); - } - .drive-control, .walk-control { - min-height: 48px; - border: 1px solid var(--hairline); - border-radius: var(--r-sm); - background: rgba(255, 255, 255, 0.08); - color: var(--ink); - font: inherit; - font-size: 10px; - letter-spacing: 0.05em; - text-transform: uppercase; - touch-action: none; - user-select: none; - -webkit-user-select: none; - } - .drive-control[aria-pressed="true"], .walk-control[aria-pressed="true"] { - border-color: rgba(242, 177, 52, 0.65); - background: rgba(242, 177, 52, 0.28); - color: var(--amber-ink); - } - /* The source line is useful but cannot sit underneath the controls. */ - body:has(.drive-controls:not([hidden])) .source, - body:has(.walk-controls:not([hidden])) .source { bottom: 7.5rem; } - } - body.touch-capable .drive-controls:not([hidden]), - body.touch-capable .walk-controls:not([hidden]) { - position: fixed; - left: 50%; - bottom: calc(var(--s3) + env(safe-area-inset-bottom)); - transform: translateX(-50%); - z-index: 5; - width: min(23rem, calc(100vw - var(--s4) * 2)); - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: var(--s1); - padding: var(--s1); - border: 1px solid var(--hairline); - border-radius: var(--r); - background: var(--glass-strong); - backdrop-filter: var(--blur); - -webkit-backdrop-filter: var(--blur); - box-shadow: var(--shadow); - } - body.touch-capable .drive-control, - body.touch-capable .walk-control { - min-height: 48px; - border: 1px solid var(--hairline); - border-radius: var(--r-sm); - background: rgba(255, 255, 255, 0.08); - color: var(--ink); - font: inherit; - font-size: 10px; - letter-spacing: 0.05em; - text-transform: uppercase; - touch-action: none; - user-select: none; - -webkit-user-select: none; - } - body.touch-capable .drive-control[aria-pressed="true"], - body.touch-capable .walk-control[aria-pressed="true"] { - border-color: rgba(242, 177, 52, 0.65); - background: rgba(242, 177, 52, 0.28); - color: var(--amber-ink); - } - body:has(.drive-controls:not([hidden])) .mode-dock, - body:has(.drive-controls:not([hidden])) .rail { bottom: calc(8rem + env(safe-area-inset-bottom)); } - body:has(.walk-controls:not([hidden])) .mode-dock, - body:has(.walk-controls:not([hidden])) .rail { bottom: calc(11.5rem + env(safe-area-inset-bottom)); } - /* GTA-like touch surface: analogue movement on the left, only the - actions meaningful to the current vehicle/actor on the right. */ + /* ---- The touch surface ------------------------------------------------ + Analogue movement on the left, only the actions meaningful to the + current vehicle or actor on the right. + + What used to live here as well: about a hundred lines of CSS for the + two button-bar controls this joystick replaced, whose elements were + removed from this document long ago. Every rule targeting them was + dead — and the rules keyed off *this* surface were worse than dead, + because `:has()` matches an element that is `display: none`. There was + no pointer guard on them, so **starting a drive on a desktop with a + mouse hid the key-hint strip and shoved the mode dock up 160px** to + make room for a control bar that is not drawn on that device, at + exactly the moment a new driver needed the hints. Both offset rules now + live inside the same `(pointer: coarse)` / `body.touch-capable` guard + the controls themselves do. */ .touch-play-controls { display: none; } @media (pointer: coarse) { .touch-play-controls:not([hidden]) { display: block; } @@ -928,7 +990,7 @@ .touch-play-controls:not([hidden]) { position: fixed; inset: 0; - z-index: 5; + z-index: var(--z-play); pointer-events: none; } .play-stick { @@ -956,6 +1018,21 @@ border: 1px solid rgba(242,177,52,.32); border-radius: 50%; } + /* The stick says what it moves. Unlabelled, it is a circle — and a circle + is what a first-time visitor ignores. */ + .play-stick-label { + position: absolute; + left: 0; + right: 0; + bottom: -1.25rem; + text-align: center; + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-3); + text-shadow: 0 1px 3px rgba(3, 6, 10, 0.8); + pointer-events: none; + } .play-stick-knob { position: absolute; left: 50%; @@ -981,10 +1058,10 @@ pointer-events: auto; } .touch-actions button { - min-height: 46px; + min-height: var(--tap); padding: 5px 7px; border: 1px solid rgba(255,255,255,.18); - border-radius: 999px; + border-radius: var(--r-pill); background: rgba(8,12,17,.78); color: var(--ink-2); box-shadow: var(--shadow); @@ -1004,38 +1081,49 @@ background: rgba(242,177,52,.28); color: var(--amber-ink); } - body:has(.touch-play-controls:not([hidden])) .mode-dock { + + /* The two offset rules, inside the guard they always should have been in. + See the block comment above `.touch-play-controls`. */ + @media (pointer: coarse) { + body:has(.touch-play-controls:not([hidden])) .mode-dock { + bottom: calc(10rem + env(safe-area-inset-bottom)); + } + body:has(.touch-play-controls:not([hidden])) .source { bottom: calc(10rem + env(safe-area-inset-bottom)); } + body:has(.touch-play-controls:not([hidden])) .rail { bottom: calc(13.5rem + env(safe-area-inset-bottom)); } + } + body.touch-capable:has(.touch-play-controls:not([hidden])) .mode-dock { bottom: calc(10rem + env(safe-area-inset-bottom)); } - body:has(.touch-play-controls:not([hidden])) #hint { display: none; } - body:has(.touch-play-controls:not([hidden])) .source { bottom: 10rem; } + body.touch-capable:has(.touch-play-controls:not([hidden])) .source { + bottom: calc(10rem + env(safe-area-inset-bottom)); + } + body.touch-capable:has(.touch-play-controls:not([hidden])) .rail { + bottom: calc(13.5rem + env(safe-area-inset-bottom)); + } /* ---- Responsive ------------------------------------------------------- - Two breakpoints and no more. At 900 the left column stops being furniture - and becomes a sheet you open; at 600 the plan stops fitting beside the - map at all and moves to the "plan view" button on the rail (and `M`, - where there is a keyboard). `main.ts` owns the *state* of both, so these - rules only describe what each state looks like. */ + Two breakpoints and no more, and they are the same two `tokens.ts` + publishes and `deviceProfile()` in `stage.ts` keys its pixel budget off, + so the stylesheet and the renderer cannot drift apart about what a phone + is. At 900 the left column stops being furniture and becomes a sheet you + open; at 600 the page stops being a scaled-down desktop entirely. + `chromeState` owns the *state* of both, so these rules only describe what + each state looks like. */ @media (max-width: 900px) { - .panel-toggle { display: flex; } #panel { - padding-top: 56px; - width: min(18rem, calc(100vw - var(--s3) * 2)); - z-index: 3; + padding: calc(var(--s3) + 44px) var(--s3) var(--s3); + width: min(19rem, calc(100vw - var(--s3) * 2)); } body.panel-closed #panel { transform: translateX(calc(-100% - var(--s3))); opacity: 0; pointer-events: none; } - .corner { - width: 11.5rem; - top: calc(var(--s3) + env(safe-area-inset-top) + 2.4rem); + .topright { + width: min(12rem, 46vw); + top: calc(var(--s3) + env(safe-area-inset-top)); right: var(--s3); } - .tier { top: calc(var(--s3) + env(safe-area-inset-top)); right: var(--s3); } - #presence-host { top: calc(var(--s3) + env(safe-area-inset-top) + 2.5rem); right: var(--s3); } - body.presence-on .corner { top: calc(var(--s3) + env(safe-area-inset-top) + 4.55rem); } .minimap-frame { height: 10rem; } .rail { right: var(--s3); bottom: calc(var(--s3) + env(safe-area-inset-bottom)); } .source { left: var(--s3); bottom: calc(var(--s3) + env(safe-area-inset-bottom)); } @@ -1048,12 +1136,23 @@ a card explaining the keyboard is furniture; and there is one thumb, which reaches the bottom third of the screen and not the top corners. - The renderer draws the same conclusion from the same numbers — - `deviceProfile()` in `stage.ts` keys off this exact 600px edge — so the - stylesheet and the pixel budget cannot drift apart. */ + Three things this layout got wrong and this pass fixes: + + - **The plan disappeared entirely.** It was seeded off below 600px and + laid out as a full-width bottom sheet, so the only two states were + "gone" and "eats half the map". It is a glanceable 8rem square in the + top-right column now, always there in the city, and the plan button + still expands it. + - **A "? shortcuts" button on a device with no keyboard.** The button + stays — it is the only reference this layout has, since the key strip + is `display: none` here — but it is labelled "Guide" and the sheet + leads every row with its gesture rather than its keycap. + - **No visible way to move.** The joystick is shown in every play mode + now rather than only once a body was already moving, and the coach + names the mode dock on first run. */ @media (max-width: 600px) { .play-hud { - top: calc(var(--s3) + env(safe-area-inset-top) + 2.65rem); + top: calc(var(--s3) + env(safe-area-inset-top) + 3rem); min-width: 0; width: calc(100vw - var(--s3) * 2); } @@ -1061,20 +1160,19 @@ max-width: calc(100vw - var(--s3) * 2); bottom: calc(var(--s3) + env(safe-area-inset-bottom)); } - .mode-dock button { padding-inline: 8px; } - body:has(.drive-controls:not([hidden])) #hint, - body:has(.walk-controls:not([hidden])) #hint { display: none; } + .mode-dock button { padding-inline: 10px; min-height: var(--tap); } + /* Everything hit by a finger clears 44px, which is 11 steps of the 4px rhythm and the smallest target anyone has managed to defend. `#help` was 17px tall. */ #panel-toggle, - #enter, - #walk, - #screens, - .city, + .act, + .board, .chapter, + .help, + .detail-close, .sheet-close { - min-height: 44px; + min-height: var(--tap); } .chapter { align-items: center; } .help, @@ -1082,44 +1180,33 @@ display: inline-flex; align-items: center; justify-content: center; - min-width: 44px; - min-height: 44px; - border-radius: 999px; + min-width: var(--tap); + border-radius: var(--r-pill); } /* The rail spans the width so the detail card — now reachable, because - a marker picks on tap — is a readable line rather than a column. */ + a marker picks on tap, and now much richer for an aircraft — is a + readable card rather than a column. */ .rail { left: var(--s3); right: var(--s3); max-width: none; align-items: stretch; } - /* No keyboard, so no key hints — and with them gone the card around - them is a full-width bar of glass holding one button. On a phone the - hint *is* the `?` button. */ - /* The key hints go because there is no keyboard. The plan button is not - one of them and is not a `span`, so it stays — which is the whole - reason it exists. */ - .hint span { display: none; } - .hint { - align-self: flex-end; - padding: 0; - border: 0; - background: none; - box-shadow: none; - backdrop-filter: none; - -webkit-backdrop-filter: none; - } + .rail-buttons { justify-content: flex-end; } + /* No keyboard, so no key hints. On a phone the hint *is* the Guide + button, and `railHints()` returns nothing for a coarse pointer so this + is belt and braces rather than the mechanism. */ + .hint { display: none; } /* The honesty line moves out of the thumb's way rather than out of the layout. It is the one caption that is never allowed to be dropped for space, so it goes to the quiet corner under the ☰ button and stays to one ellipsised line. */ .source { - top: calc(var(--s3) + env(safe-area-inset-top) + 3.1rem); + top: calc(var(--s3) + env(safe-area-inset-top) + 3.4rem); bottom: auto; - max-width: min(60vw, calc(100vw - var(--s3) * 2)); + max-width: min(58vw, calc(100vw - var(--s3) * 2)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1135,23 +1222,10 @@ white-space: normal; } - /* The plan, as a bottom sheet. At 11.5rem in a corner it ate a fifth of - a 375px map and its own affordances were about six pixels across — - a map you cannot read to move a map you can. Full width above the - rail, it is a plan view; and it is still off until asked for, which - on this layout means the "plan view" button on the rail. It was `M` - and only `M` for a while, which meant this block was written for a - device that could never reach it. */ - .corner { - top: auto; - left: var(--s3); - right: var(--s3); - width: auto; - bottom: calc(var(--s3) + 3.5rem + env(safe-area-inset-bottom)); - } - .minimap-frame { height: min(38dvh, 18rem); } - /* Hover-only text on a device with no hover. */ - .minimap-readout { display: none; } + /* The plan: glanceable, not gone. */ + .topright { width: min(8.5rem, 40vw); right: var(--s3); } + .minimap-frame { height: 8.5rem; } + .tier-adds { display: none; } /* The panel, as a bottom sheet. A left drawer on a phone covers the thing being looked at; a sheet rises from the edge the thumb starts @@ -1160,29 +1234,21 @@ inset: auto 0 0 0; width: auto; max-width: none; - max-height: 72dvh; - padding: var(--s4) var(--s3) calc(var(--s4) + env(safe-area-inset-bottom)); - gap: var(--s2); - z-index: 6; + max-height: 76dvh; + padding: var(--s3) var(--s3) calc(var(--s3) + env(safe-area-inset-bottom)); + z-index: var(--z-instrument); pointer-events: auto; - background: var(--glass-strong); - backdrop-filter: var(--blur); - -webkit-backdrop-filter: var(--blur); - border-top: 1px solid var(--hairline); - border-radius: var(--r) var(--r) 0 0; - box-shadow: 0 -8px 30px rgba(3, 6, 10, 0.5); } + .panel-card { max-height: calc(76dvh - var(--s3) * 2); } body.panel-closed #panel { transform: translateY(100%); opacity: 0; } #scrim { display: block; } body.panel-closed #scrim { display: none; } } - - /* The minimap's own visibility is a user decision (`M`, or the "plan view" - button on the rail), seeded from the viewport width by `main.ts` rather - than by a media query, so that toggling it on at 375px actually shows - it. */ - body.minimap-off .corner { display: none; } + /* The minimap's own visibility is a user decision (`M`, or the plan button + on the rail), decided by `chromeState` rather than by a media query, so + that toggling it on at 375px actually shows it. */ + body.minimap-off #corner { display: none; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { @@ -1197,59 +1263,89 @@ - +
-
-

California

-

Tera · Lumbridge Simulate

-

-
- - - - - - - -

+
+
+

California

+

Tera · Lumbridge Simulate

+

+
+ +
+

Boards

+ +
+ +
+

Go

+
+ + + + + +
+ + + +
+ + + +
+

Chapters

+ + +
+
- - -
- - + +
+ + +
+
+ +
-
- 19 chapters - choose 2 or 3 to follow - - [ ] city - O office - M plan - - - +
+
+ + +
@@ -1272,6 +1368,7 @@ aria-label="Movement joystick. Drag in any direction."> +
@@ -1290,59 +1387,24 @@ + +
+
+ diff --git a/login.html b/login.html index cbfb3df..bbd33d2 100644 --- a/login.html +++ b/login.html @@ -2,38 +2,74 @@ - + + Sign in — Lumbridge Simulate @@ -54,6 +90,14 @@

+ +

+ You do not need an account to use Tera. The city, both studios, the live + aircraft and the weather are open to everyone — signing in adds the people: + live occupancy, your own character on the floor, and the studio desk.
+ ← Back to the open demo +

+
The session is a cookie this server signs. Nothing leaves the box.
diff --git a/package.json b/package.json index 34ebff3..048905a 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "provenance": "node scripts/check-provenance.mjs && node scripts/check-no-binaries.mjs", "licenses": "node scripts/check-dependency-licenses.mjs", "sbom": "npm sbom --package-lock-only --sbom-format=spdx", - "test": "node --test \"src/test/*.test.ts\"", + "test": "node --test \"src/test/*.test.ts\" \"src/test/**/*.test.ts\"", "typecheck": "tsc --noEmit", "preview": "vite preview", "performance": "node scripts/performance-budget.mjs" diff --git a/scripts/check-arena-source-hashes.mjs b/scripts/check-arena-source-hashes.mjs index a59e5e8..66b69b6 100644 --- a/scripts/check-arena-source-hashes.mjs +++ b/scripts/check-arena-source-hashes.mjs @@ -57,6 +57,34 @@ const sourceSets = { environment: [...sharedEnvironment, "src/arena/californiaFlight.ts"], simulator: ["src/aircraft/controller.ts"], }, + // `studio-ops-v1` pins the widest simulator set in the package, and every + // entry earns its place: it is the list of files an edit to which changes + // what a rollout does. Both office packs and both operations packs are in it + // because the environment resolves a `Plan` of each; `devices/types.ts` is in + // it because the capability vocabulary decides the observation's width; and + // `engine/solar.ts` is in it because the sun is an input rather than a + // decoration. If this list is ever shorter than what the environment imports, + // a snapshot can survive a change it should not have survived. + "studio-ops-v1": { + environment: [...sharedEnvironment, "src/arena/studioOps.ts"], + simulator: [ + "src/devices/sim.ts", + "src/devices/types.ts", + "src/engine/solar.ts", + "src/interiors/plan.ts", + "src/interiors/types.ts", + "src/interiors/walker.ts", + "src/interiors/robotActivity.ts", + "src/interiors/robotOperations.ts", + "src/interiors/robotRoutes.ts", + "src/offices/lumbridge-hq.ts", + "src/offices/mateo-court.ts", + "src/offices/operations/lumbridge-hq.ts", + "src/offices/operations/mateo-court.ts", + "src/offices/sites.ts", + "src/transport/vehicleTelemetry.ts", + ], + }, }; async function digest(paths) { diff --git a/scripts/ui-smoke.mjs b/scripts/ui-smoke.mjs new file mode 100644 index 0000000..4c40aa6 --- /dev/null +++ b/scripts/ui-smoke.mjs @@ -0,0 +1,512 @@ +#!/usr/bin/env node +/** + * The interface gate: does the chrome actually mount, on both shapes of screen + * and on both sides of the sign-in line? + * + * node scripts/ui-smoke.mjs # against dist/, which must be built + * node scripts/ui-smoke.mjs --headed # watch it happen + * + * ## Why this exists + * + * Until `ui/chromeState.ts` landed, every visibility decision in this product + * was a line in `main.ts` that needed a `Stage`, a heightfield and a WebGL + * context to reach — so the interface, on a product whose entire pitch is the + * interface, was the one subsystem with no coverage at all. `chromeState` made + * the *decisions* testable in `node --test`; this makes the *wiring* testable, + * which is the other half and the half that breaks at a seam nobody owns. + * + * Four combinations, because each of the two axes has genuinely changed + * behaviour behind it and neither implies the other: + * + * - **1600 × 1000 and 390 × 844.** The phone is a different design, not a narrow + * desktop: no hover, no keyboard, one thumb. Live defect 10 was the plan view + * disappearing entirely there, and 12 was there being no visible movement + * control at all. + * - **Anonymous and signed in.** Owner decision 1 makes the signed-out visitor + * the audience this is designed for rather than a degraded tier, so anonymous + * is the case that must be *good*, not merely the case that must not crash. + * + * ## GPU flags + * + * This box has an AMD card and no monitor. `--use-gl=angle --use-angle=vulkan` + * is what makes Chrome render headlessly on it at all, and the SwiftShader + * fallback behind it is what makes this runnable on a CI box with no card — + * `--enable-unsafe-swiftshader` is required since Chrome 137 made software WebGL + * opt-in. Exactly the ladder `scripts/performance-budget.mjs` climbs, for + * exactly the same reason. + */ + +import { chromium } from "playwright"; +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, join, normalize, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const DIST = join(ROOT, "dist"); +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".webmanifest": "application/manifest+json", +}; + +const args = process.argv.slice(2); +const headed = args.includes("--headed"); +const softwareOnly = args.includes("--software"); + +const VIEWPORTS = { + desktop: { width: 1_600, height: 1_000 }, + phone: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 3 }, +}; + +/** + * The two tiers, as the two bodies `/api/v1/session` can return. + * + * `/health` says `mode: "jwt"` in both, which is what makes the anonymous case + * a *real* anonymous case: `resolveAccess` treats `mode: "none"` as a self-host + * that chose to stay open and hands it `member`, so a smoke that left auth off + * would never once exercise the tier this product is designed for. + */ +const TIERS = { + anonymous: { authenticated: false, subject: null, passwordLogin: true, admin: false }, + "signed-in": { + authenticated: true, + subject: "smoke@lumbridgecorp.test", + passwordLogin: true, + admin: false, + }, +}; + +// ---- The deployment, as a few hundred bytes of JSON ----------------------- + +async function serve(tier) { + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://local.invalid"); + const json = (status, body) => { + res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }); + res.end(JSON.stringify(body)); + }; + if (url.pathname === "/api/v1/health") { + return json(200, { + auth: { mode: "jwt", entryUrl: "/login.html" }, + // Every source off, which is the shipped default and the configuration + // CONTRACT §0's acceptance test is written against. The city still gets + // a sky, traffic and a studio full of instruments — from the simulators. + sources: { weather: "none", flights: "none", satellites: "none", markers: "none" }, + regions: [], + }); + } + if (url.pathname === "/api/v1/session") return json(200, TIERS[tier]); + if (url.pathname.startsWith("/api/v1/")) return json(404, { error: "not_found" }); + + const requested = normalize(decodeURIComponent(url.pathname)).replace(/^(?:\.\.[/\\])+/, ""); + for (const relative of [requested === "/" ? "/index.html" : requested, "/index.html"]) { + const target = resolve(DIST, `.${relative}`); + if (!target.startsWith(`${resolve(DIST)}/`)) continue; + try { + const body = await readFile(target); + res.writeHead(200, { "content-type": MIME[extname(target)] ?? "application/octet-stream" }); + res.end(body); + return; + } catch { /* fall through to the SPA entry */ } + } + res.writeHead(404).end("not found"); + }); + await new Promise((ok, fail) => { + server.once("error", fail); + server.listen(0, "127.0.0.1", ok); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("no TCP port"); + return { server, port: address.port }; +} + +// ---- Chrome --------------------------------------------------------------- + +const COMMON_ARGS = [ + "--no-sandbox", + "--disable-dev-shm-usage", + "--enable-unsafe-swiftshader", + "--ignore-gpu-blocklist", +]; + +async function launch() { + const ladder = softwareOnly + ? [["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]]] + : [ + ["vulkan", ["--use-gl=angle", "--use-angle=vulkan"]], + ["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]], + ]; + let last; + for (const [backend, flags] of ladder) { + try { + const browser = await chromium.launch({ + channel: "chrome", + headless: !headed, + args: [...COMMON_ARGS, ...flags], + }); + return { browser, backend }; + } catch (error) { + last = error; + } + } + throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`); +} + +// ---- Assertions ----------------------------------------------------------- + +const failures = []; +const notes = []; + +function check(label, condition, detail = "") { + if (condition) return true; + failures.push(`${label}${detail === "" ? "" : ` — ${detail}`}`); + return false; +} + +/** + * A `console.error` or an uncaught exception, and nothing softer. + * + * Warnings are deliberately not fatal: three.js warns about the tone-mapping + * change on some drivers and a missing optional API is a warning by design in + * three separate adapters here. An *error* means something threw or something + * decided the page was broken, and neither may happen on a first load. + */ +function watchForErrors(page, seen) { + page.on("console", (message) => { + if (message.type() !== "error") return; + /* + * Chrome logs its own console error for every non-2xx response, and the + * optional API surface answering 4xx is not an application error — it is the + * architecture. `adapters/http.ts` degrades to bundled fiction rather than + * failing, and CONTRACT §0's acceptance test is a clone with no server at + * all, so a build that treated `/api/v1/*` 404s as fatal would be gating on + * the opposite of the promise. + * + * Everything else stays fatal, and the case that matters is a missing + * `/assets/*.js` chunk: a deploy that moved a file under an open tab is + * exactly the failure this check exists to catch, and it arrives as a 404 on + * a URL that is not under `/api/v1/`. + */ + const from = message.location()?.url ?? ""; + if (/\/api\/v1\//.test(from)) return; + seen.push(`console: ${message.text()}${from === "" ? "" : ` (${from})`}`); + }); + page.on("pageerror", (error) => seen.push(`uncaught: ${String(error)}`)); + page.on("requestfailed", (request) => { + // A cancelled navigation request is not a failure of the page. + const failure = request.failure(); + if (failure && !/ERR_ABORTED/.test(failure.errorText)) { + seen.push(`request: ${request.url()} ${failure.errorText}`); + } + }); + page.on("response", (response) => { + const url = new URL(response.url()); + if (url.pathname.startsWith("/api/v1/")) return; + if (response.status() >= 400) seen.push(`http ${response.status()}: ${url.pathname}`); + }); +} + +const chromeSnapshot = () => { + const visible = (id) => { + const element = document.getElementById(id); + return element !== null && element.hidden === false; + }; + const text = (id) => document.getElementById(id)?.textContent?.trim() ?? ""; + return { + bootHidden: document.getElementById("boot")?.hidden === true, + chapters: document.querySelectorAll("#chapters .chapter").length, + boards: document.querySelectorAll("#cities .board").length, + activeBoard: document.querySelectorAll("#cities .board[aria-pressed='true']").length, + modeButtons: document.querySelectorAll("#mode-dock [data-control-mode]:not([hidden])").length, + tierVisible: visible("tier"), + tierLabel: text("tier-label"), + tierAdds: text("tier-adds"), + signInVisible: visible("tier-signin"), + enterLabel: text("enter"), + panelToggleVisible: visible("panel-toggle"), + planVisible: visible("corner"), + planPressed: document.getElementById("plan-toggle")?.getAttribute("aria-pressed"), + helpLabel: text("help"), + onboardingCards: document.querySelectorAll("#onboarding-host [data-onboarding-step]").length, + onboardingHostChildren: document.getElementById("onboarding-host")?.childElementCount ?? 0, + deviceSectionVisible: visible("device-section"), + devicesButtonVisible: visible("devices"), + deviceCards: document.querySelectorAll("#device-host [data-device]").length, + deviceControls: document.querySelectorAll("#device-host button, #device-host input").length, + stickVisible: visible("play-stick"), + canvasLabel: document.getElementById("scene")?.getAttribute("aria-label") ?? "", + bodyClasses: [...document.body.classList], + // The renderer counting its own draws is the one witness a fast frame + // cannot fool: an increment means a frame of the real scene was submitted. + frames: window.__teraSmokeFrames ?? 0, + }; +}; + +/** Count real WebGL draws, so "it booted" means pixels rather than a DOM tree. */ +const countFrames = () => { + let frames = 0; + const patch = (prototype, method) => { + if (!prototype || typeof prototype[method] !== "function") return; + const original = prototype[method]; + prototype[method] = function (...values) { + frames += 1; + return original.apply(this, values); + }; + }; + for (const prototype of [ + globalThis.WebGLRenderingContext?.prototype, + globalThis.WebGL2RenderingContext?.prototype, + ]) { + patch(prototype, "drawArrays"); + patch(prototype, "drawElements"); + patch(prototype, "drawArraysInstanced"); + patch(prototype, "drawElementsInstanced"); + } + Object.defineProperty(globalThis, "__teraSmokeFrames", { get: () => frames }); +}; + +async function settle(page, timeoutMs = 120_000) { + await page.waitForFunction(() => document.getElementById("boot")?.hidden === true, null, { + timeout: timeoutMs, + }); + // One more paint after the card goes, so anything the fade uncovers has been + // through `apply` at least once. + await page.evaluate( + () => new Promise((ok) => requestAnimationFrame(() => requestAnimationFrame(ok))), + ); +} + +// ---- One combination ------------------------------------------------------- + +async function run(browser, port, viewportName, tier) { + const label = `${viewportName} · ${tier}`; + const viewport = VIEWPORTS[viewportName]; + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: viewport.deviceScaleFactor ?? 1, + isMobile: viewport.isMobile ?? false, + hasTouch: viewport.hasTouch ?? false, + }); + const errors = []; + try { + const page = await context.newPage(); + watchForErrors(page, errors); + await page.addInitScript(countFrames); + await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: "domcontentloaded" }); + await settle(page); + + const city = await page.evaluate(chromeSnapshot); + + // ---- The board ---- + check(`${label}: boot card faded`, city.bootHidden); + check( + `${label}: a real frame reached the glass`, + city.frames > 0, + "no WebGL draw call was ever issued, so the card faded over an empty canvas", + ); + check(`${label}: chapter list mounted`, city.chapters > 0, `${city.chapters} rows`); + check(`${label}: board strip mounted`, city.boards === 3, `${city.boards} tabs`); + check(`${label}: exactly one board is current`, city.activeBoard === 1); + check( + `${label}: canvas describes itself`, + /California/.test(city.canvasLabel), + JSON.stringify(city.canvasLabel), + ); + check(`${label}: layout class applied`, city.bodyClasses.some((c) => c.startsWith("layout-"))); + + // ---- The tier, and the anon-first promise ---- + check(`${label}: tier badge visible`, city.tierVisible); + if (tier === "anonymous") { + check( + `${label}: the signed-out badge names what you get`, + city.tierLabel === "Open demo", + JSON.stringify(city.tierLabel), + ); + check(`${label}: a way in is offered`, city.signInVisible); + check( + `${label}: the offer says what signing in adds`, + city.tierAdds.length > 0, + "'Public view · Sign in' was the entire signed-out story before this line", + ); + } else { + check( + `${label}: the signed-in badge reads Full view`, + city.tierLabel === "Full view", + JSON.stringify(city.tierLabel), + ); + check(`${label}: no sign-in offer once you are in`, city.signInVisible === false); + } + + // ---- The two layouts ---- + if (viewportName === "phone") { + check( + `${label}: the plan view is on screen`, + city.planVisible, + "live defect 10: the minimap disappeared entirely on a phone, because the " + + "seed was width > 600 and a phone has no M key", + ); + check(`${label}: the panel has a toggle`, city.panelToggleVisible); + check( + `${label}: the shortcuts button is labelled for a thumb`, + city.helpLabel === "Guide", + `live defect 11: "? shortcuts" on a device with no keyboard; got ` + + JSON.stringify(city.helpLabel), + ); + check( + `${label}: no joystick while the camera is the only thing you control`, + city.stickVisible === false, + ); + } else { + check(`${label}: the panel is furniture, not a sheet`, city.panelToggleVisible === false); + check(`${label}: the shortcuts button is the key hint`, city.helpLabel === "?"); + } + + // ---- Onboarding, which must appear exactly once ---- + check( + `${label}: the first-run coach appeared`, + city.onboardingHostChildren > 0, + "there was no onboarding of any kind in this product before this build", + ); + await page.reload({ waitUntil: "domcontentloaded" }); + await settle(page); + const second = await page.evaluate(chromeSnapshot); + check( + `${label}: the coach does not come back on the second visit`, + second.onboardingHostChildren === 0, + `${second.onboardingHostChildren} children after a reload in the same context`, + ); + + // ---- Taking control ---- + // + // Live defect 12 was that a phone in VIEW mode offered no on-screen way to + // move and no way to discover that one existed. The joystick has been in + // `input/pointerStick.ts` since the play modes landed; what it never had was + // a state that showed it. + await page.click("#mode-dock [data-control-mode='actor']"); + await page.evaluate( + () => new Promise((ok) => requestAnimationFrame(() => requestAnimationFrame(ok))), + ); + const playing = await page.evaluate(chromeSnapshot); + check( + `${label}: the play HUD appears with a body under control`, + playing.bodyClasses.includes("playing"), + JSON.stringify(playing.bodyClasses), + ); + if (viewportName === "phone") { + check( + `${label}: and a thumb is given something to move with`, + playing.stickVisible, + "live defect 12: VIEW mode on a phone had no visible movement control at all", + ); + } + await page.click("#mode-dock [data-control-mode='overview']"); + + // ---- The studio, through the door rather than through the URL ---- + // + // The in-page swap is the path a visitor takes and the one that exercises + // `enterOffice` / `leaveOffice`: the city is paused and kept, the office is + // built, its hardware feed and its car are started, and the whole lot is torn + // down again on the way out. + if (viewportName === "phone") await page.click("#panel-toggle"); + await page.click("#enter"); + await settle(page); + const office = await page.evaluate(chromeSnapshot); + check( + `${label}: the studio opened`, + /Back to the city/.test(office.enterLabel), + JSON.stringify(office.enterLabel), + ); + check( + `${label}: the hardware panel is offered`, + office.devicesButtonVisible && office.deviceSectionVisible, + "the studio declares mic and speaker hardware and the panel is anon-visible " + + "by design — the declarations are authored, only the readings are an account", + ); + check( + `${label}: the hardware panel rendered one card per declared device`, + office.deviceCards === 2, + `lumbridge-hq declares a desk mic and a desk speaker; found ${office.deviceCards}`, + ); + check( + `${label}: every card carries real controls`, + office.deviceControls >= 4, + `power, mute, gain and volume are the minimum; found ${office.deviceControls}`, + ); + check(`${label}: the studio has viewpoints`, office.chapters > 0); + check( + `${label}: the board strip became the studio picker`, + office.boards === 3 && office.activeBoard === 1, + ); + + // ---- And back out, which is where a leak would show ---- + await page.click("#enter"); + await settle(page); + const back = await page.evaluate(chromeSnapshot); + check( + `${label}: stepping back out returns to the board`, + back.chapters === city.chapters && !/Back to the city/.test(back.enterLabel), + `${back.chapters} chapters, enter reads ${JSON.stringify(back.enterLabel)}`, + ); + check( + `${label}: the studio's hardware panel went with the studio`, + back.deviceSectionVisible === false && back.deviceCards === 0, + ); + + notes.push( + `${label}: ${city.chapters} chapters, ${office.chapters} viewpoints, ` + + `${office.deviceCards} devices / ${office.deviceControls} controls, ` + + `${city.frames} draws`, + ); + } finally { + await context.close(); + } + + if (errors.length > 0) { + for (const error of errors) failures.push(`${label}: ${error}`); + } +} + +// ---- Main ------------------------------------------------------------------ + +async function main() { + try { + await readFile(join(DIST, "index.html")); + } catch { + console.error("ui-smoke: dist/index.html is missing. Run `npm run build` first."); + process.exit(2); + } + + const { browser, backend } = await launch(); + console.log(`ui-smoke: chrome up on ${backend}`); + try { + for (const tier of Object.keys(TIERS)) { + const { server, port } = await serve(tier); + try { + for (const viewportName of Object.keys(VIEWPORTS)) { + await run(browser, port, viewportName, tier); + } + } finally { + await new Promise((ok) => server.close(ok)); + } + } + } finally { + await browser.close(); + } + + for (const note of notes) console.log(` ${note}`); + if (failures.length > 0) { + console.error(`\nui-smoke: FAIL — ${failures.length} problem(s)\n`); + for (const failure of failures) console.error(` ✖ ${failure}`); + console.error(""); + process.exit(1); + } + console.log("\nui-smoke: PASS — 2 viewports × 2 access tiers, no console errors\n"); +} + +await main(); diff --git a/server/src/app.ts b/server/src/app.ts index 9c3de75..c725885 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -12,9 +12,10 @@ * one and binds it. */ -import Fastify, { type FastifyInstance } from "fastify"; +import Fastify, { type FastifyError, type FastifyInstance } from "fastify"; import { registerCachePolicy } from "./cache.ts"; import { loadConfig, type Config } from "./config.ts"; +import { registerDevices } from "./routes/devices.ts"; import { registerFlights } from "./routes/flights.ts"; import { registerHealth } from "./routes/health.ts"; import { registerMarkers } from "./routes/markers.ts"; @@ -54,6 +55,7 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance { registerMedia(app, services); registerOffices(app, services); registerPresence(app, services); + registerDevices(app, services); registerRealtime(app, services); registerSession(app, services); @@ -62,7 +64,35 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance { return reply.code(404).send(body); }); - app.setErrorHandler(async (err, _req, reply) => { + /** + * The one error handler, and the one place a client's mistake is told apart + * from ours. + * + * It used to answer 500 to everything, which was right for the only thing + * that reached it at the time — a route that threw. It stopped being right + * the moment a route accepted a body: Fastify raises its own errors for a + * payload that is not JSON, a content type it was not offered and a body over + * a route's `bodyLimit`, and every one of those is the caller's mistake, + * carries a 4xx `statusCode`, and was being reported as "something went wrong + * on the server". An operator watching error rates cannot tell a broken box + * from somebody POSTing nonsense at it, and the caller is told to retry + * something that will never work. + * + * So a 4xx from the framework is passed through with its own status and the + * `ErrorBody` shape every other refusal here uses; anything else is still a + * 500 with nothing in it, because the inside of an exception is not a thing + * to hand to the internet. + */ + app.setErrorHandler(async (err: FastifyError, req, reply) => { + const status = typeof err.statusCode === "number" ? err.statusCode : 500; + if (status >= 400 && status < 500) { + req.log.info({ err: err.message, status }, "refused a malformed request"); + const body: ErrorBody = + status === 404 + ? { error: "not_found", message: "No such route." } + : { error: "bad_request", message: "That request could not be read." }; + return reply.code(status).send(body); + } app.log.error({ err }, "unhandled error"); const body: ErrorBody = { error: "internal", message: "Something went wrong." }; return reply.code(500).send(body); diff --git a/server/src/config.ts b/server/src/config.ts index 4687e3b..609e157 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -21,8 +21,10 @@ import { readFileSync } from "node:fs"; import { parseScryptHash, type ScryptHash } from "./auth/password.ts"; import { loadRegions, type RegionSet } from "./regions.ts"; import { isSafeIceUrl } from "../../src/media/iceValidation.ts"; +import { adsbAttribution, checkAdsbEndpoint, FIRST_PARTY_RECEIVER } from "./flights/licence.ts"; import type { AuthMode, + DevicesSourceId, FlightsSourceId, MarkersSourceId, SatellitesSourceId, @@ -38,8 +40,32 @@ export interface WeatherConfig { export interface FlightsConfig { source: FlightsSourceId; - /** Base URL for the `adsb` source. */ + /** + * Base URL for the `adsb` source, **validated and normalised**. + * + * Empty on every other source, including a source that was demoted to `sim` + * because its endpoint failed the licence gate. That is deliberate: a refused + * URL does not survive into the config, so no later code can fetch it by + * accident and no later reader can mistake it for one this box vouches for. + * `flights/licence.ts` is the gate and explains what it is protecting. + */ endpoint: string; + /** + * The credit lines a live body carries, derived from the endpoint's host. + * + * Not a constant, and not written next to the fetch. The whole point of + * computing it here is that there is no way for the credit and the source to + * disagree — which they did, for every value of `TERA_ADSB_ENDPOINT` that was + * not adsb.lol. + */ + attribution: string[]; + /** + * Whether this source's terms let the box re-serve the bytes to third + * parties. Gates public caching on `/api/v1/flights`. + */ + redistributable: boolean; + /** The licence id behind `redistributable`, or `null` when nothing is live. */ + licence: string | null; /** Radius in nautical miles for the `adsb` source. */ radiusNm: number; /** Path to a local dump1090 `aircraft.json`. */ @@ -61,6 +87,32 @@ export interface SatellitesConfig { ttlSeconds: number; } +export interface DevicesConfig { + source: DevicesSourceId; + /** + * How long a device snapshot may be held before it is asked for again. + * + * Short, and shorter than weather by two orders of magnitude, because the two + * are different kinds of fact: cloud cover moves over ten minutes and a mute + * button moves when somebody presses it. This is also the TTL the browser is + * told to poll on, so it is the floor on how long a viewer waits to see the + * result of somebody else's command. + * + * It is deliberately **not** a public cache lifetime. Nothing on the devices + * routes is ever publicly cached — see `routes/devices.ts`. + */ + ttlSeconds: number; + /** + * The simulator's seed, so a deployment can be reproduced. + * + * The same seed and the same declarations give the same sequence of readings + * on every box, which is what makes a bug report about a level meter + * actionable and what lets the arena wrap this exact simulator and replay a + * rollout. `src/devices/sim.ts` owns the arithmetic. + */ + seed: number; +} + export interface MarkersConfig { source: MarkersSourceId; /** Path to the JSON snapshot written by the sync oneshot. */ @@ -171,6 +223,7 @@ export interface Config { flights: FlightsConfig; satellites: SatellitesConfig; markers: MarkersConfig; + devices: DevicesConfig; offices: { dir: string }; /** * Where the rosters are. Separate from `offices.dir` because the two hold @@ -192,6 +245,7 @@ export function loadConfig(env: Env = process.env): Config { const weather = loadWeather(env, degraded); const flights = loadFlights(env, degraded); const satellites = loadSatellites(env, degraded); + const devices = loadDevices(env, degraded); const auth = loadAuth(env, degraded); const ice = loadIce(env, degraded); // After auth, because a marker feed with nobody able to sign in is worth a @@ -222,6 +276,7 @@ export function loadConfig(env: Env = process.env): Config { flights, satellites, markers, + devices, offices: { dir: str(env, "TERA_OFFICES_DIR", "") }, presence: { dir: str(env, "TERA_PRESENCE_DIR", "") }, auth, @@ -330,6 +385,15 @@ const FLIGHT_SOURCES: FlightsSourceId[] = ["sim", "adsb", "dump1090"]; */ const PLAN_EPOCH_MS = Date.UTC(2026, 0, 1); +/** + * The feed pointed at when `TERA_FLIGHTS_SOURCE=adsb` and nothing else is said. + * + * It is on the allowlist, so the default configuration passes its own gate — + * which is the only kind of default worth shipping, and is asserted in + * `test/adsbLicence.test.ts` so it stays that way. + */ +const DEFAULT_ADSB_ENDPOINT = "https://api.adsb.lol"; + function loadFlights(env: Env, degraded: string[]): FlightsConfig { const asked = str(env, "TERA_FLIGHTS_SOURCE", "sim"); let source = oneOf(asked, FLIGHT_SOURCES); @@ -350,9 +414,47 @@ function loadFlights(env: Env, degraded: string[]): FlightsConfig { source = "sim"; } + const askedEndpoint = str(env, "TERA_ADSB_ENDPOINT", DEFAULT_ADSB_ENDPOINT); + let endpoint = ""; + let attribution: string[] = []; + let redistributable = false; + let licence: string | null = null; + + if (source === "adsb") { + // The licence gate. Everything the wire will say about this source is + // decided here, from the host, before a single request goes out. + const verdict = checkAdsbEndpoint(askedEndpoint); + if (verdict.ok) { + endpoint = verdict.endpoint; + attribution = verdict.attribution; + redistributable = verdict.terms.redistributable; + licence = verdict.terms.licence; + if (verdict.caveat !== null) degraded.push(verdict.caveat); + } else { + degraded.push( + `TERA_ADSB_ENDPOINT="${askedEndpoint}" ${verdict.reason}. Demoted to the simulated ` + + "plan: this box will not republish a feed whose terms it cannot name, and it will " + + "not credit one feed for another feed's data.", + ); + source = "sim"; + } + } + + if (source === "dump1090") { + // The same claim the loopback entry makes, from the same table, because a + // receiver's own aircraft.json and a receiver's own HTTP port are the same + // data arriving by different doors and must not be credited differently. + attribution = adsbAttribution(FIRST_PARTY_RECEIVER); + redistributable = FIRST_PARTY_RECEIVER.redistributable; + licence = FIRST_PARTY_RECEIVER.licence; + } + return { source, - endpoint: str(env, "TERA_ADSB_ENDPOINT", "https://api.adsb.lol"), + endpoint, + attribution, + redistributable, + licence, radiusNm: radius(num(env, "TERA_ADSB_RADIUS_NM", 40, degraded), degraded), dump1090Path, epochMs: num(env, "TERA_FLIGHTS_EPOCH_MS", PLAN_EPOCH_MS, degraded), @@ -380,6 +482,56 @@ function radius(asked: number, degraded: string[]): number { return clamped; } +const DEVICE_SOURCES: DevicesSourceId[] = ["none", "sim", "homeassistant"]; + +/** + * `none` by default, and the default is the honest one rather than the + * impressive one. + * + * A box that has not been told about any hardware has no hardware. It serves an + * empty array and the studio's panels say so, which is the correct picture of a + * deployment nobody has wired anything into — the same posture + * `TERA_WEATHER_SOURCE` takes for exactly the reason CONTRACT.md §5.1 gives. + * `sim` is one variable away and is what the reference deployment runs: a + * deterministic state machine, `synthetic: true` on every reading it produces, + * and every panel that draws it carries the declaration's own disclosure + * sentence. + * + * `homeassistant` is in the union and is not implemented. That is deliberate + * and it demotes loudly rather than silently serving simulated readings under a + * name that promises real ones — a source that quietly downgraded from a real + * bridge to a simulator would be the exact `first-party-sensor`/`simulated` + * confusion `DeviceProvenance` exists to prevent, and it would do it in the one + * direction that matters. + */ +function loadDevices(env: Env, degraded: string[]): DevicesConfig { + const asked = str(env, "TERA_DEVICES_SOURCE", "none"); + let source = oneOf(asked, DEVICE_SOURCES); + if (source === null) { + degraded.push( + `TERA_DEVICES_SOURCE="${asked}" is not one of ${DEVICE_SOURCES.join(", ")}; ` + + `serving no device state at all.`, + ); + source = "none"; + } + + if (source === "homeassistant") { + degraded.push( + "TERA_DEVICES_SOURCE=homeassistant is named in the wire contract and is not " + + "implemented in this build. Demoted to none rather than to sim: serving " + + "invented readings under a source that promises a real bridge is the one " + + "mistake this field exists to prevent.", + ); + source = "none"; + } + + return { + source, + ttlSeconds: num(env, "TERA_DEVICES_TTL", 5, degraded), + seed: num(env, "TERA_DEVICES_SEED", 8731, degraded), + }; +} + const SATELLITE_SOURCES: SatellitesSourceId[] = ["none", "celestrak"]; /** diff --git a/server/src/devices/index.ts b/server/src/devices/index.ts new file mode 100644 index 0000000..09dd0c8 --- /dev/null +++ b/server/src/devices/index.ts @@ -0,0 +1,178 @@ +/** + * Which source answers for device state, and what happens when none does. + * + * The shape `createWeatherService` and `createFlightsService` established: one + * function, the source chosen by the environment, and **it never throws**. + * `current()` always returns a body and `command()` always returns an outcome, + * because the two routes above this have nothing sensible to do with an + * exception and a 500 on a device panel is a studio that looks broken. + * + * Three rules beyond that, and the first is the one that differs from weather: + * + * 1. **`none` serves an empty array, not a fabrication.** The flights service + * falls back to a simulated plan because an empty sky over a city reads as a + * bug; an office with no hardware in it reads as an office with no hardware + * in it, which is the truth and is a perfectly good picture. A box that + * invented microphones nobody had configured would be making a claim about a + * room. `TERA_DEVICES_SOURCE=sim` is one variable away for anyone who wants + * the demonstration studio, and it is what the reference deployment runs. + * 2. **Nothing is simulated until somebody asks.** No interval, no background + * tick; see `devices/sim.ts`. + * 3. **The declarations come from the pack, never from the caller.** + * `devices/store.ts` resolves them against a `Plan` this process built, which + * is what makes a command checkable at all. + * + * ### Commands are memory-only and bounded + * + * A command mutates a simulator held in this process and nothing else. Nothing + * is written to disk, no state outlives a restart, and the number of offices + * simulated at once is capped (CONTRACT.md §5). That is the honest scope of + * what this build's write surface is: a shared, resettable, obviously-simulated + * studio — not a control system, and never a control system by accident. + */ + +import { resolveDevices } from "./store.ts"; +import { createDeviceRuntime, type DeviceRuntime } from "./sim.ts"; +import { + normalizeDeviceCommand, + type DeviceCommand, + type DeviceState, +} from "../../../src/devices/types.ts"; +import type { Office } from "../../../src/interiors/types.ts"; +import type { Config } from "../config.ts"; +import type { DevicesBody } from "../../../src/server/wire.ts"; + +export interface DevicesService { + /** + * Every device in one office, right now. Never throws; an office with no + * declarations, or a box with no source, is an empty list and a 200. + */ + current(office: Office): DevicesBody; + /** + * Apply one command. + * + * The failure cases are collapsed into one on purpose. `no-such-device` and + * `not-permitted-op` are different mistakes by the same caller, and the route + * answers 400 to both — a caller who has been told *which* of its guesses was + * wrong is a caller being helped to guess again. + */ + command(office: Office, command: DeviceCommand): DeviceCommandOutcome; + /** What this box will serve for an office. The route uses it for its 400s. */ + declarationCount(office: Office): number; +} + +export type DeviceCommandOutcome = + | { ok: true; device: DeviceState } + | { ok: false; reason: string }; + +export interface DevicesLog { + warn(msg: string): void; +} + +/** + * Attribution for a source nobody but us produced. + * + * Empty, and it stays empty for `sim`: the readings are this repo's own + * arithmetic and there is nobody to thank for them. Crediting anybody would be + * the same mistake the flights service made when it credited adsb.lol for its + * own simulator's aircraft. A `homeassistant` bridge would put the operator's + * own attribution here, which is why the field exists on the body at all. + */ +const NO_ATTRIBUTION: string[] = []; + +export function createDevicesService(config: Config, log: DevicesLog): DevicesService { + const { source, ttlSeconds, seed } = config.devices; + // Built even for `none`, because it costs one empty `Map` and it means the + // two branches below differ by a single condition rather than by a structure. + const runtime: DeviceRuntime = createDeviceRuntime({ seed }); + + /** + * Offices already complained about, so a poll every five seconds does not + * become a log line every five seconds. Bounded by the same office-id key + * space everything else here is, and it is only ever added to when a pack is + * genuinely broken. + */ + const complained = new Set(); + + /** + * One line, once, for a pack that declares hardware none of which resolved. + * + * The single operator-facing diagnostic this service has, and it is worth + * having: every drop in `store.ts` is silent by design — a public route must + * not narrate a pack's mistakes — so without this a mistyped `anchor.propId` + * produces an empty panel and no explanation anywhere. + */ + const report = (office: Office, resolved: { declarations: readonly unknown[]; authored: number }) => { + if (resolved.authored === 0 || resolved.declarations.length > 0) return; + if (complained.has(office.id) || complained.size > 64) return; + complained.add(office.id); + log.warn( + `devices: office "${office.id}" declares ${resolved.authored} device(s) and none of them ` + + "resolved — check that each anchor.propId names a prop on that level whose kind is the " + + "declaration's assetId. See server/src/devices/store.ts.", + ); + }; + + /** The shared shell of a body, so the two paths cannot disagree about it. */ + const body = (office: Office, devices: DeviceState[], observedAt: number): DevicesBody => ({ + officeId: office.id, + devices, + observedAt, + source, + // Never `false` in this build. The only implemented source is a state + // machine, and a body that claimed observation would be a lie told by a + // constructor — the same sentence `initialDeviceState` carries. + synthetic: true, + ttlSeconds, + ...(NO_ATTRIBUTION.length > 0 ? { attribution: NO_ATTRIBUTION } : {}), + }); + + return { + current(office: Office): DevicesBody { + const now = Date.now(); + if (source === "none") return body(office, [], now); + const resolved = resolveDevices(office); + report(office, resolved); + const { declarations } = resolved; + if (declarations.length === 0) return body(office, [], now); + const simulator = runtime.advance(office.id, declarations, now); + // Restamped with the request's clock: the simulator's own `observedAt` is + // its epoch plus its simulated elapsed time, which lags by up to a step + // and by the whole of a catch-up cap. What a viewer wants to know is when + // this reading was taken, and that is now. + return body( + office, + simulator.current().map((state) => ({ ...state, observedAt: now })), + now, + ); + }, + + command(office: Office, command: DeviceCommand): DeviceCommandOutcome { + if (source === "none") return { ok: false, reason: "this deployment has no device source" }; + const { declarations } = resolveDevices(office); + const declaration = declarations.find((d) => d.id === command.deviceId); + // The whole of the authorisation for a write, in two lines. The device + // must be one this process found in the pack, and the op must be one that + // declaration declared — checked against the *resolved* pack rather than + // against anything in the request, which is what makes this a boundary + // rather than a formality. + if (declaration === undefined) return { ok: false, reason: "no such device in this office" }; + const normalized = normalizeDeviceCommand(declaration, command); + if (normalized === null) return { ok: false, reason: "that device will not accept that command" }; + + const now = Date.now(); + const simulator = runtime.advance(office.id, declarations, now); + simulator.command(normalized); + const device = simulator.current().find((state) => state.id === normalized.deviceId); + // Unreachable: `normalized` names a declaration this simulator was built + // from. Reported rather than asserted anyway — a route that threw here + // would turn a device the pack author renamed into a 500. + if (device === undefined) return { ok: false, reason: "no such device in this office" }; + return { ok: true, device: { ...device, observedAt: now } }; + }, + + declarationCount(office: Office): number { + return source === "none" ? 0 : resolveDevices(office).declarations.length; + }, + }; +} diff --git a/server/src/devices/sim.ts b/server/src/devices/sim.ts new file mode 100644 index 0000000..9be006a --- /dev/null +++ b/server/src/devices/sim.ts @@ -0,0 +1,166 @@ +/** + * The simulator, on a wall clock. + * + * `src/devices/sim.ts` is a fixed-step state machine that reads no clock. This + * file is the ten lines that make it answer questions asked over HTTP: one + * simulator per office, advanced to *now* the moment somebody asks and never + * otherwise. + * + * **It is the same module the browser and the arena drive.** Not a port, not a + * server-side reimplementation — the import is `src/devices/sim.ts`. That is + * the property that makes a bug report about a level meter reproducible and + * makes an arena rollout describe the same studio a viewer is looking at, and + * it is worth the one awkwardness it costs: a package under `src/` imported by + * the server, which `media/bindings.ts` and `regions.ts` already do for exactly + * the same reason. + * + * ### Nothing ticks in the background + * + * There is no interval here. A box nobody is looking at advances no simulation, + * makes no outbound request and does no work at all — the third rule + * `upstream.ts` states for weather and flights, applied to a source that + * happens to be local. The cost is that the first request after a quiet hour + * has an hour to catch up on, which is what `MAX_CATCHUP_STEPS` is about. + */ + +import { createSimulatedDevices, type SimulatedDevices } from "../../../src/devices/sim.ts"; +import type { DeviceDeclaration } from "../../../src/devices/types.ts"; + +/** + * Seconds per simulated step, server-side. + * + * A tenth of a second, matching the arena's `fixedStepSeconds`, so a rollout + * and a deployment are running the same physics at the same resolution. Finer + * would buy nothing over a five-second poll; coarser would make a level meter + * step visibly between polls. + */ +const STEP_SECONDS = 0.1; + +/** + * How far one request may advance a simulator that has been idle. + * + * Sixty seconds' worth. Past that the simulated clock simply jumps: catching up + * honestly on an office nobody has opened since yesterday would be nearly a + * million steps inside one request, to arrive at a level meter reading that + * nobody watched accumulate and that carries no information — the state a + * microphone converges to is not a function of how long it has been ignored. + * Commands and settings are unaffected, because they are held state rather than + * integrated state. + */ +const MAX_CATCHUP_STEPS = 600; + +/** + * A ceiling on how many offices are simulated at once. + * + * CONTRACT.md §5: memory-only, bounded state. The key space is office ids and + * `offices/store.ts` will look up any id matching its pattern, so without a cap + * an anonymous caller — well, a *signed-in* caller, this route takes a session — + * could grow this map one request at a time. The least recently touched entry + * is dropped, which loses nothing that cannot be rebuilt: a dropped simulator + * comes back powered-off, which is where it started. + */ +const MAX_OFFICES = 16; + +export interface DeviceRuntime { + /** The simulator for one office, advanced to `nowMs`. */ + advance(officeId: string, declarations: readonly DeviceDeclaration[], nowMs: number): SimulatedDevices; + /** How many offices are currently being simulated. For tests and for the bound. */ + size(): number; +} + +interface Entry { + simulator: SimulatedDevices; + /** The declaration ids and capabilities this simulator was built for. */ + signature: string; + /** Simulated time, in epoch milliseconds, that this simulator has reached. */ + clockMs: number; + /** When it was last asked for, so the cap can drop the coldest. */ + touchedMs: number; +} + +export interface DeviceRuntimeOptions { + seed: number; +} + +export function createDeviceRuntime(options: DeviceRuntimeOptions): DeviceRuntime { + const offices = new Map(); + + return { + advance(officeId, declarations, nowMs): SimulatedDevices { + const signature = signatureOf(declarations); + let entry = offices.get(officeId); + + // A pack that has been edited on disk is a different studio, and resuming + // a simulator built for the old one would leave readings for devices that + // no longer exist and none for the ones that do. Rebuilt rather than + // patched: the state a device machine carries is a few booleans and a + // level, and none of it is worth migrating. + if (entry === undefined || entry.signature !== signature) { + entry = { + simulator: createSimulatedDevices(declarations, { + // The office id is mixed into the seed so two studios on one box do + // not run in lockstep — every mic in the building peaking together + // is the tell that gives a simulation away. + seed: (options.seed ^ hash(officeId)) | 0, + fixedStepSeconds: STEP_SECONDS, + epochMs: nowMs, + }), + signature, + clockMs: nowMs, + touchedMs: nowMs, + }; + offices.set(officeId, entry); + evict(offices); + return entry.simulator; + } + + const stepMs = STEP_SECONDS * 1000; + const behind = Math.max(0, nowMs - entry.clockMs); + const steps = Math.min(MAX_CATCHUP_STEPS, Math.floor(behind / stepMs)); + for (let i = 0; i < steps; i += 1) entry.simulator.stepFixed(); + // The clock is set to `now` whichever branch ran. Advancing it by + // `steps * stepMs` instead would leave a simulator that had been capped + // permanently behind, and it would try to catch up again on every + // subsequent request — one poll's worth of work turning into a treadmill. + entry.clockMs = nowMs; + entry.touchedMs = nowMs; + // Re-inserted so the map's iteration order is least-recently-touched + // first, which is what makes `evict` drop the coldest office rather than + // the oldest one. + offices.delete(officeId); + offices.set(officeId, entry); + return entry.simulator; + }, + + size: () => offices.size, + }; +} + +function evict(offices: Map): void { + while (offices.size > MAX_OFFICES) { + const coldest = offices.keys().next(); + if (coldest.done) return; + offices.delete(coldest.value); + } +} + +/** + * What a simulator was built for, as a string. + * + * Ids and capabilities, in order — everything that changes which readings exist + * and which commands are legal. Deliberately not the labels or the disclosure, + * which are prose a pack author may reword without changing an instrument. + */ +function signatureOf(declarations: readonly DeviceDeclaration[]): string { + return declarations.map((d) => `${d.id}:${d.kind}:${d.capabilities.join(",")}`).join("|"); +} + +/** FNV-1a, so two office ids that differ by one character seed differently. */ +function hash(text: string): number { + let value = 0x811c9dc5; + for (let i = 0; i < text.length; i += 1) { + value ^= text.charCodeAt(i); + value = Math.imul(value, 0x01000193); + } + return value >>> 0; +} diff --git a/server/src/devices/store.ts b/server/src/devices/store.ts new file mode 100644 index 0000000..05f6481 --- /dev/null +++ b/server/src/devices/store.ts @@ -0,0 +1,189 @@ +/** + * Which devices an office actually has, decided by the server. + * + * The device routes never take a client's word for what is in a room. A read + * answers with the hardware **this** process found in the pack, and a command is + * refused unless the id it names is one of them — which is the same move + * `officeHasMediaBinding()` makes for a screen share, for the same reason: a + * device id in a request body is a claim, and the only thing that can check a + * claim about a building is the building. + * + * ### It asks `Plan`, and does not hold a second opinion + * + * The rules a device has to pass are real and they are exacting — the anchor + * prop must exist, be on the level the declaration claims, and not be some + * *other* instrument's hardware; the declaration must validate; the id must be + * unique; the disclosure must say "simulated" if the provenance does. All of + * that is implemented once, in `Plan.resolveDevice`, because it is the same + * question the renderer asks and the answers have to agree. + * + * They have to agree in a specific direction that is easy to miss. `Plan` + * deliberately *allows* a microphone anchored to a desk — `DeviceAnchor.offset` + * exists for exactly those few centimetres — and only refuses an anchor to a + * prop that is itself a device of another kind. A stricter copy of that rule + * here would silently drop a legitimate self-hosted pack's devices from the API + * while the browser drew them, which is the worst kind of disagreement: the + * panel is populated and every command it sends is refused. + * + * So this file resolves one `Plan` and reads `allDevices()` off it. What it adds + * is the two things a request path needs and a build step does not: it never + * throws, and it is bounded. + * + * ### Why it cannot simply trust the pack + * + * An `Office` reaches this from two places: a bundled pack compiled into this + * repo, and a JSON file in `TERA_OFFICES_DIR` that an operator wrote by hand. + * The second is untrusted input in the ordinary sense — `Plan` is written for + * authored TypeScript and reads a declaration's `label.trim()` without asking + * whether it is a string, which a hand-edited file is entitled to get wrong. A + * `TypeError` from inside a resolver would leave the route answering 500 for a + * studio whose only fault is a typo, so the whole resolution is wrapped and a + * pack this process cannot read has no devices. `offices/store.ts` takes the + * same posture toward the document that carries it. + */ + +import { Plan } from "../../../src/interiors/plan.ts"; +import type { Office } from "../../../src/interiors/types.ts"; +import type { DeviceDeclaration } from "../../../src/devices/types.ts"; + +/** + * A ceiling on how many devices one office may declare. + * + * Not a statement about studio size — the two shipped packs declare a handful + * each. It is a bound on what one file can do to this process: every device is + * a simulator entry advanced on every poll and a row in a body served to every + * viewer, so a pack with fifty thousand microphones in it, by mistake or + * otherwise, is a box that stops answering. Dropping the tail is visible and + * recoverable; the alternative is not. `presence/store.ts` bounds a roster for + * the identical reason. + */ +const MAX_DEVICES = 64; + +export interface ResolvedDevices { + /** The devices this box will serve and command, in pack order. */ + declarations: readonly DeviceDeclaration[]; + /** + * How many the pack *tried* to declare. + * + * Carried so that "this office has no devices" and "this office declares + * devices and not one of them resolved" can be told apart by the one party + * who can fix the second — the operator, through one line in the log. Without + * it a pack with a mistyped `propId` is indistinguishable from a pack that + * never mentioned a microphone, and the visible symptom of both is an empty + * panel. + */ + authored: number; +} + +interface Cached extends ResolvedDevices { + office: Office; +} + +/** + * One entry per office, keyed on the id and validated against the object. + * + * `Plan` is not free — it re-resolves every wall, prop and seat — and the device + * routes are polled every few seconds, so resolving per request would make a + * device panel the most expensive thing on the box. The `office` field is the + * real key: a bundled pack is one stable object for the life of the process, and + * a pack read off disk is a fresh object every time the file is re-read, which + * is exactly when the answer should be recomputed. + */ +const cache = new Map(); + +/** + * A ceiling on how many offices are remembered at once. + * + * "Bounded by what is on disk" is bounded by whatever a caller can name, and + * `offices/store.ts` will look up any id matching its pattern. So the map is + * capped and the oldest entry is dropped, which keeps this a cache rather than + * an unbounded index a stranger can grow by asking for offices that do not + * exist. CONTRACT.md §5. + */ +const MAX_CACHED_OFFICES = 16; + +/** The devices this box will serve and command for one office. Never throws. */ +export function resolveDevices(office: Office): ResolvedDevices { + const hit = cache.get(office.id); + if (hit !== undefined && hit.office === office) return hit; + + const resolved = resolve(office); + if (cache.size >= MAX_CACHED_OFFICES) { + const oldest = cache.keys().next(); + if (!oldest.done) cache.delete(oldest.value); + } + cache.set(office.id, { office, ...resolved }); + return resolved; +} + +/** For tests, and for anything that swaps a pack under a running process. */ +export function forgetResolvedDevices(): void { + cache.clear(); +} + +function resolve(office: Office): ResolvedDevices { + const authored = authoredCount(office); + if (authored === 0) return { declarations: [], authored: 0 }; + + let resolved; + try { + // `full` depth, because this is the server deciding what hardware exists, + // not what a particular viewer may see. Who may *read* the state is the + // route's decision and it is made before this is ever called. `warn: false` + // because a pack's problems are the pack author's business and this is a + // request path, not a build step. + resolved = new Plan(office, { depth: "full", warn: false }).allDevices(); + } catch { + // See the header: a hand-edited pack is entitled to be malformed, and the + // honest answer to one this process cannot read is that it has no devices — + // not a 500 on a studio somebody is standing in. + return { declarations: [], authored }; + } + + const declarations = resolved.slice(0, MAX_DEVICES).map( + (device): DeviceDeclaration => ({ + id: device.id, + kind: device.kind, + label: device.label, + assetId: device.assetId, + // The resolved coordinate is deliberately dropped. This side never renders + // anything, and a declaration here exists to answer two questions — does + // this device exist, and may it be asked to do this — neither of which a + // position is part of. `anchor.seatId` survives because it is the one + // address the simulator uses: a microphone's level responds to whether + // anybody is at the desk it serves. + anchor: { + levelId: device.levelId, + propId: device.propId, + ...(device.roomId === undefined ? {} : { roomId: device.roomId }), + ...(device.seatId === undefined ? {} : { seatId: device.seatId }), + }, + capabilities: [...device.capabilities], + provenance: device.provenance, + disclosure: device.disclosure, + }), + ); + return { declarations, authored }; +} + +/** + * How many devices the pack's files mention, whatever state they are in. + * + * Counted rather than resolved, and read as `unknown` rather than through the + * `Floorplan` type, because the whole value of the number is that it is + * available when the resolution produced nothing — including when the pack is + * malformed enough that `Plan` refused it outright. + */ +function authoredCount(office: Office): number { + const levels: unknown = office.levels; + if (!Array.isArray(levels)) return 0; + let count = 0; + for (const level of levels) { + if (level === null || typeof level !== "object") continue; + const floorplan = (level as { floorplan?: unknown }).floorplan; + if (floorplan === null || typeof floorplan !== "object") continue; + const devices = (floorplan as { devices?: unknown }).devices; + if (Array.isArray(devices)) count += devices.length; + } + return count; +} diff --git a/server/src/flights/adsb.ts b/server/src/flights/adsb.ts index 8061d84..161e6d5 100644 --- a/server/src/flights/adsb.ts +++ b/server/src/flights/adsb.ts @@ -2,19 +2,23 @@ * Real aircraft, from feeds that can actually be pointed at. * * Two sources, one shape. `adsb.lol` and `airplanes.live` serve the same - * volunteer-fed ADS-B in the same JSON, keyless and with open terms — set - * `TERA_ADSB_ENDPOINT` to whichever. A local `dump1090` writes that same JSON to - * disk, and reading it is the best answer of the three: an RTL-SDR on a box in - * the Bay produces first-party data with no terms to comply with at all. + * volunteer-fed ADS-B in the same JSON, keyless and under the ODbL — set + * `TERA_ADSB_ENDPOINT` to whichever, and to nothing else: `licence.ts` holds + * the allowlist and derives the credit from whichever answered. A local + * `dump1090` writes that same JSON to disk, and reading it is the best answer + * of the three: an RTL-SDR on a box in the Bay produces first-party data with + * no terms to comply with at all. * - * FlightRadar24 is deliberately absent and will stay absent. Their terms forbid - * scraping and forbid redistribution, so a client for it in an Apache-2.0 repo - * would be shipping instructions for breaking a ToS. If a private deployment - * wants it, it is an adapter in that deployment. ARCHITECTURE.md §4. + * FlightRadar24 is deliberately absent and will stay absent: their terms do not + * permit scraping and do not permit redistribution, so a client for it in an + * Apache-2.0 repo would be shipping instructions for breaking a ToS. If a + * private deployment wants it, it is an adapter in that deployment. + * ARCHITECTURE.md §4. */ import { readFile } from "node:fs/promises"; import { getJson } from "../http.ts"; +import { isOpenAdsbUrl } from "./licence.ts"; import type { WireAircraft } from "../../../src/server/wire.ts"; /** The shared dump1090/readsb aircraft record, as both feeds emit it. */ @@ -55,6 +59,15 @@ export interface AdsbLog { * * How often this is allowed to be called, and the arithmetic that keeps it * inside adsb.lol's one-request-per-second ceiling, is in `flights/index.ts`. + * + * The same argument applies to *which* feed, and is enforced twice. `config.ts` + * refuses an endpoint that is not on `licence.ts`'s allowlist at boot, so the + * only string that can reach this parameter today is one an operator was told + * about. The check below is the second lock, on the door itself: this function + * will fetch anywhere, and it is the one that turns a URL into bytes this box + * republishes under a derived open-terms credit. A caller that one day builds + * the endpoint from somewhere else pays one `new URL()` per poll to find that + * out, rather than the public finding out later. */ export async function fetchAdsb( endpoint: string, @@ -63,6 +76,13 @@ export async function fetchAdsb( log?: AdsbLog, ): Promise { const url = `${endpoint.replace(/\/$/, "")}/v2/point/${center.lat.toFixed(4)}/${center.lng.toFixed(4)}/${Math.round(radiusNm)}`; + if (!isOpenAdsbUrl(url)) { + log?.warn( + `flights:adsb: refusing to fetch ${endpoint} — it is not an openly-licensed feed this ` + + `build may republish. See server/src/flights/licence.ts.`, + ); + return null; + } const body = await getJson(url); if (body === null) return null; return normalise(body, (dropped) => @@ -151,9 +171,17 @@ function normalise( const callsign = a.flight?.trim(); const id = a.hex ?? callsign; if (id === undefined || id === "") continue; + const address = icao24(a.hex); aircraft.push({ id, callsign: callsign === "" ? undefined : callsign, + // Carried only when it is one. Both feeds emit `~`-prefixed anonymous + // addresses for TIS-B and MLAT targets, which are not ICAO addresses at + // all, and `id` falls back to the callsign for a record with no `hex` — + // so the id is not a reliable address and a detail card must not present + // it as one. `WireAircraft.icao24` says the same thing from the other + // end of the wire. + ...(address === null ? {} : { icao24: address }), lat: a.lat, lng: a.lon, // The feeds report barometric altitude in feet, and send the string @@ -165,6 +193,20 @@ function normalise( return { aircraft, observedAt: observedAtMs(body.now) }; } +/** + * A transponder address as the feeds write one: six hex digits, no prefix. + * + * Anchored and lowercased rather than pattern-matched loosely, because the + * output is something a person pastes into a registry lookup — a wrong one + * names a different aircraft, which is worse than saying nothing. Anything + * else, including the `~abcdef` anonymous form, is `null`. + */ +function icao24(hex: string | undefined): string | null { + if (typeof hex !== "string") return null; + const trimmed = hex.trim().toLowerCase(); + return /^[0-9a-f]{6}$/.test(trimmed) ? trimmed : null; +} + /** * dump1090 stamps `now` in seconds and the hosted feeds stamp it in * milliseconds, using the same field name. Anything past the year 2001 in diff --git a/server/src/flights/index.ts b/server/src/flights/index.ts index bae29eb..a3eb46a 100644 --- a/server/src/flights/index.ts +++ b/server/src/flights/index.ts @@ -14,6 +14,18 @@ * empty coastline apart, with no sane radius that covers both. The requested * region supplies the centre; the radius stays what the operator configured. * + * ### Whose data this is + * + * Nothing in this file decides who gets the credit. `TERA_ADSB_ENDPOINT` is + * checked against an allowlist of openly-licensed feeds in `flights/licence.ts` + * before the process finishes booting, and the credit lines, the licence id and + * the one bit that says whether a shared cache may keep the body all come out + * of the entry that matched. An endpoint that is not on the list never reaches + * this file at all — `config.ts` has already demoted the source to `sim` and + * said so on `/api/v1/health`. The version of this file that hardcoded + * `adsb.lol` into the attribution regardless of the endpoint is why that gate + * exists. + * * ### Staying inside adsb.lol's limits * * adsb.lol asks for **no more than one request per second** from a client and @@ -75,6 +87,10 @@ const RECEIVER_KEY = "receiver"; export function createFlightsService(config: Config, log: FlightsLog): FlightsService { const { source, endpoint, radiusNm, dump1090Path, epochMs, seed, ttlSeconds } = config.flights; + // Both derived in `config.ts` from the host that will actually answer, by + // `flights/licence.ts`. Read once here so that no code path in this file can + // construct a live body with a credit line it made up. + const { attribution, redistributable, licence } = config.flights; // Built once per region and kept: the plan is a pure function of the centre, // and it is handed out on every cacheable request. @@ -119,9 +135,9 @@ export function createFlightsService(config: Config, log: FlightsLog): FlightsSe observedAt: snapshot.observedAt, aircraft: snapshot.aircraft, ttlSeconds: liveTtl, - ...(source === "adsb" - ? { attribution: ["Aircraft positions from the adsb.lol community feed"] } - : {}), + redistributable, + ...(attribution.length > 0 ? { attribution } : {}), + ...(licence === null ? {} : { licence }), }; }, }; diff --git a/server/src/flights/licence.ts b/server/src/flights/licence.ts new file mode 100644 index 0000000..801aca8 --- /dev/null +++ b/server/src/flights/licence.ts @@ -0,0 +1,296 @@ +/** + * Which ADS-B feeds this box is allowed to republish, and who gets the credit. + * + * This is the one module in the repo that exists because of a **live real-world + * exposure** rather than a feature. `TERA_ADSB_ENDPOINT` used to be a free-form + * string: whatever it pointed at, the aircraft that came back were served from + * `/api/v1/flights` with `Cache-Control: public` and an `attribution` array that + * said, unconditionally, *adsb.lol*. Three things were therefore true at once on + * any deployment one environment variable away from the default: + * + * 1. this box would fetch a feed nobody had checked the terms of, + * 2. it would hand the bytes to a shared cache to hand to everyone else, and + * 3. it would credit a community feed that had never seen them. + * + * (1) is an operator's business. (2) is *Publicly Using a Derivative Database* + * and is the trigger CONTRACT.md §8 is about — the same reasoning that made the + * geocoder a US Census one. (3) is worse than either: an open-terms credit + * attached to data that did not come from the open lane is a false licence + * statement, and it is false in the direction that invites a downstream + * consumer to redistribute something they may not. + * + * So the endpoint is checked against a table, and **everything the wire says + * about the source is derived from the host that actually answered**. There is + * no path here that lets a credit line and a hostname disagree, because the + * credit is computed from the hostname. An endpoint that is not in the table is + * refused — `config.ts` demotes the source to the simulated plan and writes one + * sentence into `degraded[]` naming the host — and the demotion is deliberately + * not fatal, for the same reason nothing else in `config.ts` is: CONTRACT.md + * §5.1 says a misconfigured source is demoted, not a boot failure. The operator + * gets a working map, a plan-mode sky and a line on `/api/v1/health` telling + * them exactly which variable to fix. + * + * ### Adding a feed + * + * Read the feed's published terms. Write down, in the entry: the licence, a URL + * where the next person can read the same terms, the credit line that feed asks + * for, and whether those terms let this box re-serve the bytes to third parties. + * If you cannot answer all four, the entry does not go in — a guess here is the + * failure this module exists to prevent. `adsb.fi` and a handful of other + * community mirrors serve the same `/v2/point` shape and are plausible next + * entries; they are absent because nobody on this side has read their terms, + * which is the honest reason and the only one that should ever appear here. + * + * ARCHITECTURE.md §4, NOTICE's AIRCRAFT DATA block. + */ + +import type { FlightsBody } from "../../../src/server/wire.ts"; + +/** + * The licence a feed's data arrives under. + * + * `first-party` is not a licence at all and says so: it is the operator's own + * antenna, and there is nobody to comply with. See `LOOPBACK_CAVEAT` for why + * that claim is only ever as good as the operator making it. + */ +export type AdsbLicenceId = "ODbL-1.0" | "first-party"; + +export interface AdsbFeedTerms { + /** Lowercased hostname, matched exactly against the configured endpoint's. */ + host: string; + /** Named in the credit line. This is who the bytes came from, in prose. */ + credit: string; + licence: AdsbLicenceId; + /** Where a human reads the terms this entry claims. Never a guess. */ + terms: string; + /** + * May this box re-serve the data to third parties? + * + * Gates `publicCache` on the flights route — see `mayRepublish`. A feed that + * is free to *use* and not free to *redistribute* is a real category (several + * aviation feeds are exactly that), and the whole point of carrying the flag + * rather than assuming it is that such an entry can be added later without + * anybody having to remember to also change the route. + */ + redistributable: boolean; + /** + * Plain HTTP is acceptable for this host. + * + * True only for loopback: a `readsb` on the same box has no certificate and + * needs none, and forcing TLS there would push self-hosters towards a public + * feed for no security gain. Everything reachable off-box must be https — + * without it, whoever is between us and the feed chooses what this box + * publishes under an open-terms credit. + */ + loopback?: boolean; + /** One sentence for `degraded[]` when this entry is the configured one. */ + caveat?: string; +} + +/** + * A receiver on the same box. First-party by construction — and by trust. + * + * Shared with the `dump1090` file source, which is the same data arriving by a + * different door, so the two cannot drift apart in what they claim. + */ +export const FIRST_PARTY_RECEIVER: AdsbFeedTerms = { + host: "localhost", + credit: "this deployment's own ADS-B receiver", + licence: "first-party", + terms: "no licence: data received by the operator's own antenna", + redistributable: true, + loopback: true, + caveat: + "TERA_ADSB_ENDPOINT points at a loopback address, so aircraft are credited to this " + + "deployment's own receiver. That credit is derived from the host and only you can vouch " + + "for it: if the process on that port is a proxy for somebody else's feed, this box is " + + "publishing their data under your name.", +}; + +/** + * The feeds this repo will point at, with the terms it publishes about them. + * + * Two hosted community feeds and the loopback family. Both hosted entries serve + * volunteer-fed ADS-B under the Open Database Licence, both are keyless, and + * both answer the same `/v2/point/:lat/:lng/:radiusNm` shape `adsb.ts` parses — + * which is not a coincidence, they are the same lineage of software. + * + * The apex domains are absent on purpose: `adsb.lol` serves a website and + * `api.adsb.lol` serves the API, and an operator who types the former gets a + * near-miss hint out of `checkAdsbEndpoint` rather than a 404 loop. + */ +export const ADSB_ALLOWLIST: readonly AdsbFeedTerms[] = [ + { + host: "api.adsb.lol", + credit: "the adsb.lol community feed", + licence: "ODbL-1.0", + terms: "https://adsb.lol/legal-and-license/", + redistributable: true, + }, + { + host: "api.airplanes.live", + credit: "the airplanes.live community feed", + licence: "ODbL-1.0", + terms: "https://airplanes.live/", + redistributable: true, + caveat: + "TERA_ADSB_ENDPOINT=airplanes.live: their feed is volunteer-funded and asks callers to " + + "stay inside a request-per-second ceiling, which flights/index.ts holds structurally. " + + "The ODbL credit is attached to every body automatically.", + }, + FIRST_PARTY_RECEIVER, + { ...FIRST_PARTY_RECEIVER, host: "127.0.0.1" }, + { ...FIRST_PARTY_RECEIVER, host: "::1" }, +]; + +export type AdsbEndpointVerdict = + | { + ok: true; + /** The endpoint, normalised: origin plus path, no trailing slash. */ + endpoint: string; + terms: AdsbFeedTerms; + /** What the wire must say about this source. Derived, never authored. */ + attribution: string[]; + /** One sentence for `degraded[]`, or `null` when there is nothing to say. */ + caveat: string | null; + } + | { ok: false; reason: string }; + +/** + * Is this endpoint one this box may fetch, republish and credit? + * + * Every refusal `reason` is a fragment that reads correctly after + * `TERA_ADSB_ENDPOINT="…"` — `config.ts` builds the sentence, this builds the + * clause, and the host is always named so the operator can see which part of + * their URL was the problem. + */ +export function checkAdsbEndpoint(raw: string): AdsbEndpointVerdict { + const trimmed = raw.trim(); + if (trimmed === "") return { ok: false, reason: "is empty" }; + + let url: URL; + try { + url = new URL(trimmed); + } catch { + return { ok: false, reason: "is not a URL" }; + } + + // A credential is not a syntax problem, it is a category one: every feed in + // the table is keyless, so a URL carrying a secret is by construction not one + // of them — it is somebody's paid account, and paid accounts are exactly the + // terms that do not permit republication. + if (url.username !== "" || url.password !== "") { + return { + ok: false, + reason: + "carries credentials, and every openly-licensed feed here is keyless — a URL with a " + + "secret in it is an account, and an account's data is not ours to re-serve", + }; + } + + // `adsb.ts` appends `/v2/point/…` to this string. A query or a fragment + // would end up in the middle of the path, so it is refused rather than + // silently dropped: an operator who put an API key in `?key=` needs to see + // that it was neither used nor honoured. + if (url.search !== "" || url.hash !== "") { + return { + ok: false, + reason: + "carries a query string or fragment; this is a base URL that /v2/point/:lat/:lng/:nm " + + "is appended to, so anything after it would land in the middle of the path", + }; + } + + // `URL.hostname` keeps the brackets on an IPv6 literal — `http://[::1]/` + // parses to a hostname of `[::1]` — so they come off before the table is + // consulted, and the table stores the address the way a person writes it. + const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + const terms = ADSB_ALLOWLIST.find((entry) => entry.host === host); + if (terms === undefined) { + return { ok: false, reason: `points at ${host}, which ${notOnTheList(host)}` }; + } + + if (url.protocol !== "https:" && !(terms.loopback === true && url.protocol === "http:")) { + return { + ok: false, + reason: + `reaches ${host} over ${url.protocol.replace(":", "")}, and only https will do off-box: ` + + "without it, whoever is on the path chooses what this deployment publishes under an " + + "open-terms credit", + }; + } + + return { + ok: true, + endpoint: `${url.origin}${url.pathname.replace(/\/+$/, "")}`, + terms, + attribution: adsbAttribution(terms), + caveat: terms.caveat ?? null, + }; +} + +/** + * The credit lines for a feed, built from the entry rather than written twice. + * + * ODbL §4.3 wants the notice to name the source *and* point at the licence, so + * an ODbL feed gets two lines and a first-party receiver gets one. The browser + * displays whatever it is sent, in order, which is why the source line is first. + */ +export function adsbAttribution(terms: AdsbFeedTerms): string[] { + const lines = [`Aircraft positions from ${terms.credit}`]; + if (terms.licence === "ODbL-1.0") { + lines.push( + "Made available under the Open Database License (ODbL) v1.0 — " + + "https://opendatacommons.org/licenses/odbl/1-0/", + ); + } + return lines; +} + +/** + * The last line of defence, at the point of the request. + * + * `config.ts` has already validated the endpoint by the time anything calls + * `fetchAdsb`, so this should never fire. It is here because "should never" is + * a property of today's call graph and this module is about the case where that + * property quietly stops holding — a future caller that builds a URL from + * somewhere else pays one `new URL()` per poll to find out it was wrong, + * instead of the public getting a credited copy of a feed nobody checked. + */ +export function isOpenAdsbUrl(url: string): boolean { + try { + const host = new URL(url).hostname.toLowerCase(); + return ADSB_ALLOWLIST.some((entry) => entry.host === host); + } catch { + return false; + } +} + +/** + * May a shared cache keep this body and hand it to the next caller? + * + * The plan is this project's own arithmetic under Apache-2.0, so it always may. + * A live body may only if the entry the positions came from said so. The route + * asks this instead of asking the config, because the thing being cached is the + * body, and a body that outlives a config change is precisely the bug a shared + * cache produces. + */ +export function mayRepublish(body: FlightsBody): boolean { + return body.mode === "plan" ? true : body.redistributable; +} + +/** + * "…is not on the open-feed allowlist", plus a nudge when the host looks like + * somebody reaching for one of the entries and missing by a subdomain. + */ +function notOnTheList(host: string): string { + const near = ADSB_ALLOWLIST.find( + (entry) => entry.host.endsWith(`.${host}`) || host.endsWith(`.${entry.host}`), + ); + const hosts = ADSB_ALLOWLIST.map((entry) => entry.host).join(", "); + const hint = near === undefined ? "" : ` (did you mean https://${near.host}?)`; + return ( + `is not one of the openly-licensed feeds this build will republish — ${hosts}${hint}. ` + + "The endpoint is not fetched and its data is not credited to anyone" + ); +} diff --git a/server/src/media/bindings.ts b/server/src/media/bindings.ts index a99dd66..df1ab87 100644 --- a/server/src/media/bindings.ts +++ b/server/src/media/bindings.ts @@ -1,4 +1,13 @@ -/** Server-authoritative lookup for authored office screen surfaces. */ +/** + * Server-authoritative lookup for the things an office pack *authored* — screen + * surfaces today, device hardware as well now. + * + * The rule both callers share: a client says "this screen", "this microphone", + * and the server checks the claim against a pack it resolved itself rather than + * against anything in the request. `officeHasMediaBinding` does it for a screen + * share; `server/src/devices/store.ts` does it for a device command, and reads + * the same bundled packs through `bundledOffice` for the same reason. + */ import { Plan } from "../../../src/interiors/plan.ts"; import type { Office } from "../../../src/interiors/types.ts"; @@ -17,10 +26,21 @@ const BUNDLED_OFFICES: ReadonlyMap = new Map( ); /** - * A bundled pack is public sample geometry, so its exact authored screen - * catalogue is the only bundled exception. No real/private pack is inferred. + * The three packs this repo ships, by id, or `null` for anything else. + * + * A bundled pack is public sample geometry — it is compiled into the browser + * bundle, so its authored catalogue of screens and devices is already public by + * construction and the server reading its own copy tells nobody anything new. + * That is what makes it a safe exception, and it is the **only** exception: no + * real or private pack is ever inferred from anything, and an id that is not one + * of these three is not an office as far as this function is concerned. + * + * Why it is needed at all: the browser renders bundled packs without asking the + * API for them, so a viewer can be standing in `lumbridge-hq` on a deployment + * that has no `TERA_OFFICES_DIR` at all. Without this, every server-side check + * about the room they are in would have nothing to check against. */ -export function bundledMediaOffice(officeId: string): Office | null { +export function bundledOffice(officeId: string): Office | null { return BUNDLED_OFFICES.get(officeId) ?? null; } diff --git a/server/src/media/index.ts b/server/src/media/index.ts index 0b58ef4..a59de69 100644 --- a/server/src/media/index.ts +++ b/server/src/media/index.ts @@ -5,7 +5,7 @@ export { type MediaSignalService, type MediaSignalServiceOptions, } from "./service.ts"; -export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts"; +export { bundledOffice, officeHasMediaBinding } from "./bindings.ts"; export { createIceCredentialProvider, deriveIceCredentialRateKeys, diff --git a/server/src/routes/devices.ts b/server/src/routes/devices.ts new file mode 100644 index 0000000..9389bad --- /dev/null +++ b/server/src/routes/devices.ts @@ -0,0 +1,175 @@ +/** + * `GET /api/v1/offices/:id/devices` and + * `POST /api/v1/offices/:id/devices/command`. + * + * The read is a sibling of `/presence` and behaves exactly like it: viewer + * first, then the office, 401 for anonymous, 404 for anything this caller may + * not see, and never a shared cache. The reasoning is written out at length in + * `routes/presence.ts` and is not repeated here; what follows is what is + * *different* about devices, which is the write. + * + * ### Two routes, and that is the security property + * + * A command never rides in the read body and a read never applies one. This is + * the first write surface in this product that changes something another viewer + * can see, and folding it into the GET would mean a body that mutates a + * microphone is a body a shared cache is entitled to keep and replay. That is + * precisely the outcome the fail-closed `private, no-store` default in + * `cache.ts` exists to prevent, and the way to not have that problem is to not + * have that shape: reads are GETs and are never cached, writes are POSTs with a + * body of their own. + * + * Neither route ever calls `publicCache`. Stated rather than merely omitted, + * because the absence of a call is not self-evidently a decision — the same + * note `routes/presence.ts` leaves for the same reason. + * + * ### What a command is checked against + * + * The pack, resolved by this process. `devices/store.ts` builds a `Plan` and + * accepts a declaration only if the prop it is anchored to exists, is on the + * level it claims, and *is* the hardware the declaration names. So a command + * carries an id, and the id either names one of those or it does not — nothing + * in the request describes the device, which means nothing in the request can + * describe it wrongly. `officeHasMediaBinding()` makes the identical move for a + * screen share. + * + * ### Reading is the demo; writing is the account + * + * An anonymous visitor gets 401 here and gets a **locally simulated studio** + * from `src/devices/adapter.ts` instead — alive, labelled, and honest about + * what it is. That is the anon-first posture applied to a route that genuinely + * cannot be opened: the readings describe a room somebody is standing in, and + * a command changes it for everybody else in there. What an account buys is the + * shared room, not the demonstration. + */ + +import type { FastifyInstance } from "fastify"; +import { bundledOffice } from "../media/index.ts"; +import { isDeviceCommandOp, type DeviceCommand } from "../../../src/devices/types.ts"; +import type { Office } from "../../../src/interiors/types.ts"; +import type { + DeviceCommandBody, + DeviceCommandResultBody, + ErrorBody, +} from "../../../src/server/wire.ts"; +import type { Services } from "../services.ts"; + +const UNAUTHORIZED: ErrorBody = { + error: "unauthorized", + message: "Device state is for signed-in members.", +}; + +const NOT_FOUND: ErrorBody = { error: "not_found", message: "No such office." }; + +/** + * The largest command body this route will read, in bytes. + * + * A `DeviceCommand` is three short fields and the largest legitimate one is + * well under two hundred bytes. The cap is not about those — it is about the + * body nobody meant to send, and it is set here rather than trusted to a global + * because this is the only route on the box that accepts one. + */ +const MAX_COMMAND_BYTES = 2048; + +export function registerDevices(app: FastifyInstance, services: Services): void { + app.get<{ Params: { id: string } }>("/api/v1/offices/:id/devices", async (req, reply) => { + // Viewer first, before the id is looked at. The ordering is the property + // that stops this being an enumeration oracle — an anonymous caller gets + // the same 401 for a real office, a private one and an invented one — and + // `routes/presence.ts` explains why doing it the other way round is the bug. + const viewer = await services.auth.resolve(req); + if (!viewer.authenticated) { + return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED); + } + + const office = await officeFor(services, req.params.id); + if (office === null) return reply.code(404).send(NOT_FOUND); + + // No `publicCache`, ever. See the header. + return services.devices.current(office); + }); + + app.post<{ Params: { id: string }; Body: unknown }>( + "/api/v1/offices/:id/devices/command", + { bodyLimit: MAX_COMMAND_BYTES }, + async (req, reply) => { + const viewer = await services.auth.resolve(req); + if (!viewer.authenticated) { + return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED); + } + + const office = await officeFor(services, req.params.id); + if (office === null) return reply.code(404).send(NOT_FOUND); + + const command = readCommand(req.body); + if (command === null) { + const error: ErrorBody = { error: "bad_request", message: "Not a device command." }; + return reply.code(400).send(error); + } + + const outcome = services.devices.command(office, command); + if (!outcome.ok) { + // One status and one message for every way a command can be wrong. A + // caller told *which* of its guesses missed is a caller being helped to + // guess again, and the honest audience for the distinction is the log. + req.log.info({ officeId: office.id, reason: outcome.reason }, "device command refused"); + const error: ErrorBody = { error: "bad_request", message: "That command was refused." }; + return reply.code(400).send(error); + } + + const body: DeviceCommandResultBody = { + officeId: office.id, + device: outcome.device, + observedAt: outcome.device.observedAt, + }; + return body; + }, + ); +} + +/** + * The office this request is about, or `null` if there is not one this viewer + * may see. + * + * Served packs win over bundled ones, so an operator who has put their own + * `lumbridge-hq.json` in `TERA_OFFICES_DIR` gets theirs. A bundled pack is the + * fallback and not a leak: it is compiled into the browser bundle that made the + * request, so its authored device list is already in the caller's hands — see + * `bundledOffice`. Without it, every deployment that has not configured an + * offices directory would answer 404 for the very studios it is rendering. + * + * Visibility is honoured the way `routes/offices.ts` honours it: a private pack + * is 404 to anyone who is not signed in. By the time this is called the viewer + * already is, so the check that remains is the one for a document that does not + * exist at all. + */ +async function officeFor(services: Services, id: string): Promise { + const doc = await services.offices.get(id); + if (doc !== null) return doc.floor; + return bundledOffice(id); +} + +/** + * One command, read out of an untrusted body. + * + * Shape only. Whether the device exists, whether it accepts this op and whether + * the value is in range are all decided against the resolved pack in + * `devices/index.ts`, which is the only place that can decide them — this + * function's whole job is to make sure there is something of the right shape to + * hand it. + */ +function readCommand(raw: unknown): DeviceCommand | null { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null; + const body = raw as Partial; + const command: unknown = body.command; + if (command === null || typeof command !== "object" || Array.isArray(command)) return null; + const c = command as Record; + if (typeof c.deviceId !== "string" || c.deviceId === "") return null; + if (!isDeviceCommandOp(c.op)) return null; + // A value of the wrong type is dropped rather than passed through, which + // makes it a *missing* value — and `normalizeDeviceCommand` refuses a command + // whose op needs one and has none. The refusal is therefore made in the one + // place that knows which ops need what, rather than half here. + const value = typeof c.value === "boolean" || typeof c.value === "number" ? c.value : undefined; + return { deviceId: c.deviceId, op: c.op, ...(value === undefined ? {} : { value }) }; +} diff --git a/server/src/routes/flights.ts b/server/src/routes/flights.ts index 895104d..ea082d1 100644 --- a/server/src/routes/flights.ts +++ b/server/src/routes/flights.ts @@ -7,9 +7,17 @@ * traffic endpoint that will fetch any coordinate on demand is an amplifier * pointed at a volunteer-funded feed. * - * Publicly cacheable, because the whole design of the plan is that one response - * serves every viewer of a region for its whole TTL. Aircraft are not personal - * data and this body never varies by who asked — only by where. + * Publicly cacheable **when the licence allows it**, because the whole design of + * the plan is that one response serves every viewer of a region for its whole + * TTL. Aircraft are not personal data and this body never varies by who asked — + * only by where. + * + * The condition is not hypothetical caution. Handing a body to a shared cache is + * republication: the CDN serves it to people this box never spoke to, under + * whatever credit the body carries. So the answer comes from the body's own + * `redistributable` flag, which `flights/licence.ts` derived from the terms of + * the feed that answered — and a body that may not be shared simply keeps the + * fail-closed `private, no-store` every reply starts with (CONTRACT.md §5). * * ### `radiusNm` on the query is ignored, deliberately * @@ -30,6 +38,7 @@ import type { FastifyInstance } from "fastify"; import { publicCache } from "../cache.ts"; +import { mayRepublish } from "../flights/licence.ts"; import { resolveRegion, type RegionQuery } from "../regions.ts"; import type { ErrorBody } from "../../../src/server/wire.ts"; import type { Services } from "../services.ts"; @@ -43,7 +52,7 @@ export function registerFlights(app: FastifyInstance, services: Services): void } const body = await services.flights.current(resolved.region); - publicCache(req, reply, body.ttlSeconds); + if (mayRepublish(body)) publicCache(req, reply, body.ttlSeconds); return body; }); } diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 035f15c..c0a53d8 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -11,36 +11,27 @@ * config made is printed here, so "why is the weather always clear" has an * answer that does not require log access. * - * `regions` joins it for the same reason. Weather and flights now refuse a place - * this box does not serve, so a client that guesses `?city=` and a 400 it cannot - * explain is the failure this field prevents: ask health once, learn what may be - * asked for, and an operator diagnosing "why is there no SoCal weather" reads - * the answer instead of the env file. Publishing the allowlist gives nothing + * `regions` is a field of `HealthBody` for the same reason, and it is a field + * of `HealthBody` rather than of a local alias widening it next to this route: + * a body's shape stated anywhere but the wire contract is a shape the browser + * cannot read. Weather and flights now refuse a place this box does not serve, + * so a client that guesses `?city=` and earns a 400 it cannot explain is the + * failure this field prevents: ask health once, learn what may be asked for, + * and an operator diagnosing "why is there no SoCal weather" reads the answer + * instead of the env file. Publishing the allowlist gives nothing * away — knowing what is served is not the same as widening it, and the ids are * the names of the cities the map already draws. */ import type { FastifyInstance } from "fastify"; -import type { Region } from "../regions.ts"; import type { HealthBody } from "../../../src/server/wire.ts"; import type { Services } from "../services.ts"; -/** - * `HealthBody` plus the served regions. - * - * The field belongs in `src/server/wire.ts` beside the body it extends, and it - * is stated here only because that file is the browser side of this change and - * lands with it. Fold `regions: Region[]` into `HealthBody` and this alias goes - * away; nothing else has to move, because the shape is already exactly what the - * route serves. - */ -type HealthBodyWithRegions = HealthBody & { regions: Region[] }; - export function registerHealth(app: FastifyInstance, services: Services): void { const { config, startedAt } = services; app.get("/api/v1/health", async () => { - const body: HealthBodyWithRegions = { + const body: HealthBody = { ok: true, service: "tera-api", version: config.version, @@ -50,6 +41,7 @@ export function registerHealth(app: FastifyInstance, services: Services): void { flights: config.flights.source, satellites: config.satellites.source, markers: config.markers.source, + devices: config.devices.source, }, auth: { mode: config.auth.mode, diff --git a/server/src/routes/media.ts b/server/src/routes/media.ts index 4712a14..a5d77ef 100644 --- a/server/src/routes/media.ts +++ b/server/src/routes/media.ts @@ -12,7 +12,7 @@ import type { ScreenShareSignalRequest, ScreenShareStopRequest, } from "../../../src/media/signalingTypes.ts"; -import { bundledMediaOffice, officeHasMediaBinding, type MediaSignalFailureCode } from "../media/index.ts"; +import { bundledOffice, officeHasMediaBinding, type MediaSignalFailureCode } from "../media/index.ts"; import type { Services } from "../services.ts"; const BODY_LIMIT = 32 * 1024; @@ -34,7 +34,7 @@ function fail(reply: FastifyReply, failure: { code: MediaSignalFailureCode; mess async function bindingAllowed(binding: ScreenShareBinding, services: Services): Promise { const doc = await services.offices.get(binding.officeId); - const office = doc?.floor ?? bundledMediaOffice(binding.officeId); + const office = doc?.floor ?? bundledOffice(binding.officeId); return office !== null && officeHasMediaBinding(office, binding); } diff --git a/server/src/services.ts b/server/src/services.ts index bccc0ac..c223096 100644 --- a/server/src/services.ts +++ b/server/src/services.ts @@ -8,6 +8,7 @@ */ import { createAuth, type AuthService } from "./auth/index.ts"; +import { createDevicesService, type DevicesService } from "./devices/index.ts"; import { createFlightsService, type FlightsService } from "./flights/index.ts"; import { createMarkerStore, type MarkerStore } from "./markers/store.ts"; import { @@ -29,6 +30,7 @@ export interface Services { flights: FlightsService; satellites: SatellitesService; markers: MarkerStore; + devices: DevicesService; media: MediaSignalService; ice: IceCredentialProvider; offices: OfficeStore; @@ -51,6 +53,7 @@ export function createServices(config: Config, log: ServiceLog): Services { flights: createFlightsService(config, log), satellites: createSatellitesService(config, log), markers: createMarkerStore(config, log), + devices: createDevicesService(config, log), media: createMediaSignalService(), ice: createIceCredentialProvider(config.ice), offices: createOfficeStore(config.offices.dir), diff --git a/server/src/test/adsbLicence.test.ts b/server/src/test/adsbLicence.test.ts new file mode 100644 index 0000000..2be6e3e --- /dev/null +++ b/server/src/test/adsbLicence.test.ts @@ -0,0 +1,346 @@ +/** + * The licence gate on `TERA_ADSB_ENDPOINT`. + * + * This is the test file for the one exposure in this repo that is not a + * feature. Before the gate, `TERA_ADSB_ENDPOINT` accepted any string; whatever + * came back was served from `/api/v1/flights` with `Cache-Control: public` and + * an `attribution` array that read "Aircraft positions from the adsb.lol + * community feed" — hardcoded, next to the fetch, regardless of where the fetch + * went. A deployment was therefore one environment variable away from + * publishing somebody else's non-redistributable data, to a shared cache, under + * an open-terms credit belonging to a volunteer feed that had never seen it. + * + * Two properties are asserted here more than any other, because they are the + * two that were false: + * + * 1. **An allowlisted host is credited to itself.** Not to the default, not + * to the first entry in the table — to the host that actually answered. + * 2. **A host that is not on the allowlist is refused**, loudly, rather than + * fetched and mis-credited. Refused means: no request, no live body, one + * sentence in `degraded[]` naming the host, and the simulated plan on the + * wire instead. + * + * Everything else in this file is a door on the same room: schemes, credentials, + * query strings, loopback, and the `redistributable` bit that decides whether a + * CDN is allowed to keep a copy. + */ + +import assert from "node:assert/strict"; +import { after, beforeEach, describe, it } from "node:test"; +import { buildApp } from "../app.ts"; +import { loadConfig } from "../config.ts"; +import { createFlightsService } from "../flights/index.ts"; +import { + ADSB_ALLOWLIST, + adsbAttribution, + checkAdsbEndpoint, + FIRST_PARTY_RECEIVER, + isOpenAdsbUrl, + mayRepublish, + type AdsbFeedTerms, +} from "../flights/licence.ts"; +import type { FlightsBody } from "../../../src/server/wire.ts"; + +const realFetch = globalThis.fetch; +let calls: string[] = []; + +/** One aircraft, from whatever host was asked. Shape is the shared dump1090 one. */ +globalThis.fetch = (async (input: unknown) => { + const url = String(input); + calls.push(url); + if (!/\/v2\/point\//.test(url)) return new Response("nope", { status: 404 }); + return new Response( + JSON.stringify({ + now: 1_770_000_000_000, + ac: [{ hex: "a1b2c3", flight: "LMB1 ", lat: 37.5, lon: -122.3, alt_baro: 10_000, track: 90 }], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); +}) as unknown as typeof globalThis.fetch; + +after(() => { + globalThis.fetch = realFetch; +}); + +beforeEach(() => { + calls = []; +}); + +function appWith(env: Record) { + const config = loadConfig({ TERA_FLIGHTS_SOURCE: "adsb", ...env }); + config.logLevel = "silent"; + return { config, app: buildApp(config) }; +} + +/** + * The URL an operator would write for this entry. Loopback speaks plain http, + * and an IPv6 literal has to be bracketed before it is a URL at all — which is + * exactly the kind of detail a table-driven test finds and a hand-written one + * does not. + */ +function baseUrlFor(terms: AdsbFeedTerms): string { + const host = terms.host.includes(":") ? `[${terms.host}]` : terms.host; + return `${terms.loopback === true ? "http" : "https"}://${host}`; +} + +/** The `degraded[]` lines this configuration produced, minus the ones about radius etc. */ +function endpointLines(degraded: string[]): string[] { + return degraded.filter((line) => line.includes("TERA_ADSB_ENDPOINT")); +} + +describe("an allowlisted endpoint", () => { + it("credits the host that actually answered, not the default", async () => { + // The exact bug, in one assertion: point the box at airplanes.live and the + // credit must say airplanes.live. It used to say adsb.lol. + const { config, app } = appWith({ TERA_ADSB_ENDPOINT: "https://api.airplanes.live" }); + after(() => app.close()); + + const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json(); + assert.equal(config.flights.source, "adsb"); + assert.ok(body.mode === "live"); + const credits = (body.attribution ?? []).join(" "); + assert.match(credits, /airplanes\.live/); + assert.doesNotMatch(credits, /adsb\.lol/); + assert.equal(calls[0], "https://api.airplanes.live/v2/point/37.7749/-122.4194/40"); + }); + + it("credits every entry to itself, whichever one is configured", () => { + // Generalises the assertion above over the whole table, so an entry added + // later cannot inherit the previous one's credit by being copied. + for (const terms of ADSB_ALLOWLIST) { + const verdict = checkAdsbEndpoint(baseUrlFor(terms)); + assert.ok(verdict.ok, `${terms.host} should be allowed`); + const credits = verdict.attribution.join(" "); + assert.ok(credits.includes(terms.credit), `${terms.host} must be credited to itself`); + for (const other of ADSB_ALLOWLIST) { + if (other.credit === terms.credit) continue; + assert.ok( + !credits.includes(other.credit), + `${terms.host} must not be credited to ${other.host}`, + ); + } + } + }); + + it("states the licence as well as the source, which is what ODbL asks for", () => { + const verdict = checkAdsbEndpoint("https://api.adsb.lol"); + assert.ok(verdict.ok); + assert.equal(verdict.terms.licence, "ODbL-1.0"); + assert.match(verdict.attribution.join(" "), /Open Database License/); + assert.match(verdict.attribution.join(" "), /opendatacommons\.org/); + }); + + it("passes its own gate on the default configuration", () => { + // A default that fails its own validation is a gate nobody can use. + const { config } = appWith({}); + assert.equal(config.flights.source, "adsb"); + assert.equal(config.flights.endpoint, "https://api.adsb.lol"); + assert.deepEqual(endpointLines(config.degraded), []); + assert.equal(config.flights.redistributable, true); + }); + + it("normalises the endpoint so a trailing slash is not a second URL", () => { + const verdict = checkAdsbEndpoint("https://api.adsb.lol///"); + assert.ok(verdict.ok); + assert.equal(verdict.endpoint, "https://api.adsb.lol"); + }); +}); + +describe("an endpoint that is not on the allowlist", () => { + it("is refused rather than fetched and mis-credited", async () => { + const { config, app } = appWith({ TERA_ADSB_ENDPOINT: "https://flights.example.com" }); + after(() => app.close()); + + // Demoted, not fatal: CONTRACT.md §5.1. The map still works. + assert.equal(config.flights.source, "sim"); + + const lines = endpointLines(config.degraded); + assert.equal(lines.length, 1, "exactly one sentence about the endpoint"); + assert.match(lines[0] ?? "", /flights\.example\.com/, "the line names the host"); + + const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json(); + assert.equal(body.mode, "plan"); + assert.deepEqual(calls, [], "a refused endpoint is never fetched"); + // The whole point: nobody is credited for data nobody served. + assert.equal(JSON.stringify(body).includes("adsb.lol"), false); + }); + + it("does not leave the refused URL anywhere later code could use it", () => { + const { config } = appWith({ TERA_ADSB_ENDPOINT: "https://flights.example.com" }); + assert.equal(config.flights.endpoint, ""); + assert.deepEqual(config.flights.attribution, []); + assert.equal(config.flights.redistributable, false); + assert.equal(config.flights.licence, null); + }); + + it("refuses plain http off-box, where the path chooses what we publish", () => { + const verdict = checkAdsbEndpoint("http://api.adsb.lol"); + assert.ok(!verdict.ok); + assert.match(verdict.reason, /https/); + }); + + it("refuses a URL carrying credentials, because open feeds are keyless", () => { + const verdict = checkAdsbEndpoint("https://user:secret@api.adsb.lol"); + assert.ok(!verdict.ok); + assert.match(verdict.reason, /credential/); + }); + + it("refuses a query string rather than pasting it into the middle of a path", () => { + const verdict = checkAdsbEndpoint("https://api.adsb.lol?key=hunter2"); + assert.ok(!verdict.ok); + assert.match(verdict.reason, /query string/); + }); + + it("refuses something that is not a URL at all", () => { + assert.ok(!checkAdsbEndpoint("api.adsb.lol").ok); + assert.ok(!checkAdsbEndpoint("").ok); + }); + + it("refuses a host that merely contains an allowlisted name", () => { + // The attack the allowlist has to survive: an exact-match table, never a + // substring test. + for (const host of ["api.adsb.lol.example.com", "notapi.adsb.lol", "adsb.lol.evil.test"]) { + const verdict = checkAdsbEndpoint(`https://${host}`); + assert.ok(!verdict.ok, `${host} must not pass`); + } + }); + + it("points a near miss at the entry it was probably reaching for", () => { + // `adsb.lol` serves a website; `api.adsb.lol` serves the API. An operator + // who types the first one deserves better than a flat refusal. + const verdict = checkAdsbEndpoint("https://adsb.lol"); + assert.ok(!verdict.ok); + assert.match(verdict.reason, /did you mean https:\/\/api\.adsb\.lol/); + }); +}); + +describe("a receiver of one's own", () => { + it("takes loopback over plain http and credits the operator, not a feed", async () => { + const { config, app } = appWith({ TERA_ADSB_ENDPOINT: "http://127.0.0.1:8080" }); + after(() => app.close()); + + assert.equal(config.flights.source, "adsb"); + assert.equal(config.flights.licence, "first-party"); + const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json(); + assert.ok(body.mode === "live"); + assert.match((body.attribution ?? []).join(" "), /own ADS-B receiver/); + assert.equal(calls[0], "http://127.0.0.1:8080/v2/point/37.7749/-122.4194/40"); + }); + + it("says out loud that only the operator can vouch for a loopback claim", () => { + const { config } = appWith({ TERA_ADSB_ENDPOINT: "http://localhost:8080" }); + const lines = endpointLines(config.degraded); + assert.equal(lines.length, 1); + assert.match(lines[0] ?? "", /loopback/); + }); + + it("credits the dump1090 file source from the same table", () => { + // Same data, different door. If these two ever disagree about who to + // credit, one of them is lying. + const config = loadConfig({ + TERA_FLIGHTS_SOURCE: "dump1090", + TERA_DUMP1090_PATH: "/tmp/aircraft.json", + }); + assert.equal(config.flights.source, "dump1090"); + assert.deepEqual(config.flights.attribution, adsbAttribution(FIRST_PARTY_RECEIVER)); + assert.equal(config.flights.licence, "first-party"); + }); +}); + +describe("what a shared cache is allowed to keep", () => { + it("lets a CDN hold a redistributable live body", async () => { + const { app } = appWith({}); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/flights" }); + assert.match(res.headers["cache-control"] as string, /^public, max-age=\d+$/); + }); + + it("keeps a non-redistributable body private, which is the fail-closed default", () => { + // No entry in today's table is non-redistributable, and the flag exists so + // that adding one — a feed we may use but not re-serve — needs no change to + // the route. This is the branch that would then run. + const body: FlightsBody = { + mode: "live", + source: "adsb", + observedAt: 0, + aircraft: [], + ttlSeconds: 5, + redistributable: false, + }; + assert.equal(mayRepublish(body), false); + assert.equal(mayRepublish({ ...body, redistributable: true }), true); + }); + + it("always lets the simulated plan be shared, because it is ours", () => { + const plan: FlightsBody = { + mode: "plan", + source: "sim", + t0: 0, + seed: 1, + routes: [], + ttlSeconds: 300, + }; + assert.equal(mayRepublish(plan), true); + }); +}); + +describe("the second lock, on the door itself", () => { + it("will not fetch a non-allowlisted endpoint even if one reaches the service", async () => { + // `config.ts` cannot produce this. A future caller building the endpoint + // from somewhere else could, and this is what happens when it does: no + // request, and the plan on the wire. + const config = loadConfig({ TERA_FLIGHTS_SOURCE: "adsb" }); + config.flights.endpoint = "https://flights.example.com"; + const service = createFlightsService(config, { warn: () => {} }); + + const body = await service.current(config.regions[0]); + assert.equal(body.mode, "plan"); + assert.deepEqual(calls, []); + }); + + it("knows an open feed's URL from any other", () => { + assert.equal(isOpenAdsbUrl("https://api.adsb.lol/v2/point/1/2/40"), true); + assert.equal(isOpenAdsbUrl("https://flights.example.com/v2/point/1/2/40"), false); + assert.equal(isOpenAdsbUrl("not a url"), false); + }); +}); + +describe("the table itself", () => { + it("says where every claim it makes can be checked", () => { + for (const terms of ADSB_ALLOWLIST) { + assert.equal(terms.host, terms.host.toLowerCase(), `${terms.host} must be lowercase`); + assert.notEqual(terms.credit, "", `${terms.host} needs a credit line`); + assert.notEqual(terms.terms, "", `${terms.host} needs terms a human can read`); + assert.ok( + terms.licence === "ODbL-1.0" || terms.licence === "first-party", + `${terms.host} names a licence`, + ); + // Anything off-box must be https-only; only a loopback entry may relax it. + if (terms.loopback !== true) { + assert.ok(!checkAdsbEndpoint(`http://${terms.host}`).ok, `${terms.host} must be https`); + } + } + }); + + it("carries no feed whose terms forbid what this box does with it", () => { + // A `redistributable: false` entry is legitimate, but it must never be + // reachable while the route is still handing bodies to a shared cache. That + // is `mayRepublish`'s job, and this asserts the two stay wired together. + for (const terms of ADSB_ALLOWLIST) { + const verdict = checkAdsbEndpoint(baseUrlFor(terms)); + assert.ok(verdict.ok); + assert.equal( + mayRepublish({ + mode: "live", + source: "adsb", + observedAt: 0, + aircraft: [], + ttlSeconds: 5, + redistributable: verdict.terms.redistributable, + }), + terms.redistributable, + ); + } + }); +}); diff --git a/server/src/test/devices.test.ts b/server/src/test/devices.test.ts new file mode 100644 index 0000000..629675a --- /dev/null +++ b/server/src/test/devices.test.ts @@ -0,0 +1,572 @@ +/** + * The device routes: the refusal, the ordering, and the write. + * + * Three properties carry this file, and the third is the one that is new to + * this repo — everything before devices was read-only. + * + * **The refusal.** Device state describes a room somebody is standing in, so + * the read takes a session unconditionally, exactly as occupancy does. An + * anonymous caller gets an identical 401 for a real office, a private one and + * an invented one, because the viewer is resolved before the id is looked at: + * check the office first and the status codes tell them apart perfectly, which + * is CONTRACT.md §6's enumeration oracle wearing a different number. + * + * **The cache.** Neither route may ever be publicly cached, in any + * configuration, including the ones that answer 401 and 404. A shared cache + * holding a body that took a credential to obtain is one viewer's studio served + * to the next; a shared cache holding a *command* is a microphone that can be + * switched on by replaying a request nobody made. Both are asserted, on every + * status code, because `cache.ts` is fail-closed by default and the way that + * default gets lost is a route quietly opting out. + * + * **The write.** A command is checked against the pack this process resolved + * and not against anything in the request: an id that names no authored + * declaration, an op the declaration never declared, or a value of the wrong + * type is a 400 and — this is the assertion that matters — **no state + * changes**. The read after the refused write is what proves it. + */ + +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; +import { buildApp } from "../app.ts"; +import { loadConfig } from "../config.ts"; +import { forgetResolvedDevices, resolveDevices } from "../devices/store.ts"; +import type { Office } from "../../../src/interiors/types.ts"; +import type { DeviceState } from "../../../src/devices/types.ts"; +import type { DevicesBody } from "../../../src/server/wire.ts"; + +const SECRET = "not-a-real-secret-and-never-was"; + +const MIC = "tera:device.mic.desk"; +const SPEAKER = "tera:device.speaker.desk"; + +/** + * A studio with two real props and four declarations, three of which are wrong + * in a different way. + * + * Written out rather than borrowed from a shipped pack on purpose: the shipped + * packs are the `packs` workstream's to change, and a route test that fails + * because somebody moved a desk in Los Angeles is a test nobody trusts. What is + * asserted here is the *rule*, and the rule needs a pack that deliberately + * breaks it. + */ +function studio(id: string): Office { + return { + id, + name: "Test studio", + viewpoints: [], + levels: [ + { + id: "l1", + name: "Ground", + elevation: 0, + wallHeight: 3, + floorplan: { + rooms: [ + { + id: "room", + name: "Room", + floor: "tera:carpet.loop", + outline: [ + { x: 0, z: 0 }, + { x: 8, z: 0 }, + { x: 8, z: 6 }, + { x: 0, z: 6 }, + ], + }, + ], + walls: [], + seats: [{ id: "desk-01", position: { x: 2, z: 2 }, facing: 0 }], + props: [ + { id: "mic-prop", kind: MIC, position: { x: 2, z: 2 }, rotation: 0 }, + { id: "speaker-prop", kind: SPEAKER, position: { x: 3, z: 2 }, rotation: 0 }, + { id: "desk-prop", kind: "tera:desk.workstation", position: { x: 2, z: 2.4 }, rotation: 0 }, + ], + devices: [ + { + id: "mic-1", + kind: "mic", + label: "Desk mic", + assetId: MIC, + anchor: { levelId: "l1", propId: "mic-prop", seatId: "desk-01" }, + capabilities: ["power", "mute", "gain", "level"], + provenance: "simulated", + disclosure: "Simulated studio hardware. Demonstration data, never presence data.", + }, + { + id: "speaker-1", + kind: "speaker", + label: "Monitor", + assetId: SPEAKER, + anchor: { levelId: "l1", propId: "speaker-prop" }, + capabilities: ["power", "volume", "playback"], + provenance: "simulated", + disclosure: "Simulated studio hardware. Demonstration data, never presence data.", + }, + // Standing on a desk rather than being its own prop. This is the + // ordinary case and it is **accepted**: `DeviceAnchor.offset` exists + // for exactly those few centimetres, and a desk claims to be no + // instrument at all, so there is nothing for it to disagree with. + { + id: "desk-mounted-mic", + kind: "mic", + label: "Boom mic", + assetId: MIC, + anchor: { levelId: "l1", propId: "desk-prop", offset: { x: 0, y: 0.74, z: 0.1 } }, + capabilities: ["power", "level"], + provenance: "simulated", + disclosure: "Simulated studio hardware.", + }, + // Anchored to another instrument's hardware. A microphone bolted to + // a speaker is not a rendering nit — it is a command routed to the + // wrong instrument in a real room — and it is dropped. + { + id: "mic-on-a-speaker", + kind: "mic", + label: "Nothing", + assetId: MIC, + anchor: { levelId: "l1", propId: "speaker-prop" }, + capabilities: ["power", "level"], + provenance: "simulated", + disclosure: "Simulated.", + }, + // Anchored to a prop that does not exist. + { + id: "mic-nowhere", + kind: "mic", + label: "Nothing", + assetId: MIC, + anchor: { levelId: "l1", propId: "no-such-prop" }, + capabilities: ["power", "level"], + provenance: "simulated", + disclosure: "Simulated.", + }, + // Says it is simulated in its provenance and not in its words. The + // same check `resolveRobotOperations` makes, applied by + // `validateDeviceDeclaration` and enforced here. + { + id: "mic-undisclosed", + kind: "mic", + label: "Nothing", + assetId: MIC, + anchor: { levelId: "l1", propId: "mic-prop" }, + capabilities: ["power", "level"], + provenance: "simulated", + disclosure: "A microphone.", + }, + ], + }, + }, + ], + } as unknown as Office; +} + +let offices = ""; + +before(async () => { + offices = await mkdtemp(join(tmpdir(), "tera-devices-")); + await writeFile( + join(offices, "open.json"), + JSON.stringify({ id: "open", name: "Open", visibility: "public", floor: studio("open") }), + ); + await writeFile( + join(offices, "closed.json"), + JSON.stringify({ id: "closed", name: "Closed", visibility: "private", floor: studio("closed") }), + ); + await writeFile( + join(offices, "bare.json"), + JSON.stringify({ + id: "bare", + name: "Bare", + visibility: "public", + floor: { id: "bare", name: "Bare", levels: [], viewpoints: [] }, + }), + ); +}); + +const jwt = { TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: SECRET }; +const sim = { TERA_DEVICES_SOURCE: "sim" }; + +function appWith(env: Record) { + const config = loadConfig({ TERA_OFFICES_DIR: offices, ...env }); + config.logLevel = "silent"; + return buildApp(config); +} + +function hs256(claims: Record): string { + const encode = (value: unknown): string => + Buffer.from(JSON.stringify(value)).toString("base64url"); + const signed = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`; + return `${signed}.${createHmac("sha256", SECRET).update(signed).digest("base64url")}`; +} + +function bearer(): { authorization: string } { + return { + authorization: `Bearer ${hs256({ sub: "someone", exp: Math.floor(Date.now() / 1000) + 3600 })}`, + }; +} + +const url = (id: string) => `/api/v1/offices/${id}/devices`; + +describe("the refusal", () => { + it("refuses an anonymous read even for a public office", async () => { + const app = appWith({ ...jwt, ...sim }); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: url("open") }); + assert.equal(res.statusCode, 401); + assert.equal(res.headers["www-authenticate"], "Bearer"); + // The point: the office itself is public and readable by this same caller. + const doc = await app.inject({ method: "GET", url: "/api/v1/offices/open" }); + assert.equal(doc.statusCode, 200); + }); + + it("refuses an anonymous command", async () => { + const app = appWith({ ...jwt, ...sim }); + after(() => app.close()); + + const res = await app.inject({ + method: "POST", + url: `${url("open")}/command`, + payload: { command: { deviceId: "mic-1", op: "power", value: true } }, + }); + assert.equal(res.statusCode, 401); + }); + + it("tells a real, a private and an absent office apart for nobody", async () => { + const app = appWith({ ...jwt, ...sim }); + after(() => app.close()); + + const real = await app.inject({ method: "GET", url: url("open") }); + const priv = await app.inject({ method: "GET", url: url("closed") }); + const gone = await app.inject({ method: "GET", url: url("no-such-thing") }); + + assert.equal(real.statusCode, 401); + assert.equal(priv.statusCode, 401); + assert.equal(gone.statusCode, 401); + assert.deepEqual(real.json(), priv.json()); + assert.deepEqual(real.json(), gone.json()); + }); + + it("answers 404, never 403, for an office a signed-in caller cannot see", async () => { + // `offices/store.ts` refuses an id that is not filename-shaped, which is the + // same 404 by a different door — both are "there is no office here". + const app = appWith({ ...jwt, ...sim }); + after(() => app.close()); + + const gone = await app.inject({ method: "GET", url: url("no-such-thing"), headers: bearer() }); + assert.equal(gone.statusCode, 404); + assert.equal(gone.json().error, "not_found"); + + const traversal = await app.inject({ method: "GET", url: url(".."), headers: bearer() }); + assert.ok(traversal.statusCode === 404 || traversal.statusCode === 400); + }); +}); + +describe("the cache", () => { + it("never lets a shared cache keep device state, whatever the answer was", async () => { + const app = appWith({ ...jwt, ...sim }); + after(() => app.close()); + + const anonymous = await app.inject({ method: "GET", url: url("open") }); + const missing = await app.inject({ method: "GET", url: url("nope"), headers: bearer() }); + const ok = await app.inject({ method: "GET", url: url("open"), headers: bearer() }); + const commanded = await app.inject({ + method: "POST", + url: `${url("open")}/command`, + headers: bearer(), + payload: { command: { deviceId: "mic-1", op: "power", value: true } }, + }); + + for (const res of [anonymous, missing, ok, commanded]) { + assert.equal(res.headers["cache-control"], "private, no-store"); + } + }); +}); + +describe("what the pack is allowed to declare", () => { + it("serves only the declarations whose hardware is really there", () => { + forgetResolvedDevices(); + const { declarations, authored } = resolveDevices(studio("open")); + assert.deepEqual( + declarations.map((d) => d.id), + ["mic-1", "speaker-1", "desk-mounted-mic"], + ); + // Every one of the six was authored, which is what lets the service tell + // "no devices" from "no devices survived". + assert.equal(authored, 6); + }); + + it("accepts a microphone standing on a desk, and refuses one bolted to a speaker", () => { + forgetResolvedDevices(); + const { declarations } = resolveDevices(studio("open")); + // The rule is `Plan`'s and there is deliberately no second copy of it here: + // a stricter one on this side would drop a legitimate pack's devices from + // the API while the browser went on drawing them, which is the worst kind + // of disagreement — a populated panel whose every command is refused. + assert.ok(declarations.some((d) => d.id === "desk-mounted-mic")); + assert.equal(declarations.find((d) => d.id === "mic-on-a-speaker"), undefined); + assert.equal(declarations.find((d) => d.id === "mic-nowhere"), undefined); + }); + + it("drops a simulated device whose disclosure does not say so", () => { + forgetResolvedDevices(); + const { declarations } = resolveDevices(studio("open")); + assert.equal(declarations.find((d) => d.id === "mic-undisclosed"), undefined); + }); + + it("is a 200 and an empty list for an office that declares nothing", async () => { + const app = appWith({ ...jwt, ...sim }); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: url("bare"), headers: bearer() }); + assert.equal(res.statusCode, 200); + const body = res.json() as DevicesBody; + assert.deepEqual(body.devices, []); + // Not a 404: "no such office" and "no hardware in this office" are + // different facts with different fixes. + assert.equal(body.officeId, "bare"); + }); +}); + +describe("what a signed-in member reads", () => { + it("serves one state per surviving declaration, all marked synthetic", async () => { + const app = appWith({ ...jwt, ...sim }); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: url("open"), headers: bearer() }); + assert.equal(res.statusCode, 200); + const body = res.json() as DevicesBody; + assert.equal(body.source, "sim"); + assert.equal(body.synthetic, true); + assert.ok(body.ttlSeconds > 0); + assert.deepEqual(body.devices.map((d) => d.id), ["mic-1", "speaker-1", "desk-mounted-mic"]); + for (const device of body.devices) assert.equal(device.synthetic, true); + + // Readings follow the declaration, not the kind: this mic declared `gain` + // and `level`, this speaker declared neither. + const mic = body.devices[0] as DeviceState; + assert.equal(typeof mic.gainDb, "number"); + assert.equal(typeof mic.levelDb, "number"); + const speaker = body.devices[1] as DeviceState; + assert.equal(speaker.levelDb, undefined); + assert.equal(typeof speaker.volume, "number"); + }); + + it("reports no hardware at all when the box has no device source", async () => { + const app = appWith(jwt); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: url("open"), headers: bearer() }); + assert.equal(res.statusCode, 200); + const body = res.json() as DevicesBody; + assert.equal(body.source, "none"); + // Empty rather than invented. A box nobody has configured has no hardware, + // and that is the truth rather than a degraded picture of one. + assert.deepEqual(body.devices, []); + }); +}); + +describe("the command", () => { + it("applies one, and the next read shows it", async () => { + const app = appWith({ ...jwt, ...sim }); + after(() => app.close()); + + const before = await app.inject({ method: "GET", url: url("open"), headers: bearer() }); + assert.equal((before.json() as DevicesBody).devices[0]?.powered, false); + + const res = await app.inject({ + method: "POST", + url: `${url("open")}/command`, + headers: bearer(), + payload: { command: { deviceId: "mic-1", op: "power", value: true } }, + }); + assert.equal(res.statusCode, 200); + assert.equal(res.json().device.powered, true); + + const then = await app.inject({ method: "GET", url: url("open"), headers: bearer() }); + assert.equal((then.json() as DevicesBody).devices[0]?.powered, true); + }); + + it("clamps a value into the declared range rather than refusing it", async () => { + const app = appWith({ ...jwt, ...sim }); + after(() => app.close()); + + const res = await app.inject({ + method: "POST", + url: `${url("open")}/command`, + headers: bearer(), + payload: { command: { deviceId: "mic-1", op: "gain", value: 400 } }, + }); + assert.equal(res.statusCode, 200); + // 36 dB is the top of `DEVICE_RANGES.gain`. A slider reporting 400 is a + // caller who overshot, not an attack — and the body says what the hardware + // actually did, so the control can snap to it. + assert.equal(res.json().device.gainDb, 36); + }); + + it("refuses a device that does not name an authored declaration, and changes nothing", async () => { + const app = appWith({ ...jwt, ...sim }); + after(() => app.close()); + + // Deliberately the id of a declaration the pack *did* write and the server + // dropped, because its prop is a desk. It exists in the file and it must + // not be commandable. + for (const deviceId of ["mic-on-a-speaker", "mic-nowhere", "mic-undisclosed", "invented"]) { + const res = await app.inject({ + method: "POST", + url: `${url("open")}/command`, + headers: bearer(), + payload: { command: { deviceId, op: "power", value: true } }, + }); + assert.equal(res.statusCode, 400, deviceId); + assert.equal(res.json().error, "bad_request"); + } + + const read = await app.inject({ method: "GET", url: url("open"), headers: bearer() }); + const body = read.json() as DevicesBody; + assert.deepEqual(body.devices.map((d) => d.id), ["mic-1", "speaker-1", "desk-mounted-mic"]); + // Nothing was created and nothing was switched on by any of that. + assert.equal(body.devices.every((d) => d.powered === false), true); + }); + + it("refuses an op the declaration never declared", async () => { + const app = appWith({ ...jwt, ...sim }); + after(() => app.close()); + + // The speaker declares power, volume and playback. `gain` is a microphone's. + const res = await app.inject({ + method: "POST", + url: `${url("open")}/command`, + headers: bearer(), + payload: { command: { deviceId: "speaker-1", op: "gain", value: 4 } }, + }); + assert.equal(res.statusCode, 400); + }); + + it("refuses a body that is not a command", async () => { + const app = appWith({ ...jwt, ...sim }); + after(() => app.close()); + + const bodies: unknown[] = [ + {}, + { command: null }, + { command: [] }, + { command: { op: "power", value: true } }, + { command: { deviceId: "mic-1", op: "explode", value: true } }, + { command: { deviceId: "mic-1", op: "power" } }, + { command: { deviceId: "mic-1", op: "power", value: "yes" } }, + { command: { deviceId: "mic-1", op: "gain", value: true } }, + ]; + for (const payload of bodies) { + const res = await app.inject({ + method: "POST", + url: `${url("open")}/command`, + headers: bearer(), + payload: payload as never, + }); + assert.equal(res.statusCode, 400, JSON.stringify(payload)); + assert.equal(res.json().error, "bad_request"); + } + }); + + it("refuses a body that is not JSON at all, as the caller's mistake", async () => { + const app = appWith({ ...jwt, ...sim }); + after(() => app.close()); + + const res = await app.inject({ + method: "POST", + url: `${url("open")}/command`, + headers: { ...bearer(), "content-type": "text/plain" }, + payload: "turn the microphone on please", + }); + // 400 or 415 depending on which of Fastify's own checks fires first, and + // the assertion that matters is neither of those numbers: it is that this + // is a 4xx carrying the one error shape rather than a 500. This route was + // the first body on the box, and until it existed the error handler + // reported every framework refusal as a server fault. + assert.ok(res.statusCode >= 400 && res.statusCode < 500, `status ${res.statusCode}`); + assert.equal(res.json().error, "bad_request"); + }); + + it("refuses a command body larger than a command could be", async () => { + const app = appWith({ ...jwt, ...sim }); + after(() => app.close()); + + const res = await app.inject({ + method: "POST", + url: `${url("open")}/command`, + headers: bearer(), + payload: { command: { deviceId: "mic-1", op: "power", value: true }, padding: "x".repeat(4096) }, + }); + assert.equal(res.statusCode, 413); + assert.equal(res.json().error, "bad_request"); + }); + + it("refuses a command for an office that does not exist", async () => { + const app = appWith({ ...jwt, ...sim }); + after(() => app.close()); + + const res = await app.inject({ + method: "POST", + url: `${url("no-such-thing")}/command`, + headers: bearer(), + payload: { command: { deviceId: "mic-1", op: "power", value: true } }, + }); + assert.equal(res.statusCode, 404); + }); + + it("refuses every command when the box has no device source", async () => { + const app = appWith(jwt); + after(() => app.close()); + + const res = await app.inject({ + method: "POST", + url: `${url("open")}/command`, + headers: bearer(), + payload: { command: { deviceId: "mic-1", op: "power", value: true } }, + }); + assert.equal(res.statusCode, 400); + }); +}); + +describe("health says whether asking is worth it", () => { + it("names the device source and the served regions", async () => { + const app = appWith(sim); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/health" }); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.sources.devices, "sim"); + assert.ok(Array.isArray(body.regions)); + assert.ok(body.regions.length > 0); + }); + + it("says none on a box nobody configured, without calling it a demotion", async () => { + const app = appWith({}); + after(() => app.close()); + + const body = (await app.inject({ method: "GET", url: "/api/v1/health" })).json(); + assert.equal(body.sources.devices, "none"); + // The default is a choice, not a failure. A zero-config box's `degraded` + // list stays empty, which `check-zero-config-boot.mjs` also asserts. + assert.deepEqual(body.degraded, []); + }); + + it("demotes a source it cannot honour and says which", async () => { + const app = appWith({ TERA_DEVICES_SOURCE: "homeassistant" }); + after(() => app.close()); + + const body = (await app.inject({ method: "GET", url: "/api/v1/health" })).json(); + assert.equal(body.sources.devices, "none"); + assert.equal( + body.degraded.filter((line: string) => line.includes("TERA_DEVICES_SOURCE")).length, + 1, + ); + }); +}); diff --git a/src/access.ts b/src/access.ts index 924547c..3a63eb5 100644 --- a/src/access.ts +++ b/src/access.ts @@ -135,6 +135,16 @@ export interface Capabilities { export interface Feeds { weather: boolean; flights: boolean; + /** + * Device state for the studios. + * + * `false` on a zero-config box and on every clone, which is the default and + * is not a gap: `src/devices/adapter.ts` reads this and runs the bundled + * simulator in the tab instead, so the studio is alive either way. What the + * flag actually prevents is a poll against a box that will answer 404 to all + * of it, forever, on every open tab. + */ + devices: boolean; /** * A satellite catalogue. Off on almost every box, including this repo's own * default — see `loadSatellites` in the server's `config.ts` for why a clone @@ -160,6 +170,34 @@ export interface Access { * read a different field of the same body is a request nobody needs to make. */ feeds: Feeds | null; + /** + * Every demotion this deployment made, in the server's own words. + * + * `/api/v1/health` has carried this since the config learned to demote rather + * than to die (CONTRACT.md §5.1), and until now **nothing in `src/` read + * it**: it was built, served, logged and then dropped on the floor by the one + * consumer that could put it in front of a person. So an operator whose + * `TERA_WEATHER_CONTACT` was missing saw a permanently clear sky, with the + * sentence explaining exactly that sitting in a JSON body one fetch away. + * + * It rides along here because this module has already paid for the round + * trip — `/health` is the first thing boot asks for — and a second identical + * GET to read a different field of the same body is a request nobody needs to + * make. Empty on a fully-configured box, and empty when nothing answered: + * a deployment that does not exist has not demoted anything. + * + * The interface shows it to admin-tier viewers. It names environment + * variables and internal source ids, which is diagnostic detail rather than a + * secret — but it is also noise to everybody who cannot act on it. + * + * **Optional in the type and always present in practice**: every `Access` + * `resolveAccess` returns carries one, empty when there is nothing to report. + * The `?` is there only so that a hand-written pre-boot literal — the closed + * default `main.ts` holds before `resolveAccess()` settles — does not have to + * restate an empty array to keep compiling. Read it as `access.degraded ?? []` + * and the two cases are the same case. + */ + degraded?: string[]; } /** @@ -221,6 +259,7 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise< const health = await getJson<{ auth?: { mode?: unknown; entryUrl?: unknown }; sources?: unknown; + degraded?: unknown; }>(fetcher, "/health"); // Something is mounted at `/api/v1` and it is unwell. That is not the same @@ -233,6 +272,7 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise< // because we do not yet know which door this deployment uses. if (health.kind === "broken") return access("anon", null, null); + // Nothing answered. Clone-and-run: full experience, no door, no godmode. if (health.kind === "gone") return access("member", null, null); @@ -240,10 +280,11 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise< const mode = typeof body.auth?.mode === "string" ? body.auth.mode : "none"; const entryUrl = entryHref(body.auth?.entryUrl); const feeds = feedsFrom(body.sources); + const degraded = degradedFrom(body.degraded); // A box with auth switched off is a self-host that chose to stay open. Same // deal as no API at all, and for the same reason it is `member` and not `god`. - if (mode === "none") return access("member", null, null, feeds); + if (mode === "none") return access("member", null, null, feeds, degraded); const fetched = await getJson<{ authenticated?: unknown; @@ -286,8 +327,8 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise< */ const signInUrl = entryUrl ?? (passwordLogin ? "/login.html" : null); - if (!authenticated) return access("anon", null, signInUrl, feeds); - return access(admin ? "god" : "member", subject, signInUrl, feeds); + if (!authenticated) return access("anon", null, signInUrl, feeds, degraded); + return access(admin ? "god" : "member", subject, signInUrl, feeds, degraded); } function access( @@ -295,8 +336,9 @@ function access( subject: string | null, signInUrl: string | null, feeds: Feeds | null = null, + degraded: string[] = [], ): Access { - return { tier, subject, signInUrl, can: capabilitiesFor(tier), feeds }; + return { tier, subject, signInUrl, can: capabilitiesFor(tier), feeds, degraded }; } /** @@ -316,9 +358,32 @@ function feedsFrom(raw: unknown): Feeds { flights: wired("flights"), satellites: wired("satellites"), markers: wired("markers"), + devices: wired("devices"), }; } +/** + * `/health`'s `degraded` block, read as sentences. + * + * Filtered rather than cast, and for a reason beyond tidiness: these strings go + * into the interface, so a body carrying numbers, objects or `null` in that + * array would put `[object Object]` in front of an operator who is already + * looking at this list because something is wrong. Anything that is not a + * string is not a sentence and is dropped. + * + * Bounded as well. The list is one line per demotion and a fully-configured box + * has none, so a body with thousands in it is a server this client should not + * be rendering unboundedly — the same disposition `presence/store.ts` takes to + * a roster with fifty thousand rows in it. + */ +function degradedFrom(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw.filter((line): line is string => typeof line === "string").slice(0, MAX_DEGRADED); +} + +/** More demotions than any real configuration can produce. See `degradedFrom`. */ +const MAX_DEGRADED = 32; + /** * The three answers a request to `/api/v1` can carry, which is one more than * this used to have. diff --git a/src/adapters/README.md b/src/adapters/README.md index 0931404..0ea5bf3 100644 --- a/src/adapters/README.md +++ b/src/adapters/README.md @@ -16,6 +16,10 @@ else builds without any of them being a fork. See ARCHITECTURE.md §3.3. | `sample.ts` | fabricated demo markers and flight routes, so a fresh clone has something on it | | `http.ts` | the real adapter — the Tera API described in `src/server/wire.ts`, falling back to `sample.ts` | +Devices have a seam of their own next door, in `src/devices/adapter.ts`, for a +reason worth stating: it is the one feed with a **write** on it, and the write +does not degrade the way a read does. See "Devices" below. + ## The fallback is the product, not the safety net `http.ts` never throws and never leaves the map empty. No server, a 404, a @@ -62,6 +66,67 @@ markers have to be awaited first. `markers.palette` is the sample palette when the feed is the sample set and the palette you passed in `TeraApiOptions` when it is real — the sample keys are not your keys. +## Clicking an aircraft + +`TrafficSource.detail(id)` answers from what `poll()` last handed over — +callsign, ICAO 24-bit address, altitude in both units, heading as degrees and as +a compass point, position, distance from the board centre, whether anybody +observed it, and the credit lines owed for it. Synchronous, so a click opens a +card in the same frame rather than behind a round trip, and `null` for an +aircraft that has left the feed rather than a card showing where something was +two minutes ago. + +**It is available to an anonymous visitor**, and that is a decision rather than +an oversight. An ADS-B position is broadcast unencrypted by the aircraft to +anybody with a forty-dollar receiver; there is nothing here an account could +grant access to, and gating it would cost the first-visit moment this map exists +for while protecting nothing. + +Two fields are about the data rather than about the aeroplane. `observed` +travels with the aircraft because a card is read on its own, away from any +corner label, and a fabricated flight number in the same frame as a real one is +the confusion `live` exists to prevent. `attribution` travels with it because a +card is where the data is *displayed*, which is what an ODbL notice is about. +The address is `null` unless the feed gave a real one: the simulator's ids are +route names and the `~`-prefixed ids both community feeds emit for TIS-B and +MLAT targets are not ICAO addresses, and somebody pastes that field into a +registry lookup. + +## Devices: reading is the demo, writing is the account + +`src/devices/adapter.ts` is the seam, and it chooses between two strategies once, +at construction: + +- **the API**, when the deployment has a device source (`/health` says so) and + the viewer may read it; or +- **the simulator in this tab** — `src/devices/sim.ts`, seeded, deterministic, + `synthetic: true` and `live: false` — for everybody else. + +The second is not a degraded mode, it is the anonymous visitor's studio and the +zero-config clone's studio, and it is the same argument the marker fallback +makes one section up: a studio is never dark. `GET /offices/:id/devices` refuses +an anonymous caller, and it should — a reading describes a room somebody is +standing in — so the refusal produces a working, honestly-labelled instrument +rather than a dead one. + +**Commands do not get the same treatment**, and this is the one place in this +directory where a failure is reported rather than papered over. On the API +strategy a refused command is `null` and the interface says so; it is never +quietly applied to a local copy, because a control that appears to work and +changes nothing anybody else can see is worse than one that says no. On the +simulated strategy a command is applied locally and openly, because nothing +there claims to be a real room. + +A command travels as a **POST on a route of its own** and never in a read body. +A shared cache that kept a GET which turned a microphone on could replay it, and +that is precisely what the fail-closed `Cache-Control` default in CONTRACT.md §5 +exists to prevent. + +`src/devices/adapter.ts` owns no timer: the simulated strategy is advanced by +`tick(dt)` from whatever render loop already exists, and the API strategy's +polling lives in `watchDevices` here in `http.ts`, where every other watch's +timer already is. + ## No real company data ships in this repo Two separate constraints want the same thing here, which is the reason this @@ -110,8 +175,8 @@ Two things to keep if you write one: body's own TTL in the background. A `poll()` that awaits a slow fetch puts a frame behind a round trip. -**FlightRadar24 is not an option.** Their terms forbid scraping and forbid -redistributing the data, so an Apache-2.0 repo containing an FR24 client would be +**FlightRadar24 is not an option.** Their terms do not permit scraping and do +not permit redistributing the data, so an Apache-2.0 repo containing an FR24 client would be publishing instructions for breaking a ToS and shipping data it has no right to relicense. `src/engine/flights.ts` ships a simulator and points at the open community ADS-B feeds instead; anything commercial belongs in an adapter in a diff --git a/src/adapters/http.ts b/src/adapters/http.ts index 654b771..40cf8db 100644 --- a/src/adapters/http.ts +++ b/src/adapters/http.ts @@ -32,12 +32,16 @@ */ import type { WeatherObservation } from "../engine/atmosphere.ts"; +import type { DeviceCommand, DeviceState } from "../devices/types.ts"; +import { deviceStateSignature } from "../devices/types.ts"; import { + aircraftDetail, distanceNm, inRegion, sampleRoute, SimulatedFlights, syntheticRoutes, + type AircraftDetail, type Place, type SimRoute, type SkyRegion, @@ -46,6 +50,10 @@ import type { SatelliteElements } from "../engine/satellites.ts"; import type { Aircraft, FlightSource, Marker, MarkerPalette } from "../engine/types.ts"; import { seededRandom } from "../engine/world.ts"; import type { + DeviceCommandBody, + DeviceCommandResultBody, + DevicesBody, + DevicesSourceId, FlightsBody, FlightsPlanBody, HealthBody, @@ -54,6 +62,7 @@ import type { PresenceBody, SatellitesBody, WeatherBody, + WireAircraft, } from "../server/wire.ts"; import { SAMPLE_MARKERS, SAMPLE_PALETTE } from "./sample.ts"; @@ -197,6 +206,54 @@ export interface PresenceWatch { stop(): void; } +/** + * One office's hardware, plus whether anybody actually asked a server about it. + * + * `live` here means *this deployment answered*, and it is not the same claim as + * `synthetic`, which means *nobody observed these readings*. All four + * combinations are real deployments and the interface has to be able to say + * each of them: + * + * | live | synthetic | what it is | + * | --- | --- | --- | + * | false | true | no server, or an anonymous viewer: the local simulator | + * | true | true | a server running `TERA_DEVICES_SOURCE=sim` | + * | true | false | a real bridge to real hardware | + * | false | false | impossible, and nothing constructs it | + * + * The second row is the reference deployment and the first is every clone of + * this repo, which is why both of them have to look alive and both have to say + * so. `DeviceDeclaration.disclosure` is the sentence a viewer reads; these two + * booleans are what the interface branches on. + */ +export interface DeviceFeed extends Feed { + /** Which source the server said it was using, or `"none"` when nobody answered. */ + source: DevicesSourceId; + /** True when nobody observed these readings. True for everything this build ships. */ + synthetic: boolean; + /** Epoch milliseconds of the snapshot, or `null` when there is no snapshot. */ + observedAt: number | null; + attribution: string[]; +} + +/** + * A running poll of one office's hardware. + * + * `PresenceWatch` with a `current()` on it. Devices need the accessor and + * occupancy does not, because a device panel is opened *after* the room has + * been drawn — the panel wants the last reading immediately rather than waiting + * up to a TTL for the next publish, and re-fetching to answer that would spend + * a request on a body this object is already holding. + */ +export interface DeviceWatch { + /** The latest feed. Empty and not live until an answer lands. */ + current(): DeviceFeed; + /** Ask now rather than at the next tick. Ignored while a request is in flight. */ + refresh(): void; + /** Stop polling, abort anything in flight, and drop any late answer. */ + stop(): void; +} + export interface TeraClient { /** What the deployment turned out to be, or `null` if there is no server. */ health(): Promise; @@ -262,6 +319,40 @@ export interface TeraClient { * to be able to draw. */ watchPresence(officeId: string, onBody: (body: PresenceBody | null) => void): PresenceWatch; + /** + * What the hardware in one office is doing. + * + * Always an authenticated call, exactly like `presence` and for a related + * reason: `routes/devices.ts` refuses an anonymous one whatever else the + * deployment is set to. A refusal — no API, no session, an office this + * viewer may not see — is an empty feed with `live: false`, and the caller's + * answer to that is the *locally simulated* studio in `src/devices/sim.ts`, + * not an empty panel. See `src/devices/adapter.ts`, which is where that + * decision is made once instead of at every call site. + */ + devices(officeId: string, options?: { signal?: AbortSignal }): Promise; + /** + * The same question, asked repeatedly, until the caller stops it. + * + * Modelled on `watchPresence` rather than on `watchWeather`, because it + * describes the room somebody is standing in rather than the sky: it stops + * dead while the tab is hidden, wakes the moment it comes back, and publishes + * only when a reading a viewer could see has changed. + */ + watchDevices(officeId: string, onFeed: (feed: DeviceFeed) => void): DeviceWatch; + /** + * Ask one device to do something. Resolves to the state it ended up in, or + * `null` for any refusal. + * + * **A POST, on a route of its own, never folded into the read.** A command + * riding in a GET response could be replayed by any shared cache that kept a + * copy, and a cache that turned a microphone on by replaying a read is + * exactly what the fail-closed `Cache-Control` default in CONTRACT.md §5 + * exists to prevent. It is also the first write surface in this product that + * changes something another viewer can see, which is the other half of why + * it is separate: reading is the demo, writing is the account. + */ + commandDevice(officeId: string, command: DeviceCommand): Promise; } /** @@ -326,6 +417,40 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient { } }; + /** + * One POST, and `null` for every way it can go wrong. + * + * The same deliberate coarseness as `get` — a 401, a 400, a timeout and a + * static host answering with its own HTML are one outcome to the caller — with + * one difference that matters: **nothing here retries**. A GET that failed can + * be repeated because asking twice costs a request; a command that failed may + * have been applied before the connection died, and repeating it is the + * difference between "turn the microphone on" and "turn the microphone on + * twice". Idempotence is not a property this can assume on the caller's + * behalf, so a refusal is reported and the panel asks the person. + */ + const post = async (path: string, body: unknown): Promise => { + if (!doFetch) return null; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await doFetch(`${base}${path}`, { + method: "POST", + signal: controller.signal, + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) return null; + const type = res.headers.get("content-type") ?? ""; + if (!type.includes("json")) return null; + return (await res.json()) as T; + } catch { + return null; + } finally { + clearTimeout(timer); + } + }; + return { health: () => get("/health"), @@ -410,6 +535,30 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient { watchPresence(officeId, onBody) { return watchPresence(get, officeId, onBody); }, + + async devices(officeId, opts: { signal?: AbortSignal } = {}): Promise { + const body = await get(devicesPath(officeId), { + ...(opts.signal ? { signal: opts.signal } : {}), + }); + return deviceFeed(body); + }, + + watchDevices(officeId, onFeed) { + return watchDevices(get, officeId, onFeed); + }, + + async commandDevice(officeId, command): Promise { + const request: DeviceCommandBody = { command }; + const body = await post( + `${devicesPath(officeId)}/command`, + request, + ); + // Checked rather than trusted, like every other body this file adopts: a + // 200 with the wrong shape in it is what a server one version behind this + // one sends, and `null` is already the caller's "it did not happen". + if (!body || body.device === null || typeof body.device !== "object") return null; + return body.device; + }, }; } @@ -755,6 +904,16 @@ function queryString(query: Record | undefined): string * it. */ export interface TrafficSource extends FlightSource { + /** + * Narrowed from `FlightSource.poll()`, which may return a promise. + * + * Not a convenience: it is the file's central promise made into a type. This + * is called from the render loop, so it answers from whatever is in hand and + * refreshes on the body's own TTL in the background — a `poll()` that awaited + * a slow fetch would put a frame's aircraft update behind a round trip. An + * adapter that cannot promise that is a `FlightSource` and not one of these. + */ + poll(): Aircraft[]; /** True while the aircraft `poll()` returns are observed positions for this region. */ live(): boolean; /** @@ -767,6 +926,26 @@ export interface TrafficSource extends FlightSource { * exists. `describeLiveness` says what is live; this says who to thank for it. */ attribution(): string[]; + /** + * Everything known about one aircraft that is currently being drawn, or + * `null` for an id that is not. + * + * The click target of the whole city board, and it is on this interface for + * the same reason `live()` and `attribution()` are: the engine draws darts at + * coordinates and the *deployment* knows what those coordinates are. It is + * synchronous and answers from what `poll()` last handed over, so a click can + * open a card in the same frame — going back to the network for a record this + * object already holds would put a panel behind a round trip, and would ask a + * volunteer-funded feed for a row it just sent. + * + * **Available to an anonymous visitor**, and that is a decision rather than + * an oversight. An ADS-B position is broadcast unencrypted by the aircraft to + * anybody with a receiver; there is nothing here an account could grant + * access to, and gating it would cost the first-visit moment this map exists + * for while protecting nothing. `access.ts` makes the same argument about the + * sky at greater length. + */ + detail(id: string): AircraftDetail | null; /** Stop fetching and abort anything in flight. Idempotent. */ dispose(): void; } @@ -809,30 +988,91 @@ class HttpFlights implements TrafficSource { */ readonly interval = 1; + private readonly get: Get; + private readonly region: SkyRegion; private readonly fallback: SimulatedFlights; private mode: "fallback" | "plan" | "live" = "fallback"; private plan: FlightsPlanBody | null = null; private planPhase: number[] = []; private aircraft: Aircraft[] = []; private credits: string[] = []; + /** + * Everything the live body said about each aircraft, keyed by id. + * + * Only the live path fills this, because only the live path is told anything + * an `Aircraft` cannot carry — `WireAircraft.icao24` in particular, which is + * the transponder address and is the field a detail card is actually about. + * Cleared and rebuilt with every adopted body, so it can never outlive the + * positions it describes. + */ + private readonly wire = new Map(); + private latest: Aircraft[] = []; private nextFetchAt = 0; private inFlight: AbortController | null = null; private stopped = false; - constructor( - private readonly get: Get, - private readonly region: SkyRegion, - fallbackRoutes: SimRoute[], - ) { + /** + * Fields assigned in the constructor body rather than declared as parameter + * properties. + * + * **This is not a style preference and it must stay this way.** A parameter + * property is the one piece of TypeScript syntax that emits code — it is a + * hidden assignment, not a type annotation — so Node's type stripping refuses + * the *whole module* with `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. Vite never + * cared, so for the entire life of this file `node --test` could not import + * `adapters/http.ts` at all: every degrade path, every rung of the back-off + * ladder and the whole region filter below had zero coverage, in the module + * whose job is to be correct when everything else has failed. + * + * `engine/flights.ts` says the same thing over `AdsbFlights` and adds the + * consequence: the module with the worst bug this project has shipped was, by + * construction, the one module that could not be tested. Two modules had that + * property; this was the second. + */ + constructor(get: Get, region: SkyRegion, fallbackRoutes: SimRoute[]) { + this.get = get; + this.region = region; this.fallback = new SimulatedFlights(fallbackRoutes); } poll(): Aircraft[] { this.refreshIfStale(); const { mode, plan, planPhase } = this; - if (mode === "plan" && plan) return evaluatePlan(plan, planPhase, Date.now()); - if (this.mode === "live") return this.aircraft; - return this.fallback.poll(); + // Kept, so that `detail()` can answer a click about the aircraft that were + // actually drawn rather than about a fresh evaluation a few milliseconds + // later — the plan path is a function of `Date.now()`, so re-evaluating it + // for a lookup would return a position slightly ahead of the dart the + // viewer aimed at. + this.latest = + mode === "plan" && plan + ? evaluatePlan(plan, planPhase, Date.now()) + : mode === "live" + ? this.aircraft + : this.fallback.poll(); + return this.latest; + } + + /** + * One aircraft, as a card. + * + * Looked up in what was last polled, which is also what is on screen. An id + * that has left the feed answers `null` rather than the last known position: + * a card showing where something was two minutes ago, with no way to say so, + * is the same class of quiet staleness `WeatherFeed.observedAt` exists to + * prevent — and the caller's honest response is to close the card. + */ + detail(id: string): AircraftDetail | null { + const found = this.latest.find((a) => a.id === id); + if (found === undefined) return null; + const wire = this.wire.get(id); + return aircraftDetail(found, { + // Only a live body carries an address, and only a live body was observed. + // The plan's aircraft are this repo's own arithmetic and say so. + ...(wire?.icao24 === undefined ? {} : { icao24: wire.icao24 }), + observed: this.mode === "live", + attribution: this.attribution(), + from: this.region.center, + }); } /** @@ -924,6 +1164,12 @@ class HttpFlights implements TrafficSource { private adopt(body: FlightsBody): number { const ttl = Number.isFinite(body.ttlSeconds) ? Math.max(1, body.ttlSeconds) : RETRY_SECONDS; this.credits = []; + // Both of these describe the body that is about to be adopted, so both are + // dropped before it is looked at rather than on each of the four ways this + // method can decide not to adopt it. A transponder address left over from a + // previous body would otherwise be attached, by id collision, to whatever + // the next one draws. + this.wire.clear(); if (body.mode === "plan") { if (!Array.isArray(body.routes)) { @@ -981,6 +1227,11 @@ class HttpFlights implements TrafficSource { return ELSEWHERE_SECONDS; } this.aircraft = here; + // Only the live path has anything to record: a plan carries routes, not + // transponders. Cleared at the top of this method, so a record that has + // left the feed leaves this map with it rather than surviving to answer a + // click about an aircraft nobody is drawing. + for (const a of here) this.wire.set(a.id, a); this.plan = null; this.mode = "live"; // Only the live body carries credits — `wire.ts` puts `attribution` on @@ -1232,3 +1483,208 @@ function watchPresence( }, }; } + +// ---- Devices -------------------------------------------------------------- + +/** + * How often to ask what the hardware is doing, when the server does not say. + * + * Five seconds, and the number comes from the data rather than from the + * network. Occupancy moves when somebody stands up and is polled every thirty; + * a level meter moves continuously and a mute button moves the instant it is + * pressed, and a panel that took half a minute to notice somebody else had + * muted the room would read as broken. The server's `ttlSeconds` overrides this + * whenever it answers — `TERA_DEVICES_TTL` is the operator's dial and this is + * only what to do before they have had their say. + * + * It is still a poll rather than a stream. The realtime service exists and this + * deliberately does not use it: a device panel is open for a minute at a time + * on a handful of tabs, and a socket per viewer for a body this size is a + * standing cost for an occasional need. If the panels are ever open all day, + * that is the moment to revisit it. + */ +const DEVICES_INTERVAL_MS = 5_000; + +/** Bounds on whatever the server asks for, so one bad TTL cannot become a flood. */ +const DEVICES_MIN_INTERVAL_MS = 2_000; +const DEVICES_MAX_INTERVAL_MS = 60_000; + +/** + * The ceiling on the back-off ladder, five minutes — `watchPresence`'s number, + * not the weather watch's hour, and for the same reason it gives: somebody is + * standing in the room this describes. + */ +const DEVICES_MAX_BACKOFF_MS = 5 * 60_000; + +/** Nobody answered. Empty, not live, and not claiming to have observed anything. */ +function noDevices(): DeviceFeed { + return { value: [], live: false, source: "none", synthetic: true, observedAt: null, attribution: [] }; +} + +/** + * One devices body, judged. + * + * Two outcomes, and the check is on the array rather than on its length. A body + * with `devices: []` is a real answer — an office nobody has declared any + * hardware in, which is most offices — and it is `live`, because the deployment + * answered and said so. A body with no array in it at all is not an answer, and + * it is the shape a server one version behind this one sends. The same + * distinction `adsb.ts` draws between an empty circle of sky and an unreadable + * envelope, for the same reason: coercing the second into the first serves + * fiction under a live badge. + */ +function deviceFeed(body: DevicesBody | null): DeviceFeed { + if (!body || !Array.isArray(body.devices)) return noDevices(); + return { + value: body.devices, + live: true, + source: body.source ?? "none", + // Absent means synthetic. A body that did not say whether anybody observed + // its readings is not a body that may be presented as observation. + synthetic: body.synthetic !== false, + observedAt: typeof body.observedAt === "number" ? body.observedAt : null, + attribution: Array.isArray(body.attribution) ? body.attribution : [], + }; +} + +/** + * Poll one office's hardware until told to stop. + * + * `watchPresence` with a TTL from the body and a signature that covers readings + * instead of seats. The three properties it inherits are the ones that matter + * and they are argued for at length there: + * + * - **stops dead while the tab is hidden**, and asks immediately on the way + * back, because a backgrounded panel polling a meter nobody can see is + * waste at both ends; + * - **publishes only on change**, by comparing `deviceStateSignature` — every + * answer is a fresh array, so identity says nothing, and a still studio + * would otherwise rebuild its panel every few seconds forever; + * - **reports a refusal** rather than freezing on the last good reading, so + * "the room went quiet" and "I have stopped hearing about the room" stay + * distinguishable. + * + * The signature deliberately ignores `observedAt`, which moves on every poll of + * an unchanged studio and would defeat the whole comparison. + */ +function watchDevices( + get: Get, + officeId: string, + onFeed: (feed: DeviceFeed) => void, +): DeviceWatch { + let feed = noDevices(); + let signature: string | null = null; + let published = false; + let failures = 0; + let stopped = false; + let timer: ReturnType | null = null; + let inFlight: AbortController | null = null; + + const path = devicesPath(officeId); + + function schedule(delayMs: number) { + if (stopped) return; + if (timer !== null) clearTimeout(timer); + timer = setTimeout(() => void tick(), delayMs); + } + + function publish(next: DeviceFeed) { + const nextSignature = `${next.live ? "1" : "0"}${deviceStateSignature(next.value)}`; + // The first answer always goes through, even when it matches the empty + // signature the caller may have assumed. "I asked and there is nothing" and + // "I have not asked yet" are different states and only one of them should + // leave a panel saying so on purpose. + feed = next; + if (published && nextSignature === signature) return; + signature = nextSignature; + published = true; + onFeed(next); + } + + async function tick(): Promise { + timer = null; + if (stopped) return; + if (typeof document !== "undefined" && document.visibilityState === "hidden") return; + // Nothing reaches this with a request already out, but a watch that stalled + // would stay stalled until the page reloaded, and that is too quiet a + // failure to leave to the reasoning being right. + if (inFlight) { + schedule(DEVICES_INTERVAL_MS); + return; + } + + inFlight = new AbortController(); + const body = await get(path, { signal: inFlight.signal }); + inFlight = null; + // Stopped while this was in the air: the office was left or the panel was + // closed. Whatever came back describes a room nobody is looking at, and it + // is also why a cancelled request must not count as a failure below. + if (stopped) return; + + publish(deviceFeed(body)); + + if (body === null) { + failures += 1; + schedule(Math.min(DEVICES_INTERVAL_MS * 2 ** (failures - 1), DEVICES_MAX_BACKOFF_MS)); + return; + } + failures = 0; + schedule(intervalFor(body)); + } + + function onVisibility() { + if (stopped) return; + if (document.visibilityState === "visible") { + // Immediately rather than at the next tick: somebody has just come back + // to this tab and the panel in front of them is the stale thing. + failures = 0; + schedule(0); + } else if (timer !== null) { + clearTimeout(timer); + timer = null; + } + } + + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", onVisibility); + } + + void tick(); + + return { + current: () => feed, + refresh() { + if (stopped || inFlight) return; + schedule(0); + }, + stop() { + stopped = true; + if (timer !== null) clearTimeout(timer); + timer = null; + inFlight?.abort(); + inFlight = null; + if (typeof document !== "undefined") { + document.removeEventListener("visibilitychange", onVisibility); + } + }, + }; +} + +/** + * The cadence the server asked for, clamped. + * + * Checked rather than trusted, exactly as `HttpFlights.adopt` learned to be: an + * absent `ttlSeconds` makes `Math.max(1, undefined)` a `NaN`, `NaN` clears every + * comparison, and the poll interval quietly becomes the frame rate. The floor + * is the load-bearing half — a `TERA_DEVICES_TTL=0` that reads like "as fresh + * as possible" would otherwise be one request per tick per open tab. + */ +function intervalFor(body: DevicesBody): number { + const asked = Number.isFinite(body.ttlSeconds) ? body.ttlSeconds * 1000 : DEVICES_INTERVAL_MS; + return Math.min(DEVICES_MAX_INTERVAL_MS, Math.max(DEVICES_MIN_INTERVAL_MS, asked)); +} + +/** One place the route is spelled, so the read and the command cannot drift apart. */ +function devicesPath(officeId: string): string { + return `/offices/${encodeURIComponent(officeId)}/devices`; +} diff --git a/src/arena/base.ts b/src/arena/base.ts index 5645665..625e0a3 100644 --- a/src/arena/base.ts +++ b/src/arena/base.ts @@ -1,4 +1,4 @@ -import { arenaChecksum } from "./checksum.ts"; +import { arenaChecksum, quantizeForChecksum } from "./checksum.ts"; import type { ArenaScenarioRegistry } from "./scenarios.ts"; import { ARENA_API_VERSION, @@ -108,6 +108,9 @@ export abstract class BaseArenaEnvironment< envId: this.manifest.id, envVersion: this.manifest.version, envHash: this.envHash(), + // Inside the checksummed core, so a snapshot cannot be re-pinned to a + // different simulator without invalidating itself. See `ArenaSnapshot`. + sourceHashes: this.sourceHashes, seed: scenario.seed, scenarioId: scenario.id, scenarioSplit: scenario.split, @@ -127,7 +130,8 @@ export abstract class BaseArenaEnvironment< if (arenaChecksum(core) !== checksum) throw new Error("arena snapshot checksum mismatch"); if ( snapshot.apiVersion !== ARENA_API_VERSION || snapshot.envId !== this.manifest.id || - snapshot.envVersion !== this.manifest.version || snapshot.envHash !== this.envHash() + snapshot.envVersion !== this.manifest.version || snapshot.envHash !== this.envHash() || + arenaChecksum(snapshot.sourceHashes) !== arenaChecksum(this.sourceHashes) ) throw new Error("arena snapshot is incompatible with this environment"); if ( !Number.isSafeInteger(snapshot.step) || snapshot.step < 0 || @@ -148,7 +152,27 @@ export abstract class BaseArenaEnvironment< this.truncated = snapshot.truncated; this.terminalReason = snapshot.terminalReason; this.frames = []; - const observation = this.restoreSimulation(structuredClone(snapshot.simulation)); + let observation: O; + try { + observation = this.restoreSimulation(structuredClone(snapshot.simulation)); + } catch (error) { + // A concrete environment can reject the simulation payload after the + // episode bookkeeping above has already been written — and several do, + // because validating a controller's snapshot means handing it to the + // controller. Leaving the object in that state was the worst of the three + // options: the step index, the cumulative reward and the terminal flags + // would say one thing and the simulators another, and the *next* call + // would succeed and quietly produce a mixture of two episodes. + // + // Rolling back is not available either: `restoreSimulation` has usually + // rebuilt the simulators from the scenario before it validates, so the + // state it threw from is not the state it started in. So the environment + // is marked un-reset, which makes every subsequent call fail loudly until + // somebody calls `reset` or `restore` again — the same disposition + // `devices/sim.ts` takes when it refuses a snapshot outright. + this.scenario = null; + throw error; + } this.initialStateChecksum = arenaChecksum(this.statePayload()); return { observation, info: this.info() }; } @@ -200,7 +224,13 @@ export abstract class BaseArenaEnvironment< const actual = this.step(expected.action); observation = actual.observation; if ( - actual.info.stateChecksum !== expected.stateChecksum || actual.reward !== expected.reward || + actual.info.stateChecksum !== expected.stateChecksum || + // Quantised rather than `!==`, for the reason `checksum.ts` sets out at + // length: a reward is a float, a verifier runs on hardware the producer + // never saw, and `Math.pow` is not required to be correctly rounded. An + // exact comparison here would have re-introduced on one line precisely + // the cross-runtime failure the checksum was hardened against. + quantizeForChecksum(actual.reward) !== quantizeForChecksum(expected.reward) || actual.terminated !== expected.terminated || actual.truncated !== expected.truncated || arenaChecksum(actual.rewardComponents) !== arenaChecksum(expected.rewardComponents) ) throw new Error(`arena trace diverged at step ${expected.index}`); @@ -208,7 +238,7 @@ export abstract class BaseArenaEnvironment< const replayed = this.trace(); if ( replayed.finalStateChecksum !== trace.finalStateChecksum || - replayed.cumulativeReward !== trace.cumulativeReward + quantizeForChecksum(replayed.cumulativeReward) !== quantizeForChecksum(trace.cumulativeReward) ) throw new Error("arena trace final state mismatch"); return { observation, diff --git a/src/arena/californiaFlight.ts b/src/arena/californiaFlight.ts index 49ac17f..ae4cadb 100644 --- a/src/arena/californiaFlight.ts +++ b/src/arena/californiaFlight.ts @@ -8,6 +8,7 @@ import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts"; import { ArenaScenarioRegistry } from "./scenarios.ts"; import { ARENA_API_VERSION, + type ArenaFieldSpec, type ArenaManifest, type ArenaScenario, } from "./types.ts"; @@ -119,7 +120,13 @@ export const CALIFORNIA_FLIGHT_SCENARIOS = new ArenaScenarioRegistry Number.MAX_SAFE_INTEGER) return value; + // `+ 0` collapses a rounded -0 back to 0 so the sign of a vanishing quantity + // cannot change a checksum. `canonical` below does the same for a literal -0. + return Math.round(scaled) / scale + 0; +} + +/** `quantizeToPlaces(value, ARENA_CHECKSUM_DECIMALS)`. */ +export function quantizeForChecksum(value: number): number { + return quantizeToPlaces(value, ARENA_CHECKSUM_DECIMALS); +} + +/** + * Whether `value` is a plain data object rather than an instance of something. + * + * Prototype identity rather than a `constructor` name or a `Symbol.toStringTag` + * sniff, because the question is not "what does this call itself" but "are its + * own enumerable keys the whole of it". `Object.create(null)` passes for the + * same reason a `{}` literal does. + */ +function isPlainObject(value: object): boolean { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** + * The name to put in the refusal. + * + * Worth the eight lines: "arena checksums do not accept Map" sends the reader + * to the field they added, and "arena checksums do not accept object" sends + * them here to find out what this function meant. + */ +function describe(value: object): string { + const named = (value as { constructor?: { name?: unknown } }).constructor; + const name = typeof named?.name === "string" && named.name.length > 0 ? named.name : null; + return name ?? "a non-plain object"; +} function canonical(value: unknown, stack: Set): string { if (value === null) return "null"; if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); if (typeof value === "number") { if (!Number.isFinite(value)) throw new TypeError("arena checksums require finite numbers"); - return Object.is(value, -0) ? "0" : JSON.stringify(value); + if (Object.is(value, -0)) return "0"; + return JSON.stringify(quantizeForChecksum(value)); } if (Array.isArray(value)) { if (stack.has(value)) throw new TypeError("arena checksums do not accept cycles"); @@ -15,6 +136,12 @@ function canonical(value: unknown, stack: Set): string { return result; } if (typeof value === "object") { + // The refusal that gives this module its point. A `Map`, a `Set`, a `Date`, + // a typed array and a class instance all reach here, all have no own + // enumerable keys worth reading, and all used to hash as `{}`. + if (!isPlainObject(value)) { + throw new TypeError(`arena checksums do not accept ${describe(value)}`); + } if (stack.has(value)) throw new TypeError("arena checksums do not accept cycles"); stack.add(value); const record = value as Record; diff --git a/src/arena/crowNav.ts b/src/arena/crowNav.ts index 1d31271..83c059a 100644 --- a/src/arena/crowNav.ts +++ b/src/arena/crowNav.ts @@ -7,6 +7,7 @@ import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts"; import { ArenaScenarioRegistry } from "./scenarios.ts"; import { ARENA_API_VERSION, + type ArenaFieldSpec, type ArenaManifest, type ArenaScenario, } from "./types.ts"; @@ -103,7 +104,13 @@ export const CROW_NAV_SCENARIOS = new ArenaScenarioRegistry ArenaEnvironment, any>> +> = Object.freeze({ + "drive-101-v1": () => new Drive101Environment(), + "office-nav-v1": () => new OfficeNavEnvironment(), + "office-jobs-v1": () => new OfficeJobsEnvironment(), + "crow-nav-v1": () => new CrowNavEnvironment(), + "california-flight-v1": () => new CaliforniaFlightEnvironment(), + "studio-ops-v1": () => new StudioOpsEnvironment(), +}); diff --git a/src/arena/officeJobs.ts b/src/arena/officeJobs.ts index 0f09986..949f7c8 100644 --- a/src/arena/officeJobs.ts +++ b/src/arena/officeJobs.ts @@ -17,7 +17,7 @@ import { MATEO_COURT_ROBOT_OPERATIONS } from "../offices/operations/mateo-court. import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts"; import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts"; import { ArenaScenarioRegistry } from "./scenarios.ts"; -import { ARENA_API_VERSION, type ArenaManifest, type ArenaScenario } from "./types.ts"; +import { ARENA_API_VERSION, type ArenaFieldSpec, type ArenaManifest, type ArenaScenario } from "./types.ts"; export interface OfficeJobsAction { x: number; @@ -112,7 +112,13 @@ export const OFFICE_JOBS_SCENARIOS = new ArenaScenarioRegistry = Object.freeze({ export function officeJobsScriptedBaseline(observation: OfficeJobsObservation): OfficeJobsAction { if (observation.canInteract) return { x: 0, z: 0, interact: true }; - const length = Math.hypot(observation.deltaX, observation.deltaZ); + const length = Math.sqrt( + observation.deltaX * observation.deltaX + observation.deltaZ * observation.deltaZ, + ); if (length <= 1e-9) return { x: 0, z: 0, interact: false }; const magnitude = Math.min(1, length / (1.05 * OFFICE_JOBS_MANIFEST.fixedStepSeconds)); return { @@ -206,7 +272,7 @@ export class OfficeJobsEnvironment extends BaseArenaEnvironment< protected normalizeAction(action: OfficeJobsAction): OfficeJobsAction { const x = Number.isFinite(action?.x) ? action.x : 0; const z = Number.isFinite(action?.z) ? action.z : 0; - const length = Math.hypot(x, z); + const length = Math.sqrt(x * x + z * z); return { x: length > 1 ? x / length : x, z: length > 1 ? z / length : z, @@ -222,8 +288,10 @@ export class OfficeJobsEnvironment extends BaseArenaEnvironment< const canInteract = this.canInteract(before); if (action.interact && !canInteract) this.wrongInteractions += 1; const after = this.requireActivity().step({ [this.robotId]: action }).robots[0]!; - const moved = Math.hypot(after.position.x - beforePosition.x, after.position.z - beforePosition.z); - const demand = Math.hypot(action.x, action.z); + const movedX = after.position.x - beforePosition.x; + const movedZ = after.position.z - beforePosition.z; + const moved = Math.sqrt(movedX * movedX + movedZ * movedZ); + const demand = Math.sqrt(action.x * action.x + action.z * action.z); const blocked = demand > 0.2 && moved < 0.012; this.blockedStreak = blocked ? this.blockedStreak + 1 : 0; const distance = this.distanceToNext(after); @@ -325,7 +393,10 @@ export class OfficeJobsEnvironment extends BaseArenaEnvironment< private distanceToNext(robot: RobotActivityState): number { const station = this.nextStation(robot); - return station ? Math.hypot(station.position.x - robot.position.x, station.position.z - robot.position.z) : 0; + if (!station) return 0; + const dx = station.position.x - robot.position.x; + const dz = station.position.z - robot.position.z; + return Math.sqrt(dx * dx + dz * dz); } private canInteract(robot: RobotActivityState): boolean { diff --git a/src/arena/officeNav.ts b/src/arena/officeNav.ts index 608bf01..11df7a9 100644 --- a/src/arena/officeNav.ts +++ b/src/arena/officeNav.ts @@ -10,6 +10,7 @@ import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts"; import { ArenaScenarioRegistry } from "./scenarios.ts"; import { ARENA_API_VERSION, + type ArenaFieldSpec, type ArenaManifest, type ArenaScenario, } from "./types.ts"; @@ -98,7 +99,13 @@ export const OFFICE_NAV_SCENARIOS = new ArenaScenarioRegistry = Object.freeze({ x: 0, z: 0 }); export function officeNavScriptedBaseline(observation: OfficeNavObservation): OfficeNavAction { - const length = Math.hypot(observation.deltaX, observation.deltaZ); + const length = Math.sqrt( + observation.deltaX * observation.deltaX + observation.deltaZ * observation.deltaZ, + ); if (length === 0) return { x: 0, z: 0 }; return { x: observation.deltaX / length, z: observation.deltaZ / length }; } @@ -167,7 +195,7 @@ export class OfficeNavEnvironment extends BaseArenaEnvironment< protected normalizeAction(action: OfficeNavAction): OfficeNavAction { const x = Number.isFinite(action?.x) ? action.x : 0; const z = Number.isFinite(action?.z) ? action.z : 0; - const length = Math.hypot(x, z); + const length = Math.sqrt(x * x + z * z); return length > 1 ? { x: x / length, z: z / length } : { x, z }; } @@ -177,11 +205,10 @@ export class OfficeNavEnvironment extends BaseArenaEnvironment< const walker = this.requireWalker(); const before = walker.state(); const after = walker.tick(FIXED_STEP, action); - const moved = Math.hypot( - after.position.x - before.position.x, - after.position.z - before.position.z, - ); - const demand = Math.hypot(action.x, action.z); + const movedX = after.position.x - before.position.x; + const movedZ = after.position.z - before.position.z; + const moved = Math.sqrt(movedX * movedX + movedZ * movedZ); + const demand = Math.sqrt(action.x * action.x + action.z * action.z); const blocked = demand > 0.2 && moved < demand * 2 * FIXED_STEP * 0.2; this.blockedStreak = blocked ? this.blockedStreak + 1 : 0; const distance = this.goalDistance(); @@ -240,7 +267,9 @@ export class OfficeNavEnvironment extends BaseArenaEnvironment< private goalDistance(): number { const state = this.requireWalker().state(); const goal = this.currentScenario().parameters; - return Math.hypot(goal.goalX - state.position.x, goal.goalZ - state.position.z); + const dx = goal.goalX - state.position.x; + const dz = goal.goalZ - state.position.z; + return Math.sqrt(dx * dx + dz * dz); } private requireWalker(): WalkerController { diff --git a/src/arena/rollout.ts b/src/arena/rollout.ts new file mode 100644 index 0000000..7d1ce64 --- /dev/null +++ b/src/arena/rollout.ts @@ -0,0 +1,97 @@ +/** + * One episode, run to its end. + * + * Nine lines of loop that every consumer of this package had written for + * itself: the test file, both baseline proofs, and every example in ARENA.md. + * Four copies of a loop is four places for the step budget to be off by one, + * for `truncated` to be checked and `terminated` not to be, or for the returned + * total to be the environment's cumulative reward in one copy and the caller's + * own running sum in another. They agree today; nothing made them. + * + * It is deliberately thin. A trainer does not want a framework here — it wants + * the loop it was going to write, exported once so that a regression in it is a + * regression in one place. Anything richer (vectorised environments, batching, + * an action buffer) belongs in the trainer, which knows things this package + * cannot: how many workers there are, what device the policy is on, and whether + * an episode is worth finishing. + */ + +import type { + ArenaEnvironment, + ArenaScenarioRequest, + ArenaStepResult, +} from "./types.ts"; + +/** + * What a policy is, from this package's point of view. + * + * The step index is passed as well as the observation because a *scripted* + * baseline sometimes needs it — the studio-ops targeted policies switch + * behaviour at a known step to reach a particular terminal — and because a + * policy that wants only the observation can ignore a second argument for free. + * It is the step that is about to be taken, counting from zero. + */ +export type ArenaPolicy = (observation: O, step: number) => A; + +export interface RolloutOptions { + /** Defaults to 0, which is a legal seed and a boring one. */ + seed?: number; + /** An exact public scenario id, or a split request. Defaults to `train`. */ + scenario?: string | ArenaScenarioRequest; + /** + * Stop after this many steps even if the episode has not ended. + * + * Clamped to the manifest's own cap, because a caller cannot extend an + * episode past the point where the environment sets `truncated` — asking for + * more steps than the manifest allows is a mistake worth silently correcting + * rather than a request worth honouring. Below the cap it is a genuine early + * cut, and `final.terminated`/`final.truncated` will both be false, which is + * how a caller tells "I stopped it" from "it ended". + */ + maxSteps?: number; +} + +export interface RolloutResult> { + /** The sum of every step's total reward. */ + total: number; + /** How many steps were actually taken. */ + steps: number; + /** The last transition, carrying the terminal reason and final observation. */ + final: ArenaStepResult; +} + +export function rollout, S>( + environment: ArenaEnvironment, + policy: ArenaPolicy, + options: RolloutOptions = {}, +): RolloutResult { + const budget = Math.min( + environment.manifest.maxSteps, + options.maxSteps ?? environment.manifest.maxSteps, + ); + if (!Number.isSafeInteger(budget) || budget < 1) { + throw new RangeError("arena rollout needs a budget of at least one step"); + } + + let observation = environment.reset( + options.seed ?? 0, + options.scenario ?? { split: "train" }, + ).observation; + + let total = 0; + let steps = 0; + let final: ArenaStepResult | undefined; + for (let step = 0; step < budget; step += 1) { + const result = environment.step(policy(observation, step)); + observation = result.observation; + total += result.reward; + steps += 1; + final = result; + if (result.terminated || result.truncated) break; + } + // Unreachable while `budget >= 1`, and asserted rather than assumed because + // the alternative is a non-null assertion that would go stale the moment the + // loop above grows a `continue`. + if (!final) throw new Error("arena rollout took no steps"); + return { total, steps, final }; +} diff --git a/src/arena/scenarios.ts b/src/arena/scenarios.ts index 07f9be7..7ba3dd0 100644 --- a/src/arena/scenarios.ts +++ b/src/arena/scenarios.ts @@ -42,6 +42,46 @@ export class ArenaScenarioRegistry

{ } } + /** + * The scenario a bare `{ split }` request resolves to, chosen by hashing the + * seed against each candidate's **id** rather than by indexing definition + * order. + * + * This used to be `candidates[seed % candidates.length]`, which is one + * character shorter and quietly binds every seed anybody has ever run to the + * position of a scenario in an array literal. Insert a scenario in the middle + * of a split — the most ordinary edit there is, and one a reviewer reads as + * purely additive — and every seed past it silently resolves to a different + * task. Nothing fails; the numbers in a results table just stop meaning what + * they meant, and there is no artefact anywhere that records the change. + * + * Highest-random-weight selection instead: each candidate scores + * `deriveArenaSeed(seed, "::")` and the highest wins. Adding a + * scenario moves only the seeds the new id actually wins, which is the + * irreducible minimum for a set that grew; removing one moves only the seeds + * it held; reordering the literal moves nothing at all, because position is + * not an input any more. + * + * The tie-break is the id rather than the array index, so that even a 32-bit + * score collision between two ids in one split resolves the same way whatever + * order they were declared in. Ties are the one case where "first wins" would + * have smuggled definition order back in through the door it was shown out + * of. + */ + private select(seed: number, split: ArenaSplit): ArenaScenarioDefinition

| undefined { + let best: ArenaScenarioDefinition

| undefined; + let bestScore = -1; + for (const candidate of this.definitions) { + if (candidate.split !== split) continue; + const score = deriveArenaSeed(seed, `${this.envId}:${split}:${candidate.id}`); + if (score > bestScore || (score === bestScore && best !== undefined && candidate.id < best.id)) { + best = candidate; + bestScore = score; + } + } + return best; + } + ids(split: ArenaSplit): readonly string[] { return this.definitions .filter((definition) => definition.split === split) @@ -57,8 +97,7 @@ export class ArenaScenarioRegistry

{ definition = this.byId.get(request.id); if (definition?.split !== request.split) definition = undefined; } else { - const candidates = this.definitions.filter((entry) => entry.split === request.split); - definition = candidates[seed % candidates.length]; + definition = this.select(seed, request.split); } if (!definition) throw new RangeError(`unknown ${this.envId} scenario`); const random = new ArenaRandom(deriveArenaSeed(seed, `${this.envId}:${definition.id}`)); diff --git a/src/arena/sourceHashes.ts b/src/arena/sourceHashes.ts index fd3d5fb..0afaa2c 100644 --- a/src/arena/sourceHashes.ts +++ b/src/arena/sourceHashes.ts @@ -6,23 +6,27 @@ import type { ArenaSourceHashes } from "./types.ts"; */ export const ARENA_SOURCE_HASHES: Readonly> = Object.freeze({ "drive-101-v1": { - environment: "sha256:a8f3bb5c04985215a2b98b75dd2ce82a13b794b9f4933536c5821a49cf1f8125", - simulator: "sha256:be24cacb480279e88c64e72803d2a8a94db1b084312b64da55050d2ca95c90af", + environment: "sha256:394b6892c13966ab999ba88d912b3fb86083a057aabcba2ae82a53ae29726b40", + simulator: "sha256:eae9a74358885691a3ea7c1ef58bb58bcfff171de7fa8fb1611d6900dcaead4a", }, "office-nav-v1": { - environment: "sha256:870b1924a7ac641d2523d52a537f6b1538803f9cc732bbfc422522a65b4eb09a", - simulator: "sha256:34bb82d70b471cb3734d196f6ad1c685b9154083cf7e319c6089127f7a87eaf3", + environment: "sha256:ba6de0b6f940c20c1a2af3a8b2c332034a1270c2110b9bc403253705a645cd8c", + simulator: "sha256:d88513546aecacd950cb0c29d6e11874f51c0756f2d62f744e5b76670a6d69b6", }, "office-jobs-v1": { - environment: "sha256:76cb8ec23745b05d1f9d408b948618d8fcbd93cf3ac46e018163702bffdc3e0f", - simulator: "sha256:aca85f5c0ec1430b8c5fab38233af757f93eefda1341c4c9d0fb5dfad24b0476", + environment: "sha256:446a8216784bee2875c3a14bfebf17d40e3ee2b2f55afbdd5f085e8aecdd570c", + simulator: "sha256:841391e89e83a98feeb9e503f15e2ca8e5840f00fd312166a2e16fdaba58e965", }, "crow-nav-v1": { - environment: "sha256:141b1850ac01b1922a7db88ea6c30b15521302d720099de5f91c07472633f797", + environment: "sha256:448f061a182826decfdb0b6c54cdc62f39df2b2351b7dcaf33c0c77a0607dc51", simulator: "sha256:f03ba9ff320d5231a728a7f9733492ed41fa8608509e476c6d84ae567b1f24d8", }, "california-flight-v1": { - environment: "sha256:8833a25d5da376278ae56da1056ae7fa20ed243b8de0b3ef1c10d5317afbcb3f", + environment: "sha256:50dc69d1f7541b1e98bea1ec8a0a69dc13a7b3cf75f1785fd2a2e309cb4d56ea", simulator: "sha256:997aa7c63779ae77af44d584758f55b6679836305115aef5e13f207232ec4d6f", }, + "studio-ops-v1": { + environment: "sha256:18375ef89e9f890356428a7b62fc6b48b94fc019dd8ca1ac05eabede6d70e03f", + simulator: "sha256:6884955c43d7bf6488769b1c38a94a87591042322ee01ffb5e0ef013976dff1f", + }, }); diff --git a/src/arena/spaces.ts b/src/arena/spaces.ts new file mode 100644 index 0000000..d0b6e4d --- /dev/null +++ b/src/arena/spaces.ts @@ -0,0 +1,246 @@ +/** + * The observation and action *shapes*, and the encoder every trainer was + * writing by hand. + * + * `ArenaManifest` has always published `observationFields` — a list of names. + * A name list answers "what does this environment see"; it does not answer the + * only question a trainer asks before it can allocate anything, which is "how + * many numbers is that, and what are they". So every consumer wrote its own + * encoder against the environment's TypeScript source: a fork of the + * observation contract, kept outside this repository, silently invalidated by + * any field this repository adds. + * + * `ArenaFieldSpec` (in `types.ts`) closes that. Each field declares how it + * becomes numbers, `flattenObservation` performs exactly that encoding, and the + * width is a function of the manifest rather than of a comment. + * + * ### Raw values, declared bounds + * + * A float lands in the vector as its **clamped raw value**, not as a 0..1 + * normalisation of it. Two reasons. Normalisation is a modelling decision — a + * layer norm, a running mean, a tanh squash are all defensible and the trainer + * is the only party that knows which it is using — and baking one in here would + * make it impossible to recover the metre or the decibel on the other side. + * And `low`/`high` are *declared* bounds rather than guarantees: they are what + * an author believes the field spans, published so a consumer can normalise, + * and quietly wrong bounds should show up as a saturated input rather than as + * a silently rescaled one. + * + * ### Why this module imports the manifests directly + * + * `index.ts` re-exports this file, so importing `ARENA_MANIFESTS` from there + * would close a cycle. Six named imports instead, which is the same list + * `index.ts` itself keeps, and `src/test/arena/spaces.test.ts` asserts the two + * catalogues hold the same ids so the duplication cannot drift. + */ + +import { deriveArenaSeed } from "./random.ts"; +import { CALIFORNIA_FLIGHT_MANIFEST } from "./californiaFlight.ts"; +import { CROW_NAV_MANIFEST } from "./crowNav.ts"; +import { DRIVE_101_MANIFEST } from "./drive101.ts"; +import { OFFICE_JOBS_MANIFEST } from "./officeJobs.ts"; +import { OFFICE_NAV_MANIFEST } from "./officeNav.ts"; +import { STUDIO_OPS_MANIFEST } from "./studioOps.ts"; +import type { ArenaFieldSpec, ArenaManifest } from "./types.ts"; + +const BY_ID: ReadonlyMap = new Map( + [ + DRIVE_101_MANIFEST, + OFFICE_NAV_MANIFEST, + OFFICE_JOBS_MANIFEST, + CROW_NAV_MANIFEST, + CALIFORNIA_FLIGHT_MANIFEST, + STUDIO_OPS_MANIFEST, + ].map((manifest) => [manifest.id, manifest]), +); + +/** Every environment id this package can encode for, in catalogue order. */ +export function arenaEnvironmentIds(): readonly string[] { + return [...BY_ID.keys()]; +} + +/** The manifest for an id. Throws rather than returning `undefined`: a caller + * that mistyped an env id wants to know now, not to receive a zero-width + * vector fifty thousand steps into a run. */ +export function arenaManifest(envId: string): ArenaManifest { + const manifest = BY_ID.get(envId); + if (!manifest) throw new RangeError(`unknown arena environment: ${envId}`); + return manifest; +} + +/** + * How many slots one field occupies. + * + * This is also where a malformed spec is caught, because it is on the path of + * every width and every encode: a float without finite ordered bounds and an + * enum without a usable vocabulary are authoring mistakes that would otherwise + * surface as a vector of the wrong length or a slot that is always zero. + */ +export function arenaFieldWidth(spec: ArenaFieldSpec): number { + switch (spec.kind) { + case "float": { + const { low, high } = spec; + if ( + typeof low !== "number" || typeof high !== "number" || + !Number.isFinite(low) || !Number.isFinite(high) || !(low < high) + ) throw new RangeError(`arena float field ${spec.name} needs finite low < high`); + return 1; + } + case "bool": + case "id": + return 1; + case "enum": { + const values = spec.values; + if (!Array.isArray(values) || values.length === 0) { + throw new RangeError(`arena enum field ${spec.name} needs a non-empty vocabulary`); + } + if (new Set(values).size !== values.length) { + throw new RangeError(`arena enum field ${spec.name} has a duplicate value`); + } + return values.length; + } + } +} + +/** Total slots in a flattened observation for this environment. */ +export function observationWidth(envId: string): number { + return spaceWidth(arenaManifest(envId).observationSpace); +} + +/** Total slots in a flattened action for this environment. */ +export function actionWidth(envId: string): number { + return spaceWidth(arenaManifest(envId).actionSpace); +} + +function spaceWidth(space: readonly ArenaFieldSpec[]): number { + let width = 0; + for (const spec of space) width += arenaFieldWidth(spec); + return width; +} + +/** + * One observation as a fixed-width vector of finite numbers. + * + * Fixed-width is the whole contract and it holds unconditionally: a field the + * observation does not carry, carries as `undefined`, or carries as the wrong + * type still occupies its slots. That is not leniency, it is the property a + * trainer depends on — a vector whose length changes at step 400 because a + * station id went null is a crash a long way from its cause, and a batch that + * silently absorbs a `NaN` is worse than one that never sees the value at all. + * + * What an unusable value encodes as is chosen to be *inert* rather than + * plausible: a float falls back to its declared `low`, a bool to 0, an enum to + * all-zeros, an id to 0. None of those can be mistaken for a measurement, and + * `observationFields` plus the environment's own tests are what keep the case + * from arising. + */ +export function flattenObservation(envId: string, observation: unknown): number[] { + const space = arenaManifest(envId).observationSpace; + const record = isRecord(observation) ? observation : {}; + const vector: number[] = []; + for (const spec of space) encodeField(spec, record[spec.name], vector); + return vector; +} + +/** The same encoding applied to an action struct, for a trainer logging what + * it did as well as what it saw. */ +export function flattenAction(envId: string, action: unknown): number[] { + const space = arenaManifest(envId).actionSpace; + const record = isRecord(action) ? action : {}; + const vector: number[] = []; + for (const spec of space) encodeField(spec, record[spec.name], vector); + return vector; +} + +/** + * The inverse of `flattenAction`: a policy's output vector as the struct + * `step()` takes. + * + * A harness that can read an observation vector but cannot emit an action is + * half a harness, and the half it is missing is the one every consumer has to + * hand-write against a field order it read out of a source file. + * + * Only `float` and `bool` are accepted, because that is what the six action + * spaces contain and because the alternatives are worse than a refusal: an + * `enum` action would need an argmax whose tie-break this module would be + * inventing, and an `id` action cannot be inverted from a hash at all. If an + * environment ever wants a categorical action, this function should learn about + * it deliberately rather than by falling through to a default. + * + * Floats are clamped into their declared bounds and a bool is `value >= 0.5`, + * so a raw network output needs no squashing before it gets here. + */ +export function structureAction( + envId: string, + vector: readonly number[], +): Record { + const space = arenaManifest(envId).actionSpace; + const width = spaceWidth(space); + if (vector.length !== width) { + throw new RangeError(`arena ${envId} action needs exactly ${width} values`); + } + const action: Record = {}; + let cursor = 0; + for (const spec of space) { + const value = vector[cursor] ?? 0; + cursor += arenaFieldWidth(spec); + if (spec.kind === "float") { + action[spec.name] = Number.isFinite(value) + ? clamp(value, spec.low as number, spec.high as number) + : (spec.low as number); + } else if (spec.kind === "bool") { + action[spec.name] = Number.isFinite(value) && value >= 0.5; + } else { + throw new RangeError(`arena ${envId} action field ${spec.name} is not invertible`); + } + } + return action; +} + +function encodeField(spec: ArenaFieldSpec, value: unknown, into: number[]): void { + // Validated first, always: `arenaFieldWidth` is where a malformed spec is + // caught, and reading `spec.low` before asking whether it is a number would + // be the one path on which a bad manifest reached a vector instead of a throw. + arenaFieldWidth(spec); + switch (spec.kind) { + case "float": { + const low = spec.low as number; + const high = spec.high as number; + into.push(typeof value === "number" && Number.isFinite(value) ? clamp(value, low, high) : low); + return; + } + case "bool": + into.push(value === true ? 1 : 0); + return; + case "enum": { + for (const member of spec.values as readonly string[]) { + into.push(value === member ? 1 : 0); + } + return; + } + case "id": + into.push(typeof value === "string" && value.length > 0 ? idHash(value) : 0); + return; + } +} + +/** + * A string as a stable number in `[0, 1)`. + * + * `deriveArenaSeed` rather than a second hash written here: it is already the + * package's string mixer, it is already exercised by every scenario + * materialization, and two hashes over identifiers is two things to keep in + * step. The constant is an arbitrary non-zero base — it exists so that this + * stream is not the same one scenario seeds are drawn from. + */ +function idHash(value: string): number { + return deriveArenaSeed(0x9e3779b9, value) / 4_294_967_296; +} + +function clamp(value: number, low: number, high: number): number { + return Math.min(high, Math.max(low, value)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/arena/studioOps.ts b/src/arena/studioOps.ts new file mode 100644 index 0000000..3b21fd5 --- /dev/null +++ b/src/arena/studioOps.ts @@ -0,0 +1,1914 @@ +/** + * `studio-ops-v1` — running a studio, not walking a robot around one. + * + * The five environments before this one each isolate a single controller: a + * car, a walker, a robot, a crow, an aircraft. Each is honest and each is + * narrow, and a policy that solves one has learned one thing. This is the + * environment the whole build is framed as, and its point is that the variables + * are *coupled*: a robot working a job, the microphone and speaker on the desk + * it passes, the weather over the roof, the car on the apron waiting to leave, + * the aircraft crossing overhead, and one shared energy reserve that all of + * them draw from. + * + * ### It wraps the simulators the renderer drives + * + * Non-negotiable, and the reason this environment can be believed at all: + * + * | Wrapped | Where the browser uses it | + * |---|---| + * | `Plan(LUMBRIDGE_HQ \| MATEO_COURT)` | `interiors/officeScene.ts` builds the room from it | + * | `resolveRobotOperations` + `createRobotActivity` | the robots a visitor watches | + * | `createSimulatedDevices` | the mic and speaker panel, and the LEDs on the hardware | + * | `createSimulatedVehicleTelemetry` | the Model X parked on the apron | + * | `solarPosition` | `engine/atmosphere.ts`'s light rig | + * + * Not one line of physics is restated here. A headless reimplementation "for + * the trainer" would be a simulation nobody can look at, trained against a + * picture nobody can reproduce; the entire value of an environment built inside + * a renderer is that the thing being optimised is the thing being shown. + * + * ### Determinism, and the arithmetic it forbids + * + * `step()` reads no clock, no network and no `Math.random`. Everything that + * moves is a pure function of (scenario parameters, step index) or of a seeded + * simulator that this file advances by exactly one fixed step. + * + * That is necessary and not sufficient, because this environment is the first + * to put transcendental arithmetic — a solar position, a set of slant ranges — + * into a state that is compared for *exact equality* by a verifier that may be + * running on entirely different hardware. `Math.sin` is not required to be + * correctly rounded by IEEE-754 or by ECMA-262; `+`, `-`, `*`, `/` and + * `Math.sqrt` are. So: + * + * - every transcendental result is passed through `quantizeObservable` + * *before* it is observed, rewarded on, or fed forward; + * - everything downstream of one uses only the exact operations; + * - `Math.hypot` is avoided in favour of `Math.sqrt(a * a + b * b)`, because + * hypot is a library function with no correctly-rounded guarantee and the + * two-line version has one. + * + * `checksum.ts` explains the rest of the argument and holds the second half of + * the defence. + * + * ### The scenario is the weather + * + * Weather, time of day and the traffic overhead are **scenario parameters with + * a deterministic in-environment evolution**, never a fetch. A real NWS + * observation may be the *source* of a scenario — captured once, frozen into + * `cloudCoverBase`, `windKphBase` and the rest, and marked `weatherReported` — + * which is how an operator gets partially-real inputs without giving up + * replay. A network read inside `step()` would make `replay()` impossible and + * would make every published trace unverifiable a day later. None of the + * scenarios shipped here is a capture: they are invented profiles, and + * `weatherReported` is `false` on every one of them, which is the honest thing + * for that flag to say until somebody actually freezes an observation into it. + */ + +import { chargeTaper, createSimulatedVehicleTelemetry } from "../transport/vehicleTelemetry.ts"; +import type { SimulatedVehicleTelemetry } from "../transport/vehicleTelemetry.ts"; +import { DEVICE_RANGES, type DeviceDeclaration, type DeviceState } from "../devices/types.ts"; +import { createSimulatedDevices, type SimulatedDevices } from "../devices/sim.ts"; +import { solarPosition } from "../engine/solar.ts"; +import { Plan } from "../interiors/plan.ts"; +import type { Office } from "../interiors/types.ts"; +import { + createRobotActivity, + type RobotActivityController, + type RobotActivitySnapshot, + type RobotActivityState, +} from "../interiors/robotActivity.ts"; +import { + resolveRobotOperations, + type ResolvedRobotOperations, + type RobotJobKind, +} from "../interiors/robotOperations.ts"; +import { LUMBRIDGE_HQ } from "../offices/lumbridge-hq.ts"; +import { MATEO_COURT } from "../offices/mateo-court.ts"; +import { LUMBRIDGE_HQ_ROBOT_OPERATIONS } from "../offices/operations/lumbridge-hq.ts"; +import { MATEO_COURT_ROBOT_OPERATIONS } from "../offices/operations/mateo-court.ts"; +import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts"; +import { quantizeToPlaces } from "./checksum.ts"; +import { deriveArenaSeed } from "./random.ts"; +import { ArenaScenarioRegistry } from "./scenarios.ts"; +import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts"; +import { + ARENA_API_VERSION, + type ArenaFieldSpec, + type ArenaManifest, + type ArenaScenario, +} from "./types.ts"; + +// ---- Action --------------------------------------------------------------- + +/** + * One struct, nine fields, every one of them clamped in `normalizeAction`. + * + * Note what is *not* here: device power. A studio's hardware being switched on + * is a fact about the episode rather than a decision inside it — the rig is + * live when the day starts — and the interesting choices are the ones an + * operator actually makes with a live rig: what is muted, how much gain, what + * is playing and how loud. Both devices are powered at `reset` and the mute is + * the lever. It also keeps the action space honest: an environment whose + * optimal policy begins with two mandatory "turn it on" presses is one whose + * first two steps carry no information. + */ +export interface StudioOpsAction { + /** Planar drive demand for the robot, normalized to the unit disc. */ + x: number; + z: number; + /** Work the current job station. Only legal inside the station radius. */ + interact: boolean; + /** Microphone gain setpoint, dB, clamped to `DEVICE_RANGES.gain`. */ + micGain: number; + micMute: boolean; + /** Speaker volume setpoint, 0..1, clamped to `DEVICE_RANGES.volume`. */ + speakerVolume: number; + speakerPlay: boolean; + /** Run the car's climate control toward its comfort setpoint. */ + vehiclePrecondition: boolean; + /** Plug the car in. It draws from the same reserve everything else does. */ + vehicleCharge: boolean; +} + +// ---- Observation ---------------------------------------------------------- + +/** + * Forty-four flat, JSON-safe fields in five groups. + * + * Two of them are additions to the field list the build spec fixed, and both + * exist for the same reason: a terminal an agent cannot see coming is not a + * task, it is a trap. `energyReservePct` is what `battery-depleted` counts down + * and `stepsToDeparture` is what `departure-missed` counts down, and without + * them a policy could only learn those two failures as unexplained episode + * endings. Everything else is exactly as specified. + */ +export interface StudioOpsObservation { + // -- the agent and its job + officeId: string; + levelId: string; + x: number; + z: number; + mode: string; + phase: string; + jobKind: RobotJobKind | "none"; + payload: "parcel" | null; + battery: number; + jobProgress: number; + nextStationId: string; + nextX: number; + nextZ: number; + deltaX: number; + deltaZ: number; + distanceToNextM: number; + canInteract: boolean; + blockedStreak: number; + + // -- the sky over the roof + /** Local *mean solar* hours, 0..24. See `hourOfDay` below for why solar. */ + hourOfDay: number; + sunAltitudeDeg: number; + sunAzimuthDeg: number; + cloudCover: number; + precipitation: number; + visibilityKm: number; + windKph: number; + windDirDeg: number; + weatherCondition: string; + /** True only where the scenario was frozen from a real observation. */ + weatherReported: boolean; + + // -- the hardware on the desk + micPowered: boolean; + micGainDb: number; + micLevelDb: number; + micMuted: boolean; + speakerPowered: boolean; + speakerVolume: number; + speakerPlaying: boolean; + /** Whether anything is at the desk this microphone serves. */ + deskOccupied: boolean; + + // -- the car on the apron + vehicleSocPct: number; + vehicleCabinC: number; + vehiclePluggedIn: boolean; + vehicleReadyByDeparture: boolean; + + // -- traffic overhead + aircraftOverheadCount: number; + nearestAircraftSlantM: number; + + // -- the shared reserve and the clock on the departure + energyReservePct: number; + stepsToDeparture: number; +} + +// ---- Reward --------------------------------------------------------------- + +/** + * Thirteen components, summed by `base.ts` and never authored as a total. + * + * The counterweighting is the design. Every dense positive here has something + * pulling the other way, because a reward with only progress in it is a reward + * whose optimum is a behaviour nobody asked for: + * + * - `navigation` and `job` push toward the station; `time` and `control` make + * dawdling and thrashing cost. + * - `audioReady` pays for a live microphone at an occupied desk; `audioWaste` + * charges for a hot one at an empty desk, so "unmute and forget" is not a + * strategy. Both cost `energy`. + * - `energy` charges for everything drawn, priced up as `cloudCover` rises so + * a sunny hour is genuinely cheaper than an overcast one — the reserve is + * physically replenished by the roof, and the marginal cost of drawing from + * it depends on whether the sun is doing that replenishing. + * - `vehicleReady` is **potential-based**: it pays the *change* in a bounded + * readiness rather than the level, so it telescopes over an episode and + * cannot be farmed by plugging and unplugging. Charging is very cheap in + * time and expensive in reserve, which is the whole decision. + * - `noise` is the cross-variable term and the reason this is one environment + * rather than five: playing the speaker is worth something at an occupied + * desk and costs more the closer an aircraft is, the harder the wind blows, + * and the more of it a live microphone is picking up. A policy cannot decide + * whether to press play without reading the sky. + * - `collision`, `interaction` and `safety` are the failure costs. + * + * Nothing here is unbounded, and no component can be driven by an action that + * costs nothing. + */ +export type StudioOpsReward = Record< + | "navigation" | "job" | "success" | "audioReady" | "audioWaste" | "energy" + | "vehicleReady" | "noise" | "time" | "control" | "collision" | "interaction" | "safety", + number +>; + +// ---- Scenario parameters -------------------------------------------------- + +export interface StudioOpsScenarioParameters { + officeId: "lumbridge-hq" | "mateo-court"; + robotId: string; + jobId: string; + /** Which microphone and speaker this episode's operator is holding. */ + micId: string; + speakerId: string; + + /** Simulated wall-clock at step 0, epoch ms. Drives the solar position. */ + startEpochMs: number; + /** A label for the weather profile. Never parsed; it names the fixture. */ + weatherProfileId: string; + /** True only if the profile below was captured from a real observation. */ + weatherReported: boolean; + cloudCoverBase: number; + precipitationBase: number; + windKphBase: number; + windDirDeg: number; + visibilityKm: number; + ambientC: number; + /** Phases of the four weather oscillators, 0..1. Drawn by the sampler. */ + cloudPhase: number; + precipitationPhase: number; + windPhase: number; + windDirPhase: number; + + /** Seeds the overflight schedule. Jittered by the sampler. */ + aircraftScheduleSeed: number; + + /** The car, pinned rather than seeded, so a departure target is reachable. */ + vehicleInitialSocPct: number; + vehiclePluggedInAtStart: boolean; + departureStep: number; + /** Derived by the sampler as `vehicleInitialSocPct + SOC_TARGET_MARGIN_PCT`. */ + departureSocPct: number; + + /** The studio's stored energy allowance for this episode, kWh. */ + energyReserveKWh: number; +} + +interface StudioOpsSimulationSnapshot { + activity: RobotActivitySnapshot; + devices: unknown; + vehicle: unknown; + elapsedSteps: number; + energyReserveKWh: number; + previousDistanceM: number; + previousProgress: number; + previousReadiness: number; + blockedStreak: number; + wrongInteractions: number; + initialCompletedJobs: number; + departureResolved: boolean; +} + +// ---- Constants ------------------------------------------------------------ + +const FIXED_STEP = 0.1; +const MAX_STEPS = 1200; + +/** + * Decimals kept on anything a transcendental produced. + * + * Six, which for a solar altitude in degrees is a micro-degree — four + * millimetres of arc at the sun's distance, and about eight orders of magnitude + * coarser than the last-place disagreement two engines can have about + * `Math.sin`. Fine enough that no reward shaper can see the rounding; coarse + * enough that the rounding is what two runs agree on. + */ +const OBSERVABLE_DECIMALS = 6; + +/** Inside `robotActivity`'s 0.30 m arrival radius, as `office-jobs-v1` argues. */ +const INTERACTION_RADIUS_M = 0.295; +const WRONG_INTERACTION_LIMIT = 8; + +/** + * How close the robot has to be to a seat before the desk reads as occupied. + * + * A metre and a fifth: a body's reach of a desk, and comfortably outside the + * robot's own 0.28 m radius so arriving at a station next to a seat is not the + * same event as sitting at it. + * + * **This is an observation, not a claim about a person.** The only body in this + * building is the robot the policy is driving, and occupancy is fed to + * `createSimulatedDevices` as the input that module documents it as — the same + * `setOccupancy` a deployment with a real presence source would use. The + * microphone's meter responds to whether something is at the desk in front of + * it, which is what a microphone does. + */ +const OCCUPANCY_RADIUS_M = 1.2; + +// -- the studio's energy reserve +// +// The reserve is the episode's energy *allowance*, not a claim about a +// building's battery. Two minutes of simulated time is far too short for a real +// site battery to matter, so the allowance is sized deliberately: a studio at +// rest cannot come close to exhausting it, and a studio charging a car off it +// runs out with a couple of hundred steps to spare. That is a design decision +// about where the trade-off should bite, stated here rather than dressed up as +// a specification. + +/** Full charge of the reserve, kWh, and its value at step 0. */ +const SITE_ENERGY_CAPACITY_KWH = 3; +/** Lights, network, air handling and everything else that is simply on. */ +const SITE_BASE_LOAD_KW = 0.42; +/** Building climate: a fixed cost plus work proportional to the gap outside. */ +const SITE_HVAC_BASE_KW = 0.35; +const SITE_HVAC_KW_PER_KELVIN = 0.12; +const SITE_HVAC_MAX_KW = 3.5; +const SITE_COMFORT_C = 21; +/** A powered microphone, and a powered speaker plus its programme at full. */ +const MIC_DRAW_KW = 0.012; +const SPEAKER_IDLE_KW = 0.03; +const SPEAKER_PROGRAMME_KW = 0.22; +/** Peak roof generation, kW, and how much of it an overcast sky takes away. */ +const PV_PEAK_KW = 14; +const PV_CLOUD_LOSS = 0.86; + +// -- weather evolution +// +// Periods in seconds, chosen to be mutually prime-ish and short enough that +// something visibly moves inside a 120-second episode. An episode where the +// weather is a constant is an episode where four of the observation's fields +// are a fixed vector. +const CLOUD_PERIOD_S = 73; +const CLOUD_SWING = 0.18; +const PRECIPITATION_PERIOD_S = 51; +const WIND_PERIOD_S = 37; +const WIND_SWING_KPH = 9; +const WIND_DIR_PERIOD_S = 61; +const WIND_DIR_SWING_DEG = 12; + +// -- overflights +/** How many tracks the schedule carries. */ +const AIRCRAFT_TRACKS = 6; +/** Slant range inside which an aircraft counts as overhead, metres. */ +const AIRCRAFT_OVERHEAD_M = 5000; +/** Reported ceiling for the nearest track, so the field has a finite bound. */ +const AIRCRAFT_FAR_M = 60_000; + +// -- reward weights +const NAVIGATION_SCALE = 0.18; +const NAVIGATION_CLAMP_M = 0.3; +const JOB_SCALE = 1.2; +const JOB_CLAMP = 0.5; +const SUCCESS_BONUS = 10; +const AUDIO_CAPTURE_PER_STEP = 0.006; +const AUDIO_MONITOR_PER_STEP = 0.004; +const AUDIO_HOT_MIC_PER_STEP = 0.012; +const AUDIO_IDLE_PLAYBACK_PER_STEP = 0.008; +/** The gain band a take is usable in, dB. Outside it the mic is not "ready". */ +const AUDIO_GAIN_MIN_DB = 6; +const AUDIO_GAIN_MAX_DB = 24; +/** Volume below which the programme is not audibly monitoring anything. */ +const AUDIO_MONITOR_VOLUME = 0.2; +/** + * What draining the whole reserve costs, and how much dearer an overcast sky + * makes every kilowatt-hour of it. + * + * Nine is about the size of the success bonus on purpose: burning the studio's + * entire allowance should read as roughly as bad as completing the job is good. + */ +const ENERGY_WEIGHT = 9; +const ENERGY_CLOUD_SLOPE = 1.4; +/** Potential-based vehicle shaping. Telescopes to at most this over an episode. */ +const VEHICLE_SHAPING = 2; +const VEHICLE_READY_PER_STEP = 0.006; +const VEHICLE_UNREADY_PER_STEP = 0.001; +const DEPARTURE_READY_BONUS = 1.5; +/** Cabin comfort band for readiness, and the span the potential is graded over. */ +const CABIN_READY_K = 3; +const CABIN_POTENTIAL_K = 12; +/** + * The three noise weights, and why they are the sizes they are. + * + * Read them against `AUDIO_MONITOR_PER_STEP` (0.004), which is what monitoring + * pays, because the whole point is that the comparison has an answer: + * + * - bleed, at 0.009 a unit of volume, is cheaper than monitoring is worth at + * the 0.2 threshold and dearer than it is worth at full volume — so there is + * a real optimum in the volume knob rather than a binary; + * - an aircraft directly overhead, at 0.05, buries the monitoring gain at any + * volume, so the answer is "stop the playback until it has gone"; + * - saturated wind, at 0.03, does the same a little more gently. + * + * None of them fires at all when the speaker is silent, which is what makes + * "do not play" always available and always safe. + */ +const NOISE_AIRCRAFT = 0.05; +const NOISE_WIND = 0.03; +const NOISE_BLEED = 0.009; +/** Wind at which the noise penalty starts, and where it saturates, kph. */ +const NOISE_WIND_FLOOR_KPH = 25; +const NOISE_WIND_SPAN_KPH = 40; +const TIME_PER_STEP = 0.008; +const CONTROL_SCALE = 0.001; +const CONTROL_PER_COMMAND = 0.0006; +const COLLISION_PER_STEP = 0.08; +const INTERACTION_PENALTY = 0.12; +const SAFETY_STALL = 2; +const SAFETY_INTERACTION = 2; +const SAFETY_ENERGY = 3; +const SAFETY_DEPARTURE = 3; + +/** + * The condition vocabulary, mirroring `SkyCondition` in `engine/atmosphere.ts` + * and `WeatherCondition` in `server/wire.ts` member for member. + * + * Stated here rather than imported, for exactly the reason `atmosphere.ts` + * declines to import the wire: `src/arena/` may not reach three.js, and + * `atmosphere.ts` is the light rig. Three copies of an eight-member vocabulary + * is a real cost, and it is smaller than the cost of the arena's type graph + * depending on the renderer's. + * + * `snow` is in the list and is not produced by either shipped site, which is + * the correct shape for a vocabulary: the encoding is the union's, not this + * pack's. + */ +const WEATHER_CONDITIONS = [ + "clear", "partly-cloudy", "cloudy", "overcast", "fog", "rain", "snow", "thunderstorm", +] as const; + +// ---- Wrapped simulators, resolved once ------------------------------------ + +const SF_PLAN = new Plan(LUMBRIDGE_HQ, { depth: "public", warn: false }); +const LA_PLAN = new Plan(MATEO_COURT, { depth: "public", warn: false }); +const SF_OPERATIONS = resolveRobotOperations(SF_PLAN, LUMBRIDGE_HQ_ROBOT_OPERATIONS); +const LA_OPERATIONS = resolveRobotOperations(LA_PLAN, MATEO_COURT_ROBOT_OPERATIONS); + +interface StudioSetup { + plan: Plan; + operations: ResolvedRobotOperations; + /** Only the declarations the plan actually resolved. */ + declarations: readonly DeviceDeclaration[]; + lat: number; + lng: number; +} + +/** + * The declarations a plan accepted, in pack order. + * + * Filtered through `plan.device(id)` rather than taken straight off the level, + * because `Plan` is the one place a device is validated and a declaration it + * dropped has no coordinate, no room and no seat. Simulating a device the + * renderer refused to place would be the arena and the browser disagreeing + * about what is in the building, which is the single property this environment + * exists to guarantee. + */ +function resolvedDeclarations(plan: Plan, office: Office): DeviceDeclaration[] { + const declared = office.levels.flatMap((level) => level.floorplan.devices ?? []); + return declared.filter((declaration) => plan.device(declaration.id) !== null); +} + +function setupFor(officeId: StudioOpsScenarioParameters["officeId"]): StudioSetup { + const sf = officeId === "lumbridge-hq"; + const plan = sf ? SF_PLAN : LA_PLAN; + const office = sf ? LUMBRIDGE_HQ : MATEO_COURT; + const site = office.site; + if (!site) throw new Error(`${officeId} has no site; studio-ops needs a latitude`); + return { + plan, + operations: sf ? SF_OPERATIONS : LA_OPERATIONS, + declarations: resolvedDeclarations(plan, office), + lat: site.lat, + lng: site.lng, + }; +} + +// ---- Pure environment maths ----------------------------------------------- + +/** + * Everything a transcendental touched, rounded before anybody looks at it. + * + * The single most important line in this file. See the module header and + * `checksum.ts`: this is the point at which a cross-runtime disagreement about + * the last bit of `Math.sin` stops being able to propagate. + */ +export function quantizeObservable(value: number): number { + return quantizeToPlaces(value, OBSERVABLE_DECIMALS); +} + +function clamp(value: number, low: number, high: number): number { + return Math.min(high, Math.max(low, value)); +} + +function clamp01(value: number): number { + return clamp(value, 0, 1); +} + +/** A 0..1 fraction from a label and a seed, for a phase or a draw. */ +function unitHash(seed: number, label: string): number { + return deriveArenaSeed(seed, label) / 4_294_967_296; +} + +export interface StudioWeather { + cloudCover: number; + precipitation: number; + visibilityKm: number; + windKph: number; + windDirDeg: number; + condition: string; +} + +/** + * The weather at an elapsed time, as a pure function of the scenario. + * + * Four independent oscillators over the frozen base values, at periods short + * enough to move inside a two-minute episode and long enough not to read as + * noise. Every `Math.sin` result is quantised on the way out; nothing + * downstream of this function uses anything but exact arithmetic on what it + * returns. + */ +export function studioWeatherAt( + parameters: Readonly, + elapsedSeconds: number, +): StudioWeather { + const wave = (period: number, phase: number): number => + quantizeObservable(Math.sin(2 * Math.PI * (elapsedSeconds / period + phase))); + + const cloudCover = quantizeObservable( + clamp01(parameters.cloudCoverBase + CLOUD_SWING * wave(CLOUD_PERIOD_S, parameters.cloudPhase)), + ); + // Precipitation modulates its own base rather than swinging around it: a dry + // profile must stay dry. A base of zero can never rain, which is the correct + // behaviour for "the fixture says it is not raining". + const precipitation = quantizeObservable( + clamp01( + parameters.precipitationBase * + (0.6 + 0.6 * wave(PRECIPITATION_PERIOD_S, parameters.precipitationPhase)), + ), + ); + const windKph = quantizeObservable( + Math.max(0, parameters.windKphBase + WIND_SWING_KPH * wave(WIND_PERIOD_S, parameters.windPhase)), + ); + const windDirDeg = quantizeObservable( + ((parameters.windDirDeg + + WIND_DIR_SWING_DEG * wave(WIND_DIR_PERIOD_S, parameters.windDirPhase)) % 360 + 360) % 360, + ); + // Rain shortens the view. The same relationship `atmosphere.ts` applies when + // a station reported no visibility at all, and for the same reason: a wet + // afternoon that renders as thirty clear kilometres is a wrong picture. + const visibilityKm = quantizeObservable( + Math.max(0.2, parameters.visibilityKm * (1 - 0.45 * precipitation)), + ); + + return { + cloudCover, + precipitation, + visibilityKm, + windKph, + windDirDeg, + condition: weatherConditionOf(cloudCover, precipitation, visibilityKm, windKph), + }; +} + +/** + * The condition word for a set of readings. + * + * Ordered by what dominates what a person would say: fog beats everything + * because you cannot see the cloud through it, precipitation beats cloud cover + * because rain is the fact about a cloudy day, and the cloud thresholds are the + * ordinary octa boundaries. + */ +export function weatherConditionOf( + cloudCover: number, + precipitation: number, + visibilityKm: number, + windKph: number, +): string { + if (visibilityKm < 1) return "fog"; + if (precipitation > 0.55 && windKph > 55) return "thunderstorm"; + if (precipitation > 0.05) return "rain"; + if (cloudCover > 0.85) return "overcast"; + if (cloudCover > 0.55) return "cloudy"; + if (cloudCover > 0.2) return "partly-cloudy"; + return "clear"; +} + +/** + * One aircraft's track past the studio, reduced to the four numbers that decide + * whether it can be heard. + * + * Not a wrap of `engine/flights.ts`, and that is a boundary decision rather + * than a preference: `flights.ts` imports three.js on its first line, and + * `src/arena/` may not. What is here is deliberately *less* than that module — + * no callsign, no registration, no route, nothing that could be mistaken for a + * claim about a real flight — because the only thing the reward needs is a + * slant range. An operator who wants real traffic freezes an ADS-B capture into + * a scenario the same way they would a weather observation. + */ +export interface OverflightTrack { + /** Elapsed seconds at which the track is closest to the studio. */ + passSecond: number; + /** Horizontal miss distance at that instant, metres. */ + offsetM: number; + altitudeM: number; + speedMps: number; +} + +export function studioOverflights( + parameters: Readonly, +): OverflightTrack[] { + const seed = parameters.aircraftScheduleSeed; + const tracks: OverflightTrack[] = []; + for (let index = 0; index < AIRCRAFT_TRACKS; index += 1) { + // Alternating rather than uniformly drawn, and that is the difference + // between a sky that matters and one that does not. Drawing altitude + // uniformly from 400 m to 11 km puts almost every track's closest approach + // beyond audible range, so the noise coupling would exist in the code and + // never fire in an episode. Real traffic is not uniform either: it is + // arrivals and departures low over the city and everything else at cruise, + // which is exactly two populations. Three of each. + const low = index % 2 === 0; + tracks.push({ + // Spread across the episode and a little beyond either end, so a track is + // sometimes already receding at step 0 and sometimes never arrives. + passSecond: -20 + unitHash(seed, `pass:${index}`) * (MAX_STEPS * FIXED_STEP + 40), + offsetM: low + ? 100 + unitHash(seed, `offset:${index}`) * 3400 + : 500 + unitHash(seed, `offset:${index}`) * 8500, + altitudeM: low + ? 300 + unitHash(seed, `altitude:${index}`) * 1900 + : 7000 + unitHash(seed, `altitude:${index}`) * 4500, + // Approach speeds for the low ones, cruise for the high ones. + speedMps: low + ? 70 + unitHash(seed, `speed:${index}`) * 60 + : 200 + unitHash(seed, `speed:${index}`) * 60, + }); + } + return tracks; +} + +export interface StudioSky { + overhead: number; + nearestSlantM: number; +} + +/** + * How much traffic is overhead, and how close the closest of it is. + * + * `Math.sqrt` rather than `Math.hypot`: sqrt is required to be correctly + * rounded and hypot is not, and this number is inside a checksum. + */ +export function studioSkyAt( + tracks: readonly OverflightTrack[], + elapsedSeconds: number, +): StudioSky { + let overhead = 0; + let nearest = AIRCRAFT_FAR_M; + for (const track of tracks) { + const along = track.speedMps * (elapsedSeconds - track.passSecond); + const slant = Math.sqrt( + along * along + track.offsetM * track.offsetM + track.altitudeM * track.altitudeM, + ); + if (slant <= AIRCRAFT_OVERHEAD_M) overhead += 1; + if (slant < nearest) nearest = slant; + } + return { overhead, nearestSlantM: quantizeObservable(Math.min(nearest, AIRCRAFT_FAR_M)) }; +} + +/** Local mean solar hours. No timezone database, for the reason `solar.ts` gives. */ +export function localSolarHour(epochMs: number, lng: number): number { + const hours = epochMs / 3_600_000 + lng / 15; + return quantizeObservable(((hours % 24) + 24) % 24); +} + +/** + * What the roof is making, kW. + * + * Proportional to the sine of the sun's altitude — the cosine of the angle of + * incidence on a flat array — and cut by cloud. Below the horizon it is zero + * rather than negative, which is not a detail: `Math.sin` of a negative + * altitude is negative, and an array that consumed power at night would make + * the reserve grow at dusk. + */ +export function studioSolarKw(sunAltitudeDeg: number, cloudCover: number): number { + const altitudeRad = (sunAltitudeDeg * Math.PI) / 180; + const incidence = quantizeObservable(Math.max(0, Math.sin(altitudeRad))); + return PV_PEAK_KW * incidence * (1 - PV_CLOUD_LOSS * clamp01(cloudCover)); +} + +/** What the building's climate plant is drawing to hold 21 °C against `ambientC`. */ +export function studioHvacKw(ambientC: number): number { + const gap = Math.abs(ambientC - SITE_COMFORT_C); + return Math.min(SITE_HVAC_MAX_KW, SITE_HVAC_BASE_KW + gap * SITE_HVAC_KW_PER_KELVIN); +} + +/** What the declared hardware is drawing, kW, across every device in the pack. */ +export function studioDeviceKw(states: readonly DeviceState[]): number { + let kw = 0; + for (const state of states) { + if (!state.powered) continue; + if (state.kind === "mic") { + kw += MIC_DRAW_KW; + continue; + } + kw += SPEAKER_IDLE_KW; + if (state.playing === true) kw += SPEAKER_PROGRAMME_KW * clamp01(state.volume ?? 0); + } + return kw; +} + +/** + * The energy penalty for one step's draw. + * + * Priced as a fraction of the whole reserve rather than at a tariff, so the + * number means "how much of the studio's allowance did that cost" — which is + * the quantity the agent is actually trading against, and the only scaling + * under which a 150 kW charge post and a 12 W microphone are commensurable + * within a two-minute episode. + * + * Strictly increasing in `cloudCover` by construction: the multiplier is + * `1 + slope · cloud` with a positive slope and the load is strictly positive, + * because `SITE_BASE_LOAD_KW` is always on. That strictness is asserted in + * `src/test/arena/studioOps.test.ts` and is the coupling this component exists + * to demonstrate. + */ +export function studioOpsEnergyPenalty(loadKw: number, cloudCover: number): number { + const kwh = Math.max(0, loadKw) * (FIXED_STEP / 3600); + if (kwh <= 0) return 0; + const price = ENERGY_WEIGHT * (1 + ENERGY_CLOUD_SLOPE * clamp01(cloudCover)); + return -(kwh / SITE_ENERGY_CAPACITY_KWH) * price; +} + +export interface StudioNoiseInput { + speakerPlaying: boolean; + speakerVolume: number; + nearestAircraftSlantM: number; + windKph: number; + /** A live, unmuted microphone at an occupied desk — the bleed path. */ + micLive: boolean; + deskOccupied: boolean; +} + +/** + * The cross-variable penalty, and the reason this is one environment. + * + * Zero unless the speaker is playing, because a silent speaker cannot be heard + * on a take no matter what is overhead. When it is playing, the cost rises with + * the volume and with three independent things the agent does not control: how + * close the nearest aircraft is, how hard the wind is blowing, and whether a + * live microphone is in the room to pick any of it up. + * + * Strictly increasing as an aircraft approaches while playing — the acceptance + * this component is written against — because the proximity factor is + * `1 - slant/overheadRange` clamped at zero and multiplied by a positive volume. + */ +export function studioOpsNoisePenalty(input: StudioNoiseInput): number { + if (!input.speakerPlaying) return 0; + const volume = clamp01(input.speakerVolume); + if (volume <= 0) return 0; + const proximity = clamp01(1 - input.nearestAircraftSlantM / AIRCRAFT_OVERHEAD_M); + const gust = clamp01((input.windKph - NOISE_WIND_FLOOR_KPH) / NOISE_WIND_SPAN_KPH); + const bleed = input.micLive && input.deskOccupied ? NOISE_BLEED : 0; + const severity = NOISE_AIRCRAFT * proximity + NOISE_WIND * gust + bleed; + // Returned as a positive zero rather than `-volume * 0`. A negative zero is + // equal to zero under `===` and *not* under `Object.is`, prints as "-0" in a + // failure message, and would be the only field in a reward vector whose sign + // bit depended on whether a speaker happened to be playing. + return severity <= 0 ? 0 : -volume * severity; +} + +/** + * Vehicle readiness as a bounded 0..1 potential. + * + * Half charge, half cabin comfort, both graded rather than thresholded so the + * shaping term has a gradient to follow. `vehicleReadyByDeparture` is the + * *thresholded* version of the same two facts and is what the departure check + * uses; a potential that jumped from 0 to 1 at the threshold would give a + * policy nothing to climb. + */ +export function studioVehicleReadiness( + socPct: number, + cabinC: number, + requiredSocPct: number, +): number { + const charge = requiredSocPct <= 0 ? 1 : clamp01(socPct / requiredSocPct); + const comfort = clamp01(1 - Math.abs(cabinC - SITE_COMFORT_C) / CABIN_POTENTIAL_K); + return 0.5 * charge + 0.5 * comfort; +} + +// ---- Scenarios ------------------------------------------------------------ + +/** + * Five public scenarios: three train, two dev, across both studios, five + * weather profiles and both microphones in the LA pack. + * + * `startEpochMs` values are chosen so the solar altitude is genuinely different + * between them — a dawn, a morning, two afternoons and an evening, spanning + * +3 to +41 degrees — because a sun that is always at the same height makes + * three of the observation's fields constant and the roof's generation a fixed + * number rather than a variable. + * + * Two of them run the same authored job (`la-l1-inspect-directory`) under + * different weather and against a different microphone, which is deliberate: + * holding the robot's task fixed is what makes the *rest* of the observation + * the thing that distinguishes them. + * + * `weatherReported` is `false` on every one of them. None of these was captured + * from a station; they are invented profiles, and the flag says so. The field + * is here so that an operator who *does* freeze an NWS observation into a + * scenario has somewhere honest to record it. + */ +const DEFINITIONS = [ + { + id: "train-sf-clear-morning-desk-check", + split: "train" as const, + parameters: { + officeId: "lumbridge-hq" as const, + robotId: "sf-studio-activity-01", + // The monitor inspection, not the display one, and the choice is + // load-bearing: `sf-studio-monitor` stands 0.10 m from the seat + // `sf-desk-mic` serves, so the robot working this job *is* the desk being + // occupied. That is the coupling this environment is named for, and it is + // the only station in either shipped pack that sits at a microphone's + // seat. See the note above `OCCUPANCY_RADIUS_M`. + jobId: "sf-studio-inspect-monitor", + micId: "sf-desk-mic", + speakerId: "sf-desk-speaker", + // 2026-03-17 18:09 UTC — 10:00 local solar time over the Transbay, sun + // at +41 degrees. Every `startEpochMs` here is chosen for the *altitude* + // it produces, and the five differ by a good forty degrees between them: + // a sun at a fixed height would make three observation fields constant + // and the roof's generation a number rather than a variable. + startEpochMs: 1_773_770_940_000, + weatherProfileId: "clear-morning", + weatherReported: false, + cloudCoverBase: 0.12, + precipitationBase: 0, + windKphBase: 11, + windDirDeg: 265, + visibilityKm: 24, + ambientC: 19.5, + cloudPhase: 0, + precipitationPhase: 0, + windPhase: 0, + windDirPhase: 0, + aircraftScheduleSeed: 8_731, + vehicleInitialSocPct: 61, + vehiclePluggedInAtStart: false, + departureStep: 1000, + departureSocPct: 61.6, + energyReserveKWh: SITE_ENERGY_CAPACITY_KWH, + }, + }, + { + id: "train-la-overcast-inspection", + split: "train" as const, + parameters: { + officeId: "mateo-court" as const, + robotId: "la-office-activity-01", + jobId: "la-l1-inspect-directory", + micId: "la-front-mic", + speakerId: "la-front-speaker", + // 2026-01-22 22:23 UTC — 14:30 local solar time, a low winter sun at + // +27 degrees behind nine tenths of cloud. + startEpochMs: 1_769_120_580_000, + weatherProfileId: "overcast-drizzle", + weatherReported: false, + cloudCoverBase: 0.93, + precipitationBase: 0.12, + windKphBase: 16, + windDirDeg: 210, + visibilityKm: 12, + ambientC: 17, + cloudPhase: 0, + precipitationPhase: 0, + windPhase: 0, + windDirPhase: 0, + aircraftScheduleSeed: 4_477, + vehicleInitialSocPct: 58, + vehiclePluggedInAtStart: true, + departureStep: 1000, + departureSocPct: 58.6, + energyReserveKWh: SITE_ENERGY_CAPACITY_KWH, + }, + }, + { + id: "train-la-hot-afternoon-studio", + split: "train" as const, + parameters: { + officeId: "mateo-court" as const, + robotId: "la-office-activity-01", + jobId: "la-l1-inspect-directory", + micId: "la-studio-mic", + speakerId: "la-studio-speaker", + // 2026-08-14 23:53 UTC — 16:00 local solar time, sun at +34 degrees and + // the hot end of an August afternoon. + startEpochMs: 1_786_751_580_000, + weatherProfileId: "hot-clear-afternoon", + weatherReported: false, + cloudCoverBase: 0.06, + precipitationBase: 0, + windKphBase: 8, + windDirDeg: 250, + visibilityKm: 18, + ambientC: 34, + cloudPhase: 0, + precipitationPhase: 0, + windPhase: 0, + windDirPhase: 0, + aircraftScheduleSeed: 20_617, + vehicleInitialSocPct: 47, + vehiclePluggedInAtStart: false, + departureStep: 1000, + departureSocPct: 47.6, + energyReserveKWh: SITE_ENERGY_CAPACITY_KWH, + }, + }, + { + id: "dev-sf-windy-evening-delivery", + split: "dev" as const, + parameters: { + officeId: "lumbridge-hq" as const, + robotId: "sf-studio-activity-01", + jobId: "sf-studio-deliver", + micId: "sf-desk-mic", + speakerId: "sf-desk-speaker", + // 2026-05-03 02:39 UTC — 18:30 local solar time, sun at +3 degrees and + // about to go. The roof makes almost nothing, which is the point. + startEpochMs: 1_777_775_940_000, + weatherProfileId: "windy-evening", + weatherReported: false, + cloudCoverBase: 0.62, + precipitationBase: 0.05, + windKphBase: 46, + windDirDeg: 290, + visibilityKm: 20, + ambientC: 13, + cloudPhase: 0, + precipitationPhase: 0, + windPhase: 0, + windDirPhase: 0, + aircraftScheduleSeed: 1_781, + vehicleInitialSocPct: 66, + vehiclePluggedInAtStart: false, + departureStep: 1000, + departureSocPct: 66.6, + energyReserveKWh: SITE_ENERGY_CAPACITY_KWH, + }, + }, + { + id: "dev-la-marine-layer-loft-delivery", + split: "dev" as const, + parameters: { + officeId: "mateo-court" as const, + robotId: "la-office-activity-02", + jobId: "la-l2-deliver", + // The loft job runs on level 2 and every LA device is on level 1, so this + // scenario's desk is never occupied. That is a real operating case and a + // deliberate one: the correct play is to mute and get on with the job, + // and a policy that has only ever seen a reachable desk has not learned + // the difference between "unmute when somebody arrives" and "unmute". + micId: "la-studio-mic", + speakerId: "la-studio-speaker", + // 2026-06-19 15:23 UTC — 07:30 local solar time under the marine layer, + // sun at +31 degrees and almost none of it reaching the ground. + startEpochMs: 1_781_882_580_000, + weatherProfileId: "marine-layer", + weatherReported: false, + cloudCoverBase: 0.88, + precipitationBase: 0.02, + windKphBase: 7, + windDirDeg: 200, + visibilityKm: 0.8, + ambientC: 16, + cloudPhase: 0, + precipitationPhase: 0, + windPhase: 0, + windDirPhase: 0, + aircraftScheduleSeed: 6_112, + vehicleInitialSocPct: 52, + vehiclePluggedInAtStart: true, + departureStep: 1000, + departureSocPct: 52.6, + energyReserveKWh: SITE_ENERGY_CAPACITY_KWH, + }, + }, +] as const; + +/** + * How far above the starting charge the departure target sits, percentage + * points. + * + * Six tenths of a point, which at the apron post's tapered output is about + * fifteen seconds of charging and a fifth of the studio's whole reserve. Small + * on purpose: 150 kW into a 95 kWh pack moves the charge about five points in + * the two minutes an episode lasts, so a target further away than that would + * not be a decision, it would be a wall. The number that matters is the *ratio* + * — reaching the target costs a fifth of the allowance and buys the departure — + * and that ratio is what the agent is trading. + */ +const SOC_TARGET_MARGIN_PCT = 0.6; + +/** + * Seeded materialization. + * + * The four oscillator phases and the overflight seed are drawn here rather than + * inside the environment, which is what makes the weather and the traffic a + * *property of the scenario* — they land in `scenario.parameters`, they are + * covered by `scenario.hash`, and anybody holding a trace can read exactly what + * sky it was flown under. Drawing them inside `step()` would put the same + * numbers somewhere nobody could audit. + * + * The jitter on the bases is small on purpose: enough that two seeds are not + * the same episode, not so much that a scenario stops being the profile it is + * named after. An `overcast-drizzle` that samples clear is a mislabelled + * fixture. + */ +export const STUDIO_OPS_SCENARIOS = new ArenaScenarioRegistry( + "studio-ops-v1", + DEFINITIONS, + (base, random) => { + // Drawn before the literal because the departure target has to follow the + // jittered start charge. Keeping "can this be reached inside an episode" a + // property of the task rather than of the draw is the whole point. + const vehicleInitialSocPct = clamp( + base.vehicleInitialSocPct + random.between(-3, 3), + 5, + 95, + ); + return { + ...base, + cloudCoverBase: clamp01(base.cloudCoverBase + random.between(-0.05, 0.05)), + precipitationBase: clamp01(base.precipitationBase * random.between(0.8, 1.2)), + windKphBase: Math.max(0, base.windKphBase + random.between(-3, 3)), + windDirDeg: ((base.windDirDeg + random.between(-8, 8)) % 360 + 360) % 360, + visibilityKm: Math.max(0.2, base.visibilityKm * random.between(0.9, 1.1)), + ambientC: base.ambientC + random.between(-1.2, 1.2), + cloudPhase: random.next(), + precipitationPhase: random.next(), + windPhase: random.next(), + windDirPhase: random.next(), + aircraftScheduleSeed: + (base.aircraftScheduleSeed + Math.floor(random.between(0, 1_000_000))) >>> 0, + vehicleInitialSocPct, + departureSocPct: vehicleInitialSocPct + SOC_TARGET_MARGIN_PCT, + }; + }, +); + +// ---- Manifest ------------------------------------------------------------- + +const ACTION_SPACE: readonly ArenaFieldSpec[] = [ + { name: "x", kind: "float", low: -1, high: 1, unit: "normalized drive demand" }, + { name: "z", kind: "float", low: -1, high: 1, unit: "normalized drive demand" }, + { name: "interact", kind: "bool" }, + { + name: "micGain", + kind: "float", + low: DEVICE_RANGES.gain.min, + high: DEVICE_RANGES.gain.max, + unit: "dB", + }, + { name: "micMute", kind: "bool" }, + { + name: "speakerVolume", + kind: "float", + low: DEVICE_RANGES.volume.min, + high: DEVICE_RANGES.volume.max, + unit: "fraction", + }, + { name: "speakerPlay", kind: "bool" }, + { name: "vehiclePrecondition", kind: "bool" }, + { name: "vehicleCharge", kind: "bool" }, +]; + +const OBSERVATION_SPACE: readonly ArenaFieldSpec[] = [ + { name: "officeId", kind: "enum", values: ["lumbridge-hq", "mateo-court"] }, + { name: "levelId", kind: "enum", values: ["level-1", "level-2"] }, + { name: "x", kind: "float", low: 0, high: 40, unit: "m" }, + { name: "z", kind: "float", low: 0, high: 40, unit: "m" }, + { + name: "mode", + kind: "enum", + values: ["idle", "patrol", "deliver", "inspect", "charge", "blocked-recovery"], + }, + { + name: "phase", + kind: "enum", + values: [ + "scheduled-idle", "patrolling", "patrol-check", "to-pickup", "loading-parcel", + "to-dropoff", "delivering-parcel", "to-inspection", "inspecting", "to-charge", + "charging", "awaiting-interaction", "replanning", "backoff-and-replan", "terminal", + ], + }, + { name: "jobKind", kind: "enum", values: ["none", "patrol", "deliver", "inspect", "charge"] }, + { name: "payload", kind: "enum", values: ["parcel"] }, + { name: "battery", kind: "float", low: 0, high: 1, unit: "fraction" }, + { name: "jobProgress", kind: "float", low: 0, high: 1, unit: "fraction" }, + { name: "nextStationId", kind: "id" }, + { name: "nextX", kind: "float", low: 0, high: 40, unit: "m" }, + { name: "nextZ", kind: "float", low: 0, high: 40, unit: "m" }, + { name: "deltaX", kind: "float", low: -40, high: 40, unit: "m" }, + { name: "deltaZ", kind: "float", low: -40, high: 40, unit: "m" }, + { name: "distanceToNextM", kind: "float", low: 0, high: 60, unit: "m" }, + { name: "canInteract", kind: "bool" }, + { name: "blockedStreak", kind: "float", low: 0, high: MAX_STEPS, unit: "steps" }, + { name: "hourOfDay", kind: "float", low: 0, high: 24, unit: "local solar hours" }, + { name: "sunAltitudeDeg", kind: "float", low: -90, high: 90, unit: "deg above horizon" }, + { name: "sunAzimuthDeg", kind: "float", low: 0, high: 360, unit: "deg from true north" }, + { name: "cloudCover", kind: "float", low: 0, high: 1, unit: "fraction" }, + { name: "precipitation", kind: "float", low: 0, high: 1, unit: "intensity" }, + { name: "visibilityKm", kind: "float", low: 0, high: 40, unit: "km" }, + { name: "windKph", kind: "float", low: 0, high: 120, unit: "kph" }, + { name: "windDirDeg", kind: "float", low: 0, high: 360, unit: "deg the wind is from" }, + { name: "weatherCondition", kind: "enum", values: WEATHER_CONDITIONS }, + { name: "weatherReported", kind: "bool" }, + { name: "micPowered", kind: "bool" }, + { + name: "micGainDb", + kind: "float", + low: DEVICE_RANGES.gain.min, + high: DEVICE_RANGES.gain.max, + unit: "dB", + }, + { + name: "micLevelDb", + kind: "float", + low: DEVICE_RANGES.level.min, + high: DEVICE_RANGES.level.max, + unit: "dBFS", + }, + { name: "micMuted", kind: "bool" }, + { name: "speakerPowered", kind: "bool" }, + { + name: "speakerVolume", + kind: "float", + low: DEVICE_RANGES.volume.min, + high: DEVICE_RANGES.volume.max, + unit: "fraction", + }, + { name: "speakerPlaying", kind: "bool" }, + { name: "deskOccupied", kind: "bool" }, + { name: "vehicleSocPct", kind: "float", low: 0, high: 100, unit: "percent" }, + { name: "vehicleCabinC", kind: "float", low: -20, high: 60, unit: "deg C" }, + { name: "vehiclePluggedIn", kind: "bool" }, + { name: "vehicleReadyByDeparture", kind: "bool" }, + { name: "aircraftOverheadCount", kind: "float", low: 0, high: AIRCRAFT_TRACKS, unit: "count" }, + { name: "nearestAircraftSlantM", kind: "float", low: 0, high: AIRCRAFT_FAR_M, unit: "m" }, + { name: "energyReservePct", kind: "float", low: 0, high: 100, unit: "percent" }, + { name: "stepsToDeparture", kind: "float", low: 0, high: MAX_STEPS, unit: "steps" }, +]; + +export const STUDIO_OPS_MANIFEST: ArenaManifest = Object.freeze({ + apiVersion: ARENA_API_VERSION, + id: "studio-ops-v1", + version: 1, + title: "Studio operations", + description: + "Multi-variable operation of Lumbridge's two simulated studios: a robot job, the mic and " + + "speaker on the desk, the weather over the roof, the car on the apron, the traffic " + + "overhead, and one shared energy reserve.", + simulator: + "Plan + robotActivity + createSimulatedDevices + createSimulatedVehicleTelemetry + solarPosition", + fixedStepSeconds: FIXED_STEP, + maxSteps: MAX_STEPS, + actionFields: ACTION_SPACE.map((spec) => spec.name), + observationFields: OBSERVATION_SPACE.map((spec) => spec.name), + actionSpace: ACTION_SPACE, + observationSpace: OBSERVATION_SPACE, + rewardComponents: { + navigation: "Bounded reduction in distance to the current authored job station.", + job: "Dense progress through the assigned job's phases.", + success: "Sparse bonus for completing the assigned job.", + audioReady: "A live microphone, and monitoring, at an occupied desk.", + audioWaste: "A hot microphone or a playing speaker at an empty desk.", + energy: "Device, HVAC and charging draw as a share of the reserve, priced up by cloud cover.", + vehicleReady: "Potential-based progress toward a charged, comfortable car, plus the departure bonus.", + noise: "Playback under an aircraft, in wind, or into a live microphone.", + time: "Per-step pressure that keeps inaction below zero.", + control: "Planar action magnitude and device command churn.", + collision: "Commanded movement that fails against resolved office collision.", + interaction: "Interaction away from the current work station.", + safety: "Terminal stall, invalid-interaction, reserve-exhaustion or missed-departure penalty.", + }, + safetyTerminals: [ + "collision-stall", + "wrong-interaction-limit", + "battery-depleted", + "departure-missed", + ], + scenarioIds: { + train: STUDIO_OPS_SCENARIOS.ids("train"), + dev: STUDIO_OPS_SCENARIOS.ids("dev"), + }, + baselines: { + inaction: + "Zero drive, no interaction and an unmuted microphone: the mic runs hot at an empty desk, " + + "the job never starts, and the departure is missed.", + scripted: + "Follow the exposed route waypoint, interact only inside the station radius, mute the " + + "microphone unless the desk is occupied, monitor only when the sky and the wind are quiet, " + + "and charge only until the departure target is met.", + }, +}); + +export const STUDIO_OPS_INACTION: Readonly = Object.freeze({ + x: 0, + z: 0, + interact: false, + micGain: 0, + micMute: false, + speakerVolume: 0, + speakerPlay: false, + vehiclePrecondition: false, + vehicleCharge: false, +}); + +/** + * The smoke-proof baseline. Not an optimal policy, and not trying to be. + * + * It exists so that a reward regression or an impossible task fails in CI + * before anybody spends a training budget, and so the claims in the manifest's + * `baselines` block are executable rather than aspirational. Every branch in it + * is readable straight off the observation, which is also a check on the + * observation: a baseline that needed a field the environment does not publish + * would be evidence the task is unfairly partial. + */ +export function studioOpsScriptedBaseline(observation: StudioOpsObservation): StudioOpsAction { + const occupied = observation.deskOccupied; + const skyQuiet = observation.nearestAircraftSlantM > AIRCRAFT_OVERHEAD_M; + const windQuiet = observation.windKph < NOISE_WIND_FLOOR_KPH; + // Charge only while short of the departure target: the post draws from the + // same reserve the studio does, so leaving it plugged in past the target is + // paying for nothing. + const wantsCharge = !observation.vehicleReadyByDeparture && + observation.energyReservePct > 25; + const base = { + interact: false, + micGain: 12, + micMute: !occupied, + // Just above the threshold that counts as monitoring, because the bleed + // into a live microphone is charged per unit of volume and the reward for + // monitoring is not. Louder is strictly worse here; see the noise weights. + speakerVolume: 0.22, + speakerPlay: occupied && skyQuiet && windQuiet, + // Precondition only where the cabin is within reach of comfort. On a 34 °C + // afternoon it is not, and running the climate at it would spend charge on + // a target the episode is too short to hit. + vehiclePrecondition: Math.abs(observation.vehicleCabinC - SITE_COMFORT_C) > 1 && + Math.abs(observation.vehicleCabinC - SITE_COMFORT_C) < 8, + vehicleCharge: wantsCharge, + }; + + if (observation.canInteract) return { ...base, x: 0, z: 0, interact: true }; + const length = Math.sqrt( + observation.deltaX * observation.deltaX + observation.deltaZ * observation.deltaZ, + ); + if (length <= 1e-9) return { ...base, x: 0, z: 0 }; + const magnitude = Math.min(1, length / (1.05 * FIXED_STEP)); + return { + ...base, + x: (observation.deltaX / length) * magnitude, + z: (observation.deltaZ / length) * magnitude, + }; +} + +// ---- The environment ------------------------------------------------------ + +export class StudioOpsEnvironment extends BaseArenaEnvironment< + StudioOpsAction, + StudioOpsObservation, + StudioOpsReward, + StudioOpsSimulationSnapshot, + StudioOpsScenarioParameters +> { + readonly manifest = STUDIO_OPS_MANIFEST; + protected readonly registry = STUDIO_OPS_SCENARIOS; + protected readonly sourceHashes = ARENA_SOURCE_HASHES["studio-ops-v1"]!; + + private setup: StudioSetup | null = null; + private activity: RobotActivityController | null = null; + private devices: SimulatedDevices | null = null; + private vehicle: SimulatedVehicleTelemetry | null = null; + private tracks: readonly OverflightTrack[] = []; + private micSeat: { levelId: string; x: number; z: number } | null = null; + + private robotId = ""; + /** + * Steps taken since `reset`, and the environment's own clock. + * + * Not `BaseArenaEnvironment.currentStep()`, and the difference is a real bug + * rather than a style choice: the base class increments its step index + * *after* `advanceSimulation` returns, so inside that call `currentStep()` is + * still the index of the step that has already been taken. Building the + * returned observation from it reported the sun, the weather and the sky of + * one step ago while every simulator had already advanced — an observation + * describing a moment the reward was not computed at, which is the kind of + * skew a policy learns around and a reader never sees. + */ + private elapsedSteps = 0; + private energyReserveKWh = 0; + private previousDistanceM = 0; + private previousProgress = 0; + private previousReadiness = 0; + private blockedStreak = 0; + private wrongInteractions = 0; + private initialCompletedJobs = 0; + private departureResolved = false; + + // ---- lifecycle ---------------------------------------------------------- + + protected resetSimulation( + scenario: ArenaScenario, + ): StudioOpsObservation { + const parameters = scenario.parameters; + const setup = setupFor(parameters.officeId); + this.setup = setup; + this.robotId = parameters.robotId; + + this.activity = createRobotActivity(setup.plan, setup.operations, { + seed: scenario.seed, + robotIds: [this.robotId], + controlledRobotIds: [this.robotId], + initialJobIds: { [this.robotId]: parameters.jobId }, + }); + + // `epochMs: 0` on both simulators, so every timestamp in a snapshot is an + // elapsed count rather than a wall clock. A replayed rollout that carried + // `Date.now()` in one field would be the one thing that could never match. + this.devices = createSimulatedDevices(setup.declarations, { + seed: scenario.seed, + fixedStepSeconds: FIXED_STEP, + epochMs: 0, + }); + // The rig is live when the day starts. See `StudioOpsAction` for why power + // is not an action. + for (const declaration of setup.declarations) { + this.devices.command({ deviceId: declaration.id, op: "power", value: true }); + } + + this.vehicle = createSimulatedVehicleTelemetry({ + seed: scenario.seed, + fixedStepSeconds: FIXED_STEP, + ambientC: parameters.ambientC, + epochMs: 0, + initialSocPct: parameters.vehicleInitialSocPct, + }); + // The seeded start has the car plugged in or not depending on the draw. + // Pinned from the scenario instead, because whether the episode begins with + // 150 kW flowing out of the reserve is a property of the task and not + // something to discover from a hash. + this.vehicle.command({ op: "charge", value: parameters.vehiclePluggedInAtStart }); + this.vehicle.command({ op: "climate", value: false }); + + this.tracks = studioOverflights(parameters); + this.micSeat = this.locateMicSeat(setup, parameters.micId); + this.energyReserveKWh = parameters.energyReserveKWh; + this.elapsedSteps = 0; + this.blockedStreak = 0; + this.wrongInteractions = 0; + this.departureResolved = false; + + const robot = this.robot(); + this.applyOccupancy(robot); + this.initialCompletedJobs = robot.completedJobs; + this.previousDistanceM = this.distanceToNext(robot); + this.previousProgress = robot.progress; + const vehicle = this.vehicle.current(); + this.previousReadiness = studioVehicleReadiness( + vehicle.socPct, + vehicle.cabinC, + this.requiredSocPct(), + ); + return this.observation(); + } + + protected normalizeAction(action: StudioOpsAction): StudioOpsAction { + const finite = (value: unknown, fallback = 0): number => + typeof value === "number" && Number.isFinite(value) ? value : fallback; + const x = finite(action?.x); + const z = finite(action?.z); + // The unit disc, not the unit square: a diagonal demand of (1, 1) would + // otherwise be 41 % faster than a straight one, which is a bug every + // twin-stick controller has shipped at least once. + const length = Math.sqrt(x * x + z * z); + return { + x: length > 1 ? x / length : x, + z: length > 1 ? z / length : z, + interact: action?.interact === true, + // Rounded to the precision the instrument actually reports — two decimals + // of a decibel, three of a volume fraction, exactly as `DeviceState` + // rounds them on the way out. Without this a setpoint of 12.005 dB is + // read back as 12.01 dB, compares unequal to the action that set it, and + // is re-commanded on every step for the rest of the episode: a control + // that never settles, and a churn cost charged for standing still. + micGain: quantizeToPlaces( + clamp( + finite(action?.micGain, DEVICE_RANGES.gain.initial), + DEVICE_RANGES.gain.min, + DEVICE_RANGES.gain.max, + ), + 2, + ), + micMute: action?.micMute === true, + speakerVolume: quantizeToPlaces( + clamp( + finite(action?.speakerVolume, DEVICE_RANGES.volume.initial), + DEVICE_RANGES.volume.min, + DEVICE_RANGES.volume.max, + ), + 3, + ), + speakerPlay: action?.speakerPlay === true, + vehiclePrecondition: action?.vehiclePrecondition === true, + vehicleCharge: action?.vehicleCharge === true, + }; + } + + protected advanceSimulation( + action: StudioOpsAction, + ): SimulationTransition { + const parameters = this.currentScenario().parameters; + const before = this.robot(); + const beforePosition = { ...before.position }; + const canInteract = this.canInteract(before); + if (action.interact && !canInteract) this.wrongInteractions += 1; + + // -- hardware and vehicle commands, issued only where something changes. + // `normalizeDeviceCommand` and `normalizeVehicleTelemetryCommand` both + // refuse rather than throw, and neither simulator draws randomness on a + // command, so this is a courtesy to the reward's churn term rather than a + // determinism requirement. + const commands = this.applyDeviceCommands(action) + this.applyVehicleCommands(action); + + // -- advance every simulator by exactly one fixed step, in a fixed order. + const after = this.requireActivity().step({ + [this.robotId]: { x: action.x, z: action.z, interact: action.interact }, + }).robots[0]!; + this.applyOccupancy(after); + const devices = this.requireDevices(); + devices.stepFixed(); + const vehicleSource = this.requireVehicle(); + vehicleSource.stepFixed(); + + const deviceStates = devices.current(); + const vehicle = vehicleSource.current(); + this.elapsedSteps += 1; + const step = this.elapsedSteps; + const elapsedSeconds = step * FIXED_STEP; + const weather = studioWeatherAt(parameters, elapsedSeconds); + const sun = this.sunAt(elapsedSeconds); + const sky = studioSkyAt(this.tracks, elapsedSeconds); + + // -- energy: what was drawn, what the roof made, what is left. + const chargeKw = vehicle.pluggedIn && vehicle.socPct < 100 + ? 150 * chargeTaper(vehicle.socPct) + : 0; + const loadKw = SITE_BASE_LOAD_KW + studioHvacKw(parameters.ambientC) + + studioDeviceKw(deviceStates) + chargeKw; + const solarKw = studioSolarKw(sun.altitudeDeg, weather.cloudCover); + this.energyReserveKWh = clamp( + this.energyReserveKWh + (solarKw - loadKw) * (FIXED_STEP / 3600), + 0, + parameters.energyReserveKWh, + ); + + // -- audio, read off the pair this scenario's operator is holding. + const mic = this.micState(deviceStates); + const speaker = this.speakerState(deviceStates); + const deskOccupied = this.deskOccupied(after); + const micLive = mic?.powered === true && mic.muted !== true; + const micInBand = (mic?.gainDb ?? 0) >= AUDIO_GAIN_MIN_DB && + (mic?.gainDb ?? 0) <= AUDIO_GAIN_MAX_DB; + const speakerPlaying = speaker?.playing === true; + const speakerVolume = speaker?.volume ?? 0; + + const audioReady = + (deskOccupied && micLive && micInBand ? AUDIO_CAPTURE_PER_STEP : 0) + + (deskOccupied && speakerPlaying && speakerVolume >= AUDIO_MONITOR_VOLUME + ? AUDIO_MONITOR_PER_STEP + : 0); + const audioWaste = + (micLive && !deskOccupied ? -AUDIO_HOT_MIC_PER_STEP : 0) + + (speakerPlaying && !deskOccupied ? -AUDIO_IDLE_PLAYBACK_PER_STEP : 0); + + // -- vehicle readiness, as a bounded potential and as a threshold. + const requiredSoc = this.requiredSocPct(); + const readiness = studioVehicleReadiness(vehicle.socPct, vehicle.cabinC, requiredSoc); + const readyNow = this.vehicleReady(vehicle.socPct, vehicle.cabinC, requiredSoc); + let vehicleReward = VEHICLE_SHAPING * (readiness - this.previousReadiness) + + (readyNow ? VEHICLE_READY_PER_STEP : -VEHICLE_UNREADY_PER_STEP); + this.previousReadiness = readiness; + + // -- the departure deadline, checked exactly once. + let departureMissed = false; + if (!this.departureResolved && step >= parameters.departureStep) { + this.departureResolved = true; + if (readyNow) vehicleReward += DEPARTURE_READY_BONUS; + else departureMissed = true; + } + + // -- movement, collision and the job. + const movedX = after.position.x - beforePosition.x; + const movedZ = after.position.z - beforePosition.z; + const moved = Math.sqrt(movedX * movedX + movedZ * movedZ); + const demand = Math.sqrt(action.x * action.x + action.z * action.z); + const blocked = demand > 0.2 && moved < 0.012; + const control = CONTROL_SCALE * demand * demand + CONTROL_PER_COMMAND * commands; + this.blockedStreak = blocked ? this.blockedStreak + 1 : 0; + const distance = this.distanceToNext(after); + const navigation = clamp( + this.previousDistanceM - distance, + -NAVIGATION_CLAMP_M, + NAVIGATION_CLAMP_M, + ); + const jobProgress = clamp(after.progress - this.previousProgress, -JOB_CLAMP, JOB_CLAMP); + this.previousDistanceM = distance; + this.previousProgress = after.progress; + + // -- terminals, in the order a reader would rank them. + const success = after.completedJobs > this.initialCompletedJobs; + const controllerFailure = after.terminalReason; + const batteryDepleted = controllerFailure === "battery-depleted" || + (this.energyReserveKWh <= 0 && loadKw > solarKw); + const collisionStall = after.mode === "blocked-recovery" || + controllerFailure === "blocked-unrecoverable"; + const wrongInteractionFailure = this.wrongInteractions >= WRONG_INTERACTION_LIMIT; + const terminalReason = success + ? "job-complete" + : batteryDepleted + ? "battery-depleted" + : collisionStall + ? "collision-stall" + : wrongInteractionFailure + ? "wrong-interaction-limit" + : departureMissed + ? "departure-missed" + : undefined; + + const safety = success + ? 0 + : batteryDepleted + ? -SAFETY_ENERGY + : collisionStall + ? -SAFETY_STALL + : wrongInteractionFailure + ? -SAFETY_INTERACTION + : departureMissed + ? -SAFETY_DEPARTURE + : 0; + + return { + observation: this.observation(), + rewardComponents: { + navigation: navigation * NAVIGATION_SCALE, + job: jobProgress * JOB_SCALE, + success: success ? SUCCESS_BONUS : 0, + audioReady, + audioWaste, + energy: studioOpsEnergyPenalty(loadKw, weather.cloudCover), + vehicleReady: vehicleReward, + noise: studioOpsNoisePenalty({ + speakerPlaying, + speakerVolume, + nearestAircraftSlantM: sky.nearestSlantM, + windKph: weather.windKph, + micLive, + deskOccupied, + }), + time: -TIME_PER_STEP, + // Assembled positive and negated once, so a step that demanded nothing + // and commanded nothing reports `0` rather than `-0`. The checksum + // collapses the two, but a reward vector where one component's sign bit + // flips on an unrelated condition is a needless surprise to a reader. + control: control <= 0 ? 0 : -control, + collision: blocked ? -COLLISION_PER_STEP : 0, + interaction: action.interact && !canInteract ? -INTERACTION_PENALTY : 0, + safety, + }, + terminated: terminalReason !== undefined, + terminalReason, + }; + } + + protected simulationSnapshot(): StudioOpsSimulationSnapshot { + return { + activity: this.requireActivity().snapshot(), + devices: this.requireDevices().snapshot(), + vehicle: this.requireVehicle().snapshot(), + elapsedSteps: this.elapsedSteps, + energyReserveKWh: this.energyReserveKWh, + previousDistanceM: this.previousDistanceM, + previousProgress: this.previousProgress, + previousReadiness: this.previousReadiness, + blockedStreak: this.blockedStreak, + wrongInteractions: this.wrongInteractions, + initialCompletedJobs: this.initialCompletedJobs, + departureResolved: this.departureResolved, + }; + } + + protected restoreSimulation(snapshot: StudioOpsSimulationSnapshot): StudioOpsObservation { + // Rebuild from the scenario first, so a restore lands on a simulator this + // environment constructed rather than on whatever the previous episode left + // behind. The same order `office-jobs-v1` uses. + this.resetSimulation(this.currentScenario()); + if ( + !Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0 || + snapshot.elapsedSteps > MAX_STEPS || + !Number.isFinite(snapshot.energyReserveKWh) || snapshot.energyReserveKWh < 0 || + !Number.isFinite(snapshot.previousDistanceM) || + !Number.isFinite(snapshot.previousProgress) || + !Number.isFinite(snapshot.previousReadiness) || + !Number.isSafeInteger(snapshot.blockedStreak) || snapshot.blockedStreak < 0 || + !Number.isSafeInteger(snapshot.wrongInteractions) || snapshot.wrongInteractions < 0 || + !Number.isSafeInteger(snapshot.initialCompletedJobs) || snapshot.initialCompletedJobs < 0 || + typeof snapshot.departureResolved !== "boolean" + ) throw new Error("studio ops simulation snapshot is invalid"); + this.requireActivity().restore(snapshot.activity); + this.requireDevices().restore(snapshot.devices); + this.requireVehicle().restore(snapshot.vehicle); + this.elapsedSteps = snapshot.elapsedSteps; + this.energyReserveKWh = snapshot.energyReserveKWh; + this.previousDistanceM = snapshot.previousDistanceM; + this.previousProgress = snapshot.previousProgress; + this.previousReadiness = snapshot.previousReadiness; + this.blockedStreak = snapshot.blockedStreak; + this.wrongInteractions = snapshot.wrongInteractions; + this.initialCompletedJobs = snapshot.initialCompletedJobs; + this.departureResolved = snapshot.departureResolved; + return this.observation(); + } + + // ---- observation -------------------------------------------------------- + + private observation(): StudioOpsObservation { + const parameters = this.currentScenario().parameters; + const robot = this.robot(); + const station = this.nextStation(robot); + const waypoint = robot.route[robot.routeIndex] ?? station?.position ?? robot.position; + const elapsedSeconds = this.elapsedSteps * FIXED_STEP; + const weather = studioWeatherAt(parameters, elapsedSeconds); + const sun = this.sunAt(elapsedSeconds); + const sky = studioSkyAt(this.tracks, elapsedSeconds); + const deviceStates = this.requireDevices().current(); + const mic = this.micState(deviceStates); + const speaker = this.speakerState(deviceStates); + const vehicle = this.requireVehicle().current(); + const requiredSoc = this.requiredSocPct(); + + return { + officeId: this.requireSetup().operations.definition.officeId, + levelId: robot.levelId, + x: robot.position.x, + z: robot.position.z, + mode: robot.mode, + phase: robot.phase, + jobKind: robot.activeJobKind ?? "none", + payload: robot.payload, + battery: robot.battery, + jobProgress: robot.progress, + nextStationId: station?.id ?? "", + nextX: waypoint.x, + nextZ: waypoint.z, + deltaX: waypoint.x - robot.position.x, + deltaZ: waypoint.z - robot.position.z, + distanceToNextM: station ? this.distanceToNext(robot) : 0, + canInteract: this.canInteract(robot), + blockedStreak: this.blockedStreak, + + hourOfDay: localSolarHour(parameters.startEpochMs + elapsedSeconds * 1000, this.requireSetup().lng), + sunAltitudeDeg: sun.altitudeDeg, + sunAzimuthDeg: sun.azimuthDeg, + cloudCover: weather.cloudCover, + precipitation: weather.precipitation, + visibilityKm: weather.visibilityKm, + windKph: weather.windKph, + windDirDeg: weather.windDirDeg, + weatherCondition: weather.condition, + weatherReported: parameters.weatherReported, + + micPowered: mic?.powered === true, + micGainDb: mic?.gainDb ?? DEVICE_RANGES.gain.initial, + micLevelDb: mic?.levelDb ?? DEVICE_RANGES.level.min, + micMuted: mic?.muted === true, + speakerPowered: speaker?.powered === true, + speakerVolume: speaker?.volume ?? 0, + speakerPlaying: speaker?.playing === true, + deskOccupied: this.deskOccupied(robot), + + vehicleSocPct: quantizeObservable(vehicle.socPct), + vehicleCabinC: quantizeObservable(vehicle.cabinC), + vehiclePluggedIn: vehicle.pluggedIn, + vehicleReadyByDeparture: this.vehicleReady(vehicle.socPct, vehicle.cabinC, requiredSoc), + + aircraftOverheadCount: sky.overhead, + nearestAircraftSlantM: sky.nearestSlantM, + + energyReservePct: quantizeObservable( + (this.energyReserveKWh / parameters.energyReserveKWh) * 100, + ), + stepsToDeparture: Math.max(0, parameters.departureStep - this.elapsedSteps), + }; + } + + // ---- helpers ------------------------------------------------------------ + + /** + * The sun, quantised. + * + * `new Date(...)` from a fixed epoch is arithmetic, not a clock read — and it + * never enters the state, which is just as well: `canonicalJson` throws on a + * `Date` precisely so that one cannot be smuggled into a checksum. + */ + private sunAt(elapsedSeconds: number): { altitudeDeg: number; azimuthDeg: number } { + const setup = this.requireSetup(); + const parameters = this.currentScenario().parameters; + const when = new Date(parameters.startEpochMs + elapsedSeconds * 1000); + const position = solarPosition(setup.lat, setup.lng, when); + return { + altitudeDeg: quantizeObservable(position.elevation), + azimuthDeg: quantizeObservable(position.azimuth), + }; + } + + /** The target charge. Materialized by the sampler, a fixed margin above the start. */ + private requiredSocPct(): number { + return this.currentScenario().parameters.departureSocPct; + } + + private vehicleReady(socPct: number, cabinC: number, requiredSocPct: number): boolean { + return socPct >= requiredSocPct && Math.abs(cabinC - SITE_COMFORT_C) <= CABIN_READY_K; + } + + private locateMicSeat( + setup: StudioSetup, + micId: string, + ): { levelId: string; x: number; z: number } | null { + const device = setup.plan.device(micId); + if (!device?.seatId) return null; + const seat = setup.plan.seat(device.seatId); + return seat ? { levelId: seat.levelId, x: seat.position.x, z: seat.position.z } : null; + } + + /** + * Tell the device simulator who is at the desk. + * + * Called on every step *including* reset, and never conditionally: the first + * call switches `createSimulatedDevices` off its own occupancy schedule for + * good, and a run that made that call at step 40 rather than step 0 would be + * a different run. See the note on `setOccupancy` in `devices/sim.ts`. + */ + private applyOccupancy(robot: RobotActivityState): void { + const seatIds: string[] = []; + if (this.micSeat && this.nearMicSeat(robot)) { + const device = this.requireSetup().plan.device(this.currentScenario().parameters.micId); + if (device?.seatId) seatIds.push(device.seatId); + } + this.requireDevices().setOccupancy(seatIds); + } + + private nearMicSeat(robot: RobotActivityState): boolean { + const seat = this.micSeat; + if (!seat || seat.levelId !== robot.levelId) return false; + const dx = seat.x - robot.position.x; + const dz = seat.z - robot.position.z; + return Math.sqrt(dx * dx + dz * dz) <= OCCUPANCY_RADIUS_M; + } + + private deskOccupied(robot: RobotActivityState): boolean { + return this.nearMicSeat(robot); + } + + private micState(states: readonly DeviceState[]): DeviceState | undefined { + const id = this.currentScenario().parameters.micId; + return states.find((state) => state.id === id); + } + + private speakerState(states: readonly DeviceState[]): DeviceState | undefined { + const id = this.currentScenario().parameters.speakerId; + return states.find((state) => state.id === id); + } + + /** Issues only the device commands that change something. Returns how many. */ + private applyDeviceCommands(action: StudioOpsAction): number { + const devices = this.requireDevices(); + const parameters = this.currentScenario().parameters; + const states = devices.current(); + const mic = this.micState(states); + const speaker = this.speakerState(states); + let issued = 0; + + if (mic && mic.muted !== action.micMute) { + devices.command({ deviceId: parameters.micId, op: "mute", value: action.micMute }); + issued += 1; + } + if (mic && mic.gainDb !== action.micGain) { + devices.command({ deviceId: parameters.micId, op: "gain", value: action.micGain }); + issued += 1; + } + if (speaker && speaker.volume !== action.speakerVolume) { + devices.command({ + deviceId: parameters.speakerId, + op: "volume", + value: action.speakerVolume, + }); + issued += 1; + } + if (speaker && speaker.playing !== action.speakerPlay) { + devices.command({ + deviceId: parameters.speakerId, + op: "playback", + value: action.speakerPlay, + }); + issued += 1; + } + return issued; + } + + private applyVehicleCommands(action: StudioOpsAction): number { + const vehicle = this.requireVehicle(); + const state = vehicle.current(); + let issued = 0; + if (state.pluggedIn !== action.vehicleCharge) { + vehicle.command({ op: "charge", value: action.vehicleCharge }); + issued += 1; + } + if (state.climateOn !== action.vehiclePrecondition) { + vehicle.command({ op: "climate", value: action.vehiclePrecondition }); + issued += 1; + } + return issued; + } + + private nextStation(robot: RobotActivityState) { + const id = robot.activeStationIds[robot.stationIndex]; + return id ? this.requireSetup().operations.stations.get(id) : undefined; + } + + private distanceToNext(robot: RobotActivityState): number { + const station = this.nextStation(robot); + if (!station) return 0; + const dx = station.position.x - robot.position.x; + const dz = station.position.z - robot.position.z; + return Math.sqrt(dx * dx + dz * dz); + } + + private canInteract(robot: RobotActivityState): boolean { + return this.distanceToNext(robot) <= INTERACTION_RADIUS_M; + } + + private robot(): RobotActivityState { + const robot = this.requireActivity().states()[0]; + if (!robot) throw new Error("studio ops environment has no controlled robot"); + return robot; + } + + private requireSetup(): StudioSetup { + if (!this.setup) throw new Error("studio ops environment has not been reset"); + return this.setup; + } + + private requireActivity(): RobotActivityController { + if (!this.activity) throw new Error("studio ops environment has not been reset"); + return this.activity; + } + + private requireDevices(): SimulatedDevices { + if (!this.devices) throw new Error("studio ops environment has not been reset"); + return this.devices; + } + + private requireVehicle(): SimulatedVehicleTelemetry { + if (!this.vehicle) throw new Error("studio ops environment has not been reset"); + return this.vehicle; + } +} diff --git a/src/arena/types.ts b/src/arena/types.ts index 3f8ed76..3b60844 100644 --- a/src/arena/types.ts +++ b/src/arena/types.ts @@ -57,6 +57,48 @@ export interface ArenaStepResult> { info: ArenaInfo; } +/** + * How one observation or action field is encoded as numbers. + * + * `observationFields` was always a list of names, which tells a reader what the + * environment observes and tells a trainer nothing it can allocate against: how + * wide is the vector, which slots are angles, what does `2` mean in the slot + * holding a phase. Every consumer had to read the environment's source and + * write the encoder by hand, which is a fork of the observation contract kept + * in a file this repository cannot see. + * + * Four kinds, because four is what the six environments actually contain: + * + * - `float` — a quantity, with `low`/`high` as *declared bounds* rather than + * guarantees. `flattenObservation` clamps into them; a policy normalises with + * them. One slot. + * - `bool` — one slot, 0 or 1. + * - `enum` — a closed vocabulary, one-hot over `values`. As many slots as + * there are values. A value outside the vocabulary encodes as all zeros, + * which reads as "none of these" rather than silently colliding with the + * first member. + * - `id` — an open-vocabulary identifier: a station id, a robot id, a road + * name. One slot holding a stable hash in `[0, 1)`. Nothing should try to + * *learn* from that slot — it has no order and no metric — but dropping the + * field would make the vector and `observationFields` incommensurable, and a + * hash at least changes exactly when the identity does, which is enough to + * detect "the station I am walking to is not the one I was walking to". + */ +export type ArenaFieldKind = "float" | "bool" | "enum" | "id"; + +export interface ArenaFieldSpec { + /** Matches the key in the observation or action object, exactly. */ + name: string; + kind: ArenaFieldKind; + /** `float` only. Both required for a float, finite, and `low < high`. */ + low?: number; + high?: number; + /** `enum` only. Non-empty and free of duplicates. */ + values?: readonly string[]; + /** Documentation for a reader of the manifest. Never parsed. */ + unit?: string; +} + export interface ArenaManifest { apiVersion: typeof ARENA_API_VERSION; id: string; @@ -68,6 +110,17 @@ export interface ArenaManifest { maxSteps: number; actionFields: readonly string[]; observationFields: readonly string[]; + /** + * The same fields as `actionFields`/`observationFields`, in the same order, + * with an encoding for each. + * + * Two lists rather than one because the name lists are the human-readable + * contract that has been published since v1 and the spaces are the machine + * one; `src/test/arena/spaces.test.ts` asserts they agree name for name and + * in order, so the redundancy cannot rot into a disagreement. + */ + actionSpace: readonly ArenaFieldSpec[]; + observationSpace: readonly ArenaFieldSpec[]; rewardComponents: Readonly>; safetyTerminals: readonly string[]; scenarioIds: Readonly>; @@ -82,6 +135,26 @@ export interface ArenaSnapshot { envId: string; envVersion: number; envHash: string; + /** + * The source pins the snapshot was taken under. + * + * `envHash` is a hash of the *manifest*, which is semantics: the id, the + * version, the field names, the reward vocabulary. It does not move when the + * physics under it does. Edit the walker's collision epsilon or the device + * simulator's release rate, leave the manifest alone, and a snapshot taken + * before the edit restored cleanly afterwards and resumed into a different + * simulation — silently, mid-episode, with a checksum that still verified + * because it was recomputed over the new state. + * + * `replay()` has always guarded this by comparing the trace envelope's + * `sourceHashes`; `restore()` did not, and a checkpoint is exactly as + * dangerous as a trace. So the pins travel in the snapshot too, inside the + * checksummed core, and `restore()` refuses a snapshot whose simulator or + * environment source has moved. The cost is that a legitimate source edit + * invalidates outstanding checkpoints, which is the correct thing for it to + * do: they were checkpoints of a program that no longer exists. + */ + sourceHashes: ArenaSourceHashes; seed: number; scenarioId: string; scenarioSplit: ArenaSplit; diff --git a/src/assets/materials.ts b/src/assets/materials.ts index 2887f7e..d0d5f8c 100644 --- a/src/assets/materials.ts +++ b/src/assets/materials.ts @@ -69,6 +69,11 @@ export type SurfaceRole = | "lightHousing" | "lightDiffuser" | "whiteboard" + // Devices + | "deviceShell" + | "deviceMesh" + | "deviceIndicator" + | "screenContent" // Objects | "foliage" | "planter" @@ -82,6 +87,11 @@ export type SurfaceRole = */ export type MaterialQuality = "low" | "medium" | "high"; +/** + * `MeshPhysicalMaterial` is a subclass of `MeshStandardMaterial`, so it needs no + * arm of its own here — but it is worth knowing it is in the union, because + * `glazing` is one at `medium` and `high` and a `MeshStandardMaterial` at `low`. + */ export type SurfaceMaterial = THREE.MeshStandardMaterial | THREE.MeshLambertMaterial; interface RoleSpec { @@ -92,10 +102,50 @@ interface RoleSpec { texture?: TextureKind; /** Fraction of the role's own colour emitted. Screens and diffusers only. */ glow?: number; + /** + * Emit through `texture` rather than flat across the surface. + * + * Only meaningful with a `texture` that carries content rather than grain, and + * `screenContent` is the only such role. It is the difference between a + * monitor and a light box: with a flat `glow` the whole panel emits and the + * drawn interface is a pattern printed on a lamp, and with the map bound to + * `emissiveMap` the lit pixels emit and the chrome around them does not. + */ + emissiveFromMap?: boolean; + /** + * A coverage map, and the threshold a fragment has to clear to be drawn. + * + * Cutout, not blend. `alphaTest` discards below the threshold and leaves the + * material opaque, so a leaf still writes depth, still sorts like solid + * geometry and still casts a correctly-shaped shadow — three's depth material + * copies `alphaMap` and `alphaTest` across for exactly this. Making foliage + * `transparent` instead would buy a soft edge and cost the shadow, the depth + * write and the sort order, on the one class of object there are hundreds of. + */ + alphaTexture?: TextureKind; + alphaTest?: number; /** Opacity below 1 makes the material transparent. */ opacity?: number; /** Leaf cards and glass want both faces. */ doubleSided?: boolean; + /** + * Refract through the surface instead of blending over it. + * + * `MeshPhysicalMaterial`'s transmission is the difference between glass and a + * grey film: it takes the *lit* colour of what is behind the surface, tints it + * by `color`, bends it by `ior` over `thickness`, and — the part that actually + * sells it — leaves a specular highlight and an environment reflection on top + * that a 22%-opacity blend cannot have. `roughness` becomes frosting rather + * than a matte grey, which is what a fritted partition wants. + * + * It costs a copy of the render target per transmissive draw, which is why it + * is `medium` and `high` only and why exactly one role uses it. + */ + transmission?: number; + /** Refractive index. 1.5 is soda-lime glass. Only read with `transmission`. */ + ior?: number; + /** Metres of glass the refraction is integrated over. Only read with `transmission`. */ + thickness?: number; } const ROLE_SPECS: Record = { @@ -115,7 +165,31 @@ const ROLE_SPECS: Record = { // Glass writes no depth. With it on, anything behind a window disappears // depending on which mesh the sorter happens to draw first, and a meeting // room made of glass is exactly the case where that is most visible. - glazing: { roughness: 0.05, metalness: 0.1, opacity: 0.22, doubleSided: true }, + // + // That reasoning survives the move to transmission unchanged, and it has to be + // said out loud because three.js *encourages* the opposite: a transmissive + // material is drawn in the transmission pass and the usual advice is to let it + // write depth. Here it must not. An office is a box of glass boxes — a meeting + // room seen through a corridor screen through an external window is three + // sheets deep — and depth-writing glass makes whichever sheet the sorter + // reached first erase the other two. The `opacity` stays as well: it is what + // `low` quality falls back to, and it is what keeps the frame visible against + // the glass in the ghosted wall-occlusion copy. + glazing: { + roughness: 0.05, + // Was 0.1, and had to go: three.js scales transmission by `1 - metalness` + // because a metal is opaque by definition, so a tenth of metalness is a + // tenth of the glass quietly turned back into a mirror. + metalness: 0, + opacity: 0.22, + doubleSided: true, + transmission: 0.92, + ior: 1.5, + // Millimetres, not metres of solid glass: `thickness` scales the volumetric + // tint, and a 6 mm pane that tints like a 6 m aquarium is the classic way + // this parameter goes wrong. + thickness: 0.006, + }, glazingFrame: { roughness: 0.35, metalness: 0.7 }, doorLeaf: { roughness: 0.6, metalness: 0 }, @@ -139,7 +213,38 @@ const ROLE_SPECS: Record = { lightDiffuser: { roughness: 0.9, metalness: 0, glow: 0.85 }, whiteboard: { roughness: 0.15, metalness: 0, texture: "whiteboard" }, - foliage: { roughness: 0.8, metalness: 0, doubleSided: true }, + /** + * The four device roles. + * + * `deviceMesh` is a grille or a windscreen — the perforated part — and it is + * double-sided because you can see through it to the inside of the housing at + * a glancing angle, which is most of what makes a speaker look like a speaker. + * `deviceIndicator` is the only role in the table with a `glow` of 1: an LED + * is a light source rather than a lit surface, and under the tone curve + * `stage.ts` now runs, a full-strength emissive reads as a lamp instead of + * saturating to the same white as the housing beside it. + */ + deviceShell: { roughness: 0.42, metalness: 0.28 }, + deviceMesh: { roughness: 0.52, metalness: 0.8, doubleSided: true }, + deviceIndicator: { roughness: 0.35, metalness: 0, glow: 1 }, + screenContent: { + roughness: 0.18, + metalness: 0, + texture: "screenUI", + glow: 0.9, + emissiveFromMap: true, + }, + + // A leaf is a quad with a leaf cut out of it. See `leafAlpha` in textures.ts + // for why that is worth a texture channel, and `alphaTest` at 0.5 for why the + // threshold sits in the middle of a hard-edged drawing rather than at its toe. + foliage: { + roughness: 0.8, + metalness: 0, + doubleSided: true, + alphaTexture: "leafAlpha", + alphaTest: 0.5, + }, planter: { roughness: 0.7, metalness: 0 }, paper: { roughness: 0.9, metalness: 0 }, accent: { roughness: 0.6, metalness: 0.1 }, @@ -227,6 +332,11 @@ export class MaterialRegistry { * The map is dropped deliberately: carpet grain at 18% opacity is visual * noise on top of whatever it is supposed to be letting you see. Depth * writing goes with it, for the same reason glazing does not write depth. + * + * `alphaMap` is deliberately *not* dropped with it. The colour map is + * decoration and the coverage map is shape — a ghosted leaf with its cutout + * removed is not a faint leaf, it is the flat green shard the cutout exists to + * get rid of, at 18% opacity. */ ghostOf(role: SurfaceRole): SurfaceMaterial { const hit = this.ghosts.get(role); @@ -234,6 +344,16 @@ export class MaterialRegistry { const ghost = this.get(role).clone(); ghost.name = `${role}:ghost`; ghost.map = null; + // The relief goes with the colour map and for the same reason. It also has + // to: a normal map on a surface that is 82% see-through is a lighting cue + // for a surface nobody is being asked to look at. + if ("normalMap" in ghost) ghost.normalMap = null; + // A ghost is a hint, not a window. Leaving transmission on would put the + // occlusion fade — which exists to be cheap and is redrawn as the camera + // moves — through the transmission pass and its render-target copy. + if ("transmission" in ghost) { + (ghost as THREE.MeshPhysicalMaterial).transmission = 0; + } ghost.transparent = true; ghost.opacity = 0.18; ghost.depthWrite = false; @@ -257,6 +377,38 @@ export class MaterialRegistry { return made; } + /** + * A role drawn with a different layout of its own texture. + * + * Only `screenContent` has more than one today (`SCREEN_UI_VARIANTS` of them), + * and this exists because of a constraint one layer up rather than a wish for + * variety: `furnish.ts` batches props per kind and draws `ctx.rand` **once per + * kind**, so a screen asset cannot roll for a layout per instance. Variety has + * to arrive as a parameter, from a pack authoring separate batches, which + * means it has to arrive as a separate material — one material per layout, all + * of them cached here, and the draw-call cost is one call per layout actually + * used rather than one per screen. + * + * `color` is optional so the common case reads `variant(role, n)`; pass one to + * get a tinted layout, which is the same shape `tinted` offers. + */ + variant(role: SurfaceRole, variant: number, color?: number): SurfaceMaterial { + const texture = ROLE_SPECS[role].texture; + const count = texture ? this.textures.variants(texture) : 1; + const index = count <= 1 ? 0 : (((variant % count) + count) % count) | 0; + const hue = color ?? this.palette[role]; + // Variant 0 with the role's own colour *is* the base material. Minting a + // second identical one would be a second draw call for the same picture. + if (index === 0 && color === undefined) return this.get(role); + const key = `${role}:${hue.toString(16)}:${index}`; + const hit = this.tints.get(key); + if (hit) return hit; + const made = this.create(role, hue, index); + made.name = key; + this.tints.set(key, made); + return made; + } + /** * Turn an authored `SurfaceId` into a role. Unknown ids give `fallback`. * @@ -276,27 +428,65 @@ export class MaterialRegistry { return this.get(this.resolve(surface, fallback)); } - private create(role: SurfaceRole, color: number): SurfaceMaterial { + private create(role: SurfaceRole, color: number, variant = 0): SurfaceMaterial { const spec = ROLE_SPECS[role]; - const map = spec.texture ? this.textures.get(spec.texture) : null; + const map = spec.texture ? this.textures.get(spec.texture, variant) : null; + const alphaMap = spec.alphaTexture ? this.textures.get(spec.alphaTexture) : null; + // Relief comes from the same kind as the colour, and the bin answers `null` + // for the kinds that have none — a whiteboard and a display are flat, and + // `low` quality has no maps at all. No role opts in separately: a surface + // either has a texture or it does not, and asking for the grain without the + // relief that produced it is not a combination worth spelling. + const normalMap = spec.texture ? this.textures.normal(spec.texture) : null; const transparent = spec.opacity !== undefined && spec.opacity < 1; const shared = { color, map, + // `alphaTest` is only set when there is a map to test against. Left on + // with a null `alphaMap` at `low` quality it would test the material's + // flat opacity of 1 against the threshold on every fragment — which + // passes, but compiles a branch into the shader for nothing. + alphaMap, + alphaTest: alphaMap ? (spec.alphaTest ?? 0.5) : 0, side: spec.doubleSided ? THREE.DoubleSide : THREE.FrontSide, transparent, opacity: spec.opacity ?? 1, depthWrite: !transparent, - emissive: spec.glow ? color : 0x000000, + // White rather than the role's colour when the map is doing the emitting: + // `emissive` multiplies `emissiveMap`, so anything but white would tint + // the drawn interface a second time on top of `color` already tinting it. + emissive: spec.emissiveFromMap ? 0xffffff : spec.glow ? color : 0x000000, + emissiveMap: spec.emissiveFromMap ? map : null, emissiveIntensity: spec.glow ?? 0, }; + // `low` is flat Lambert: no maps, no roughness, no transmission. The + // `normalMap` is not merely unused there — `MeshLambertMaterial` does have + // one, but the whole point of `low` is to compile the cheap shader. if (this.quality === "low") return new THREE.MeshLambertMaterial(shared); - return new THREE.MeshStandardMaterial({ + + const physical = { ...shared, + normalMap, roughness: spec.roughness, metalness: spec.metalness, + }; + + if (spec.transmission === undefined) return new THREE.MeshStandardMaterial(physical); + return new THREE.MeshPhysicalMaterial({ + ...physical, + transmission: spec.transmission, + ior: spec.ior ?? 1.5, + thickness: spec.thickness ?? 0.01, + // Transmission carries the see-through, so the blend must not do it a + // second time. Left transparent at 0.22 the sheet would be four fifths + // invisible *and* refracting the fifth that was left, which reads as a + // smear rather than as glass. `depthWrite` stays false regardless — see + // the note on the role. + transparent: false, + opacity: 1, + depthWrite: false, }); } diff --git a/src/assets/office/common.ts b/src/assets/office/common.ts index ee08a90..6a59203 100644 --- a/src/assets/office/common.ts +++ b/src/assets/office/common.ts @@ -40,11 +40,17 @@ * the refusal as "skip this material", and the result is not an error but a * chair with no shell on it — which is a lot harder to notice than a crash. * - * In practice: `roundedBox` is an `ExtrudeGeometry` and carries no index, while - * every other part in `parts.ts` does. So a material is a rounded material or a - * boxy one, and where that forces a choice the honest fix is to move the part - * to the material it belongs to anyway — a task chair's arm pads are upholstery - * as readily as they are shell. + * In practice: `roundedBox` and `roundedBoxOf` are `ExtrudeGeometry` and carry + * no index, while every other part in `parts.ts` does. So a material is a + * rounded material or a boxy one, and where that forces a choice the honest fix + * is to move the part to the material it belongs to anyway — a task chair's arm + * pads are upholstery as readily as they are shell. + * + * `slab`'s `chamfer` option is the one place that rule had to be worked around + * rather than worked with, and `nonIndexed` below is how. A chamfered desktop + * wants an extruded body *and* a metric top face in the same material, and those + * two are on opposite sides of the rule; converting the quad is four vertices of + * cost and keeps the whole desktop in one merge. * * ### Light fixtures emit no light * @@ -54,10 +60,35 @@ * shadow-casting lights, the end of the frame budget. */ +import * as THREE from "three"; import { tintFor, type AssetContext } from "../kit.ts"; import type { SurfaceMaterial, SurfaceRole } from "../materials.ts"; import type { MeshBin } from "../parts.ts"; +/** + * A de-indexed copy of one of the shared parts, memoised per source geometry. + * + * `mergeGeometries` refuses a mixture of indexed and non-indexed inputs, so a + * material carrying an `ExtrudeGeometry` cannot also carry a `PlaneGeometry`. + * Rather than give up either the chamfer or the metric UVs, the quad is + * converted once and cached. + * + * A `WeakMap` rather than a `Map` because the key is a geometry owned by + * `PartBin`: if the bin is ever disposed and rebuilt, the derived copies become + * unreachable with their sources instead of pinning a disposed buffer forever. + */ +const NON_INDEXED = new WeakMap(); + +function nonIndexed(source: THREE.BufferGeometry): THREE.BufferGeometry { + if (source.getIndex() === null) return source; + const hit = NON_INDEXED.get(source); + if (hit) return hit; + const made = source.toNonIndexed(); + made.name = `${source.name || "part"}:flat`; + NON_INDEXED.set(source, made); + return made; +} + /** * The material for the one part of an asset that answers to `Prop.colorKey` — * a chair's fabric, a locker's doors, a rug's pile. Every asset names its @@ -84,16 +115,59 @@ export function tintable(ctx: AssetContext, role: SurfaceRole): SurfaceMaterial * two different materials (see the UV note in `parts.ts`). The quad sits 0.6 mm * proud of the box so the two never z-fight. * + * ### `chamfer`, and why three millimetres is worth a whole code path + * + * A sharp arris is the single most reliable tell that a surface was rendered + * rather than built. Every real desktop, worktop and shelf board has a small + * radius on its edge, and what that radius does is catch a *line* of specular + * highlight along the whole length of the board — one bright edge that separates + * the top from the front and tells you where the object stops. Without it a + * desktop and the wall behind it meet in a hard colour change and the desk reads + * as a decal. + * + * Three to six millimetres is the range; past that it starts reading as a + * moulded plastic table. `roundedBoxOf` takes the radius in metres and applies it + * after the proportions are known, which is the only way to get a circular + * corner on a board that is fifty times wider than it is thick. + * + * It is opt-in rather than the default because it changes the primitive class of + * the whole material (see the header): every other part an asset draws in the + * same material has to become an extrusion too, and for a shelf board carrying + * books that trade is not worth making. + * * `y` is the underside of the slab. */ export function slab( bin: MeshBin, ctx: AssetContext, material: SurfaceMaterial, - s: { x?: number; y: number; z?: number; width: number; depth: number; thickness: number }, + s: { + x?: number; + y: number; + z?: number; + width: number; + depth: number; + thickness: number; + /** Edge radius in metres. 0.003–0.006 for a board; omit for a sharp edge. */ + chamfer?: number; + }, ): void { const x = s.x ?? 0; const z = s.z ?? 0; + const chamfer = s.chamfer ?? 0; + if (chamfer > 0) { + bin.add(ctx.parts.roundedBoxOf(s.width, s.thickness, s.depth, chamfer), material, { + x, + y: s.y, + z, + }); + bin.add(nonIndexed(ctx.parts.metricQuad(s.width - chamfer * 2, s.depth - chamfer * 2)), material, { + x, + y: s.y + s.thickness + 0.0006, + z, + }); + return; + } bin.add(ctx.parts.box(), material, { x, y: s.y, diff --git a/src/assets/office/desks.ts b/src/assets/office/desks.ts index cc380fe..ed6836c 100644 --- a/src/assets/office/desks.ts +++ b/src/assets/office/desks.ts @@ -46,11 +46,15 @@ export const deskWorkstation = defineAsset({ const frame = ctx.materials.get("deskFrame"); const deckY = p.height - TOP_THICKNESS; + // 4 mm on the edge of the desktop. `deskSurface` is used by nothing else in + // this asset, which is what makes the chamfer affordable here — see the + // primitive-class note on `slab`. slab(bin, ctx, ctx.materials.get("deskSurface"), { y: deckY, width: p.width, depth: p.depth, thickness: TOP_THICKNESS, + chamfer: 0.004, }); const legX = p.width / 2 - 0.09; diff --git a/src/assets/office/devices.ts b/src/assets/office/devices.ts new file mode 100644 index 0000000..6c84df2 --- /dev/null +++ b/src/assets/office/devices.ts @@ -0,0 +1,467 @@ +/** + * The two pieces of hardware the smart-device layer drives: a desk microphone + * and a desk monitor speaker. + * + * These are not props with a light glued on. They are the *instruments* the + * studio simulation observes and commands — a mic that can be muted and gained, + * a speaker that can be turned up and played through — so they are modelled to + * be looked at from the distance somebody sits from their own desk, which is + * about sixty centimetres. At that range a speaker with a painted-on grille is a + * lie you can see, which is why the grille here is slats with gaps between them + * and the driver is a real cone in a real surround. + * + * ### The id convention is load-bearing + * + * `:device..` — `tera:device.mic.desk`, + * `tera:device.speaker.desk`. `deviceKindOfAssetId()` in `src/devices/types.ts` + * parses the kind straight back out of the id, and the device API refuses a + * declaration whose asset kind disagrees with its declared kind. That is what + * lets a self-hoster register `acme:device.mic.boom` and have it read as a + * microphone for free, with no table to edit and no fork; and it is why anything + * not matching the pattern is treated as "not device hardware" rather than as a + * silently mis-typed device. + * + * ### Every device exposes a sub-object named `indicator` + * + * The device render layer (`src/interiors/devices.ts`) tints one part of each + * device per state — powered, muted, idle — by swapping the material on it with + * `materials.tinted("deviceIndicator", colour)`, which reaches both `color` and + * `emissive`. It finds that part by **name**, not by material and not by index: + * a name survives a self-hoster's override, a material does not (two devices + * sharing `deviceIndicator` would both light up), and an index does not survive + * anybody adding a part. + * + * So the contract is exactly: `object.getObjectByName("indicator")` returns a + * group holding the LED and nothing else. It is a separate group rather than a + * separate mesh because `MeshBin.build` names its meshes after their material, + * and the layer above should not have to know what material an LED happens to + * be made of. + * + * ### Roles, and the primitive-class rule + * + * `deviceShell` is the moulded housing, `deviceMesh` the perforated parts — + * grille, basket, windscreen — and `deviceIndicator` the LED. `common.ts` warns + * that every part under one material must be all-indexed or all-non-indexed or + * `mergeGeometries` silently drops it; both assets here are built entirely from + * indexed primitives (`box`, `cylinder`, `rod`, `cone`, `sphere`, `disc`), which + * makes that rule impossible to break by accident rather than merely remembered. + * + * Neither takes a `colorKey`. A studio microphone is the colour a studio + * microphone is, and the one part that changes colour changes it because of + * *state*, which is the device layer's business and not a pack's. + */ + +import * as THREE from "three"; +import { defineAsset, type AssetContext, type AssetId } from "../kit.ts"; +import { MeshBin } from "../parts.ts"; + +/** The built-in device ids, in the order `src/devices` expects to find them. */ +export const DEVICE_ASSET_IDS: readonly AssetId[] = [ + "tera:device.mic.desk", + "tera:device.speaker.desk", +]; + +/** The name the device render layer looks up to find the tintable LED. */ +export const DEVICE_INDICATOR_NAME = "indicator"; + +/** + * Assemble a device: its static hardware, plus the one sub-object that changes + * colour, under the agreed name. + * + * The LED bin is built with shadows off in both directions. A four-millimetre + * emissive dot has nothing meaningful to cast and nothing meaningful to receive, + * and leaving it in the shadow pass costs a draw call in every cascade for a + * part that is smaller than a shadow-map texel at any sensible resolution. + */ +function deviceGroup(name: string, hardware: MeshBin, led: MeshBin): THREE.Group { + const group = new THREE.Group(); + group.name = name; + group.add(hardware.build(name)); + const indicator = led.build(DEVICE_INDICATOR_NAME, { + castShadow: false, + receiveShadow: false, + }); + indicator.name = DEVICE_INDICATOR_NAME; + group.add(indicator); + return group; +} + +/** + * A ring of `count` short bars around the Y axis at `radius`. + * + * Used for the shock mount's elastic suspension and for the speaker's driver + * surround. Worth a helper rather than three copies because getting the local + * yaw wrong produces a ring of bars that all face the same way, which reads as a + * mistake rather than as a detail. + */ +function ringOfBars( + bin: MeshBin, + ctx: AssetContext, + material: Parameters[1], + r: { + count: number; + radius: number; + y: number; + size: [number, number, number]; + pitch?: number; + phase?: number; + }, +): void { + for (let i = 0; i < r.count; i++) { + const yaw = (i / r.count) * Math.PI * 2 + (r.phase ?? 0); + bin.add(ctx.parts.box(), material, { + x: Math.sin(yaw) * r.radius, + z: Math.cos(yaw) * r.radius, + y: r.y, + size: r.size, + yaw, + pitch: r.pitch ?? 0, + }); + } +} + +// ---- Microphone ----------------------------------------------------------- + +type MicParams = { + /** Length of the capsule body itself, metres. A large-diaphragm condenser. */ + bodyLength: number; + /** Diameter of the weighted desk base. */ + baseDiameter: number; + /** Floor of the base to the centre of the capsule. */ + standHeight: number; + /** Radians the capsule leans back from vertical, toward the person at +Z. */ + tilt: number; +}; + +/** + * A shock-mounted desk condenser. + * + * Four things carry it, in the order they matter at desk distance: + * + * 1. **The shock mount.** A microphone hanging inside a ring on visible elastic + * is the single most recognisable thing about a studio desk, and it is the + * part that says "this is a real microphone" before any of the rest resolves. + * 2. **The basket.** A grille with gaps in it, not a painted cylinder — eight + * vertical wires and two hoops, in the double-sided `deviceMesh` role, so you + * see through it to the shaded inside of the head at a glancing angle. + * 3. **The windscreen**, as a foam sphere pushed over the top of the basket. It + * is what breaks the hard cylinder silhouette. + * 4. **The mute LED**, on the front of the body under the basket, where the + * hardware button is on almost every one of these. + * + * Faces −Z at yaw zero like everything else, which puts the front of the capsule + * and the LED at +Z, toward the person (`common.ts`). + */ +export const deviceMicDesk = defineAsset({ + id: "tera:device.mic.desk", + label: "Desk microphone", + defaults: { bodyLength: 0.09, baseDiameter: 0.12, standHeight: 0.21, tilt: 0.16 }, + + footprint(p) { + // The shock mount is wider than the base and the capsule leans out of it, so + // the footprint is the ring's swept width rather than the base's diameter. + // 1.5 × the body, measured off the built mesh rather than guessed: the ring + // is `bodyLength × 0.66` in radius and the suspension bars stick out past it, + // and the foam windscreen is wider again than the basket it is pushed over. + const ring = p.bodyLength * 1.5; + const height = p.standHeight + p.bodyLength * 0.95 + 0.05; + return { width: Math.max(p.baseDiameter, ring), depth: ring + 0.04, height, clearance: 0.12 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const led = new MeshBin(); + const shell = ctx.materials.get("deviceShell"); + const grille = ctx.materials.get("deviceMesh"); + const metal = ctx.materials.get("metalTrim"); + + // ---- The weighted base. Two discs and a fillet: a microphone base is heavy + // and low, and a straight cylinder reads as a cotton reel. + bin.add(P.cylinder(20), shell, { size: [p.baseDiameter, 0.014, p.baseDiameter] }); + bin.add(P.cylinder(20), shell, { + y: 0.014, + size: [p.baseDiameter * 0.82, 0.012, p.baseDiameter * 0.82], + }); + bin.add(P.cylinder(16), metal, { + y: 0.026, + size: [p.baseDiameter * 0.34, 0.01, p.baseDiameter * 0.34], + }); + + // ---- The post, and the yoke that carries the ring. + const ringY = p.standHeight; + bin.add(P.rod(), metal, { y: 0.03, size: [0.014, ringY - 0.03 - 0.01, 0.014] }); + const ringR = p.bodyLength * 0.66; + for (const sx of [-1, 1]) { + bin.add(P.box(), metal, { + x: sx * ringR * 0.7, + y: ringY - 0.055, + size: [0.008, 0.075, 0.008], + roll: sx * 0.32, + }); + } + + // ---- The shock-mount ring. Twelve short bars around the circle rather than + // a torus: `parts.ts` has no torus, and twelve boxes is both cheaper and, at + // this size, indistinguishable from one. + ringOfBars(bin, ctx, metal, { + count: 12, + radius: ringR, + y: ringY - 0.006, + size: [ringR * 0.58, 0.012, 0.009], + pitch: 0, + }); + // The elastic. Six lines from the ring in to the body, alternating up and + // down the capsule the way a real suspension is strung. + for (let i = 0; i < 6; i++) { + const yaw = (i / 6) * Math.PI * 2 + 0.26; + const lift = i % 2 === 0 ? 0.022 : -0.022; + bin.add(P.box(), metal, { + x: (Math.sin(yaw) * ringR) / 2, + z: (Math.cos(yaw) * ringR) / 2, + y: ringY + lift * 0.5, + size: [ringR, 0.004, 0.004], + yaw: yaw + Math.PI / 2, + roll: lift > 0 ? 0.42 : -0.42, + }); + } + + // ---- The capsule. Built about the ring's centre and tilted back, so the + // whole head — body, basket, windscreen, LED — leans as one piece. + const tilt = p.tilt; + const dia = p.bodyLength * 0.54; + const lean = (d: number): { y: number; z: number } => ({ + y: Math.cos(tilt) * d, + z: Math.sin(tilt) * d, + }); + + const bodyBase = lean(-p.bodyLength * 0.5); + bin.add(P.cylinder(18), shell, { + y: ringY + bodyBase.y, + z: bodyBase.z, + size: [dia, p.bodyLength * 0.62, dia], + pitch: -tilt, + }); + // A collar where the body meets the head. Every one of these has one and it + // is what stops the capsule reading as a single extruded tube. + const collar = lean(p.bodyLength * 0.1); + bin.add(P.cylinder(18), metal, { + y: ringY + collar.y, + z: collar.z, + size: [dia * 1.08, 0.006, dia * 1.08], + pitch: -tilt, + }); + + // ---- The basket: two hoops and eight wires, with a dome on top. + const headBase = lean(p.bodyLength * 0.12); + const headLength = p.bodyLength * 0.5; + const headDia = dia * 1.12; + for (const at of [0.12, 0.62]) { + const hoop = lean(p.bodyLength * (0.12 + at * 0.5)); + bin.add(P.cylinder(18), metal, { + y: ringY + hoop.y, + z: hoop.z, + size: [headDia * 1.02, 0.004, headDia * 1.02], + pitch: -tilt, + }); + } + for (let i = 0; i < 8; i++) { + const yaw = (i / 8) * Math.PI * 2; + // The wires stand in the head's own tilted frame, so they are placed at + // the head's base and rotated with it rather than around the world Y. + const offset = headDia * 0.5; + bin.add(P.box(), grille, { + x: Math.sin(yaw) * offset, + y: ringY + headBase.y + Math.cos(yaw) * offset * Math.sin(tilt), + z: headBase.z + Math.cos(yaw) * offset * Math.cos(tilt), + size: [0.0035, headLength, 0.0035], + pitch: -tilt, + }); + } + const domeAt = lean(p.bodyLength * 0.12 + headLength); + bin.add(P.sphere(14), grille, { + y: ringY + domeAt.y - headDia * 0.25, + z: domeAt.z, + size: [headDia, headDia * 0.5, headDia], + pitch: -tilt, + }); + + // ---- The foam windscreen, pushed over the basket. Slightly bigger than the + // head and slightly squashed, because foam is. + const foamAt = lean(p.bodyLength * 0.3); + bin.add(P.sphere(16), grille, { + y: ringY + foamAt.y - headDia * 0.62, + z: foamAt.z, + size: [headDia * 1.24, headDia * 1.34, headDia * 1.24], + pitch: -tilt, + }); + + // ---- The mute LED, on the front of the body below the basket. + const ledAt = lean(-p.bodyLength * 0.12); + led.add(P.cylinder(10), ctx.materials.get("deviceIndicator"), { + y: ringY + ledAt.y, + z: ledAt.z + dia * 0.5, + size: [0.008, 0.003, 0.008], + pitch: Math.PI / 2 - tilt, + }); + + return deviceGroup("device.mic.desk", bin, led); + }, +}); + +// ---- Speaker -------------------------------------------------------------- + +type SpeakerParams = { + width: number; + height: number; + depth: number; + /** Slats across the grille. Fewer reads as a radiator, more as a solid panel. */ + slats: number; +}; + +/** + * A compact powered monitor speaker. + * + * The grille is the reason this asset exists in geometry rather than in a + * texture. `deviceMesh` is double-sided (`materials.ts`) precisely so that the + * gaps between the slats show the shaded inside of the cabinet behind them, and + * that parallax — slats in front, driver behind, dark cabinet behind that — is + * the entire difference between a speaker and a box with stripes on it. It costs + * a dozen boxes. + * + * The bass port is a real hole in the same sense: a recessed dark tube on the + * front baffle rather than a black circle drawn on it. + * + * The cabinet is `roundedBoxOf` at its finished size, so the 4 mm chamfer round + * its edges is genuinely circular instead of an ellipse stretched by a + * non-uniform scale — which is the whole reason that method exists. + */ +export const deviceSpeakerDesk = defineAsset({ + id: "tera:device.speaker.desk", + label: "Desk monitor speaker", + defaults: { width: 0.14, height: 0.22, depth: 0.17, slats: 11 }, + + footprint(p) { + // The isolation pad under it is a touch wider than the cabinet, and the + // grille stands a few millimetres proud of the baffle at +Z. + return { + width: p.width + 0.012, + depth: p.depth + 0.018, + height: p.height + 0.016, + clearance: 0.1, + }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const led = new MeshBin(); + const shell = ctx.materials.get("deviceShell"); + const mesh = ctx.materials.get("deviceMesh"); + const metal = ctx.materials.get("metalTrim"); + + // ---- The isolation pad. A monitor on a desk stands on foam, and the 16 mm + // of shadow under the cabinet is what stops it looking stuck to the desktop. + const pad = 0.014; + bin.add(P.box(), ctx.materials.get("upholstery"), { + y: 0, + size: [p.width + 0.012, pad, p.depth + 0.006], + pitch: 0, + }); + + // ---- The cabinet, and the baffle recessed into its front face. + bin.add(P.roundedBoxOf(p.width, p.height, p.depth, 0.005), shell, { y: pad, size: 1 }); + const baffleZ = p.depth / 2 - 0.008; + bin.add(P.roundedBoxOf(p.width - 0.014, p.height - 0.014, 0.01, 0.004), shell, { + y: pad + 0.007, + z: baffleZ - 0.004, + size: 1, + }); + + // ---- The woofer: surround ring, cone, dust cap. The cone points at the + // person, so it is a `cone()` rolled over — its base is its wide end. + const wooferY = pad + p.height * 0.36; + const wooferR = Math.min(p.width * 0.36, p.height * 0.24); + bin.add(P.cylinder(20), metal, { + y: wooferY, + z: baffleZ, + size: [wooferR * 2.2, 0.006, wooferR * 2.2], + pitch: Math.PI / 2, + }); + bin.add(P.cone(20), mesh, { + y: wooferY, + z: baffleZ - 0.024, + size: [wooferR * 1.9, 0.026, wooferR * 1.9], + pitch: -Math.PI / 2, + }); + // The dust cap rides on `metalTrim` rather than on `deviceShell`: the shell + // is all `roundedBoxOf` in this asset and a sphere is indexed, and mixing the + // two under one material makes `mergeGeometries` drop the whole cabinet. + bin.add(P.sphere(12), metal, { + y: wooferY, + z: baffleZ - 0.004, + size: [wooferR * 0.7, wooferR * 0.5, wooferR * 0.7], + pitch: -Math.PI / 2, + }); + + // ---- The tweeter, in its own shallow waveguide. + const tweeterY = pad + p.height * 0.78; + const tweeterR = wooferR * 0.42; + bin.add(P.cylinder(16), metal, { + y: tweeterY, + z: baffleZ, + size: [tweeterR * 2.6, 0.005, tweeterR * 2.6], + pitch: Math.PI / 2, + }); + bin.add(P.sphere(12), mesh, { + y: tweeterY, + z: baffleZ - 0.002, + size: [tweeterR * 1.6, tweeterR * 1.2, tweeterR * 1.6], + pitch: -Math.PI / 2, + }); + + // ---- The bass port: a tube sunk into the baffle between the drivers. + const portY = pad + p.height * 0.16; + bin.add(P.cylinder(14), mesh, { + y: portY, + z: baffleZ - 0.03, + size: [wooferR * 0.62, 0.034, wooferR * 0.62], + pitch: Math.PI / 2, + }); + + // ---- The grille. Horizontal slats standing proud of the baffle, with real + // gaps between them — the point of the whole asset. + const slats = Math.max(3, Math.round(p.slats)); + const span = p.height - 0.03; + const pitch = span / slats; + for (let i = 0; i < slats; i++) { + bin.add(P.box(), mesh, { + y: pad + 0.015 + i * pitch, + z: baffleZ + 0.004, + size: [p.width - 0.018, pitch * 0.52, 0.004], + }); + } + // Two uprights holding the slats, so the grille is a frame and not a stack. + for (const sx of [-1, 1]) { + bin.add(P.box(), mesh, { + x: (sx * (p.width - 0.018)) / 2, + y: pad + 0.015, + z: baffleZ + 0.004, + size: [0.005, span, 0.005], + }); + } + + // ---- The power LED, bottom-centre under the grille. + led.add(P.cylinder(10), ctx.materials.get("deviceIndicator"), { + y: pad + 0.008, + z: baffleZ + 0.006, + size: [0.006, 0.003, 0.006], + pitch: Math.PI / 2, + }); + + return deviceGroup("device.speaker.desk", bin, led); + }, +}); + +/** Both device assets, for `index.ts` and for anything registering a subset. */ +export const DEVICE_ASSETS = [deviceMicDesk, deviceSpeakerDesk] as const; diff --git a/src/assets/office/greenery.ts b/src/assets/office/greenery.ts index b9a3bfd..5d3b1e1 100644 --- a/src/assets/office/greenery.ts +++ b/src/assets/office/greenery.ts @@ -1,23 +1,131 @@ /** * Plants. A small one for a desk or a sill, and a tall one for a corner. * - * Leaves are single quads in a double-sided `foliage` material rather than - * modelled solids: forty cards is forty quads, a modelled leaf is a hundred - * triangles each, and at the distance an office plant is ever seen the two look - * the same. They are laid out on the golden angle, which is what stops a ring of - * cards from reading as a ring, plus a little jitter from `ctx.rand` — seeded - * per prop, so the plant on the third desk is the same plant on every reload. + * ### The shard problem, and what fixed it * - * Neither takes a `colorKey`. A plant is the colour a plant is. + * Every leaf in here used to be a bare `P.panel()` — an untextured rectangle in + * an opaque green material. That is fine in a thumbnail and catastrophic at eye + * height, and the live LA studio proved it: walk into the courtyard and the + * corner planting fills a third of the frame with flat green shards. It was the + * worst-looking asset in the product and no amount of lighting work was ever + * going to fix it, because the problem is the *silhouette* — a rectangle has the + * wrong outline no matter how it is shaded. + * + * The fix is a coverage map. `foliage` now carries the `leafAlpha` cutout with + * `alphaTest 0.5` (`materials.ts`), so the leaf-shaped part of each quad is + * drawn and the rest is discarded. Three things follow from that, and all three + * are why this is a cutout rather than a blend: + * + * - the leaf still writes depth and still sorts like solid geometry, so a plant + * in front of a window does not have to be drawn in a particular order; + * - the shadow it casts is leaf-shaped, because three's depth material copies + * `alphaMap` and `alphaTest` across; + * - it survives `low` quality, because `leafAlpha` carries a resolution floor. + * "No maps" at low quality is a statement about *shading* cost; a cutout is + * one fetch and a discard, and the alternative at low quality is not a + * cheaper plant, it is the shard again. + * + * ### What the geometry still has to do + * + * A cutout only works if the quad's UVs run along the leaf. `leafAlpha` is drawn + * tip-at-top with the stem at the bottom, so **+V must run from the base of the + * quad to its tip**, which is exactly what `P.panel()` gives (0..1 over a + * standing rectangle, base on the floor) — the panel was never the problem, the + * missing map was. + * + * Beyond that, two changes make a card read as a frond rather than as a + * postcard with a leaf printed on it. Blades are **curved**, by splitting each + * one into two or three cards that each pick up a little more pitch, so the leaf + * arches instead of standing dead straight; and each blade gets a small **roll** + * so it is not edge-on flat to its own stem. Both are geometry the alpha map + * cannot supply, and both cost one extra quad per leaf at most. + * + * Leaves stay cards rather than modelled solids: a modelled leaf is a hundred + * triangles and forty of them is a plant nobody can afford in a room that also + * has furniture in it. + * + * Neither asset takes a `colorKey`. A plant is the colour a plant is. */ -import { defineAsset } from "../kit.ts"; +import { defineAsset, type AssetContext } from "../kit.ts"; +import type { SurfaceMaterial } from "../materials.ts"; import { MeshBin } from "../parts.ts"; import { clamp, jitter } from "./common.ts"; /** ~137.5°, the angle a real plant puts between successive leaves. */ const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); +/** + * One arching blade, as `segments` cards laid end to end. + * + * Exported because `studio.ts` plants a courtyard trough out of the same + * blades: two files drawing foliage two different ways is how a library starts + * looking like two libraries. + * + * Each card starts where the last one ended and carries a little more pitch, so + * the blade curves over instead of leaving in a straight line. The arithmetic is + * the only fiddly part: a card placed with `pitch` has its base at the given + * point and its tip `length` away along the pitched direction, and pitch here + * runs in the *yawed* frame — so `advance` steps along the plant's local −Z + * (outward, in the blade's own yaw) and up by whatever the pitch leaves. + * + * `pitch` is measured from vertical, matching the rest of the file: 0 stands + * straight up and π/2 lies flat. + */ +export function leafBlade( + bin: MeshBin, + ctx: AssetContext, + material: SurfaceMaterial, + b: { + x?: number; + y: number; + z?: number; + /** Total length along the blade. */ + length: number; + /** Width of the widest card. Tapers toward the tip. */ + width: number; + yaw: number; + /** Pitch at the base, radians from vertical. */ + pitch: number; + /** How much further the blade has arched over by its tip. */ + droop: number; + roll?: number; + segments: number; + }, +): void { + const segments = Math.max(1, Math.round(b.segments)); + const step = b.length / segments; + const sin = Math.sin(b.yaw); + const cos = Math.cos(b.yaw); + // Base of the current card, in the plant's own frame. + let x = b.x ?? 0; + let y = b.y; + let z = b.z ?? 0; + + for (let i = 0; i < segments; i++) { + const t = i / segments; + const pitch = b.pitch + b.droop * t * t; + // Cards narrow toward the tip; the last one is roughly half the first. + const width = b.width * (1 - 0.42 * t); + bin.add(ctx.parts.panel(), material, { + x, + y, + z, + size: [width, step, 1], + yaw: b.yaw, + pitch, + roll: b.roll ?? 0, + }); + // Where this card's tip is: `step` along the pitched direction, resolved + // back into the plant's frame through the blade's yaw. + const rise = Math.cos(pitch) * step; + const reach = Math.sin(pitch) * step; + y += rise; + x -= sin * reach; + z -= cos * reach; + } +} + type PottedParams = { /** Overall height including the pot. */ height: number; @@ -49,6 +157,8 @@ export const plantPotted = defineAsset({ y: potH - 0.03, size: [p.potDiameter, 0.03, p.potDiameter], }); + // The soil. A disc rather than nothing: without it you see straight down + // into an open cylinder from the dollhouse camera. bin.add(P.disc(14), pot, { y: potH - 0.012, size: [p.potDiameter * 0.9, 1, p.potDiameter * 0.9], @@ -59,13 +169,16 @@ export const plantPotted = defineAsset({ for (let i = 0; i < count; i++) { const t = i / count; const length = reach * (0.55 + 0.45 * (1 - t)) * (0.85 + ctx.rand() * 0.3); - bin.add(P.panel(), leaf, { + leafBlade(bin, ctx, leaf, { y: potH - 0.02, - size: [length * 0.34, length, 1], + length, + width: length * 0.42, yaw: i * GOLDEN_ANGLE + jitter(ctx.rand, 0.2), - // Outer leaves lean further out; the middle ones stand up. Pitch runs - // in the yawed frame, so this is a lean along whichever way it faces. - pitch: 0.25 + t * 0.8 + jitter(ctx.rand, 0.12), + // Outer leaves lean further out; the middle ones stand up. + pitch: 0.22 + t * 0.7 + jitter(ctx.rand, 0.12), + droop: 0.42 + t * 0.3, + roll: jitter(ctx.rand, 0.2), + segments: 2, }); } @@ -81,8 +194,10 @@ type TallParams = { }; /** Pitch of the lowest whorl and of the highest. The bottom droops, the top stands. */ -const TALL_DROOP = 1.35; -const TALL_CROWN = 0.6; +const TALL_DROOP = 1.2; +const TALL_CROWN = 0.5; +/** Extra arch a blade picks up between its base and its tip. */ +const TALL_ARCH = 0.5; /** * The one piece of arithmetic `footprint` and `build` have to agree on. @@ -92,6 +207,10 @@ const TALL_CROWN = 0.6; * apart. Written the first time, they had — the stated height was a fifth * taller than the plant, because a leaf at 60° from vertical contributes * `cos 60°` of its length and not all of it. + * + * The blades now arch as well as lean, so the effective angle used here is the + * *mid-blade* one — base pitch plus a third of the arch — which is the honest + * average of a curve that starts at one angle and finishes at another. */ function tallCanopy(p: TallParams): { potHeight: number; @@ -102,14 +221,16 @@ function tallCanopy(p: TallParams): { const potHeight = clamp(p.height * 0.26, 0.24, 0.55); const trunk = (p.height - potHeight) * 0.55; const rise = p.height - potHeight - trunk; + const crownAngle = TALL_CROWN + TALL_ARCH / 3; + const droopAngle = TALL_DROOP + TALL_ARCH / 3; // The top whorl starts a third of the way up the canopy and reaches the rest // of the way with the vertical component of one leaf. - const leaf = (rise * 0.66) / Math.cos(TALL_CROWN); + const leaf = (rise * 0.66) / Math.cos(crownAngle); return { potHeight, trunk, leaf, - spread: Math.max(p.potDiameter, 2 * leaf * Math.sin(TALL_DROOP)), + spread: Math.max(p.potDiameter, 2 * leaf * Math.sin(droopAngle)), }; } @@ -162,11 +283,17 @@ export const plantTall = defineAsset({ const y = potH + trunkH + rise * 0.34 * t; const blades = 7 - tier; for (let i = 0; i < blades; i++) { - bin.add(P.panel(), leaf, { + leafBlade(bin, ctx, leaf, { y, - size: [leafLen * 0.26, leafLen * (0.85 + ctx.rand() * 0.3), 1], + length: leafLen * (0.85 + ctx.rand() * 0.3), + width: leafLen * 0.3, yaw: n++ * GOLDEN_ANGLE + jitter(ctx.rand, 0.25), pitch: TALL_DROOP + (TALL_CROWN - TALL_DROOP) * t + jitter(ctx.rand, 0.15), + droop: TALL_ARCH, + roll: jitter(ctx.rand, 0.22), + // Three cards on the long blades of a corner plant: this is the one + // the camera gets closest to, and it is the one that was broken. + segments: 3, }); } } diff --git a/src/assets/office/habitat.ts b/src/assets/office/habitat.ts index 13ccecf..37d3536 100644 --- a/src/assets/office/habitat.ts +++ b/src/assets/office/habitat.ts @@ -163,7 +163,9 @@ export const kitchenRun = defineAsset({ defaults: { width: 3.6, depth: 0.62, counterHeight: 0.91, height: 2.16, bays: 5 }, footprint(p) { - return { width: p.width, depth: p.depth, height: p.height, clearance: 1 }; + // The worktop oversails the carcass by 50 mm at the front, which is what a + // worktop does and what the stated depth was missing. + return { width: p.width, depth: p.depth + 0.05, height: p.height, clearance: 1 }; }, build(p, ctx) { @@ -195,11 +197,14 @@ export const kitchenRun = defineAsset({ size: [0.012, 0.12, 0.012], }); } + // The worktop's front edge is at hand height and is the one edge in a + // kitchen you actually touch, so it gets the full 6 mm. slab(bin, ctx, top, { y: p.counterHeight - 0.045, width: p.width, depth: p.depth + 0.05, thickness: 0.045, + chamfer: 0.006, }); // Backsplash and upper units stop short over the sink to create a focal bay. @@ -282,6 +287,7 @@ export const kitchenIsland = defineAsset({ width: p.length, depth: p.depth, thickness: 0.05, + chamfer: 0.006, }); for (const sx of [-1, 1]) { bin.add(ctx.parts.rod(), ctx.materials.get("metalTrim"), { @@ -304,7 +310,10 @@ export const storageWardrobe = defineAsset({ defaults: { width: 1.8, depth: 0.58, height: 2.18, doors: 3 }, footprint(p) { - return { width: p.width, depth: p.depth, height: p.height, clearance: 0.75 }; + // Doors and pulls stand proud of the carcass at +Z, and the stated depth has + // to include them or a wardrobe pushed flush to a wall puts its handles + // through the plaster. + return { width: p.width, depth: p.depth + 0.03, height: p.height, clearance: 0.75 }; }, build(p, ctx) { @@ -349,7 +358,16 @@ export const seatStool = defineAsset({ defaults: { diameter: 0.4, seatHeight: 0.68, back: true }, footprint(p) { - return { width: p.diameter + 0.1, depth: p.diameter + 0.12, height: p.back ? 0.96 : p.seatHeight, clearance: 0.35 }; + // The back reaches 380 mm above the seat pan, and the height has to be + // derived from `seatHeight` rather than hard-coded: it was 0.96, which was + // right for the default 0.68 seat and 100 mm short of the geometry, and + // wrong by an arbitrary amount for any other seat height a pack asked for. + return { + width: p.diameter + 0.1, + depth: p.diameter + 0.12, + height: p.back ? p.seatHeight + 0.38 : p.seatHeight, + clearance: 0.35, + }; }, build(p, ctx) { @@ -404,7 +422,18 @@ export const lightFloor = defineAsset({ defaults: { height: 1.55, reach: 0.42, shadeDiameter: 0.32 }, footprint(p) { - return { width: Math.max(0.42, p.shadeDiameter), depth: p.reach + p.shadeDiameter / 2, height: p.height }; + // The one asset in the kit that is genuinely not centred on its own origin: + // the base is under the column and the shade cantilevers out to −Z over it. + // The footprint has to enclose both, so it is the reach plus half a shade in + // front and the base's own radius behind — which is 180 mm the stated depth + // used to be missing, and 180 mm is enough to push a floor lamp through a + // wall. + const base = 0.18; + return { + width: Math.max(0.42, p.shadeDiameter), + depth: p.reach + p.shadeDiameter / 2 + base, + height: p.height, + }; }, build(p, ctx) { diff --git a/src/assets/office/index.ts b/src/assets/office/index.ts index 1b8101f..13d6ee2 100644 --- a/src/assets/office/index.ts +++ b/src/assets/office/index.ts @@ -17,14 +17,35 @@ * somebody walks through a wall — leave the collider with no gap where the door * is. * - * Twenty-five assets is not a furniture catalogue and is not trying to be. It is + * Forty assets is not a furniture catalogue and is not trying to be. It is * the set that gets a real floor plate looking like an office: somewhere to * work, somewhere to sit, somewhere to meet, somewhere to put things, something * to look at, something alive, and light. + * + * ### Why the count keeps going up, and why more *instances* would not have done + * + * Fifteen of these arrived at once — thirteen studio props and two devices — and + * the reason is a property of `furnish.ts` rather than an appetite for + * furniture. Props are batched by **(asset, colorKey)** and every instance in a + * batch is geometrically identical, `ctx.rand` included. So a room that looks + * sparse cannot be fixed by placing more of what is already there: ten more + * shelves are the same shelf with the same books on it in ten places. Apparent + * density is a function of how many distinct *kinds* a floor uses, and that + * makes the length of this list the lever a pack author actually has. + * + * The catalogue is now three groups: + * + * - the **office kit** (`desks`, `seating`, `tables`, `storage`, `screens`, + * `lighting`, `surfaces`, `greenery`) — a floor plate of desks and meetings; + * - the **habitat kit** (`habitat.ts`) — a studio home rather than an office; + * - the **studio kit** (`studio.ts`) and the **devices** (`devices.ts`) — a + * working production floor: a courtyard, a robotics lab, a model loft, and + * the two instruments the smart-device layer drives. */ import { kit, type AnyAsset, type AssetRegistry } from "../kit.ts"; import { deskPartition, deskPedestal, deskWorkstation } from "./desks.ts"; +import { DEVICE_ASSETS, deviceMicDesk, deviceSpeakerDesk } from "./devices.ts"; import { plantPotted, plantTall } from "./greenery.ts"; import { bedPlatform, @@ -40,6 +61,22 @@ import { robotOptimus } from "./optimus.ts"; import { screenMonitor, screenWallDisplay } from "./screens.ts"; import { seatLounge, seatTaskChair } from "./seating.ts"; import { storageLocker, storageShelf } from "./storage.ts"; +import { + STUDIO_ASSETS, + acousticBaffle, + benchLab, + benchSlat, + cameraTripod, + canopyParasol, + cartTool, + caseStack, + dividerSlat, + dockRobot, + lightSoftbox, + planterTrough, + rackEquipment, + shelfWall, +} from "./studio.ts"; import { rug, whiteboard } from "./surfaces.ts"; import { tableMeeting, tableSide } from "./tables.ts"; @@ -69,6 +106,8 @@ export const OFFICE_ASSETS: readonly AnyAsset[] = [ storageWardrobe, seatStool, lightFloor, + ...STUDIO_ASSETS, + ...DEVICE_ASSETS, ]; /** Register the built-in catalogue into a registry. Defaults to the shared one. */ @@ -104,4 +143,22 @@ export { storageWardrobe, seatStool, lightFloor, + planterTrough, + benchSlat, + canopyParasol, + benchLab, + rackEquipment, + cartTool, + dockRobot, + caseStack, + lightSoftbox, + cameraTripod, + acousticBaffle, + dividerSlat, + shelfWall, + deviceMicDesk, + deviceSpeakerDesk, }; + +export { DEVICE_ASSET_IDS, DEVICE_INDICATOR_NAME } from "./devices.ts"; +export { STUDIO_ASSET_IDS } from "./studio.ts"; diff --git a/src/assets/office/optimus.ts b/src/assets/office/optimus.ts index bd9aa7b..7b0753b 100644 --- a/src/assets/office/optimus.ts +++ b/src/assets/office/optimus.ts @@ -58,11 +58,25 @@ * darkens their screen surrounds darkens the robot's face, which is the right * coupling rather than a coincidental one. * - * `metalTrim` was tried as a third material for the joint barrels and dropped. - * The office rig carries no environment map, so a `metalness: 0.85` role has - * nothing to reflect and renders as a dull dark grey — indistinguishable from + * `metalTrim` was tried as a third material for the joint barrels and dropped, + * and then taken back up. The original reasoning was sound and is no longer + * true, so it is worth recording both halves rather than quietly deleting one: + * the office rig carried **no environment map**, so a `metalness: 0.85` role had + * nothing to reflect and rendered as a dull dark grey — indistinguishable from * `screenBezel` at ten metres — while costing another mesh in nine of the eleven - * groups. Two materials, eighteen meshes. + * groups. + * + * `engine/environmentRig.ts` now supplies one. A metal barrel therefore picks up + * the room around it and reads as a machined surface rather than as a darker + * patch of plastic, which is the whole difference between "a robot" and "a + * figurine of a robot" at the distance somebody stands next to one. + * + * The cost was re-scoped rather than re-accepted, though. `metalTrim` is used + * **only for the four joint barrels** — hip axle, shoulder, knee, elbow — so it + * appears in four groups rather than nine, and the figure is three materials and + * twenty-two meshes rather than three materials and twenty-seven. The sole, the + * ankle and the visor stay `screenBezel`, because a sole is rubber, an ankle is a + * gap and a visor is glass, and none of the three wants a specular ring on it. * * ### The indexed/non-indexed rule bites here harder than anywhere else * @@ -245,6 +259,12 @@ export interface OptimusRig { interface Skin { shell: SurfaceMaterial; frame: SurfaceMaterial; + /** + * The joint barrels, and nothing else. Kept separate from `frame` so that + * widening its use is a deliberate act with a visible cost in the mesh count + * rather than a one-character change — see the header. + */ + metal: SurfaceMaterial; } /** A point in whichever joint frame the emitter is drawing into. */ @@ -315,7 +335,7 @@ function emitPelvis(bin: MeshBin, P: PartBin, s: Skin): void { // is the gap rule 4 in the header is about and the one the hip did not have. // Shorten it again and the hip goes back to one unbroken pale mass from the // waist to the knee, which is what a mannequin looks like. - barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.115, 2 * OPTIMUS.hipHalf + 0.14); + barrel(bin, P, s.metal, { x: 0, y: 0, z: 0 }, 0.115, 2 * OPTIMUS.hipHalf + 0.14); } /** @@ -385,7 +405,7 @@ function emitTorso(bin: MeshBin, P: PartBin, s: Skin): void { // robot and 80 draw calls for a crowd of four instead of 72. Eight draw // calls for a 24 mm band of dark under a cap that already reads as a // separate piece is not the trade. A fatter drum is free. - barrel(bin, P, s.frame, { x: side * OPTIMUS.shoulderHalf, y: shoulderY, z: 0 }, 0.135, 0.17); + barrel(bin, P, s.metal, { x: side * OPTIMUS.shoulderHalf, y: shoulderY, z: 0 }, 0.135, 0.17); } } @@ -602,7 +622,7 @@ function emitThigh(bin: MeshBin, P: PartBin, s: Skin): void { */ function emitShin(bin: MeshBin, P: PartBin, s: Skin): void { const drop = OPTIMUS.kneeY - OPTIMUS.ankleY; - barrel(bin, P, s.frame, { x: 0, y: 0, z: -0.012 }, 0.118, 0.125); + barrel(bin, P, s.metal, { x: 0, y: 0, z: -0.012 }, 0.118, 0.125); bin.add(P.roundedBox(0.065), s.shell, { y: -drop + 0.02, size: [0.1, drop - 0.05, 0.118] }); @@ -707,7 +727,7 @@ const DIGITS = [ */ function emitForearm(bin: MeshBin, P: PartBin, s: Skin, side: number): void { const drop = OPTIMUS.elbowY - OPTIMUS.wristY; - barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.094, 0.088); + barrel(bin, P, s.metal, { x: 0, y: 0, z: 0 }, 0.094, 0.088); bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.02, size: [0.08, drop - 0.055, 0.088] }); // Wider than the palm below it and narrower than the forearm above, in that // order. At 0.062 it was narrower than both, which put a 6 mm slot of @@ -779,6 +799,7 @@ export function buildOptimus(ctx: AssetContext): OptimusRig { const skin: Skin = { shell: ctx.materials.get("paper"), frame: ctx.materials.get("screenBezel"), + metal: ctx.materials.get("metalTrim"), }; const root = new THREE.Group(); diff --git a/src/assets/office/screens.ts b/src/assets/office/screens.ts index 428256d..12b0bdb 100644 --- a/src/assets/office/screens.ts +++ b/src/assets/office/screens.ts @@ -7,14 +7,110 @@ * `mount` height, because how high a display hangs is a property of the display * and not of the room it is in. * - * Neither takes a `colorKey`. A screen is bezel and glass, and there is no part - * of it that anybody wants to be the colour of a team. + * ### A screen is three surfaces, not one + * + * Both of these used to be a bezel with a flat `screenDisplay` panel glued to + * it, and on the live site every display in the building was one uniform glowing + * rectangle. Under the tone curve `stage.ts` now runs it was worse than uniform: + * a 40%-emissive white panel clips, so a monitor, a wall display and a whiteboard + * were all the same shade of blown-out white. + * + * The fix is to separate the three things a display physically is: + * + * - **`screenBezel`** — the moulded surround, a lit surface like any other; + * - **`screenDisplay`** — the *dark* panel, the black border of glass around the + * active area and what an off screen looks like; + * - **`screenContent`** — the active area, carrying the `screenUI` drawing as + * both `map` and `emissiveMap`. That second binding is the whole point: with + * a flat glow the drawn interface is a pattern printed on a lamp, and with the + * map on `emissiveMap` the lit pixels emit and the dark chrome between them + * does not. It is the difference between a monitor and a light box. + * + * ### Where the layout comes from, and why it cannot be random + * + * `screenUI` draws `SCREEN_UI_VARIANTS` different layouts, and a wall of screens + * showing the same one is the same defect one level down. But an asset cannot + * roll for a layout per instance: `furnish.ts` batches props **per kind** and + * draws `ctx.rand` once for the whole batch, so twelve monitors in one batch + * would roll once between them and get one layout anyway. + * + * So the layout arrives as *authored data*: it is derived from the prop's + * `colorKey`, which is already part of the batch key. A pack that writes + * `colorKey: "ui-b"` on half its monitors gets two batches, two materials and + * two layouts, and gets them deterministically — the same pack renders the same + * wall of screens on every reload. `colorKey` is opaque here in exactly the way + * `ARCHITECTURE.md` §3.3 requires: this file will never learn that `"ui-b"` means + * anything, it only hashes it. + * + * Neither asset tints. A screen is bezel and glass, and there is no part of it + * anybody wants to be the colour of a team. */ -import { defineAsset } from "../kit.ts"; +import { defineAsset, type AssetContext } from "../kit.ts"; import { MeshBin } from "../parts.ts"; import { alongFacing } from "./common.ts"; +/** + * The layout this instance's batch shows, from its `colorKey`. + * + * FNV-1a, the same four lines `furnish.ts` uses to seed a batch, and for the + * same reason: any stable string-to-number would do and this one needs no + * dependency. An absent key gives layout 0 rather than a random one, so a pack + * that says nothing gets the plainest screen rather than an arbitrary one. + */ +function layoutFor(ctx: AssetContext): number { + const key = ctx.colorKey; + if (!key) return 0; + let h = 0x811c9dc5; + for (let i = 0; i < key.length; i++) { + h ^= key.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +/** + * The dark panel and the lit content face, as two quads a fraction of a + * millimetre apart. + * + * The dark one is the full active-area rectangle and the lit one is inset by the + * black border every panel has, so the content never runs to the edge of the + * glass — which is the single cue that separates a screen from a sheet of paper + * with a picture on it. `screenContent` is `DoubleSide`-free and faces +Z, so + * both quads are drawn only from the front. + */ +function displayFace( + bin: MeshBin, + ctx: AssetContext, + face: { + y: number; + z: number; + width: number; + height: number; + pitch?: number; + /** Black border between the glass edge and the drawn content, metres. */ + border: number; + }, +): void { + const pitch = face.pitch ?? 0; + const dark = ctx.materials.get("screenDisplay"); + const lit = ctx.materials.variant("screenContent", layoutFor(ctx)); + + bin.add(ctx.parts.panel(), dark, { + y: face.y, + z: face.z, + size: [face.width, face.height, 1], + pitch, + }); + const proud = alongFacing(pitch, 0.0015); + bin.add(ctx.parts.panel(), lit, { + y: face.y + face.border + proud.y, + z: face.z + proud.z, + size: [face.width - face.border * 2, face.height - face.border * 2, 1], + pitch, + }); +} + type MonitorParams = { /** Bezel width, metres. 0.56 is a 24-inch panel. */ width: number; @@ -40,23 +136,57 @@ export const screenMonitor = defineAsset({ const trim = ctx.materials.get("metalTrim"); const bezel = ctx.materials.get("screenBezel"); - bin.add(P.box(), trim, { size: [p.width * 0.4, 0.016, 0.15] }); + // `metalTrim` here is all `roundedBoxOf` and `screenBezel` is all + // `roundedBoxOf` too — see the indexed/non-indexed rule in `common.ts`. A + // 4 mm chamfer on the foot is what makes it catch a highlight along its edge + // instead of reading as a printed rectangle on the desk. + bin.add(P.roundedBoxOf(p.width * 0.42, 0.014, 0.16, 0.004), trim, { + size: 1, + z: -0.008, + }); // The neck runs a few centimetres past the bottom of the bezel, so the // joint is hidden behind the panel however far it is tilted. - bin.add(P.box(), trim, { y: 0.01, z: -0.02, size: [0.055, p.standHeight + 0.07, 0.045] }); + bin.add(P.roundedBoxOf(0.052, p.standHeight + 0.07, 0.042, 0.008), trim, { + y: 0.01, + z: -0.02, + size: 1, + }); const baseY = p.standHeight + 0.02; const pitch = -p.tilt; - const front = alongFacing(pitch, 0.014); - bin.add(P.roundedBox(0.03), bezel, { + const front = alongFacing(pitch, 0.013); + + // The shell, and a shallower housing behind it. A monitor is not a slab: it + // is a thin panel with the electronics in a bulge behind the middle, and + // that bulge is what its silhouette from three-quarters is made of. + bin.add(P.roundedBoxOf(p.width, p.height, 0.022, 0.006), bezel, { y: baseY, - size: [p.width, p.height, 0.024], + size: 1, pitch, }); - bin.add(P.panel(), ctx.materials.get("screenDisplay"), { - y: baseY + 0.012 + front.y, + const back = alongFacing(pitch, -0.02); + bin.add(P.roundedBoxOf(p.width * 0.6, p.height * 0.55, 0.026, 0.01), bezel, { + y: baseY + p.height * 0.22 + back.y, + z: back.z, + size: 1, + pitch, + }); + + displayFace(bin, ctx, { + y: baseY + 0.011 + front.y, z: front.z, - size: [p.width - 0.018, p.height - 0.026, 1], + width: p.width - 0.016, + height: p.height - 0.024, + pitch, + border: 0.008, + }); + + // Standby light, bottom-right of the chin as it is on almost every panel. + bin.add(P.box(), ctx.materials.get("deviceIndicator"), { + x: p.width * 0.36, + y: baseY + 0.005 + front.y, + z: front.z + 0.002, + size: [0.012, 0.004, 0.004], pitch, }); @@ -85,20 +215,33 @@ export const screenWallDisplay = defineAsset({ build(p, ctx) { const P = ctx.parts; const bin = new MeshBin(); + const bezel = ctx.materials.get("screenBezel"); + // The bracket. Boxes, and `metalTrim` uses nothing else in this asset. bin.add(P.box(), ctx.materials.get("metalTrim"), { y: p.mount + p.height / 2 - 0.16, - z: -0.045, - size: [0.44, 0.32, 0.04], + z: -0.05, + size: [0.44, 0.32, 0.03], }); - bin.add(P.roundedBox(0.02), ctx.materials.get("screenBezel"), { + for (const sx of [-1, 1]) { + bin.add(P.box(), ctx.materials.get("metalTrim"), { + x: sx * 0.19, + y: p.mount + p.height / 2 - 0.16, + z: -0.028, + size: [0.05, 0.3, 0.026], + }); + } + + bin.add(P.roundedBoxOf(p.width, p.height, 0.042, 0.008), bezel, { y: p.mount, - size: [p.width, p.height, 0.05], + size: 1, }); - bin.add(P.panel(), ctx.materials.get("screenDisplay"), { - y: p.mount + 0.014, - z: 0.027, - size: [p.width - 0.024, p.height - 0.028, 1], + displayFace(bin, ctx, { + y: p.mount + 0.012, + z: 0.023, + width: p.width - 0.022, + height: p.height - 0.024, + border: 0.01, }); return bin.build("screen.wall-display"); diff --git a/src/assets/office/storage.ts b/src/assets/office/storage.ts index 7dd97e5..4f42c3f 100644 --- a/src/assets/office/storage.ts +++ b/src/assets/office/storage.ts @@ -40,10 +40,14 @@ export const storageShelf = defineAsset({ const bayH = (p.height - (bays + 1) * BOARD) / bays; const inner = p.width - 2 * BOARD; + // Every part in the `shelf` material is a `roundedBoxOf`, uprights included, + // because the primitive class has to be uniform across a material and a + // chamfered board beside a sharp upright would look like a mistake anyway. + // A 2.5 mm radius: shelf boards are thin and anything larger reads as a + // moulded plastic unit rather than as a board. for (const sx of [-1, 1]) { - bin.add(P.box(), board, { - x: sx * (p.width - BOARD) / 2, - size: [BOARD, p.height, p.depth], + bin.add(P.roundedBoxOf(BOARD, p.height, p.depth, 0.0025), board, { + x: (sx * (p.width - BOARD)) / 2, }); } bin.add(P.box(), ctx.materials.get("cabinet"), { @@ -52,9 +56,8 @@ export const storageShelf = defineAsset({ }); for (let i = 0; i <= bays; i++) { - bin.add(P.box(), board, { + bin.add(P.roundedBoxOf(inner, BOARD, p.depth, 0.0025), board, { y: i * (bayH + BOARD), - size: [inner, BOARD, p.depth], }); } @@ -112,7 +115,11 @@ export const storageLocker = defineAsset({ footprint(p) { // A door has to swing, and a locker with a metre of nothing in front of it // is the difference between a corridor and a corridor you can use. - return { width: p.width, depth: p.depth, height: p.height, clearance: 0.9 }; + // + // The stated depth includes the doors and their pulls, which stand 24 mm + // proud of the carcass at +Z. It did not, and a bank of lockers pushed + // flush to a wall by its own footprint put its handles through the plaster. + return { width: p.width, depth: p.depth + 0.05, height: p.height, clearance: 0.9 }; }, build(p, ctx) { diff --git a/src/assets/office/studio.ts b/src/assets/office/studio.ts new file mode 100644 index 0000000..bce7c69 --- /dev/null +++ b/src/assets/office/studio.ts @@ -0,0 +1,1406 @@ +/** + * The studio kit: twelve props the LA floor needs and the office catalogue did + * not have. + * + * ### Why twelve *kinds* and not more instances + * + * This file exists because of one property of the layer above it. `furnish.ts` + * batches props by **(asset, colorKey)** and every instance in a batch is + * geometrically identical — `ctx.rand` is drawn once per batch, not once per + * prop. The consequence is blunt and it decides what an asset library is for: + * **adding ten more shelves to a room adds nothing the eye can read**, because + * they are the same shelf with the same books on it in ten places. Only a new + * *kind* adds density. + * + * So the answer to "the LA studio looks empty and the SF one does not" is not + * more props, it is more shapes. Twelve of them, chosen by walking the four + * chapters `mateo-court` already declares and asking what is conspicuously + * missing from each: + * + * - **Courtyard** — `planter.trough`, `bench.slat`, `canopy.parasol` + * - **Robotics Lab** — `bench.lab`, `rack.equipment`, `cart.tool`, `dock.robot` + * - **Model Loft** — `light.softbox`, `camera.tripod`, `case.stack` + * - **everywhere** — `acoustic.baffle`, `divider.slat`, `shelf.wall` + * + * The last two are aimed squarely at the two defects the live site shows most + * often: blank untextured wall planes with no trim, and an arrival viewpoint + * looking at the flat side of a corridor. A wall of acoustic panels and a timber + * slat screen are both real studio fittings *and* the cheapest honest way to put + * something on a wall that is currently nothing. + * + * ### Conventions + * + * Everything in here follows `common.ts`: origin on the floor at the centre of + * the footprint, facing −Z at yaw zero, used from +Z, metres. Two assets are + * wall-hung and carry their own `mount` height for the same reason `whiteboard` + * does — how high a panel goes is a property of the panel, not of the room. + * + * And the rule that bites hardest is the same one: every part under one material + * must be all-indexed or all-non-indexed, or `mergeGeometries` silently drops + * the material and you get a bench with no top. Where a rounded edge is worth + * having, the whole material is `roundedBoxOf`; everywhere else the whole + * material is `box`/`cylinder`/`rod`. + */ + +import * as THREE from "three"; +import { defineAsset, type AssetContext, type AssetId } from "../kit.ts"; +import type { SurfaceMaterial } from "../materials.ts"; +import { MeshBin } from "../parts.ts"; +import { clamp, jitter, panelSlab, slab, tintable } from "./common.ts"; +import { leafBlade } from "./greenery.ts"; + +/** + * One leg of a tripod: a bar running from a foot on the floor up to a hub on + * the axis. + * + * Worth a helper because the placement maths is the one thing in this file that + * is genuinely easy to get wrong, and getting it wrong is not obvious — you get + * a stand whose legs are the right length and the wrong angle, and the only + * symptom is a footprint that does not match the geometry. + * + * `Placement` rotates a part about its own **base**, and a part built on + * `box()` runs along +Y from there. Under the `YXZ` euler the pitch is applied + * before the yaw, so +Y goes to `(sinψ·sinθ, cosθ, cosψ·sinθ)`. To send the tip + * from a foot at `(sinψ·R, 0, cosψ·R)` to the hub at `(0, hubY, 0)` the + * direction has to be `(−sinψ·R, hubY, −cosψ·R)` over its own length, so + * `θ = atan2(R, hubY)` and the yaw is **ψ + π** — the leg leans back over the + * axis rather than away from it. The half-angle version of this, with the base + * at the middle of the leg, is what produced a light stand a third wider than it + * said it was. + */ +function tripodLeg( + bin: MeshBin, + ctx: AssetContext, + material: SurfaceMaterial, + leg: { index: number; count: number; radius: number; hubY: number; thickness: number; phase?: number }, +): number { + const yaw = (leg.index / leg.count) * Math.PI * 2 + (leg.phase ?? 0); + const length = Math.hypot(leg.radius, leg.hubY); + bin.add(ctx.parts.box(), material, { + x: Math.sin(yaw) * leg.radius, + z: Math.cos(yaw) * leg.radius, + size: [leg.thickness, length, leg.thickness], + yaw: yaw + Math.PI, + pitch: Math.atan2(leg.radius, leg.hubY), + }); + return yaw; +} + +/** + * Every id this file registers. + * + * Exported as data rather than left implicit because a pack author has to be + * able to see the list without reading twelve builders, and because the test + * that asserts they all build walks this rather than a hand-copied array that + * would drift the first time somebody added a thirteenth. + */ +export const STUDIO_ASSET_IDS: readonly AssetId[] = [ + "tera:planter.trough", + "tera:bench.slat", + "tera:canopy.parasol", + "tera:bench.lab", + "tera:rack.equipment", + "tera:cart.tool", + "tera:dock.robot", + "tera:case.stack", + "tera:light.softbox", + "tera:camera.tripod", + "tera:acoustic.baffle", + "tera:divider.slat", + "tera:shelf.wall", +]; + +// ---- Courtyard ------------------------------------------------------------ + +type TroughParams = { + length: number; + depth: number; + height: number; + /** Shrubs along the run. Each is a small fan of arching blades. */ + clumps: number; +}; + +/** + * A long planted trough — the LA courtyard's edge, and the thing that turns a + * paved rectangle into a garden. + * + * The planting reuses `leafBlade` from `greenery.ts` rather than drawing its own + * cards, so a trough and a corner plant are made of the same leaf with the same + * cutout. Two files inventing foliage separately is how a library stops looking + * like one library. + * + * The vessel is the tintable part: a pack that wants a terracotta courtyard and + * a concrete lobby says so with a `colorKey` and changes nothing else. + */ +export const planterTrough = defineAsset({ + id: "tera:planter.trough", + label: "Planted trough", + defaults: { length: 1.8, depth: 0.44, height: 0.46, clumps: 4 }, + + footprint(p) { + // The planting overhangs the vessel on every side, and it is the planting + // somebody brushes past — so the footprint is the trough plus a leaf, not + // the trough. + return { + width: p.length + 0.24, + depth: p.depth + 0.24, + height: p.height + 0.62, + clearance: 0.4, + }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const vessel = tintable(ctx, "planter"); + const soil = ctx.materials.get("cabinet"); + const leaf = ctx.materials.get("foliage"); + + // `planter` is all `roundedBoxOf` here: a 12 mm round on the top edge of a + // cast trough is what catches the sun along its whole length, and a sharp + // arris on a 1.8 m object reads as a cardboard box. + const wall = 0.05; + bin.add(P.roundedBoxOf(p.length, p.height, p.depth, 0.012), vessel, { size: 1 }); + // The inner void, sunk in from the top, so the walls have thickness. + bin.add(P.box(), soil, { + y: p.height - 0.1, + size: [p.length - wall * 2, 0.1, p.depth - wall * 2], + }); + + const clumps = Math.max(1, Math.round(p.clumps)); + const soilY = p.height - 0.012; + for (let c = 0; c < clumps; c++) { + const x = p.length * ((c + 0.5) / clumps - 0.5); + const blades = 5; + for (let i = 0; i < blades; i++) { + const yaw = (i / blades) * Math.PI * 2 + c * 1.1; + const length = 0.34 + ctx.rand() * 0.3; + leafBlade(bin, ctx, leaf, { + x: x + jitter(ctx.rand, 0.05), + y: soilY, + z: jitter(ctx.rand, 0.05), + length, + width: length * 0.34, + yaw, + pitch: 0.3 + (i / blades) * 0.7, + droop: 0.55, + roll: jitter(ctx.rand, 0.2), + segments: 2, + }); + } + } + + return bin.build("planter.trough"); + }, +}); + +type BenchParams = { + length: number; + depth: number; + seatHeight: number; + /** Slats across the seat. Odd numbers centre a gap, which looks deliberate. */ + slats: number; + back: boolean; +}; + +/** + * A slatted timber bench. Courtyard seating, and the lobby bench nobody sits on. + * + * The slats are separate boards with gaps between them rather than one board + * with lines drawn on it, which matters more here than it sounds: a bench is + * almost always seen from above and slightly to one side, and the gaps are what + * put shadow stripes on the ground under it. + */ +export const benchSlat = defineAsset({ + id: "tera:bench.slat", + label: "Slat bench", + defaults: { length: 1.6, depth: 0.44, seatHeight: 0.44, slats: 5, back: false }, + + footprint(p) { + const height = p.back ? p.seatHeight + 0.42 : p.seatHeight; + return { width: p.length, depth: p.depth, height, clearance: 0.5 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const frame = ctx.materials.get("metalTrim"); + const board = tintable(ctx, "shelf"); + + // Two sled frames. A bench on four separate legs wobbles visually; the + // continuous foot is what makes it read as one object. + const legX = p.length / 2 - 0.14; + for (const sx of [-1, 1]) { + const x = sx * legX; + bin.add(P.box(), frame, { x, size: [0.05, 0.04, p.depth - 0.04] }); + bin.add(P.box(), frame, { x, y: p.seatHeight - 0.06, size: [0.05, 0.05, p.depth - 0.08] }); + for (const sz of [-1, 1]) { + bin.add(P.box(), frame, { + x, + z: (sz * (p.depth - 0.1)) / 2, + size: [0.04, p.seatHeight - 0.06, 0.04], + }); + } + } + bin.add(P.box(), frame, { y: 0.04, size: [legX * 2, 0.04, 0.04] }); + + const slats = Math.max(2, Math.round(p.slats)); + const pitch = (p.depth - 0.06) / slats; + for (let i = 0; i < slats; i++) { + bin.add(P.box(), board, { + y: p.seatHeight - 0.035, + z: -(p.depth - 0.06) / 2 + pitch * (i + 0.5), + size: [p.length, 0.035, pitch * 0.78], + }); + } + + if (p.back) { + const backSlats = Math.max(2, slats - 2); + for (let i = 0; i < backSlats; i++) { + bin.add(P.box(), board, { + y: p.seatHeight + 0.06 + i * 0.1, + z: -(p.depth / 2 - 0.06), + size: [p.length, 0.07, 0.032], + pitch: 0.14, + }); + } + for (const sx of [-1, 1]) { + bin.add(P.box(), frame, { + x: sx * legX, + y: p.seatHeight - 0.02, + z: -(p.depth / 2 - 0.06), + size: [0.04, 0.44, 0.04], + pitch: 0.14, + }); + } + } + + return bin.build("bench.slat"); + }, +}); + +type ParasolParams = { + /** Diameter of the open canopy. */ + spread: number; + /** Floor to the tip of the finial. */ + height: number; + /** Sides of the canopy. Eight is the usual market parasol. */ + panels: number; +}; + +/** + * A courtyard parasol. + * + * The one prop in this file whose job is mostly to be *tall and soft*: an + * outdoor space with nothing above waist height reads as a car park, and a + * canopy at 2.3 m gives the courtyard camera something to frame under. The + * canopy is a faceted cone, which is what a panelled parasol actually is, and + * the ribs run down the seams so the facets read as construction rather than as + * a low-polygon budget. + * + * The fabric is the tintable part. + */ +export const canopyParasol = defineAsset({ + id: "tera:canopy.parasol", + label: "Courtyard parasol", + defaults: { spread: 2.4, height: 2.42, panels: 8 }, + + footprint(p) { + return { width: p.spread, depth: p.spread, height: p.height, clearance: 0.3 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const metal = ctx.materials.get("metalTrim"); + const fabric = tintable(ctx, "partitionFabric"); + const panels = Math.max(4, Math.round(p.panels)); + + // A cast base, which is the only thing stopping this from looking like a + // parasol pushed into a floor. + bin.add(P.cylinder(20), ctx.materials.get("chairBase"), { size: [0.46, 0.05, 0.46] }); + bin.add(P.cylinder(20), ctx.materials.get("chairBase"), { + y: 0.05, + size: [0.34, 0.06, 0.34], + }); + + const canopyDrop = p.spread * 0.24; + const skirtY = p.height - canopyDrop - 0.06; + bin.add(P.rod(), metal, { y: 0.09, size: [0.05, skirtY - 0.09 + 0.1, 0.05] }); + + // The canopy: a cone with its base at the skirt and its apex at the top. + bin.add(P.cone(panels), fabric, { + y: skirtY, + size: [p.spread, canopyDrop, p.spread], + }); + // Ribs along the seams. Each lies in the slant plane, so it is placed at the + // mid-radius and pitched by the canopy's own slope. + const slope = Math.atan2(canopyDrop, p.spread / 2); + const ribLength = Math.hypot(canopyDrop, p.spread / 2); + for (let i = 0; i < panels; i++) { + const yaw = ((i + 0.5) / panels) * Math.PI * 2; + bin.add(P.box(), metal, { + x: (Math.sin(yaw) * p.spread) / 4, + z: (Math.cos(yaw) * p.spread) / 4, + y: skirtY + canopyDrop / 2 - 0.008, + size: [0.018, 0.018, ribLength], + yaw, + // A bar laid along local +Z tips its far end downward under a *positive* + // pitch (rotation about +X sends +Z to −Y), so the sign here is what + // decides whether the canopy has ribs or antennae. + pitch: slope, + }); + } + // The finial, so the pole does not simply stop. + bin.add(P.sphere(10), metal, { y: p.height - 0.07, size: [0.07, 0.07, 0.07] }); + + return bin.build("canopy.parasol"); + }, +}); + +// ---- Robotics lab --------------------------------------------------------- + +type LabBenchParams = { + width: number; + depth: number; + height: number; + /** The perforated tool wall behind the bench. */ + pegboard: boolean; + drawers: number; +}; + +/** + * A robotics workbench: heavy top, boxed frame, drawer bank, and a pegboard + * tool wall behind it. + * + * The tool wall is what makes this a *lab* bench rather than a wide desk. It is + * modelled as a board with real hanging tools on it — six bars and hooks at + * seeded positions — because the alternative, a flat panel, is exactly the blank + * grey plane the live LA studio is already full of. + * + * The tools use `ctx.rand`, which is drawn once per batch: every lab bench in a + * room therefore has the *same* tools in the same places. That is the batching + * price `furnish.ts` documents, it is paid knowingly, and the alternative is one + * geometry per bench. + */ +export const benchLab = defineAsset({ + id: "tera:bench.lab", + label: "Lab workbench", + defaults: { width: 1.9, depth: 0.78, height: 0.92, pegboard: true, drawers: 3 }, + + footprint(p) { + const height = p.pegboard ? p.height + 0.72 : p.height; + return { width: p.width, depth: p.depth, height, clearance: 1 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const frame = ctx.materials.get("deskFrame"); + const carcass = ctx.materials.get("cabinet"); + const top = ctx.materials.get("polishedConcrete"); + const metal = ctx.materials.get("metalTrim"); + + const deckY = p.height - 0.05; + slab(bin, ctx, top, { y: deckY, width: p.width, depth: p.depth, thickness: 0.05 }); + + // A boxed frame rather than four legs. A bench that carries a robot arm has + // visible structure under it, and the diagonal is most of what says so. + const legX = p.width / 2 - 0.07; + const legZ = p.depth / 2 - 0.07; + for (const sx of [-1, 1]) { + for (const sz of [-1, 1]) { + bin.add(P.box(), frame, { + x: sx * legX, + z: sz * legZ, + size: [0.06, deckY, 0.06], + }); + } + bin.add(P.box(), frame, { x: sx * legX, y: 0.12, size: [0.05, 0.05, legZ * 2] }); + // The diagonal, from the bottom of the back leg to the top of the front + // one. Placed at its own base and pitched, exactly as `tripodLeg` + // explains: a bar along +Y under pitch θ lands at + // `(0, L cos θ, L sin θ)`, so θ and L follow from the two ends. + const rise = deckY - 0.14; + const run = legZ * 2; + bin.add(P.box(), frame, { + x: sx * legX, + y: 0.14, + z: -legZ, + size: [0.036, Math.hypot(rise, run), 0.036], + pitch: Math.atan2(run, rise), + }); + } + bin.add(P.box(), frame, { y: 0.12, z: -legZ, size: [legX * 2, 0.05, 0.05] }); + + // The drawer bank, on the left half, fronts at +Z toward the person. + const drawers = Math.max(1, Math.round(p.drawers)); + const bankW = Math.min(0.5, p.width * 0.3); + const bankX = -p.width / 2 + bankW / 2 + 0.1; + const bankH = deckY - 0.16; + bin.add(P.box(), carcass, { + x: bankX, + y: 0.14, + size: [bankW, bankH, p.depth - 0.14], + }); + const front = tintable(ctx, "cabinet"); + const pitch = (bankH - 0.02) / drawers; + for (let i = 0; i < drawers; i++) { + const y = 0.15 + i * pitch; + bin.add(P.box(), front, { + x: bankX, + y, + z: (p.depth - 0.14) / 2, + size: [bankW - 0.02, pitch - 0.01, 0.02], + }); + bin.add(P.box(), metal, { + x: bankX, + y: y + pitch - 0.045, + z: (p.depth - 0.14) / 2 + 0.012, + size: [bankW * 0.5, 0.012, 0.012], + }); + } + + if (!p.pegboard) return bin.build("bench.lab"); + + // The tool wall. Its face is at −Z, against the wall the bench backs onto, + // so its tools hang toward the person at +Z. + const boardY = p.height; + const boardH = 0.68; + const boardZ = -(p.depth / 2 - 0.03); + panelSlab(bin, ctx, carcass, { + y: boardY, + z: boardZ, + width: p.width - 0.1, + height: boardH, + thickness: 0.018, + faces: "front", + }); + for (const sx of [-1, 1]) { + bin.add(P.box(), metal, { + x: (sx * (p.width - 0.1)) / 2, + y: boardY, + z: boardZ, + size: [0.03, boardH, 0.03], + }); + } + // Hanging tools: a rail, then bars of varying length dropped off it. + bin.add(P.box(), metal, { + y: boardY + boardH * 0.62, + z: boardZ + 0.02, + size: [p.width - 0.2, 0.014, 0.014], + }); + for (let i = 0; i < 8; i++) { + const x = (p.width - 0.28) * ((i + 0.5) / 8 - 0.5); + const drop = 0.09 + ctx.rand() * 0.16; + bin.add(P.box(), metal, { + x, + y: boardY + boardH * 0.62 - drop, + z: boardZ + 0.028, + size: [0.018 + ctx.rand() * 0.03, drop, 0.016], + roll: jitter(ctx.rand, 0.06), + }); + } + // A task light under the top shelf of the board. + bin.add(P.box(), ctx.materials.get("lightHousing"), { + y: boardY + boardH - 0.06, + z: boardZ + 0.06, + size: [p.width - 0.3, 0.05, 0.07], + }); + + return bin.build("bench.lab"); + }, +}); + +type RackParams = { + width: number; + depth: number; + height: number; + /** Rack units of equipment showing through the door. */ + units: number; +}; + +/** + * A 19-inch equipment rack. + * + * The vented door is the whole asset. `deviceMesh` is double-sided so the gaps + * between the louvres show the dark inside of the cabinet and the equipment + * faces behind them, and that depth is what separates a rack from a fridge. The + * status LEDs are `accent` rather than `deviceIndicator`: they are decoration + * here, and `deviceIndicator` means "a reading the device layer drives" — using + * it for scenery would make a rack light up when somebody muted a microphone. + */ +export const rackEquipment = defineAsset({ + id: "tera:rack.equipment", + label: "Equipment rack", + defaults: { width: 0.62, depth: 0.9, height: 1.9, units: 7 }, + + footprint(p) { + return { width: p.width, depth: p.depth, height: p.height, clearance: 1 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const carcass = ctx.materials.get("cabinet"); + const metal = ctx.materials.get("metalTrim"); + const louvre = ctx.materials.get("deviceMesh"); + const led = ctx.materials.get("accent"); + + // Castors and plinth. + for (const sx of [-1, 1]) { + for (const sz of [-1, 1]) { + bin.add(P.cylinder(10), metal, { + x: sx * (p.width / 2 - 0.07), + z: sz * (p.depth / 2 - 0.09), + size: [0.06, 0.055, 0.06], + }); + } + } + const plinth = 0.055; + const bodyH = p.height - plinth; + + // Sides, back and top. The front is left open for the door. + for (const sx of [-1, 1]) { + bin.add(P.box(), carcass, { + x: (sx * (p.width - 0.02)) / 2, + y: plinth, + size: [0.02, bodyH, p.depth], + }); + } + bin.add(P.box(), carcass, { + y: plinth, + z: -(p.depth / 2 - 0.01), + size: [p.width, bodyH, 0.02], + }); + bin.add(P.box(), carcass, { + y: p.height - 0.02, + size: [p.width, 0.02, p.depth], + }); + + // The equipment, recessed behind the door plane: faceplates with a handle + // and a row of lights each. + const uH = (bodyH - 0.14) / Math.max(1, Math.round(p.units)); + const units = Math.max(1, Math.round(p.units)); + for (let i = 0; i < units; i++) { + const y = plinth + 0.07 + i * uH; + bin.add(P.box(), ctx.materials.get("screenBezel"), { + y, + z: p.depth / 2 - 0.12, + size: [p.width - 0.06, uH - 0.012, 0.05], + }); + for (const sx of [-1, 1]) { + bin.add(P.box(), metal, { + x: sx * (p.width / 2 - 0.07), + y: y + uH * 0.4, + z: p.depth / 2 - 0.09, + size: [0.03, uH * 0.5, 0.014], + }); + } + for (let k = 0; k < 4; k++) { + bin.add(P.box(), led, { + x: -p.width * 0.18 + k * 0.03, + y: y + uH * 0.3, + z: p.depth / 2 - 0.088, + size: [0.012, 0.008, 0.006], + }); + } + } + + // The louvred door. Horizontal blades with gaps, plus a frame and a handle. + const blades = 22; + const bladePitch = (bodyH - 0.06) / blades; + for (let i = 0; i < blades; i++) { + bin.add(P.box(), louvre, { + y: plinth + 0.03 + i * bladePitch, + z: p.depth / 2 - 0.012, + size: [p.width - 0.05, bladePitch * 0.55, 0.008], + pitch: 0.4, + }); + } + for (const sx of [-1, 1]) { + bin.add(P.box(), metal, { + x: (sx * (p.width - 0.03)) / 2, + y: plinth, + z: p.depth / 2 - 0.012, + size: [0.03, bodyH, 0.02], + }); + } + bin.add(P.box(), metal, { + x: p.width / 2 - 0.06, + y: p.height * 0.5, + z: p.depth / 2 + 0.004, + size: [0.02, 0.16, 0.03], + }); + + return bin.build("rack.equipment"); + }, +}); + +type CartParams = { + width: number; + depth: number; + height: number; + drawers: number; +}; + +/** A rolling tool trolley. The drawer fronts are the tintable part. */ +export const cartTool = defineAsset({ + id: "tera:cart.tool", + label: "Tool cart", + defaults: { width: 0.68, depth: 0.44, height: 0.94, drawers: 5 }, + + footprint(p) { + // The push handle stands above the worktop and the castors stand it off the + // floor, so the overall height is neither `p.height` nor the cabinet's. + return { width: p.width + 0.06, depth: p.depth + 0.06, height: p.height + 0.14, clearance: 0.7 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const carcass = ctx.materials.get("cabinet"); + const metal = ctx.materials.get("metalTrim"); + const front = tintable(ctx, "cabinet"); + + const lift = 0.085; + for (const sx of [-1, 1]) { + for (const sz of [-1, 1]) { + bin.add(P.cylinder(10), metal, { + x: sx * (p.width / 2 - 0.08), + z: sz * (p.depth / 2 - 0.08), + size: [0.07, lift, 0.03], + yaw: sz > 0 ? 0 : 0.3, + }); + } + } + + const bodyH = p.height - lift - 0.03; + bin.add(P.box(), carcass, { y: lift, size: [p.width, bodyH, p.depth] }); + // A rubber-topped worktop with a lip, which is what a tool cart's top is. + bin.add(P.box(), ctx.materials.get("upholstery"), { + y: lift + bodyH, + size: [p.width + 0.02, 0.028, p.depth + 0.02], + }); + bin.add(P.box(), metal, { + y: lift + bodyH + 0.028, + z: -(p.depth / 2), + size: [p.width + 0.02, 0.022, 0.018], + }); + + const drawers = Math.max(1, Math.round(p.drawers)); + const pitch = (bodyH - 0.03) / drawers; + for (let i = 0; i < drawers; i++) { + const y = lift + 0.015 + i * pitch; + bin.add(P.box(), front, { + y, + z: p.depth / 2, + size: [p.width - 0.026, pitch - 0.012, 0.02], + }); + bin.add(P.box(), metal, { + y: y + pitch - 0.05, + z: p.depth / 2 + 0.014, + size: [p.width * 0.62, 0.016, 0.014], + }); + } + + // The push handle, at the −Z end where the person pushing from behind is. + bin.add(P.box(), metal, { + y: lift + bodyH + 0.12, + z: -(p.depth / 2 + 0.02), + size: [p.width * 0.7, 0.022, 0.022], + }); + for (const sx of [-1, 1]) { + bin.add(P.box(), metal, { + x: (sx * p.width * 0.7) / 2, + y: lift + bodyH + 0.03, + z: -(p.depth / 2 + 0.02), + size: [0.022, 0.1, 0.022], + }); + } + + return bin.build("cart.tool"); + }, +}); + +type DockParams = { + width: number; + depth: number; + /** Height of the backboard the robot parks against. */ + height: number; +}; + +/** + * A robot charging dock: a marked floor pad, a backboard and a contact plate. + * + * This is the prop `optimus.ts` needs somewhere to *be* when it is not walking. + * A humanoid standing in the middle of an empty floor reads as a mistake; the + * same humanoid standing on a marked pad reads as a charging robot, and the + * difference is a rectangle of paint and a plate at shoulder height. + * + * The pad's marking is the tintable part, so a pack can colour-code a row of + * docks without touching the hardware. + */ +export const dockRobot = defineAsset({ + id: "tera:dock.robot", + label: "Robot dock", + defaults: { width: 0.9, depth: 0.7, height: 1.9 }, + + footprint(p) { + return { width: p.width, depth: p.depth + 0.1, height: p.height, clearance: 1.2 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const metal = ctx.materials.get("metalTrim"); + const carcass = ctx.materials.get("cabinet"); + const marking = tintable(ctx, "carpetAccent"); + + // The pad: a shallow tray with a painted field inside it. Two quads a + // millimetre apart, so neither z-fights the floor it is laid on. + bin.add(P.box(), carcass, { size: [p.width, 0.012, p.depth] }); + bin.add(P.metricQuad(p.width - 0.06, p.depth - 0.06), marking, { y: 0.0135 }); + for (const sz of [-1, 1]) { + bin.add(P.box(), metal, { + z: (sz * (p.depth - 0.03)) / 2, + size: [p.width, 0.02, 0.03], + }); + } + + // The backboard, at −Z, with the robot facing out of the dock at +Z. + const boardZ = -(p.depth / 2 - 0.04); + panelSlab(bin, ctx, carcass, { + y: 0.012, + z: boardZ, + width: p.width * 0.72, + height: p.height - 0.012, + thickness: 0.06, + faces: "front", + }); + for (const sx of [-1, 1]) { + bin.add(P.box(), metal, { + x: (sx * p.width * 0.72) / 2, + y: 0.012, + z: boardZ, + size: [0.04, p.height - 0.012, 0.08], + }); + } + + // The contact plate at shoulder height, and the cable duct down to the pad. + bin.add(P.box(), metal, { + y: p.height * 0.62, + z: boardZ + 0.05, + size: [p.width * 0.4, 0.16, 0.05], + }); + for (let i = 0; i < 3; i++) { + bin.add(P.box(), ctx.materials.get("accent"), { + x: -p.width * 0.1 + i * 0.1, + y: p.height * 0.62 + 0.05, + z: boardZ + 0.078, + size: [0.05, 0.03, 0.008], + }); + } + bin.add(P.box(), carcass, { + y: 0.012, + z: boardZ + 0.05, + size: [0.1, p.height * 0.62, 0.05], + }); + + return bin.build("dock.robot"); + }, +}); + +type CaseStackParams = { + width: number; + depth: number; + /** Cases in the stack, bottom to top. Each is a little shorter than the last. */ + cases: number; +}; + +/** + * A stack of flight cases. + * + * Studios are full of these and nothing else in the library looks like one. What + * makes a flight case a flight case is the corner armour and the recessed + * latches — eight small parts per case, all in `metalTrim`, against a plain + * tinted body. Without them it is a stack of boxes, which is precisely what the + * asset would otherwise be. + */ +export const caseStack = defineAsset({ + id: "tera:case.stack", + label: "Case stack", + defaults: { width: 0.78, depth: 0.56, cases: 3 }, + + footprint(p) { + const cases = Math.max(1, Math.round(p.cases)); + let height = 0; + for (let i = 0; i < cases; i++) height += 0.34 - i * 0.05; + // Each case is rotated by a few degrees of seeded jitter, so the stack's + // plan is larger than any one case in it. 60 mm covers ±0.05 rad on the + // longest edge plus the latches standing off the front face. + return { width: p.width + 0.06, depth: p.depth + 0.06, height, clearance: 0.5 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const body = tintable(ctx, "cabinet"); + const metal = ctx.materials.get("metalTrim"); + const cases = Math.max(1, Math.round(p.cases)); + + let y = 0; + for (let i = 0; i < cases; i++) { + const h = 0.34 - i * 0.05; + // Each case is a touch smaller than the one below and rotated slightly, + // because nobody stacks road cases square. + const shrink = i * 0.02; + const w = p.width - shrink; + const d = p.depth - shrink; + const yaw = jitter(ctx.rand, 0.05); + + bin.add(P.box(), body, { y, size: [w, h, d], yaw }); + // Corner armour: eight L-shaped blocks, one per vertex, faked as a single + // cube each. At the size a case occupies, an actual L is four times the + // geometry for a shape nobody resolves. + for (const sx of [-1, 1]) { + for (const sz of [-1, 1]) { + for (const sy of [0, 1]) { + bin.add(P.box(), metal, { + x: (sx * (w - 0.05)) / 2, + y: y + sy * (h - 0.05), + z: (sz * (d - 0.05)) / 2, + size: [0.05, 0.05, 0.05], + yaw, + }); + } + } + } + // Latches and a lid seam, on the used face at +Z. + bin.add(P.box(), metal, { y: y + h * 0.6, size: [w, 0.012, d], yaw }); + for (const sx of [-1, 1]) { + bin.add(P.box(), metal, { + x: sx * w * 0.28, + y: y + h * 0.6 - 0.02, + z: (Math.cos(yaw) * d) / 2, + size: [0.07, 0.05, 0.014], + yaw, + }); + } + y += h; + } + + return bin.build("case.stack"); + }, +}); + +// ---- Model loft ----------------------------------------------------------- + +type SoftboxParams = { + /** Floor to the centre of the box. */ + height: number; + width: number; + boxHeight: number; + /** Radians the box tilts down toward the subject at +Z. */ + tilt: number; +}; + +/** + * A studio softbox on a stand. + * + * It emits no light. Like every other luminaire in the library, the office's + * lighting is a fixed rig owned by the scene (CONTRACT.md §4) and what a fitting + * contributes is a glowing `lightDiffuser` you can see. `furnish.ts` recognises + * it as a fitting from the `:light.` in its id, exactly as it does the pendant + * and the troffer, so it brightens with the rest of the room's fittings and + * costs nothing extra to wire. + * + * Its datum is the **floor**, not the ceiling — it is a stand, and `light.floor` + * in `habitat.ts` set that precedent. The two ceiling fittings are the + * exceptions, and they say so. + */ +export const lightSoftbox = defineAsset({ + id: "tera:light.softbox", + label: "Studio softbox", + defaults: { height: 1.85, width: 0.9, boxHeight: 0.68, tilt: 0.32 }, + + footprint(p) { + // The tripod's spread is what somebody trips over, and it is wider than the + // box at the heights a softbox is actually set to. `0.21` is the leg radius + // used in `build`, so the diameter is twice it — the two have to agree, and + // this is the whole of what they have to agree about. + const spread = Math.max(p.width + 0.06, p.height * 0.42); + return { width: spread, depth: spread, height: p.height + p.boxHeight / 2 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const body = new MeshBin(); + const glow = new MeshBin(); + const metal = ctx.materials.get("metalTrim"); + const housing = ctx.materials.get("lightHousing"); + + // Three legs on a hub, which is a light stand. Four would be a table. + const spread = p.height * 0.21; + const hubY = p.height * 0.34; + for (let i = 0; i < 3; i++) { + tripodLeg(body, ctx, metal, { + index: i, + count: 3, + radius: spread, + hubY, + thickness: 0.026, + }); + } + body.add(P.cylinder(12), metal, { y: hubY, size: [0.07, 0.05, 0.07] }); + body.add(P.rod(), metal, { y: hubY, size: [0.032, p.height - hubY, 0.032] }); + body.add(P.cylinder(12), metal, { y: p.height - 0.06, size: [0.06, 0.06, 0.06] }); + + // The box: a shallow reflector behind a diffusion panel. Both are pitched, + // and the diffuser stands a centimetre proud of the housing. + const tilt = p.tilt; + body.add(P.box(), housing, { + y: p.height - p.boxHeight / 2, + z: -0.06, + size: [p.width, p.boxHeight, 0.16], + pitch: tilt, + }); + for (const sx of [-1, 1]) { + body.add(P.box(), housing, { + x: (sx * p.width) / 2, + y: p.height - p.boxHeight / 2, + z: 0.02, + size: [0.02, p.boxHeight, 0.2], + pitch: tilt, + yaw: sx * 0.06, + }); + } + glow.add(P.panel(), ctx.materials.get("lightDiffuser"), { + y: p.height - p.boxHeight / 2, + z: 0.03, + size: [p.width - 0.03, p.boxHeight - 0.03, 1], + pitch: tilt, + }); + + const group = new THREE.Group(); + group.name = "light.softbox"; + group.add(body.build("light.softbox:body")); + group.add(glow.build("light.softbox:glow", { castShadow: false, receiveShadow: false })); + return group; + }, +}); + +type TripodParams = { + /** Floor to the centre of the lens. */ + height: number; + /** Include the little on-board monitor. */ + monitor: boolean; +}; + +/** + * A cinema camera on sticks. + * + * Pointed along −Z like everything else in the library, which for a camera means + * the lens looks the way the prop faces — the same convention a desk and a chair + * follow, so a pack aims a camera the way it aims a person. + * + * The little on-board monitor uses `screenContent` and therefore picks up the + * same `screenUI` drawing every other display in the building does. That is the + * point of a shared role: a monitor on a camera and a monitor on a desk are the + * same kind of object and should not look like two different ideas. + */ +export const cameraTripod = defineAsset({ + id: "tera:camera.tripod", + label: "Camera on tripod", + defaults: { height: 1.52, monitor: true }, + + footprint(p) { + // Twice the leg radius used in `build`, plus the pan bar sticking out behind. + const spread = p.height * 0.48; + return { width: spread, depth: spread + 0.2, height: p.height + 0.24, clearance: 0.8 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const metal = ctx.materials.get("metalTrim"); + const bodyMat = ctx.materials.get("screenBezel"); + + const headY = p.height - 0.14; + const radius = p.height * 0.24; + for (let i = 0; i < 3; i++) { + const yaw = tripodLeg(bin, ctx, metal, { + index: i, + count: 3, + radius, + hubY: headY, + thickness: 0.034, + phase: Math.PI, + }); + // The spreader, a third of the way up, which is what stops a tripod + // reading as three sticks that happen to meet. It sits on the leg line, so + // its own radius is a third of the leg's. + const at = radius * 0.66; + bin.add(P.box(), metal, { + x: Math.sin(yaw) * at, + z: Math.cos(yaw) * at, + y: headY * 0.32, + size: [0.018, 0.018, at], + yaw: yaw + Math.PI / 2, + }); + } + bin.add(P.cylinder(12), metal, { y: headY, size: [0.09, 0.07, 0.09] }); + + // Fluid head and plate. + const plateY = headY + 0.07; + bin.add(P.box(), metal, { y: plateY, size: [0.11, 0.045, 0.19] }); + // The pan bar, sticking out behind at +Z where the operator is. + bin.add(P.rod(), metal, { + y: plateY + 0.02, + z: 0.12, + size: [0.016, 0.34, 0.016], + roll: 0, + pitch: 1.15, + }); + + // The body: a boxy cine camera, not a DSLR. Its mass is behind the lens. + const bodyY = plateY + 0.045; + bin.add(P.box(), bodyMat, { y: bodyY, z: 0.03, size: [0.15, 0.16, 0.28] }); + bin.add(P.box(), bodyMat, { y: bodyY + 0.16, z: 0.06, size: [0.1, 0.05, 0.16] }); + // The lens, on the −Z face, in three stepped barrels. + const lensY = bodyY + 0.08; + bin.add(P.cylinder(18), metal, { + y: lensY, + z: -0.13, + size: [0.11, 0.06, 0.11], + pitch: Math.PI / 2, + }); + bin.add(P.cylinder(18), metal, { + y: lensY, + z: -0.19, + size: [0.095, 0.1, 0.095], + pitch: Math.PI / 2, + }); + bin.add(P.disc(18), ctx.materials.get("glazing"), { + y: lensY, + z: -0.242, + size: [0.082, 1, 0.082], + pitch: -Math.PI / 2, + }); + // Tally light, on the front where the subject can see it. + bin.add(P.box(), ctx.materials.get("accent"), { + y: bodyY + 0.15, + z: -0.108, + size: [0.03, 0.014, 0.01], + }); + + if (p.monitor) { + bin.add(P.box(), bodyMat, { + x: 0.11, + y: bodyY + 0.09, + z: 0.02, + size: [0.11, 0.08, 0.014], + yaw: -0.5, + }); + bin.add(P.panel(), ctx.materials.variant("screenContent", 3), { + x: 0.113, + y: bodyY + 0.095, + z: 0.026, + size: [0.092, 0.062, 1], + yaw: -0.5 + Math.PI, + }); + } + + return bin.build("camera.tripod"); + }, +}); + +// ---- Wall fittings -------------------------------------------------------- + +type BaffleParams = { + /** Total width of the array. */ + width: number; + /** Total height of the array. */ + height: number; + /** Floor to the bottom edge. */ + mount: number; + /** Panels across the array. */ + columns: number; + rows: number; +}; + +/** + * A wall of acoustic panels. + * + * The most direct answer in this file to a defect on the live site: "blank + * white/grey wall planes everywhere, no material variation, no trim". A grid of + * fabric panels standing 45 mm off the wall gives a flat plane a shadow under + * every panel, a material that is not plaster, and — because the panels are + * offset in depth by a seeded amount — a surface that changes as you walk past + * it rather than one that is uniformly grey from every angle. + * + * Authored on the floor with a `mount` height, like `whiteboard`, and its −Z + * face is skipped because it is against a wall. + * + * The fabric is the tintable part, which is the one thing a pack will want to + * change per room. + */ +export const acousticBaffle = defineAsset({ + id: "tera:acoustic.baffle", + label: "Acoustic panels", + defaults: { width: 2.4, height: 1.2, mount: 0.9, columns: 4, rows: 2 }, + + footprint(p) { + return { width: p.width, depth: 0.07, height: p.mount + p.height }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const fabric = tintable(ctx, "partitionFabric"); + const frame = ctx.materials.get("metalTrim"); + + const columns = Math.max(1, Math.round(p.columns)); + const rows = Math.max(1, Math.round(p.rows)); + const cellW = p.width / columns; + const cellH = p.height / rows; + const gap = Math.min(0.03, cellW * 0.08); + + for (let c = 0; c < columns; c++) { + for (let r = 0; r < rows; r++) { + // Seeded depth. 20 to 55 mm is the range real absorbers come in, and it + // is enough that the array has relief without looking damaged. + const depth = clamp(0.02 + ctx.rand() * 0.035, 0.02, 0.055); + panelSlab(bin, ctx, fabric, { + x: -p.width / 2 + cellW * (c + 0.5), + y: p.mount + cellH * r + gap / 2, + z: depth / 2, + width: cellW - gap, + height: cellH - gap, + thickness: depth, + faces: "front", + }); + } + } + // A rail top and bottom, so the array is a fitting rather than panels stuck + // to a wall. + for (const sy of [0, 1]) { + bin.add(P.box(), frame, { + y: p.mount + sy * p.height - (sy === 0 ? 0.016 : 0), + z: 0.012, + size: [p.width, 0.016, 0.024], + }); + } + + return bin.build("acoustic.baffle"); + }, +}); + +type DividerParams = { + width: number; + height: number; + /** Vertical slats across the width. */ + slats: number; + /** Depth of each slat. Deeper slats close the view off at a shallower angle. */ + slatDepth: number; +}; + +/** + * A vertical timber slat screen. + * + * The one asset in the library that is *different depending on where you stand*. + * Head-on you see through it; at a glancing angle the slats overlap and it is a + * wall. That is why it is worth its own kind rather than being a partition with + * a different colour: it does something no other prop in the office does, and it + * is the standard way an open-plan studio divides a room without building one. + * + * It is also the direct answer to the LA arrival viewpoint looking at a bare + * corridor wall — a slat screen at the end of a corridor gives that view depth + * and a reason to walk toward it. + * + * The timber is the tintable part. + */ +export const dividerSlat = defineAsset({ + id: "tera:divider.slat", + label: "Slat divider", + defaults: { width: 2.2, height: 2.1, slats: 22, slatDepth: 0.09 }, + + footprint(p) { + return { width: p.width, depth: p.slatDepth + 0.04, height: p.height, clearance: 0.5 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const timber = tintable(ctx, "shelf"); + const frame = ctx.materials.get("metalTrim"); + + const rail = 0.05; + for (const sy of [0, 1]) { + bin.add(P.box(), frame, { + y: sy * (p.height - rail), + size: [p.width, rail, p.slatDepth + 0.02], + }); + } + for (const sx of [-1, 1]) { + bin.add(P.box(), frame, { + x: (sx * (p.width - 0.04)) / 2, + size: [0.04, p.height, p.slatDepth + 0.02], + }); + } + + const slats = Math.max(2, Math.round(p.slats)); + const pitch = (p.width - 0.12) / slats; + for (let i = 0; i < slats; i++) { + bin.add(P.box(), timber, { + x: -(p.width - 0.12) / 2 + pitch * (i + 0.5), + y: rail, + size: [pitch * 0.5, p.height - rail * 2, p.slatDepth], + }); + } + + return bin.build("divider.slat"); + }, +}); + +type WallShelfParams = { + width: number; + depth: number; + /** Floor to the underside of the lowest board. */ + mount: number; + boards: number; + /** Vertical gap between boards. */ + pitch: number; +}; + +/** + * A wall-hung shelf, on visible brackets. + * + * This is the direct answer to a defect on the live site: "a wall shelf floats + * with no visible bracket". The floor-standing `storage.shelf` has uprights that + * read as support when it is on the floor and read as nothing when a pack raises + * it up a wall with `Prop.elevation` — and because `furnish.ts` builds every + * asset from its **defaults** and never passes a pack's parameters through, a + * `brackets: true` option on the existing shelf would have been unreachable from + * a pack. A wall shelf has to be its own kind or it cannot exist at all. + * + * The bracket is the whole point, so it is a real L: an arm under the board and + * a plate against the wall, with the plate taller than the arm is long, which is + * what makes it look like it is carrying the load rather than resting beside it. + * + * Authored on the floor with a `mount` height, like `whiteboard` and + * `acoustic.baffle`, and its −Z side is against the wall. + * + * The boards are the tintable part. + */ +export const shelfWall = defineAsset({ + id: "tera:shelf.wall", + label: "Wall shelf", + defaults: { width: 1.2, depth: 0.26, mount: 1.05, boards: 2, pitch: 0.38 }, + + footprint(p) { + // The top board carries books, and books are what somebody's head hits: the + // stated height is the tallest thing on the shelf, not the shelf. + const boards = Math.max(1, Math.round(p.boards)); + return { + width: p.width, + depth: p.depth, + height: p.mount + (boards - 1) * p.pitch + 0.032 + 0.26, + clearance: 0.5, + }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const board = tintable(ctx, "shelf"); + const bracket = ctx.materials.get("metalTrim"); + const boards = Math.max(1, Math.round(p.boards)); + const bracketX = Math.max(0.12, p.width / 2 - 0.16); + + for (let i = 0; i < boards; i++) { + const y = p.mount + i * p.pitch; + // `shelf` is all `roundedBoxOf` here for the same reason the desktop is: + // a 3 mm round is what gives a board a bright line along its front edge + // instead of a hard colour change against the wall behind it. + bin.add(P.roundedBoxOf(p.width, 0.032, p.depth, 0.003), board, { y }); + + for (const sx of [-1, 1]) { + const x = sx * bracketX; + // The arm, under the board and stopping 30 mm short of its front edge — + // a bracket flush with the front reads as a second, thinner board. + bin.add(P.box(), bracket, { + x, + y: y - 0.026, + z: 0.015, + size: [0.022, 0.026, p.depth - 0.03], + }); + // The wall plate, taller than the arm is long. + bin.add(P.box(), bracket, { + x, + y: y - 0.16, + z: -(p.depth / 2 - 0.012), + size: [0.026, 0.19, 0.022], + }); + // The gusset between them, which is the part that actually reads as an + // L-bracket from below rather than as two separate bars. + bin.add(P.box(), bracket, { + x, + y: y - 0.14, + z: -(p.depth / 2 - 0.05), + size: [0.018, 0.16, 0.014], + pitch: 0.62, + }); + } + } + + // A few things on the top board, so a shelf reads as storage rather than as + // a ledge. Three materials that already exist in the library, and seeded, so + // every wall shelf in one batch carries the same objects — the price + // `furnish.ts` documents. + const topY = p.mount + (boards - 1) * p.pitch + 0.032; + const spines = [ + ctx.materials.get("paper"), + ctx.materials.get("accent"), + ctx.materials.get("cabinet"), + ]; + let x = -p.width / 2 + 0.06; + while (x < p.width / 2 - 0.1) { + if (ctx.rand() < 0.22) { + x += 0.05 + ctx.rand() * 0.1; + continue; + } + const w = 0.02 + ctx.rand() * 0.036; + const h = 0.16 + ctx.rand() * 0.09; + const material = spines[Math.floor(ctx.rand() * spines.length)] ?? spines[0]; + if (!material) break; + bin.add(P.box(), material, { + x: x + w / 2, + y: topY, + z: 0.01 + jitter(ctx.rand, 0.012), + size: [w, h, clamp(p.depth * 0.62, 0.1, 0.2)], + roll: jitter(ctx.rand, 0.035), + }); + x += w + 0.004; + } + + return bin.build("shelf.wall"); + }, +}); + +/** Every studio asset, in the order `STUDIO_ASSET_IDS` names them. */ +export const STUDIO_ASSETS = [ + planterTrough, + benchSlat, + canopyParasol, + benchLab, + rackEquipment, + cartTool, + dockRobot, + caseStack, + lightSoftbox, + cameraTripod, + acousticBaffle, + dividerSlat, + shelfWall, +] as const; diff --git a/src/assets/office/surfaces.ts b/src/assets/office/surfaces.ts index 7cf8eab..bab3ae2 100644 --- a/src/assets/office/surfaces.ts +++ b/src/assets/office/surfaces.ts @@ -8,7 +8,7 @@ import { defineAsset } from "../kit.ts"; import { MeshBin } from "../parts.ts"; -import { panelSlab, tintable } from "./common.ts"; +import { jitter, panelSlab, tintable } from "./common.ts"; type RugParams = { width: number; @@ -56,6 +56,8 @@ type WhiteboardParams = { /** Floor to the bottom edge of the writing surface. */ mount: number; tray: boolean; + /** Sticky notes and abstract marker strokes on the face. */ + worked: boolean; }; /** @@ -66,11 +68,35 @@ type WhiteboardParams = { * The −Z face is skipped: it is against a wall, and drawing it would put a * second sheet of whiteboard texture into the merge for a surface nobody can * ever see. + * + * ### Why there is anything on it + * + * The live site shows this as "a large blank white rectangle", and it is the + * biggest single flat surface in a meeting room. The `whiteboard` texture draws + * faint ghosting from previous wipes, which is right and is not enough: what + * makes a board read as *used* is objects on it that catch their own light — + * sticky notes standing a millimetre proud, and strokes with a shadow under + * them. + * + * So `worked` adds relief rather than more texture. Two millimetres is enough to + * cast a hairline shadow under the rig's key light, which is what separates a + * note stuck to a board from a coloured rectangle printed on one. + * + * Everything on it is **abstract**: rectangles and strokes, no glyphs, no words, + * no diagrams of anything in particular. That is `ARCHITECTURE.md` §3.1 — + * legible content on a board is either somebody's real work or a convincing + * imitation of it, and neither belongs in an Apache-2.0 repo. Read from two + * metres it says "a team used this room", which is the whole job. + * + * It is seeded from `ctx.rand`, so every board in one batch carries the same + * notes in the same places. That is the price `furnish.ts` documents and it is + * paid knowingly; a pack that wants two different boards authors two batches + * with different `colorKey`s, which is the same seam the screens use. */ export const whiteboard = defineAsset({ id: "tera:whiteboard", label: "Whiteboard", - defaults: { width: 1.8, height: 1.2, mount: 0.9, tray: true }, + defaults: { width: 1.8, height: 1.2, mount: 0.9, tray: true, worked: true }, footprint(p) { return { width: p.width, depth: 0.1, height: p.mount + p.height }; @@ -102,6 +128,77 @@ export const whiteboard = defineAsset({ }); } + if (p.worked) { + // The writing surface, inset from the frame — the same rectangle the + // panel above occupies, minus its border. + const faceX = p.width - 0.14; + const faceY = p.height - 0.14; + const originX = -faceX / 2; + const originY = p.mount + 0.06; + const face = 0.012; + + // Two columns of sticky notes. A grid rather than a scatter, because a + // board that has been worked on has structure on it and a scatter reads as + // confetti. + const notes = ctx.materials.get("accent"); + const pale = ctx.materials.get("paper"); + for (let column = 0; column < 3; column++) { + const rows = 2 + Math.floor(ctx.rand() * 2); + for (let row = 0; row < rows; row++) { + const size = 0.07 + ctx.rand() * 0.02; + bin.add(P.box(), ctx.rand() < 0.55 ? notes : pale, { + x: originX + faceX * (0.62 + column * 0.13) + jitter(ctx.rand, 0.008), + y: originY + faceY * (0.62 - row * 0.19) + jitter(ctx.rand, 0.008), + z: face, + size: [size, size, 0.002], + roll: jitter(ctx.rand, 0.06), + }); + } + } + + // Marker work on the left two thirds: a few boxes and the strokes joining + // them. Thin dark slabs, standing proud enough to catch an edge highlight. + const ink = ctx.materials.get("screenBezel"); + const boxes: [number, number][] = []; + for (let i = 0; i < 3; i++) { + const bx = originX + faceX * (0.1 + i * 0.16); + const by = originY + faceY * (0.28 + (i % 2) * 0.3); + boxes.push([bx, by]); + const w = 0.16 + ctx.rand() * 0.06; + const h = 0.09 + ctx.rand() * 0.03; + // Four strokes rather than a filled rectangle: a drawn box is an + // outline, and a solid one reads as a sticker. + for (const sy of [-1, 1]) { + bin.add(P.box(), ink, { x: bx, y: by + (sy * h) / 2, z: face, size: [w, 0.006, 0.002] }); + } + for (const sx of [-1, 1]) { + bin.add(P.box(), ink, { x: bx + (sx * w) / 2, y: by, z: face, size: [0.006, h, 0.002] }); + } + } + for (let i = 0; i < boxes.length - 1; i++) { + const from = boxes[i]; + const to = boxes[i + 1]; + if (!from || !to) continue; + const dx = to[0] - from[0]; + const dy = to[1] - from[1]; + bin.add(P.box(), ink, { + x: from[0] + dx / 2, + y: from[1] + dy / 2, + z: face, + size: [Math.hypot(dx, dy), 0.005, 0.002], + roll: Math.atan2(dy, dx), + }); + } + // One underlined heading bar across the top, which is what the eye reads + // as "this board has a subject" without anything being legible. + bin.add(P.box(), ink, { + x: originX + faceX * 0.26, + y: originY + faceY * 0.88, + z: face, + size: [faceX * 0.4, 0.008, 0.002], + }); + } + if (p.tray) { bin.add(P.box(), trim, { y: p.mount - 0.03, diff --git a/src/assets/office/tables.ts b/src/assets/office/tables.ts index 8f4417f..df7d3a4 100644 --- a/src/assets/office/tables.ts +++ b/src/assets/office/tables.ts @@ -51,7 +51,16 @@ export const tableMeeting = defineAsset({ return bin.build("table.meeting"); } - slab(bin, ctx, top, { y: deckY, width: p.length, depth: p.width, thickness: TOP }); + // A 5 mm edge radius. A 2.4 m board is the longest specular highlight in the + // room and it is worth having; the round top a few lines above already has a + // circular edge for free, which is why only this branch asks for one. + slab(bin, ctx, top, { + y: deckY, + width: p.length, + depth: p.width, + thickness: TOP, + chamfer: 0.005, + }); if (p.legs === "post") { for (const sx of [-1, 1]) { diff --git a/src/assets/palette.ts b/src/assets/palette.ts index b7d0869..0badd90 100644 --- a/src/assets/palette.ts +++ b/src/assets/palette.ts @@ -33,8 +33,36 @@ import { DEFAULT_PALETTE } from "../engine/terrain.ts"; import type { ScenePalette } from "../engine/types.ts"; import type { SurfaceRole } from "./materials.ts"; -/** How far outside the city's lightness range an interior role may sit. */ -export const LIGHTNESS_HEADROOM = 0.14; +/** + * How far outside the city's lightness range an interior role may sit. + * + * ### Why this is 0.22 and not 0.14 + * + * It moved when `stage.ts` took the renderer off `NoToneMapping`. The old value + * was set against a renderer that clipped at linear 1.0, and under a clipping + * renderer the top of the range is not a range at all — every role above about + * 0.9 albedo, lit by a sun the atmosphere drives past 2.3, displayed as exactly + * the same white. Widening the band would have bought darker darks and, at the + * other end, more roles indistinguishable from each other. So the number was + * held down, and roles that wanted to be genuinely dark — `screenBezel` at + * L≈0.24, `chairBase`, `deviceShell` — were clamped up into a mid-grey they did + * not want to be. + * + * ACES's shoulder gives the top two stops back: linear 1.0, 2.0 and 4.0 now + * display at about 0.90, 0.95 and 0.98 and stay separable all the way up. With + * the top recoverable, the band can be widened for the sake of the bottom + * without the top collapsing, and 0.22 is what lets the darkest roles reach + * L≈0.17 — a charcoal, which is what a screen bezel and a microphone body + * actually are. + * + * One asymmetry worth knowing, since this is documented as one number applied to + * both ends. `bandOf` clamps `maxL` at 1.0, and the city palette's lightest + * entry (`skyHorizon`, L≈0.89) already reached that ceiling at 0.14. So in + * practice this number only ever bites at the dark end. It is still written as + * one number, because the moment it becomes two somebody will tune them + * independently and the band stops meaning anything. + */ +export const LIGHTNESS_HEADROOM = 0.22; /** One role's derivation: a city colour, and how far to move it. */ export interface RoleShift { @@ -103,6 +131,30 @@ export const ROLE_SHIFTS: Record = { lightDiffuser: { from: "skyHorizon", dh: 6, ds: -0.2, dl: 0.1 }, whiteboard: { from: "shore", dh: 8, ds: -0.03, dl: 0.26 }, + // Devices + // + // A desk microphone and a monitor speaker are the two darkest objects in a + // studio and they are dark for a reason that is not styling: a hot LED and a + // level meter have to read against their own body from three metres away. The + // shell therefore goes as far down as the widened band allows (L≈0.20), and + // the grille sits a hair under it so the two do not merge into one silhouette. + // + // `deviceIndicator` is the one role here whose *default* colour barely + // matters. It descends from the city's park green at high lightness, which is + // a credible "powered, idle" lamp, but the device render layer tints it per + // state through `MaterialRegistry.tinted()` and that path bypasses the band + // entirely. Authoring a saturated red here instead would not survive `bandOf` + // anyway — saturation is clamped hard, by design, and an LED is exactly the + // kind of thing that would talk somebody into softening that rule. + deviceShell: { from: "flats", dh: 2, ds: -0.03, dl: -0.4 }, + deviceMesh: { from: "upland", dh: 6, ds: -0.04, dl: -0.34 }, + deviceIndicator: { from: "park", dh: -6, ds: 0.06, dl: 0.18 }, + // Near-white on purpose: `screenContent` carries the one texture in the + // library that is *not* neutral (see `screenUI` in textures.ts), so this + // colour has to get out of its way. A mid-grey here would multiply the drawn + // interface down into mud. + screenContent: { from: "shore", dh: 2, ds: -0.05, dl: 0.3 }, + // Objects foliage: { from: "park", dh: 4, ds: 0.06, dl: -0.06 }, planter: { from: "shore", dh: -4, ds: 0.02, dl: -0.06 }, diff --git a/src/assets/parts.ts b/src/assets/parts.ts index 05287b0..79060ae 100644 --- a/src/assets/parts.ts +++ b/src/assets/parts.ts @@ -63,57 +63,93 @@ export class PartBin { * A box with rounded vertical corners and bevelled top and bottom — cushions, * chair shells, monitor bodies, anything moulded. * - * `radius` is a *fraction of the unit*, and it does not survive non-uniform - * scaling: a 0.06 rounded box scaled to 2 × 0.1 × 1 has visibly oval corners - * on two sides. Ask for a radius near the one you will end up with, or use - * `box()` and accept the sharp edge. + * `radius` is a fraction of the unit and is only correct while the part stays + * cubic, because a corner is round in *object* space and a non-uniform scale + * turns a circle into an ellipse. That is not a caveat you can design around + * — almost nothing in an office is a cube — and it is why `seating.ts` gave up + * and went back to sharp boxes. + * + * **Use `roundedBoxOf` instead**, which takes the finished metres and gets a + * genuinely circular corner at any proportion. This one stays for the parts + * that really are cubic, and because it is the cache entry `roundedBoxOf(1, 1, + * 1, r)` resolves to anyway. */ roundedBox(radius = 0.06): THREE.BufferGeometry { - const bevel = Math.min(0.24, Math.max(0.01, radius)); - return this.memo(`rounded:${mm(bevel)}`, () => { - const half = 0.5 - bevel; - const r = Math.min(half * 0.98, bevel * 2); - const shape = new THREE.Shape(); - shape.moveTo(-half + r, -half); - shape.lineTo(half - r, -half); - shape.quadraticCurveTo(half, -half, half, -half + r); - shape.lineTo(half, half - r); - shape.quadraticCurveTo(half, half, half - r, half); - shape.lineTo(-half + r, half); - shape.quadraticCurveTo(-half, half, -half, half - r); - shape.lineTo(-half, -half + r); - shape.quadraticCurveTo(-half, -half, -half + r, -half); + return this.roundedBoxOf(1, 1, 1, radius); + } - // Extrusion runs along +Z and the bevel overhangs both ends, so the solid - // spans -bevel..1-bevel before it is stood up and dropped onto the floor. - const geo = new THREE.ExtrudeGeometry(shape, { - depth: 1 - 2 * bevel, - bevelEnabled: true, - bevelSize: bevel, - bevelThickness: bevel, - bevelSegments: 2, - curveSegments: 4, - }); - geo.rotateX(-Math.PI / 2); - geo.translate(0, bevel, 0); - geo.computeVertexNormals(); - return geo; - }); + /** + * The same moulded box, authored at its finished size in metres. + * + * The corner radius is in **metres** and is applied after the proportions are + * known, so a 1.6 × 0.05 × 0.9 desk return gets a 12 mm round on all four + * corners rather than a 12 mm round on two of them and a 380 mm oval on the + * others. Place it with `size: 1` — the geometry is already the right size, + * and scaling it is what this method exists to avoid. + * + * The radius is clamped to a fifth of the shortest side. Past that the bevel + * eats the extrusion (a 0.5 radius on a 0.9-thick shelf has no flat left to + * extrude) and `ExtrudeGeometry` starts emitting self-intersecting caps. + */ + roundedBoxOf(width: number, height: number, depth: number, radius = 0.06): THREE.BufferGeometry { + const shortest = Math.max(0.002, Math.min(width, height, depth)); + const bevel = Math.min(shortest * 0.2, Math.max(0.001, radius)); + return this.memo( + `rounded:${mm(width)}:${mm(height)}:${mm(depth)}:${mm(bevel)}`, + () => { + // The shape is drawn in the extruder's XY and the extrusion runs along + // its +Z; the `rotateX` below maps that to width × depth on the floor + // with the extrusion standing up as height. + const halfX = width / 2 - bevel; + const halfY = depth / 2 - bevel; + const r = Math.min(halfX * 0.98, halfY * 0.98, bevel * 2); + const shape = new THREE.Shape(); + shape.moveTo(-halfX + r, -halfY); + shape.lineTo(halfX - r, -halfY); + shape.quadraticCurveTo(halfX, -halfY, halfX, -halfY + r); + shape.lineTo(halfX, halfY - r); + shape.quadraticCurveTo(halfX, halfY, halfX - r, halfY); + shape.lineTo(-halfX + r, halfY); + shape.quadraticCurveTo(-halfX, halfY, -halfX, halfY - r); + shape.lineTo(-halfX, -halfY + r); + shape.quadraticCurveTo(-halfX, -halfY, -halfX + r, -halfY); + + // The bevel overhangs both ends of the extrusion, so the solid spans + // -bevel..height-bevel before it is stood up and dropped onto the floor. + const geo = new THREE.ExtrudeGeometry(shape, { + depth: height - 2 * bevel, + bevelEnabled: true, + bevelSize: bevel, + bevelThickness: bevel, + bevelSegments: 3, + curveSegments: 6, + }); + geo.rotateX(-Math.PI / 2); + geo.translate(0, bevel, 0); + geo.computeVertexNormals(); + return geo; + }, + ); } /** Unit-diameter cylinder, base on the floor. */ - cylinder(segments = 16): THREE.BufferGeometry { + cylinder(segments = 20): THREE.BufferGeometry { return this.memo(`cyl:${segments}`, () => new THREE.CylinderGeometry(0.5, 0.5, 1, segments).translate(0, 0.5, 0), ); } /** - * A six-sided cylinder. Legs, columns, pen barrels — anything thin enough + * An eight-sided cylinder. Legs, columns, pen barrels — anything thin enough * that nobody will count the sides, which is most of the office. + * + * Six read as a hexagon on a chair column at desk distance, and the office + * measures 44,754 triangles against a 550,000 budget: eight is four extra + * triangles on the commonest part in the library and there is nowhere for the + * saving to go. */ rod(): THREE.BufferGeometry { - return this.cylinder(6); + return this.cylinder(8); } /** Unit-diameter cone, base on the floor. */ @@ -143,7 +179,7 @@ export class PartBin { } /** Unit-diameter disc lying in XZ, facing up. */ - disc(segments = 24): THREE.BufferGeometry { + disc(segments = 32): THREE.BufferGeometry { return this.memo(`disc:${segments}`, () => new THREE.CircleGeometry(0.5, segments).rotateX(-Math.PI / 2), ); @@ -294,6 +330,27 @@ export class MeshBin { return this.add(parts.box(), material, place); } + /** + * A moulded box with a true corner radius at whatever proportions `place.size` + * asks for — the drop-in replacement for `box()` on anything that is not a + * sawn edge. + * + * The size is spent on the *geometry* rather than on the placement matrix, + * which is the whole trick: `parts.roundedBox()` scaled to 1.4 × 0.06 × 0.7 + * has 42 mm corners on two sides and 3 mm on the others, and looking at that + * is why `seating.ts` reverted to sharp boxes. Everything else about the + * placement — position, yaw, pitch, roll — is passed through untouched. + * + * The cost is a cache entry per distinct size rather than one for the whole + * library, so this is for the parts a reader will see the silhouette of, not + * for a hundred randomised trinkets. + */ + rounded(material: THREE.Material, place: Placement, radius = 0.02): this { + const size = place.size ?? 1; + const [w, h, d] = typeof size === "number" ? [size, size, size] : size; + return this.add(parts.roundedBoxOf(w, h, d, radius), material, { ...place, size: 1 }); + } + /** Number of parts waiting to be merged. Handy in an asset's own tests. */ get size(): number { let n = 0; diff --git a/src/assets/textures.ts b/src/assets/textures.ts index b6be41a..41f462d 100644 --- a/src/assets/textures.ts +++ b/src/assets/textures.ts @@ -12,7 +12,9 @@ * supplies only the grain. One `carpetLoop` texture therefore serves every * palette. Baking the colour in would have made the cache key * `kind × colour` and given a self-hoster who recolours their world eight - * new uploads to the GPU for no visible gain. + * new uploads to the GPU for no visible gain. **`screenUI` is the one + * declared exception** — a display is content rather than a finish, and the + * reasoning is written out at the drawing itself. * 2. **They tile.** Value noise is not periodic, so a naively-drawn 2 m tile * shows a hard seam every 2 m across a floor. `tileableNoise` blends the * four wrapped samples so the edges match; see the comment there. @@ -21,6 +23,17 @@ * at 512² is about sixteen million `Math.sin` calls and roughly a second * of blocked main thread, which is not a price a floor is worth. * + * There is a **second channel**. Six of these kinds describe a surface with real + * relief — a carpet has pile, a plank floor has seams, a tiled floor has grout — + * and until now every one of those was drawn as a *darker line* and nothing + * else. A darker line does not move when the sun does, so a tiled floor at ten + * in the morning looked exactly like a tiled floor at six in the evening, which + * is the flat, printed-on look the whole office had. `NORMAL_RECIPES` below + * restates each of those six as a height field in **metres**, and `TextureBin` + * Sobel-differentiates it into a tangent-space normal map. See the comment on + * `HeightRecipe` for why the height field is authored rather than read back off + * the drawn canvas. + * * The noise itself is the engine's — `fbm` and `seededRandom` are imported from * `engine/world.ts` rather than reimplemented, so the city and the office are * grained by the same function. @@ -48,15 +61,40 @@ export type TextureKind = | "plasterPaint" | "fabricWeave" | "tileGrid" - | "whiteboard"; + | "whiteboard" + | "screenUI" + | "leafAlpha"; export type TextureQuality = "low" | "medium" | "high"; +/** + * How many parameterised layouts `screenUI` can draw. + * + * Published as a number rather than left implicit because `furnish.ts` batches + * props **per kind** and draws `ctx.rand` once for the whole batch, so a screen + * asset cannot pick a layout per instance — it has to be handed one, from a + * separate colour-key or parameter batch. Whoever authors those batches needs to + * know how many there are to choose between, and `variant` is taken modulo this + * so an out-of-range index wraps rather than throwing. + */ +export const SCREEN_UI_VARIANTS = 6; + /** `low` draws nothing at all: the materials fall back to flat colour. */ const RESOLUTION: Record = { low: 0, medium: 256, high: 512 }; // ---- Noise ---------------------------------------------------------------- +/** + * How coarse the noise lattice is. + * + * Shared by the colour drawings and by the height fields the normal maps are + * differentiated from, and that sharing is the point rather than a tidy-up: a + * height field evaluated on a *different* lattice would put the bumps somewhere + * other than the grain you can see, and the surface would light as though it + * were embossed with a second, invisible pattern. + */ +const NOISE_RES = 64; + /** * A tileable noise field, sampled on a coarse grid. * @@ -112,21 +150,22 @@ function sampleField(field: Float32Array, res: number, u: number, v: number): nu */ function grain( ctx: CanvasRenderingContext2D, - size: number, + width: number, + height: number, scale: number, amount: number, offset: number, ): void { - const res = 64; + const res = NOISE_RES; const field = tileableNoise(res, scale, offset); - const image = ctx.getImageData(0, 0, size, size); + const image = ctx.getImageData(0, 0, width, height); const data = image.data; - for (let y = 0; y < size; y++) { - for (let x = 0; x < size; x++) { - const n = sampleField(field, res, x / size, y / size); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const n = sampleField(field, res, x / width, y / height); // fbm's four octaves land in roughly 0..0.94 with a mean near 0.47. const k = 1 - amount * Math.min(1, Math.max(0, n / 0.94)); - const i = (y * size + x) * 4; + const i = (y * width + x) * 4; data[i] = (data[i] ?? 0) * k; data[i + 1] = (data[i + 1] ?? 0) * k; data[i + 2] = (data[i + 2] ?? 0) * k; @@ -137,15 +176,32 @@ function grain( // ---- The drawings --------------------------------------------------------- -type Draw = (ctx: CanvasRenderingContext2D, size: number) => void; +/** + * What a drawing is told about the canvas it is drawing on. + * + * `width` and `height` are separate because two of these kinds are not square: + * a display is 16:9 and drawing a dashboard on a square then stretching it puts + * every rule and every tile in the wrong proportion. Everything that *tiles* is + * square and reads `width` for both axes — a repeat is defined in metres by + * `TEXTURE_TILE_METRES` and a non-square repeat would need a per-axis one. + */ +interface DrawContext { + readonly width: number; + readonly height: number; + /** Which parameterised layout to draw. Already reduced into range. */ + readonly variant: number; +} + +type Draw = (ctx: CanvasRenderingContext2D, d: DrawContext) => void; const DRAW: Record = { /** Loop pile: dense fine speckle, plus the faint rows a loop carpet lays in. */ - carpetLoop(ctx, size) { + carpetLoop(ctx, d) { + const size = d.width; ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, size, size); - grain(ctx, size, 26, 0.16, 3.1); - grain(ctx, size, 90, 0.1, 11.7); + grain(ctx, size, size, 26, 0.16, 3.1); + grain(ctx, size, size, 90, 0.1, 11.7); const rand = seededRandom(0x9e11); ctx.strokeStyle = "rgba(0,0,0,0.035)"; ctx.lineWidth = 1; @@ -158,10 +214,11 @@ const DRAW: Record = { }, /** Boards along +U, with grain stretched hard along the board. */ - woodPlank(ctx, size) { + woodPlank(ctx, d) { + const size = d.width; ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, size, size); - grain(ctx, size, 4, 0.1, 21.4); + grain(ctx, size, size, 4, 0.1, 21.4); const rand = seededRandom(0x7a03); const boards = 5; const pitch = size / boards; @@ -189,11 +246,12 @@ const DRAW: Record = { }, /** Power-floated slab: broad mottle and a scatter of exposed aggregate. */ - polishedConcrete(ctx, size) { + polishedConcrete(ctx, d) { + const size = d.width; ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, size, size); - grain(ctx, size, 6, 0.09, 5.9); - grain(ctx, size, 40, 0.05, 31.2); + grain(ctx, size, size, 6, 0.09, 5.9); + grain(ctx, size, size, 40, 0.05, 31.2); const rand = seededRandom(0x51c0); for (let i = 0; i < size * 1.5; i++) { const r = 0.5 + rand() * 1.4; @@ -205,10 +263,11 @@ const DRAW: Record = { }, /** Mineral fibre tile: a 600 mm grid — one tile per 600 mm at a 2 m repeat. */ - ceilingTile(ctx, size) { + ceilingTile(ctx, d) { + const size = d.width; ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, size, size); - grain(ctx, size, 70, 0.07, 13.3); + grain(ctx, size, size, 70, 0.07, 13.3); const rand = seededRandom(0x0ce1); for (let i = 0; i < size * 3; i++) { ctx.fillStyle = `rgba(0,0,0,${0.05 + rand() * 0.08})`; @@ -231,18 +290,20 @@ const DRAW: Record = { }, /** Emulsion over plasterboard: almost nothing, which is the point. */ - plasterPaint(ctx, size) { + plasterPaint(ctx, d) { + const size = d.width; ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, size, size); - grain(ctx, size, 9, 0.045, 8.8); - grain(ctx, size, 120, 0.03, 27.6); + grain(ctx, size, size, 9, 0.045, 8.8); + grain(ctx, size, size, 120, 0.03, 27.6); }, /** Upholstery weave: two crossed sets of threads, low contrast. */ - fabricWeave(ctx, size) { + fabricWeave(ctx, d) { + const size = d.width; ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, size, size); - grain(ctx, size, 34, 0.1, 17.2); + grain(ctx, size, size, 34, 0.1, 17.2); ctx.lineWidth = 1; const pitch = Math.max(2, Math.round(size / 128)); ctx.strokeStyle = "rgba(0,0,0,0.05)"; @@ -262,10 +323,11 @@ const DRAW: Record = { }, /** Square tile with a grout line — kitchens, WCs, entrance mats. */ - tileGrid(ctx, size) { + tileGrid(ctx, d) { + const size = d.width; ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, size, size); - grain(ctx, size, 14, 0.05, 4.4); + grain(ctx, size, size, 14, 0.05, 4.4); const cells = 4; const pitch = size / cells; ctx.strokeStyle = "rgba(0,0,0,0.2)"; @@ -281,10 +343,11 @@ const DRAW: Record = { }, /** A wiped-down board: faint ghosting, no writing. */ - whiteboard(ctx, size) { + whiteboard(ctx, d) { + const size = d.width; ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, size, size); - grain(ctx, size, 5, 0.03, 9.1); + grain(ctx, size, size, 5, 0.03, 9.1); const rand = seededRandom(0x0b0a); ctx.lineCap = "round"; for (let i = 0; i < 14; i++) { @@ -297,8 +360,759 @@ const DRAW: Record = { ctx.stroke(); } }, + + /** + * What is on a screen. The one drawing in this file that is not neutral. + * + * Property 1 at the top of the module — textures are near-white and modulate + * downward so `material.color` carries the hue — is deliberately broken here, + * and it is worth being explicit about why rather than letting the next reader + * discover it as an inconsistency. A carpet is a *finish*: it has one colour + * and a grain, and the grain is the only thing a texture needs to supply. A + * display is not a finish, it is content: it is dark where nothing is lit and + * bright in four different hues where something is, and there is no colour + * `material.color` could carry that would produce that from a greyscale map. + * + * So this one draws its own colour, and `screenContent`'s palette entry is + * pushed to near-white (see `ROLE_SHIFTS`) specifically to get out of its way. + * It is also bound as `emissiveMap` as well as `map`, which is what makes a + * screen the brightest thing in a room without the whole panel glowing: the + * lit pixels emit and the dark chrome does not. That only reads correctly + * because `stage.ts` runs a tone curve with a shoulder — under the old + * clipping renderer every screen was one flat white rectangle, which is + * exactly what the live site shows today. + * + * Nothing here is legible and nothing here is meant to be. There are no + * glyphs, no words and no logos: every "line of text" is a rounded rectangle, + * every chart is abstract, and the whole thing is read from three metres away + * as *a screen with work on it*. That is also the only version of this that + * ARCHITECTURE.md §3.1 permits — a screen drawing a recognisable interface is + * a screen drawing somebody's trademark. + */ + screenUI(ctx, d) { + const layout = SCREEN_LAYOUTS[d.variant % SCREEN_LAYOUTS.length]; + // Unreachable — `variant` arrives already reduced — but the index signature + // is what TypeScript sees, and a placeholder beats a non-null assertion. + if (!layout) return; + layout(ctx, d, seededRandom(0x5c00 + d.variant * 0x9d)); + }, + + /** + * One leaf, in coverage rather than colour: white where the leaf is, black + * where it is not. + * + * This exists because of the single worst-looking asset in the product. + * `greenery.ts` builds every leaf as a bare `P.panel()` — an untextured + * rectangle — so the plants in the LA studio render as giant flat green shards + * filling the bottom corners of the frame. No amount of lighting work fixes a + * rectangle. An `alphaMap` with `alphaTest` cuts the silhouette out of it for + * one texture and no extra draw calls, and the cutout is what turns a shard + * into a leaf. + * + * Drawn hard-edged and without grain, on purpose. `alphaTest` compares against + * a threshold and throws the fragment away, so a soft edge does not buy a soft + * leaf — it buys a leaf whose outline moves as the mip level changes. The + * shape carries a midrib, a base that narrows to a stem and a shallow + * serration on each flank, because those three things are what the eye uses to + * decide something is foliage at a glance. + * + * Note the resolution floor in `KIND_SPECS`: this is the one kind that draws + * even at `low` quality. `low` means "no maps", and that is a statement about + * *shading* cost — an alpha cutout is one texture fetch and a discard, and the + * alternative at low quality is not a cheaper plant, it is the shard. + */ + leafAlpha(ctx, d) { + const w = d.width; + const h = d.height; + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, w, h); + + // Tip at the top, stem at the bottom, so a quad's +V runs along the leaf. + const cx = w * 0.5; + const tipY = h * 0.045; + const baseY = h * 0.86; + const half = w * 0.29; + + ctx.fillStyle = "#ffffff"; + ctx.beginPath(); + ctx.moveTo(cx, tipY); + // Right flank: wide at a third of the way down, tucked back in at the base. + ctx.bezierCurveTo(cx + half * 0.85, h * 0.2, cx + half, h * 0.52, cx, baseY); + ctx.bezierCurveTo(cx - half, h * 0.52, cx - half * 0.85, h * 0.2, cx, tipY); + ctx.fill(); + + // Serrations: shallow scallops bitten out of each flank. Cut rather than + // added, so they cannot push the silhouette outside the quad's own UVs. + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "#ffffff"; + const teeth = 5; + for (let i = 0; i < teeth; i++) { + const t = 0.18 + (i / (teeth - 1)) * 0.6; + const y = tipY + (baseY - tipY) * t; + const reach = half * (0.9 - Math.abs(t - 0.45)) * 1.05; + const r = w * 0.045; + ctx.beginPath(); + ctx.arc(cx + reach, y, r, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(cx - reach, y + (baseY - tipY) * 0.06, r, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalCompositeOperation = "source-over"; + + // The stem. Narrow enough that `alphaTest` keeps it at the mip levels a + // plant three metres away is actually sampled at. + ctx.fillStyle = "#ffffff"; + ctx.fillRect(cx - w * 0.022, baseY - h * 0.02, w * 0.044, h * 0.13); + }, }; +// ---- Screen layouts ------------------------------------------------------- + +/** + * The chrome every screen layout shares, and the body rectangle left over. + * + * A window bar at the top costs four rectangles and is the single cheapest thing + * that makes a lit panel read as a computer rather than as a light box. + */ +function screenChrome( + ctx: CanvasRenderingContext2D, + d: DrawContext, + rand: () => number, + accent: string, +): { x: number; y: number; w: number; h: number } { + const w = d.width; + const h = d.height; + ctx.fillStyle = SCREEN_BACKDROP; + ctx.fillRect(0, 0, w, h); + + const bar = h * 0.09; + ctx.fillStyle = SCREEN_CHROME; + ctx.fillRect(0, 0, w, bar); + ctx.fillStyle = accent; + ctx.fillRect(0, bar - Math.max(1, h * 0.006), w * (0.25 + rand() * 0.3), h * 0.006); + + // Three window dots, then a run of tab-shaped blocks. + for (let i = 0; i < 3; i++) { + ctx.fillStyle = SCREEN_DIM; + ctx.beginPath(); + ctx.arc(w * 0.03 + i * w * 0.028, bar * 0.5, h * 0.014, 0, Math.PI * 2); + ctx.fill(); + } + let tab = w * 0.16; + for (let i = 0; i < 4 && tab < w * 0.9; i++) { + const tw = w * (0.07 + rand() * 0.09); + ctx.fillStyle = i === 0 ? SCREEN_PANEL : SCREEN_CHROME_HI; + roundRect(ctx, tab, bar * 0.22, tw, bar * 0.56, bar * 0.16); + ctx.fill(); + tab += tw + w * 0.012; + } + + const pad = w * 0.03; + return { x: pad, y: bar + pad, w: w - pad * 2, h: h - bar - pad * 2 }; +} + +/** A run of "text": rounded bars of varying length, never a glyph. */ +function textLines( + ctx: CanvasRenderingContext2D, + rand: () => number, + x: number, + y: number, + w: number, + lines: number, + lineH: number, + gap: number, + color: string, +): void { + ctx.fillStyle = color; + for (let i = 0; i < lines; i++) { + const len = w * (0.45 + rand() * 0.55); + roundRect(ctx, x, y + i * (lineH + gap), len, lineH, lineH * 0.4); + ctx.fill(); + } +} + +function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, +): void { + const k = Math.min(r, w * 0.5, h * 0.5); + ctx.beginPath(); + ctx.moveTo(x + k, y); + ctx.arcTo(x + w, y, x + w, y + h, k); + ctx.arcTo(x + w, y + h, x, y + h, k); + ctx.arcTo(x, y + h, x, y, k); + ctx.arcTo(x, y, x + w, y, k); + ctx.closePath(); +} + +const SCREEN_BACKDROP = "#0d121a"; +const SCREEN_CHROME = "#161d27"; +const SCREEN_CHROME_HI = "#1e2733"; +const SCREEN_PANEL = "#141c26"; +const SCREEN_DIM = "#2d3947"; +const SCREEN_INK = "#61748a"; +const SCREEN_INK_HI = "#93a7bd"; +const SCREEN_ACCENTS = ["#4c9cff", "#38cf94", "#ffb44f", "#ff6f8d", "#a98bff"] as const; + +type ScreenLayout = (ctx: CanvasRenderingContext2D, d: DrawContext, rand: () => number) => void; + +/** + * Six layouts, because six is what it takes to fill a room. + * + * `furnish.ts` batches per kind, so the only way a studio gets two different + * screens is for the pack to author two different parameter batches — and a + * variety of *kinds of work* is what makes a floor of desks read as occupied. + * Four would satisfy the requirement; six covers what is actually on the + * monitors in a studio: dashboards, documents, code, data, a terminal, and + * footage. + */ +const SCREEN_LAYOUTS: readonly ScreenLayout[] = [ + // 0 — dashboard: tiles, bars, a donut. + (ctx, d, rand) => { + const accent = SCREEN_ACCENTS[0] ?? "#4c9cff"; + const b = screenChrome(ctx, d, rand, accent); + const tileH = b.h * 0.26; + const tileW = (b.w - b.w * 0.04) / 3; + for (let i = 0; i < 3; i++) { + const x = b.x + i * (tileW + b.w * 0.02); + ctx.fillStyle = SCREEN_PANEL; + roundRect(ctx, x, b.y, tileW, tileH, tileH * 0.12); + ctx.fill(); + ctx.fillStyle = SCREEN_INK; + roundRect(ctx, x + tileW * 0.08, b.y + tileH * 0.16, tileW * 0.42, tileH * 0.12, tileH * 0.06); + ctx.fill(); + ctx.fillStyle = SCREEN_ACCENTS[i % SCREEN_ACCENTS.length] ?? accent; + roundRect(ctx, x + tileW * 0.08, b.y + tileH * 0.42, tileW * 0.55, tileH * 0.26, tileH * 0.08); + ctx.fill(); + } + + // A column chart, from the same seeded stream so the same variant is always + // the same picture. + const chartY = b.y + tileH + b.h * 0.05; + const chartH = b.h * 0.42; + ctx.fillStyle = SCREEN_PANEL; + roundRect(ctx, b.x, chartY, b.w * 0.62, chartH, chartH * 0.06); + ctx.fill(); + const bars = 11; + const bw = (b.w * 0.62 - b.w * 0.08) / bars; + for (let i = 0; i < bars; i++) { + const v = 0.25 + rand() * 0.72; + ctx.fillStyle = i === bars - 1 ? accent : SCREEN_DIM; + const hgt = chartH * 0.72 * v; + roundRect( + ctx, + b.x + b.w * 0.04 + i * bw, + chartY + chartH * 0.86 - hgt, + bw * 0.62, + hgt, + bw * 0.2, + ); + ctx.fill(); + } + + // A donut, drawn as two arcs so there is no radial gradient to sample. + const cx = b.x + b.w * 0.82; + const cy = chartY + chartH * 0.5; + const r = Math.min(b.w * 0.14, chartH * 0.42); + ctx.lineWidth = r * 0.34; + ctx.strokeStyle = SCREEN_DIM; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.stroke(); + ctx.strokeStyle = SCREEN_ACCENTS[1] ?? accent; + ctx.beginPath(); + ctx.arc(cx, cy, r, -Math.PI * 0.5, -Math.PI * 0.5 + Math.PI * (0.7 + rand() * 0.9)); + ctx.stroke(); + + textLines(ctx, rand, b.x, b.y + b.h * 0.88, b.w * 0.5, 2, b.h * 0.035, b.h * 0.02, SCREEN_DIM); + }, + + // 1 — a document. A light page, which is what makes it the brightest screen + // in the room and the one that lights a desk. + (ctx, d, rand) => { + const accent = SCREEN_ACCENTS[4] ?? "#a98bff"; + const b = screenChrome(ctx, d, rand, accent); + ctx.fillStyle = "#e9edf2"; + roundRect(ctx, b.x + b.w * 0.12, b.y, b.w * 0.76, b.h, b.w * 0.01); + ctx.fill(); + const px = b.x + b.w * 0.18; + const pw = b.w * 0.64; + ctx.fillStyle = "#2b3440"; + roundRect(ctx, px, b.y + b.h * 0.08, pw * 0.55, b.h * 0.07, b.h * 0.02); + ctx.fill(); + ctx.fillStyle = accent; + roundRect(ctx, px, b.y + b.h * 0.2, pw * 0.22, b.h * 0.022, b.h * 0.011); + ctx.fill(); + textLines(ctx, rand, px, b.y + b.h * 0.28, pw, 5, b.h * 0.032, b.h * 0.028, "#9aa5b3"); + textLines(ctx, rand, px, b.y + b.h * 0.62, pw, 4, b.h * 0.032, b.h * 0.028, "#9aa5b3"); + }, + + // 2 — code. Indentation is the whole read: a wall of equal-length lines is a + // document, and a staircase is source. + (ctx, d, rand) => { + const accent = SCREEN_ACCENTS[1] ?? "#38cf94"; + const b = screenChrome(ctx, d, rand, accent); + const rows = 17; + const rowH = b.h / rows; + const gutter = b.w * 0.05; + ctx.fillStyle = SCREEN_PANEL; + ctx.fillRect(b.x, b.y, gutter, b.h); + let indent = 0; + for (let i = 0; i < rows; i++) { + const y = b.y + i * rowH + rowH * 0.22; + ctx.fillStyle = SCREEN_DIM; + roundRect(ctx, b.x + gutter * 0.3, y, gutter * 0.4, rowH * 0.4, rowH * 0.2); + ctx.fill(); + + const r = rand(); + if (r < 0.16 && indent > 0) indent--; + else if (r > 0.78 && indent < 3) indent++; + let x = b.x + gutter + b.w * 0.03 + indent * b.w * 0.045; + // Two to four tokens a line, coloured like syntax rather than like text. + const tokens = 2 + Math.floor(rand() * 3); + for (let t = 0; t < tokens && x < b.x + b.w * 0.9; t++) { + const tw = b.w * (0.05 + rand() * 0.16); + ctx.fillStyle = + t === 0 + ? (SCREEN_ACCENTS[4] ?? accent) + : rand() > 0.66 + ? accent + : rand() > 0.5 + ? SCREEN_INK_HI + : SCREEN_INK; + roundRect(ctx, x, y, tw, rowH * 0.4, rowH * 0.2); + ctx.fill(); + x += tw + b.w * 0.014; + } + } + }, + + // 3 — a table. Alternating row tint plus two highlighted cells, which is what + // a spreadsheet looks like from across a room. + (ctx, d, rand) => { + const accent = SCREEN_ACCENTS[2] ?? "#ffb44f"; + const b = screenChrome(ctx, d, rand, accent); + const cols = 6; + const rows = 12; + const cw = b.w / cols; + const rh = b.h / rows; + ctx.fillStyle = SCREEN_CHROME_HI; + ctx.fillRect(b.x, b.y, b.w, rh); + for (let c = 0; c < cols; c++) { + ctx.fillStyle = SCREEN_INK_HI; + roundRect(ctx, b.x + c * cw + cw * 0.12, b.y + rh * 0.32, cw * 0.5, rh * 0.34, rh * 0.17); + ctx.fill(); + } + for (let r = 1; r < rows; r++) { + if (r % 2 === 0) { + ctx.fillStyle = SCREEN_PANEL; + ctx.fillRect(b.x, b.y + r * rh, b.w, rh); + } + for (let c = 0; c < cols; c++) { + const hot = rand() > 0.9; + ctx.fillStyle = hot ? (SCREEN_ACCENTS[(r + c) % SCREEN_ACCENTS.length] ?? accent) : SCREEN_INK; + const len = cw * (0.3 + rand() * 0.5); + roundRect(ctx, b.x + c * cw + cw * 0.12, b.y + r * rh + rh * 0.34, len, rh * 0.3, rh * 0.15); + ctx.fill(); + } + } + }, + + // 4 — a terminal. One prompt colour, ragged output, a status strip. + (ctx, d, rand) => { + const accent = SCREEN_ACCENTS[1] ?? "#38cf94"; + const b = screenChrome(ctx, d, rand, accent); + ctx.fillStyle = "#080c12"; + ctx.fillRect(b.x, b.y, b.w, b.h); + const rows = 20; + const rowH = b.h / rows; + for (let i = 0; i < rows - 1; i++) { + const y = b.y + i * rowH + rowH * 0.24; + const prompt = rand() > 0.72; + let x = b.x + b.w * 0.02; + if (prompt) { + ctx.fillStyle = accent; + roundRect(ctx, x, y, b.w * 0.02, rowH * 0.42, rowH * 0.2); + ctx.fill(); + x += b.w * 0.035; + } + ctx.fillStyle = prompt ? SCREEN_INK_HI : SCREEN_INK; + roundRect(ctx, x, y, b.w * (0.12 + rand() * 0.72), rowH * 0.42, rowH * 0.2); + ctx.fill(); + } + ctx.fillStyle = accent; + ctx.fillRect(b.x, b.y + b.h - rowH * 0.9, b.w, rowH * 0.9); + ctx.fillStyle = "#0b1017"; + roundRect(ctx, b.x + b.w * 0.02, b.y + b.h - rowH * 0.7, b.w * 0.2, rowH * 0.5, rowH * 0.2); + ctx.fill(); + }, + + // 5 — footage. A hero frame over a strip of thumbnails; the only layout with + // large flat areas, which is what an editor's screen throws onto a wall. + (ctx, d, rand) => { + const accent = SCREEN_ACCENTS[3] ?? "#ff6f8d"; + const b = screenChrome(ctx, d, rand, accent); + const heroH = b.h * 0.62; + ctx.fillStyle = "#1b2530"; + roundRect(ctx, b.x, b.y, b.w * 0.72, heroH, b.h * 0.02); + ctx.fill(); + // A horizon and a light source: two rectangles that read as a shot. + ctx.fillStyle = "#2f4257"; + ctx.fillRect(b.x, b.y + heroH * 0.58, b.w * 0.72, heroH * 0.42); + ctx.fillStyle = "#d8c39a"; + ctx.beginPath(); + ctx.arc(b.x + b.w * 0.52, b.y + heroH * 0.44, heroH * 0.12, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = SCREEN_PANEL; + roundRect(ctx, b.x + b.w * 0.75, b.y, b.w * 0.25, heroH, b.h * 0.02); + ctx.fill(); + textLines( + ctx, + rand, + b.x + b.w * 0.78, + b.y + b.h * 0.05, + b.w * 0.19, + 6, + b.h * 0.03, + b.h * 0.03, + SCREEN_DIM, + ); + + const strip = b.y + heroH + b.h * 0.06; + const thumbs = 6; + const tw = (b.w - (thumbs - 1) * b.w * 0.014) / thumbs; + for (let i = 0; i < thumbs; i++) { + ctx.fillStyle = i === 2 ? accent : SCREEN_DIM; + roundRect(ctx, b.x + i * (tw + b.w * 0.014), strip, tw, b.h * 0.24, b.h * 0.015); + ctx.fill(); + ctx.fillStyle = "#151d26"; + roundRect( + ctx, + b.x + i * (tw + b.w * 0.014) + tw * 0.06, + strip + b.h * 0.02, + tw * 0.88, + b.h * 0.16 * (0.7 + rand() * 0.3), + b.h * 0.01, + ); + ctx.fill(); + } + }, +]; + +// ---- What each kind is ---------------------------------------------------- + +/** + * The facts about a kind that are not its drawing. + * + * Two of these matter enough to be worth stating rather than defaulting. + * + * `clamp` is the difference between a *finish* and an *image*. A carpet repeats + * every `TEXTURE_TILE_METRES` and must wrap or it seams; a screen and a leaf are + * one picture on one quad, and wrapping them means a UV that overshoots by a + * hair draws the far edge of the leaf against the near one. + * + * `linear` is the one that is silently wrong if you get it backwards. Three + * reads a single channel out of an `alphaMap` and uses it as coverage. Coverage + * is not a colour and has no transfer function, so decoding it as sRGB shifts + * every alpha value — a cutout authored at 0.5 arrives at 0.21, and an + * `alphaTest` tuned against the drawing eats half the leaf. + */ +interface KindSpec { + /** Canvas width divided by height. Omitted means square. */ + aspect?: number; + /** How many parameterised layouts the drawing produces. Omitted means one. */ + variants?: number; + /** A single image rather than a repeat: clamp both axes. */ + clamp?: boolean; + /** Coverage rather than colour: no sRGB transfer on the way in. */ + linear?: boolean; + /** Draw at least this big, even where the quality says draw nothing. */ + minSize?: number; +} + +const KIND_SPECS: Partial> = { + screenUI: { aspect: 16 / 9, variants: SCREEN_UI_VARIANTS, clamp: true }, + leafAlpha: { clamp: true, linear: true, minSize: 128 }, +}; + +// ---- Relief --------------------------------------------------------------- + +/** + * Which channel of a kind is being asked for. + * + * `"color"` is the drawing above. `"normal"` is the same surface expressed as + * relief, and only the six kinds in `NORMAL_RECIPES` have one — a whiteboard, a + * screen and a leaf cutout are flat, and binding a normal map to them would be + * inventing texture that is not there. + */ +export type TextureChannel = "color" | "normal"; + +/** + * A surface's relief, in metres, as a sum of terms. + * + * **Why this is authored rather than read back off the canvas.** The obvious + * implementation is to draw the colour map, read the pixels, take the luminance + * as a height and differentiate that. It is wrong here for two reasons, and the + * second one is fatal. Luminance is not height — a dark wood plank and a pale + * one are the same flatness, so a stain would emboss itself — and, more simply, + * there is no canvas at all in Node, which is where the tests and the server + * typecheck run. An authored recipe restates the *same* noise layers the drawing + * uses (same `scale`, same `offset`, therefore the same lattice) plus the relief + * the drawing could only imply, and it is pure arithmetic, so the normal map is + * the one texture in this file that exists identically in a browser and under + * `node --test`. + * + * Everything is in metres over a `TEXTURE_TILE_METRES` tile, which is what makes + * the numbers arguable: carpet pile is about a millimetre, a grout line is three + * deep and twenty wide, a plank seam is a knife cut. Authoring in metres also + * makes the map resolution-independent — the gradient is taken per unit UV and + * divided by the tile size, so `medium` and `high` differ in sharpness and not + * in how steep the floor looks. + * + * **The depths are exaggerated, deliberately and by different amounts.** A 512² + * map over a 2 m tile is 4 mm to the texel, so every finish here is standing in + * for structure the map cannot resolve, and authored at its true depth most of + * it encodes to within a texel of flat — which costs a texture fetch per + * fragment and returns a surface that still looks printed. The rule applied + * below is: exaggerate until the relief reads at a grazing sun and no further. + * `plasterPaint` is the extreme (roughly ten times life) because a painted wall + * genuinely is almost flat; `tileGrid` and `ceilingTile` are close to true, + * because a grout line and a ceiling shadow gap are millimetres deep across + * centimetres and survive on their own. + */ +interface HeightRecipe { + /** + * Noise layers, each mirroring one `grain()` call in the kind's drawing. + * `scale` and `offset` **must** match the drawing or the bumps and the grain + * come apart; `metres` is the peak-to-trough depth this layer contributes. + */ + readonly noise: readonly { scale: number; offset: number; metres: number }[]; + /** + * Cut lines — plank seams, grout, a ceiling grid. `along` is the axis the + * lines *run* along, so plank seams that run across the tile are `"u"`. + * `width` is the full width of the cut in UV, `metres` how deep it goes. + */ + readonly lines?: { + readonly along: "u" | "v" | "both"; + readonly count: number; + readonly width: number; + readonly metres: number; + }; + /** + * Corrugation — carpet rows, the over-and-under of a weave. A cosine rather + * than a cut, because that is what a woven surface actually is and because a + * smooth term has a smooth derivative at every mip level. + */ + readonly ribs?: { + readonly along: "u" | "v" | "both"; + readonly count: number; + readonly metres: number; + }; +} + +/** + * The six kinds that have relief, and how much. + * + * The list is exactly the spec's: carpet, plank, weave, paint, tile, ceiling. + * The other four kinds are deliberately absent — `polishedConcrete` is a power + * float and is meant to be flat, and a whiteboard, a display and an alpha cutout + * have no surface of their own to speak of. + */ +const NORMAL_RECIPES: Partial> = { + // Pile, then the rows a loop carpet is laid in. The rows are the only thing on + // a carpet you can see the light move across; they are about half a millimetre + // proud in life and are carried at 1.2 mm here, which is the mildest of the + // exaggerations in this table and the one a raking wall-washer earns. + carpetLoop: { + noise: [ + { scale: 26, offset: 3.1, metres: 0.002 }, + { scale: 90, offset: 11.7, metres: 0.001 }, + ], + ribs: { along: "u", count: 32, metres: 0.0012 }, + }, + + // A board floor is flat; the seams between boards are not. Five boards per + // 2 m tile, matching the drawing, cut 1.2 mm deep and 12 mm wide — wider than + // a real board joint, because a 2 mm joint is half a texel and would alias + // into a dotted line. What has to be right is that it is a groove and not a + // dark stripe: a groove catches a low sun along one flank and shades the other. + woodPlank: { + noise: [{ scale: 4, offset: 21.4, metres: 0.0004 }], + lines: { along: "u", count: 5, width: 0.006, metres: 0.0012 }, + }, + + // Warp and weft. The drawing rules them at `size/128`, so 128 each way here. + fabricWeave: { + noise: [{ scale: 34, offset: 17.2, metres: 0.0012 }], + ribs: { along: "both", count: 128, metres: 0.0008 }, + }, + + // Roller-laid emulsion over skimmed plasterboard. The long layer is trowel + // undulation — which is what makes a wall catch a wall-washer unevenly — and + // the short one is roller stipple. + // + // This is where the exaggeration declared at `HeightRecipe` shows most: both + // layers are carried at roughly ten times life, because a painted wall really + // is almost flat and at its true depth the normals peaked two values off + // (128, 128, 255). That costs a texture fetch on every wall in the building + // and returns a wall that still looks printed. + plasterPaint: { + noise: [ + { scale: 9, offset: 8.8, metres: 0.013 }, + { scale: 120, offset: 27.6, metres: 0.005 }, + ], + }, + + // The one kind where the relief is louder than the colour, and the one closest + // to life: grout really does sit about 3 mm below the tile face. The 24 mm + // width (0.012 UV) is generous for a joint and is set by the map rather than + // by the tiler — three texels at 512 is the narrowest a smooth groove can be + // before mipping turns it into a crawling line. + tileGrid: { + noise: [{ scale: 14, offset: 4.4, metres: 0.0003 }], + lines: { along: "both", count: 4, width: 0.012, metres: 0.003 }, + }, + + // Mineral fibre, and the shadow gap the tile sits in — 4 mm deep and 16 mm + // wide, which is close to a real exposed-tee grid. Three tiles per repeat, + // matching the drawing's `cells = 3`. + ceilingTile: { + noise: [{ scale: 70, offset: 13.3, metres: 0.0009 }], + lines: { along: "both", count: 3, width: 0.008, metres: 0.004 }, + }, +}; + +/** Which kinds `TextureBin.normal()` can answer for. Exported for the tests. */ +export const NORMAL_MAP_KINDS = Object.keys(NORMAL_RECIPES) as readonly TextureKind[]; + +/** A smooth, symmetric dip of unit depth. Zero slope at the middle and at the rim. */ +function dip(t: number): number { + if (t >= 1) return 0; + return 0.5 * (1 + Math.cos(Math.PI * t)); +} + +/** The sum of `dip`s for one axis of a `lines` term, in metres. */ +function lineDepth(coord: number, count: number, width: number, metres: number): number { + if (count <= 0 || width <= 0) return 0; + const pitch = 1 / count; + // Lines sit at 0, 1/count, 2/count …, exactly where the drawings put them. + const offset = coord - Math.round(coord / pitch) * pitch; + return dip(Math.abs(offset) / (width * 0.5)) * metres; +} + +/** + * Evaluate a recipe onto a `size × size` grid of metres. + * + * Sampled at `x / size`, **not** at `(x + 0.5) / size`. Sampling on the texel + * corners is what makes the field exactly periodic — row `size` is row `0` — so + * the wrapped Sobel below sees a genuinely seamless surface rather than a step + * one texel wide all the way down the left edge. + * + * **Row `y` of the field is row `y` of the drawing, not of the texture.** A + * `CanvasTexture` has `flipY` on, so canvas row `y` is sampled at `v = 1 − y/h`; + * a `DataTexture` has it off, so data row `y` is sampled at `v = y/h`. The two + * channels therefore only line up if the height field reads the drawing's own + * row coordinate *backwards* — `s` below — and that is cheaper and far more + * legible than flipping one of the two buffers and hoping the unpack flag + * survives a three.js upgrade. + */ +function heightField(recipe: HeightRecipe, size: number): Float32Array { + const height = new Float32Array(size * size); + + for (const layer of recipe.noise) { + const field = tileableNoise(NOISE_RES, layer.scale, layer.offset); + for (let y = 0; y < size; y++) { + const s = (size - y) / size; + for (let x = 0; x < size; x++) { + // `/ 0.94` matches `grain()`: four octaves of `fbm` land in 0..0.94. + const n = Math.min(1, Math.max(0, sampleField(field, NOISE_RES, x / size, s) / 0.94)); + height[y * size + x] = (height[y * size + x] ?? 0) + n * layer.metres; + } + } + } + + const lines = recipe.lines; + const ribs = recipe.ribs; + if (lines || ribs) { + for (let y = 0; y < size; y++) { + const s = (size - y) / size; + for (let x = 0; x < size; x++) { + const u = x / size; + let cut = 0; + if (lines) { + // A line running along `u` repeats down the other axis, and vice versa. + if (lines.along !== "v") cut += lineDepth(s, lines.count, lines.width, lines.metres); + if (lines.along !== "u") cut += lineDepth(u, lines.count, lines.width, lines.metres); + } + if (ribs) { + if (ribs.along !== "v") { + cut += 0.5 * (1 - Math.cos(2 * Math.PI * ribs.count * s)) * ribs.metres; + } + if (ribs.along !== "u") { + cut += 0.5 * (1 - Math.cos(2 * Math.PI * ribs.count * u)) * ribs.metres; + } + } + height[y * size + x] = (height[y * size + x] ?? 0) - cut; + } + } + } + + return height; +} + +/** + * Sobel-differentiate a height field into an RGBA tangent-space normal map. + * + * Sobel rather than a plain central difference because the field is bilinearly + * upsampled off a 64² lattice: a two-tap difference on a bilinear surface + * reproduces the lattice as a faint grid of creases, and averaging the three + * neighbouring rows is enough to bury it. + * + * The gradient is converted to a **slope** before it is encoded — `× size` puts + * it per unit UV, `/ TEXTURE_TILE_METRES` puts it per metre — which is why the + * same recipe drawn at 256 and at 512 gives the same steepness rather than the + * higher-resolution floor looking twice as smooth. + */ +function normalMapData(height: Float32Array, size: number): Uint8Array { + const data = new Uint8Array(size * size * 4); + const at = (x: number, y: number): number => + height[(((y % size) + size) % size) * size + (((x % size) + size) % size)] ?? 0; + + // Per-texel Sobel sums weight 4 either side across a 2-texel baseline. + const perUv = size / 8 / TEXTURE_TILE_METRES; + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const gu = + (at(x + 1, y - 1) + 2 * at(x + 1, y) + at(x + 1, y + 1) - + at(x - 1, y - 1) - 2 * at(x - 1, y) - at(x - 1, y + 1)) * perUv; + // Data row `y` is texture coordinate `v = y / size` — the backwards read + // happened in `heightField`, so this one is a plain forward difference. + const gv = + (at(x - 1, y + 1) + 2 * at(x, y + 1) + at(x + 1, y + 1) - + at(x - 1, y - 1) - 2 * at(x, y - 1) - at(x + 1, y - 1)) * perUv; + + // The surface normal of a height field is (-∂h/∂u, -∂h/∂v, 1), normalised. + const nx = -gu; + const ny = -gv; + const inv = 1 / Math.hypot(nx, ny, 1); + const i = (y * size + x) * 4; + data[i] = Math.round((nx * inv * 0.5 + 0.5) * 255); + data[i + 1] = Math.round((ny * inv * 0.5 + 0.5) * 255); + data[i + 2] = Math.round((inv * 0.5 + 0.5) * 255); + data[i + 3] = 255; + } + } + + return data; +} + // ---- The bin -------------------------------------------------------------- /** @@ -312,27 +1126,106 @@ const DRAW: Record = { */ export class TextureBin { readonly quality: TextureQuality; - private readonly cache = new Map(); + private readonly cache = new Map(); constructor(quality: TextureQuality = "high") { this.quality = quality; } - get(kind: TextureKind): THREE.Texture | null { - const hit = this.cache.get(kind); + /** + * The texture for a kind, and for parameterised kinds a layout within it. + * + * `variant` is reduced modulo the kind's own count, so a caller may hand it a + * running index — a desk number, a prop ordinal — without knowing or caring + * how many layouts exist. Kinds that have only one ignore it entirely, which + * is why every existing call site still reads `get("carpetLoop")`. + */ + get(kind: TextureKind, variant = 0): THREE.Texture | null { + const count = KIND_SPECS[kind]?.variants ?? 1; + // `%` keeps a negative index negative, and a negative cache key would draw + // the same picture under two names. + const index = count <= 1 ? 0 : (((variant % count) + count) % count) | 0; + const key = index === 0 ? kind : `${kind}#${index}`; + const hit = this.cache.get(key); if (hit !== undefined) return hit; - const texture = this.draw(kind); - this.cache.set(kind, texture); + const texture = this.draw(kind, "color", index); + this.cache.set(key, texture); return texture; } - private draw(kind: TextureKind): THREE.Texture | null { + /** + * The relief for a kind, or `null` where the kind has none. + * + * Not parameterised by variant: the six kinds with relief have one layout + * each, and a screen — the only kind with layouts — is flat. + */ + normal(kind: TextureKind): THREE.Texture | null { + const key = `${kind}!normal`; + const hit = this.cache.get(key); + if (hit !== undefined) return hit; + const texture = this.draw(kind, "normal"); + this.cache.set(key, texture); + return texture; + } + + /** How many distinct layouts `get` can return for a kind. */ + variants(kind: TextureKind): number { + return KIND_SPECS[kind]?.variants ?? 1; + } + + /** + * Build one texture, **uncached**. + * + * Public because the tests need to look at a channel without the cache in the + * way, and because a capture pass occasionally wants a texture it is free to + * dispose. Everything in the product goes through `get` or `normal`; calling + * this in a loop is a new GPU upload every time round. + */ + draw(kind: TextureKind, channel: TextureChannel = "color", variant = 0): THREE.Texture | null { + return channel === "normal" ? this.drawNormal(kind) : this.drawColor(kind, variant); + } + + /** + * Relief needs no canvas — it is arithmetic over an authored height field — + * which is what lets a normal map exist under `node --test` where the colour + * channel cannot. See `HeightRecipe`. + */ + private drawNormal(kind: TextureKind): THREE.Texture | null { + const recipe = NORMAL_RECIPES[kind]; const size = RESOLUTION[this.quality]; + // `low` means "no maps"; a normal map is a fetch and a matrix multiply per + // fragment, which is exactly the cost `low` exists to refuse. + if (!recipe || size === 0) return null; + + const data = normalMapData(heightField(recipe, size), size); + const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat); + texture.name = `${kind}!normal`; + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + // A normal map is a direction, not a colour: an sRGB decode on the way in + // would bend every one of them toward the surface. + texture.colorSpace = THREE.NoColorSpace; + // `DataTexture` defaults to nearest filtering and no mipmaps, which on a + // floor running to the horizon is a field of shimmering static. + texture.magFilter = THREE.LinearFilter; + texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.generateMipmaps = true; + texture.anisotropy = 4; + texture.needsUpdate = true; + return texture; + } + + private drawColor(kind: TextureKind, variant: number): THREE.Texture | null { + const spec = KIND_SPECS[kind] ?? {}; + const size = Math.max(RESOLUTION[this.quality], spec.minSize ?? 0); if (size === 0 || typeof document === "undefined") return null; + const width = size; + const height = Math.max(1, Math.round(size / (spec.aspect ?? 1))); + const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; + canvas.width = width; + canvas.height = height; // Every material passes through `grain()`, which reads the full canvas back // before uploading it to Three.js. Declare that workload so Chromium keeps // the 2D surface on its read-optimised path instead of warning once per @@ -340,13 +1233,14 @@ export class TextureBin { const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) return null; - DRAW[kind](ctx, size); + DRAW[kind](ctx, { width, height, variant }); const texture = new THREE.CanvasTexture(canvas); - texture.name = kind; - texture.wrapS = THREE.RepeatWrapping; - texture.wrapT = THREE.RepeatWrapping; - texture.colorSpace = THREE.SRGBColorSpace; + texture.name = variant === 0 ? kind : `${kind}#${variant}`; + const wrap = spec.clamp ? THREE.ClampToEdgeWrapping : THREE.RepeatWrapping; + texture.wrapS = wrap; + texture.wrapT = wrap; + texture.colorSpace = spec.linear ? THREE.NoColorSpace : THREE.SRGBColorSpace; texture.anisotropy = 4; texture.needsUpdate = true; return texture; diff --git a/src/assets/vehicles/index.ts b/src/assets/vehicles/index.ts index e51efb2..c530bb3 100644 --- a/src/assets/vehicles/index.ts +++ b/src/assets/vehicles/index.ts @@ -1,12 +1,15 @@ export { MODEL_X_METRICS, + MODEL_X_PAINTS, LUMBRIDGE_EV_METRICS, advanceModelXWheels, buildModelX, buildLumbridgeEV, cloneModelX, createModelXMaterials, + createModelXPaintPool, disposeModelX, + disposeModelXPaintPool, modelXInstanceParts, setModelXSteering, setModelXWheelRotation, diff --git a/src/assets/vehicles/modelX.ts b/src/assets/vehicles/modelX.ts index a9fa573..0b848a4 100644 --- a/src/assets/vehicles/modelX.ts +++ b/src/assets/vehicles/modelX.ts @@ -1,16 +1,57 @@ /** - * Lumbridge EV-01: an original, unbadged procedural electric grand tourer. + * Lumbridge EV-01: an original, unbadged procedural electric crossover, built + * as a hero asset rather than as background traffic. * * The asset is authored in metres with its origin on the road at the centre of - * the wheelbase. It faces -Z at yaw zero, matching the rest of Tera; +X is the - * vehicle's right. The broad, low nose, rising panoramic glass, cab-forward - * roof and tapered tail carry the silhouette. Fine detail is deliberately - * sparse because this car is normally read from a corridor or chase camera. + * the wheelbase. It faces −Z at yaw zero, matching the rest of Tera; +X is the + * vehicle's right. Legacy `ModelX*` API names are retained as serialised and + * realtime compatibility contracts, not as design intent — nothing here is + * anyone's trademark, and `ARCHITECTURE.md` §3.1 is why every badge, grille + * shape and plate in the file is abstract. + * + * ### Why this was rebuilt + * + * The first version was 160 triangles and read like it. Five things were wrong + * and all five are worth naming, because they are the five things that separate + * a car-shaped object from a car: + * + * 1. **A single `computeVertexNormals()` over the whole shell.** The comment + * claimed "deliberately strong shoulder highlights" and the code averaged + * every normal across every crease, so the shoulder line — the one horizontal + * highlight that tells you a body panel is pressed metal — was smoothed away. + * Normals are now computed **per panel**, and a panel boundary *is* a crease. + * See `PANEL_HARD` and `emitPanels`. + * 2. **Glass floated on the paint.** Five quads were laid over an unbroken + * shell. The greenhouse is now an *opening*: the loft's daylight band carries + * the glass material and the paint genuinely stops at its edge, with the + * blackout reveal above and below modelled as its own strips. + * 3. **The wheels intersected a flat flank.** There were no arches at all. The + * section profile now deforms into a real wheel opening — see `archHeightAt` + * and the `wellWall`/`archLip` points — which is one continuous loft, not a + * topological cut, so the shell stays watertight and the arch stays smooth. + * 4. **A tyre was a `TorusGeometry`.** A torus has no sidewall, no shoulder, no + * tread and no flat where it meets the road. The tyre is now a revolved + * profile with a loaded sidewall bulge, a shoulder crease and circumferential + * grooves, and the tread band is flat so the wheel *sits* on the road. + * 5. **Fake ambient on the paint.** `emissive: 0x11191e` existed because the + * scene had no environment map, so `metalness` had nothing to reflect and the + * car went black. `engine/environmentRig.ts` now supplies one, and an + * emissive term on top of it double-counts. It is gone from `paint` and + * `wheel`; the head- and tail-lights keep theirs, because those are lamps. + * + * ### Two LODs, and what actually differs + * + * `corridor` is the parked and background car — forty of them go through + * `engine/roadTraffic.ts` as instanced batches, so its triangle count is + * multiplied by forty and is the number that matters to the city's budget. + * `follow` is the one car the chase camera is looking at, and it is the only + * one that gets the dense wheels, the interior, the mirrors, the shutlines and + * the applied window trim. The split is real: `corridor` is roughly 3.7k + * triangles and `follow` roughly 12k. * * Build one rig and clone it. Clones share geometry and materials while their - * wheel and steering joints remain independent. Legacy Model-X API names are - * retained as serialized/realtime compatibility contracts, not design intent. - * after every clone has left the scene. + * wheel and steering joints stay independent, so `disposeModelX` must only ever + * be called on a prototype. */ import * as THREE from "three"; @@ -41,6 +82,21 @@ export interface ModelXMaterials { brake: THREE.Material; headlight: THREE.Material; tailLight: THREE.Material; + /** + * The mirror glass itself. Separate from `glass` because a wing mirror is a + * first-surface reflector and the side windows are tinted and translucent — + * one material cannot be both, and with an environment map present the + * difference between them is most of what makes a mirror read as a mirror. + */ + mirror: THREE.Material; + /** + * The number plate. Blank and unlettered on purpose: a plate carrying + * characters is either somebody's real registration or an invented one that + * looks like it, and neither belongs in an Apache-2.0 repo (ARCHITECTURE.md + * §3.1). What the plate contributes is the pale rectangle low on a dark + * bumper, which is the part the eye actually uses. + */ + plate: THREE.Material; } export interface ModelXBuildOptions { @@ -89,56 +145,72 @@ export interface ModelXInstancePart { const UNIT_BOX = new THREE.BoxGeometry(1, 1, 1); const UNIT_PLANE = new THREE.PlaneGeometry(1, 1); -/** Default materials are intentionally untextured: no binary art and no UV dependency. */ +// ---- Materials ------------------------------------------------------------ + +/** + * Default materials are intentionally untextured: no binary art and no UV + * dependency, which is the same promise `src/assets/textures.ts` makes one + * level up. + * + * The paint is `MeshPhysicalMaterial` with a full clearcoat because that is the + * one thing a car body has that no other surface in the library does — a + * lacquer layer with its own, much sharper, reflection sitting on top of a + * metallic basecoat. It needs an environment to reflect; `createEnvironmentRig` + * supplies one, and a scene without one renders the car duller rather than + * broken. + */ export function createModelXMaterials( paint: THREE.ColorRepresentation = 0x465157, ): ModelXMaterials { - const body = new THREE.MeshPhysicalMaterial({ - name: "model-x.paint", - color: paint, - metalness: 0.72, - roughness: 0.26, - clearcoat: 1, - clearcoatRoughness: 0.12, - emissive: 0x11191e, - emissiveIntensity: 0.72, - }); return { - paint: body, + paint: createModelXPaint(paint), glass: new THREE.MeshPhysicalMaterial({ name: "model-x.glass", - color: 0x193746, - metalness: 0.12, - roughness: 0.12, + color: 0x14262f, + metalness: 0.1, + roughness: 0.08, transparent: true, - opacity: 0.78, + opacity: 0.72, + // The greenhouse is a closed volume with an interior inside it, so the + // far side of the glass is genuinely visible through the near side. side: THREE.DoubleSide, + depthWrite: false, }), trim: new THREE.MeshStandardMaterial({ name: "model-x.trim", - color: 0x272d31, - metalness: 0.68, - roughness: 0.3, + color: 0x1d2225, + metalness: 0.42, + roughness: 0.46, }), tire: new THREE.MeshStandardMaterial({ name: "model-x.tire", color: 0x111213, metalness: 0, - roughness: 0.92, + roughness: 0.93, }), wheel: new THREE.MeshStandardMaterial({ name: "model-x.wheel", - color: 0x515960, - metalness: 0.88, - roughness: 0.25, - emissive: 0x111519, - emissiveIntensity: 0.45, + color: 0x646c73, + metalness: 0.9, + roughness: 0.26, }), brake: new THREE.MeshStandardMaterial({ name: "model-x.brake", - color: 0x72777a, - metalness: 0.92, - roughness: 0.32, + color: 0x6d7275, + metalness: 0.9, + roughness: 0.34, + }), + mirror: new THREE.MeshStandardMaterial({ + name: "model-x.mirror", + color: 0xc9d3d8, + metalness: 1, + roughness: 0.06, + }), + plate: new THREE.MeshStandardMaterial({ + name: "model-x.plate", + color: 0xd6dad4, + metalness: 0, + roughness: 0.62, }), headlight: new THREE.MeshStandardMaterial({ name: "model-x.headlight", @@ -157,6 +229,64 @@ export function createModelXMaterials( }; } +function createModelXPaint(color: THREE.ColorRepresentation): THREE.MeshPhysicalMaterial { + return new THREE.MeshPhysicalMaterial({ + name: "model-x.paint", + color, + metalness: 0.72, + roughness: 0.26, + clearcoat: 1, + clearcoatRoughness: 0.1, + }); +} + +/** + * The stock colours a street of these is drawn from. + * + * Deliberately narrow and deliberately desaturated. A car park where every car + * is a different hue reads as a toy shelf; real traffic is four greys, a white, + * a dark blue and one red, and it is the *distribution* rather than the range + * that makes it look observed rather than generated. + */ +export const MODEL_X_PAINTS: readonly number[] = [ + 0x1c2024, 0x9aa1a6, 0xd7dade, 0x465157, 0x2c3f57, 0x6c2f31, 0x3a4a42, +]; + +/** + * A pool of complete skins that differ only in their paint. + * + * This exists because of a constraint the renderer imposes and the pack layer + * cannot work around: an `InstancedMesh` shares one material across every + * instance, so per-car colour has to arrive as a *different material*, not as a + * per-instance attribute. `setColorAt` would multiply the material's colour + * rather than replace it, and it cannot reach the clearcoat at all. + * + * Seven skins is seven extra draw calls at worst and one geometry — the pool + * shares every non-paint material, so the tyres, glass, trim and wheels of all + * seven are the same objects. + */ +export function createModelXPaintPool( + colors: readonly number[] = MODEL_X_PAINTS, +): readonly ModelXMaterials[] { + const shared = createModelXMaterials(colors[0] ?? 0x465157); + return colors.map((color, index) => + index === 0 ? shared : { ...shared, paint: createModelXPaint(color) }, + ); +} + +/** Dispose a pool built by `createModelXPaintPool`, shared materials included. */ +export function disposeModelXPaintPool(pool: readonly ModelXMaterials[]): void { + const seen = new Set(); + for (const skin of pool) { + for (const material of Object.values(skin)) { + if (material instanceof THREE.Material) seen.add(material); + } + } + for (const material of seen) material.dispose(); +} + +// ---- Placement and batching ---------------------------------------------- + interface Placement { position?: readonly [number, number, number]; scale?: readonly [number, number, number]; @@ -172,6 +302,17 @@ function matrix(place: Placement): THREE.Matrix4 { return new THREE.Matrix4().compose(position, quaternion, scale); } +/** + * Collects transformed pieces and emits one mesh per material, the same trade + * `assets/parts.ts` makes for the office. + * + * One rule governs everything that goes in here: **every geometry under one + * material must carry the same attribute set**, because `mergeGeometries` + * refuses a mixture and `build` treats the refusal as "skip this material" — + * which is not an error, it is a car with no paint on it. Three.js primitives + * all carry `position`/`normal`/`uv`, so every generated geometry in this file + * carries all three too, `uv` included, even where nothing samples it. + */ class StaticBatch { private readonly groups = new Map(); @@ -192,6 +333,23 @@ class StaticBatch { this.add(UNIT_BOX, material, place); } + /** Mirror a placement across the centreline. `side` is +1 right, −1 left. */ + boxPair(material: THREE.Material, place: Placement, mirrorRotation = true): void { + for (const side of [-1, 1] as const) { + const [x, y, z] = place.position ?? [0, 0, 0]; + const [rx, ry, rz] = place.rotation ?? [0, 0, 0]; + this.box(material, { + position: [side * x, y, z], + scale: place.scale, + rotation: mirrorRotation ? [rx, side * ry, side * rz] : [rx, ry, rz], + }); + } + } + + get materialCount(): number { + return this.groups.size; + } + build(name: string): THREE.Group { const group = new THREE.Group(); group.name = name; @@ -211,277 +369,1026 @@ class StaticBatch { } } +// ---- The body loft -------------------------------------------------------- + +/** + * One authored cross-section of the bodyshell, in metres. + * + * Every field is a *line* that runs the length of the car, which is how a car + * is actually drawn: you draw the shoulder line, the beltline and the roof line + * in side view first, and the sections follow from them. Naming them here is + * what lets the arch deformation, the applied trim and the shutlines all agree + * about where a surface is without any of them measuring the mesh. + */ interface BodySection { z: number; - halfWidth: number; - bottom: number; - shoulder: number; + /** Half-width at the widest line — the shoulder. */ + w: number; + /** Underbody pan. */ + pan: number; + /** Lower edge of the bodyside skin: the rocker line. */ + sill: number; + /** The shoulder crease. Widest point of the body and the hardest edge on it. */ + hip: number; + /** Lower edge of the daylight opening — the beltline. */ belt: number; - roof: number; + /** Upper edge of the daylight opening, where the glass meets the roof rail. */ + rail: number; + /** Roof centreline. */ + crown: number; } +/** + * Nineteen control sections. The loft resamples between them (see + * `monotoneSlopes`) rather than treating these as the mesh, so adding a section + * here refines the shape instead of adding facets. + */ const BODY_SECTIONS: readonly BodySection[] = [ - { z: -2.52, halfWidth: 0.58, bottom: 0.55, shoulder: 0.68, belt: 0.79, roof: 0.84 }, - { z: -2.34, halfWidth: 0.88, bottom: 0.47, shoulder: 0.69, belt: 0.88, roof: 0.93 }, - { z: -1.58, halfWidth: 1.0, bottom: 0.4, shoulder: 0.72, belt: 0.98, roof: 1.08 }, - { z: -0.78, halfWidth: 0.99, bottom: 0.39, shoulder: 0.76, belt: 1.06, roof: 1.32 }, - { z: -0.12, halfWidth: 0.97, bottom: 0.39, shoulder: 0.78, belt: 1.12, roof: 1.58 }, - { z: 0.72, halfWidth: 0.96, bottom: 0.4, shoulder: 0.78, belt: 1.1, roof: 1.68 }, - { z: 1.48, halfWidth: 0.97, bottom: 0.41, shoulder: 0.77, belt: 1.06, roof: 1.52 }, - { z: 2.28, halfWidth: 0.89, bottom: 0.48, shoulder: 0.72, belt: 0.98, roof: 1.18 }, - { z: 2.52, halfWidth: 0.63, bottom: 0.57, shoulder: 0.69, belt: 0.82, roof: 0.9 }, + { z: -2.52, w: 0.6, pan: 0.52, sill: 0.46, hip: 0.7, belt: 0.76, rail: 0.8, crown: 0.82 }, + { z: -2.38, w: 0.85, pan: 0.42, sill: 0.34, hip: 0.8, belt: 0.86, rail: 0.9, crown: 0.93 }, + { z: -2.16, w: 0.96, pan: 0.34, sill: 0.26, hip: 0.87, belt: 0.94, rail: 0.98, crown: 1.01 }, + { z: -1.88, w: 0.995, pan: 0.31, sill: 0.24, hip: 0.9, belt: 0.99, rail: 1.03, crown: 1.06 }, + { z: -1.58, w: 1.0, pan: 0.3, sill: 0.235, hip: 0.91, belt: 1.01, rail: 1.05, crown: 1.085 }, + { z: -1.3, w: 1.005, pan: 0.3, sill: 0.235, hip: 0.915, belt: 1.03, rail: 1.08, crown: 1.115 }, + { z: -1.12, w: 1.0, pan: 0.3, sill: 0.235, hip: 0.918, belt: 1.045, rail: 1.14, crown: 1.18 }, + { z: -0.95, w: 0.998, pan: 0.3, sill: 0.235, hip: 0.92, belt: 1.055, rail: 1.33, crown: 1.39 }, + { z: -0.62, w: 0.995, pan: 0.3, sill: 0.235, hip: 0.92, belt: 1.065, rail: 1.52, crown: 1.575 }, + { z: -0.28, w: 0.995, pan: 0.3, sill: 0.235, hip: 0.92, belt: 1.072, rail: 1.605, crown: 1.658 }, + { z: 0.06, w: 0.995, pan: 0.3, sill: 0.235, hip: 0.92, belt: 1.075, rail: 1.628, crown: 1.678 }, + { z: 0.42, w: 0.992, pan: 0.3, sill: 0.235, hip: 0.92, belt: 1.075, rail: 1.632, crown: 1.68 }, + { z: 0.8, w: 0.99, pan: 0.3, sill: 0.235, hip: 0.918, belt: 1.07, rail: 1.622, crown: 1.672 }, + { z: 1.16, w: 0.988, pan: 0.3, sill: 0.238, hip: 0.915, belt: 1.058, rail: 1.59, crown: 1.64 }, + { z: 1.48, w: 0.99, pan: 0.31, sill: 0.245, hip: 0.91, belt: 1.045, rail: 1.535, crown: 1.585 }, + { z: 1.8, w: 0.982, pan: 0.33, sill: 0.265, hip: 0.9, belt: 1.028, rail: 1.45, crown: 1.505 }, + { z: 2.1, w: 0.95, pan: 0.37, sill: 0.315, hip: 0.885, belt: 1.0, rail: 1.325, crown: 1.39 }, + { z: 2.34, w: 0.875, pan: 0.42, sill: 0.375, hip: 0.855, belt: 0.96, rail: 1.155, crown: 1.215 }, + { z: 2.52, w: 0.64, pan: 0.5, sill: 0.46, hip: 0.79, belt: 0.88, rail: 0.99, crown: 1.04 }, ] as const; -/** A low-poly longitudinal loft with deliberately strong shoulder highlights. */ -function bodyLoft(): THREE.BufferGeometry { - const positions: number[] = []; - const indices: number[] = []; - const ring = 10; +const HALF_WHEELBASE = MODEL_X_METRICS.wheelbase / 2; - for (const s of BODY_SECTIONS) { - const points: readonly [number, number][] = [ - [-s.halfWidth * 0.7, s.bottom], - [-s.halfWidth, s.shoulder], - [-s.halfWidth * 0.98, s.belt], - [-s.halfWidth * 0.83, s.roof - 0.08], - [-s.halfWidth * 0.58, s.roof], - [s.halfWidth * 0.58, s.roof], - [s.halfWidth * 0.83, s.roof - 0.08], - [s.halfWidth * 0.98, s.belt], - [s.halfWidth, s.shoulder], - [s.halfWidth * 0.7, s.bottom], - ]; - for (const [x, y] of points) positions.push(x, y, s.z); +/** Half-length of the wheel opening in Z, and the height of its crown. */ +const ARCH_SEMI_Z = 0.6; +const ARCH_TOP = 0.868; +/** How far the arch lip stands proud of the shoulder line at its crown. */ +const ARCH_FLARE = 0.035; +/** Absolute half-width of the wheel-well inner wall. Must clear the tyre. */ +const WELL_HALF_WIDTH = 0.72; +/** How far the daylight opening is recessed behind its blackout surround. */ +const DLO_INSET = 0.012; + +/** + * The top of the wheel opening at `z`, or the rocker line where there is none. + * + * An ellipse rather than a circle, and one that lands exactly on the rocker line + * at its ends: a wheel arch meets the sill vertically, which is why the tangent + * at `|dz| = ARCH_SEMI_Z` is infinite here and why the opening closes cleanly + * instead of leaving a step to explain away. + */ +function archHeightAt(z: number, sill: number): number { + let top = sill; + for (const axle of [-HALF_WHEELBASE, HALF_WHEELBASE]) { + const dz = (z - axle) / ARCH_SEMI_Z; + if (Math.abs(dz) >= 1) continue; + const y = sill + (ARCH_TOP - sill) * Math.sqrt(1 - dz * dz); + if (y > top) top = y; } + return top; +} - for (let z = 0; z < BODY_SECTIONS.length - 1; z++) { - for (let i = 0; i < ring; i++) { - const next = (i + 1) % ring; - const a = z * ring + i; - const b = z * ring + next; - const c = (z + 1) * ring + next; - const d = (z + 1) * ring + i; - indices.push(a, b, d, b, c, d); +/** + * Slopes for a monotone cubic (Fritsch–Carlson) interpolant. + * + * Catmull-Rom was the obvious choice and is the wrong one: it overshoots at the + * nose and the tail, where the sections close down fast, and an overshoot in + * `crown` is a roof that is taller than the car's published height — which is + * not a cosmetic problem, because `MODEL_X_METRICS.height` is what the transport + * layer collides and frames with. The limiter below is the whole reason this is + * hand-written rather than borrowed from three's curve classes. + */ +function monotoneSlopes(xs: readonly number[], ys: readonly number[]): number[] { + const n = xs.length; + const secants: number[] = []; + for (let i = 0; i < n - 1; i++) { + secants.push((ys[i + 1]! - ys[i]!) / (xs[i + 1]! - xs[i]!)); + } + const m = new Array(n).fill(0); + m[0] = secants[0] ?? 0; + m[n - 1] = secants[n - 2] ?? 0; + for (let i = 1; i < n - 1; i++) { + const a = secants[i - 1]!; + const b = secants[i]!; + m[i] = a * b <= 0 ? 0 : (a + b) / 2; + } + for (let i = 0; i < n - 1; i++) { + const s = secants[i]!; + if (s === 0) { + m[i] = 0; + m[i + 1] = 0; + continue; + } + const a = m[i]! / s; + const b = m[i + 1]! / s; + const h = Math.hypot(a, b); + if (h > 3) { + const t = 3 / h; + m[i] = t * a * s; + m[i + 1] = t * b * s; } } + return m; +} - const frontCenter = positions.length / 3; - positions.push(0, 0.72, BODY_SECTIONS[0]!.z); - const rearCenter = positions.length / 3; - positions.push(0, 0.74, BODY_SECTIONS[BODY_SECTIONS.length - 1]!.z); - for (let i = 0; i < ring; i++) { - const next = (i + 1) % ring; - indices.push(frontCenter, next, i); - const base = (BODY_SECTIONS.length - 1) * ring; - indices.push(rearCenter, base + i, base + next); +type SectionField = Exclude; +const SECTION_FIELDS: readonly SectionField[] = ["w", "pan", "sill", "hip", "belt", "rail", "crown"]; + +/** Precomputed interpolant, built once at module load and shared by both LODs. */ +const SECTION_Z = BODY_SECTIONS.map((s) => s.z); +const SECTION_SLOPES: Record = Object.fromEntries( + SECTION_FIELDS.map((field) => [ + field, + monotoneSlopes( + SECTION_Z, + BODY_SECTIONS.map((s) => s[field]), + ), + ]), +) as Record; + +/** The bodyshell's authored lines at any `z`, clamped outside the car. */ +function sectionAt(z: number): BodySection { + const last = BODY_SECTIONS.length - 1; + const clamped = Math.min(Math.max(z, SECTION_Z[0]!), SECTION_Z[last]!); + let i = 0; + while (i < last - 1 && SECTION_Z[i + 1]! < clamped) i++; + const z0 = SECTION_Z[i]!; + const z1 = SECTION_Z[i + 1]!; + const h = z1 - z0; + const t = (clamped - z0) / h; + // Hermite basis. Written out rather than looped because the four coefficients + // are the same for every field and computing them once is the point. + const t2 = t * t; + const t3 = t2 * t; + const h00 = 2 * t3 - 3 * t2 + 1; + const h10 = t3 - 2 * t2 + t; + const h01 = -2 * t3 + 3 * t2; + const h11 = t3 - t2; + const out: Record = { z: clamped }; + for (const field of SECTION_FIELDS) { + const y0 = BODY_SECTIONS[i]![field]; + const y1 = BODY_SECTIONS[i + 1]![field]; + const slopes = SECTION_SLOPES[field]; + out[field] = h00 * y0 + h10 * h * slopes[i]! + h01 * y1 + h11 * h * slopes[i + 1]!; } + return out as unknown as BodySection; +} +/** + * The fifteen points of a half-section, outboard from the underbody centreline + * up and over to the roof centreline. + * + * Index order is the ring order, and it is load-bearing twice over: the strips + * between consecutive points are the panels, and the winding derived from + * "tangent along the ring × tangent along +Z" points outward everywhere only + * because the traversal never reverses. + */ +const P_PAN_CENTRE = 0; +const P_WELL_WALL = 3; +const P_ARCH_LIP = 4; +const P_HIP = 6; +const P_BELT = 8; +const P_DLO_BOTTOM = 9; +const P_DLO_TOP = 10; +const P_DLO_UPPER = 11; +const P_RAIL_TOP = 12; +const P_CROWN = 14; +const HALF_POINTS = 15; + +/** Which of the fifteen carry a crease. A panel boundary is exactly this set. */ +const PANEL_HARD: ReadonlySet = new Set([ + P_WELL_WALL, + P_ARCH_LIP, + P_HIP, + P_BELT, + P_DLO_BOTTOM, + P_DLO_TOP, + P_DLO_UPPER, + P_RAIL_TOP, +]); + +function smoothstep(edge0: number, edge1: number, x: number): number { + const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); +} + +/** Z range over which the car has a daylight opening at all. */ +const CABIN_Z: readonly [number, number] = [-1.16, 2.1]; +/** Z range of the panoramic glass, from the cowl to the tailgate. */ +const GLASS_ROOF_Z: readonly [number, number] = [-1.16, 2.16]; +/** The three side windows. Everything between them inside `CABIN_Z` is a pillar. */ +const WINDOW_Z: readonly (readonly [number, number])[] = [ + [-0.92, 0.16], + [0.3, 1.3], + [1.46, 1.92], +]; + +function within(range: readonly [number, number], z: number): boolean { + return z >= range[0] && z <= range[1]; +} + +/** + * The half-section at `z`, as fifteen `(x, y)` pairs. + * + * The wheel opening is a *deformation of this list*, not a hole cut into the + * finished shell, and that is the single decision the arch quality rests on. + * As `archT` runs 0 → 1: + * + * - `wellWall` slides inboard from the rocker's outer edge to the well liner + * and rises to the arch crown, so the strip below it turns from the + * underbody chamfer into the vertical inner wall of the wheel well; + * - `archLip` rises with it, so the strip between them turns from the rocker's + * underside into the horizontal roof of the wheel well; + * - the flank above shortens to the band of paint between the arch lip and the + * shoulder line. + * + * One continuous loft does all three, the shell stays watertight, and there is + * no z-order of overlapping cut geometry to get wrong. + */ +function halfSection(z: number): Float64Array { + const s = sectionAt(z); + const archY = archHeightAt(z, s.sill); + const archT = (archY - s.sill) / (ARCH_TOP - s.sill); + const flare = ARCH_FLARE * archT; + const wOuter = s.w + flare; + const wUpper = s.w + flare * 0.35; + const inset = DLO_INSET * smoothstep(CABIN_Z[0] - 0.14, CABIN_Z[0], z) * + (1 - smoothstep(CABIN_Z[1], CABIN_Z[1] + 0.14, z)); + + const out = new Float64Array(HALF_POINTS * 2); + const put = (i: number, x: number, y: number): void => { + out[i * 2] = x; + out[i * 2 + 1] = y; + }; + + put(0, 0, s.pan); + put(1, 0.44 * s.w, s.pan); + put(2, 0.72 * s.w, s.pan - 0.008); + put(3, 0.86 * s.w + (WELL_HALF_WIDTH - 0.86 * s.w) * archT, archY); + put(4, wOuter, archY); + put(5, wOuter * 0.9985, archY + (s.hip - archY) * 0.55); + put(6, wOuter, s.hip); + put(7, wUpper * 0.986, s.hip + (s.belt - s.hip) * 0.55); + put(8, wUpper * 0.965, s.belt); + put(9, wUpper * 0.965 - inset, s.belt + 0.014); + put(10, s.w * 0.878 - inset, s.rail - 0.014); + put(11, s.w * 0.878, s.rail); + put(12, s.w * 0.735, s.rail + (s.crown - s.rail) * 0.58); + put(13, s.w * 0.4, s.crown - 0.011); + put(14, 0, s.crown); + return out; +} + +/** + * Where the bodyside surface is, at a given height, on the right-hand side. + * + * Applied trim — a shutline, a handle, the window garnish, the charge-port door + * — has to sit *on* the paint, and the paint is a lofted curve rather than a + * plane. Sampling the profile is how every one of those parts finds its own + * position without any of them repeating the section table. + */ +function bodyXAt(z: number, y: number): number { + const half = halfSection(z); + let best = half[P_HIP * 2]!; + let bestGap = Infinity; + for (let i = P_ARCH_LIP; i < P_RAIL_TOP; i++) { + const y0 = half[i * 2 + 1]!; + const y1 = half[(i + 1) * 2 + 1]!; + const lo = Math.min(y0, y1); + const hi = Math.max(y0, y1); + if (y >= lo && y <= hi && hi - lo > 1e-6) { + const t = (y - y0) / (y1 - y0); + return half[i * 2]! + (half[(i + 1) * 2]! - half[i * 2]!) * t; + } + const gap = Math.min(Math.abs(y - lo), Math.abs(y - hi)); + if (gap < bestGap) { + bestGap = gap; + best = y < lo ? half[i * 2]! : half[(i + 1) * 2]!; + } + } + return best; +} + +/** A ring corner: which half-profile point, and which side of the car. */ +interface Corner { + p: number; + side: 1 | -1; +} + +/** + * The full closed ring, from the underbody centreline up the right side, over + * the crown and back down the left. + * + * Twenty-eight corners for fifteen half-points: the two on the centreline are + * shared, the rest appear twice. + */ +const RING: readonly Corner[] = (() => { + const ring: Corner[] = [{ p: P_PAN_CENTRE, side: 1 }]; + for (let p = 1; p <= HALF_POINTS - 2; p++) ring.push({ p, side: 1 }); + ring.push({ p: P_CROWN, side: 1 }); + for (let p = HALF_POINTS - 2; p >= 1; p--) ring.push({ p, side: -1 }); + return ring; +})(); + +/** + * The runs of ring corners that shade together. + * + * A panel starts and ends on a hard point, so `computeVertexNormals` run over + * one panel's geometry averages only within that panel and the boundary between + * two panels is a crease with two different normals on it. That is what "split + * vertices at creases" means in practice, and it is why the shoulder line + * survives this time. + */ +const PANELS: readonly Corner[][] = (() => { + const hardAt: number[] = []; + for (let i = 0; i < RING.length; i++) { + if (PANEL_HARD.has(RING[i]!.p)) hardAt.push(i); + } + const panels: Corner[][] = []; + for (let k = 0; k < hardAt.length; k++) { + const start = hardAt[k]!; + const end = hardAt[(k + 1) % hardAt.length]!; + const corners: Corner[] = []; + let i = start; + for (;;) { + corners.push(RING[i]!); + if (i === end) break; + i = (i + 1) % RING.length; + } + panels.push(corners); + } + return panels; +})(); + +type PanelSurface = "paint" | "glass" | "trim"; + +/** + * Which surface a panel carries at a given `z`. + * + * Three panels are not always paint, and between them they are the whole + * greenhouse: the daylight band is glass over a window and blackout trim over a + * pillar, the two reveals around it are blackout trim wherever there is a + * cabin, and the roof is glass from the cowl to the tailgate — one continuous + * panoramic pane, which is this car's whole silhouette idea and costs nothing + * extra because the roof strips already exist. + */ +function panelSurfaceAt(corners: readonly Corner[], z: number): PanelSurface { + const points = new Set(corners.map((c) => c.p)); + if (points.has(P_CROWN)) return within(GLASS_ROOF_Z, z) ? "glass" : "paint"; + const inCabin = within(CABIN_Z, z); + if (points.has(P_DLO_BOTTOM) && points.has(P_DLO_TOP)) { + if (!inCabin) return "paint"; + return WINDOW_Z.some((range) => within(range, z)) ? "glass" : "trim"; + } + const isReveal = + (points.has(P_BELT) && points.has(P_DLO_BOTTOM)) || + (points.has(P_DLO_TOP) && points.has(P_DLO_UPPER)); + if (isReveal) return inCabin ? "trim" : "paint"; + return "paint"; +} + +/** The z values a given LOD lofts through. */ +function sectionSamples(detail: ModelXDetail): number[] { + const control = + detail === "follow" + ? SECTION_Z + : SECTION_Z.filter((_, i) => i % 2 === 0 || i === 6 || i === 7 || i === 14); + // The arch is a curve inside a small span of z, so it needs its own samples; + // without them a wheel opening is an octagon however dense the rest is. + const archOffsets = detail === "follow" ? [0, 0.1, 0.22, 0.34, 0.44, 0.52, 0.575] : [0, 0.4]; + const set = new Set(control); + for (const axle of [-HALF_WHEELBASE, HALF_WHEELBASE]) { + for (const offset of archOffsets) { + set.add(Number((axle - offset).toFixed(4))); + set.add(Number((axle + offset).toFixed(4))); + } + } + return [...set] + .filter((z) => z > SECTION_Z[0]! && z < SECTION_Z[SECTION_Z.length - 1]!) + .concat([SECTION_Z[0]!, SECTION_Z[SECTION_Z.length - 1]!]) + .sort((a, b) => a - b); +} + +function finishGeometry( + positions: number[], + indices: number[], + uvs: number[], + name: string, +): THREE.BufferGeometry { const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); geometry.setIndex(indices); geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - geometry.name = "model-x.body-loft"; + // `uv` last, so the attribute order matches three's own primitives and + // `mergeGeometries` has nothing to disagree with. + geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2)); + geometry.name = name; return geometry; } -function quad(points: readonly [number, number, number][]): THREE.BufferGeometry { - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.Float32BufferAttribute(points.flat(), 3)); - geometry.setIndex([0, 1, 2, 0, 2, 3]); - geometry.computeVertexNormals(); - return geometry; +/** + * Loft every panel into `batch`, splitting each one wherever its surface + * changes along the car. + * + * A run is a maximal stretch of section pairs carrying the same surface. Runs + * are separate geometries, which means a glass-to-paint boundary is also a + * crease — correct, because it is a physical edge, and free, because the split + * had to happen anyway to change material. + */ +function emitPanels(batch: StaticBatch, materials: ModelXMaterials, detail: ModelXDetail): void { + const zs = sectionSamples(detail); + const sections = zs.map((z) => halfSection(z)); + const zSpan = zs[zs.length - 1]! - zs[0]!; + const surface: Record = { + paint: materials.paint, + glass: materials.glass, + trim: materials.trim, + }; + + for (let panelIndex = 0; panelIndex < PANELS.length; panelIndex++) { + const corners = PANELS[panelIndex]!; + const width = corners.length; + let runStart = 0; + let runSurface = panelSurfaceAt(corners, (zs[0]! + zs[1]!) / 2); + + const flush = (endPair: number): void => { + const first = runStart; + const last = endPair + 1; + const positions: number[] = []; + const uvs: number[] = []; + const indices: number[] = []; + for (let si = first; si <= last; si++) { + const half = sections[si]!; + const v = (zs[si]! - zs[0]!) / zSpan; + for (let c = 0; c < width; c++) { + const corner = corners[c]!; + positions.push(corner.side * half[corner.p * 2]!, half[corner.p * 2 + 1]!, zs[si]!); + uvs.push(width === 1 ? 0 : c / (width - 1), v); + } + } + for (let si = 0; si < last - first; si++) { + for (let c = 0; c < width - 1; c++) { + const a = si * width + c; + const b = a + 1; + const d = a + width; + const cc = d + 1; + indices.push(a, b, d, b, cc, d); + } + } + if (indices.length === 0) return; + batch.addOwned( + finishGeometry(positions, indices, uvs, `model-x.panel${panelIndex}`), + surface[runSurface], + ); + }; + + for (let pair = 1; pair < zs.length - 1; pair++) { + const next = panelSurfaceAt(corners, (zs[pair]! + zs[pair + 1]!) / 2); + if (next === runSurface) continue; + flush(pair - 1); + runStart = pair; + runSurface = next; + } + flush(zs.length - 2); + } +} + +/** Close the nose and the tail with a shallow fan. */ +function emitCaps(batch: StaticBatch, materials: ModelXMaterials, detail: ModelXDetail): void { + const zs = sectionSamples(detail); + for (const front of [true, false]) { + const z = front ? zs[0]! : zs[zs.length - 1]!; + const half = halfSection(z); + const apexZ = front ? z - 0.012 : z + 0.012; + let apexY = 0; + for (const corner of RING) apexY += half[corner.p * 2 + 1]!; + apexY /= RING.length; + + const positions: number[] = [0, apexY, apexZ]; + const uvs: number[] = [0.5, 0.5]; + for (const corner of RING) { + positions.push(corner.side * half[corner.p * 2]!, half[corner.p * 2 + 1]!, z); + uvs.push(0, 0); + } + const indices: number[] = []; + for (let i = 0; i < RING.length; i++) { + const a = 1 + i; + const b = 1 + ((i + 1) % RING.length); + // Front faces −Z and rear faces +Z, so the fan winds the opposite way. + if (front) indices.push(0, b, a); + else indices.push(0, a, b); + } + batch.addOwned( + finishGeometry(positions, indices, uvs, front ? "model-x.nose-cap" : "model-x.tail-cap"), + materials.paint, + ); + } +} + +// ---- Applied detail ------------------------------------------------------- + +/** Where the doors and the tailgate break, per side. Used for shutlines and trim. */ +const SHUTLINE_Z: readonly number[] = [-1.02, 0.2, 1.34]; + +function emitBodyDetail( + batch: StaticBatch, + materials: ModelXMaterials, + detail: ModelXDetail, +): void { + const { trim, headlight, tailLight, plate } = materials; + + // ---- Bumpers. Unpainted lower mouldings, which is what stops a dark car + // dissolving into the road and what gives the nose and tail their mass. + batch.box(trim, { position: [0, 0.42, -2.425], scale: [1.72, 0.3, 0.22] }); + batch.box(trim, { position: [0, 0.3, -2.345], scale: [1.5, 0.1, 0.38] }); + batch.box(trim, { position: [0, 0.44, 2.4], scale: [1.66, 0.3, 0.22] }); + batch.box(trim, { position: [0, 0.34, 2.32], scale: [1.44, 0.12, 0.34] }); + // Rear diffuser fins. Five thin blades read as a diffuser from any angle you + // can see under the car from, and cost sixty triangles. + for (let i = -2; i <= 2; i++) { + batch.box(trim, { position: [i * 0.24, 0.33, 2.38], scale: [0.035, 0.11, 0.26] }); + } + + // ---- Number plates. Blank, recessed, and mounted on the bumper rather than + // on the paint, which is where a plate actually goes. + batch.box(trim, { position: [0, 0.53, -2.49], scale: [0.4, 0.15, 0.02] }); + batch.box(plate, { position: [0, 0.53, -2.505], scale: [0.36, 0.115, 0.012] }); + batch.box(trim, { position: [0, 0.62, 2.45], scale: [0.4, 0.15, 0.02] }); + batch.box(plate, { position: [0, 0.62, 2.465], scale: [0.36, 0.115, 0.012] }); + + // ---- Lighting. A continuous blade across the nose and the tail, plus the + // wrapped corner units. No grille shape and no badge anywhere near either. + batch.box(headlight, { position: [0, 0.9, -2.45], scale: [1.34, 0.045, 0.03] }); + batch.box(tailLight, { position: [0, 1.0, 2.45], scale: [1.4, 0.04, 0.028] }); + batch.boxPair(headlight, { + position: [0.64, 0.9, -2.4], + scale: [0.56, 0.09, 0.04], + rotation: [0, 0.13, 0.04], + }); + batch.boxPair(tailLight, { + position: [0.64, 1.0, 2.38], + scale: [0.58, 0.08, 0.045], + rotation: [0, -0.12, -0.03], + }); + + // ---- Rocker cladding: the dark band along the bottom of the doors, sitting + // on the sampled surface so it follows the car rather than a straight line. + for (let i = 0; i < 5; i++) { + const z = -1.6 + i * 0.8; + const y = sectionAt(z).sill + 0.07; + batch.boxPair( + trim, + { position: [bodyXAt(z, y) - 0.01, y, z], scale: [0.03, 0.12, 0.82] }, + false, + ); + } + + // ---- Roof rails. Satin rather than paint, so the glass roof reads as glass + // between two rails instead of as a hole in a painted roof. + for (let i = 0; i < 6; i++) { + const z = -0.85 + i * 0.55; + const s = sectionAt(z); + batch.boxPair( + trim, + { position: [s.w * 0.735, s.rail + (s.crown - s.rail) * 0.6, z], scale: [0.05, 0.03, 0.56] }, + false, + ); + } + + // ---- Charge port. A flush door on the left rear quarter with its own shut + // line — small, and one of the very few details that says "electric" without + // writing a word on the car. + { + const z = 1.98; + const y = 0.84; + const x = bodyXAt(z, y); + batch.box(trim, { position: [-x - 0.002, y, z], scale: [0.014, 0.1, 0.15] }); + batch.box(materials.paint, { position: [-x - 0.008, y, z], scale: [0.01, 0.082, 0.13] }); + } + + if (detail !== "follow") return; + + // ---- Shutlines. Panel gaps are the cheapest possible "this is assembled out + // of pressings" cue and they are invisible until they are missing. + for (const z of SHUTLINE_Z) { + const s = sectionAt(z); + const mid = (s.sill + s.belt) / 2; + batch.boxPair( + trim, + { position: [bodyXAt(z, mid) + 0.001, mid, z], scale: [0.012, s.belt - s.sill, 0.01] }, + false, + ); + } + // Bonnet and tailgate outlines. + batch.boxPair(trim, { position: [0.72, 1.11, -1.62], scale: [0.012, 0.012, 0.9] }, false); + batch.box(trim, { position: [0, 1.175, -1.15], scale: [1.44, 0.012, 0.012] }); + batch.box(trim, { position: [0, 1.0, 2.16], scale: [1.6, 0.012, 0.012] }); + + // ---- Door handles. Flush, because a protruding handle at this scale is a + // smudge; what reads is the shadow line of the recess. + for (const z of [-0.62, 0.72]) { + const y = sectionAt(z).belt - 0.075; + const x = bodyXAt(z, y); + batch.boxPair(trim, { position: [x - 0.004, y, z], scale: [0.016, 0.042, 0.24] }, false); + } +} + +/** + * The window surround, as its own named group. + * + * The loft already recesses the daylight opening and blacks out the reveal + * around it; this is the applied garnish that sits proud of both — the strip a + * real car has along the beltline and up each pillar. It is separate geometry + * rather than more loft strips because it stands off the surface, and it is a + * separate *object* because the device and capture layers want to be able to + * find it by name. + */ +function buildGlassFrames(materials: ModelXMaterials): THREE.Group { + const batch = new StaticBatch(); + const edges = [ + CABIN_Z[0] + 0.02, + ...WINDOW_Z.flatMap((range) => [range[0], range[1]]), + CABIN_Z[1] - 0.16, + ]; + for (const z of edges) { + const s = sectionAt(z); + const mid = (s.belt + s.rail) / 2; + batch.boxPair( + materials.trim, + { position: [bodyXAt(z, mid) + 0.004, mid, z], scale: [0.014, s.rail - s.belt, 0.028] }, + false, + ); + } + // The beltline garnish, in four segments so it follows the section curve. + for (let i = 0; i < 4; i++) { + const z = -0.7 + i * 0.86; + const s = sectionAt(z); + batch.boxPair( + materials.trim, + { position: [bodyXAt(z, s.belt) + 0.004, s.belt + 0.012, z], scale: [0.014, 0.026, 0.86] }, + false, + ); + } + const group = batch.build("model-x.glass-frames"); + group.userData.kind = "vehicle-glass-frames"; + return group; +} + +/** + * Wing mirrors, as their own named group. + * + * They are held inside `MODEL_X_METRICS.width` rather than standing proud of it + * the way a real mirror does. That is deliberate and it is a simulation + * decision, not a modelling shortcut: `width` is the number the transport layer + * collides, frames and lane-positions with, and a mesh wider than its own + * published width is a car that clips kerbs it looks like it cleared. + */ +function buildMirrors(materials: ModelXMaterials): THREE.Group { + const batch = new StaticBatch(); + const z = -0.8; + const y = 1.11; + const rootX = bodyXAt(z, y - 0.06); + batch.boxPair( + materials.trim, + { position: [(rootX + 0.978) / 2, y - 0.03, z - 0.05], scale: [0.12, 0.035, 0.05] }, + false, + ); + batch.boxPair( + materials.trim, + { position: [0.978, y, z], scale: [0.1, 0.075, 0.19], rotation: [0, 0.12, 0] }, + true, + ); + for (const side of [-1, 1] as const) { + batch.add(UNIT_PLANE, materials.mirror, { + position: [side * 0.976, y, z + 0.093], + scale: [0.085, 0.058, 1], + rotation: [0, Math.PI + side * 0.12, 0], + }); + } + const group = batch.build("model-x.mirrors"); + group.userData.kind = "vehicle-mirrors"; + return group; +} + +/** + * The cabin, seen through a panoramic roof and three windows. + * + * Something has to be in here at **both** LODs. A glass shell with nothing + * inside it shows you the far side of the body from the inside, which + * backface-culls to the sky — a car you can see straight through. The corridor + * version is one dark block that reads as an occupied cabin at fifteen metres; + * the follow version is a dashboard, a console, four seats and a wheel, because + * the chase camera is two metres from the glass. + */ +function emitInterior(batch: StaticBatch, materials: ModelXMaterials, detail: ModelXDetail): void { + const t = materials.trim; + if (detail !== "follow") { + batch.box(t, { position: [0, 1.02, 0.35], scale: [1.78, 0.44, 2.6] }); + return; + } + + // Floor, transmission tunnel and door cards: the box that stops daylight. + batch.box(t, { position: [0, 0.82, 0.35], scale: [1.78, 0.06, 2.7] }); + batch.boxPair(t, { position: [0.88, 1.0, 0.35], scale: [0.06, 0.42, 2.6] }, false); + batch.box(t, { position: [0, 0.94, 0.12], scale: [0.36, 0.2, 1.5] }); + + // Dashboard, cowl and the centre display. + batch.box(t, { position: [0, 1.02, -0.86], scale: [1.74, 0.22, 0.42], rotation: [-0.14, 0, 0] }); + batch.box(t, { position: [0, 1.12, -0.72], scale: [1.7, 0.05, 0.24], rotation: [-0.3, 0, 0] }); + batch.box(t, { position: [0, 1.1, -0.62], scale: [0.42, 0.26, 0.02], rotation: [-0.1, 0, 0] }); + + // Steering wheel, on the left as most of the world drives. + batch.add( + new THREE.TorusGeometry(0.17, 0.018, 8, 20), + t, + { position: [-0.38, 1.13, -0.62], rotation: [1.24, 0, 0] }, + ); + batch.box(t, { position: [-0.38, 1.05, -0.55], scale: [0.05, 0.16, 0.05], rotation: [0.33, 0, 0] }); + + // Four seats. A base, a raked back and a headrest each — the headrest is what + // makes a seat read as a seat through glass, because it breaks the skyline. + for (const [z, height] of [ + [-0.22, 0.58], + [0.92, 0.52], + ] as const) { + batch.boxPair(t, { position: [0.36, 0.88, z], scale: [0.5, 0.12, 0.5] }, false); + batch.boxPair( + t, + { position: [0.36, 0.94, z + 0.3], scale: [0.48, height, 0.14], rotation: [0.16, 0, 0] }, + false, + ); + batch.boxPair( + t, + { position: [0.36, 0.94 + height, z + 0.36], scale: [0.26, 0.16, 0.11] }, + false, + ); + } } function buildBody(materials: ModelXMaterials, detail: ModelXDetail): THREE.Group { const batch = new StaticBatch(); - batch.addOwned(bodyLoft(), materials.paint); - - // The windows are fitted panels rather than holes. Against black paint their - // blue-grey reflectance is what separates the greenhouse from the body. - batch.addOwned( - quad([ - [-0.77, 1.04, -0.79], - [0.77, 1.04, -0.79], - [0.63, 1.56, -0.14], - [-0.63, 1.56, -0.14], - ]), - materials.glass, - ); - batch.addOwned( - quad([ - [-0.61, 1.58, -0.1], - [0.61, 1.58, -0.1], - [0.58, 1.64, 1.18], - [-0.58, 1.64, 1.18], - ]), - materials.glass, - ); - batch.addOwned( - quad([ - [-0.65, 1.5, 1.45], - [0.65, 1.5, 1.45], - [0.72, 1.04, 2.12], - [-0.72, 1.04, 2.12], - ]), - materials.glass, - ); - - for (const side of [-1, 1]) { - const x = side * 0.956; - batch.addOwned( - quad([ - [x, 1.06, -0.71], - [x, 1.55, -0.08], - [x, 1.59, 0.3], - [x, 1.06, 0.3], - ]), - materials.glass, - ); - batch.addOwned( - quad([ - [x, 1.06, 0.34], - [x, 1.59, 0.34], - [side * 0.94, 1.49, 1.34], - [side * 0.96, 1.05, 1.46], - ]), - materials.glass, - ); - - // Slim pillars and the falcon-door roof seam survive a chase camera while - // keeping the side glass readable as two doors rather than one dark strip. - batch.box(materials.trim, { - position: [x, 1.31, 0.32], - scale: [0.028, 0.56, 0.045], - }); - batch.box(materials.trim, { - position: [side * 0.76, 1.625, 0.73], - scale: [0.022, 0.025, 0.86], - rotation: [0, 0, side * -0.08], - }); - - // Flush black handles: relief and a highlight, never a badge. - if (detail === "follow") { - for (const z of [-0.24, 0.82]) { - batch.box(materials.trim, { - position: [side * 0.987, 1.025, z], - scale: [0.018, 0.035, 0.22], - }); - } - } - - // Headlights sweep back into the fender; taillights wrap the rear corner. - batch.box(materials.headlight, { - position: [side * 0.65, 0.88, -2.39], - scale: [0.56, 0.085, 0.035], - rotation: [0, side * 0.13, side * 0.04], - }); - batch.box(materials.tailLight, { - position: [side * 0.65, 0.97, 2.35], - scale: [0.58, 0.075, 0.04], - rotation: [0, side * -0.12, side * -0.03], - }); - } - - // EV-01 signature: continuous light blades and satin aero rails remain - // readable at corridor LOD without a badge or a borrowed grille shape. - batch.box(materials.headlight, { - position: [0, 0.89, -2.43], - scale: [1.28, 0.035, 0.028], - }); - batch.box(materials.tailLight, { - position: [0, 0.98, 2.39], - scale: [1.34, 0.032, 0.026], - }); - for (const side of [-1, 1]) { - batch.box(materials.wheel, { - position: [side * 0.985, 0.47, 0.12], - scale: [0.025, 0.06, 3.45], - }); - } - - if (detail === "follow") { - // A small real interior reads through the panoramic glazing in chase and - // driver cameras. It deliberately uses the pooled trim surface. - batch.box(materials.trim, { - position: [0, 1.05, -0.58], - scale: [1.45, 0.12, 0.34], - rotation: [-0.12, 0, 0], - }); - for (const side of [-1, 1]) { - batch.box(materials.trim, { - position: [side * 0.38, 1.02, 0.08], - scale: [0.42, 0.52, 0.46], - rotation: [0.12, 0, 0], - }); - batch.box(materials.trim, { - position: [side * 0.38, 1.33, 0.17], - scale: [0.32, 0.28, 0.2], - }); - } - } - - // Lower aero surfaces stop the black shell dissolving into the road. - batch.box(materials.trim, { - position: [0, 0.39, -1.92], - scale: [1.82, 0.13, 1.02], - }); - batch.box(materials.trim, { - position: [0, 0.42, 2.32], - scale: [1.5, 0.15, 0.3], - }); - batch.add(UNIT_PLANE, materials.trim, { - position: [0, 1.685, 0.68], - scale: [1.18, 0.72, 1], - rotation: [-Math.PI / 2, 0, 0], - }); - + emitPanels(batch, materials, detail); + emitCaps(batch, materials, detail); + emitBodyDetail(batch, materials, detail); + emitInterior(batch, materials, detail); const body = batch.build("model-x.body"); body.userData.kind = "vehicle-body"; return body; } +// ---- Wheels --------------------------------------------------------------- + +/** One point of a profile revolved about the wheel's X axis. */ +interface RevolvePoint { + /** Lateral offset from the wheel's centre plane. */ + x: number; + /** Radius. */ + r: number; + /** Start a new shading panel here. Same crease machinery as the bodyshell. */ + hard?: boolean; +} + +/** + * Revolve a profile about +X, splitting at creases the same way the body does. + * + * `flip` mirrors the profile for the other side of the car and reverses the + * winding with it. Building two variants rather than scaling one by −1 is the + * only correct option: a negative scale inverts every normal, and a wheel whose + * rim lights from the inside is a bug that survives three code reviews because + * it only shows on one side. + */ +function revolveX( + profile: readonly RevolvePoint[], + segments: number, + name: string, + flip = false, +): THREE.BufferGeometry[] { + const points = flip ? profile.map((p) => ({ ...p, x: -p.x })).reverse() : profile; + const runs: RevolvePoint[][] = []; + let current: RevolvePoint[] = []; + for (let i = 0; i < points.length; i++) { + const point = points[i]!; + current.push(point); + const isLast = i === points.length - 1; + if (!isLast && point.hard === true && current.length > 1) { + runs.push(current); + current = [point]; + } + if (isLast) runs.push(current); + } + + return runs + .filter((run) => run.length > 1) + .map((run, runIndex) => { + const positions: number[] = []; + const uvs: number[] = []; + const indices: number[] = []; + for (let i = 0; i < run.length; i++) { + const point = run[i]!; + for (let j = 0; j <= segments; j++) { + const angle = (j / segments) * Math.PI * 2; + positions.push(point.x, point.r * Math.cos(angle), point.r * Math.sin(angle)); + uvs.push(j / segments, i / (run.length - 1)); + } + } + const stride = segments + 1; + for (let i = 0; i < run.length - 1; i++) { + for (let j = 0; j < segments; j++) { + const a = i * stride + j; + const b = (i + 1) * stride + j; + const d = a + 1; + const c = b + 1; + // Derived from a plain cylinder at angle 0: (B−A)×(D−A) points inward + // there, so the quad winds A,D,B / B,D,C to face out. + indices.push(a, d, b, b, d, c); + } + } + return finishGeometry(positions, indices, uvs, `${name}.${runIndex}`); + }); +} + +const TIRE_OUTER = MODEL_X_METRICS.wheelRadius; +/** Visual half-width. Narrower than `tireWidth` so the tyre clears the arch lip. */ +const TIRE_HALF = 0.12; + +/** + * The tyre profile, outside-in. + * + * Three things this has that a torus does not, in order of how much they + * matter: + * + * 1. **A flat tread band.** The tread sits at exactly `wheelRadius` across its + * whole width, so the tyre meets the road along a line rather than at a + * point. That line *is* the contact patch as far as the eye is concerned. + * A modelled flat spot would be wrong for a different reason worth stating: + * the tyre spins, so a deformation baked into the mesh rotates with it, and + * a flat spot going round and round is a broken wheel rather than a loaded + * one. + * 2. **A sidewall that bulges past the shoulder.** A loaded tyre's widest point + * is below the tread, not at it, and that single silhouette cue is most of + * what makes a car look like it has weight on its wheels. + * 3. **Circumferential grooves**, at `follow` only. Four ribs, two grooves a + * side. Lateral tread blocks were tried and dropped: at the size a tyre + * occupies on screen they add several hundred triangles of noise that the + * mip chain eats anyway. + */ +function tireProfile(detail: ModelXDetail): RevolvePoint[] { + const shoulder: RevolvePoint[] = + detail === "follow" + ? [ + { x: -TIRE_HALF, r: 0.25, hard: true }, + { x: -TIRE_HALF - 0.008, r: 0.286 }, + { x: -TIRE_HALF - 0.013, r: 0.33 }, + { x: -TIRE_HALF - 0.008, r: 0.372 }, + { x: -TIRE_HALF + 0.002, r: 0.398, hard: true }, + ] + : [ + { x: -TIRE_HALF, r: 0.25, hard: true }, + { x: -TIRE_HALF - 0.013, r: 0.33 }, + { x: -TIRE_HALF + 0.002, r: 0.398, hard: true }, + ]; + const tread: RevolvePoint[] = + detail === "follow" + ? [ + { x: -0.104, r: TIRE_OUTER, hard: true }, + { x: -0.062, r: TIRE_OUTER, hard: true }, + { x: -0.052, r: 0.392, hard: true }, + { x: -0.03, r: 0.392, hard: true }, + { x: -0.02, r: TIRE_OUTER, hard: true }, + { x: 0.02, r: TIRE_OUTER, hard: true }, + { x: 0.03, r: 0.392, hard: true }, + { x: 0.052, r: 0.392, hard: true }, + { x: 0.062, r: TIRE_OUTER, hard: true }, + { x: 0.104, r: TIRE_OUTER, hard: true }, + ] + : [ + { x: -0.104, r: TIRE_OUTER, hard: true }, + { x: 0.104, r: TIRE_OUTER, hard: true }, + ]; + const mirrored = shoulder + .map((p) => ({ ...p, x: -p.x })) + .reverse(); + return [...shoulder, ...tread, ...mirrored]; +} + +/** + * The rim: a dished barrel with a turbine face. + * + * The barrel is revolved so the lip, the well and the mounting face are one + * continuous surface with creases where a real wheel has them. The spokes are + * boxes, tapered by placing them on a radius and letting the scale do the work, + * which is enough at any distance a wheel is legible from. + */ +function rimGeometries(detail: ModelXDetail, flip: boolean): THREE.BufferGeometry[] { + const segments = detail === "follow" ? 28 : 10; + // The corridor barrel keeps the lip, the mounting face and the dish and drops + // the intermediate radii: at fifteen metres a wheel is forty pixels across and + // the profile between the lip and the hub is one of them. + const profile: RevolvePoint[] = + detail === "follow" + ? [ + { x: -0.108, r: 0.252, hard: true }, + { x: -0.104, r: 0.29 }, + { x: -0.09, r: 0.31, hard: true }, + { x: -0.02, r: 0.318, hard: true }, + { x: 0.04, r: 0.322 }, + { x: 0.086, r: 0.34, hard: true }, + { x: 0.104, r: 0.345, hard: true }, + { x: 0.108, r: 0.33, hard: true }, + { x: 0.09, r: 0.3 }, + { x: 0.075, r: 0.2, hard: true }, + { x: 0.072, r: 0.09, hard: true }, + { x: 0.078, r: 0.075, hard: true }, + { x: 0.05, r: 0.07 }, + { x: 0.02, r: 0.068, hard: true }, + ] + : [ + { x: -0.104, r: 0.29, hard: true }, + { x: -0.02, r: 0.318, hard: true }, + { x: 0.104, r: 0.345, hard: true }, + { x: 0.108, r: 0.33, hard: true }, + { x: 0.075, r: 0.2, hard: true }, + { x: 0.072, r: 0.075, hard: true }, + { x: 0.02, r: 0.068, hard: true }, + ]; + const parts = revolveX(profile, segments, "model-x.rim", flip); + + const spokes = detail === "follow" ? 10 : 5; + const face = flip ? -0.082 : 0.082; + for (let i = 0; i < spokes; i++) { + const angle = (i / spokes) * Math.PI * 2; + parts.push( + UNIT_BOX.clone().applyMatrix4( + matrix({ + position: [face, Math.cos(angle) * 0.2, Math.sin(angle) * 0.2], + scale: [0.032, 0.28, 0.052], + rotation: [angle, 0, 0], + }), + ), + ); + } + return parts; +} + interface WheelGeometry { tire: THREE.BufferGeometry; rim: THREE.BufferGeometry; brake: THREE.BufferGeometry; + /** Non-rotating. Present at `follow` only. */ + caliper: THREE.BufferGeometry | null; } -function createWheelGeometry(detail: ModelXDetail): WheelGeometry { - const radialSegments = detail === "follow" ? 24 : 16; - const tubularSegments = detail === "follow" ? 12 : 8; - const tire = new THREE.TorusGeometry(0.315, 0.09, tubularSegments, radialSegments); - tire.rotateY(Math.PI / 2); - tire.name = "model-x.wheel.tire"; +function mergeOwned(parts: THREE.BufferGeometry[], name: string): THREE.BufferGeometry { + const merged = parts.length === 1 ? parts[0]! : mergeGeometries(parts, false); + if (parts.length > 1) for (const part of parts) part.dispose(); + if (!merged) throw new Error(`model-x: could not merge "${name}"`); + merged.name = name; + return merged; +} - const rimParts: THREE.BufferGeometry[] = []; - const lip = new THREE.TorusGeometry(0.218, 0.024, 6, radialSegments); - lip.rotateY(Math.PI / 2); - rimParts.push(lip); - const hub = new THREE.CylinderGeometry(0.067, 0.067, 0.1, 12, 1); - hub.rotateZ(Math.PI / 2); - rimParts.push(hub); - for (let i = 0; i < 5; i++) { - const angle = (i / 5) * Math.PI * 2; - const spoke = UNIT_BOX.clone().applyMatrix4( - matrix({ - position: [0, Math.cos(angle) * 0.13, Math.sin(angle) * 0.13], - scale: [0.055, 0.255, 0.035], - rotation: [angle, 0, 0], - }), +function createWheelGeometry(detail: ModelXDetail, flip: boolean): WheelGeometry { + const segments = detail === "follow" ? 32 : 12; + const tire = mergeOwned( + revolveX(tireProfile(detail), segments, "model-x.tire", flip), + "model-x.wheel.tire", + ); + const rim = mergeOwned(rimGeometries(detail, flip), "model-x.wheel.rim"); + + // The disc, with a hat behind it so it is not a coin seen edge-on. + const discSegments = detail === "follow" ? 24 : 12; + const disc = new THREE.CylinderGeometry(0.185, 0.185, 0.026, discSegments, 1); + disc.rotateZ(Math.PI / 2); + const hat = new THREE.CylinderGeometry(0.088, 0.088, 0.07, Math.max(8, discSegments >> 1), 1); + hat.rotateZ(Math.PI / 2); + hat.translate(flip ? 0.03 : -0.03, 0, 0); + const brake = mergeOwned([disc, hat], "model-x.wheel.brake"); + + // The caliper does not rotate with the wheel, so it is built separately and + // hung off the steering group. A caliper that spins is the single most common + // tell that a wheel was assembled by whoever needed it done quickly. + let caliper: THREE.BufferGeometry | null = null; + if (detail === "follow") { + const side = flip ? -1 : 1; + const body = UNIT_BOX.clone().applyMatrix4( + matrix({ position: [side * 0.045, 0.2, 0.05], scale: [0.055, 0.14, 0.1] }), ); - rimParts.push(spoke); + const bridge = UNIT_BOX.clone().applyMatrix4( + matrix({ position: [0, 0.208, 0.05], scale: [0.13, 0.05, 0.09] }), + ); + const inner = UNIT_BOX.clone().applyMatrix4( + matrix({ position: [side * -0.045, 0.2, 0.05], scale: [0.04, 0.12, 0.09] }), + ); + caliper = mergeOwned([body, bridge, inner], "model-x.wheel.caliper"); } - const rim = mergeGeometries(rimParts, false); - for (const part of rimParts) part.dispose(); - if (!rim) throw new Error("model-x: could not build wheel rim"); - rim.name = "model-x.wheel.rim"; - - const brake = new THREE.CylinderGeometry(0.17, 0.17, MODEL_X_METRICS.tireWidth + 0.018, 20, 1); - brake.rotateZ(Math.PI / 2); - brake.name = "model-x.wheel.brake"; - return { tire, rim, brake }; + return { tire, rim, brake, caliper }; } function buildWheel( @@ -515,6 +1422,13 @@ function buildWheel( tire.receiveShadow = true; spin.add(tire); + if (geometries.caliper) { + const caliper = new THREE.Mesh(geometries.caliper, materials.brake); + caliper.name = `${name}.caliper`; + caliper.castShadow = true; + steering.add(caliper); + } + return { steering, spin, tire, rim }; } @@ -553,18 +1467,26 @@ export function buildModelX(options: ModelXBuildOptions = {}): ModelXRig { root.userData.unbranded = true; root.userData.forwardAxis = "-Z"; - root.add(buildBody(materials, detail)); - const geometry = createWheelGeometry(detail); + const body = buildBody(materials, detail); + root.add(body); + if (detail === "follow") { + root.add(buildMirrors(materials)); + root.add(buildGlassFrames(materials)); + } + + // Two geometry sets, one per side, so the rim dish and the caliper face the + // right way on both. Left and right share nothing but the material. + const right = createWheelGeometry(detail, false); + const left = createWheelGeometry(detail, true); const halfTrack = MODEL_X_METRICS.track / 2; - const halfWheelbase = MODEL_X_METRICS.wheelbase / 2; const wheels: ModelXWheels = { - frontLeft: buildWheel("frontLeft", -halfTrack, -halfWheelbase, geometry, materials), - frontRight: buildWheel("frontRight", halfTrack, -halfWheelbase, geometry, materials), - rearLeft: buildWheel("rearLeft", -halfTrack, halfWheelbase, geometry, materials), - rearRight: buildWheel("rearRight", halfTrack, halfWheelbase, geometry, materials), + frontLeft: buildWheel("frontLeft", -halfTrack, -HALF_WHEELBASE, left, materials), + frontRight: buildWheel("frontRight", halfTrack, -HALF_WHEELBASE, right, materials), + rearLeft: buildWheel("rearLeft", -halfTrack, HALF_WHEELBASE, left, materials), + rearRight: buildWheel("rearRight", halfTrack, HALF_WHEELBASE, right, materials), }; for (const key of WHEEL_KEYS) root.add(wheels[key].steering); - return { root, body: root.getObjectByName("model-x.body") as THREE.Group, wheels, ownsMaterials: !options.materials }; + return { root, body, wheels, ownsMaterials: !options.materials }; } /** Preferred builder for the original Lumbridge EV visual. */ diff --git a/src/devices/adapter.ts b/src/devices/adapter.ts new file mode 100644 index 0000000..6ab6704 --- /dev/null +++ b/src/devices/adapter.ts @@ -0,0 +1,402 @@ +/** + * Where device readings come from — and what to do when nowhere will say. + * + * The seam. Above it, the office scene and the device panel take a + * `DeviceSource` and never learn whether a server answered. Below it there are + * two strategies and one rule for choosing between them, and both the + * strategies and the rule live here so that no consumer has to hold an opinion + * about deployments. + * + * ### The rule + * + * **A studio is never dark.** If this deployment has a device source and this + * viewer may read it, the readings come over the API. Otherwise they come from + * `sim.ts`, running in this tab, labelled `synthetic: true` and `live: false`. + * That is the same shape `markers()` takes to `sample.ts` and `HttpFlights` + * takes to `SimulatedFlights`, and it is the same argument: an empty panel and + * a working panel look identical to a broken one, and the clone-and-run case in + * CONTRACT.md §0 is the commonest way this bundle is used. + * + * ### Reading is the demo; writing is the account + * + * `routes/devices.ts` refuses an anonymous read, and it should — a reading is + * about a room somebody is standing in. But an anonymous visitor is the + * audience this product is designed for, so a refusal must not produce a dead + * instrument: it produces the local simulator, which is honest, alive and says + * in the panel exactly what it is. + * + * Commands do **not** get the same treatment across the boundary. On the API + * strategy a refused command is a refusal, reported as `null`, and the panel + * tells the person to sign in — it is never quietly applied to a local copy, + * because a control that appears to work and changes nothing anybody else can + * see is worse than one that says no. On the simulated strategy a command is + * applied locally and openly: nothing there claims to be a real room. + * + * ### No timers + * + * Nothing here owns a `setInterval`. The simulated strategy is advanced by + * `tick(dt)` from whatever render loop already exists — the same idiom + * `luminaires.ts` and `FlightLayer` use — so there is no handle to leak, no + * work done in a tab nobody is looking at, and a test can step it by hand and + * get the same numbers every time. The API strategy's polling lives in + * `watchDevices` in `adapters/http.ts`, which is where every other watch's + * timer already is. + */ + +import type { DevicesSourceId } from "../server/wire.ts"; +import { createSimulatedDevices, type SimulatedDevices } from "./sim.ts"; +import { + deviceStateSignature, + initialDeviceState, + normalizeDeviceCommand, + type DeviceCommand, + type DeviceDeclaration, + type DeviceState, +} from "./types.ts"; + +/** + * What the caller is handed, with the two provenance facts attached. + * + * `live` is "a deployment answered"; `synthetic` is "nobody observed this". + * They are independent and the interface needs both — see `DeviceFeed` in + * `adapters/http.ts`, which carries the same pair over the wire and tabulates + * every combination that exists. + */ +export interface DeviceReading { + states: DeviceState[]; + live: boolean; + synthetic: boolean; + source: DevicesSourceId; + attribution: string[]; +} + +export interface DeviceSource { + /** The latest reading. Never null and never a promise; safe in a render loop. */ + current(): DeviceReading; + /** + * Advance simulated time by `dtSeconds`. + * + * A no-op on the API strategy, which is driven by its own poll. Call it every + * frame; it is a handful of arithmetic per device and it is what makes the + * meters move. + */ + tick(dtSeconds: number): void; + /** Ask again now. A no-op on the simulated strategy, which is always current. */ + refresh(): void; + /** + * Send a command. Resolves to the resulting state, or `null` for a refusal. + * + * `null` is the honest answer to "you are not signed in", "that device is not + * in this office" and "that op is not one this device declared" alike. The + * caller shows one message; distinguishing them here would be a taxonomy + * nobody branches on. + */ + command(command: DeviceCommand): Promise; + /** + * Which seats have somebody in them, so a microphone can respond to the room. + * + * Only the simulated strategy uses it — a real bridge is reading real + * hardware and does not need to be told who is at the desk. Passing an empty + * array means "nobody", which is a different statement from never calling it + * at all; see `setOccupancy` in `sim.ts`. + */ + setOccupancy(seatIds: readonly string[]): void; + /** Stop any poll and drop any late answer. Idempotent. */ + stop(): void; +} + +/** + * Just enough of `TeraClient` to read and command devices. + * + * A structural slice rather than the whole client, so this module does not + * depend on the adapter's other nine methods and a test can hand in an object + * with two functions on it. `adapters/http.ts` satisfies it by construction. + */ +export interface DeviceClient { + watchDevices( + officeId: string, + onFeed: (feed: { + value: DeviceState[]; + live: boolean; + synthetic: boolean; + source: DevicesSourceId; + attribution: string[]; + }) => void, + ): { current(): unknown; refresh(): void; stop(): void }; + commandDevice(officeId: string, command: DeviceCommand): Promise; +} + +export interface DeviceSourceOptions { + /** What this office declared. The simulated strategy runs exactly these. */ + declarations: readonly DeviceDeclaration[]; + /** Fired whenever a reading a viewer could notice has changed. */ + onReading?: (reading: DeviceReading) => void; + /** The API client, or `null` on a build with no server behind it. */ + client?: DeviceClient | null; + officeId?: string; + /** + * Whether `/health` said this deployment has a device source at all. + * + * `false` means the API strategy is skipped without a request being made — + * the same job `Feeds` does in `access.ts` for weather and markers, and for + * the same reason: a poll against a box whose answer is structurally empty is + * a request per TTL per tab, forever, to be told nothing. + */ + serverHasDevices?: boolean; + /** The simulator's seed. Same seed, same studio, on every machine. */ + seed?: number; + /** Seconds per simulated step. Smaller is smoother and costs arithmetic. */ + fixedStepSeconds?: number; + /** What simulated time zero means, for `observedAt`. Defaults to now. */ + epochMs?: number; +} + +/** + * How many fixed steps one `tick` may run. + * + * A tab that was backgrounded for ten minutes comes back with a `dt` of six + * hundred seconds, and catching up honestly would be six thousand steps in one + * frame — a visible hitch to arrive at a meter reading nobody was watching + * accumulate. The excess is dropped rather than queued: simulated time is + * allowed to lag, because nothing downstream measures it against a clock. + */ +const MAX_STEPS_PER_TICK = 12; + +const DEFAULT_FIXED_STEP_SECONDS = 0.1; +const DEFAULT_SEED = 8731; + +/** + * A source that never has anything to say. + * + * For an office that declares no devices — which is most offices, and every + * pack written before devices existed. Distinct from a simulated source with an + * empty declaration list only in that it is obviously nothing: no simulator is + * constructed and `tick` does no work at all. + */ +export function createNullDeviceSource(): DeviceSource { + const reading: DeviceReading = { + states: [], + live: false, + // An empty studio has invented nothing, but saying `synthetic: false` would + // read as "these zero readings were observed", which is a claim about a + // room. Nothing here observed anything. + synthetic: true, + source: "none", + attribution: [], + }; + return { + current: () => reading, + tick: () => {}, + refresh: () => {}, + command: () => Promise.resolve(null), + setOccupancy: () => {}, + stop: () => {}, + }; +} + +/** + * The source for one office, choosing its own strategy. + * + * See the header for the rule. The choice is made once, at construction, from + * facts the caller already has — there is no runtime failover, because a + * strategy that silently swapped a real bridge for a simulator mid-session + * would be the `first-party-sensor`/`simulated` confusion `DeviceProvenance` + * exists to prevent, arriving without a word in the interface. + * + * What *does* change at runtime is `live`: an API strategy whose deployment + * stops answering reports `live: false` and an empty list, exactly as + * `watchPresence` reports a floor it has stopped hearing about. It does not + * quietly start inventing readings instead. + */ +export function createDeviceSource(options: DeviceSourceOptions): DeviceSource { + const declarations = options.declarations; + if (declarations.length === 0) return createNullDeviceSource(); + + const useApi = + options.client !== null && + options.client !== undefined && + typeof options.officeId === "string" && + options.officeId !== "" && + options.serverHasDevices !== false; + + return useApi + ? apiSource(options.client as DeviceClient, options.officeId as string, declarations, options) + : simulatedSource(declarations, options); +} + +/** + * Readings over the API. + * + * Thin on purpose: `watchDevices` already owns the poll, the back-off, the + * hidden-tab rule and the publish-on-change comparison, and duplicating any of + * it here would be a second place for the cadence to be wrong. + * + * The pre-connection reading is `initialDeviceState` for every declaration + * rather than an empty list, so the panel draws its instruments immediately — + * powered off, meters at the floor, marked not live. An empty list would make + * the panel flicker into existence a poll later, and would be indistinguishable + * from an office that declared nothing. + */ +function apiSource( + client: DeviceClient, + officeId: string, + declarations: readonly DeviceDeclaration[], + options: DeviceSourceOptions, +): DeviceSource { + const observedAt = options.epochMs ?? Date.now(); + /** + * The instruments, at rest, before anything has been heard — and again + * whenever the deployment stops answering. + * + * A refusal must not empty the panel. An empty `states` means "this office + * declares no hardware", which is a different sentence from "nobody will tell + * me what the hardware is doing", and collapsing the two makes a panel + * disappear at exactly the moment somebody is wondering why it is not + * working. `live: false` is what says nobody answered; the readings alongside + * it are the at-rest defaults, every one of them `synthetic: true`, which is + * the same claim `initialDeviceState` is documented to make. + */ + const atRest = (): DeviceState[] => declarations.map((d) => initialDeviceState(d, observedAt)); + + let reading: DeviceReading = { + states: atRest(), + live: false, + synthetic: true, + source: "none", + attribution: [], + }; + + const watch = client.watchDevices(officeId, (feed) => { + reading = { + states: feed.live ? feed.value : atRest(), + live: feed.live, + synthetic: feed.synthetic, + source: feed.source, + attribution: feed.attribution, + }; + options.onReading?.(reading); + }); + + return { + current: () => reading, + // The server's clock, not ours. Advancing a local simulation alongside a + // real feed would put two sets of numbers on one meter. + tick: () => {}, + refresh: () => watch.refresh(), + async command(command: DeviceCommand): Promise { + const declaration = declarations.find((d) => d.id === command.deviceId); + if (declaration === undefined) return null; + // Validated before it is sent as well as after it arrives. Not + // redundancy: this is what stops a slider that has been dragged past its + // own bounds from spending a round trip to be told no, and the server's + // copy of the check is there because a browser is not a boundary. + const normalized = normalizeDeviceCommand(declaration, command); + if (normalized === null) return null; + const state = await client.commandDevice(officeId, normalized); + // A command that landed changes the room, so ask for the new picture + // rather than waiting out the poll — and ask rather than patching the + // local copy, because the server is the authority on what the state now + // is and it may have clamped what we sent. + if (state !== null) watch.refresh(); + return state; + }, + setOccupancy: () => {}, + stop: () => watch.stop(), + }; +} + +/** + * Readings from the simulator in this tab. + * + * The anonymous visitor's studio, the offline clone's studio, and the studio a + * self-hoster gets before they have configured anything. Everything it produces + * is `synthetic: true` and `live: false`, which is what the panel puts in front + * of the viewer alongside each declaration's own disclosure sentence. + */ +function simulatedSource( + declarations: readonly DeviceDeclaration[], + options: DeviceSourceOptions, +): DeviceSource { + const fixedStepSeconds = options.fixedStepSeconds ?? DEFAULT_FIXED_STEP_SECONDS; + const simulator: SimulatedDevices = createSimulatedDevices(declarations, { + seed: options.seed ?? DEFAULT_SEED, + fixedStepSeconds, + epochMs: options.epochMs ?? Date.now(), + }); + + let states = simulator.current(); + let signature = deviceStateSignature(states); + let carried = 0; + let stopped = false; + + const read = (): DeviceReading => ({ + states, + live: false, + synthetic: true, + // `"sim"` and not `"none"`: something is producing these readings and the + // interface is entitled to name it. `"none"` is reserved for a source that + // produces nothing, which is what `createNullDeviceSource` is. + source: "sim", + attribution: [], + }); + let reading = read(); + + function republish(): void { + states = simulator.current(); + const next = deviceStateSignature(states); + reading = read(); + // Only when something a viewer could see has moved. `observedAt` advances + // on every step and is deliberately not in the signature, or this would + // publish at the tick rate forever. + if (next === signature) return; + signature = next; + options.onReading?.(reading); + } + + return { + current: () => reading, + + tick(dtSeconds: number): void { + if (stopped || !Number.isFinite(dtSeconds) || dtSeconds <= 0) return; + carried += dtSeconds; + let steps = 0; + while (carried >= fixedStepSeconds && steps < MAX_STEPS_PER_TICK) { + simulator.stepFixed(); + carried -= fixedStepSeconds; + steps += 1; + } + // Whatever is left over after the cap is dropped rather than banked; see + // `MAX_STEPS_PER_TICK`. + if (steps === MAX_STEPS_PER_TICK) carried = 0; + if (steps > 0) republish(); + }, + + refresh(): void { + // Always current by construction — there is nothing to ask. Republished + // anyway so a caller that calls `refresh()` to force a redraw gets one. + republish(); + }, + + command(command: DeviceCommand): Promise { + if (stopped) return Promise.resolve(null); + const declaration = declarations.find((d) => d.id === command.deviceId); + if (declaration === undefined) return Promise.resolve(null); + const normalized = normalizeDeviceCommand(declaration, command); + if (normalized === null) return Promise.resolve(null); + simulator.command(normalized); + republish(); + const applied = states.find((s) => s.id === normalized.deviceId) ?? null; + // A promise even though nothing is awaited, so the two strategies are the + // same shape to the caller and a panel does not have to know which it has. + return Promise.resolve(applied); + }, + + setOccupancy(seatIds: readonly string[]): void { + simulator.setOccupancy(seatIds); + }, + + stop(): void { + stopped = true; + }, + }; +} diff --git a/src/devices/index.ts b/src/devices/index.ts new file mode 100644 index 0000000..316f681 --- /dev/null +++ b/src/devices/index.ts @@ -0,0 +1,64 @@ +/** + * The device surface, in one import. + * + * A barrel and nothing else — no logic, no re-shaping, no defaults. It exists + * because four consumers want different thirds of this directory and none of + * them should have to know which file a name lives in: a pack authors against + * `types.ts`, the arena drives `sim.ts`, the office scene takes a + * `DeviceSource` from `adapter.ts`, and the panel wants the vocabulary tables + * from all three. + * + * **Nothing here imports THREE, the DOM or the network**, and that is a + * property worth stating rather than assuming: `src/index.ts` re-exports this + * onto the package surface, and `src/arena/` may import it, so a three.js + * import added anywhere under `src/devices/` would break both at once. The + * render layer for devices is `src/interiors/devices.ts`, which is deliberately + * *not* re-exported from here. + */ + +export { + CANONICAL_CAPABILITIES, + CAPABILITY_READING, + DEVICE_CAPABILITIES, + DEVICE_COMMAND_OPS, + DEVICE_KINDS, + DEVICE_PROVENANCE, + DEVICE_RANGES, + deviceKindOfAssetId, + deviceStateSignature, + hasCapability, + initialDeviceState, + isDeviceCapability, + isDeviceCommandOp, + isDeviceKind, + isDeviceProvenance, + normalizeDeviceCommand, + validateDeviceDeclaration, + type DeviceAnchor, + type DeviceAssetId, + type DeviceCapability, + type DeviceCommand, + type DeviceCommandOp, + type DeviceCommandValue, + type DeviceDeclaration, + type DeviceKind, + type DeviceOffset, + type DeviceProvenance, + type DeviceRange, + type DeviceState, +} from "./types.ts"; + +export { + createSimulatedDevices, + type SimulatedDevices, + type SimulatedDevicesOptions, +} from "./sim.ts"; + +export { + createDeviceSource, + createNullDeviceSource, + type DeviceClient, + type DeviceReading, + type DeviceSource, + type DeviceSourceOptions, +} from "./adapter.ts"; diff --git a/src/devices/sim.ts b/src/devices/sim.ts new file mode 100644 index 0000000..113a735 --- /dev/null +++ b/src/devices/sim.ts @@ -0,0 +1,536 @@ +/** + * Devices that behave like hardware without being any. + * + * A fixed-step, seeded state machine over a list of `DeviceDeclaration`s. Same + * seed, same declarations, same commands, same readings — on every machine, + * forever, with no clock read anywhere inside `stepFixed()`. + * + * ### Why determinism is the whole design and not a nicety + * + * The arena imports **this module**, not a headless copy of it. `studio-ops-v1` + * observes a microphone's level, a speaker's programme and whether the desk in + * front of the mic is occupied, and it has to be able to replay a rollout and + * arrive at the identical checksum — which is only true if every number here is + * a pure function of (seed, step, commands). So: + * + * - nothing calls `Date.now()`, `Math.random()` or `performance.now()`; + * - `stepFixed()` advances by exactly `fixedStepSeconds` and takes no argument + * that could vary with a frame rate; + * - the random stream is per device and is advanced only by `stepFixed`, so + * issuing a command does not shift the sequence a replay would draw; + * - `snapshot()` is plain JSON data and `restore()` reproduces the remainder + * of a run bit for bit. + * + * The property that makes the arena honest is that the renderer drives this + * exact object. A second implementation "for the trainer" would be a simulator + * nobody can see and a picture nobody can train against. + * + * ### What it models, and what it refuses to + * + * A microphone with a level that responds to whether anybody is at the desk it + * serves, and a speaker with an output meter that follows its volume. That is + * enough for the two things a viewer does with this — watch a meter move, press + * mute and see it stop — and enough for the cross-variable coupling the arena + * needs (a hot mic at an empty desk is waste; playback under an aircraft is + * noise). + * + * It does **not** invent a track title, an artist, a speaker identity or + * anything else that would read as a fact about a real room. `DeviceState` + * carries no field for one, deliberately: every reading here is `synthetic` and + * the panel says so, and the line between "a meter that moves" and "Marta is + * talking" is the line between a simulation and a claim. `DeviceProvenance` in + * `types.ts` is the same argument at the level of the whole device. + * + * ### Occupancy + * + * Who is at the desk is an **input**, not an invention, whenever the caller + * knows: `setOccupancy()` hands over the seats that are occupied and the mics + * anchored to them go live. A caller that never says gets a deterministic + * schedule drawn from the same seed, so a deployment with no presence source — + * which is most of them, and every anonymous viewer — still shows a studio that + * is alive rather than a row of flat meters. Both paths are deterministic; the + * schedule is not a fallback to randomness, it is a fallback to a fixture. + */ + +import { + DEVICE_RANGES, + hasCapability, + normalizeDeviceCommand, + type DeviceCapability, + type DeviceCommand, + type DeviceDeclaration, + type DeviceKind, + type DeviceState, +} from "./types.ts"; + +// ---- The public shape ----------------------------------------------------- + +export interface SimulatedDevicesOptions { + seed: number; + /** + * Seconds per `stepFixed()`. The arena runs 0.1; the browser and the server + * run whatever their tick is, and both get the same physics because every + * rate below is expressed per second and multiplied by this. + */ + fixedStepSeconds: number; + /** + * What instant simulated time zero corresponds to, in epoch milliseconds. + * + * `0` by default, which makes `observedAt` an elapsed-milliseconds count and + * is what the arena wants — a wall-clock stamp in a replayed rollout would be + * the one field that could not match. A server or a browser driving this in + * real time passes `Date.now()` at construction, and then `observedAt` is a + * real timestamp because the caller advances the simulation in step with the + * clock. + */ + epochMs?: number; +} + +export interface SimulatedDevices { + /** Every declared device's current reading, in declaration order. */ + current(): DeviceState[]; + /** + * Apply one command, or ignore it. + * + * Ignored — not thrown on — for an id this simulator does not carry, an op + * the declaration did not declare, or a value of the wrong type; the shared + * `normalizeDeviceCommand()` makes that decision so that the browser, the API + * and this all refuse exactly the same things. A number outside its range is + * clamped rather than refused, which is that function's documented + * disposition and not a second opinion held here. + */ + command(command: DeviceCommand): void; + /** Advance by exactly `fixedStepSeconds`. Reads no clock. */ + stepFixed(): void; + /** Plain JSON data. Safe to `JSON.stringify`, store and hand back later. */ + snapshot(): unknown; + /** Resume from a snapshot. A snapshot this simulator cannot read is ignored. */ + restore(state: unknown): void; + /** + * Which seats have somebody in them, if the caller knows. + * + * Additive to the signature the build spec fixed, and additive on purpose: + * without it the mic level could not respond to the room, which is the one + * behaviour that makes a level meter worth drawing. Calling it once switches + * this simulator off its own occupancy schedule for good — including a call + * with an empty array, which means "I looked, and nobody is there", not "I + * have nothing to say". + */ + setOccupancy(occupiedSeatIds: readonly string[]): void; +} + +// ---- Level model ---------------------------------------------------------- +// +// Everything here is in dBFS and every rate is per second. The numbers are +// chosen to be *watchable* rather than to be a measurement: a meter that sits +// still is indistinguishable from a broken one, and a meter that jumps the full +// scale every frame is indistinguishable from noise. + +/** The floor. A device that is off, muted or silent reports exactly this. */ +const FLOOR_DB = DEVICE_RANGES.level.min; + +/** The gain at which the mic model is calibrated — anything else shifts it. */ +const REFERENCE_GAIN_DB = DEVICE_RANGES.gain.initial; + +/** An empty studio with the air handling running, at reference gain. */ +const ROOM_TONE_DB: Span = { low: -56, high: -47 }; + +/** Somebody talking at the desk this mic serves, at reference gain. */ +const SPEECH_DB: Span = { low: -27, high: -8 }; + +/** A speaker at full volume, playing. Scaled by volume below. */ +const PROGRAMME_DB: Span = { low: -14, high: -5 }; + +/** How long one syllable-scale target lasts, in seconds. */ +const SPEECH_PHASE: Span = { low: 0.25, high: 0.9 }; +/** Room tone drifts far more slowly than speech does. */ +const ROOM_PHASE: Span = { low: 1.4, high: 3.6 }; +/** Programme material moves between the two. */ +const PROGRAMME_PHASE: Span = { low: 0.4, high: 1.6 }; + +/** + * How fast the meter rises and falls, in dB per second. + * + * Asymmetric, like every programme meter ever built: fast attack so a syllable + * registers, slow release so the eye can read the peak it just produced. A + * symmetric filter reads as a wobble rather than as a level. + */ +const ATTACK_DB_PER_SECOND = 220; +const RELEASE_DB_PER_SECOND = 34; + +/** + * How long the desk this mic serves stays occupied, and stays empty, when + * nobody has told us. Seconds. + * + * Minutes rather than seconds, because this is a person at a desk rather than a + * syllable, and because a mic whose meter came alive every four seconds would + * read as a fault. The two spans differ: studios are empty more than they are + * busy. + */ +const BUSY_PHASE: Span = { low: 25, high: 90 }; +const IDLE_PHASE: Span = { low: 40, high: 180 }; + +interface Span { + low: number; + high: number; +} + +// ---- Randomness ----------------------------------------------------------- + +/** + * One 32-bit stream per device. + * + * Local rather than `seededRandom` from `engine/world.ts`, and that is a hard + * requirement rather than a preference: this module is imported by + * `src/arena/`, which may not reach three.js, the DOM or the network, and + * `world.ts` reaches the first. It is the same mulberry32 arithmetic, stated in + * eleven lines, so the dependency this module carries stays at zero. + * + * Per device rather than one shared stream so that adding a device to a pack + * does not re-roll every other device's future — the same reason + * `HttpFlights` draws its route phases over the whole plan before filtering. + */ +function nextRandom(state: number): { value: number; state: number } { + let t = (state + 0x6d2b79f5) >>> 0; + let x = Math.imul(t ^ (t >>> 15), 1 | t); + x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; + return { value: ((x ^ (x >>> 14)) >>> 0) / 4_294_967_296, state: t }; +} + +/** A 32-bit seed for one device, mixed from the run seed and the device id. */ +function streamSeed(seed: number, id: string): number { + // FNV-1a over the id, then mixed with the run seed. The same construction + // `arena/checksum.ts` uses, for the same reason: two devices whose ids differ + // by one character must not draw neighbouring streams. + let hash = 0x811c9dc5 ^ (seed | 0); + for (let i = 0; i < id.length; i += 1) { + hash ^= id.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +// ---- Per-device runtime --------------------------------------------------- + +interface Runtime { + id: string; + kind: DeviceKind; + capabilities: readonly DeviceCapability[]; + /** The seat this device serves, for occupancy. `""` when it serves none. */ + seatId: string; + powered: boolean; + muted: boolean; + gainDb: number; + volume: number; + playing: boolean; + /** The smoothed meter reading, dBFS. */ + levelDb: number; + /** What the meter is heading for until the phase ends. */ + targetDb: number; + /** Seconds left in the current level phase. */ + phaseLeft: number; + /** The device's own random stream. */ + rng: number; + /** Self-driven occupancy, used only while nobody has called `setOccupancy`. */ + busy: boolean; + /** Seconds left in the current self-driven occupancy phase. */ + busyLeft: number; +} + +/** + * The snapshot format. Versioned, flat, and plain JSON. + * + * Versioned because a snapshot outlives the code that wrote it — the arena + * stores one mid-episode and restores it later — and an unreadable snapshot has + * to be *recognisably* unreadable rather than half-applied. `restore()` ignores + * anything that is not this, which leaves the simulator on its own consistent + * state instead of a mixture of two. + */ +interface Snapshot { + v: 1; + elapsedMs: number; + occupancyProvided: boolean; + occupied: string[]; + devices: Runtime[]; +} + +const SNAPSHOT_VERSION = 1; + +export function createSimulatedDevices( + declarations: readonly DeviceDeclaration[], + options: SimulatedDevicesOptions, +): SimulatedDevices { + const dt = Math.max(0, options.fixedStepSeconds); + const epochMs = options.epochMs ?? 0; + // Copied, so a caller mutating the array it handed in cannot change what this + // simulator validates commands against half way through a run. + const declared = declarations.map((d) => d); + const byId = new Map(declared.map((d) => [d.id, d])); + + let elapsedMs = 0; + let occupancyProvided = false; + let occupied = new Set(); + let devices = declared.map((d) => initialRuntime(d, options.seed)); + + /** Whether the desk this device serves has somebody at it, right now. */ + function isOccupied(device: Runtime): boolean { + if (!occupancyProvided) return device.busy; + return device.seatId !== "" && occupied.has(device.seatId); + } + + /** + * Where the meter is heading, and for how long. + * + * One draw per phase rather than one per step: a target redrawn every frame is + * white noise, and white noise through a smoothing filter is a meter that + * hovers around its own mean and never peaks. + */ + function drawPhase(device: Runtime): void { + const quiet = !device.powered; + if (device.kind === "mic") { + if (quiet || device.muted) { + device.targetDb = FLOOR_DB; + device.phaseLeft = span(device, ROOM_PHASE); + return; + } + const busy = isOccupied(device); + device.targetDb = clamp( + span(device, busy ? SPEECH_DB : ROOM_TONE_DB) + (device.gainDb - REFERENCE_GAIN_DB), + FLOOR_DB, + DEVICE_RANGES.level.max, + ); + device.phaseLeft = span(device, busy ? SPEECH_PHASE : ROOM_PHASE); + return; + } + + if (quiet || !device.playing) { + device.targetDb = FLOOR_DB; + device.phaseLeft = span(device, PROGRAMME_PHASE); + return; + } + // Volume is a fraction and the meter is decibels, so the knob enters as + // 20·log10 — which is why halving the volume drops the meter about 6 dB + // rather than halving the number on it. Floored well above zero so a + // speaker turned all the way down reads as silent rather than as -Infinity. + const attenuation = 20 * Math.log10(Math.max(device.volume, 0.001)); + device.targetDb = clamp( + span(device, PROGRAMME_DB) + attenuation, + FLOOR_DB, + DEVICE_RANGES.level.max, + ); + device.phaseLeft = span(device, PROGRAMME_PHASE); + } + + /** One draw from the device's own stream, mapped into a span. */ + function span(device: Runtime, range: Span): number { + const drawn = nextRandom(device.rng); + device.rng = drawn.state; + return range.low + (range.high - range.low) * drawn.value; + } + + function initialRuntime(declaration: DeviceDeclaration, seed: number): Runtime { + const device: Runtime = { + id: declaration.id, + kind: declaration.kind, + capabilities: [...declaration.capabilities], + seatId: declaration.anchor.seatId ?? "", + // Off, like everything in `initialDeviceState`. A studio whose hardware + // powers itself on because a page was loaded is a studio making a claim. + powered: false, + muted: false, + gainDb: DEVICE_RANGES.gain.initial, + volume: DEVICE_RANGES.volume.initial, + playing: false, + levelDb: FLOOR_DB, + targetDb: FLOOR_DB, + phaseLeft: 0, + rng: streamSeed(seed, declaration.id), + busy: false, + busyLeft: 0, + }; + // Drawn immediately so that two devices do not change phase on the same + // step for the whole run, which is what a zero initial phase would produce. + device.busyLeft = span(device, IDLE_PHASE); + drawPhase(device); + return device; + } + + return { + current(): DeviceState[] { + const observedAt = epochMs + elapsedMs; + return devices.map((device) => reading(device, byId.get(device.id), observedAt)); + }, + + command(command: DeviceCommand): void { + const declaration = byId.get(command.deviceId); + if (declaration === undefined) return; + const normalized = normalizeDeviceCommand(declaration, command); + if (normalized === null) return; + const device = devices.find((d) => d.id === normalized.deviceId); + if (device === undefined) return; + + switch (normalized.op) { + case "power": + device.powered = normalized.value === true; + // A speaker that has been switched off is not playing. Leaving + // `playing` true would make a powered-down speaker report a + // now-playing state, which is the one combination no real box has. + if (!device.powered) device.playing = false; + break; + case "mute": + device.muted = normalized.value === true; + break; + case "playback": + // Refused rather than queued on an unpowered speaker: nothing else in + // this file turns a device on as a side effect of another command, + // and a press that silently powered the room would be a surprise. + if (device.powered) device.playing = normalized.value === true; + break; + case "gain": + device.gainDb = normalized.value as number; + break; + case "volume": + device.volume = normalized.value as number; + break; + } + // The meter follows the new state from the next step, not from the next + // phase. Without this, muting a mic leaves the meter at speech level for + // up to a second, which reads as a button that did not work. + device.phaseLeft = 0; + }, + + stepFixed(): void { + elapsedMs += dt * 1000; + for (const device of devices) { + if (!occupancyProvided) { + device.busyLeft -= dt; + if (device.busyLeft <= 0) { + device.busy = !device.busy; + device.busyLeft = span(device, device.busy ? BUSY_PHASE : IDLE_PHASE); + } + } + + device.phaseLeft -= dt; + if (device.phaseLeft <= 0) drawPhase(device); + + const rate = device.targetDb > device.levelDb ? ATTACK_DB_PER_SECOND : RELEASE_DB_PER_SECOND; + const move = rate * dt; + const gap = device.targetDb - device.levelDb; + device.levelDb = + Math.abs(gap) <= move ? device.targetDb : device.levelDb + Math.sign(gap) * move; + } + }, + + snapshot(): unknown { + const state: Snapshot = { + v: SNAPSHOT_VERSION, + elapsedMs, + occupancyProvided, + occupied: [...occupied], + // Deep-copied, because a caller holding a snapshot must not be holding a + // live view of the state this simulator is about to mutate. + devices: devices.map((d) => ({ ...d, capabilities: [...d.capabilities] })), + }; + return state; + }, + + restore(state: unknown): void { + const parsed = readSnapshot(state); + if (parsed === null) return; + elapsedMs = parsed.elapsedMs; + occupancyProvided = parsed.occupancyProvided; + occupied = new Set(parsed.occupied); + // Only devices this simulator was constructed with, and in *its* order: + // a snapshot from a pack with an extra microphone in it must not add one + // here, because `current()` is answered against the declarations the + // caller validated commands against. + devices = devices.map((device) => { + const saved = parsed.devices.find((d) => d.id === device.id); + return saved === undefined ? device : { ...device, ...saved, id: device.id }; + }); + }, + + setOccupancy(occupiedSeatIds: readonly string[]): void { + occupancyProvided = true; + occupied = new Set(occupiedSeatIds); + }, + }; +} + +/** + * One runtime as a `DeviceState`, carrying only the readings its declaration + * implies. + * + * A field is omitted rather than zeroed when the device does not declare the + * capability behind it, because `undefined` and `0` mean genuinely different + * things to every consumer: the panel renders no control for a reading that is + * absent, and would render a dead one for a reading that is present and zero. + */ +function reading( + device: Runtime, + declaration: DeviceDeclaration | undefined, + observedAt: number, +): DeviceState { + const has = (capability: DeviceCapability): boolean => + declaration === undefined + ? device.capabilities.includes(capability) + : hasCapability(declaration, capability); + + const state: DeviceState = { + id: device.id, + kind: device.kind, + powered: device.powered, + observedAt, + // Never anything else from this file. Everything above is invented, and the + // one field that says so is not a flag a caller may set. + synthetic: true, + }; + if (has("mute")) state.muted = device.muted; + if (has("gain")) state.gainDb = round(device.gainDb, 2); + if (has("level")) state.levelDb = round(device.levelDb, 1); + if (has("volume")) state.volume = round(device.volume, 3); + if (has("playback")) state.playing = device.playing; + return state; +} + +/** + * A snapshot, if it is one. + * + * Checked rather than cast, because a snapshot arrives from wherever the caller + * kept it — a JSON file, an arena trace, a previous version of this module — + * and a half-applied restore is worse than a refused one: the caller believes + * it is replaying and is not. `null` means "not mine", and `restore()` leaves + * the simulator exactly as it was. + */ +function readSnapshot(state: unknown): Snapshot | null { + if (state === null || typeof state !== "object" || Array.isArray(state)) return null; + const raw = state as Partial; + if (raw.v !== SNAPSHOT_VERSION) return null; + if (typeof raw.elapsedMs !== "number" || !Number.isFinite(raw.elapsedMs)) return null; + if (!Array.isArray(raw.devices)) return null; + if (!Array.isArray(raw.occupied)) return null; + return { + v: SNAPSHOT_VERSION, + elapsedMs: raw.elapsedMs, + occupancyProvided: raw.occupancyProvided === true, + occupied: raw.occupied.filter((id): id is string => typeof id === "string"), + devices: raw.devices.filter((d): d is Runtime => d !== null && typeof d === "object"), + }; +} + +function clamp(value: number, low: number, high: number): number { + return Math.min(high, Math.max(low, value)); +} + +/** + * Rounded on the way out, never in the running state. + * + * The meter is smoothed at full precision and reported at a tenth of a decibel, + * which is both what a meter is read to and what `deviceStateSignature` hashes + * — so a reading that has not visibly changed does not republish, and a run + * that is bit-identical internally stays bit-identical on the wire. + */ +function round(value: number, places: number): number { + const scale = 10 ** places; + return Math.round(value * scale) / scale; +} diff --git a/src/devices/types.ts b/src/devices/types.ts new file mode 100644 index 0000000..96b8420 --- /dev/null +++ b/src/devices/types.ts @@ -0,0 +1,544 @@ +/** + * What a device *is*, before anything simulates one, draws one or commands one. + * + * This file is deliberately the first one written in this directory and it + * exports no behaviour beyond small pure helpers, because four separate pieces + * of the build are held up on the same four questions: what does a pack author + * write into a room, what does a live reading look like, what can be commanded, + * and how does a consumer know which of those apply to a given device. Packs + * authors declarations from this; the device panel builds its controls from it; + * the arena builds part of its observation vector from it; the API validates + * against it. It therefore imports nothing — no THREE, no interiors, no wire — + * and it can be imported by all of them. + * + * ### The split that matters most + * + * **A declaration is authored and lives in the office pack. A state never + * does.** That is the same line `Presence` draws in `interiors/types.ts`, for + * the same reason and with the same consequence: a pack is bundled into the + * static build, so everything in it is public by construction, and anything + * that must be refused to an anonymous caller has to arrive over the API from a + * route that can refuse it. A `DeviceDeclaration` says *there is a microphone + * on that desk, here is the hardware, here is what it can be asked to do*, and + * publishing that is fine — it is a description of a room. A `DeviceState` says + * what the microphone is hearing right now, and that never appears in a file + * anybody can download. + * + * ### Strictly JSON-serialisable + * + * CONTRACT.md §2, non-negotiable, because a declaration is authored inside an + * `Office` and a hand-written pack and one arriving over HTTP have to be the + * same thing. No classes, no functions, no `Date`, no THREE types, no getters, + * nothing that survives `structuredClone` but not `JSON.stringify`. The helpers + * below are functions *about* the data, never fields *in* it. + * + * ### Two kinds now, five more later, without a new shape + * + * This build carries a mic and a speaker. A smart light, a thermostat, a door + * sensor and a vehicle charger were all drawn on paper against this shape + * before it was written, and all four fit: each is an authored declaration + * anchored to a prop, a set of capabilities drawn from one closed list, and a + * state carrying one reading per capability. Adding one is a new member of + * `DeviceKind`, an entry in `CANONICAL_CAPABILITIES`, and — for a reading that + * genuinely does not exist yet, such as a thermostat's setpoint — one optional + * field on `DeviceState` and one row in `CAPABILITY_READING`. Nothing nests + * differently, no authored pack is invalidated, and no consumer has to learn a + * second way to ask what a device can do. That is what "fits without a schema + * change" means here: the *structure* is fixed, the vocabulary grows. + * + * Weather is **not** a device and is not modelled as one. It is an observation + * of the world that arrives from `/api/v1/weather`, it is nobody's hardware, it + * is anchored to no prop, and there is nothing to command. `Environment` in + * CONTRACT.md §4 is where it lives and it stays there. + */ + +// ---- Identifiers ---------------------------------------------------------- + +/** + * A namespaced asset id whose asset is device hardware, like + * `"tera:device.mic.desk"`. + * + * The same string as `AssetId` in `src/assets/kit.ts` and in + * `src/interiors/types.ts`, restated here for the reason those two give for + * each other: this contract is data, and data should not have to import the + * mesh library to be parsed, validated or stored. The narrower name is a + * courtesy to the reader — not every `AssetId` is a device, and `assetId` on a + * declaration is only ever one that is. + * + * The convention, which `deviceKindOfAssetId` reads and the API relies on, is + * `:device..`. A self-hoster's + * `acme:device.mic.boom` is a mic to every consumer here without registering + * anything with us, which is the same reskinning story `overrides` tells for + * furniture. + */ +export type DeviceAssetId = string; + +// ---- Kinds ---------------------------------------------------------------- + +/** The device kinds this build carries. See the header on growing this list. */ +export type DeviceKind = "mic" | "speaker"; + +export const DEVICE_KINDS: readonly DeviceKind[] = ["mic", "speaker"]; + +// ---- Capabilities --------------------------------------------------------- + +/** + * One thing a device can do or report — the unit both the UI and the simulator + * are built out of. + * + * A capability is declared per *device*, not per kind, because two microphones + * in the same building are genuinely not always the same instrument: the desk + * condenser has a gain stage, the ceiling array does not. The device panel + * renders one control per declared capability and the simulator advances one + * reading per declared capability, so a device that declares nothing it does + * not have cannot grow a control that does nothing. + * + * `level` is the odd one and is why this union is not simply a list of buttons: + * it is a *reading only*. You can ask a microphone for its level; you cannot + * set it. `DeviceCommandOp` is derived from this union by removing exactly + * that, so the two can never drift apart. + */ +export type DeviceCapability = "power" | "gain" | "mute" | "volume" | "playback" | "level"; + +export const DEVICE_CAPABILITIES: readonly DeviceCapability[] = [ + "power", + "gain", + "mute", + "volume", + "playback", + "level", +]; + +/** Everything a `DeviceCommand` may ask for: the capabilities that are not read-only. */ +export type DeviceCommandOp = Exclude; + +export const DEVICE_COMMAND_OPS: readonly DeviceCommandOp[] = [ + "power", + "gain", + "mute", + "volume", + "playback", +]; + +/** + * What each kind normally has, for a pack author who wants the ordinary answer. + * + * Advisory, not enforced: a declaration carries its own `capabilities` and that + * is what every consumer reads. This is here so that two studios authored + * months apart describe the same instrument the same way, which is worth more + * than it looks — the arena's observation vector is a fixed width, and a mic + * that quietly stopped declaring `gain` in one pack would make two scenarios + * incomparable. + */ +export const CANONICAL_CAPABILITIES: Readonly> = { + mic: ["power", "mute", "gain", "level"], + speaker: ["power", "volume", "playback"], +}; + +/** + * The reading each capability implies on `DeviceState`. + * + * This table is the joint that makes the whole design hold. Without it, every + * consumer would carry its own `if (kind === "mic") show gain` ladder and they + * would disagree the first time a kind was added. With it: the UI renders + * `capabilities.map(...)`, the simulator advances `capabilities.map(...)`, the + * observation flattens `capabilities.map(...)`, and a new kind is data. + */ +export const CAPABILITY_READING: Readonly> = { + power: "powered", + gain: "gainDb", + mute: "muted", + volume: "volume", + playback: "playing", + level: "levelDb", +}; + +// ---- Ranges --------------------------------------------------------------- + +/** + * The bounds of a numeric reading, in the units it is reported in. + * + * Published rather than left to each consumer because three of them need the + * same numbers for different reasons and a disagreement would be silent: the + * panel draws a slider between them, the API clamps a command into them, and + * the arena normalises an observation by them. `initial` is the value at rest — + * what an unobserved or freshly-powered device reports — which is what lets + * `initialDeviceState` be a pure function of a declaration. + */ +export interface DeviceRange { + min: number; + max: number; + /** The value at rest, before anything has been observed or commanded. */ + initial: number; + unit: string; +} + +export const DEVICE_RANGES: Readonly> = { + /** Preamp gain on a desk condenser. Below zero is pad, not silence. */ + gain: { min: -12, max: 36, initial: 12, unit: "dB" }, + /** Output level, as a fraction. Not decibels: this is the knob, not the meter. */ + volume: { min: 0, max: 1, initial: 0.35, unit: "fraction" }, + /** + * Programme level on the meter, full-scale referenced. At rest it sits at the + * floor, because a microphone nobody has switched on is not hearing −20 dBFS + * of anything. + */ + level: { min: -60, max: 0, initial: -60, unit: "dBFS" }, +}; + +// ---- Provenance ----------------------------------------------------------- + +/** + * Where a device's readings come from, as a closed vocabulary. + * + * A closed union rather than free text, and the same idea as the per-row + * provenance the marker gate enforces under CONTRACT.md §8: a value the server + * can check is worth more than a sentence it can only pass through. What is + * being guarded is different but the failure is identical — something presented + * as observed when it was invented, or as invented when it was observed. + * + * - `simulated` — a deterministic state machine. Everything this build ships. + * - `operator-authored` — a fixed value an operator wrote down. Honest, static. + * - `first-party-sensor` — a real reading from the operator's own hardware, + * which is the door a Home Assistant bridge comes through later. + * + * `synthetic` on `DeviceState` is the runtime half of the same statement, and + * it is the one a viewer is shown. + */ +export type DeviceProvenance = "simulated" | "operator-authored" | "first-party-sensor"; + +export const DEVICE_PROVENANCE: readonly DeviceProvenance[] = [ + "simulated", + "operator-authored", + "first-party-sensor", +]; + +// ---- The authored declaration --------------------------------------------- + +/** Metres, in the anchor prop's own frame. */ +export interface DeviceOffset { + x: number; + y: number; + z: number; +} + +/** + * Where a device physically is — by reference, never by restatement. + * + * `propId` is required, and that is the whole design of this type: a device is + * a piece of hardware, hardware sits on something, and the something is already + * placed in the floorplan with a position and a rotation that `Plan` resolves. + * Giving the declaration its own world coordinate would create a second answer + * to "where is the mic", and the two would disagree the first time somebody + * nudged the desk. So the position is *derived*: the anchor prop's transform, + * plus an optional `offset` in the prop's own frame for the few centimetres + * between the desk's origin and the top of the mic stand. + * + * The same argument `Prop.seat` makes for chairs, and the same one CONTRACT.md + * makes about packs importing their site rather than restating its coordinates. + * + * `roomId` and `seatId` are addresses, not positions. They are what lets a + * consumer ask "is anybody sitting where this mic is pointed" — which the arena + * does, and which is the difference between a hot mic and a wasted one. + */ +export interface DeviceAnchor { + levelId: string; + /** The authored prop that *is* this device's hardware. */ + propId: string; + /** The room the prop stands in, when a consumer wants it without a lookup. */ + roomId?: string; + /** The seat this device serves, if it serves one. */ + seatId?: string; + /** Metres from the anchor prop's origin, in the prop's frame. */ + offset?: DeviceOffset; +} + +/** + * One device, as a pack author writes it. + * + * Authored, public, and inert: nothing here is a reading and nothing here + * changes. `Plan` resolves it the way it resolves every other authored address + * — dropping what it cannot bind and recording the problem rather than throwing + * — so one typo in a device id does not stop an office from opening. + */ +export interface DeviceDeclaration { + /** Unique within the office. Referenced by every `DeviceState` and command. */ + id: string; + kind: DeviceKind; + /** Shown in the panel. "Desk mic", not "tera:device.mic.desk". */ + label: string; + /** The hardware to build. Its kind segment must agree with `kind`. */ + assetId: DeviceAssetId; + anchor: DeviceAnchor; + /** What this particular unit can do. Usually `CANONICAL_CAPABILITIES[kind]`. */ + capabilities: readonly DeviceCapability[]; + provenance: DeviceProvenance; + /** + * One sentence shown to a viewer next to the readings. + * + * Mandatory, and validated: a `simulated` device must say so in words a + * person reading the panel would understand, exactly as + * `RobotOperationsDefinition.disclosure` is checked to contain "simulat" + * before any of it is drawn. A studio that shows a live-looking level meter + * without saying where the level came from is making a claim about a real + * room, and this is the field that stops it. + */ + disclosure: string; +} + +// ---- The live state ------------------------------------------------------- + +/** + * What a device is doing right now. **Never authored, never in a pack.** + * + * Every reading past `powered` is optional because a device only carries the + * readings its capabilities imply — see `CAPABILITY_READING`. A consumer that + * wants to know whether a field is meaningful asks the declaration, not the + * state: `undefined` here means "this device has no such reading", which is a + * different thing from zero. + */ +export interface DeviceState { + id: string; + kind: DeviceKind; + /** Every device has this one. A device with no power state is a prop. */ + powered: boolean; + muted?: boolean; + gainDb?: number; + /** Programme level, dBFS. A reading only — no command sets it. */ + levelDb?: number; + volume?: number; + playing?: boolean; + /** Epoch milliseconds. */ + observedAt: number; + /** + * Was this reading invented? + * + * `true` for everything this build ships, and it is not a flag anybody may + * default to `false` for convenience. The panel shows it, the wire carries + * it, and `DevicesBody.synthetic` is the same statement one level up. + */ + synthetic: boolean; +} + +// ---- Commands ------------------------------------------------------------- + +/** Booleans for the switches, numbers for the knobs. Nothing else is a value. */ +export type DeviceCommandValue = boolean | number; + +/** + * One instruction for one device. + * + * Deliberately tiny and deliberately not batched. It travels in a POST body of + * its own — never in a read response, because a shared cache that replayed a + * GET which turned a microphone on is precisely what the fail-closed + * `Cache-Control` default in CONTRACT.md §5 exists to prevent. + * + * `value` is absent only for an op that carries no argument, and today there is + * none: `power`, `mute` and `playback` take a boolean, `gain` and `volume` take + * a number. It stays optional because a future `playback: "next"`-shaped op + * would want it to be, and because a command that arrives without one must be + * rejected by `normalizeDeviceCommand` rather than by the type system alone — + * the wire can send anything. + */ +export interface DeviceCommand { + deviceId: string; + op: DeviceCommandOp; + value?: DeviceCommandValue; +} + +// ---- Helpers -------------------------------------------------------------- +// +// Pure, total, and free of I/O. They exist because the alternative is four +// consumers each writing their own slightly different version of the same +// check, which is how a validation gate ends up being enforced in three places +// and skipped in the fourth. + +export function isDeviceKind(value: unknown): value is DeviceKind { + return typeof value === "string" && (DEVICE_KINDS as readonly string[]).includes(value); +} + +export function isDeviceCapability(value: unknown): value is DeviceCapability { + return typeof value === "string" && (DEVICE_CAPABILITIES as readonly string[]).includes(value); +} + +export function isDeviceCommandOp(value: unknown): value is DeviceCommandOp { + return typeof value === "string" && (DEVICE_COMMAND_OPS as readonly string[]).includes(value); +} + +export function isDeviceProvenance(value: unknown): value is DeviceProvenance { + return typeof value === "string" && (DEVICE_PROVENANCE as readonly string[]).includes(value); +} + +/** + * The kind an asset id claims to be, or `null` if it does not claim to be a + * device at all. + * + * `"tera:device.mic.desk"` → `"mic"`. Namespace-agnostic, so a self-hoster's + * `acme:device.speaker.shelf` reads as a speaker without registering anything. + * + * This is what lets the API check that a declaration's `assetId` and `kind` + * agree, and check it *server-side* against the resolved plan rather than + * trusting a body — the same move `officeHasMediaBinding()` makes for screens. + * A mic declaration pointing at a speaker's hardware is not a rendering bug, it + * is a command routed to the wrong instrument. + */ +export function deviceKindOfAssetId(assetId: DeviceAssetId): DeviceKind | null { + const path = assetId.includes(":") ? assetId.slice(assetId.indexOf(":") + 1) : assetId; + const segments = path.split("."); + if (segments[0] !== "device") return null; + const kind = segments[1]; + return isDeviceKind(kind) ? kind : null; +} + +export function hasCapability( + declaration: Pick, + capability: DeviceCapability, +): boolean { + return declaration.capabilities.includes(capability); +} + +/** + * Everything wrong with an authored declaration, as sentences. Empty is good. + * + * **It never throws**, which is the whole point: `Plan` resolves authored data + * by dropping what it cannot use and recording why, so that one bad device does + * not cost a viewer the building. Compare `resolveRobotOperations`, which does + * throw — that is authored *behaviour*, resolved once at build time by whoever + * wrote it, and a robot station with no floor under it is a bug in the pack. A + * device is authored *furniture*, and furniture degrades. + */ +export function validateDeviceDeclaration(declaration: DeviceDeclaration): string[] { + const problems: string[] = []; + const id = declaration.id === "" ? "" : declaration.id; + + if (declaration.id === "") problems.push("device has no id"); + if (!isDeviceKind(declaration.kind)) problems.push(`device ${id} has an unknown kind`); + if (declaration.label.trim() === "") problems.push(`device ${id} has no label`); + if (declaration.anchor.levelId === "") problems.push(`device ${id} names no level`); + if (declaration.anchor.propId === "") { + problems.push(`device ${id} is anchored to no prop, and a device with no hardware is fiction`); + } + + const assetKind = deviceKindOfAssetId(declaration.assetId); + if (assetKind === null) { + problems.push(`device ${id} names ${declaration.assetId}, which is not device hardware`); + } else if (assetKind !== declaration.kind) { + problems.push( + `device ${id} is declared a ${declaration.kind} but its asset ${declaration.assetId} is a ` + + `${assetKind}`, + ); + } + + if (declaration.capabilities.length === 0) { + problems.push(`device ${id} declares no capabilities and could do nothing`); + } + for (const capability of declaration.capabilities) { + if (!isDeviceCapability(capability)) { + problems.push(`device ${id} declares an unknown capability ${String(capability)}`); + } + } + if (!isDeviceProvenance(declaration.provenance)) { + problems.push(`device ${id} has an unknown provenance`); + } + + // The same check `resolveRobotOperations` makes, for the same reason: a + // simulated reading displayed without the word is a claim about a real room. + if (declaration.disclosure.trim() === "") { + problems.push(`device ${id} has no disclosure`); + } else if ( + declaration.provenance === "simulated" && + !declaration.disclosure.toLowerCase().includes("simulat") + ) { + problems.push(`device ${id} is simulated and its disclosure does not say so`); + } + + return problems; +} + +/** + * The state a declaration implies before anything has been observed. + * + * Powered off, every reading at rest, and `synthetic: true` whatever the + * declaration's provenance says — because nothing has been observed yet, and a + * state that claimed otherwise would be a lie told by a constructor. + */ +export function initialDeviceState( + declaration: DeviceDeclaration, + observedAt: number, +): DeviceState { + const state: DeviceState = { + id: declaration.id, + kind: declaration.kind, + powered: false, + observedAt, + synthetic: true, + }; + if (hasCapability(declaration, "mute")) state.muted = false; + if (hasCapability(declaration, "gain")) state.gainDb = DEVICE_RANGES.gain.initial; + if (hasCapability(declaration, "level")) state.levelDb = DEVICE_RANGES.level.initial; + if (hasCapability(declaration, "volume")) state.volume = DEVICE_RANGES.volume.initial; + if (hasCapability(declaration, "playback")) state.playing = false; + return state; +} + +/** + * A command this declaration will actually accept, clamped — or `null`. + * + * The one validator, run by everybody: the panel before it sends, the API + * before it mutates, the simulator before it applies. `null` is a refusal and + * the caller decides what that means — a 400 on the route, a no-op in the UI. + * A returned command is a fresh object, so a caller cannot hold a reference to + * something the store is about to mutate. + * + * Clamping rather than refusing an out-of-range number is deliberate and is the + * one place this is lenient: a slider that reports 1.0000000002 is not an + * attack, and `TERA_ADSB_RADIUS_NM` has the same disposition for the same + * reason. A *wrong type* is refused, because that is a caller who has + * misunderstood the contract rather than one who overshot. + */ +export function normalizeDeviceCommand( + declaration: DeviceDeclaration, + command: DeviceCommand, +): DeviceCommand | null { + if (command.deviceId !== declaration.id) return null; + if (!isDeviceCommandOp(command.op)) return null; + if (!hasCapability(declaration, command.op)) return null; + + if (command.op === "gain" || command.op === "volume") { + const range = DEVICE_RANGES[command.op]; + if (typeof command.value !== "number" || !Number.isFinite(command.value)) return null; + return { + deviceId: declaration.id, + op: command.op, + value: Math.min(range.max, Math.max(range.min, command.value)), + }; + } + + if (typeof command.value !== "boolean") return null; + return { deviceId: declaration.id, op: command.op, value: command.value }; +} + +/** + * A string that changes exactly when something a viewer would notice changes. + * + * `observedAt` is left out on purpose. It moves on every poll of an unchanged + * room and would defeat the whole comparison — which is the same trap, and the + * same answer, as the presence watch in `adapters/http.ts`. `watchDevices` + * publishes on a change of this and on nothing else. + */ +export function deviceStateSignature(states: readonly DeviceState[]): string { + return states + .map((s) => + [ + s.id, + s.powered ? "1" : "0", + s.muted === undefined ? "" : s.muted ? "1" : "0", + s.gainDb === undefined ? "" : s.gainDb.toFixed(2), + s.levelDb === undefined ? "" : s.levelDb.toFixed(1), + s.volume === undefined ? "" : s.volume.toFixed(3), + s.playing === undefined ? "" : s.playing ? "1" : "0", + s.synthetic ? "1" : "0", + ].join("|"), + ) + .join(";"); +} diff --git a/src/engine/atmosphere.ts b/src/engine/atmosphere.ts index aca0ff4..cefe5a1 100644 --- a/src/engine/atmosphere.ts +++ b/src/engine/atmosphere.ts @@ -556,12 +556,31 @@ const NIGHT_FLOOR_HORIZON = 0x16203a; * a roof is lighter than a wall; and the keyframe table's token sidelight * survives at full strength on a moonless night (see `applyNight`) so the hills * still have a lit side and a dark one. + * + * ### Why these two numbers moved when tone mapping arrived + * + * They went up by a third, and the ratios between all five did not change, + * which is the point. `stage.ts` now runs ACES filmic instead of a bare + * `saturate()`, and ACES has a toe: it is steeper than a plain sRGB encode + * everywhere below about linear 0.1, which is the entire range a moonless night + * occupies. A ground reading that displayed at 0.212 under the old renderer + * came out at 0.174 under the new one for the same physical light — an 18% loss + * concentrated exactly where this file has the least to give. + * + * A third more linear light puts it back at 0.240 and leaves the *shape* of the + * night alone, because every one of the five terms was scaled by the same + * factor. That is deliberate: the comment above spends four paragraphs on the + * ratios between them, and a re-tune that fixed the brightness by flattening the + * sky-to-ground gradient would have thrown away the argument to keep the number. + * The city's own lit windows keep their four-to-five-times lead as well — they + * are emissive, they were clipping at 1.0 before, and under a shoulder they now + * separate rather than all rendering as the same white. */ const NIGHT_FLOOR_HEMI_SKY = 0x354c88; const NIGHT_FLOOR_HEMI_GROUND = 0x1f2740; -const NIGHT_FLOOR_HEMI_INTENSITY = 0.78; +const NIGHT_FLOOR_HEMI_INTENSITY = 1.05; const NIGHT_FLOOR_AMBIENT = 0x47557f; -const NIGHT_FLOOR_AMBIENT_INTENSITY = 0.22; +const NIGHT_FLOOR_AMBIENT_INTENSITY = 0.3; /** * The same sky, and the same fill, with a full moon in it. @@ -636,6 +655,45 @@ interface Keyframe extends Rig { * so a city that declares a paler or bluer sky keeps it at noon and still gets * the same dusk as everywhere else — dusk is not regional in any way this * renderer can see. + * + * ### The intensities are tuned against a tone curve, and it is ACES + * + * Read the three intensity columns as a set, because they were re-tuned as one + * when `stage.ts` stopped rendering through `NoToneMapping`. The old numbers + * were not wrong — they were correct for a renderer that hard-clipped at linear + * 1.0, and being correct for that is exactly what makes them wrong now. + * + * Two things changed, in opposite directions, and both are visible in the table. + * + * **Below the horizon the fills went up by about a third.** ACES's toe is + * steeper than a plain sRGB encode everywhere under about linear 0.1, which is + * the whole of the range a night frame lives in. See `NIGHT_FLOOR_HEMI_SKY` for + * the arithmetic; the three night stops move with the floor because they sit + * just under it and it is the floor that binds. + * + * **Above the horizon the key went up and the fill came down.** That is not a + * brightness change, it is a contrast change, and it is the whole reason to have + * done this. Under a clipping renderer a sunlit 0.7-albedo wall and a sunlit + * 0.95-albedo wall were both exactly white, so the only way to make a daylit + * scene read as *lit* was to pour in fill until the shadows came up to meet the + * blown highlights — which is a description of a flat picture. With a shoulder + * over the top, a sun at 2.6 puts a lit face at about 0.88 display and its own + * shaded face at about 0.52, and the difference between them is the modelling + * that was missing. So `hemiIntensity` loses roughly a tenth and + * `ambientIntensity` roughly a quarter at the two day stops. + * + * The fill can afford that for a second reason: it is no longer the only + * indirect light in the scene. `environmentRig.ts` derives a real sky + * environment from this same `LightingState` and puts it on `Scene.environment`, + * so the diffuse bounce the hemisphere light was standing in for now arrives + * from something with a direction and a horizon in it. Trimming here and adding + * there is one move, not two. + * + * The sky columns are untouched, and that is not an oversight. Three marks the + * background mesh `toneMapped = false` for an sRGB-transfer texture and mixes + * fog after the tone map from an already-encoded uniform, so `skyTop`, + * `skyHorizon` and the fog colour are displayed exactly as written here. Only + * the lit geometry moved, so only the light was re-tuned. */ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] { return [ @@ -660,36 +718,36 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] { skyTop: 0x05070f, skyHorizon: 0x0b1120, sunColor: 0x44558a, - sunIntensity: 0.16, + sunIntensity: 0.22, hemiSky: 0x2f447e, hemiGround: 0x1b2234, - hemiIntensity: 0.55, + hemiIntensity: 0.74, ambientColor: 0x414e78, - ambientIntensity: 0.16, + ambientIntensity: 0.22, }, { elevation: -12, skyTop: 0x080d1e, skyHorizon: 0x141d38, sunColor: 0x51629b, - sunIntensity: 0.19, + sunIntensity: 0.26, hemiSky: 0x32477d, hemiGround: 0x1d2437, - hemiIntensity: 0.57, + hemiIntensity: 0.77, ambientColor: 0x424f7a, - ambientIntensity: 0.17, + ambientIntensity: 0.23, }, { elevation: -6, skyTop: 0x101a3a, skyHorizon: 0x2b3560, sunColor: 0x66699a, - sunIntensity: 0.26, + sunIntensity: 0.35, hemiSky: 0x3c558c, hemiGround: 0x23293c, - hemiIntensity: 0.6, + hemiIntensity: 0.81, ambientColor: 0x485389, - ambientIntensity: 0.19, + ambientIntensity: 0.26, }, { // The sun on the horizon. Warm at the bottom, cold at the top, and the @@ -698,36 +756,36 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] { skyTop: 0x2a4275, skyHorizon: 0x9a6a63, sunColor: 0xc2795c, - sunIntensity: 0.45, + sunIntensity: 0.58, hemiSky: 0x4a5f8c, hemiGround: 0x2a2a2c, - hemiIntensity: 0.6, + hemiIntensity: 0.72, ambientColor: 0x6a6a80, - ambientIntensity: 0.2, + ambientIntensity: 0.25, }, { elevation: 3, skyTop: 0x4d76ac, skyHorizon: 0xdba078, sunColor: 0xff9c56, - sunIntensity: 1.25, + sunIntensity: 1.45, hemiSky: 0x86a6cc, hemiGround: 0x54503f, - hemiIntensity: 0.85, + hemiIntensity: 0.9, ambientColor: 0xffd9b8, - ambientIntensity: 0.24, + ambientIntensity: 0.26, }, { elevation: 8, skyTop: 0x6b96c6, skyHorizon: 0xebc9a4, sunColor: 0xffc489, - sunIntensity: 1.8, + sunIntensity: 2.0, hemiSky: 0xb2cbe4, hemiGround: 0x6a6752, - hemiIntensity: 0.98, + hemiIntensity: 0.95, ambientColor: 0xffe7cf, - ambientIntensity: 0.28, + ambientIntensity: 0.26, }, { // Ordinary daylight, and the one stop that reproduces `cityDaylight()`. @@ -735,12 +793,12 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] { skyTop: dayTop, skyHorizon: dayHorizon, sunColor: 0xfff3e0, - sunIntensity: 2.1, + sunIntensity: 2.35, hemiSky: 0xdcecf7, hemiGround: 0x6b6f5e, - hemiIntensity: 1.05, + hemiIntensity: 0.92, ambientColor: 0xffffff, - ambientIntensity: 0.32, + ambientIntensity: 0.24, }, { // A high sun. The zenith deepens — less air to scatter through overhead — @@ -749,12 +807,12 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] { skyTop: mixHex(dayTop, 0x2f6bb0, 0.35), skyHorizon: mixHex(dayHorizon, 0xffffff, 0.2), sunColor: 0xfffdf6, - sunIntensity: 2.35, + sunIntensity: 2.6, hemiSky: 0xe6f2fb, hemiGround: 0x74786a, - hemiIntensity: 1.1, + hemiIntensity: 0.95, ambientColor: 0xffffff, - ambientIntensity: 0.3, + ambientIntensity: 0.22, }, ]; } diff --git a/src/engine/environmentRig.ts b/src/engine/environmentRig.ts new file mode 100644 index 0000000..cb41904 --- /dev/null +++ b/src/engine/environmentRig.ts @@ -0,0 +1,557 @@ +/** + * The environment map: what every reflective surface in the world is looking at. + * + * ## What this fixes + * + * Before this file existed there was no `Scene.environment` anywhere in `src/`, + * and that single absence is the whole explanation for the most common + * complaint about how this product looks. A `MeshStandardMaterial` with + * `metalness > 0` has, by construction, almost no diffuse term — metal does not + * scatter, it *reflects* — so with nothing to reflect it renders as a flat dark + * grey wash and reads as painted plastic. The library has eleven such roles: + * `metalTrim` at 0.85, `chairBase` at 0.75, `glazingFrame` and `deskFrame` at + * 0.7 and 0.65, `partitionFrame`, `deviceMesh`, and the five Model X materials. + * All of them were being asked to look like metal with a black room around them. + * + * The codebase already documents this against itself in two places, which is + * how you know it is not a matter of taste. `office/optimus.ts:62` abandoned a + * whole material role over it, and `vehicles/modelX.ts:96` fakes an `emissive` + * term on the car paint to stand in for the sky bounce that was missing. Both + * are workarounds for this file not existing. + * + * ## What it is allowed to do + * + * **It constructs no light. Not one, of any type.** CONTRACT.md §4 makes + * `Atmosphere` the sole light owner and gives the data exactly one direction to + * flow: an `Environment` is observed, `atmosphere.apply()` turns it into a + * `LightingState`, a scene applies that, and nothing writes back. This rig sits + * at the end of that same one-way street — it is handed the `LightingState` + * that has *already been decided* and derives an environment from it. It never + * decides anything about the light itself, so there is no second owner and + * nothing to keep in sync. + * + * That constraint is also why there is no `RoomEnvironment` import here. + * Three's version is a perfectly good office environment and the office path + * below is recognisably descended from it, but it is a fixed room: it does not + * know the hour, the weather, or which way the building faces, so an office at + * 4 p.m. in August would reflect the same neutral studio light as one at + * midnight in January. Deriving the room from `LightingState` instead costs + * about forty lines and means the reflections move with the sun like everything + * else in the scene does. + * + * ## Procedural, like everything else + * + * No `.hdr`, no `.exr`, no cubemap faces on disk. The city environment is a + * `DataTexture` filled in a double loop from the same sky colours the + * background gradient uses, and the office environment is nine untextured + * quads. `scripts/check-no-binaries.mjs` stays satisfied for the same reason + * `textures.ts` keeps it satisfied — the art is the code (CONTRACT.md §3). + */ + +import * as THREE from "three"; +import type { LightingState } from "./types.ts"; + +/** Which of the two worlds is being reflected. */ +export type EnvironmentKind = "city" | "office"; + +export interface EnvironmentRig { + apply(scene: THREE.Scene, lighting: LightingState, kind: "city" | "office"): void; + /** + * Forget a scene that is being torn down. + * + * `apply` records every scene it has written to, so that a rebuilt + * environment can be pushed to all of them at once rather than only to the + * one that happened to ask. That ledger is a strong reference, and a page + * that switches city three times disposes three scenes the rig would + * otherwise hold forever — the whole graph, because `createScene`'s dispose + * frees geometries and materials without clearing its children. This is the + * matching call, and a disposing scene is the only correct caller: it nulls + * `scene.environment` and drops the entry. The cached PMREM targets are + * shared across every scene of that kind and are **not** freed here; that is + * `dispose()`'s job, and it belongs to whoever owns the Stage. + */ + release(scene: THREE.Scene): void; + dispose(): void; +} + +/** + * Equirectangular source resolution, in texels. + * + * Small on purpose. PMREM takes a cube face of `width / 4`, so 256 gives a + * 64² cubemap, and the thing being encoded is a vertical gradient with one + * bright lobe in it — there is no detail here that 512 would preserve and 256 + * would lose. What that buys is the right to rebuild often: the whole cost of + * a sky change is 32k texels of CPU fill, a 128 KB upload and a handful of + * quarter-megapixel GPU passes, which is affordable several times a second and + * therefore affordable during a time-lapse. + */ +const EQUIRECT_WIDTH = 256; +const EQUIRECT_HEIGHT = 128; + +/** + * How much of the sky's brightness the environment carries, against the + * hemisphere light that was already carrying all of it. + * + * This number exists because the environment map and the hemisphere light are + * two descriptions of the same physical thing — light arriving from the sky — + * and applying both at full strength counts it twice. Three's + * `getIBLIrradiance` returns `PI * radiance`, and `BRDF_Lambert` divides by PI, + * so an environment of uniform radiance R contributes exactly `albedo * R` to a + * diffuse surface, against `albedo * intensity * colour / PI` from the + * hemisphere. At 0.12 the environment lands at roughly a fifth of the + * hemisphere's diffuse contribution, and the day stops in `atmosphere.ts` gave + * up about a tenth of `hemiIntensity` and a quarter of `ambientIntensity` to + * make room for it. Those two edits are one decision and were made together. + * + * The reason it is a minority share rather than a replacement: a PMREM + * irradiance probe has no shadow term and no local occlusion, so raising it + * until it *is* the sky fill would light the inside of a closed room as + * brightly as the roof of it. The hemisphere light has the same flaw, but it + * is the flaw the rest of the world is already tuned against. + */ +const SKY_RADIANCE_SHARE = 0.12; + +/** + * The sun's own disc, as a radiance and a tightness. + * + * Kept narrow and kept modest. A cosine power of 400 is a lobe about four + * degrees across, which contributes almost nothing to the irradiance integral — + * so the directional light `Atmosphere` already owns is not double-counted — + * while giving every smooth metal and every pane of glass a specular highlight + * with a *direction* in it. That highlight is most of what separates "this is + * metal" from "this is grey", and it is the one thing a uniform ambient can + * never supply. + */ +const SUN_LOBE_RADIANCE = 1.2; +const SUN_LOBE_TIGHTNESS = 400; +const SUN_GLOW_RADIANCE = 0.22; +const SUN_GLOW_TIGHTNESS = 9; + +/** + * How much of the sky bounces back off the ground. + * + * The lower half of the sphere is not black — it is the city, or the floor, lit + * by the same sun. `LightingState.hemisphere.ground` is the number + * `Atmosphere` already publishes for exactly this and it is reused rather than + * re-derived, so a hazy afternoon greys the underside of a car and the top of + * it together. + */ +const GROUND_RADIANCE_SHARE = 0.09; + +/** + * The office room, in metres. A generous meeting room rather than a studio, + * because the reflection of a room reads as the *proportions* of a room and a + * cube reads as a lift. + */ +const ROOM_WIDTH = 9; +const ROOM_DEPTH = 7; +const ROOM_HEIGHT = 3.2; + +export function createEnvironmentRig(renderer: THREE.WebGLRenderer): EnvironmentRig { + /** + * Everything below is built on first use and not before. + * + * A `PMREMGenerator` compiles three shader programs the moment it is asked to + * do anything, and a page that opens on a city board should not pay for the + * office's blur chain until somebody walks into an office. `createStage` + * builds one rig for the life of the page (see the note in `stage.ts` about + * why there is exactly one renderer), so "first use" here means once. + */ + let pmrem: THREE.PMREMGenerator | null = null; + let equirect: THREE.DataTexture | null = null; + let room: RoomProbe | null = null; + + /** One cached PMREM target per kind, with the key it was built from. */ + const built = new Map(); + + /** + * Every scene this rig has written an environment onto, and which kind it + * was given. + * + * The kind is carried rather than just the scene because a page can hold both + * at once — CONTRACT.md §1 keeps the city alive and paused while an office is + * on screen — and when one kind rebuilds, the scenes that need the new texture + * are the ones on *that* kind. Handing a sunset city sky to the office + * standing beside it would light the room through a wall. + */ + const applied = new Map(); + + let disposed = false; + + function generator(): THREE.PMREMGenerator { + if (!pmrem) pmrem = new THREE.PMREMGenerator(renderer); + return pmrem; + } + + function build(kind: EnvironmentKind, lighting: LightingState): THREE.WebGLRenderTarget { + if (kind === "office") { + if (!room) room = createRoomProbe(); + room.tune(lighting); + // A little blur at capture time. The room is nine flat quads and a hard + // edge between two of them would show up as a visible seam in the + // reflection on a polished desk; four hundredths of a radian is under a + // pixel of the cube face and is enough to take that edge off. + return generator().fromScene(room.scene, 0.04, 0.1, 40); + } + equirect = fillSkyEquirect(equirect, lighting); + return generator().fromEquirectangular(equirect); + } + + return { + apply(scene, lighting, kind) { + if (disposed) return; + const key = environmentKey(kind, lighting); + const current = built.get(kind); + + if (!current || current.key !== key) { + let target: THREE.WebGLRenderTarget; + try { + target = build(kind, lighting); + } catch (error) { + /* + * Degrade rather than take the frame down. + * + * Everything in here runs against a live GL context, and the two ways + * that goes wrong in the field are a lost context and a driver that + * refuses a half-float render target. Neither is a reason for a city + * to stop drawing: without an environment the world looks the way it + * looked before this file was written, which is worse and still a + * world. The warning is deliberately not swallowed silently — a + * missing environment is very hard to diagnose from the picture + * alone, because "everything is slightly duller" does not look like + * an error. + */ + console.warn("environmentRig: could not build an environment map", error); + return; + } + current?.target.dispose(); + built.set(kind, { key, target }); + // The previous texture has just been freed, and every scene that was + // holding it is now pointing at a disposed target — not only the scene + // that happened to ask for the rebuild. Scenes on the other kind are + // left strictly alone. + for (const [other, otherKind] of applied) { + if (otherKind === kind) other.environment = target.texture; + } + } + + const target = built.get(kind); + if (!target) return; + scene.environment = target.target.texture; + // Stated rather than left at its default, because the brightness of the + // environment is decided by the radiances above and a stray intensity + // here would silently override all of that reasoning. + scene.environmentIntensity = 1; + applied.set(scene, kind); + }, + + release(scene) { + if (!applied.delete(scene)) return; + scene.environment = null; + }, + + dispose() { + disposed = true; + for (const scene of applied.keys()) { + scene.environment = null; + } + applied.clear(); + for (const entry of built.values()) entry.target.dispose(); + built.clear(); + equirect?.dispose(); + equirect = null; + room?.dispose(); + room = null; + // `PMREMGenerator.dispose()` frees its own blur materials and ping-pong + // target. It does not touch the targets it handed out, which is why they + // are disposed above first. + pmrem?.dispose(); + pmrem = null; + }, + }; +} + +// ---- Rebuild key ---------------------------------------------------------- + +/** + * A coarse fingerprint of the lighting, used to decide whether to rebuild. + * + * Coarse is the entire point. `Atmosphere` interpolates its keyframe table + * continuously, so on a running clock every field of a `LightingState` changes + * by a fraction every single frame — an exact key would rebuild the environment + * sixty times a second and none of those rebuilds would be visible. Colours are + * reduced to five bits a channel and the sun direction to twelfths of a unit + * vector, which is roughly a five-degree bucket, so a real dawn still rebuilds + * often enough to track and a static afternoon rebuilds once. + */ +function environmentKey(kind: EnvironmentKind, l: LightingState): string { + const d = l.sun.direction; + return [ + kind, + quantiseColor(l.sky?.top ?? l.hemisphere.sky), + quantiseColor(l.sky?.horizon ?? l.hemisphere.ground), + quantiseColor(l.hemisphere.sky), + quantiseColor(l.hemisphere.ground), + Math.round(l.hemisphere.intensity * 20), + quantiseColor(l.ambient.color), + Math.round(l.ambient.intensity * 20), + quantiseColor(l.sun.color), + Math.round(l.sun.intensity * 20), + Math.round(d[0] * 12), + Math.round(d[1] * 12), + Math.round(d[2] * 12), + ].join(":"); +} + +/** 24-bit colour down to 15, which is finer than the eye reads off a gradient. */ +function quantiseColor(hex: number): number { + return (((hex >> 19) & 0x1f) << 10) | (((hex >> 11) & 0x1f) << 5) | ((hex >> 3) & 0x1f); +} + +// ---- The city sky --------------------------------------------------------- + +/** + * Fill (or refill) the equirectangular sky. + * + * Written into an existing buffer where there is one. A `DataTexture` is 128 KB + * of `Uint16Array` and the rebuild happens on a timer nobody controls, so + * allocating a new one each time hands the garbage collector a steady drip of + * medium-sized buffers for no reason — and the GPU-side texture object would be + * recreated with it, which is the expensive half. + * + * The sphere is built in three parts, all of them derived from the + * `LightingState` and none of them invented here: + * + * - **Above the horizon**, the same top-to-horizon gradient `scenekit.ts` + * paints on the background, so the environment and the visible sky are the + * same sky. It is blended on `sin(elevation)` raised to a power rather than + * linearly on the angle, because that is what puts the pale band near the + * horizon where the eye expects it. + * - **Below the horizon**, the hemisphere light's ground colour. The lower + * half of a real environment is the terrain, and leaving it black is the + * single most common way an environment map makes a car look like a toy. + * - **The sun**, as a narrow lobe plus a wide glow, at the direction + * `Atmosphere` already computed. Half-float storage is what makes this + * possible at all: the lobe sits several times above 1.0 and an 8-bit + * texture would clip it to the same white as the sky beside it, which is + * the exact failure `stage.ts` just removed from the main render path. + */ +function fillSkyEquirect(existing: THREE.DataTexture | null, l: LightingState): THREE.DataTexture { + const width = EQUIRECT_WIDTH; + const height = EQUIRECT_HEIGHT; + const texture = + existing ?? + new THREE.DataTexture( + new Uint16Array(width * height * 4), + width, + height, + THREE.RGBAFormat, + THREE.HalfFloatType, + ); + const data = texture.image.data as Uint16Array; + + const top = linearOf(l.sky?.top ?? l.hemisphere.sky); + const horizon = linearOf(l.sky?.horizon ?? l.hemisphere.ground); + const ground = linearOf(l.hemisphere.ground); + + const skyScale = SKY_RADIANCE_SHARE * Math.max(0, l.hemisphere.intensity); + const groundScale = GROUND_RADIANCE_SHARE * Math.max(0, l.hemisphere.intensity); + const sun = linearOf(l.sun.color); + const sunScale = Math.max(0, l.sun.intensity); + const [sx, sy, sz] = l.sun.direction; + + const half = THREE.DataUtils.toHalfFloat; + + for (let j = 0; j < height; j++) { + // Row 0 is v = 0. Three's `equirectUv` puts v = 0 at `dir.y = -1`, and a + // `DataTexture` does not flip, so row 0 is straight down. + const v = (j + 0.5) / height; + const phi = (v - 0.5) * Math.PI; + const sinPhi = Math.sin(phi); + const cosPhi = Math.cos(phi); + + // The vertical blend, before the sun is added. Above the horizon it walks + // the sky gradient; below it fades the ground colour down as it goes under, + // so there is no hard band at the equator to show up in a mirror. + let baseR: number; + let baseG: number; + let baseB: number; + if (sinPhi >= 0) { + const t = Math.pow(sinPhi, 0.55); + baseR = (horizon[0] + (top[0] - horizon[0]) * t) * skyScale; + baseG = (horizon[1] + (top[1] - horizon[1]) * t) * skyScale; + baseB = (horizon[2] + (top[2] - horizon[2]) * t) * skyScale; + } else { + const t = Math.pow(-sinPhi, 0.7); + const dim = 1 - 0.55 * t; + baseR = (horizon[0] * (1 - t) * skyScale + ground[0] * t * groundScale) * dim; + baseG = (horizon[1] * (1 - t) * skyScale + ground[1] * t * groundScale) * dim; + baseB = (horizon[2] * (1 - t) * skyScale + ground[2] * t * groundScale) * dim; + } + + for (let i = 0; i < width; i++) { + const u = (i + 0.5) / width; + const theta = (u - 0.5) * Math.PI * 2; + const dx = cosPhi * Math.cos(theta); + const dy = sinPhi; + const dz = cosPhi * Math.sin(theta); + + const cos = dx * sx + dy * sy + dz * sz; + let solar = 0; + if (cos > 0) { + solar = + SUN_LOBE_RADIANCE * Math.pow(cos, SUN_LOBE_TIGHTNESS) + + SUN_GLOW_RADIANCE * Math.pow(cos, SUN_GLOW_TIGHTNESS); + solar *= sunScale; + } + + const o = (j * width + i) * 4; + data[o] = half(baseR + sun[0] * solar); + data[o + 1] = half(baseG + sun[1] * solar); + data[o + 2] = half(baseB + sun[2] * solar); + data[o + 3] = half(1); + } + } + + texture.mapping = THREE.EquirectangularReflectionMapping; + // Half-float data is already linear radiance; naming a transfer function here + // would apply an sRGB decode to numbers that were never encoded. + texture.colorSpace = THREE.NoColorSpace; + texture.needsUpdate = true; + return texture; +} + +// ---- The office room ------------------------------------------------------ + +interface RoomProbe { + scene: THREE.Scene; + tune(l: LightingState): void; + dispose(): void; +} + +/** + * Nine quads that reflect like a room. + * + * The list is short and every entry earns its place in a reflection: a bright + * ceiling and two brighter light panels (which is what puts the long vertical + * highlight down the edge of a monitor bezel), a floor darker than the walls, a + * window wall carrying the sun's own colour, and a warm end wall so the room + * has a direction to it and a chrome chair leg is not the same colour all the + * way round. + * + * Built once and re-coloured, not rebuilt. `tune` only assigns to + * `material.color`, so the geometry, the materials and their shader programs + * survive every change of hour. + */ +function createRoomProbe(): RoomProbe { + const scene = new THREE.Scene(); + const geometries: THREE.BufferGeometry[] = []; + const materials: THREE.MeshBasicMaterial[] = []; + + function quad( + w: number, + h: number, + position: [number, number, number], + rotation: [number, number, number], + ): THREE.MeshBasicMaterial { + const geometry = new THREE.PlaneGeometry(w, h); + // `DoubleSide` so the probe camera at the origin sees every quad whichever + // way it was authored — an inside-out wall in an environment reads as a + // hole, and a hole reads as a black stripe across everything shiny. + const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }); + const mesh = new THREE.Mesh(geometry, material); + mesh.position.set(position[0], position[1], position[2]); + mesh.rotation.set(rotation[0], rotation[1], rotation[2]); + scene.add(mesh); + geometries.push(geometry); + materials.push(material); + return material; + } + + const HALF_W = ROOM_WIDTH / 2; + const HALF_D = ROOM_DEPTH / 2; + const HALF_H = ROOM_HEIGHT / 2; + + const ceiling = quad(ROOM_WIDTH, ROOM_DEPTH, [0, HALF_H, 0], [Math.PI / 2, 0, 0]); + const floor = quad(ROOM_WIDTH, ROOM_DEPTH, [0, -HALF_H, 0], [-Math.PI / 2, 0, 0]); + const back = quad(ROOM_WIDTH, ROOM_HEIGHT, [0, 0, -HALF_D], [0, 0, 0]); + const front = quad(ROOM_WIDTH, ROOM_HEIGHT, [0, 0, HALF_D], [0, Math.PI, 0]); + const left = quad(ROOM_DEPTH, ROOM_HEIGHT, [-HALF_W, 0, 0], [0, Math.PI / 2, 0]); + const window = quad(ROOM_DEPTH, ROOM_HEIGHT, [HALF_W, 0, 0], [0, -Math.PI / 2, 0]); + const panelA = quad(ROOM_WIDTH * 0.62, 0.5, [0, HALF_H - 0.02, -1.4], [Math.PI / 2, 0, 0]); + const panelB = quad(ROOM_WIDTH * 0.62, 0.5, [0, HALF_H - 0.02, 1.4], [Math.PI / 2, 0, 0]); + const accent = quad(ROOM_WIDTH * 0.9, 0.35, [0, -0.6, -HALF_D + 0.01], [0, 0, 0]); + + return { + scene, + tune(l) { + const sky = linearOf(l.hemisphere.sky); + const groundC = linearOf(l.hemisphere.ground); + const ambient = linearOf(l.ambient.color); + const sun = linearOf(l.sun.color); + + // An interior probe is normalised against the *fill*, not against the + // sun: a room's own surfaces are what a desk reflects, and they are lit + // by whatever is getting inside. Reading `hemisphere.intensity` keeps a + // night office reflecting a dim room and a noon office a bright one + // without this file forming its own opinion about either. + const fill = Math.max(0.05, l.hemisphere.intensity) * SKY_RADIANCE_SHARE; + const solar = Math.max(0, l.sun.intensity) * SKY_RADIANCE_SHARE; + + setLinear(ceiling, ambient, fill * 1.5); + setLinear(floor, groundC, fill * 0.8); + setLinear(back, ambient, fill * 1.1); + setLinear(front, ambient, fill * 1.0); + setLinear(left, ambient, fill * 1.2); + // The window is the only surface that knows what time it is, and it is + // the one that gives a monitor bezel a bright edge on the daylight side. + setLinear(window, sun, solar * 2.2 + fill * 0.6); + setLinear(panelA, sky, fill * 7); + setLinear(panelB, sky, fill * 7); + setLinear(accent, groundC, fill * 1.6); + }, + dispose() { + for (const g of geometries) g.dispose(); + for (const m of materials) m.dispose(); + geometries.length = 0; + materials.length = 0; + scene.clear(); + }, + }; +} + +// ---- Colour --------------------------------------------------------------- + +const SCRATCH = new THREE.Color(); + +/** + * An authored `0xrrggbb` as linear-light RGB. + * + * Named rather than inlined because getting it wrong is invisible until it is + * everywhere: `LightingState` colours are sRGB by the definition in `types.ts`, + * and everything on this page is radiance, which is linear. Multiplying an + * un-decoded 0.5 by an intensity is off by more than a factor of two at the + * dark end of the range, and the symptom is a night sky that reflects like an + * overcast noon. + */ +function linearOf(hex: number): [number, number, number] { + SCRATCH.setHex(hex, THREE.SRGBColorSpace); + return [SCRATCH.r, SCRATCH.g, SCRATCH.b]; +} + +/** + * Set a material's colour from linear radiance, which may exceed 1. + * + * `Color.setHex` and the `{ color }` constructor argument both go through an + * sRGB decode and both saturate at 1.0, so neither can express a light panel + * seven times brighter than the wall beside it. `setRGB` in the working colour + * space can, and a light panel that cannot be brighter than the wall is not a + * light panel. + */ +function setLinear( + material: THREE.MeshBasicMaterial, + color: readonly [number, number, number], + scale: number, +): void { + material.color.setRGB(color[0] * scale, color[1] * scale, color[2] * scale); +} diff --git a/src/engine/flights.ts b/src/engine/flights.ts index b07a1aa..e6a2355 100644 --- a/src/engine/flights.ts +++ b/src/engine/flights.ts @@ -2,12 +2,14 @@ * Aircraft over the city. * * The engine takes a `FlightSource` rather than talking to any particular - * service, because the obvious one cannot ship here. FlightRadar24's terms - * forbid scraping and forbid redistributing their data, so an Apache-2.0 repo - * containing an FR24 client would be publishing instructions for breaking a - * ToS and shipping data it has no right to relicense. Commercial sources are - * adapters in a private deployment; this file holds what we can actually give - * away. See ARCHITECTURE.md §4. + * service, because the obvious one cannot ship here. FlightRadar24's terms do + * not permit scraping and do not permit redistributing their data. An + * Apache-2.0 repo shipping such a client would not merely be breaking a ToS — + * it would be publishing instructions for doing so, alongside data it has no + * right to relicense. Commercial sources are adapters in a private deployment; + * this file holds what we can actually give away. See ARCHITECTURE.md §4, and + * `server/src/flights/licence.ts` for the allowlist that keeps the open lane + * open in practice rather than in principle. * * `SimulatedFlights` is the default and is genuinely enough for the map — what * a city view wants is convincing motion in the right corridors, not a @@ -345,6 +347,16 @@ const ADSB_HOLD_SECONDS = 60; * licence problem. The best answer long-term is an RTL-SDR on a fleet box: * first-party data, nothing to comply with. * + * **`endpoint` is not free-form, even though its type is `string`.** The + * allowlist of feeds this project will fetch, and the credit line each of them + * is owed, live in `server/src/flights/licence.ts`, which is where the API's + * `TERA_ADSB_ENDPOINT` is validated before a request is made. A browser drawing + * a feed for itself is not republishing it and so is not the exposure that gate + * exists for — but a self-hoster who constructs this class with some other + * endpoint is choosing terms nobody here has read, and this is the sentence + * that says so. Nothing in this repo constructs it: the shipped path is + * `HttpFlights` against our own API. + * * The region is required and has no default. It used to default to a point in * San Francisco, which is a fine centre for one of the two cities in this build * and a five-hundred-kilometre error for the other — and a wrong default is @@ -445,10 +457,183 @@ interface RawAircraft { track?: number; } +// ---- Detail --------------------------------------------------------------- + +/** + * One aircraft, described well enough to put on a card somebody clicked. + * + * The demo this project leads with is a signed-out visitor clicking a dart over + * a city they recognise and being told what it is, so this type is written for + * **anon** and carries nothing an account would be needed for. Everything in it + * is either broadcast unencrypted by the aircraft itself — ADS-B is receivable + * with a forty-dollar dongle — or arithmetic on top of that. There is no route, + * no registration and no operator here, because the open feeds do not carry + * them and inventing them would be the same class of lie `synthetic` exists to + * prevent. `owner-decisions.md` reserves those for an openly-licensed registry + * we have not wired. + * + * Two fields are about the *provenance* rather than the aeroplane, and they are + * the reason this is a type and not an object literal built in the UI: + * `observed` says whether anybody actually saw this, and `attribution` carries + * whatever the feed asks to be credited with **at the point the data is + * displayed**, which is what an ODbL notice is for. A card is a display. A + * corner label on the other side of the screen is not obviously one. + */ +export interface AircraftDetail { + /** The source's own id. The ICAO address for a live feed; a route name for the simulator. */ + id: string; + /** Flight number or tail as broadcast, trimmed, or `null` when the feed said nothing. */ + callsign: string | null; + /** + * The transponder's 24-bit ICAO address, lowercase hex, or `null`. + * + * `null` rather than a guess for anything that does not look like one — the + * simulator's ids are route names and a `~`-prefixed id on a real feed is a + * non-ICAO address (TIS-B and MLAT targets carry them), which is genuinely + * not an ICAO24 and must not be presented as one. Somebody can paste this + * into a registry lookup, so a wrong one sends them to another aircraft. + */ + icao24: string | null; + lat: number; + lng: number; + /** Barometric altitude, metres — the unit the wire and the engine both use. */ + altitudeM: number; + /** The same altitude in feet, which is the unit aviation is actually read in. */ + altitudeFt: number; + /** Degrees clockwise from true north. */ + headingDeg: number; + /** The heading as a 16-point compass name, for a card a human reads. */ + headingCompass: string; + /** Nautical miles from the board's centre, or `null` when no centre was given. */ + distanceNm: number | null; + /** + * Did somebody observe this, or did this repo invent it? + * + * The same statement `TrafficSource.live()` makes about the whole feed, made + * about one aircraft, and it must travel with the aircraft: a card is read on + * its own, away from any corner label, and a fabricated flight number + * presented in the same frame as a real one is the confusion the `live` flag + * exists to prevent. + */ + observed: boolean; + /** Credit lines owed for this aircraft, to be shown on the card itself. */ + attribution: string[]; +} + +/** + * An ICAO 24-bit address as the feeds write it: six hex digits, lowercase. + * + * Anchored, so `sim-BA286` fails and `~abc123` — the anonymous-address form + * both community feeds emit for targets whose real address is not known — fails + * too, which is the point. See `AircraftDetail.icao24`. + */ +const ICAO24 = /^[0-9a-f]{6}$/; + +/** The sixteen names, in the order the compass runs. */ +const COMPASS = [ + "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", + "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW", +]; + +/** + * A bearing as a compass point. + * + * Sixteen points rather than eight because the difference between "north-east" + * and "east-north-east" is the difference between two departure corridors, and + * rather than thirty-two because nobody reads "NNE by N" off a card. Negative + * and out-of-range degrees are wrapped rather than refused: a heading is an + * angle and every angle names a direction. + */ +export function compassPoint(degrees: number): string { + if (!Number.isFinite(degrees)) return "—"; + const wrapped = ((degrees % 360) + 360) % 360; + return COMPASS[Math.round(wrapped / 22.5) % 16] ?? "N"; +} + +/** Metres to feet. The wire carries metres; aviation is read in feet. */ +const FEET_PER_METRE = 3.280_84; + +export interface AircraftDetailOptions { + /** + * The transponder address, when the caller was told one separately. + * + * `HttpFlights` is: `WireAircraft.icao24` is a field on the body and + * `Aircraft` has nowhere to put it, so the adapter keeps the wire record + * beside the position and hands it back here. Absent, the id is tested + * against `ICAO24` — which is right for every feed that keys on the hex, and + * correctly declines for the simulator. + */ + icao24?: string | null; + /** Whether these coordinates were observed. Defaults to `false`: invented until said otherwise. */ + observed?: boolean; + /** Credit lines the feed asks for, shown on the card. */ + attribution?: readonly string[]; + /** Board centre, for the distance readout. Omit and `distanceNm` is `null`. */ + from?: Place; +} + +/** + * Turn an `Aircraft` into something a panel can render, without the panel + * knowing where aircraft come from. + * + * Pure, total and free of I/O, so the interface layer can call it on a click + * without awaiting anything, and so it can be tested without a network. It + * invents nothing: every field is a restatement, a unit conversion or a `null`. + */ +export function aircraftDetail( + aircraft: Aircraft, + options: AircraftDetailOptions = {}, +): AircraftDetail { + const callsign = aircraft.callsign?.trim(); + const declared = options.icao24?.trim().toLowerCase(); + const fromId = aircraft.id.trim().toLowerCase(); + const icao24 = + declared !== undefined && ICAO24.test(declared) + ? declared + : ICAO24.test(fromId) + ? fromId + : null; + + return { + id: aircraft.id, + callsign: callsign === undefined || callsign === "" ? null : callsign, + icao24, + lat: aircraft.lat, + lng: aircraft.lng, + altitudeM: aircraft.altitude, + altitudeFt: Math.round(aircraft.altitude * FEET_PER_METRE), + headingDeg: aircraft.heading, + headingCompass: compassPoint(aircraft.heading), + distanceNm: + options.from === undefined + ? null + : Math.round(distanceNm(options.from, { lat: aircraft.lat, lng: aircraft.lng }) * 10) / 10, + observed: options.observed === true, + attribution: [...(options.attribution ?? [])], + }; +} + // ---- Rendering ------------------------------------------------------------ export interface FlightLayer { group: THREE.Group; + /** + * The aircraft meshes currently in the sky, as a **live** array, each + * carrying `userData.aircraftId`. + * + * Here rather than on the caller because only this layer knows which mesh is + * which track: the map from id to mesh is private and the group's child order + * is an artefact of when each aircraft appeared. It is the same shape + * `MarkerLayer.pickables` publishes and it exists for the same reason — a + * pick is resolved from the object that was hit, and something has to say + * what the object stands for. + * + * `owner-decisions.md` is why this is not gated on anything: an ADS-B + * position is broadcast unencrypted to anybody with a receiver, so the card + * it opens is available to an anonymous visitor and the picking that reaches + * it must be too. + */ + pickables: THREE.Object3D[]; /** * Hand over a fresh observation. Called on the source's own timer, which is * once a second for the simulator and once every several seconds for a real @@ -716,6 +901,9 @@ interface Track { export function createFlightLayer(world: World): FlightLayer { const group = new THREE.Group(); group.name = "flights"; + // Mutated in place as tracks appear and expire, so `setPicking` can hold the + // array itself as its target list rather than re-reading it every pointer move. + const pickables: THREE.Object3D[] = []; const geo = airlinerGeometry(); const materials = new Map(); @@ -799,7 +987,11 @@ export function createFlightLayer(world: World): FlightLayer { // Yaw then pitch, because the heading is about the world's vertical and // the climb angle is about the aircraft's own wing. mesh.rotation.order = "YXZ"; + // The id, on the object, so a raycast hit resolves to an aeroplane + // without this layer having to expose its private track table. + mesh.userData.aircraftId = a.id; group.add(mesh); + pickables.push(mesh); track = { mesh, samples: [], @@ -921,6 +1113,8 @@ export function createFlightLayer(world: World): FlightLayer { if (track.missingSince === 0) track.missingSince = now; if (now - track.missingSince < TRACK_GRACE_SECONDS) continue; group.remove(track.mesh); + const at = pickables.indexOf(track.mesh); + if (at >= 0) pickables.splice(at, 1); tracks.delete(id); } @@ -1139,6 +1333,7 @@ export function createFlightLayer(world: World): FlightLayer { return { group, + pickables, update, tick, dispose() { @@ -1148,6 +1343,7 @@ export function createFlightLayer(world: World): FlightLayer { trailGeo.dispose(); trailMat.dispose(); tracks.clear(); + pickables.length = 0; group.clear(); }, }; diff --git a/src/engine/officeExterior.ts b/src/engine/officeExterior.ts new file mode 100644 index 0000000..a3cbc7d --- /dev/null +++ b/src/engine/officeExterior.ts @@ -0,0 +1,490 @@ +/** + * The apron outside the front door, and the car standing on it. + * + * Every shipped pack authors one `ExteriorArrival` — a marked stall on the + * ground outside the building, in the pack's own plan frame. This layer turns + * that one anchor into a piece of the world: a paved pad, a painted bay, a kerb, + * a charge post, and a Model X parked in it whose lamps and cabin reflect a live + * {@link VehicleTelemetryState}. + * + * ### Why the car stands here rather than on the board + * + * There has been a Model X in this product since the freeway corridor shipped, + * and it has only ever existed as traffic: forty instanced glyphs at 0.18 scale + * on a board where one scene unit is 94 metres, seen from four kilometres up. + * That is a map symbol. This is the same asset at 1 unit = 1 m, three metres + * from a doorway you can walk through, which is the first time anything in the + * product has asked it to be a car — and it is why `assets/vehicles/modelX.ts` + * was rebuilt with arches, glass openings and a real tyre. + * + * ### What this layer owns and what it borrows + * + * It owns geometry and exactly one material (the cabin glow, whose emissive + * strength varies continuously and therefore cannot be a shared registry + * material). Everything else is borrowed: surface roles come from the + * `MaterialRegistry`, indicator colours come from `materials.tinted`, and the + * kerbside planter is built through the `AssetRegistry` so a self-hoster who has + * re-skinned `tera:planter.trough` gets their trough out here too. + * + * `dispose()` frees what this layer made and deliberately leaves the registries + * alone. Disposing a shared `polishedConcrete` here would blank the floor of the + * office the apron stands outside of. + * + * ### It constructs no light + * + * CONTRACT §4: `Atmosphere` is the sole light owner. A charge lamp, a marker + * lamp and a lit cabin are all *emissive materials*, exactly as + * `interiors/luminaires.ts` makes a ceiling fitting glow without becoming one. + * Nothing in this file is a light source, and the release gate's grep for the + * five three.js light constructors returns nothing over it on purpose. + */ + +import * as THREE from "three"; +import type { AssetRegistry } from "../assets/kit.ts"; +import { createAssetContext } from "../assets/kit.ts"; +import type { MaterialRegistry } from "../assets/materials.ts"; +import { MeshBin, parts } from "../assets/parts.ts"; +import { + MODEL_X_METRICS, + MODEL_X_PAINTS, + buildModelX, + disposeModelX, + type ModelXDetail, +} from "../assets/vehicles/index.ts"; +import type { ExteriorArrival, OfficeSite } from "../interiors/types.ts"; +import { + apronKindFor, + apronMetrics, + exteriorVehicleAppearance, + lampTint, + parkPose, + type ApronMetrics, + type ExteriorVehicleAppearance, +} from "../transport/exteriorVehicle.ts"; +import type { VehicleTelemetryState } from "../transport/vehicleTelemetry.ts"; + +export interface OfficeExteriorOptions { + site: OfficeSite; + arrival: ExteriorArrival; + assets: AssetRegistry; + materials: MaterialRegistry; + /** Seeded per office, so the same studio has the same car outside it forever. */ + rand: () => number; + /** + * Which Model X to build. + * + * `corridor` is the right answer for a parked car and is what a caller should + * pass unless the camera is close enough to read a door shutline: 3,192 + * triangles across 18 draw calls, against 13,080 and 25 for `follow`. The + * difference a viewer can see at three metres is the wing mirrors, the glass + * frames and the brake calipers; at ten it is nothing at all. + */ + detail: ModelXDetail; +} + +export interface OfficeExterior { + object: THREE.Object3D; + /** Reflect one telemetry observation. Cheap, idempotent, safe every frame. */ + apply(telemetry: VehicleTelemetryState): void; + dispose(): void; +} + +/** + * Where the charge flap sits on the vehicle's left rear quarter, in the asset's + * own frame. + * + * These are not invented: they are the surface the bodyshell loft actually + * produces there, measured by casting a ray across the flank at that height + * (`x = −1.0004` on both LODs), plus 3.6 mm so the flap stands proud the way a + * real one does rather than z-fighting with the paint. Aft of the rear arch and + * below the shoulder line, which is where a charge port goes on every car that + * has one. + */ +const CHARGE_PORT = { x: -1.004, y: 0.86, z: 1.95 } as const; + +/** Radius of the charge-flap ring, metres. */ +const CHARGE_PORT_RADIUS = 0.055; + +/** + * The cabin glow slab, in the vehicle's frame: a dome light under the roof. + * + * Deliberately a thin horizontal plate rather than a filled cabin volume. The + * greenhouse is real glass with an interior behind it, so a lit box would be + * seen *through* the seats; a plate at head height reads as the light being on + * and disappears when it is not. + */ +const CABIN_GLOW = { width: 1.24, thickness: 0.02, depth: 2.05, y: 1.3, z: -0.05 } as const; + +/** How hard the cabin plate is driven at `cabinGlow === 1`. */ +const CABIN_GLOW_MAX_INTENSITY = 1.6; + +/** + * Build the apron and the vehicle at a pack's arrival anchor. + * + * The returned object sits at the plan's origin with an identity transform, and + * everything inside it is positioned in the pack's own metres — so a caller adds + * it to the level group and nothing has to agree about a frame. That is also + * what makes the anchor assertable: the Model X's world position is + * `arrival.position` plus a bounded parking jitter, and nothing else. + */ +export function createOfficeExterior(options: OfficeExteriorOptions): OfficeExterior { + const { site, arrival, assets, materials, rand, detail } = options; + + const root = new THREE.Group(); + root.name = "office-exterior"; + root.userData.kind = "office-exterior"; + root.userData.arrivalKind = arrival.kind; + if (arrival.label) root.userData.label = arrival.label; + if (site.label) root.userData.siteLabel = site.label; + + const kind = apronKindFor(site.elevation); + const metrics = apronMetrics( + { length: MODEL_X_METRICS.length, width: MODEL_X_METRICS.width }, + kind, + ); + + // Geometries this layer minted and must free. Registry materials are not in + // here and must not be: they belong to the office this apron stands outside. + const ownedGeometries: THREE.BufferGeometry[] = []; + const ownedMaterials: THREE.Material[] = []; + + // ---- The apron ---------------------------------------------------------- + + const apron = new THREE.Group(); + apron.name = "office-exterior:apron"; + apron.position.set(arrival.position.x, 0, arrival.position.z); + // `Yaw` is `object.rotation.y` with no conversion (interiors/types.ts), which + // is why nothing in this file converts an angle. + apron.rotation.y = arrival.rotation; + root.add(apron); + + const pavingColor = kind === "street" ? 0x6f7370 : 0x8d908a; + const paving = materials.tinted("polishedConcrete", pavingColor); + const kerbMaterial = materials.get("skirting"); + const lineMaterial = materials.tinted("polishedConcrete", 0xd9d8cd); + const postShell = materials.get("deviceShell"); + const postCap = materials.get("metalTrim"); + + // Two bins rather than one, split on whether the piece casts a shadow. A + // `MeshBin` sets the flags per build, and a flat slab lying on the ground has + // nothing to cast onto while a 1.3 m post very much does. + const flat = new MeshBin(); + const upright = new MeshBin(); + const top = metrics.padThickness; + + // The slab. `metricQuad` under the top face rather than a scaled `quad`, + // because the paving carries a texture and its relief now carries a normal + // map: a unit quad scaled to six metres smears one 2 m tile across the whole + // pad, colour and relief together. + flat.box(paving, { + y: 0, + size: [metrics.padWidth, metrics.padThickness, metrics.padDepth], + }); + flat.add(parts.metricQuad(metrics.padWidth, metrics.padDepth), paving, { y: top + 0.001 }); + + // A kerb upstand around the pad, with a dropped crossing at the open end. + // + // Two decisions in one shape, and both are worth naming. The upstand runs all + // the way round rather than along one edge because the exterior layer is + // handed a stall and a site and *neither of them says which side the street + // is on*: `mateo-court`'s bay runs east along Mateo Street with the façade to + // its left, `frontier-valley`'s faces an apron with taxiway on three sides, + // and `lumbridge-hq`'s has no street at all. A carriageway laid on a guessed + // side would be wrong half the time; a kerbed island is right every time. + // + // The gap at +Z is the crossing the car drove in over. Without it the kerb + // closes the bay on all four sides and the car reads as having been craned + // into a planter, which is a small thing that reliably breaks the illusion. + const openingWidth = metrics.stallWidth + 0.4; + const returnWidth = Math.max(0.2, (metrics.padWidth - openingWidth) / 2); + const returnX = openingWidth / 2 + returnWidth / 2; + const frontZ = metrics.padDepth / 2 - metrics.kerbDepth / 2; + const kerbRuns: [number, number, number, number][] = [ + // The head of the bay, and the two long flanks. + [0, -metrics.padDepth / 2 + metrics.kerbDepth / 2, metrics.padWidth, metrics.kerbDepth], + [-metrics.padWidth / 2 + metrics.kerbDepth / 2, 0, metrics.kerbDepth, metrics.padDepth], + [metrics.padWidth / 2 - metrics.kerbDepth / 2, 0, metrics.kerbDepth, metrics.padDepth], + // The two returns either side of the crossing. + [-returnX, frontZ, returnWidth, metrics.kerbDepth], + [returnX, frontZ, returnWidth, metrics.kerbDepth], + ]; + for (const [x, z, w, d] of kerbRuns) { + upright.box(kerbMaterial, { x, y: top, z, size: [w, metrics.kerbHeight, d] }); + } + + // The painted bay: two flanks and a head. Three strips and not a rectangle + // outline, because a bay is open at the end you drive in through, and the + // open end is what tells a viewer which way the car came in. + const lineY = top + 0.004; + const halfW = metrics.stallWidth / 2; + const halfL = metrics.stallLength / 2; + flat.box(lineMaterial, { + x: -halfW, y: lineY, z: 0, + size: [metrics.lineWidth, 0.004, metrics.stallLength], + }); + flat.box(lineMaterial, { + x: halfW, y: lineY, z: 0, + size: [metrics.lineWidth, 0.004, metrics.stallLength], + }); + flat.box(lineMaterial, { + x: 0, y: lineY, z: -halfL, + size: [metrics.stallWidth, 0.004, metrics.lineWidth], + }); + + // The charge post. A moulded column with a metal cap and a recessed face; the + // lamps and the charge bar are separate meshes because they change. + upright.box(postShell, { + x: metrics.postOffsetX, y: top, z: metrics.postOffsetZ, + size: [metrics.postWidth, metrics.postHeight, metrics.postDepth], + }); + upright.box(postCap, { + x: metrics.postOffsetX, y: top + metrics.postHeight, z: metrics.postOffsetZ, + size: [metrics.postWidth + 0.03, 0.035, metrics.postDepth + 0.03], + }); + upright.box(postCap, { + x: metrics.postOffsetX, y: top, z: metrics.postOffsetZ, + size: [metrics.postWidth + 0.06, 0.05, metrics.postDepth + 0.06], + }); + + for (const group of [ + flat.build("office-exterior:paving", { castShadow: false }), + upright.build("office-exterior:furniture", { castShadow: true }), + ]) { + for (const child of group.children) { + const mesh = child as THREE.Mesh; + if (mesh.isMesh) ownedGeometries.push(mesh.geometry); + } + apron.add(group); + } + + // ---- Kerbside dressing, through the asset registry ---------------------- + // + // Built rather than modelled inline so that a self-hoster who registered + // `acme:planter.trough` with `overrides: "tera:planter.trough"` gets their + // planter out here as well as inside. Gated on `has()` because a stripped + // registry is a legitimate configuration and a placeholder box on the kerb is + // worse than an empty kerb. + const PLANTER_ID = "tera:planter.trough"; + if (assets.has(PLANTER_ID)) { + const planter = assets.build( + PLANTER_ID, + createAssetContext({ materials, registry: assets, rand }), + ); + const footprint = assets.footprintOf(PLANTER_ID); + // Turned side-on so its length runs along the bay rather than across it, + // and set just inside the kerb on the side away from the charge post. + planter.rotation.y = Math.PI / 2; + planter.position.set(halfW + footprint.depth / 2 + 0.18, top, 0); + planter.name = "office-exterior:planter"; + apron.add(planter); + planter.traverse((object) => { + const mesh = object as THREE.Mesh; + if (mesh.isMesh) ownedGeometries.push(mesh.geometry); + }); + } + + // ---- Indicators --------------------------------------------------------- + // + // One geometry shared by three lamps, and a material per lamp swapped on the + // way through `materials.tinted`. See `LAMP_INTENSITY_STEPS` for why the + // brightness is quantised rather than continuous. + const lampGeometry = new THREE.BoxGeometry( + metrics.lampSize, metrics.lampSize, metrics.lampSize, + ); + ownedGeometries.push(lampGeometry); + + function makeLamp(name: string, index: number): THREE.Mesh { + const mesh = new THREE.Mesh(lampGeometry, materials.tinted("deviceIndicator", 0x000000)); + mesh.name = `office-exterior:${name}`; + mesh.position.set( + metrics.postOffsetX, + top + metrics.lampHeight - index * (metrics.lampSize + 0.04), + metrics.postOffsetZ + metrics.postDepth / 2, + ); + apron.add(mesh); + return mesh; + } + const chargeLamp = makeLamp("lamp-charge", 0); + const climateLamp = makeLamp("lamp-climate", 1); + const lockLamp = makeLamp("lamp-lock", 2); + + // The charge bar. Scaling a mesh is free; minting a material per percent is + // not, so the *quantity* is the scale and the colour is fixed. + const barGeometry = new THREE.BoxGeometry(0.045, 1, 0.012).translate(0, 0.5, 0); + ownedGeometries.push(barGeometry); + const chargeBar = new THREE.Mesh( + barGeometry, + materials.tinted("deviceIndicator", 0x46d07a), + ); + chargeBar.name = "office-exterior:charge-bar"; + chargeBar.position.set( + metrics.postOffsetX, + top + 0.24, + metrics.postOffsetZ + metrics.postDepth / 2, + ); + apron.add(chargeBar); + + // ---- The vehicle -------------------------------------------------------- + + const pose = parkPose(arrival, rand); + const vehicle = new THREE.Group(); + vehicle.name = "office-exterior:vehicle"; + vehicle.position.set(pose.x, top, pose.z); + vehicle.rotation.y = pose.yaw; + root.add(vehicle); + + const paintIndex = Math.min( + MODEL_X_PAINTS.length - 1, + Math.max(0, Math.floor(rand() * MODEL_X_PAINTS.length)), + ); + const rig = buildModelX({ detail, paint: MODEL_X_PAINTS[paintIndex] ?? 0x465157 }); + rig.root.name = "office-exterior:model-x"; + vehicle.add(rig.root); + + const portGeometry = new THREE.CircleGeometry(CHARGE_PORT_RADIUS, 20); + ownedGeometries.push(portGeometry); + const chargePort = new THREE.Mesh( + portGeometry, + materials.tinted("deviceIndicator", 0x000000), + ); + chargePort.name = "office-exterior:charge-port"; + chargePort.position.set(CHARGE_PORT.x, CHARGE_PORT.y, CHARGE_PORT.z); + // A `CircleGeometry` faces +Z; the flap is on the vehicle's left, so it turns + // to face −X. + chargePort.rotation.y = -Math.PI / 2; + vehicle.add(chargePort); + + const cabinGeometry = new THREE.BoxGeometry( + CABIN_GLOW.width, CABIN_GLOW.thickness, CABIN_GLOW.depth, + ); + ownedGeometries.push(cabinGeometry); + // The one material this layer owns. It cannot come from the registry because + // its `emissiveIntensity` is a continuous function of telemetry, and a shared + // material is shared: dimming this one would dim every indicator in the + // office with it. + const cabinMaterial = new THREE.MeshStandardMaterial({ + name: "office-exterior.cabin-glow", + color: 0xffe9c8, + emissive: 0xffe4bc, + emissiveIntensity: 0, + roughness: 1, + metalness: 0, + transparent: true, + opacity: 0.9, + // The plate lives inside a closed glass volume and is only ever seen through + // it. Writing depth would let it punch a hole in the tinted glass in front + // of it, which reads as a rectangular window cut in the roof. + depthWrite: false, + }); + ownedMaterials.push(cabinMaterial); + const cabinGlow = new THREE.Mesh(cabinGeometry, cabinMaterial); + cabinGlow.name = "office-exterior:cabin-glow"; + cabinGlow.position.set(0, CABIN_GLOW.y, CABIN_GLOW.z); + cabinGlow.castShadow = false; + cabinGlow.receiveShadow = false; + cabinGlow.visible = false; + vehicle.add(cabinGlow); + + // ---- The cable ---------------------------------------------------------- + // + // Built in the root's frame rather than either child's, because its two ends + // live in different frames: the socket is on the post (apron frame) and the + // flap is on the car (vehicle frame, which carries the parking jitter). A + // curve between two world points is the only version of this that stays + // attached when the car parks 90 mm off the line. + // Both children have just been positioned and nothing has rendered yet, so + // their world matrices are stale until this runs. + root.updateMatrixWorld(true); + const socket = apron.localToWorld( + new THREE.Vector3( + metrics.postOffsetX, + top + 0.72, + metrics.postOffsetZ + metrics.postDepth / 2, + ), + ); + const flap = vehicle.localToWorld( + new THREE.Vector3(CHARGE_PORT.x - 0.02, CHARGE_PORT.y, CHARGE_PORT.z), + ); + const sag = socket.clone().add(flap).multiplyScalar(0.5); + // A charging cable hangs. Half a metre of droop over a two-metre span is what + // a heavy DC lead actually does, and it is the difference between a cable and + // a stick. + sag.y = Math.min(socket.y, flap.y) - 0.42; + const cableGeometry = new THREE.TubeGeometry( + new THREE.QuadraticBezierCurve3(socket, sag, flap), + 12, + 0.021, + 6, + false, + ); + ownedGeometries.push(cableGeometry); + const cable = new THREE.Mesh(cableGeometry, postShell); + cable.name = "office-exterior:cable"; + cable.castShadow = true; + cable.receiveShadow = true; + cable.visible = false; + root.add(cable); + + // ---- Telemetry ---------------------------------------------------------- + + /** + * The last appearance applied, as a string. + * + * `apply` is safe to call every frame and a caller should not have to know + * that. Everything downstream of a change is cheap except the registry tint + * lookups, and those are a `Map.get` and a string concat each — small, but + * three of them sixty times a second for a car that has not changed state in + * an hour is work nobody asked for. + */ + let lastSignature = ""; + + function signatureOf(look: ExteriorVehicleAppearance, plugged: boolean): string { + return [ + lampTint(look.charge), lampTint(look.climate), lampTint(look.lock), + look.cabinGlow.toFixed(3), look.chargeFraction.toFixed(3), plugged ? 1 : 0, + ].join("|"); + } + + function apply(telemetry: VehicleTelemetryState): void { + const look = exteriorVehicleAppearance(telemetry); + const signature = signatureOf(look, telemetry.pluggedIn); + if (signature === lastSignature) return; + lastSignature = signature; + + chargeLamp.material = materials.tinted("deviceIndicator", lampTint(look.charge)); + climateLamp.material = materials.tinted("deviceIndicator", lampTint(look.climate)); + lockLamp.material = materials.tinted("deviceIndicator", lampTint(look.lock)); + chargePort.material = materials.tinted("deviceIndicator", lampTint(look.charge)); + + // A zero-height bar is a degenerate mesh rather than an absent one, so the + // floor of the scale is a millimetre and visibility carries the rest. + chargeBar.scale.y = Math.max(0.001, look.chargeFraction * 0.62); + chargeBar.visible = look.chargeFraction > 0.005; + + cabinMaterial.emissiveIntensity = look.cabinGlow * CABIN_GLOW_MAX_INTENSITY; + cabinGlow.visible = look.cabinGlow > 0.01; + + cable.visible = telemetry.pluggedIn; + } + + return { + object: root, + apply, + dispose() { + // The rig owns its own materials (nothing external was handed in), so it + // frees them; `disposeModelX` already defaults to exactly that when + // `ownsMaterials` is true, and it is stated here rather than implied. + disposeModelX(rig, { disposeMaterials: true }); + for (const geometry of ownedGeometries) geometry.dispose(); + ownedGeometries.length = 0; + for (const material of ownedMaterials) material.dispose(); + ownedMaterials.length = 0; + root.clear(); + apron.clear(); + vehicle.clear(); + }, + }; +} + +/** Re-exported so a caller can size a bay without reaching into `transport/`. */ +export type { ApronMetrics }; diff --git a/src/engine/roadTraffic.ts b/src/engine/roadTraffic.ts index 4fc5985..275f4ff 100644 --- a/src/engine/roadTraffic.ts +++ b/src/engine/roadTraffic.ts @@ -35,7 +35,15 @@ export interface RoadTrafficOptions { seed?: number; /** Vehicle metres to scene units. State-scale cars are intentional glyphs. */ scale?: number; - /** Route-distance compression for playable corridor travel. Defaults to 900. */ + /** + * Route-distance compression for playable corridor travel. Defaults to 900. + * + * The counterpart is `METRE_SCALE_VEHICLE_OPTIONS` in + * `transport/exteriorVehicle.ts`, which is this same controller at 1 for the + * apron outside a studio. One state machine, one dial; see the note on + * `VehicleControllerOptions.travelScale` for what the dial does and does not + * touch. + */ travelScale?: number; } diff --git a/src/engine/satellites.ts b/src/engine/satellites.ts index 78b0036..855e707 100644 --- a/src/engine/satellites.ts +++ b/src/engine/satellites.ts @@ -300,6 +300,20 @@ export interface SatelliteLayer { group: THREE.Group; /** Redraw from a set of fixes. Cheap enough to call every frame, and is. */ update(fixes: SatelliteFix[]): void; + /** + * How dark the sky is, 0..1 — `nightFactor(sun.elevation)` from + * `atmosphere.ts`, and nothing else. + * + * This layer draws additively, which is correct at night and catastrophic in + * daylight: at 15:55 with the sun at +44° every dot was adding to an already + * bright sky and clipping to a hard white square, which is what a first-time + * visitor to the California board saw scattered across the frame before + * anything else registered. A satellite in daylight is not visible to the + * naked eye, so the honest alpha is zero — and the fade is the *same* curve + * `nightlights.ts` switches the city on with, so the sky does not empty at a + * different dusk from the one the windows light up at. + */ + setSkyDarkness(darkness: number): void; /** * Whether the layer draws at all. The catalogue keeps propagating either way — * see `setVisible` for why that is deliberate rather than wasteful. @@ -481,6 +495,24 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer { const scratchVec = new THREE.Vector3(); + /** + * 1 until somebody says otherwise, so a caller that never calls + * `setSkyDarkness` gets exactly the behaviour this layer had before it + * existed. A silent regression to an invisible sky would be worse than the + * defect being fixed. + */ + let skyDarkness = 1; + /** Whether the godmode switch wants this layer at all. Two questions, two flags. */ + let wanted = true; + + /** The hour outranks the switch: a god at noon still gets no white squares. */ + function applyVisibility(): void { + // Below a fiftieth the dots contribute nothing a screen can show, and + // skipping the draw entirely is what makes the daytime cost of this layer + // zero rather than merely invisible. + group.visible = wanted && skyDarkness > 0.02; + } + function update(fixes: SatelliteFix[]): void { let n = 0; for (const fix of fixes) { @@ -500,10 +532,11 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer { colors[n * 4] = scratch.r; colors[n * 4 + 1] = scratch.g; colors[n * 4 + 2] = scratch.b; - colors[n * 4 + 3] = horizon * (SHADOW_ALPHA + (1 - SHADOW_ALPHA) * lit); + colors[n * 4 + 3] = horizon * (SHADOW_ALPHA + (1 - SHADOW_ALPHA) * lit) * skyDarkness; n += 1; } + applyVisibility(); geo.setDrawRange(0, n); positionAttr.needsUpdate = true; colorAttr.needsUpdate = true; @@ -512,6 +545,10 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer { return { group, update, + setSkyDarkness(darkness) { + skyDarkness = Math.min(1, Math.max(0, darkness)); + applyVisibility(); + }, /** * Hiding the layer stops it drawing and does **not** stop the catalogue * propagating, which is the right way round: turning the sky back on should @@ -521,7 +558,8 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer { * re-entry is worth more than reclaiming it. */ setVisible(visible: boolean) { - group.visible = visible; + wanted = visible; + applyVisibility(); }, dispose() { geo.dispose(); diff --git a/src/engine/scene.ts b/src/engine/scene.ts index 261e095..8e4f398 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -34,6 +34,7 @@ import { createFlightLayer, type FlightLayer } from "./flights.ts"; import { createCloudLayer, type CloudLayer } from "./clouds.ts"; import { createMarkerLayer, type MarkerLayer } from "./markers.ts"; import { solarPosition, sunDirection } from "./solar.ts"; +import { nightFactor } from "./atmosphere.ts"; import { createStarlinkMeshLayer, type StarlinkMeshLayer } from "./starlinkMesh.ts"; import { createSatelliteLayer, @@ -41,6 +42,7 @@ import { type SatelliteLayer, } from "./satellites.ts"; import { createSceneKit, type Pose } from "./scenekit.ts"; +import type { EnvironmentRig } from "./environmentRig.ts"; import { createRoadTrafficLayer, type RoadTrafficLayer, @@ -55,6 +57,7 @@ import type { import { createBridges, createFreewayWorld, createRoads } from "./structures.ts"; import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts"; import type { + Aircraft, Chapter, City, FlightSource, @@ -84,6 +87,11 @@ import { cityControlOwnership, type CityControlMode } from "../play/controlMode. export type CityRealtimePeersOptions = Omit; +/** What the pointer is over: an authored place, or an observed aeroplane. */ +type Pick = + | { kind: "marker"; marker: Marker } + | { kind: "aircraft"; aircraft: Aircraft }; + export interface SceneOptions { city: City; markerPalette?: MarkerPalette; @@ -118,6 +126,34 @@ export interface SceneOptions { satellites?: SatelliteCatalogue; /** Fires on hover/click of a marker head. */ onMarkerPick?: (marker: Marker | null) => void; + /** + * Fires on hover of an aeroplane, and with `null` as the pointer leaves one. + * + * The same shape as `onMarkerPick` and for the same reason: `scenekit` reports + * picks by hover, so a click handler upstairs reads whatever the last hover + * resolved. What comes back is the engine's own `Aircraft` — a position and a + * callsign — and nothing about where it came from, because that is a question + * about the deployment and `adapters/http.ts` is the layer that can answer it. + * + * Not gated on anything. An ADS-B position is broadcast in clear to anybody + * with a receiver, so there is nothing here an account could grant; see + * `owner-decisions.md` and the note on `TrafficSource.detail`. + */ + onAircraftPick?: (aircraft: Aircraft | null) => void; + /** + * The shared environment map, when the page has one. + * + * Handed in rather than built here, and that is the whole of the wiring rule: + * a `PMREMGenerator` and its render targets belong to the **renderer**, which + * outlives every city on the page, so one rig is built beside the `Stage` and + * shared. A rig per `createScene` would allocate a fresh blur chain and a + * fresh target for every board and leak both on the next switch, which is + * exactly the arithmetic `stage.ts` records for the renderer itself. + * + * Absent, everything renders as it did before the rig existed: duller metal, + * no sky in the water, and no error. + */ + environment?: EnvironmentRig; /** * Opening light rig. Comes from an `Atmosphere` when there is one; without * one the city gets `cityDaylight()`, because a scene that renders black @@ -356,6 +392,9 @@ export async function createScene( // two disagree for the one frame before the app's first `setLighting`. const opening = options.lighting ?? cityDaylight(pal, boardSpan); kit.applyLighting(opening); + // The environment before the first layer is added, so the very first frame + // has a sky to reflect rather than acquiring one a `setLighting` later. + options.environment?.apply(scene, opening, "city"); scene.add(createWater(world)); scene.add(createShorePlates(world)); @@ -443,6 +482,15 @@ export async function createScene( let flightLayer: FlightLayer | null = null; let flightTimer = 0; + /** + * The last observation, by id, so a pick has something to hand back. + * + * The layer interpolates between observations and keeps no record a caller + * could read; this is the record. Rebuilt wholesale on every poll rather than + * merged, so an aeroplane that has left the region leaves this table with it + * and a card cannot be opened on a track that is no longer in the sky. + */ + const lastAircraft = new Map(); if (options.flights) { flightLayer = createFlightLayer(world); scene.add(flightLayer.group); @@ -553,12 +601,38 @@ export async function createScene( // ---- Picking ------------------------------------------------------------ - // `pickables` is mutated in place by the layer, so the array itself is the - // live target list. - kit.setPicking({ - targets: markerLayer.pickables, - resolve: (hit) => (hit.object.userData.marker as Marker | undefined) ?? null, - onChange: (marker) => options.onMarkerPick?.(marker), + /** + * Two things on this board are worth pointing at, and both are resolved here. + * + * `markerLayer.pickables` and `flightLayer.pickables` are both mutated in + * place by their layers, so neither array can simply be concatenated once — + * the picking target list has to be a getter that reads both at the moment of + * the test. A pin is an authored place; an aeroplane is an observation, and + * `Pick` keeps them apart as a union rather than flattening both to a string, + * because the aircraft card is five fields and a provenance line and the + * moment it becomes a sentence it can never be anything else again. + */ + const pickTargets = (): THREE.Object3D[] => + flightLayer === null + ? markerLayer.pickables + : [...markerLayer.pickables, ...flightLayer.pickables]; + + kit.setPicking({ + targets: pickTargets, + resolve: (hit) => { + const marker = hit.object.userData.marker as Marker | undefined; + if (marker) return { kind: "marker", marker }; + const id = hit.object.userData.aircraftId as string | undefined; + const aircraft = id === undefined ? undefined : lastAircraft.get(id); + return aircraft ? { kind: "aircraft", aircraft } : null; + }, + onChange: (picked) => { + // Both callbacks fire on every change, including the change back to + // `null`, so whichever card is up is retired by a pointer that leaves — + // and by a pointer that moves from a pin straight onto an aeroplane. + options.onMarkerPick?.(picked?.kind === "marker" ? picked.marker : null); + options.onAircraftPick?.(picked?.kind === "aircraft" ? picked.aircraft : null); + }, }); // ---- The scene, as the stage sees it ------------------------------------ @@ -588,7 +662,11 @@ export async function createScene( flightTimer -= dt; if (flightTimer <= 0) { flightTimer = options.flights.interval; - void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac)); + void Promise.resolve(options.flights.poll()).then((ac) => { + lastAircraft.clear(); + for (const a of ac) lastAircraft.set(a.id, a); + flightLayer?.update(ac); + }); } } // Every frame and on no timer of its own. The catalogue's sweep is @@ -610,16 +688,40 @@ export async function createScene( * dusk geometry that lights a Starlink pass. */ const when = skyOverride ?? new Date(); + const solar = solarPosition(city.center.lat, city.center.lng, when); + /** + * The sky's own brightness, and the fix for the worst thing on this + * board at first load. + * + * Both satellite layers draw light *added* to the sky: the dot cloud is + * `AdditiveBlending` and the near-field buses are unlit white. That is + * exactly right against a night sky and is a hard white square against a + * daytime one — which is what the California board showed at 15:55 with + * the sun at +44°, scattered across the frame, reading as render + * artefacts before anything else on the page registered. + * + * `nightFactor` is `atmosphere.ts`'s own dusk curve and is deliberately + * the same one `nightlights.ts` switches the city on with, so the sky + * does not empty at a different dusk from the one the windows light up + * at. It is computed here rather than taken from the rig for the reason + * the sun vector below is: `atmosphere.ts` floors the *rig's* light + * direction at `shadowFloorDeg` to keep the shadow camera usable, and a + * sun pinned above the horizon is precisely the wrong input for a + * question about how dark it is. + */ + const darkness = nightFactor(solar.elevation); + satelliteLayer.setSkyDarkness(darkness); + starlinkMeshes?.setSkyDarkness(darkness); const fixes = options.satellites.fixes(when); satelliteLayer.update(fixes); - starlinkMeshes?.update( - fixes, - kit.camera, - sunDirection(solarPosition(city.center.lat, city.center.lng, when)), - ); + starlinkMeshes?.update(fixes, kit.camera, sunDirection(solar)); } }, dispose() { + // Before anything else frees a texture: the rig holds this scene in a + // ledger so a rebuilt environment can be pushed to every scene using it, + // and a disposed city left in that ledger is the whole graph retained. + options.environment?.release(scene); options.flights?.dispose?.(); flightLayer?.dispose(); satelliteLayer?.dispose(); @@ -654,6 +756,17 @@ export async function createScene( setLighting: (state) => { kit.applyLighting(state); clouds.setLighting(state); + /** + * Every lighting change, and it is cheap to do it every one. + * + * The rig fingerprints the state coarsely and rebuilds only when the + * fingerprint moves, so an unchanged sky is a map lookup and an + * assignment. Calling it here rather than on a timer of its own is what + * keeps CONTRACT §4's single direction intact: `Atmosphere` decided this + * rig, the scene is applying it, and the environment is derived from the + * decision rather than being a second opinion about the light. + */ + options.environment?.apply(scene, state, "city"); }, setCloudCover: (fraction) => clouds.setCover(fraction), setWind: (kph, fromDeg) => clouds.setWind(kph, fromDeg), diff --git a/src/engine/stage.ts b/src/engine/stage.ts index c2d809e..1cf5407 100644 --- a/src/engine/stage.ts +++ b/src/engine/stage.ts @@ -83,8 +83,35 @@ export interface StageOptions { /** Device pixel ratio ceiling. Defaults to `deviceProfile().maxPixelRatio`. */ maxPixelRatio?: number; shadows?: boolean; + /** + * Tone mapping exposure. Defaults to `DEFAULT_TONE_MAPPING_EXPOSURE`. + * + * Live afterwards as `stage.renderer.toneMappingExposure` — the renderer is + * on the `Stage` for exactly this kind of reason, and a `setExposure` method + * would widen the interface CONTRACT.md §1 pins down for one assignment. + */ + exposure?: number; } +/** + * The exposure the world is tuned at. + * + * ACES takes linear radiance, divides by 0.6, runs the RRT+ODT fit and saturates, + * so `toneMappingExposure` is a photographic stop dial and not a brightness + * slider: at 1.0 an 18% grey card lands on 0.5 sRGB, which is the definition of + * the curve being *neutral*. 1.15 is a little over a fifth of a stop above + * neutral, and it is there because ACES darkens the bottom of the range — a + * linear 0.02 that used to display at 0.152 comes out at 0.080 — and this world + * spends a third of its day at night. The lift buys most of that back at the + * bottom while the shoulder eats it at the top, where nothing is left to lose. + * + * Exported because the number is a fact about the whole picture, not about this + * file: `atmosphere.ts`'s keyframe table is tuned against this exposure and + * `environmentRig.ts` builds its sky radiances to sit under the same shoulder. + * Anything that changes it is changing all three. + */ +export const DEFAULT_TONE_MAPPING_EXPOSURE = 1.15; + /** * What kind of machine this is, to the extent a browser will say. * @@ -172,6 +199,59 @@ export function createStage(canvas: HTMLCanvasElement, options: StageOptions = { Math.min(window.devicePixelRatio, options.maxPixelRatio ?? profile.maxPixelRatio), ); renderer.setSize(canvas.clientWidth, canvas.clientHeight, false); + + /** + * The two lines that decide what every colour in the product looks like. + * + * ### Why the output colour space is written down + * + * `outputColorSpace` has defaulted to `SRGBColorSpace` since r152, and this + * file relied on that default for its whole life. That is a bet on a default + * staying put across a library this repo pins with a caret, and the same bet + * lost once already three lines below: `PCFSoftShadowMap` was silently + * demoted to unfiltered basic shadows by an upstream change nobody here saw. + * A renderer's output transfer function is not a thing to inherit quietly — + * if it ever moves, every texture, palette and keyframe in the repo is wrong + * at once and the symptom is "the whole app looks washed out", which is the + * least diagnosable bug shape there is. So it is stated. + * + * ### Why ACES, and what it fixes + * + * `NoToneMapping` is not "no transform". It is `saturate()`: everything above + * linear 1.0 becomes exactly 1.0, and every distinction above that value is + * destroyed before the sRGB encode ever runs. This world drives values well + * past 1.0 on purpose — `atmosphere.ts` peaks the sun above 2.3, the office + * assets set `emissiveIntensity` up to 3.2 — so the brightest and most + * expensive third of the lighting range was being flattened into a single + * flat white. That is the whole explanation for the two worst-looking things + * in the product: sunlit walls with no shading gradient left in them, and + * every light fitting and screen rendering as an identical white rectangle + * regardless of how bright it was told to be. + * + * ACES filmic replaces the cliff with a shoulder. Linear 1.0 displays at + * about 0.90, 2.0 at 0.95, 4.0 at 0.98 — still separable, all the way up — so + * a diffuser at 0.85 and a screen at 3.2 finally look like different things. + * It costs a little in the shadows, where the toe is steeper than a plain + * gamma encode; the exposure above and the re-tuned night stops in + * `atmosphere.ts` are what pay that back. Both halves of that trade are + * required. Turning this on and leaving the intensity table alone gives a + * world that is correctly *shaped* and too dark, which reads as worse. + * + * Two things this deliberately does not touch, and it is worth knowing which + * so nobody goes hunting for a horizon seam that is not there. Three sets + * `toneMapped = false` on the background mesh whenever the background texture + * carries an sRGB transfer — `scenekit.ts`'s sky gradient does — and fog is + * mixed in `fog_fragment` *after* both `tonemapping_fragment` and + * `colorspace_fragment`, from a uniform already converted into the renderer's + * output space. So the sky and the fog are still displayed exactly as + * `atmosphere.ts` authored them, and they still agree with each other at the + * horizon. Only lit geometry moves, which is precisely the surface the + * intensity table controls. + */ + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = options.exposure ?? DEFAULT_TONE_MAPPING_EXPOSURE; + if (options.shadows ?? true) { renderer.shadowMap.enabled = true; /** diff --git a/src/engine/starlinkMesh.ts b/src/engine/starlinkMesh.ts index 1b3cf2b..e50e20b 100644 --- a/src/engine/starlinkMesh.ts +++ b/src/engine/starlinkMesh.ts @@ -320,6 +320,18 @@ export interface StarlinkMeshLayer { * pointing its solar panels at yesterday afternoon. */ update(fixes: readonly SatelliteFix[], camera: THREE.Camera, sun: SunVector): void; + /** + * How dark the sky is, 0..1 — `nightFactor(sun.elevation)`, the same number + * `SatelliteLayer` takes and from the same call site. + * + * These buses are unlit white boxes (`MeshBasicMaterial({ color: 0xffffff })`) + * because that is what a sunlit satellite twenty pixels across looks like + * against a night sky. Under a tone curve, against a *daytime* sky, they are + * the literal white squares live defect 1 named — the most damaging thing on + * the board at first load, and drawn at 15:55 with the sun at +44°, where no + * naked eye would see a satellite at all. + */ + setSkyDarkness(darkness: number): void; setVisible(visible: boolean): void; dispose(): void; } @@ -382,6 +394,14 @@ export function createStarlinkMeshLayer(options: StarlinkMeshOptions): StarlinkM * twelve kilometres of air a city sits in. This is 550 km above all of it. */ const busMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, fog: false }); + + /** + * Whether the caller wants this layer, and whether the sky is dark enough for + * it to be honest. Both have to be true, and they are two different questions: + * the first is a godmode switch and the second is the hour. + */ + let wanted = true; + let skyDarkness = 1; const arrayMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, fog: false, @@ -799,6 +819,14 @@ export function createStarlinkMeshLayer(options: StarlinkMeshOptions): StarlinkM return { group, update, + setSkyDarkness(darkness: number) { + skyDarkness = Math.min(1, Math.max(0, darkness)); + // Whole-group rather than a per-material opacity, because these two + // materials are opaque by design: making them `transparent` to fade them + // would buy a sort order and a blend for objects that are never partly + // visible — they are either in a sky you could see them in or they are not. + group.visible = wanted && skyDarkness > 0.02; + }, /** * Unlike `SatelliteLayer.setVisible`, this one also stops the work — see the * early return in `update`. The distinction is not an inconsistency: that @@ -808,7 +836,8 @@ export function createStarlinkMeshLayer(options: StarlinkMeshOptions): StarlinkM * on and the next visible frame is complete. */ setVisible(visible: boolean) { - group.visible = visible; + wanted = visible; + group.visible = visible && skyDarkness > 0.02; }, dispose() { // The instanced meshes first. `InstancedMesh.dispose()` releases the diff --git a/src/engine/structures.ts b/src/engine/structures.ts index 3efce4e..f4ff540 100644 --- a/src/engine/structures.ts +++ b/src/engine/structures.ts @@ -5,15 +5,145 @@ * Roads follow the terrain: each path is resampled far more finely than it is * written in the city pack, and every sample takes its height from the ground, * so a street climbs out of the flats instead of burrowing through the hill. + * + * ### Everything here is batched, and it has to be + * + * The city ran at 616 draw calls against a budget of 650 while the office spent + * 8% of its triangle budget: quality is nearly free indoors and is not free at + * all out here, so anything this module can hand back is headroom the exterior + * vehicles and the aircraft get to spend. Batching the corridor and the bridges + * took the California board to 557 measured — 59 calls, from 59 freeway meshes + * down to 20 plus the four extra shadow-pass draws the guardrails and sign + * posts used to cost. + * + * Two rules keep it honest, and both were broken before: + * + * 1. **Materials are cached by colour**, in a `Batch` that lives as long as + * the build call. Twelve identical asphalt decks used to be twelve + * `MeshLambertMaterial`s, which is twelve things that can never merge, and + * a single suspension bridge minted a fresh material for its deck, each + * tower, each brace, each cable and each hanger — about thirty-four. + * 2. **Geometry is merged per material.** Every helper below returns a + * `BufferGeometry` rather than a `Mesh`, and the caller drops it into a + * named bucket; one mesh comes out per bucket at the end. + * + * The cache is deliberately *not* module-level. `createScene().dispose()` walks + * the scene and disposes every material it finds, so a cache that outlived one + * build would hand the next board a disposed material and render it black. + * + * The corollary for anyone adding a helper here: give every geometry the **same + * attribute set** — position, normal, uv, indexed — or `mergeGeometries` + * refuses the bucket and silently drops it. That is why the ribbons below carry + * UVs they have no texture for. */ import * as THREE from "three"; +import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; import { buildFreewayWorldPlan } from "../transport/freewayWorld.ts"; import type { TransportPack } from "../transport/types.ts"; import { buildRoutePath, sampleRoute } from "../transport/vehicleSim.ts"; import type { Bridge, LatLng } from "./types.ts"; import type { World } from "./world.ts"; +// ---- Batching ------------------------------------------------------------- + +/** + * The three ways a surface out here is shaded. + * + * `deck` and `solid` differ only in sidedness: a road deck is a one-sided strip + * that has to survive being looked at from underneath on a bridge approach, and + * a tower is a closed solid where a back face is a waste. + * + * `marking` is unlit and `toneMapped: false` on purpose. Paint on a road is the + * one thing in the frame whose job is to be a fixed, known white — it is + * retroreflective, it is what a driver navigates by, and putting it through the + * ACES shoulder with everything else turns a lane line into a grey smear at + * midday and loses it entirely at dusk. + */ +type SurfaceKind = "deck" | "solid" | "marking"; + +interface Bucket { + readonly name: string; + readonly material: THREE.Material; + readonly castShadow: boolean; + readonly receiveShadow: boolean; + readonly parts: THREE.BufferGeometry[]; +} + +/** + * One build's worth of materials and geometry, merged on the way out. + * + * Buckets are keyed on **name and material together** rather than on the + * material alone. Sharing the material is what saves the draw call; keeping the + * name is what lets somebody looking at the scene graph still find the + * guardrails, and the one extra call it costs where two classes happen to share + * a material is worth being able to debug the thing. + */ +class Batch { + private readonly materials = new Map(); + private readonly buckets = new Map(); + + /** The one material for a kind and colour in this build. */ + material(kind: SurfaceKind, color: number): THREE.Material { + const key = `${kind}:${color.toString(16)}`; + const hit = this.materials.get(key); + if (hit) return hit; + const made = + kind === "marking" + ? new THREE.MeshBasicMaterial({ color, toneMapped: false, side: THREE.DoubleSide }) + : new THREE.MeshLambertMaterial({ + color, + side: kind === "deck" ? THREE.DoubleSide : THREE.FrontSide, + }); + made.name = key; + this.materials.set(key, made); + return made; + } + + add( + name: string, + geometry: THREE.BufferGeometry, + material: THREE.Material, + shadows: { cast?: boolean; receive?: boolean } = {}, + ): void { + const key = `${material.uuid}|${name}`; + const bucket = this.buckets.get(key); + if (bucket) { + bucket.parts.push(geometry); + return; + } + this.buckets.set(key, { + name, + material, + castShadow: shadows.cast ?? false, + receiveShadow: shadows.receive ?? true, + parts: [geometry], + }); + } + + /** Merge every bucket and hang the results off `into`. */ + flush(into: THREE.Group): void { + for (const bucket of this.buckets.values()) { + const merged = + bucket.parts.length === 1 ? bucket.parts[0] : mergeGeometries(bucket.parts, false); + // `mergeGeometries` returns null when the attribute sets disagree. Losing + // the bucket silently is exactly the failure the module comment warns + // about, so say so rather than rendering a road with no markings on it. + if (!merged) { + console.warn(`structures: "${bucket.name}" has mismatched attributes and was not merged`); + continue; + } + if (bucket.parts.length > 1) for (const part of bucket.parts) part.dispose(); + const mesh = new THREE.Mesh(merged, bucket.material); + mesh.name = bucket.name; + mesh.castShadow = bucket.castShadow; + mesh.receiveShadow = bucket.receiveShadow; + into.add(mesh); + } + this.buckets.clear(); + } +} + /** Resample a lat/lng path into scene-space points that ride the ground. */ function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14): THREE.Vector3[] { const out: THREE.Vector3[] = []; @@ -35,25 +165,32 @@ function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14 return out; } -function ribbon(points: THREE.Vector3[], width: number, color: number): THREE.Mesh { +/** A tube swept along a path — a bridge deck, a cable, a barrier. */ +function tubeGeometry(points: THREE.Vector3[], width: number, radial = 4): THREE.BufferGeometry { const curve = new THREE.CatmullRomCurve3(points); - const geo = new THREE.TubeGeometry(curve, points.length * 2, width / 2, 4, false); - const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ color })); - mesh.receiveShadow = true; - return mesh; + return new THREE.TubeGeometry(curve, points.length * 2, width / 2, radial, false); } -/** A draped, flat road deck. A tube turns a freeway into a raised pipeline. */ -function roadRibbon( +/** + * A draped, flat road deck. A tube turns a freeway into a raised pipeline. + * + * The UVs run 0..1 across the carriageway and in **metres** along it, which is + * the sane convention if anyone ever puts a surface texture on a road. Right + * now nothing does, and they are here for a duller reason: `mergeGeometries` + * only merges geometries whose attribute sets match exactly, so a strip without + * UVs cannot share a bucket with the tube barriers beside it. + */ +function roadRibbonGeometry( points: readonly THREE.Vector3[], width: number, - color: number, lift = 0, -): THREE.Mesh { +): THREE.BufferGeometry { const positions: number[] = []; const normals: number[] = []; + const uvs: number[] = []; const indices: number[] = []; const half = width / 2; + let along = 0; for (let index = 0; index < points.length; index += 1) { const point = points[index]; @@ -65,11 +202,13 @@ function roadRibbon( const length = Math.hypot(dx, dz) || 1; const nx = -dz / length; const nz = dx / length; + if (index > 0) along += point.distanceTo(previous); positions.push( point.x + nx * half, point.y + lift, point.z + nz * half, point.x - nx * half, point.y + lift, point.z - nz * half, ); normals.push(0, 1, 0, 0, 1, 0); + uvs.push(0, along, 1, along); if (index < points.length - 1) { const a = index * 2; indices.push(a, a + 2, a + 1, a + 1, a + 2, a + 3); @@ -79,14 +218,10 @@ function roadRibbon( const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3)); + geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2)); geometry.setIndex(indices); geometry.computeBoundingSphere(); - const mesh = new THREE.Mesh( - geometry, - new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide }), - ); - mesh.receiveShadow = true; - return mesh; + return geometry; } function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vector3[] { @@ -100,15 +235,15 @@ function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vec }); } -/** Merge alternating path spans into one dashed marking mesh. */ -function dashedRibbon( +/** Merge alternating path spans into one dashed marking geometry. */ +function dashedRibbonGeometry( points: readonly THREE.Vector3[], offset: number, width: number, - color: number, -): THREE.Mesh { +): THREE.BufferGeometry { const shifted = offsetPath(points, offset); const positions: number[] = []; + const uvs: number[] = []; const indices: number[] = []; for (let index = 0; index < shifted.length - 1; index += 2) { const a = shifted[index]; @@ -127,18 +262,15 @@ function dashedRibbon( b.x + nx, b.y + 0.035, b.z + nz, b.x - nx, b.y + 0.035, b.z - nz, ); + uvs.push(0, 0, 1, 0, 0, 1, 1, 1); indices.push(base, base + 2, base + 1, base + 1, base + 2, base + 3); } const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2)); geometry.setIndex(indices); geometry.computeVertexNormals(); - const mesh = new THREE.Mesh( - geometry, - new THREE.MeshBasicMaterial({ color, toneMapped: false, side: THREE.DoubleSide }), - ); - mesh.name = "freeway:lane-dashes"; - return mesh; + return geometry; } function makeShieldMaterial(identity: "us-highway" | "interstate", shield: string): THREE.Material { @@ -178,6 +310,7 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro const plan = buildFreewayWorldPlan(pack); group.userData.planSeed = plan.seed; + const batch = new Batch(); const asphalt = [0x353a3d, 0x303538]; const shoulder = [0x555759, 0x4e5153]; const berm = [0x64705c, 0x74674c]; @@ -211,6 +344,7 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro let poleCount = 0; let siloCount = 0; const dummy = new THREE.Object3D(); + const reflectorMatrices: THREE.Matrix4[] = []; world.city.roads.forEach((road, roadIndex) => { if (road.kind !== "freeway") return; @@ -219,39 +353,58 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro const identityIndex = route?.identity === "interstate" ? 1 : 0; const routePath = route ? buildRoutePath(pack, route.routeId) : null; // Broad earthwork under separate decks makes grade and curve changes read. - group.add(roadRibbon(path, 2.75, berm[identityIndex] ?? berm[0]!, -0.09)); + batch.add( + "freeway:berm", + roadRibbonGeometry(path, 2.75, -0.09), + batch.material("deck", berm[identityIndex] ?? berm[0]!), + ); for (const side of [-1, 1] as const) { - group.add(roadRibbon(offsetPath(path, side * 0.64), 1.18, shoulder[identityIndex] ?? shoulder[0]!, 0.004)); - group.add(roadRibbon(offsetPath(path, side * 0.64), 1.03, asphalt[identityIndex] ?? asphalt[0]!, 0.012)); + batch.add( + "freeway:shoulder", + roadRibbonGeometry(offsetPath(path, side * 0.64), 1.18, 0.004), + batch.material("deck", shoulder[identityIndex] ?? shoulder[0]!), + ); + batch.add( + "freeway:carriageway", + roadRibbonGeometry(offsetPath(path, side * 0.64), 1.03, 0.012), + batch.material("deck", asphalt[identityIndex] ?? asphalt[0]!), + ); // Inner yellow edge, two lane dividers, outer white shoulder edge. - group.add(roadRibbon(offsetPath(path, side * 0.12), 0.026, 0xf0c84f, 0.038)); - group.add(roadRibbon(offsetPath(path, side * 1.16), 0.026, 0xe8ece8, 0.038)); - group.add(dashedRibbon(path, side * 0.47, 0.022, 0xf4f4ec)); - group.add(dashedRibbon(path, side * 0.81, 0.022, 0xf4f4ec)); + batch.add( + "freeway:edge-line", + roadRibbonGeometry(offsetPath(path, side * 0.12), 0.026, 0.038), + batch.material("deck", 0xf0c84f), + ); + batch.add( + "freeway:edge-line", + roadRibbonGeometry(offsetPath(path, side * 1.16), 0.026, 0.038), + batch.material("deck", 0xe8ece8), + ); + const dashes = batch.material("marking", 0xf4f4ec); + batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.47, 0.022), dashes); + batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.81, 0.022), dashes); const guardPath = offsetPath(path, side * 1.27); - const guard = new THREE.Mesh( + batch.add( + "freeway:outer-guardrail", new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length * 2), 0.025, 5, false), guardMaterial, + { cast: true }, ); - guard.name = "freeway:outer-guardrail"; - guard.castShadow = true; - group.add(guard); } // Low concrete median walls keep both carriageways visually independent. for (const side of [-1, 1] as const) { const medianPath = offsetPath(path, side * 0.075).map((point) => point.clone().setY(point.y + 0.065)); - const median = new THREE.Mesh( + batch.add( + "freeway:median-barrier", new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length * 2), 0.055, 4, false), barrierMaterial, ); - median.name = "freeway:median-barrier"; - group.add(median); } // Retroreflectors are instanced and restrained, never roadside light blobs. + // The matrices are collected across every corridor and committed to one + // `InstancedMesh` after the loop, because two corridors' worth of the same + // 0.018 m box is two draw calls for something nobody can resolve. const reflectorPoints = path.filter((_, index) => index % 2 === 0); - const reflectors = new THREE.InstancedMesh(reflectorGeometry, reflectorMaterial, reflectorPoints.length * 4); - reflectors.name = "freeway:reflectors"; - let reflectorIndex = 0; for (const pointIndex of reflectorPoints.keys()) { const point = reflectorPoints[pointIndex]; if (!point) continue; @@ -261,11 +414,9 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro dummy.rotation.set(0, 0, 0); dummy.scale.setScalar(1); dummy.updateMatrix(); - reflectors.setMatrixAt(reflectorIndex++, dummy.matrix); + reflectorMatrices.push(dummy.matrix.clone()); } } - reflectors.count = reflectorIndex; - group.add(reflectors); if (!route || !routePath) return; const shieldMaterial = makeShieldMaterial(route.identity, route.shield); @@ -278,17 +429,18 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro const pz = z + Math.sin(heading) * sceneSetback * feature.side; const ground = world.groundAt(sample.lat, sample.lng); if (feature.kind === "route-sign") { - const sign = new THREE.Group(); - sign.name = `freeway:sign:${route.shield}`; - const post = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.62, 0.035), guardMaterial); - post.position.y = 0.31; - const board = new THREE.Mesh(new THREE.PlaneGeometry(0.42, 0.31), shieldMaterial); - board.position.y = 0.69; - board.rotation.y = -heading + (feature.side === 1 ? Math.PI : 0); - sign.add(post, board); - sign.position.set(px, ground + 0.08, pz); - sign.userData.routeId = route.routeId; - group.add(sign); + // Baked into world space rather than parented under a per-sign `Group`. + // Nine signs used to be nine groups of two meshes; they are now two + // meshes for the whole route, and the shield's own name survives on the + // board so the scene graph still says which route it belongs to. + const post = new THREE.BoxGeometry(0.035, 0.62, 0.035); + post.translate(px, ground + 0.08 + 0.31, pz); + batch.add("freeway:sign-post", post, guardMaterial, { cast: true }); + + const board = new THREE.PlaneGeometry(0.42, 0.31); + board.rotateY(-heading + (feature.side === 1 ? Math.PI : 0)); + board.translate(px, ground + 0.08 + 0.69, pz); + batch.add(`freeway:sign:${route.shield}`, board, shieldMaterial); continue; } const visualScale = feature.scale * 0.58; @@ -309,6 +461,18 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro } } }); + batch.flush(group); + + const reflectors = new THREE.InstancedMesh( + reflectorGeometry, + reflectorMaterial, + Math.max(1, reflectorMatrices.length), + ); + reflectors.name = "freeway:reflectors"; + reflectorMatrices.forEach((matrix, index) => reflectors.setMatrixAt(index, matrix)); + reflectors.count = reflectorMatrices.length; + group.add(reflectors); + trunks.count = trunkCount; poles.count = poleCount; silos.count = siloCount; @@ -321,16 +485,22 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro export function createRoads(world: World): THREE.Group { const group = new THREE.Group(); group.name = "roads"; + const batch = new Batch(); for (const road of world.city.roads) { const color = road.kind === "freeway" ? 0x7d7166 : 0x8b8578; const path = drapePath(world, road.path); - group.add(roadRibbon(path, road.width, color)); + batch.add("road:deck", roadRibbonGeometry(path, road.width), batch.material("deck", color)); if (road.kind === "freeway") { // One warm median stroke is enough at corridor scale to read as divided // highway without spending a textured asset or a draw call per lane. - group.add(roadRibbon(path, Math.max(0.025, road.width * 0.035), 0xd7c27c, 0.012)); + batch.add( + "road:median-stroke", + roadRibbonGeometry(path, Math.max(0.025, road.width * 0.035), 0.012), + batch.material("deck", 0xd7c27c), + ); } } + batch.flush(group); return group; } @@ -347,32 +517,37 @@ export function createBridge(world: World, bridge: Bridge): THREE.Group { const deckY = world.metres(bridge.deckHeight); const towerY = world.metres(bridge.towerHeight); - const material = () => new THREE.MeshLambertMaterial({ color: bridge.color }); + + /** + * One material for the whole bridge, and one mesh out of it. + * + * This used to read `const material = () => new THREE.MeshLambertMaterial(…)` + * and be called once per part, so the Golden Gate arrived as about + * thirty-four meshes with thirty-four identical materials — thirty-four draw + * calls the sorter had to keep apart, for one orange object. Everything a + * bridge is made of is painted the same colour, so everything a bridge is made + * of belongs in one bucket. + */ + const batch = new Batch(); + const paint = batch.material("solid", bridge.color); + const part = (geometry: THREE.BufferGeometry) => + batch.add(bridge.name, geometry, paint, { cast: true }); const deckPoints = bridge.path.map(([lat, lng]) => { const [x, z] = world.project(lat, lng); return new THREE.Vector3(x, deckY, z); }); - const deck = ribbon(deckPoints, 0.5, bridge.color); - deck.castShadow = true; - group.add(deck); + part(tubeGeometry(deckPoints, 0.5)); const towerTops: THREE.Vector3[] = []; for (const [lat, lng] of bridge.towers) { const [x, z] = world.project(lat, lng); - const geo = new THREE.BoxGeometry(0.34, towerY, 0.34); - geo.translate(0, towerY / 2, 0); - const tower = new THREE.Mesh(geo, material()); - tower.position.set(x, 0, z); - tower.castShadow = true; - group.add(tower); + part(new THREE.BoxGeometry(0.34, towerY, 0.34).translate(x, towerY / 2, z)); // Cross-braces, which is most of what you see of a tower at distance. for (const frac of [0.55, 0.82]) { - const brace = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.16, 0.4), material()); - brace.position.set(x, towerY * frac, z); - group.add(brace); + part(new THREE.BoxGeometry(0.5, 0.16, 0.4).translate(x, towerY * frac, z)); } towerTops.push(new THREE.Vector3(x, towerY, z)); } @@ -392,12 +567,7 @@ export function createBridge(world: World, bridge: Bridge): THREE.Group { p.y -= Math.sin(t * Math.PI) * sag; pts.push(p); } - group.add( - new THREE.Mesh( - new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false), - material(), - ), - ); + part(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false)); // Vertical hangers down to the deck. for (let s = 2; s < 18; s += 2) { @@ -406,14 +576,11 @@ export function createBridge(world: World, bridge: Bridge): THREE.Group { const top = p.y - Math.sin(t * Math.PI) * sag; if (top <= deckY + 0.2) continue; const h = top - deckY; - const geo = new THREE.BoxGeometry(0.035, h, 0.035); - geo.translate(0, h / 2, 0); - const hanger = new THREE.Mesh(geo, material()); - hanger.position.set(p.x, deckY, p.z); - group.add(hanger); + part(new THREE.BoxGeometry(0.035, h, 0.035).translate(p.x, deckY + h / 2, p.z)); } } + batch.flush(group); return group; } diff --git a/src/engine/terrain.ts b/src/engine/terrain.ts index 35eecc8..face3ab 100644 --- a/src/engine/terrain.ts +++ b/src/engine/terrain.ts @@ -179,9 +179,27 @@ export function createWater(world: World): THREE.Group { const [x0, z0] = world.project(bounds.minLat, bounds.minLng); const [x1, z1] = world.project(bounds.maxLat, bounds.maxLng); + /** + * Standard rather than Lambert, and it is the whole difference between an + * ocean and a blue card. + * + * `MeshLambertMaterial` has no specular term at all — none, by construction — + * so the Pacific, which is between a third and a half of the California + * board's frame, rendered as one flat value at every hour and from every + * angle. A low roughness gives it the sun's glint back, and now that the + * scene carries an environment map (`engine/environmentRig.ts`) it also gives + * it the sky: `MeshStandardMaterial` reads `scene.environment`, so the water + * reflects whatever the atmosphere decided the sky is, for free and with no + * second pass. + * + * `metalness: 0` is stated rather than defaulted because water is a + * dielectric: its reflection is a Fresnel term over a coloured body, which is + * exactly what metalness 0 with low roughness produces, and a metallic water + * would lose `pal.sea` entirely. + */ const sea = new THREE.Mesh( new THREE.PlaneGeometry(Math.abs(x1 - x0) * 1.8, Math.abs(z1 - z0) * 1.8), - new THREE.MeshLambertMaterial({ color: pal.sea }), + new THREE.MeshStandardMaterial({ color: pal.sea, roughness: 0.14, metalness: 0 }), ); sea.rotation.x = -Math.PI / 2; sea.position.set((x0 + x1) / 2, -0.06, (z0 + z1) / 2); @@ -192,9 +210,17 @@ export function createWater(world: World): THREE.Group { const pts = world.projectPolygon(poly).map(([x, z]) => new THREE.Vector2(x, z)); const geo = new THREE.ShapeGeometry(new THREE.Shape(pts)); geo.rotateX(Math.PI / 2); + // The same change as the sea above, and for the same reason. Slightly + // rougher: an inland lake is sheltered, and a mirror-smooth bay next to a + // wind-roughened ocean reads as the wrong way round. const lake = new THREE.Mesh( geo, - new THREE.MeshLambertMaterial({ color: pal.lake, side: THREE.DoubleSide }), + new THREE.MeshStandardMaterial({ + color: pal.lake, + roughness: 0.2, + metalness: 0, + side: THREE.DoubleSide, + }), ); lake.position.y = 0.05; group.add(lake); diff --git a/src/engine/types.ts b/src/engine/types.ts index 51f806c..7cc077f 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -319,11 +319,12 @@ export interface Aircraft { /** * Where aircraft come from. * - * An interface rather than a client because the obvious source — FlightRadar24 - * — cannot ship in an Apache-2.0 repo: their terms forbid scraping and forbid - * redistributing the data. This package ships a simulator and open community - * sources; anything commercial is an adapter in a private deployment. See - * ARCHITECTURE.md §4. + * An interface rather than a client, because this repo must not ship one for the + * obvious source: FlightRadar24's terms do not permit scraping and do not permit + * redistributing the data, so a client for it in an Apache-2.0 repo would be + * publishing instructions for violating a ToS. This package ships a simulator + * and open community sources; anything commercial is an adapter in a private + * deployment. See ARCHITECTURE.md §4. */ export interface FlightSource { /** Current traffic. Called on a timer; must be cheap and must not throw. */ diff --git a/src/index.ts b/src/index.ts index 30fcc95..13c57eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,31 @@ -/** Tera's renderer-independent public package surface. */ +/** + * Tera's renderer-independent public package surface. + * + * Everything exported here imports **no three.js, no DOM and no network**, which + * is the property that makes the package usable from a verifier, a test harness + * or a Node service that has no GPU. `src/test/integration/barrel.test.ts` + * enforces it by importing this file under Node's type stripping and failing if + * anything in the graph reaches for a renderer. + * + * That rule is why three of the modules this build added are conspicuously + * absent. `src/interiors/devices.ts`, `src/engine/officeExterior.ts` and + * `src/interiors/officeScene.ts` are the *render layers* for the same + * simulations whose state machines are exported below — they take a `Plan` and a + * `MaterialRegistry` and produce meshes, and exporting one of them would put + * three.js on the package surface for every consumer of a device type. + */ export * from "./arena/index.ts"; export * from "./actors/controller.ts"; export * from "./aircraft/controller.ts"; +export * from "./devices/index.ts"; export * from "./interiors/plan.ts"; export * from "./interiors/robotActivity.ts"; export * from "./interiors/robotOperations.ts"; export * from "./interiors/robotRoutes.ts"; export * from "./interiors/types.ts"; export * from "./interiors/walker.ts"; +export * from "./transport/exteriorVehicle.ts"; export * from "./transport/types.ts"; export * from "./transport/vehicleController.ts"; export * from "./transport/vehicleSim.ts"; +export * from "./transport/vehicleTelemetry.ts"; diff --git a/src/input/vehicle.ts b/src/input/vehicle.ts deleted file mode 100644 index da4ec4e..0000000 --- a/src/input/vehicle.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** Device adapters for the renderer-independent vehicle action contract. */ - -import { - normalizeVehicleActions, - type VehicleActionSnapshot, -} from "../transport/vehicleController.ts"; - -export interface GamepadButtonLike { - pressed: boolean; - value: number; -} - -export interface GamepadLike { - axes: readonly number[]; - buttons: readonly GamepadButtonLike[]; -} - -export interface GamepadButtonState { - assist: boolean; - reset: boolean; -} - -export interface GamepadVehicleSample { - actions: VehicleActionSnapshot; - buttons: GamepadButtonState; -} - -function axis(value: number | undefined, deadzone = 0.12): number { - if (!Number.isFinite(value)) return 0; - const clamped = Math.max(-1, Math.min(1, value ?? 0)); - if (Math.abs(clamped) <= deadzone) return 0; - return Math.sign(clamped) * ((Math.abs(clamped) - deadzone) / (1 - deadzone)); -} - -function button(pad: GamepadLike, index: number): number { - const found = pad.buttons[index]; - if (!found) return 0; - return Math.max(0, Math.min(1, Number.isFinite(found.value) ? found.value : found.pressed ? 1 : 0)); -} - -/** - * Standard-layout mapping: left stick steers, triggers brake/throttle, B is - * handbrake, Y resumes assistance, and X resets. Mode/reset are rising edges. - */ -export function sampleStandardGamepad( - pad: GamepadLike, - previous: GamepadButtonState = { assist: false, reset: false }, -): GamepadVehicleSample { - const buttons = { - assist: button(pad, 3) > 0.5, - reset: button(pad, 2) > 0.5, - }; - return { - actions: normalizeVehicleActions({ - steering: axis(pad.axes[0]), - brake: button(pad, 6), - throttle: button(pad, 7), - handbrake: button(pad, 1) > 0.5, - modeRequest: buttons.assist && !previous.assist ? "assisted" : "none", - reset: buttons.reset && !previous.reset, - }), - buttons, - }; -} - -/** Keyboard/touch and gamepad may be used together; strongest intent wins. */ -export function mergeVehicleActions( - primary: Partial, - secondary: Partial, -): VehicleActionSnapshot { - const a = normalizeVehicleActions(primary); - const b = normalizeVehicleActions(secondary); - return normalizeVehicleActions({ - throttle: Math.max(a.throttle, b.throttle), - brake: Math.max(a.brake, b.brake), - steering: Math.abs(b.steering) > Math.abs(a.steering) ? b.steering : a.steering, - handbrake: a.handbrake || b.handbrake, - modeRequest: b.modeRequest !== "none" ? b.modeRequest : a.modeRequest, - reset: a.reset || b.reset, - }); -} diff --git a/src/interiors/devices.ts b/src/interiors/devices.ts new file mode 100644 index 0000000..879b0ba --- /dev/null +++ b/src/interiors/devices.ts @@ -0,0 +1,275 @@ +/** + * The hardware, in the room. + * + * One microphone-sized object per authored `DeviceDeclaration`, standing on the + * prop that declaration named, with a lamp on it that changes colour when the + * state changes. That is the whole layer. + * + * ### It resolves nothing + * + * Every coordinate comes from `Plan.device()`, which has already turned the + * authored anchor — a prop id and an offset in that prop's frame — into a + * position and a yaw. This file does not repeat that arithmetic, does not parse + * an asset id and does not decide whether a declaration is valid; + * `validateDeviceDeclaration` and `Plan` did all three. A second derivation + * here would be a second answer to "where is the mic", and the two would + * disagree the first time somebody nudged the desk — which is precisely what + * `DeviceAnchor` is shaped to prevent, and it would be this file undoing it. + * + * A declaration the plan dropped is therefore skipped rather than placed from + * some other source. The plan drops one when its anchor prop is not there — a + * typo, or a private prop in a public build — and inventing a position would + * leave a microphone standing on the lobby floor. + * + * ### Nothing here is a light source + * + * `src/interiors/luminaires.ts` opens with that sentence and it is repeated + * here because the temptation is stronger, not weaker: an LED is *obviously* a + * light, and a `PointLight` per device would be one line. CONTRACT.md §4 says + * Atmosphere is the sole light owner, and past about four shadow casters a + * frame budget ends. + * + * The indicator reads as lit without one, through the `deviceIndicator` + * material role: `materials.tinted("deviceIndicator", colour)` reaches both + * `color` and `emissive`, and the role carries `emissiveIntensity: 1.0`, so + * under the tone curve it reads as a lamp rather than as a white dot. A 3 mm + * LED also implies **no house light at all** — unlike a ceiling fitting, which + * is why `luminaires.ts` hands a scalar to the rig and this file has nothing to + * hand anybody. If a device is ever added that genuinely lights a room, the + * scalar goes to Atmosphere and the light still does not get constructed here. + * + * ### Materials are borrowed; geometry is owned + * + * Every mesh comes out of `MeshBin`, which clones and merges, so the geometry + * in this subtree belongs to this layer and is disposed with it. The materials + * come from the shared `MaterialRegistry` and are cached there across the whole + * office — three states across a dozen devices is three materials, not + * thirty-six — so `dispose()` deliberately does **not** touch them. Disposing a + * registry material here would empty the desks in the rest of the building. + */ + +import * as THREE from "three"; +import { createAssetContext, type AssetRegistry } from "../assets/kit.ts"; +import type { MaterialRegistry } from "../assets/materials.ts"; +import { DEVICE_RANGES, type DeviceDeclaration, type DeviceState } from "../devices/types.ts"; +import type { Plan } from "./plan.ts"; + +/** + * The sub-object every device asset is expected to expose. + * + * A name rather than a `userData` flag because it is what an asset author + * already writes — `MeshBin.build(name)` names the group — and because a name + * survives the merge that turns an asset into one mesh per material. An asset + * without one still builds and still stands on the desk; it simply has no lamp + * to change, which is the right degrade for a self-hoster's own hardware model. + */ +const INDICATOR = "indicator"; + +/** + * What each state looks like, as a colour. + * + * Four states and no more, deliberately: an indicator that encodes a continuous + * reading is a display, and a display needs a legend. These are the four a + * person can read across a room without one — dark, live, muted, idle — and the + * numbers behind them belong in the panel, which has the room to say what they + * mean. + */ +const INDICATOR_COLORS = { + /** Powered off. Not black: an unlit LED is grey plastic, and black reads as a hole. */ + off: 0x2b3138, + /** A microphone that is open, or a speaker that is playing. */ + live: 0x46d17a, + /** A microphone that is muted. The one state worth reading from the doorway. */ + muted: 0xe2543f, + /** Powered, idle: a speaker that is on with nothing playing. */ + idle: 0xd7a63c, +} as const; + +/** + * How much the indicator grows at full scale, as a fraction of its own size. + * + * The only reading this layer draws, and it is deliberately tiny. A meter + * belongs in the panel; what the room needs is a hint that the thing is doing + * something, which is what a lamp that breathes with the programme gives you. + * It is a transform on one small object, so it costs nothing and it mints no + * material — a level-driven *colour* would mint one per tenth of a decibel, + * which is the version of this idea that must not be written. + */ +const INDICATOR_LEVEL_GAIN = 0.3; + +export interface DeviceLayer { + object: THREE.Object3D; + /** + * Show these readings. Ids this layer does not carry are ignored, and a + * device this layer carries that is absent from `states` is left as it was — + * a poll that dropped one device is not the same event as that device being + * switched off, and only one of them should change what is on screen. + */ + apply(states: readonly DeviceState[]): void; + dispose(): void; +} + +export interface DeviceLayerOptions { + plan: Plan; + declarations: readonly DeviceDeclaration[]; + assets: AssetRegistry; + materials: MaterialRegistry; +} + +interface Mounted { + id: string; + /** The asset's own indicator, or `null` for hardware that exposes none. */ + indicator: THREE.Object3D | null; + /** The indicator's authored scale, so the level response is relative to it. */ + baseScale: number; +} + +export function createDeviceLayer(options: DeviceLayerOptions): DeviceLayer { + const { plan, declarations, assets, materials } = options; + const object = new THREE.Group(); + object.name = "devices"; + const mounted = new Map(); + const warned = new Set(); + + for (const declaration of declarations) { + const resolved = plan.device(declaration.id); + if (resolved === null) { + warnOnce( + warned, + `device ${declaration.id} is not in this plan — check that its anchor prop exists on ` + + `level ${declaration.anchor.levelId} and survived this build's depth`, + ); + continue; + } + + // The transform, whole, from the plan. The offset is already folded into + // `position` there, in the anchor prop's frame; re-applying it here would + // place the mic twice as far up the desk as the pack asked for. + const mount = new THREE.Group(); + mount.name = `device:${declaration.id}`; + mount.position.set(resolved.position.x, resolved.position.y, resolved.position.z); + mount.rotation.y = resolved.rotation; + + const hardware = assets.build(declaration.assetId, contextFor(declaration, materials, assets)); + mount.add(hardware); + object.add(mount); + + const indicator = hardware.getObjectByName(INDICATOR) ?? null; + if (indicator === null) { + warnOnce(warned, `device asset ${declaration.assetId} exposes no "${INDICATOR}" sub-object; its state will not be visible in the room`); + } + mounted.set(declaration.id, { + id: declaration.id, + indicator, + baseScale: indicator?.scale.x ?? 1, + }); + } + + return { + object, + + apply(states: readonly DeviceState[]): void { + for (const state of states) { + const device = mounted.get(state.id); + if (device === undefined || device.indicator === null) continue; + paint(device.indicator, materials, colorFor(state)); + device.indicator.scale.setScalar(device.baseScale * (1 + INDICATOR_LEVEL_GAIN * levelOf(state))); + } + }, + + dispose(): void { + object.traverse((child) => { + const mesh = child as THREE.Mesh; + // Geometry only. See the header: every material here belongs to the + // shared registry and is still holding up the rest of the office. + if (mesh.isMesh) mesh.geometry.dispose(); + }); + object.clear(); + mounted.clear(); + }, + }; +} + +/** + * Which lamp, for one reading. + * + * Kind-aware rather than capability-aware, and that is the one place in this + * whole surface where switching on the kind is right: this is a *picture* of a + * device, and what a green light means on a microphone ("open") is not what it + * means on a speaker ("playing"). Everywhere a control or an observation is + * built, the capability list is the thing to iterate. + */ +function colorFor(state: DeviceState): number { + if (!state.powered) return INDICATOR_COLORS.off; + if (state.kind === "mic") return state.muted === true ? INDICATOR_COLORS.muted : INDICATOR_COLORS.live; + return state.playing === true ? INDICATOR_COLORS.live : INDICATOR_COLORS.idle; +} + +/** + * The reading as 0..1, or zero for a device that reports no level. + * + * `undefined` is not zero — it means this device has no meter — but both come + * out here as "do not grow the lamp", which is the honest picture for a device + * that is not reporting anything to grow it by. + */ +function levelOf(state: DeviceState): number { + if (state.levelDb === undefined || !state.powered) return 0; + const { min, max } = DEVICE_RANGES.level; + return Math.min(1, Math.max(0, (state.levelDb - min) / (max - min))); +} + +/** + * Point every mesh under the indicator at the tinted material for a state. + * + * `tinted` is cached by the registry, so the whole building's microphones share + * one material per state and the assignment is a pointer write rather than a + * new draw call. It reaches `color` and `emissive` together, which is what + * makes a tinted LED read as lit instead of as a coloured pebble. + */ +function paint(indicator: THREE.Object3D, materials: MaterialRegistry, color: number): void { + const material = materials.tinted("deviceIndicator", color); + indicator.traverse((child) => { + const mesh = child as THREE.Mesh; + if (mesh.isMesh) mesh.material = material; + }); +} + +/** + * An asset context for one device. + * + * The random stream is seeded from the device id rather than from `Math.random` + * so that the same pack builds the same hardware on every machine and in every + * capture — the determinism rule every asset in this repo is held to. A device + * is one small object built once, so the generator is three lines rather than a + * dependency. + */ +function contextFor( + declaration: DeviceDeclaration, + materials: MaterialRegistry, + assets: AssetRegistry, +) { + let seed = 0x811c9dc5; + for (let i = 0; i < declaration.id.length; i += 1) { + seed ^= declaration.id.charCodeAt(i); + seed = Math.imul(seed, 0x01000193); + } + const rand = (): number => { + seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0; + return seed / 4_294_967_296; + }; + return createAssetContext({ materials, registry: assets, rand }); +} + +/** + * One line per problem, once. + * + * A pack with a typo in a device id is a pack that repeats it — the same + * declaration is rebuilt every time the office is entered — and a warning per + * entry is a console nobody reads. `AssetRegistry.placeholder` warns once per + * id for the same reason. + */ +function warnOnce(seen: Set, message: string): void { + if (seen.has(message)) return; + seen.add(message); + console.warn(`[tera/devices] ${message}`); +} diff --git a/src/interiors/officeScene.ts b/src/interiors/officeScene.ts index 0f67fa8..3950e46 100644 --- a/src/interiors/officeScene.ts +++ b/src/interiors/officeScene.ts @@ -68,8 +68,15 @@ import * as THREE from "three"; import { createSceneKit, type Pose } from "../engine/scenekit.ts"; import type { StageScene } from "../engine/stage.ts"; -import type { LightingState, Pin, View } from "../engine/types.ts"; -import type { AssetRegistry } from "../assets/kit.ts"; +import type { Aircraft, FlightSource, LightingState, Pin, View } from "../engine/types.ts"; +import { airlinerGeometry } from "../engine/aircraftGeometry.ts"; +import type { EnvironmentRig } from "../engine/environmentRig.ts"; +import { createOfficeExterior, type OfficeExterior } from "../engine/officeExterior.ts"; +import { createDeviceLayer, type DeviceLayer } from "./devices.ts"; +import type { DeviceDeclaration, DeviceState } from "../devices/types.ts"; +import type { VehicleTelemetryState } from "../transport/vehicleTelemetry.ts"; +import type { ModelXDetail } from "../assets/vehicles/index.ts"; +import { kit as assetKit, type AssetRegistry } from "../assets/kit.ts"; import { MaterialRegistry, type MaterialQuality } from "../assets/materials.ts"; import type { InteriorPalette } from "../assets/palette.ts"; // Importing the catalogue registers the built-in `tera:` assets into the shared @@ -140,6 +147,53 @@ const HORIZON_EXTENT = 12_000; */ const HORIZON_DARKEN = 0.5; +/** + * How far out the overhead traffic dome sits, in metres, at most. + * + * The camera's far plane is `HORIZON_EXTENT * 0.7` — 8.4 km — so 3.6 km is + * comfortably inside it with the horizon plane still behind. The number itself + * carries no claim: an aeroplane on this dome is a **map symbol drawn in 3-D**, + * placed at the bearing and elevation it is genuinely at and at a distance + * chosen so it is visible, exactly as `aircraftGeometry.ts` argues for the + * city's own traffic. Drawing airliners at true metre range would put most of + * them past the far plane and the rest inside the fog. + */ +const OVERHEAD_MAX_RADIUS_M = 3_600; + +/** + * How large an aeroplane is drawn, as an angle at the eye. + * + * 0.012 rad is about 0.7 degrees — a little over the width of a fingernail at + * arm's length, which is roughly what an airliner at cruise actually looks like + * from directly beneath and is enough to read the sweep of a wing. It is an + * angle rather than a length so the glyph does not have to be retuned if the + * dome radius ever changes. + */ +const OVERHEAD_ANGULAR_SIZE = 0.012; + +/** The bounding length of `airlinerGeometry()`, which the angular size divides. */ +const AIRLINER_LENGTH = 0.42; + +/** + * How low an aeroplane may be and still be drawn, in degrees above the horizon. + * + * Below this it is behind the ground plane from any viewpoint inside the + * building, so drawing it is drawing an aeroplane through a floor. Five degrees + * is also about where an airliner stops being distinguishable from the haze. + */ +const OVERHEAD_MIN_ELEVATION_DEG = 5; + +/** + * How many aeroplanes the dome can hold at once. + * + * One `InstancedMesh` and therefore one draw call at any occupancy, so the cost + * of the ceiling is 32 unused matrices rather than 32 unused objects. The + * godmode traffic dial can put four hundred aircraft over the city; a room's + * sky wants the nearest few, and the nearest few is what a person looking up + * would see anyway. + */ +const OVERHEAD_CAPACITY = 32; + export interface OfficeSceneOptions { /** * The renderer's canvas. Orbit input and pointer coordinates are read against @@ -236,6 +290,45 @@ export interface OfficeSceneOptions { */ background?: number | null; plan?: PlanOptions; + /** + * The page's one environment map, shared with the city. + * + * The same argument `scene.ts` makes: a `PMREMGenerator` and its targets + * belong to the renderer, not to a scene, so one rig is built beside the + * `Stage` and handed to both. It matters more indoors than out — `deviceMesh`, + * `chairBase`, `metalTrim`, `glazingFrame` and the Model X's paint are all + * metal or clearcoat, and metal with nothing to reflect is grey plastic. + * + * Absent, the office renders exactly as it did before the rig existed. + */ + environment?: EnvironmentRig; + /** + * Overhead traffic for a sited office's sky. + * + * The same `FlightSource` the city board is drawing, deliberately: a studio in + * the Arts District and the SoCal board above it are one world, and an arena + * that observes an overflight while the viewer standing in the room sees an + * empty sky is two. Polled here and **never disposed** here — the source + * belongs to whoever built it, which is the city. + * + * Ignored on a pack with no `site`: without a coordinate there is no bearing + * to put an aeroplane on, and without a horizon there is no sky to put it in. + */ + flights?: FlightSource; + /** + * Park a Model X on the pack's arrival apron. + * + * Ignored unless `office.site.arrival` names a stall, which is a pack's own + * decision — `ExteriorArrival` is optional and a floorplan with no outdoors + * has nowhere to put a car. + * + * `detail` is a required choice by the exterior's own contract: `corridor` is + * 33 draw calls and 4,098 triangles against `follow`'s 40 and 13,986, and the + * difference a viewer can see at three metres is mirrors, glass frames and + * brake calipers. `seed` makes the parking jitter and the paint a property of + * the studio rather than of the page load. + */ + exteriorVehicle?: { detail: ModelXDetail; seed: number }; } export interface OfficeScene extends StageScene { @@ -281,6 +374,28 @@ export interface OfficeScene extends StageScene { */ setRobotsVisible(visible: boolean): void; setLighting(state: LightingState): void; + /** + * The hardware this pack declared, in the order it authored it. + * + * Authored, public and inert — a declaration says a microphone exists and what + * it can be asked to do. It is on the handle so that the interface can build a + * panel for a studio without reading the pack a second time, and it is the + * **resolved** list: a declaration `Plan` dropped, because its anchor prop is + * not on this level or did not survive this build's depth, is not here. + */ + devices: readonly DeviceDeclaration[]; + /** + * Show these readings on the hardware. Cheap and idempotent; call it whenever + * a feed publishes. A no-op for an office that declared no devices. + */ + setDeviceStates(states: readonly DeviceState[]): void; + /** + * Reflect one vehicle telemetry observation on the car outside. + * + * Signature-guarded downstream, so calling it every frame costs a comparison. + * A no-op for a pack with no arrival stall, or when no exterior was asked for. + */ + setVehicleTelemetry(state: VehicleTelemetryState): void; /** * The sun's height, in degrees, from whatever clock the app is running. * @@ -402,7 +517,12 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): // reads as a stall. flightSpeed: 0.95, }); - kit.applyLighting(options.lighting ?? officeInterior()); + const openingLighting = options.lighting ?? officeInterior(); + kit.applyLighting(openingLighting); + // Before a single surface is built, so the first frame already has a room to + // reflect. The rig fingerprints the state and caches per kind, so this and + // every later `setLighting` cost a map lookup unless the light actually moved. + options.environment?.apply(scene, openingLighting, "office"); /** * The sky wins over the flat colour when there is one. @@ -562,6 +682,64 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): if (presence) scene.add(presence.group); shell.ceilings.visible = options.showCeilings ?? false; + // ---- The hardware on the furniture -------------------------------------- + // + // Read off the floorplans rather than taken as an option, because a device is + // part of a pack in exactly the way a desk is: `Floorplan.devices` says the + // microphone exists and which prop it stands on, and `Plan` has already + // decided which of those declarations survived this build's depth. Filtering + // to what `Plan` accepted is what stops the interface offering a panel for a + // device the room does not contain — the layer would have skipped it and the + // panel would have shown a control that reaches nothing. + const registry: AssetRegistry = options.registry ?? assetKit; + const declared: DeviceDeclaration[] = []; + for (const level of office.levels) { + for (const declaration of level.floorplan.devices ?? []) declared.push(declaration); + } + const deviceDeclarations: readonly DeviceDeclaration[] = declared.filter( + (declaration) => plan.device(declaration.id) !== null, + ); + const deviceLayer: DeviceLayer | null = + deviceDeclarations.length > 0 + ? createDeviceLayer({ plan, declarations: deviceDeclarations, assets: registry, materials }) + : null; + if (deviceLayer) scene.add(deviceLayer.object); + + // ---- The car outside ----------------------------------------------------- + // + // Guarded on the pack having authored a stall, which most will not: an + // `ExteriorArrival` is optional and a floor plate with no outdoors has nowhere + // to put one. The exterior positions everything in the pack's own metres from + // the plan origin, so the only transform it needs is the storey its stall is + // measured from — a podium deck at level 1 is 188 m off the street, and the + // apron stands on the floor of `arrival.levelId` by the exterior's own wording. + const arrivalStall = office.site?.arrival; + let exterior: OfficeExterior | null = null; + if (options.exteriorVehicle && office.site && arrivalStall) { + exterior = createOfficeExterior({ + site: office.site, + arrival: arrivalStall, + assets: registry, + materials, + rand: mulberry32(options.exteriorVehicle.seed), + detail: options.exteriorVehicle.detail, + }); + exterior.object.position.y = plan.level(arrivalStall.levelId)?.floorY ?? 0; + scene.add(exterior.object); + } + + // ---- The traffic overhead ------------------------------------------------ + const overhead: OverheadTraffic | null = + options.flights && office.site && options.horizon + ? createOverheadTraffic({ + source: options.flights, + site: office.site, + centre: plan.bounds.center, + radius: Math.min(far * 0.42, OVERHEAD_MAX_RADIUS_M), + }) + : null; + if (overhead) scene.add(overhead.group); + // ---- Viewpoints --------------------------------------------------------- const viewpointById = new Map(plan.viewpoints.map((v) => [v.id, v])); @@ -818,6 +996,13 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): onViewChange(fn) { viewListeners.push(fn); }, + devices: deviceDeclarations, + setDeviceStates(states) { + deviceLayer?.apply(states); + }, + setVehicleTelemetry(state) { + exterior?.apply(state); + }, setPresence(people) { if (!presence) { // Once, not once per poll: an occupancy feed pointed at the public @@ -843,6 +1028,10 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): setLighting(state) { kit.applyLighting(state); paintHorizon(state); + // One direction, still: `Atmosphere` decided this rig, `officeDaylight` + // turned it into the building's frame, and the environment is derived + // from the result rather than being a second opinion about the light. + options.environment?.apply(scene, state, "office"); }, setSolarElevation(degrees) { luminaires.setSolarElevation(degrees); @@ -887,8 +1076,16 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): // *now*, not to where they were last frame. robots?.tick(dt); luminaires.tick(dt); + overhead?.tick(dt); }, dispose() { + // First, because the rig keeps a ledger of every scene it has written to + // so a rebuilt environment reaches all of them — and a disposed office + // left in that ledger is a whole floor plate retained. + options.environment?.release(scene); + overhead?.dispose(); + exterior?.dispose(); + deviceLayer?.dispose(); mediaSurfaces.dispose(); officeWalker?.dispose(); realtimePeers?.dispose(); @@ -922,6 +1119,206 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): }; } +/** + * A deterministic generator from one integer, so a studio's car is the same car + * on every machine and on every reload. + * + * Mulberry32, four lines, no dependency. It is here rather than imported + * because the only thing in this file that needs randomness is the parking + * jitter, and `createOfficeExterior` takes a `() => number` precisely so that + * the caller owns the reproducibility rather than the layer. + */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// ---- Overhead traffic ----------------------------------------------------- + +interface OverheadTraffic { + group: THREE.Group; + tick(dt: number): void; + dispose(): void; +} + +interface OverheadTrafficOptions { + source: FlightSource; + site: NonNullable; + /** The middle of the floor plate, in the pack's metres. The dome is centred here. */ + centre: { x: number; z: number }; + radius: number; +} + +/** + * The same aeroplanes the city board is drawing, seen from inside a building. + * + * ### Why a dome rather than a position + * + * An airliner over Los Angeles is ten kilometres up and twenty across. Placed at + * true metre range in a scene whose far plane is 8.4 km it is clipped, and if + * the far plane were moved out to reach it the fog — which saturates at 4.2 km, + * because that is what makes the horizon a horizon — would have swallowed it + * long before. So the *direction* is kept exactly and the *distance* is not: + * every track is put on a fixed dome at the bearing and elevation it is really + * at, sized by an angle rather than a length. That is the same bargain + * `aircraftGeometry.ts` already makes for the city, written down again here + * because the reason is different: the city trades scale for legibility, and + * this trades range for a depth buffer that works. + * + * ### Why it is instanced + * + * One geometry, one material, one draw call whatever the occupancy — against + * the city layer's mesh-per-track, which exists there because each aircraft + * carries its own altitude-banded material and its own pick target. Neither is + * wanted here: a room's sky is scenery, nothing in it is clickable, and the + * office's draw-call budget is the one that has to hold an entire studio. + * + * ### One direction, again + * + * `site.heading` is the bearing the pack's −Z points along, so a compass bearing + * becomes a building-frame yaw by subtracting it — the same rotation + * `officeDaylight` applies to the sun, for the same reason and in the same + * sense. Getting it backwards would put the afternoon traffic over the wrong + * wall, which is exactly as wrong as putting the afternoon sun there. + */ +function createOverheadTraffic(options: OverheadTrafficOptions): OverheadTraffic { + const { source, site, centre, radius } = options; + const group = new THREE.Group(); + group.name = "overhead-traffic"; + + const geometry = airlinerGeometry(); + /** + * Lit, with a small emissive floor, and out of the fog. + * + * The emissive is the city layer's number and is there for the city layer's + * reason: after sunset the rig is a tenth of an intensity and a purely diffuse + * dart simply vanishes, on the one evening sky worth looking at. `fog: false` + * because the dome's radius is a drawing convention rather than a distance — + * applying 3.6 km of haze to a symbol that stands for twenty kilometres is + * fogging an arbitrary number. + */ + const material = new THREE.MeshLambertMaterial({ + color: 0xdfe7ef, + emissive: 0xdfe7ef, + emissiveIntensity: 0.35, + fog: false, + }); + const mesh = new THREE.InstancedMesh(geometry, material, OVERHEAD_CAPACITY); + mesh.name = "overhead-traffic-instances"; + mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + // The dome is centred on the building and always in frame; a bounding-sphere + // test on something that can never be culled is pure cost. + mesh.frustumCulled = false; + mesh.count = 0; + mesh.castShadow = false; + mesh.receiveShadow = false; + group.add(mesh); + + const scale = (radius * OVERHEAD_ANGULAR_SIZE) / AIRLINER_LENGTH; + const matrix = new THREE.Matrix4(); + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + const euler = new THREE.Euler(0, 0, 0, "YXZ"); + const scaleVector = new THREE.Vector3(scale, scale, scale); + + const headingRad = (site.heading * Math.PI) / 180; + const cosLat = Math.cos((site.lat * Math.PI) / 180); + const minSinElevation = Math.sin((OVERHEAD_MIN_ELEVATION_DEG * Math.PI) / 180); + + let timer = 0; + let disposed = false; + + function place(aircraft: readonly Aircraft[]): void { + let count = 0; + for (const a of aircraft) { + if (count >= OVERHEAD_CAPACITY) break; + /* + * Equirectangular, not great-circle, and that is a decision rather than a + * shortcut: an aeroplane still above five degrees from a building is at + * most a couple of hundred kilometres away, where the cosine-corrected + * flat approximation is wrong by metres in a bearing that is then drawn + * on a dome anyway. A haversine here would be four transcendentals per + * aeroplane per poll to move a symbol by less than its own width. + */ + const east = (a.lng - site.lng) * cosLat * METRES_PER_DEGREE; + const north = (a.lat - site.lat) * METRES_PER_DEGREE; + const ground = Math.hypot(east, north); + const up = a.altitude - site.elevation; + const slant = Math.hypot(ground, up); + if (slant < 1) continue; + const sinElevation = up / slant; + if (sinElevation < minSinElevation) continue; + + // Bearing clockwise from true north, turned into the building's frame by + // subtracting the heading its own −Z points along. + const bearing = Math.atan2(east, north) - headingRad; + const cosElevation = Math.sqrt(Math.max(0, 1 - sinElevation * sinElevation)); + position.set( + centre.x + Math.sin(bearing) * cosElevation * radius, + sinElevation * radius, + centre.z - Math.cos(bearing) * cosElevation * radius, + ); + + // Nose along +Z and −Z is the building's own north, so a half turn less + // the track's heading in this frame — the identical mapping `flights.ts` + // uses, and the one whose inverse once flew every departure tail-first. + euler.set(0, Math.PI - ((a.heading * Math.PI) / 180 - headingRad), 0); + quaternion.setFromEuler(euler); + matrix.compose(position, quaternion, scaleVector); + mesh.setMatrixAt(count, matrix); + count += 1; + } + mesh.count = count; + mesh.instanceMatrix.needsUpdate = true; + } + + return { + group, + tick(dt) { + if (disposed) return; + timer -= dt; + if (timer > 0) return; + timer = source.interval; + /* + * No interpolation, unlike the city layer, and the sky is why. A track on + * this dome moves a few pixels between polls: at 3.6 km an airliner + * covers about 0.24 degrees a second across the dome, which is a third of + * its own drawn width, so tweening it would be machinery for motion + * nobody can see. The city layer interpolates because there the same + * aeroplane crosses a visible fraction of the board. + */ + void Promise.resolve(source.poll()).then((aircraft) => { + if (!disposed) place(aircraft); + }); + }, + dispose() { + disposed = true; + // Never `source.dispose()`: this layer is a second reader of a feed the + // city owns, and disposing it here would take the traffic off the board + // the moment somebody stepped indoors. + mesh.dispose(); + geometry.dispose(); + material.dispose(); + group.clear(); + }, + }; +} + +/** + * Metres per degree of latitude, and of longitude at the equator. + * + * The WGS-84 mean, which is the same constant `main.ts` uses to turn a scene + * offset back into a coordinate. A degree of latitude varies by about half a + * percent between the equator and the pole; on a bearing drawn as a symbol that + * is nothing. + */ +const METRES_PER_DEGREE = 111_320; + function withoutPeerFactory(options: OfficeRealtimePeersOptions): Omit { const { create: _create, ...peerOptions } = options; return peerOptions; diff --git a/src/interiors/plan.ts b/src/interiors/plan.ts index 16d814c..5967d5c 100644 --- a/src/interiors/plan.ts +++ b/src/interiors/plan.ts @@ -43,6 +43,23 @@ * exception with no context in the middle of a 180-prop pack tells the author * nothing and loses the other 179. * + * ### Two things resolve late, and both are addresses + * + * Almost everything here is resolved level by level, in one pass, because a wall + * and the slab under it are facts about one storey. Two authored things are not: + * a **device** names a prop, and the prop may be anywhere in the building; the + * **exterior arrival stall** names a level and stands outside every one of them. + * Both are therefore resolved after the level loop has run, next to the + * prop-to-seat binding pass that runs late for exactly the same reason — a + * cross-level address checked against a half-built plan reports a problem that + * is not there. + * + * A device is the first authored record in this format that takes its + * *coordinate* from another record rather than restating one. `DeviceAnchor` in + * `src/devices/types.ts` argues that case; the consequence here is that the + * derivation happens once, in `resolveDevice`, and a device that cannot find its + * hardware is dropped rather than given a position of its own. + * * ### Depth: a public build does not build the private half * * `PlanOptions.depth` is the other reason something can be absent from the build @@ -57,10 +74,18 @@ * and is public whatever it is marked. */ +import type { + DeviceCapability, + DeviceDeclaration, + DeviceKind, + DeviceProvenance, +} from "../devices/types.ts"; +import { deviceKindOfAssetId, validateDeviceDeclaration } from "../devices/types.ts"; import type { AssetId, Audience, DeskBank, + ExteriorArrival, Level, Office, Opening, @@ -222,6 +247,45 @@ export interface ResolvedSeat { source: { bankId: string; station: number } | undefined; } +/** + * A device, bound to the hardware it stands on and given a coordinate. + * + * The declaration in the pack has no position — see `DeviceAnchor` in + * `src/devices/types.ts`, which argues the case at length: a device is hardware, + * hardware sits on something, and that something is already placed. So this is + * where the coordinate comes from, exactly once, by reading the anchor prop's + * resolved transform and adding the authored offset **in the prop's own frame**. + * Nudge the desk and the mic moves with it, because there was never a second + * number to forget to update. + * + * `roomId` is filled in even when the pack left it out, because the answer is a + * lookup the pack should not have to restate and a consumer should not have to + * repeat. `seatId` is not — a device that serves no seat serves no seat, and + * inventing the nearest one would be the engine deciding what a microphone is + * pointed at. + */ +export interface ResolvedDevice { + id: string; + kind: DeviceKind; + label: string; + assetId: AssetId; + levelId: string; + /** The prop this device's hardware is, or stands on. Always resolves. */ + propId: string; + /** Office-world metres: anchor prop transform, plus the prop-frame offset. */ + position: { x: number; y: number; z: number }; + /** The anchor prop's yaw. A device faces the way its hardware faces. */ + rotation: Yaw; + /** The room the hardware stands in, resolved when the pack did not say. */ + roomId: string | undefined; + /** The seat this device serves, if the pack bound it to one. An address. */ + seatId: string | undefined; + capabilities: readonly DeviceCapability[]; + provenance: DeviceProvenance; + /** The sentence a viewer is shown next to the readings. Never empty. */ + disclosure: string; +} + /** A room's floor slab, cleaned, re-wound and measured. */ export interface ResolvedRoom { id: string; @@ -269,6 +333,8 @@ export interface LevelPlan { props: readonly PropPlacement[]; seats: readonly ResolvedSeat[]; zones: readonly ResolvedZone[]; + /** Resolved after every level exists — see the pass in the constructor. */ + devices: readonly ResolvedDevice[]; collision: readonly Segment[]; bounds: Bounds; } @@ -289,6 +355,30 @@ export interface PlanProblem { action: "dropped" | "repaired"; } +/** + * One authored device waiting for the rest of the building to exist. + * + * `sink` is the array the level already handed out as `LevelPlan.devices`, so + * the late pass fills in the answer the level is already advertising rather than + * replacing it. + */ +interface PendingDevice { + where: string; + /** The level whose floorplan declared it, which its anchor must agree with. */ + levelId: string; + declaration: DeviceDeclaration; + sink: ResolvedDevice[]; + /** + * Prop ids this level had and this *depth* does not — the private half, in a + * public build. Shared by every device on the level. + * + * Without it a public build would report a problem for every device standing + * on a private prop, which is not a problem: it is the depth doing exactly + * what it is for. See the note beside the audience skips in `buildLevel`. + */ + hidden: Set; +} + /** How every pass reports. Threaded through rather than closed over, so the * polygon and opening helpers can stay free functions. */ type Report = (where: string, message: string, action: PlanProblem["action"]) => void; @@ -365,6 +455,21 @@ export class Plan { readonly levels: readonly LevelPlan[]; /** Only those whose `levelId` resolves. `viewpoints[0]` is still the arrival pose. */ readonly viewpoints: readonly Viewpoint[]; + /** + * Where a vehicle stands outside, or `null` when the pack authored none or + * authored one that does not resolve. + * + * Deliberately **not** called `arrival`, because `arrival()` next to it means + * something else entirely and has since before this field existed: that one is + * the camera pose you open the building at, this one is a rectangle of tarmac + * outside it. Two things called arrival in one class is how a caller ends up + * parking a car in the lobby. + * + * The authored object is handed back by reference rather than copied. It is + * plain data on a frozen-by-convention pack, and a consumer that wants to keep + * it can `structuredClone` it — the same treatment `office` gets. + */ + readonly exteriorArrival: ExteriorArrival | null; /** Everything the validation pass dropped or repaired, in build order. */ readonly problems: readonly PlanProblem[]; /** The whole office, every level unioned. */ @@ -375,6 +480,7 @@ export class Plan { private readonly seatsById = new Map(); private readonly propsById = new Map(); private readonly viewpointsById = new Map(); + private readonly devicesById = new Map(); constructor(office: Office, options: PlanOptions = {}) { this.office = office; @@ -400,11 +506,18 @@ export class Plan { seat: new Set(), zone: new Set(), viewpoint: new Set(), + device: new Set(), }; // `levels` and `viewpoints` are required by the type, but a pack arriving as // JSON has been through no type checker at all, and a missing array should // produce an empty office rather than a TypeError with a stack trace in it. + // Devices are collected here and resolved after the level loop, for the same + // reason the prop-to-seat pass below runs late: an anchor is an address into + // the whole building, and checking one against a half-built plan reports a + // problem that is not there. + const pending: PendingDevice[] = []; + const levels: LevelPlan[] = []; (office.levels ?? []).forEach((level, li) => { const where = `levels[${li}]`; @@ -413,7 +526,7 @@ export class Plan { return; } seen.level.add(level.id); - const built = this.buildLevel(level, levels.length, where, seen, report); + const built = this.buildLevel(level, levels.length, where, seen, report, pending); levels.push(built); this.levelsById.set(built.id, built); for (const seat of built.seats) this.seatsById.set(seat.id, seat); @@ -436,6 +549,8 @@ export class Plan { } } + for (const item of pending) this.resolveDevice(item, seen.device, report); + const viewpoints: Viewpoint[] = []; (office.viewpoints ?? []).forEach((viewpoint, vi) => { const where = `viewpoints[${vi}]`; @@ -462,6 +577,7 @@ export class Plan { this.levels = levels; this.viewpoints = viewpoints; + this.exteriorArrival = this.acceptArrival(office.site?.arrival, report); this.problems = problems; this.bounds = extent.finish(); } @@ -484,6 +600,15 @@ export class Plan { return this.viewpointsById.get(id) ?? null; } + device(id: string): ResolvedDevice | null { + return this.devicesById.get(id) ?? null; + } + + /** Every device in the building, in declaration order, levels in order. */ + allDevices(): ResolvedDevice[] { + return [...this.devicesById.values()]; + } + /** Where you arrive. `viewpoints[0]`, or nothing if the pack declared none. */ arrival(): Viewpoint | null { return this.viewpoints[0] ?? null; @@ -544,6 +669,7 @@ export class Plan { where: string, seen: Record<"room" | "wall" | "prop" | "seat" | "zone", Set>, report: Report, + pending: PendingDevice[], ): LevelPlan { const floorY = level.elevation; const wallThickness = level.wallThickness ?? DEFAULT_WALL_THICKNESS; @@ -607,15 +733,34 @@ export class Plan { // which is the one whose author can do something about it. const props: PropPlacement[] = []; const seats: ResolvedSeat[] = []; + // Ids the depth took away rather than ids the pack got wrong. Only devices + // read it, and only to tell "your hardware is not in this build" apart from + // "your hardware does not exist". + const hidden = new Set(); + (floorplan.deskBanks ?? []).forEach((bank, bi) => { const at = `${where}.deskBanks[${bi}]`; - if (!included(this.depth, bank.audience)) return; + if (!included(this.depth, bank.audience)) { + // A private bank generates no props at all, so its stations' ids have to + // be derived rather than observed. They are contractual — `types.ts` + // promises a pack author exactly these strings — which is why they are + // computed by the same helper that emits them. + const stations = Math.floor(bank.columns) * Math.floor(bank.rows); + for (let station = 1; station <= stations; station += 1) { + hidden.add(bankPropId(bank.id, "desk", station)); + if (bank.chair !== undefined) hidden.add(bankPropId(bank.id, "chair", station)); + } + return; + } this.expandBank(bank, level, floorY, at, seen, report, props, seats); }); (floorplan.props ?? []).forEach((prop, pi) => { const at = `${where}.props[${pi}]`; - if (!included(this.depth, prop.audience)) return; + if (!included(this.depth, prop.audience)) { + hidden.add(prop.id); + return; + } if (seen.prop.has(prop.id)) { report(at, `duplicate prop id "${prop.id}"`, "dropped"); return; @@ -661,6 +806,22 @@ export class Plan { }); }); + // Devices are not resolved here, only queued: the array this level exposes is + // filled in by the pass in the constructor, once every prop in the building + // has an id and a transform. Handing the same array out now and filling it + // later is what lets `LevelPlan` stay one flat record rather than growing a + // second, half-built shape nobody can tell from the finished one. + const devices: ResolvedDevice[] = []; + (floorplan.devices ?? []).forEach((declaration, di) => { + pending.push({ + where: `${where}.devices[${di}]`, + levelId: level.id, + declaration, + sink: devices, + hidden, + }); + }); + // Props and seats join the extent last so that bank expansions are included // too — the bounds of a floor whose only content is one desk bank should not // come out as a point at the origin. @@ -681,6 +842,7 @@ export class Plan { props, seats, zones, + devices, collision, bounds: extent.finish(), }; @@ -921,7 +1083,7 @@ export class Plan { }); pushProp({ - id: `${bank.id}-desk-${n}`, + id: bankPropId(bank.id, "desk", station), kind: bank.desk, at: { x, z }, rotation: facing, @@ -932,7 +1094,7 @@ export class Plan { if (bank.chair !== undefined) { pushProp({ - id: `${bank.id}-chair-${n}`, + id: bankPropId(bank.id, "chair", station), kind: bank.chair, at: { x: x + seatX, z: z + seatZ }, rotation: facing, @@ -944,10 +1106,209 @@ export class Plan { } } } + + /** + * One authored device, bound to the hardware it stands on. + * + * Nothing here throws, and that is a deliberate difference from + * `resolveRobotOperations`, which does. A robot's station list is authored + * *behaviour* and a station with no floor under it is a bug in the pack. A + * device is authored *furniture*: one mic with a typo in its anchor should + * cost a viewer that mic and not the building. So every failure below drops + * one device and records why, exactly as a bad wall opening does. + * + * The order is: what the declaration says about itself, then what it says + * about the plan. `validateDeviceDeclaration` owns the first half — id, label, + * kind against asset id, capabilities, and the disclosure check that stops a + * simulated reading being shown without the word "simulated" anywhere near it. + * This method owns only the half that needs a resolved building. + */ + private resolveDevice(item: PendingDevice, seen: Set, report: Report): void { + const { where, levelId, declaration } = item; + const id = declaration.id; + + const faults = validateDeviceDeclaration(declaration); + if (faults.length > 0) { + for (const fault of faults) report(where, fault, "dropped"); + return; + } + if (seen.has(id)) { + report(where, `duplicate device id "${id}"`, "dropped"); + return; + } + + const anchor = declaration.anchor; + // The anchor restates the level it was declared on, because the declaration + // type has to stand on its own over the wire. Restated facts disagree + // eventually, so the disagreement is caught here rather than resolved by + // picking a winner. + if (anchor.levelId !== levelId) { + report( + where, + `device "${id}" is declared on level "${levelId}" and anchored to "${anchor.levelId}"`, + "dropped", + ); + return; + } + + // At public depth a private prop was never resolved, so a device standing on + // one lands here and is dropped — which is the right answer: hardware whose + // furniture is not in the build has nowhere to be. + const prop = this.propsById.get(anchor.propId); + if (!prop) { + // Not reported, and not a problem: the pack is fine, it is being read at a + // depth that does not include the furniture this device stands on. The + // device goes with it, which is the answer you want — a public build with + // a floating microphone over a desk it cannot see is worse than a public + // build with no microphone. + if (item.hidden.has(anchor.propId)) return; + report(where, `device "${id}" is anchored to unknown prop "${anchor.propId}"`, "dropped"); + return; + } + if (prop.levelId !== levelId) { + report( + where, + `device "${id}" is anchored to prop "${prop.id}", which is on level "${prop.levelId}"`, + "dropped", + ); + return; + } + + /** + * A mic bolted to a speaker is not a rendering mistake, it is a command sent + * to the wrong instrument, so it is dropped rather than drawn. + * + * Only when the anchor prop is *itself* device hardware, though. A mic + * standing on a desk is the ordinary case — `DeviceAnchor.offset` exists for + * exactly those few centimetres — and a desk claims to be no device at all, + * so `deviceKindOfAssetId` returns null for it and there is nothing to + * disagree with. + */ + const propKind = deviceKindOfAssetId(prop.kind); + if (propKind !== null && propKind !== declaration.kind) { + report( + where, + `device "${id}" is a ${declaration.kind} anchored to ${prop.kind}, which is a ${propKind}`, + "dropped", + ); + return; + } + + // The one calculation in this method. Local +X of a prop at yaw φ points at + // (cos φ, -sin φ) and local +Z at (sin φ, cos φ) — the same frame + // `expandBank` lays its stations out in, and the reason the offset is + // authored in the prop's frame rather than the room's: turn the desk and the + // mic stays on the corner of it. + const offset = anchor.offset; + const cos = Math.cos(prop.rotation); + const sin = Math.sin(prop.rotation); + const position = offset + ? { + x: prop.position.x + offset.x * cos + offset.z * sin, + y: prop.position.y + offset.y, + z: prop.position.z - offset.x * sin + offset.z * cos, + } + : { x: prop.position.x, y: prop.position.y, z: prop.position.z }; + + // `roomId` and `seatId` are addresses rather than positions, and they are + // treated the way every other address in this file is: an unknown one is + // cleared and reported, never invented. The room is derived when the pack + // left it out, because that answer is a lookup and a pack should not have to + // restate what the geometry already knows. + let roomId = anchor.roomId; + if (roomId !== undefined) { + const known = this.levelsById.get(levelId)?.rooms.some((room) => room.id === roomId) ?? false; + if (!known) { + report(where, `device "${id}" names unknown room "${roomId}"`, "repaired"); + roomId = undefined; + } + } + if (roomId === undefined) { + roomId = this.roomAt(levelId, { x: position.x, z: position.z })?.id; + } + + let seatId = anchor.seatId; + if (seatId !== undefined && !this.seatsById.has(seatId)) { + report(where, `device "${id}" serves unknown seat "${seatId}"`, "repaired"); + seatId = undefined; + } + + seen.add(id); + const resolved: ResolvedDevice = { + id, + kind: declaration.kind, + label: declaration.label, + assetId: declaration.assetId, + levelId, + propId: prop.id, + position, + rotation: prop.rotation, + roomId, + seatId, + // Copied rather than aliased: a build product that shares an array with + // the pack is one `sort()` away from editing the authored data. + capabilities: [...declaration.capabilities], + provenance: declaration.provenance, + disclosure: declaration.disclosure, + }; + item.sink.push(resolved); + this.devicesById.set(id, resolved); + } + + /** + * The exterior stall, checked for the three things that would make it + * unusable and deliberately not for the fourth. + * + * Level, kind and finite numbers are checked. **Whether the stall is actually + * outside the building is not**, and that is on purpose: a courtyard block + * with a stall in its own yard, a covered undercroft, a loading bay half under + * an overhang are all things a real pack might mean, and `Plan` has no + * business ruling on architecture. A pack that wants that guarantee asserts it + * in its own test — the shipped three do, in `src/test/packs/`. + */ + private acceptArrival( + arrival: ExteriorArrival | undefined, + report: Report, + ): ExteriorArrival | null { + if (arrival === undefined || arrival === null) return null; + const where = "site.arrival"; + if (arrival.kind !== "vehicle-stall") { + report(where, `arrival anchor has unknown kind "${String(arrival.kind)}"`, "dropped"); + return null; + } + if (!this.levelsById.has(arrival.levelId)) { + report(where, `arrival anchor is on unknown level "${arrival.levelId}"`, "dropped"); + return null; + } + const position = arrival.position; + if ( + !position || + !Number.isFinite(position.x) || + !Number.isFinite(position.z) || + !Number.isFinite(arrival.rotation) + ) { + report(where, "arrival anchor has a position or rotation that is not a number", "dropped"); + return null; + } + return arrival; + } } // ---- Placement helpers ---------------------------------------------------- +/** + * The prop id a bank station generates. + * + * Contractual: `types.ts` promises a pack author that bank `eng`'s fourth desk + * is `eng-desk-04` and nothing may renumber it. It is a function because two + * places need the answer — the expansion that emits them, and the depth pass + * that has to name the ones a private bank did *not* emit — and two copies of a + * promise is one copy too many. + */ +function bankPropId(bankId: string, part: "desk" | "chair", station: number): string { + return `${bankId}-${part}-${String(station).padStart(2, "0")}`; +} + function placeProp(prop: Prop, levelId: string, floorY: number): PropPlacement { const s = prop.scale ?? 1; const scale: [number, number, number] = diff --git a/src/interiors/types.ts b/src/interiors/types.ts index f863ed3..43f21de 100644 --- a/src/interiors/types.ts +++ b/src/interiors/types.ts @@ -29,6 +29,7 @@ * +Z down the page. */ +import type { DeviceDeclaration } from "../devices/types.ts"; import type { BuildingGlyph, Pin, View } from "../engine/types.ts"; // ---- Geometry ------------------------------------------------------------- @@ -257,6 +258,57 @@ export interface OfficeSite { * the generic marker layer; the city still never imports an office pack. */ exterior?: BuildingGlyph; + /** + * Where a vehicle stands on the ground outside the front door. + * + * Optional, and it lives on the *site* rather than on the `Office` for the + * same reason `elevation` does: it is a fact about the building's + * relationship to the ground outside it, and a pack with no site has no + * outside for anything to stand in. A pack that never mentions a vehicle + * renders exactly as it did before this field existed, which is the property + * every addition to this file has to keep. + */ + arrival?: ExteriorArrival; +} + +/** + * A marked place on the ground outside the building. + * + * The exterior layer needs one number a pack cannot get from anywhere else: + * **where, in the plan's own coordinates, is the apron outside the front + * door.** `lat`/`lng` says where the building is on the earth and nothing about + * which corner of the lot you park on; `Plan.bounds` is the extent of what was + * authored and its edge is a wall rather than a kerb. So the stall is authored, + * like every other number in a pack. + * + * ### It is in the plan's frame, not the world's + * + * `position` is metres in exactly the same XZ frame the walls are in, and + * `rotation` is a `Yaw` — zero faces −Z, as everything else here does. That is + * what makes the anchor legible next to the wall it stands outside of: a stall + * on the street side of a façade authored at `z = 0` has a negative `z`, and a + * reader can see it is outside the building without converting anything. + * + * `levelId` names the storey whose floor the stall is measured from, which for + * every shipped pack is the ground floor and for a building on a slope is not + * necessarily so. + * + * ### One kind, spelled out + * + * `kind` is a closed union with a single member rather than a free string, so + * that the second member — a loading bay, a bike rack, a helipad — arrives as a + * decision somebody made rather than as a typo that happened to render. + */ +export interface ExteriorArrival { + /** The storey whose floor this stall is measured from. */ + levelId: string; + /** Metres, in the pack's own plan frame, outside the building's footprint. */ + position: Point2; + /** Which way a vehicle parked here faces. See `Yaw`. */ + rotation: Yaw; + kind: "vehicle-stall"; + /** What to call it, for a caption. Absent where it needs no name. */ + label?: string; } /** @@ -318,6 +370,22 @@ export interface Floorplan { deskBanks?: DeskBank[]; seats?: Seat[]; zones?: Zone[]; + /** + * Smart hardware standing on the furniture — see `src/devices/types.ts`, + * which owns the type and is imported here for it. + * + * A `DeviceDeclaration` is authored, public and inert: it says a microphone + * exists, what it can be asked to do, and which prop is its hardware. What + * that microphone is *hearing* is a `DeviceState`, which never appears in a + * pack at all — it arrives over the API from a route that can refuse an + * anonymous caller. That is the same line `Presence` draws one type down, for + * the same reason, and it is why a pack can be published and a reading cannot. + * + * The list is on the floorplan rather than on the `Office` because a device is + * anchored to a prop and props are per storey, so the two lists that have to + * agree with each other sit next to each other. + */ + devices?: readonly DeviceDeclaration[]; } // ---- Rooms ---------------------------------------------------------------- diff --git a/src/main.ts b/src/main.ts index d5868b2..6595a2d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ /** - * The demo: two cities under a real sun and moon, and one office you can step + * The demo: two cities under a real sun and moon, and two studios you can step * into. * * It ships **no real company data**. The markers are fabricated — see @@ -9,7 +9,24 @@ * the UI says which of the two it is showing. * * It also runs with no server at all: the sun and moon are computed locally, - * the traffic is simulated, the office is a data file. Clone it and it works. + * the traffic is simulated, the office is a data file, the studio hardware is a + * fixed-step simulator running in this tab. Clone it and it works. + * + * ### What this file is, after the interface moved out + * + * This file used to hold about forty imperative `element.hidden = condition` + * decisions with the condition and the write on the same line, spread through a + * 655-line "Chrome" section, and none of it was testable without a WebGL + * context. That is now two modules: `ui/chromeState.ts` decides and `ui/mount.ts` + * writes, and what is left here is the one call between them — + * `chrome.apply(chromeState(chromeInputs()))` — plus the thing this file is + * actually for, which is **assembly**: it owns the clock, the access tier, which + * board is mounted, which building is open, and which of the delivered modules + * is handed to which scene. + * + * The rule that keeps it that way: nothing below this line may write to a DOM + * node that `mount.ts` owns. Where you want a piece of chrome to change, add a + * field to `ChromeInputs` and call `renderChrome()`. */ import { @@ -21,7 +38,9 @@ import { } from "./engine/atmosphere.ts"; import { officeDaylight, withHouseLights } from "./interiors/daylight.ts"; import { createScene, type SceneHandle } from "./engine/scene.ts"; +import { createEnvironmentRig } from "./engine/environmentRig.ts"; import { + aircraftDetail, regionOf, SimulatedFlights, withTrafficDial, @@ -31,7 +50,7 @@ import { SatelliteCatalogue, type SatelliteElements } from "./engine/satellites. import type { Pose } from "./engine/scenekit.ts"; import { createStage, deviceProfile } from "./engine/stage.ts"; import { daylightPhase } from "./engine/solar.ts"; -import type { City, Marker, MarkerPalette, View } from "./engine/types.ts"; +import type { Aircraft, City, Marker, MarkerPalette, View } from "./engine/types.ts"; import CALIFORNIA from "./cities/california.ts"; import SAN_FRANCISCO from "./cities/sf.ts"; import SOCAL from "./cities/socal.ts"; @@ -44,13 +63,10 @@ import { PlayInputRouter, sampleStandardPlayGamepad, vehicleActionsFromPlay, - type PlayDigitalControl, type StandardPlayGamepadButtons, } from "./input/play.ts"; -import { PointerStick } from "./input/pointerStick.ts"; import { createTeraClient, - describeLiveness, type PresenceWatch, type TrafficSource, type WeatherWatch, @@ -111,22 +127,44 @@ import { type OfficeScreenRemoteStatus, } from "./media/index.ts"; import type { RemoteMediaState, RemoteOfficeMedia } from "./media/remoteMedia.ts"; +/** + * The interface, as two modules and one call. + * + * `chromeState` is pure — no DOM, no THREE, no clock — and `mountChrome` is the + * only thing in the product that writes to the page. Both are value imports and + * both belong in the entry chunk: the chrome is on screen before anything else + * is, including the boot card's own successor. + */ +import { + chromeState, + seedPanelOpen, + seedPlanOpen, + type ChromeDetail, + type ChromeInputs, + type ChromeLayout, +} from "./ui/chromeState.ts"; +import { mountChrome, type ChromeHandle } from "./ui/mount.ts"; +import { controlForKey, edgeForKey } from "./ui/shortcuts.ts"; +import { browserStorage, hasSeenOnboarding } from "./ui/onboarding.ts"; +import { playHudKindFor, type AircraftDetailInput, type PlayTelemetry } from "./ui/hud.ts"; /** * Three type-only imports and not one value among them, which is what keeps the * office and the instruments out of the entry chunk. * - * `import type` is erased before Rollup ever sees it, so none of these three - * files is an edge in the module graph and none of them lands in the 780 kB - * everybody downloads. The office arrives through the `await import()` in - * `loadOffice()`, the tools through the one in `boot()`, and the rule that says - * so for `src/tools/` is written out at the top of `tools/index.ts`. Turning any - * of these into a value import silently undoes the split, and nothing fails — - * the bundle just gets big again. + * `import type` is erased before Rollup sees it, so none of these files is an + * edge in the module graph. The office arrives through `loadOffice()`, the tools + * through the `await import()` in `boot()`. Turning any of them into a value + * import silently undoes the split and nothing fails — the bundle just gets big + * again. The device and vehicle simulators travel with the office for the same + * reason: renderer-free, but with nothing to say until you are in a studio. */ import type { OfficeScene } from "./interiors/officeScene.ts"; import type { Office, Presence } from "./interiors/types.ts"; import type { RobotOperationsDefinition } from "./interiors/robotOperations.ts"; -import type { MaterialRegistry } from "./assets/materials.ts"; +import type { MaterialQuality, MaterialRegistry } from "./assets/materials.ts"; +import type { DeviceSource } from "./devices/adapter.ts"; +import type { DeviceCommand, DeviceDeclaration, DeviceState } from "./devices/types.ts"; +import type { SimulatedVehicleTelemetry } from "./transport/vehicleTelemetry.ts"; // Type-only for the reason above, and it matters more here than it looks: // `officeMinimap.ts` imports the asset registry as a *value*, to read prop // footprints, so a value import of it here would drag the furniture catalogue @@ -212,18 +250,14 @@ function journeyToCity(city: JourneyCity): void { /** * The buildings this page can walk into. * - * Three 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 tower above Transbay, a hangar at Alameda Point, - * and an Arts District courtyard — 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. + * Three, deliberately unalike — a tower above Transbay, a hangar at Alameda + * Point, an Arts District courtyard — because the thing worth showing is that + * one engine and one format render all three, 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. + * The loaders stay lazy: a pack eagerly imported would put its furniture in the + * entry bundle for every visitor who never opens that door. */ const OFFICE_LOADERS: Readonly Promise<{ default: Office }>>> = { "lumbridge-hq": () => import("./offices/lumbridge-hq.ts"), @@ -257,20 +291,34 @@ if (!canvas) throw new Error("#scene canvas missing"); * One renderer, one loop, for as long as this page is open. * * Built here rather than inside `createScene` because a `WebGLRenderer` is a - * property of the *canvas* and not of the city drawn on it. When each city - * built its own, every switch between the Bay Area and SoCal abandoned a - * renderer on the one GL context this page has, and abandoned renderers do not - * give their textures back: `WebGLRenderer.dispose()` frees no texture at all, - * so ten switches measured 88 live GPU textures against zero `deleteTexture` - * calls, 16.8 MB of orphaned shadow map at a time. `stage.ts` has the numbers - * and the reading of three's source that they come from. + * property of the *canvas* and not of the city drawn on it. When each city built + * its own, every switch abandoned a renderer on the one GL context this page + * has — and `WebGLRenderer.dispose()` frees no texture at all. `stage.ts` has + * the measurement and the reading of three's source it comes from. * - * Nothing disposes this. It outlives every city and every office on the page, - * and the page unload takes it — the same arrangement, and the same reasoning, - * as `officeMaterials` below. + * Nothing disposes this: it outlives every city and every office on the page, + * the same arrangement as `officeMaterials` below. */ const stage = createStage(canvas); +/** + * One environment map for the page, beside the one renderer, for the same + * reason there is one renderer. + * + * A `PMREMGenerator` compiles three shader programs and each cached environment + * is a half-float render target; both are properties of the GL context, not of + * the board drawn on it. Building a rig inside `createScene` would allocate a + * fresh chain per city and orphan the previous one on every switch, which is + * precisely the arithmetic `stage.ts` records for the renderer itself. So it is + * built here, handed to both scenes, and each scene calls `release()` on its way + * out so the rig's ledger does not retain a floor plate nobody can see. + * + * Nothing about it is eager: it allocates on the first `apply()`, and the two + * kinds — a sky for the city, a room for an office — are cached separately, so a + * page that never opens a door never builds the office blur chain. + */ +const environment = createEnvironmentRig(stage.renderer); + // `authFetch` so the private-office pack (`/api/v1/offices/:id`, which answers // 404 rather than 403 to anyone who may not see it) is requested as the signed-in // viewer. On a `password`-mode or open deployment it is an ordinary fetch. @@ -313,20 +361,14 @@ const realtimeAircraftId = crypto.randomUUID(); /** * The buildings you can walk into, as procedural glyphs on the city. * - * This is the one thing that makes Tera and Spaces feel like one product rather - * than two views sharing a bundle. Both packs carry a real `site` — it is what - * puts the sun in the right place — and until now that coordinate was known to - * the lighting and to nothing else. A stranger looking at the board had no way - * to tell that two of those buildings are ones they can go inside. + * The one thing that makes Tera and Spaces read as one product rather than two + * views sharing a bundle: both packs carry a real `site`, and until these + * existed that coordinate was known to the lighting and to nothing else. * - * `OFFICE_SITES` rather than the packs themselves, deliberately: a pack is a - * lazy chunk worth tens of kilobytes and the city wants these the instant the - * board appears, long before anybody opens a door. See `offices/sites.ts`. - * - * `colorKey` is opaque to the engine, as every `Pin.colorKey` is — the palette - * resolves it, and giving these their own key is what lets a door look different - * from a company. `glyph` is equally literal: dimensions and a silhouette, with - * no office semantics in the renderer. + * `OFFICE_SITES` rather than the packs, deliberately — a pack is a lazy chunk + * and the city wants these the instant the board appears. `colorKey` and `glyph` + * stay opaque to the engine, so a door looks different from a company without + * the renderer knowing what either means. */ const OFFICE_MARKERS: Marker[] = OFFICE_SITES.map((entry) => ({ id: `office:${entry.id}`, @@ -364,16 +406,10 @@ let hoveredMarker: Marker | null = null; /** * The colour a door is drawn in, which no marker feed knows about. * - * `colorKey` is opaque to the engine and resolved by the consuming app, so the - * palette is this file's business. The office key is merged in **here** rather - * than added to `SAMPLE_PALETTE`, because it is not a sample of anything: a - * deployment that replaces the whole marker feed with its own palette - * (`feed.palette`, further down) must still get doors it can see, and folding - * this into the sample set would lose it the moment real markers arrived. - * - * Amber, to sit with the chapter list and the "Enter the office" button rather - * than with the marker hues — a door is a piece of this application's - * navigation, and it should read as one. + * Merged in **here** rather than added to `SAMPLE_PALETTE`, because it is not a + * sample of anything: a deployment that replaces the whole marker feed with its + * own palette must still get doors it can see. Amber, so a door reads as this + * application's navigation rather than as another marker hue. */ const OFFICE_PALETTE: MarkerPalette = { office: 0xf5b53f }; @@ -418,23 +454,17 @@ let cityFlights: TrafficSource | null = null; /** * The satellite element sets, fetched once for the page rather than once per city. * - * Every other feed here is per-city and is torn down on a switch. These are not, - * and the asymmetry is the physics: an aircraft at 10,000 m is visible for tens - * of kilometres and the two boards are six hundred apart, but a satellite at - * 550 km is above the horizon for a circle two thousand kilometres across. The - * same element sets serve both cities and would serve a continent. + * The asymmetry with every other feed here is the physics: an aircraft at + * 10,000 m is visible for tens of kilometres and the two boards are six hundred + * apart, but a satellite at 550 km is above the horizon for a circle two + * thousand kilometres across. So the elements are shared — and the *catalogue* + * is not, because a `SatelliteCatalogue` is built around an observer, and + * reusing one would compute the Southland's sky from San Francisco with nothing + * on screen to say so. * - * **The elements are shared and the catalogue is not.** A `SatelliteCatalogue` - * is built around an observer, and the observer is the city centre: reusing one - * across a city switch would compute the Southland's sky from San Francisco and - * put every look angle several degrees out, with nothing on screen to say so. - * So the expensive, universal half is cached here and the cheap, local half is - * rebuilt per board. - * - * `null` until the first fetch is started, and on the overwhelming majority of - * deployments forever: `TERA_SATELLITES_SOURCE` is off by default. A promise - * rather than a value so that a second city mounted while the first fetch is - * still in the air waits for it instead of starting another. + * A promise rather than a value, so a second city mounted while the first fetch + * is in the air waits for it instead of starting another. `null` forever on the + * majority of deployments: `TERA_SATELLITES_SOURCE` is off by default. */ let satelliteElements: Promise | null = null; /** @@ -576,25 +606,16 @@ const OPENS_IN_OFFICE = /** * Where the city is, when this page is the office front door. * - * `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind - * two names, and until now leaving the office from the office door swapped the - * scene and left the address bar saying `office.` — so the site's own URL - * disagreed with the site. That is not a cosmetic complaint: it is the thing you - * copy, bookmark and send to someone, and it took them somewhere other than what - * you were looking at. + * Leaving the office used to swap the scene and leave the address bar saying + * `office.`, so the URL you would copy and send disagreed with what you were + * looking at. The city door is therefore this hostname with its first label + * swapped — a guess only ever made when that label is literally `office`, which + * is the same comparison that made this the office door. Same registrable + * domain, same static root, same API; a deployment serving one name and not the + * other has half-configured itself, and a 404 is the honest failure for that. * - * So the city door is this hostname with its first label swapped, and the guess - * is exactly as safe as the one directly above it: it is only ever consulted - * when that label is literally `office`, which is the same string comparison - * that made this the office door in the first place. Same registrable domain, - * same origin policy, same static root, same API — a deployment that serves one - * of these names and not the other has half-configured itself, and the honest - * failure for that is a 404 on a name it chose not to serve rather than a silent - * lie in the address bar. - * - * Anything else — a bare domain, `spaces.example.com`, `?view=office` on the - * city door — derives nothing and keeps the in-page swap, which is what the - * whole scene-swap design was for. + * Anything else — a bare domain, `?view=office` on the city door — derives + * nothing and keeps the in-page swap. */ function cityDoorUrl(cityWanted?: string): string | null { if (!OFFICE_HOST) return null; @@ -613,6 +634,92 @@ function cityDoorUrl(cityWanted?: string): string | null { return url.href; } + +// ---- The interface's own state -------------------------------------------- + +/** + * The applier, once the page has one. + * + * `null` for the handful of statements between this module's first line and + * `boot()`, which is why every call site is optional-chained rather than + * asserted: a keystroke that lands in that window should do nothing, not throw. + */ +let chrome: ChromeHandle | null = null; + +/** + * Two pieces of chrome are a *user* decision rather than a media query, and the + * distinction matters: a media query that hides the plan below 600px also makes + * `M` do nothing there, which is the width where a plan view is most useful and + * least affordable. So the width only seeds the initial state, and the moment + * someone presses the key or the button the viewport stops having an opinion. + * + * The seeds themselves live in `ui/chromeState.ts` next to the breakpoints they + * read, because that is where the reasoning about what a phone can afford is — + * and because the old inline `window.innerWidth > 600` for the plan was the + * whole of live defect 10, "the minimap disappears entirely" on a phone. + */ +let panelOpen = seedPanelOpen(window.innerWidth); +let planOpen = seedPlanOpen(window.innerWidth); +let panelChosen = false; +let planChosen = false; + +/** + * Whether this browser has been offered the first-run coach. + * + * Read **once**, here, before anything mounts: `mountOnboarding` marks the flag + * as it appears rather than as it completes, so asking `localStorage` a second + * time after the coach is up would answer "seen" and take it straight back off + * the screen. The write belongs to the coach; this is the read, and it is the + * only one. + */ +let firstVisit = !hasSeenOnboarding(browserStorage()); + +/** + * What the detail card is showing: an authored sentence, an observed aircraft, + * or nothing. + * + * A union rather than a string, because the two are not the same kind of thing + * and flattening the aircraft to a sentence is exactly what used to happen — + * `showDetail(\`${m.label} — ${m.blurb}\`)` for everything, including a track + * with five fields and a licence credit on it. + */ +let detail: ChromeDetail | null = null; + +// ---- Studio hardware ------------------------------------------------------- + +/** + * Where the readings for the room you are standing in come from. + * + * One at a time and it belongs to the visit, like `presenceWatch` above it. The + * choice between the deployment's own device route and the fixed-step simulator + * in this tab is made inside `devices/adapter.ts` and deliberately not here — + * that is the one place the anon-first fallback decision lives, and a second + * copy of it in the app is how the two drift apart. + */ +let deviceSource: DeviceSource | null = null; +/** The latest readings, so the panel and the hardware in the room agree. */ +let deviceStates: readonly DeviceState[] = []; + +/** + * The car outside, as a state machine. + * + * Seeded from the office id, so a studio's Model X has the same charge, the same + * cabin temperature and the same parking jitter on every machine and on every + * reload — a car whose battery was different every time you opened the door + * would read as noise rather than as a vehicle. + */ +let vehicleTelemetry: SimulatedVehicleTelemetry | null = null; + +/** + * The clock line, already formatted, because this file owns the clock. + * + * `instantOverride` is the one thing on the page that can make the rendered + * instant disagree with the wall clock, and it lives here — so a formatter + * downstream would have to be handed the override as well as the time, which is + * two facts to keep in step for one string. Written by `updateSun`, which is + * also the only thing that reads the sun. + */ +let clockLabel = ""; // ---- Time ----------------------------------------------------------------- /** @@ -750,21 +857,13 @@ function updateSun() { city.setLighting(atmosphere.apply(env)); city.setSolarElevation(env.sun.elevation); /** - * The sky's own cover, which is a different question from what it does to the - * light and is why the scene takes it separately. + * `atmosphere.cloudCover(env)` and **not** `currentWeather()?.cloudCover ?? 0`. * - * `atmosphere.cloudCover(env)` and **not** `currentWeather()?.cloudCover ?? 0`, - * and the difference is the whole point of the layer existing. `null` weather - * is "nobody was asked", which is not an edge case — it is the *default* - * deployment and the exact configuration this repo is held to: a stranger - * clones it, runs one command, and gets a city with no account and no key. - * Falling back to zero meant that stranger's sky was permanently, silently - * empty, and the cloud layer only ever appeared for somebody who had wired up - * NWS. - * - * `atmosphere` models a sky when nobody has observed one — it already does - * exactly that for the marine layer — and an observed cover still wins - * outright when there is one. See `cloudCover` in `atmosphere.ts`. + * `null` weather is "nobody was asked", which is not an edge case — it is the + * default deployment and the configuration this repo is held to. Falling back + * to zero meant a clean clone's sky was permanently, silently empty. + * `atmosphere` models a sky when nobody has observed one, and an observed + * cover still wins outright when there is one. */ city.setCloudCover(atmosphere.cloudCover(env)); city.setWind(currentWeather()?.windKph ?? null, currentWeather()?.windDirDeg ?? null); @@ -778,12 +877,14 @@ function updateSun() { // a set of three.js lights and the minimap has none. minimap?.setSolarElevation(env.sun.elevation); - const clock = document.querySelector("#clock"); - if (!clock) return; const el = env.sun.elevation; const time = env.time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); const moon = env.moon ? ` · moon ${Math.round(env.moon.illuminated * 100)}%` : ""; - clock.textContent = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${daylightPhase(el)}${moon}`; + // Formatted here rather than in the chrome, because the clock is this file's + // — `instantOverride` is the one thing on the page that can make it disagree + // with the wall clock, and a formatter downstream would have to be told. + clockLabel = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${daylightPhase(el)}${moon}`; + renderChrome(); } // ---- Cities --------------------------------------------------------------- @@ -836,18 +937,14 @@ async function mountCity(id: string) { * The sky and the traffic are per-city and are chosen here, before the build, * because `flights` is fixed at scene construction. * - * Still two gates, but only one of them is about the visitor now. + * Two gates and only one of them is about the visitor. `can.liveEnvironment` + * is true for everybody — the sky is not a thing an account can grant. What is + * load-bearing is `feeds`, the *deployment*, which stops the ordinary box + * where every source is `none` polling two endpoints forever for a 404. * - * `can.liveEnvironment` is true for everybody — the reasoning is in - * `access.ts`, and it comes down to the sky not being a thing an account can - * grant you. What remains load-bearing is `feeds`, the *deployment*: it is - * what stops the ordinary box, where every source is `none`, from polling two - * endpoints forever for a 404 on every tab that is open. - * - * The other half of the old comment is still worth keeping, because it was a - * real bug: the flights used to be gated on the *markers* flag, which is a - * different feed entirely, so a deployment with a real ADS-B receiver and no - * marker file flew the simulator. + * These used to be gated on the *markers* flag, which is a different feed + * entirely, so a deployment with a real ADS-B receiver and no marker file flew + * the simulator. */ const region = regionOf(entry.city); // The hand-authored corridors for *this* city. `SAMPLE_ROUTES` was passed @@ -924,6 +1021,9 @@ async function mountCity(id: string) { const handle = await createScene(stage, { city: entry.city, + // The page's one rig, shared with the office. Handed in rather than built + // per board; see the note where it is constructed. + environment, actor: { kind: actorKindForPresence(access.subject !== null, "outdoors"), identity: access.subject === null @@ -984,6 +1084,16 @@ async function mountCity(id: string) { hoveredMarker = m; showDetail(m ? `${m.label}${m.blurb ? ` — ${m.blurb}` : ""}` : null); }, + /** + * The other thing worth pointing at, and the one the owner asked for by + * name: a card per aeroplane, for anybody who lands on this page. + * + * Hover opens it and hover away closes it, which is the same contract the + * marker card has had since picking existed — there is no click handler + * here because there is nothing extra a click could mean. See + * `showAircraftDetail` for where the provenance comes from. + */ + onAircraftPick: (a) => showAircraftDetail(a), signal: mount.signal, // An abandoned build keeps its worker running for a tick or two after the // abort; its percentages must not land on the card the new city is using. @@ -1031,7 +1141,7 @@ async function mountCity(id: string) { id !== "california" && access.can.liveEnvironment && access.feeds?.weather ? tera.watchWeather(entry.city.center, () => { updateSun(); - renderSource(); + renderChrome(); }) : null; @@ -1076,7 +1186,7 @@ async function mountCity(id: string) { // not a decoration. LA gets its own weather, not San Francisco's fog. marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null, }); - city.onChapterChange(() => renderLegend()); + city.onChapterChange(() => renderChrome()); city.onControlModeChange((mode) => adoptCityControlMode(mode)); /** @@ -1135,36 +1245,72 @@ async function mountCity(id: string) { refreshGodmodePlace(); updateSun(); - renderLegend(); + renderChrome(); } /** - * The minimap's own frame pump. + * The app's own frame pump, beside the renderer's. * - * `Stage` owns the render loop and `SceneHandle` exposes no per-frame hook, so - * the alternative is adding an `onTick` to the scene handle for exactly one - * call site. This is the smaller change and it costs nothing measurable: an - * idle `tick()` is a timestamp comparison and a dirty flag, 0.0002 ms, and the - * loop keeps running unchanged across a city swap, across the office swap, and - * during the window where there is no minimap at all. + * `Stage` owns the render loop and neither scene handle exposes a per-frame + * hook, so the alternative is adding an `onTick` to both for a handful of call + * sites. This is the smaller change and it costs nothing measurable: an idle + * `tick()` is a timestamp comparison and a dirty flag, 0.0002 ms, and the loop + * keeps running unchanged across a city swap, across the office swap, and + * during the window where there is no scene at all. + * + * What lives on it, and why each is here rather than in a scene: + * + * - the two plan widgets, which are 2-D canvases the engine draws; + * - the pose editor, which is `null` for everyone who is not god; + * - the studio hardware and the car outside, which are **simulations** rather + * than layers — both are fixed-step state machines that the arena also drives, + * and putting their clocks in a scene would mean the arena and the renderer + * stepping two different copies; + * - the chrome, but only while a body is under control. See below. */ -requestAnimationFrame(function pumpMinimap() { - requestAnimationFrame(pumpMinimap); - syncJourneyVehicle(performance.now()); +let lastPumpAt = performance.now(); +requestAnimationFrame(function pump(now) { + requestAnimationFrame(pump); + /** + * Clamped, because a backgrounded tab comes back with a `dt` of minutes and + * neither simulator should be asked to catch up on a room nobody was in. Both + * of them drop the excess internally as well; this is the cheaper half of the + * same decision, made before the call rather than inside it. + */ + const dt = Math.min(0.1, Math.max(0, (now - lastPumpAt) / 1000)); + lastPumpAt = now; + + syncJourneyVehicle(now); updateLocalPlayerMaps(); - renderPlayHud(); // Only the mounted one. The other is still constructed and still holds a live // camera, but its canvas is out of the document, so `clientWidth` is 0, every // `resize()` puts it back to `ready = false`, and ticking it would be a // function call that exists to do nothing sixty times a second. if (inside) officePlan?.tick(); else minimap?.tick(); - // The pose editor is on the same pump for the same reasons, and is `null` for - // everyone who is not god, so this is one property read per frame on a - // public page. + if (inside) { + // A no-op on the API strategy, which is driven by its own poll — see + // `devices/adapter.ts`, which owns that choice and nothing above it knows + // which of the two it got. + deviceSource?.tick(dt); + stepVehicleTelemetry(dt); + } poseEditor?.tick(); - publishRealtimePresence(performance.now()); + publishRealtimePresence(now); pollLiveness(); + /** + * The chrome, on the frames where something in it actually moves. + * + * `chromeState` is pure and `mount.apply` compares before it writes, so + * calling this unconditionally would be correct — and it would also rebuild + * nothing, sixty times a second, for the ninety-odd percent of a session + * spent in an orbit camera. The one part of the interface that changes per + * frame is the play HUD's speed and altitude, and `playHudKindFor` is exactly + * the test for "is a controller reporting numbers right now". Everything else + * calls `renderChrome()` at the moment it changes, which is what makes this + * an optimisation rather than a second source of truth. + */ + if (playHudKindFor(controlModeState.mode) !== null) renderChrome(); }); function updateLocalPlayerMaps(): void { @@ -1239,7 +1385,7 @@ function pollLiveness() { const now = performance.now(); if (now - livenessCheckedAt < 1000) return; livenessCheckedAt = now; - renderSource(); + renderChrome(); } /** Persist route progress cheaply and turn the hero's wrap into a real scale door. */ @@ -1333,6 +1479,31 @@ async function enterOffice() { office = createOfficeScene(pack, { dom: city.stage.renderer.domElement, + // The page's one environment map, the same object the city holds. Indoors + // it is the difference between `deviceMesh`, `chairBase` and the Model X's + // clearcoat reading as metal and reading as grey plastic. + environment, + /** + * The same aeroplanes the board outside is drawing. + * + * `dial.source` rather than the raw feed, so a sky somebody turned up in + * godmode is the sky in both places; and the *city's* source rather than a + * second subscription, so the office is a second reader of one feed rather + * than a second poller of a volunteer-funded API. The office never + * disposes it — see the note on `OfficeSceneOptions.flights`. + */ + ...(trafficDial ? { flights: trafficDial.source } : {}), + /** + * The car on the apron. `corridor` detail because it is parked and the + * arrival viewpoint looks at it from across a courtyard: the 22 extra draw + * calls `follow` buys are mirrors, glass frames and brake calipers, none + * of which resolve at that range. + * + * The seed is the office id rather than a constant, so the two studios do + * not park identical cars at identical angles — which is the single + * clearest tell that a scene was generated. + */ + exteriorVehicle: { detail: "corridor", seed: seedForOffice(officeId) }, // A visitor owns one local actor. Anonymous visitors are the promised // office dog; a signed-in visitor gets the procedural humanoid. The // arrival viewpoint is already a pack-authored clear point on a floor, @@ -1372,8 +1543,10 @@ async function enterOffice() { ...(createOfficePeers ? { realtimePeers: { create: createOfficePeers } } : {}), }); builtOfficeId = officeId; - office.onViewChange(() => renderLegend()); + office.onViewChange(() => renderChrome()); officePlan = buildOfficePlan(createOfficeMinimap, office); + startDeviceFeed(built.createDeviceSource, office.devices); + startVehicleTelemetry(built.createSimulatedVehicleTelemetry); } requestControlMode("overview"); city.stage.setScene(office); @@ -1390,7 +1563,7 @@ async function enterOffice() { showPlan(); showDetail(null); refreshGodmodePlace(); - renderLegend(); + renderChrome(); // After the room is on screen, not before. The first ask is a second request // and the building is worth looking at while it is in flight; awaiting it here // would hold the door shut on a network round trip to draw people into a scene @@ -1520,13 +1693,19 @@ function leaveOffice() { showPlan(); showDetail(null); refreshGodmodePlace(); - renderLegend(); + renderChrome(); } /** Dispose everything whose coordinates or subscriptions belong to one office. */ function disposeLoadedOffice(): void { stopWatchingOccupancy(); disposeOfficeScreenUi(); + // Before the scene, so a reading in flight cannot land on a disposed layer. + // `stop()` is idempotent and is the only thing that owns a timer down there. + deviceSource?.stop(); + deviceSource = null; + deviceStates = []; + vehicleTelemetry = null; officePlan?.dispose(); officePlan = null; office?.dispose(); @@ -1535,32 +1714,182 @@ function disposeLoadedOffice(): void { officeAtmosphere = null; } +/** + * A stable integer per building, for the things that must look the same twice. + * + * FNV-1a over the id, which is the same hash the arena's checksums use and is + * chosen for the same property: it is short, it has no dependencies, and it + * gives two ids that differ by one character completely different seeds — so + * `lumbridge-hq` and `mateo-court` genuinely park different cars rather than + * two that differ in the third decimal. + */ +function seedForOffice(id: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < id.length; i += 1) { + hash ^= id.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +/** + * Point the hardware in this room at whatever will answer for it. + * + * The choice — the deployment's device route, or the fixed-step simulator in + * this tab — is made inside `createDeviceSource` and deliberately not here. + * That is the one place the anon-first fallback lives, and the reason it is not + * a branch in this file is that the two halves would drift: the route refuses an + * anonymous read, which is correct, and an anonymous visitor is the audience + * this product is designed for, so the refusal must produce a working + * instrument rather than a dead one. + * + * `serverHasDevices` is the same short-circuit `access.feeds` performs for + * weather and markers: a box with no device source answers with an empty body, + * and asking it once per TTL per tab forever to be told nothing is a request + * nobody needs. + */ +function startDeviceFeed( + create: typeof import("./devices/adapter.ts").createDeviceSource, + declarations: readonly DeviceDeclaration[], +): void { + deviceSource?.stop(); + deviceStates = []; + if (declarations.length === 0) { + deviceSource = null; + return; + } + deviceSource = create({ + declarations, + client: tera, + officeId: officePack?.id ?? officeId, + serverHasDevices: access.feeds?.devices !== false, + seed: seedForOffice(officeId), + onReading: (reading) => { + deviceStates = reading.states; + // The hardware in the room and the panel beside it, from one reading, so + // a lit indicator and a lit control can never disagree about a mic. + office?.setDeviceStates(reading.states); + chrome?.applyDeviceStates(reading.states); + }, + }); + const opening = deviceSource.current(); + deviceStates = opening.states; + office?.setDeviceStates(opening.states); + chrome?.applyDeviceStates(opening.states); +} + +/** + * Send one command, and say nothing if it is refused. + * + * `normalizeDeviceCommand` has already run inside the panel, so anything that + * reaches here is well formed for a device this office declared. A `null` back + * is a *deployment* refusal — an anonymous caller writing to a real room — and + * the panel is left showing the last reading rather than a control that appears + * to have worked. The refusal is not narrated because the sign-in offer is + * already on the same screen. + */ +function sendDeviceCommand(command: DeviceCommand): void { + void deviceSource?.command(command).then((state) => { + if (state === null) return; + deviceStates = deviceStates.map((current) => (current.id === state.id ? state : current)); + office?.setDeviceStates(deviceStates); + chrome?.applyDeviceStates(deviceStates); + }); +} + +/** Bring the studio hardware panel into view, opening the column if it is shut. */ +function openDevicePanel(): void { + panelOpen = true; + panelChosen = true; + renderChrome(); + document.querySelector("#device-section")?.scrollIntoView({ + block: "nearest", + // A jump rather than a glide, and not only for `prefers-reduced-motion`: the + // panel it is scrolling inside has just been opened in the same frame. + behavior: "auto", + }); +} + +/** + * The car outside, as its own fixed-step simulation. + * + * Seeded per office and stepped from the frame pump rather than from the office + * scene, because this is the *same* module the arena drives — a state machine + * with a snapshot and a restore, not a render layer. Keeping its clock here is + * what lets the renderer and the arena hold two independent instances of one + * implementation instead of two implementations. + * + * The ambient temperature is a constant and that is a gap rather than a choice: + * `WeatherObservation` carries cloud, precipitation, visibility and wind, and no + * temperature at all, so there is nothing live to couple to. Inventing one from + * the sun's elevation would be publishing a reading nobody took, which is the + * one thing the whole telemetry layer is arranged not to do. + */ +function startVehicleTelemetry( + create: typeof import("./transport/vehicleTelemetry.ts").createSimulatedVehicleTelemetry, +): void { + vehicleTelemetry = create({ + seed: seedForOffice(officeId), + fixedStepSeconds: VEHICLE_STEP_SECONDS, + ambientC: DEFAULT_AMBIENT_C, + }); + office?.setVehicleTelemetry(vehicleTelemetry.current()); +} + +/** Seconds of simulated vehicle time per fixed step. */ +const VEHICLE_STEP_SECONDS = 0.5; +/** + * The ambient temperature the parked car sits in, in Celsius. + * + * A mild Californian afternoon, which is what both studios stand in. See + * `startVehicleTelemetry` for why it is not read off the weather feed. + */ +const DEFAULT_AMBIENT_C = 19; + +let vehicleStepDebt = 0; +/** + * Advance the car by whole fixed steps, and only ever by whole ones. + * + * A fixed-step simulator that is handed a variable `dt` stops being + * reproducible, and reproducibility is the entire reason this module is shared + * with the arena. So the frame's elapsed time is accumulated and spent in whole + * steps, with the remainder carried; `apply` is signature-guarded downstream, so + * the frames that spend no step cost a comparison. + */ +function stepVehicleTelemetry(dt: number): void { + const source = vehicleTelemetry; + if (!source || !office) return; + vehicleStepDebt = Math.min(vehicleStepDebt + dt, VEHICLE_STEP_SECONDS * 4); + while (vehicleStepDebt >= VEHICLE_STEP_SECONDS) { + vehicleStepDebt -= VEHICLE_STEP_SECONDS; + source.stepFixed(); + } + office.setVehicleTelemetry(source.current()); +} + /** * Fetch Spaces. * - * The office is the largest thing in this build that most visitors never open: - * the interior, the furniture catalogue, the material registry and the - * floorplan are 67 kB of chunk — 22 kB across the wire — and they used to be - * downloaded, parsed and executed on every load of a map page by people who - * came to look at a city. Behind these lazy imports Vite gives them - * chunks of their own and the door fetches them on the way through. Measured, - * entry chunk: 780.18 kB / 216.89 kB gzipped before, 720.89 / 198.33 after — - * the difference is smaller than the chunks because three.js is shared and - * stays where it was. + * The office is the largest thing in this build that most visitors never open, + * so it is a chunk the door fetches on the way through rather than bytes every + * map visitor downloads. Measured entry chunk: 780.18 kB before the split, + * 720.89 after — smaller than the chunks themselves because three.js is shared + * and stays where it was. * - * All modules in one `Promise.all` because they are one arrival: the pack without - * the builder is a data file nobody can draw, so the fetches overlap rather - * than queue. Rollup emits chunks the browser asks for together; awaiting them - * in sequence would add avoidable round trips on a slow link. + * One `Promise.all` because they are one arrival: a pack without its builder is + * a data file nobody can draw, so the fetches overlap rather than queue. * - * There is deliberately no retry and no cache-busting. A failed chunk fetch is - * a deploy that moved the file under an open tab; the honest answer is to say - * the door did not open and let the next click try again, which it will, - * because a rejected dynamic import is not memoised by the browser. + * No retry and no cache-busting, deliberately. A failed chunk fetch is a deploy + * that moved the file under an open tab; the honest answer is to say the door + * did not open and let the next click try again, which it can, because a + * rejected dynamic import is not memoised by the browser. */ async function loadOffice(): Promise<{ createOfficeScene: typeof import("./interiors/officeScene.ts").createOfficeScene; createOfficeMinimap: typeof import("./engine/officeMinimap.ts").createOfficeMinimap; + createDeviceSource: typeof import("./devices/adapter.ts").createDeviceSource; + createSimulatedVehicleTelemetry: + typeof import("./transport/vehicleTelemetry.ts").createSimulatedVehicleTelemetry; pack: Office; materials: MaterialRegistry; robotOperations: RobotOperationsDefinition | null; @@ -1573,21 +1902,28 @@ async function loadOffice(): Promise<{ // 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, operations] = await Promise.all([ + const [interiors, pack, assets, plan, operations, devices, telemetry] = await Promise.all([ import("./interiors/officeScene.ts"), entry.load(), import("./assets/materials.ts"), import("./engine/officeMinimap.ts"), entry.loadOperations?.() ?? Promise.resolve(null), + // Neither of these imports THREE, the DOM or the network, and neither has + // anything to say until somebody is standing in a studio — so both travel + // with the furniture rather than in the bundle every map visitor fetches. + import("./devices/adapter.ts"), + import("./transport/vehicleTelemetry.ts"), ]); // Assigned rather than memoised with `??=`: the memo was what made this // single-office forever, quietly serving the first pack fetched for every // later request whatever id was asked for. officePack = pack.default; - officeMaterials ??= new assets.MaterialRegistry({ quality: "high" }); + officeMaterials ??= new assets.MaterialRegistry({ quality: officeMaterialQuality() }); return { createOfficeScene: interiors.createOfficeScene, createOfficeMinimap: plan.createOfficeMinimap, + createDeviceSource: devices.createDeviceSource, + createSimulatedVehicleTelemetry: telemetry.createSimulatedVehicleTelemetry, pack: officePack, materials: officeMaterials, robotOperations: operations?.default ?? null, @@ -1598,6 +1934,31 @@ async function loadOffice(): Promise<{ } } +/** + * How much texture a studio may draw, decided by the device rather than by a + * constant. + * + * This said `"high"` for every visitor, which meant `materials.ts` had a fully + * implemented `low` and `medium` path — different roughness handling, a cheaper + * glazing fallback, and 256px textures instead of 512 — that **no caller could + * ever reach**. The documented mobile escape hatch had never actually existed, + * and a phone was being handed desktop-grade texture memory for a canvas at + * 1.5× pixel ratio. + * + * `medium` rather than `low` on a handheld: `low` drops to flat Lambert with no + * maps at all, which is the setting that makes an office open on an integrated + * GPU and is far more than a modern phone needs. `medium` is the same physically + * shaded materials at half the texture resolution, which is the actual + * difference a phone can feel. + * + * `deviceProfile()` is the one definition of "phone" on this page — the pixel + * ratio cap, the shadow map size and now the texture budget all read it, so they + * cannot disagree about what a phone is. + */ +function officeMaterialQuality(): MaterialQuality { + return deviceProfile().handheld ? "medium" : "high"; +} + /** The office's name, for the two bits of chrome that say where you are. */ function officeName(): string { return officePack?.name ?? "Spaces"; @@ -1606,20 +1967,14 @@ function officeName(): string { /** * Who is in the building, asked for once per entry. * - * Only at full depth, and that is not an optimisation. `createOfficeScene` at - * `"public"` builds **no presence layer at all** — not an empty one, not a - * hidden one — so there is nothing here to populate and the request would be one - * this visitor's session is going to be refused anyway. `presence.ts` explains - * at length why the layer is absent rather than emptied; this is the call site - * that would otherwise quietly reintroduce it. + * Only at full depth, and that is not an optimisation: `createOfficeScene` at + * `"public"` builds no presence layer at all, so there is nothing to populate + * and the request would be one the session is going to be refused anyway. * - * The fallback is `markers`' fallback, one room in: an API that does not answer - * gets the fabricated roster, because a clone with no server is the flagship - * case and a member shown the same empty room as a stranger has been told the - * tier means something when it does not. An API that *does* answer is believed, - * including when it answers with nobody — an office where everyone has gone home - * is a real fact about an office, and overwriting it with invented people to - * make the demo livelier is the one thing this file must never do. + * The fallback is `markers`' fallback, one room in. An API that answers is + * believed, including when it answers with nobody — an office where everyone has + * gone home is a real fact about an office, and overwriting it with invented + * people to liven up the demo is the one thing this file must never do. */ function watchOccupancy() { stopWatchingOccupancy(); @@ -1636,8 +1991,7 @@ function watchOccupancy() { // depth) without the watch having been stopped in between. if (office !== scene) return; applyPresence(); - renderOfficeBadge(); - renderSource(); + renderChrome(); }); } @@ -1672,55 +2026,26 @@ function applyPresence() { } // ---- Chrome --------------------------------------------------------------- +// +// Six DOM handles and no more. Everything else on this page belongs to +// `ui/mount.ts`, which is the only module in the product that writes to it — +// these six are the hosts of things `mount` deliberately does not own: two +// widgets built by the engine, one overlay built by `profile/`, one by +// `media/`, and one indicator built by `realtime/`. -const nav = document.querySelector("#chapters"); -const blurb = document.querySelector("#blurb"); -const title = document.querySelector("#title"); -const subtitle = document.querySelector("#subtitle"); -const enterButton = document.querySelector("#enter"); -const cityNav = document.querySelector("#cities"); -const source = document.querySelector("#source"); const minimapFrame = document.querySelector("#minimap .minimap-frame"); const minimapReadout = document.querySelector("#minimap-readout"); -const tierBadge = document.querySelector("#tier"); const presenceHost = document.querySelector("#presence-host"); -const officeBadge = document.querySelector("#office-badge"); -const panelToggle = document.querySelector("#panel-toggle"); -const panelToggleLabel = document.querySelector("#panel-toggle-label"); -const shortcutsCard = document.querySelector("#shortcuts"); -const helpButton = document.querySelector("#help"); -const planToggle = document.querySelector("#plan-toggle"); -const credits = document.querySelector("#credits"); -const driveControls = document.querySelector("#drive-controls"); -const driveHint = document.querySelector("#drive-hint"); -const walkButton = document.querySelector("#walk"); -const flyButton = document.querySelector("#fly"); -const screensButton = document.querySelector("#screens"); -const walkControls = document.querySelector("#walk-controls"); -const walkHint = document.querySelector("#walk-hint"); -const touchPlayControls = document.querySelector("#touch-play-controls"); -const playStick = document.querySelector("#play-stick"); -const touchPrimary = document.querySelector("#touch-primary"); -const touchSecondary = document.querySelector("#touch-secondary"); -const touchPitchUp = document.querySelector("#touch-pitch-up"); -const touchPitchDown = document.querySelector("#touch-pitch-down"); -const touchAssist = document.querySelector("#touch-assist"); -const touchReset = document.querySelector("#touch-reset"); -const touchCamera = document.querySelector("#touch-camera"); -const touchMap = document.querySelector("#touch-map"); -const modeDock = document.querySelector("#mode-dock"); -const playHud = document.querySelector("#play-hud"); -const playHudMode = document.querySelector("#play-hud-mode"); -const playHudPrimary = document.querySelector("#play-hud-primary"); -const playHudStatus = document.querySelector("#play-hud-status"); -const profileOverlay = document.querySelector("#profile-overlay"); const webcamFaceIndicator = document.querySelector("#webcam-face-indicator"); +const profileOverlay = document.querySelector("#profile-overlay"); const screensOverlay = document.querySelector("#screens-overlay"); let profileEditor: ProfileEditor | null = null; let webcamFacePanel: WebcamFacePanel | null = null; let webcamFaceTexture: WebcamFaceTextureAdapter | null = null; let webcamFaceConsent = createWebcamFaceConsent(); let webcamCapture: WebcamCaptureController | null = null; +/** Whether hosted presence has a widget on screen; the chrome makes room for it. */ +let presenceMounted = false; function availableControlModes() { const route = city?.current(); @@ -1734,12 +2059,26 @@ function availableControlModes() { }; } +/** + * The same availability, as the list the chrome takes. + * + * Two shapes for one fact is a smell and is the lesser one here: + * `transitionControlMode` was written against the record and is tested against + * it, and `chromeState` takes a list because a list is what a dock renders. This + * is the one conversion, in one place, rather than each caller deriving its own. + */ +function availableModes(): ControlMode[] { + const can = availableControlModes(); + const modes: ControlMode[] = [inside ? "office-overview" : "overview"]; + if (can.drive) modes.push("drive"); + if (can.actor) modes.push("actor"); + if (can.aircraft) modes.push("aircraft"); + if (can.officeWalk) modes.push("office-walk"); + return modes; +} + function clearPublishedPlayInput(): void { playInput.clearAll(); - for (const button of document.querySelectorAll( - "[data-drive-key][aria-pressed], [data-walk-key][aria-pressed], [data-play-control][aria-pressed]", - )) button.setAttribute("aria-pressed", "false"); - resetPlayStick(); city?.setVehicleActions({}); city?.setActorActions({}); city?.setAircraftActions({}); @@ -1753,16 +2092,8 @@ function adoptCityControlMode(mode: ReturnType): void { clearPublishedPlayInput(); controlModeState = { mode, revision: controlModeState.revision + 1 }; } - if ( - previous === "overview" && mode !== "overview" && - (document.body.classList.contains("touch-capable") || - window.matchMedia?.("(pointer: coarse)").matches === true) - ) { - planOpen = false; - planChosen = true; - applyPlan(); - } - renderLegend(); + if (previous === "overview" && mode !== "overview") closePanelForPlay(); + renderChrome(); } /** One transaction updates Journey, simulation ownership, input and chrome. */ @@ -1802,68 +2133,291 @@ function requestControlMode(requested: ControlMode): boolean { if ( transition.changed && (transition.previous === "overview" || transition.previous === "office-overview") && - next !== "overview" && next !== "office-overview" && - (document.body.classList.contains("touch-capable") || - window.matchMedia?.("(pointer: coarse)").matches === true) + next !== "overview" && next !== "office-overview" ) { - planOpen = false; - planChosen = true; - applyPlan(); + closePanelForPlay(); } - renderLegend(); + renderChrome(); publishPlayActions(); return transition.accepted; } +/** One authored sentence in the detail card, or nothing. */ function showDetail(text: string | null) { - const card = document.querySelector("#detail"); - const body = document.querySelector("#detail-text"); - if (!card || !body) return; - card.hidden = text === null; - // The text, and not the card: the card also holds the dismiss button, which - // `textContent` on the card would delete the first time a marker was picked. - body.textContent = text ?? ""; + detail = text === null ? null : { kind: "text", text }; + renderChrome(); } /** - * The board strip above the legend: world scales outside, buildings inside. + * One aeroplane in the detail card, for anybody at all. * - * 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. + * Owner decision 2: the flight card is part of the demo an anonymous visitor + * gets, and there is nothing here an account could grant — an ADS-B position is + * broadcast unencrypted by the aircraft to anybody with a receiver. What the + * *deployment* knows and the engine does not is provenance, which is why the + * live source is asked first: `TrafficSource.detail` carries the transponder + * address off the wire body and the credit line the feed asks to be shown + * beside its data, and neither is recoverable from the position alone. + * + * The fallback is not a degraded case. It is the zero-config clone, the + * California corridor board and every aircraft the godmode dial invented, and + * for all three the honest answer is a card that says "simulated track" rather + * than a hex address nobody transmitted. `aircraftDetail` defaults `observed` to + * false precisely so that forgetting to say so cannot produce the other claim. */ -function renderCityPicker() { - if (!cityNav) return; - cityNav.replaceChildren(); - const entries = inside - ? OFFICES.map((o) => ({ id: o.id, label: o.label, active: o.id === officeId, status: o.status })) - : CITIES.map((c) => ({ id: c.id, label: c.label, active: c.id === cityId, status: null })); - - for (const entry of entries) { - const b = document.createElement("button"); - // `aria-pressed` rather than a class, because these buttons choose exactly - // one active board. The stylesheet keys off the attribute - // so the visual state and the announced state cannot drift apart. - b.className = entry.status ? "city office-choice" : "city"; - b.type = "button"; - b.setAttribute("aria-pressed", String(entry.active)); - const name = document.createElement("span"); - name.textContent = entry.label; - b.append(name); - if (entry.status) { - const status = document.createElement("span"); - status.className = "office-choice__status"; - status.textContent = entry.status; - b.append(status); - } - b.addEventListener("click", () => { - if (inside) void switchOffice(entry.id); - else switchCity(entry.id); - }); - cityNav.append(b); +function showAircraftDetail(aircraft: Aircraft | null): void { + if (aircraft === null) { + if (detail?.kind === "aircraft") showDetail(null); + return; } + const centre = CITIES.find((c) => c.id === cityId)?.city.center; + const resolved = cityFlights?.detail(aircraft.id) + ?? aircraftDetail(aircraft, { observed: false, ...(centre ? { from: centre } : {}) }); + const card: AircraftDetailInput = { + // The hex when the feed gave one; the source's own id otherwise, which the + // card only ever shows for a track it has already labelled as simulated. + id: resolved.icao24 ?? resolved.id, + callsign: resolved.callsign, + lat: resolved.lat, + lng: resolved.lng, + altitude: resolved.altitudeM, + heading: resolved.headingDeg, + synthetic: !resolved.observed, + attribution: resolved.attribution.length > 0 ? resolved.attribution.join(" · ") : null, + }; + detail = { kind: "aircraft", aircraft: card }; + renderChrome(); +} + +/** The board strip: worlds outside a building, buildings inside one. */ +function boardTabs() { + return inside + ? OFFICES.map((o) => ({ id: o.id, label: o.label, status: o.status })) + : CITIES.map((c) => ({ id: c.id, label: c.label, status: null })); +} + +/** + * Whatever the mode's controller is reporting, in the shape the HUD formats. + * + * Keyed off `playHudKindFor` rather than off a second `switch` on the mode, so + * the two orbit states are excluded once, in the module that also decides the + * card's visibility. `null` whenever the controller has nothing to say — a + * scene that has not built an aircraft cannot report on one — and the HUD is + * hidden rather than blank in that case. + */ +function playTelemetry(): PlayTelemetry | null { + switch (playHudKindFor(controlModeState.mode)) { + case "drive": { + const state = city?.vehicleState(); + if (!state) return null; + return { + kind: "drive", + speedMps: state.speedMps, + roadName: state.roadName, + driveMode: state.mode, + progress: state.progress, + camera: city?.vehicleCamera() ?? "chase", + guardrailContact: state.guardrailContact, + collisionRisk: state.collisionRisk, + }; + } + case "actor": { + const state = city?.actorState(); + if (!state) return null; + return { + kind: "actor", + actor: state.kind, + speedMps: state.speedMps, + altitudeM: Math.max(0, state.y), + distanceM: state.distanceM, + pose: state.kind === "crow" ? state.crowPose : state.mode, + flightEnergy: state.flightEnergy, + displayName: state.identity.displayName, + atAltitudeBound: state.altitudeBoundContact !== "none", + }; + } + case "aircraft": { + const state = city?.aircraftState(); + if (!state) return null; + return { + kind: "aircraft", + speedMps: state.speedMps, + altitudeM: state.altitudeM, + flightMode: state.mode, + batteryWh: state.batteryWh, + stalled: state.stalled, + hardLanding: state.hardLanding, + envelopeContact: state.envelopeContact, + }; + } + case "office-walk": { + const state = office?.walker?.state(); + if (!state) return null; + return { + kind: "office-walk", + officeLabel: officeName(), + distanceM: state.distance, + x: state.position.x, + z: state.position.z, + }; + } + default: + return null; + } +} + +/** The body under control, for the labels that name it. */ +function chromeActor() { + if (inside) { + const walker = office?.walker?.state(); + if (!walker) return {}; + return { kind: walker.actor === "anonymous-dog" ? ("dog" as const) : ("humanoid" as const) }; + } + if (controlModeState.mode === "aircraft") return { flying: true }; + const state = city?.actorState(); + if (!state) return {}; + return { kind: state.kind, flying: state.mode === "flight" }; +} + +/** + * Everybody who is owed a credit for what is currently on screen. + * + * MET Norway and Open-Meteo publish under CC BY 4.0 and the ADS-B feeds ask to + * be named for the positions; all of it arrived, and until the `?` sheet started + * printing it, all of it was read by nobody — a licence obligation plumbed to + * within one line of being met. The weather override is deliberately excluded: + * a sky somebody typed is not MET Norway's sky and must not be attributed to + * them. + */ +function creditLines(): string[] { + const lines: string[] = []; + if (weatherOverride === null) lines.push(...(weatherWatch?.current().attribution ?? [])); + lines.push(...(cityFlights?.attribution() ?? [])); + return lines.filter((line) => line.trim() !== ""); +} + +/** + * Everything the interface needs to know, as one plain object. + * + * This function is the whole of the coupling between the application and its + * chrome. It reads module state and returns data; it writes nothing and it + * decides nothing — every "should this be visible" question in the product is + * answered by `chromeState`, and every answer is applied by `mount`. Adding a + * piece of chrome is a field here, a decision there and a write in the applier, + * in that order, and never a fortieth `element.hidden =` in this file. + */ +function chromeInputs(): ChromeInputs { + const entry = CITIES.find((c) => c.id === cityId); + const selectedOffice = OFFICES.find((o) => o.id === officeId) ?? OFFICES[0]; + const mediaSurfaces = inside && office?.depth === "full" ? office.listMediaSurfaces() : []; + const robotActivity = inside ? office?.robotActivityInfo() ?? null : null; + const views: View[] = inside && office ? office.views : city?.chapters ?? []; + const activeViewId = inside && office ? office.current() : city?.current() ?? null; + + return { + mode: controlModeState.mode, + available: availableModes(), + access: { + tier: access.tier, + signInUrl: access.signInUrl, + subject: access.subject, + displayName: localProfile?.displayName ?? null, + hasProfile: localProfile !== null, + }, + inside, + officeDepth: inside ? office?.depth ?? null : null, + viewport: { + width: window.innerWidth, + height: window.innerHeight, + // The class is set by the first touch this page ever sees and never + // removed, because a laptop with a touchscreen has both and the one the + // person actually used is the one worth believing. + coarsePointer: + document.body.classList.contains("touch-capable") || + window.matchMedia?.("(pointer: coarse)").matches === true, + }, + feeds: { + markers: liveData, + // An override is a sky somebody invented, so it retires the claim for as + // long as it is up: the label is about what is on screen, not about what + // the deployment could have shown. + weather: weatherOverride === null && (weatherWatch?.current().live ?? false), + flights: cityFlights?.live() ?? false, + weatherOverridden: weatherOverride !== null, + sampleOccupancy: presenceIsSample, + credits: creditLines(), + }, + degraded: access.degraded ?? [], + devices: inside ? office?.devices ?? [] : [], + firstVisit, + panelOpen, + planOpen, + + board: { + cityId, + cityLabel: entry?.city.name ?? "", + officeId, + officeLabel: inside ? officeName() : selectedOffice?.label ?? "the studio", + officeStatus: selectedOffice?.status ?? "building", + isCalifornia: cityId === "california", + }, + actor: chromeActor(), + office: { + mediaSurfaceCount: mediaSurfaces.length, + mediaSurfaceActiveCount: mediaSurfaces.filter((surface) => surface.bound).length, + robotDisclosure: robotActivity?.disclosure ?? null, + walkable: office?.walker != null, + }, + cameraLive: webcamCapture?.status() === "active", + presenceVisible: presenceMounted, + boards: boardTabs(), + views: views.map((view) => ({ + id: view.id, + number: view.number ?? null, + shortLabel: view.shortLabel, + ...(view.description === undefined ? {} : { description: view.description }), + })), + activeViewId, + telemetry: playTelemetry(), + detail, + clockLabel, + }; +} + +/** + * Draw the interface. One call, and it replaces forty. + * + * Cheap enough to run from the frame pump — `chromeState` is pure arithmetic and + * string building, and the applier compares before it writes and rebuilds a + * subtree only when that subtree's signature moves — but it is still called on + * *changes* rather than unconditionally, and the pump only joins in while a body + * is under control. See `pumpChrome`. + * + * The two plan widgets are updated here rather than inside the applier because + * neither is chrome: they are canvases the engine draws, holding a live camera, + * and `mount.ts` is deliberately ignorant of THREE. + */ +function renderChrome(): void { + // The one place the mode the app *thinks* it is in is reconciled with the mode + // the scene is actually in. A scene can change it on its own — a chapter that + // is a driving route puts the city into `drive` — and a dock that disagreed + // with the camera was the oldest bug in this file. + if (city) { + const walking = inside && (office?.walker?.active() ?? false); + const actual: ControlMode = inside + ? walking ? "office-walk" : "office-overview" + : city.controlMode(); + if (controlModeState.mode !== actual) { + controlModeState = { mode: actual, revision: controlModeState.revision + 1 }; + } + } + chrome?.apply(chromeState(chromeInputs())); + // Each plan rings the entry its own legend is showing as current. Both are + // updated whichever one is mounted, so the hidden one is already right when it + // comes back rather than correcting itself a frame after it appears. + if (city) minimap?.setChapters(city.chapters, city.current()); + officePlan?.setActiveView(office?.current() ?? null); } /** @@ -1871,10 +2425,11 @@ function renderCityPicker() { * * 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. + * limits, the horizon drop, the light rig, the hardware on the desks and the car + * on the apron. 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. @@ -1885,7 +2440,8 @@ async function switchOffice(id: string) { const previous = officeId; officeId = id; - // The roster, screen session, plan and scene all belong to the building left. + // The roster, screen session, hardware feed, plan and scene all belong to the + // building being left. disposeLoadedOffice(); entering = true; @@ -1910,419 +2466,7 @@ async function switchOffice(id: string) { 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(); - if (!nav || !city) return; - const views: View[] = inside && office ? office.views : city.chapters; - const activeId = inside && office ? office.current() : city.current(); - - nav.replaceChildren(); - views.forEach((view, i) => { - const button = document.createElement("button"); - button.className = "chapter"; - button.type = "button"; - button.setAttribute("aria-pressed", String(view.id === activeId)); - const number = view.number ?? String(i + 1).padStart(2, "0"); - button.innerHTML = `${number}${view.shortLabel}`; - button.addEventListener("click", () => flyToIndex(i)); - nav.append(button); - }); - - const active = views.find((v) => v.id === activeId); - if (blurb) { - blurb.textContent = active?.description ?? ""; - blurb.hidden = !active?.description; - } - const cityLabel = CITIES.find((c) => c.id === cityId)?.city.name ?? ""; - const selectedOffice = OFFICES.find((entry) => entry.id === officeId) ?? OFFICES[0]; - if (title) title.textContent = inside ? officeName() : cityLabel; - if (subtitle) { - subtitle.textContent = inside - ? `Tera · ${selectedOffice?.status ?? "building"} environment` - : "Tera · Lumbridge Simulate"; - } - if (enterButton) { - enterButton.textContent = inside - ? "← Back to the city" - : selectedOffice?.status === "active" - ? `Open ${selectedOffice.label} →` - : `Preview ${selectedOffice?.label ?? "environment"} · building →`; - } - const walking = inside && (office?.walker?.active() ?? false); - const exploring = !inside && (city.actorActive() ?? false); - const flying = !inside && (city.aircraftActive() ?? false); - const actualMode: ControlMode = inside - ? walking ? "office-walk" : "office-overview" - : city.controlMode(); - if (controlModeState.mode !== actualMode) { - controlModeState = { mode: actualMode, revision: controlModeState.revision + 1 }; - } - const availability = availableControlModes(); - for (const button of modeDock?.querySelectorAll("[data-control-mode]") ?? []) { - const mode = button.dataset.controlMode as ControlMode; - button.hidden = mode === "drive" ? !availability.drive - : mode === "actor" ? !availability.actor - : mode === "aircraft" ? !availability.aircraft - : mode === "office-walk" ? !availability.officeWalk - : false; - const pressed = mode === "overview" - ? actualMode === "overview" || actualMode === "office-overview" - : mode === actualMode; - button.setAttribute("aria-pressed", String(pressed)); - } - if (walkButton) { - walkButton.hidden = inside ? office?.walker === null : city.actorState() === null; - walkButton.setAttribute("aria-pressed", String(walking || exploring)); - if (inside && office?.walker) { - const actor = office.walker.state().actor === "anonymous-dog" ? "your dog" : "your humanoid"; - walkButton.textContent = walking ? "Return to overview ↑" : `Walk as ${actor} →`; - } else if (city.actorState()) { - const actor = city.actorState()?.kind === "crow" ? "your crow" : "your humanoid"; - walkButton.textContent = exploring ? "Return to flyover ↑" : `Explore as ${actor} →`; - } - } - if (flyButton) { - flyButton.hidden = inside || cityId !== "california" || city.aircraftState() === null; - flyButton.setAttribute("aria-pressed", String(flying)); - flyButton.textContent = flying ? "Return to flyover ↑" : "Fly the California route →"; - } - if (screensButton) { - const canManage = inside && office?.depth === "full" && (office.listMediaSurfaces().length > 0); - screensButton.hidden = !canManage; - } - renderSource(); - if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel; - if (canvas) { - canvas.setAttribute( - "aria-label", - inside - ? walking - ? `${officeName()}, following your ${office?.walker?.state().actor === "anonymous-dog" ? "dog" : "humanoid"}. Use W A S D to move.` - : `${officeName()}, seen from above. Drag to orbit, scroll to zoom.` - : routeDriveIsActive() - ? `${cityLabel}, following your car on ${city.vehicleState()?.roadName ?? "the selected route"}. Use W A S D to drive.` - : flying - ? `${cityLabel}, following your electric aircraft. Use W A S D to fly or P to resume assisted flight.` - : exploring - ? `${cityLabel}, following your ${city.actorState()?.kind ?? "actor"}. Use W A S D to move, Q and E for altitude, and G to glide.` - : `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`, - ); - } - // Each plan rings the entry its own legend is showing as current. Both are - // updated whichever one is mounted, so the hidden one is already right when it - // comes back rather than correcting itself a frame after it appears. - minimap?.setChapters(city.chapters, city.current()); - officePlan?.setActiveView(office?.current() ?? null); - renderOfficeBadge(); - if (driveControls) driveControls.hidden = !routeDriveIsActive(); - if (walkControls) { - walkControls.hidden = !(walking || exploring || flying); - walkControls.setAttribute( - "aria-label", - flying - ? "Aircraft flight controls" - : exploring && city.actorState()?.kind === "crow" - ? "Crow flight controls" - : "Walking controls", - ); - } - const touchModeActive = routeDriveIsActive() || walking || exploring || flying; - if (touchPlayControls) { - touchPlayControls.hidden = !touchModeActive; - touchPlayControls.setAttribute("aria-label", `${actualMode.replace("office-", "")} touch controls`); - } - const crowPlaying = exploring && city.actorState()?.kind === "crow" && - city.actorState()?.mode === "flight"; - if (touchPrimary) { - touchPrimary.hidden = walking; - touchPrimary.dataset.playControl = crowPlaying ? "ascend" : "primary"; - touchPrimary.textContent = routeDriveIsActive() ? "Handbrake" - : flying ? "Throttle" - : crowPlaying ? "Climb" - : "Sprint"; - } - if (touchSecondary) { - touchSecondary.hidden = !crowPlaying; - touchSecondary.dataset.playControl = "secondary"; - touchSecondary.textContent = "Glide"; - } - if (touchPitchUp) touchPitchUp.hidden = !crowPlaying; - if (touchPitchDown) touchPitchDown.hidden = !crowPlaying; - if (touchAssist) touchAssist.hidden = !(routeDriveIsActive() || flying); - if (touchReset) touchReset.hidden = !(routeDriveIsActive() || flying); - if (touchCamera) touchCamera.hidden = !routeDriveIsActive(); - if (touchMap) touchMap.setAttribute("aria-pressed", String(planOpen)); - for (const control of walkControls?.querySelectorAll("[data-walk-key]") ?? []) { - control.textContent = flying - ? control.dataset.aircraftLabel ?? control.textContent - : control.dataset.walkLabel ?? control.textContent; - } - for (const control of walkControls?.querySelectorAll(".flight-only") ?? []) { - control.hidden = inside || (!flying && city.actorState()?.kind !== "crow"); - } - for (const control of walkControls?.querySelectorAll(".aircraft-only") ?? []) { - control.hidden = !flying; - } - for (const control of walkControls?.querySelectorAll(".crow-only") ?? []) { - control.hidden = inside || flying || city.actorState()?.kind !== "crow"; - } - if (driveHint) driveHint.hidden = inside || cityId !== "california" || flying; - if (walkHint) { - walkHint.hidden = !(inside || city.actorState() || city.aircraftState()); - walkHint.textContent = flying - ? "WASD fly · P assisted · R reset" - : inside - ? "V walk · WASD move" - : "V explore · WASD · Q/E altitude · I/K pitch · G glide"; - } -} - -function renderPlayHud(): void { - if (!playHud || !playHudMode || !playHudPrimary || !playHudStatus || !city) return; - const mode = controlModeState.mode; - playHud.hidden = mode === "overview" || mode === "office-overview"; - playHudStatus.classList.remove("warning"); - if (mode === "drive") { - const state = city.vehicleState(); - if (!state) return; - playHudMode.textContent = "Drive"; - playHudPrimary.textContent = `${Math.round(state.speedMps * 2.23694)} mph · ${state.roadName}`; - playHudStatus.textContent = `${state.mode} · ${Math.round(state.progress * 100)}% · ${city.vehicleCamera() ?? "chase"}`; - playHudStatus.classList.toggle("warning", state.guardrailContact || state.collisionRisk > 0.55); - } else if (mode === "actor") { - const state = city.actorState(); - if (!state) return; - playHudMode.textContent = state.kind === "crow" ? "Crow" : "Explore"; - playHudPrimary.textContent = state.kind === "crow" - ? `${state.speedMps.toFixed(1)} m/s · ${Math.max(0, state.y).toFixed(0)} m alt` - : `${state.speedMps.toFixed(1)} m/s · ${state.distanceM.toFixed(0)} m travelled`; - playHudStatus.textContent = state.kind === "crow" - ? `${state.crowPose} · ${Math.round(state.flightEnergy * 100)}% energy` - : `${state.mode} · ${state.identity.displayName}`; - playHudStatus.classList.toggle("warning", state.altitudeBoundContact !== "none"); - } else if (mode === "aircraft") { - const state = city.aircraftState(); - if (!state) return; - playHudMode.textContent = "Flight"; - playHudPrimary.textContent = `${Math.round(state.speedMps * 1.94384)} kt · ${Math.round(state.altitudeM).toLocaleString()} m`; - playHudStatus.textContent = `${state.mode} · ${Math.round(state.batteryWh)} Wh${state.stalled ? " · STALL" : ""}`; - playHudStatus.classList.toggle("warning", state.stalled || state.hardLanding || state.envelopeContact); - } else if (mode === "office-walk") { - const state = office?.walker?.state(); - if (!state) return; - playHudMode.textContent = "Office"; - playHudPrimary.textContent = `${officeName()} · ${state.distance.toFixed(0)} m walked`; - playHudStatus.textContent = `${state.position.x.toFixed(1)}, ${state.position.z.toFixed(1)} m`; - } -} - -/** - * The corner label, which now names the parts rather than claiming the whole. - * - * Only the *positive* case gets a permanent label. This used to read "sample - * data · fabricated, not real companies" on every frame of every load, which is - * the overwhelmingly common case — no deployment has a markers source wired by - * default — so the disclosure was on screen approximately always and had become - * furniture. A caption nobody reads is not disclosure, it is a watermark. The - * fact still has to be somewhere on the same screen as the map, so it is stated - * on the boot card everyone passes through and again in the `?` card, one - * keypress away and permanently reachable. - * - * What is left is the informative signal, and it is three signals rather than - * one. The markers, the weather and the traffic arrive from three different - * places and every combination of them is a deployment that exists; a single - * flag has to pick one to be about and then lie about the other two. The - * particular lie this closes is "live data" printed over invented companies - * because a weather station answered — which is precisely the claim the `live` - * flag was introduced to prevent. `describeLiveness` in `adapters/http.ts` owns - * the wording; all three live is the only case that still says "live data". - * - * Called from `renderLegend` and once a second from the frame pump, because the - * feeds settle after the legend has been drawn. - */ -function renderSource() { - if (!source) return; - /** - * The office's provenance line, which outranks the city's. - * - * It used to live on `#office-badge` inside `#panel`, and on a phone `#panel` - * is a bottom sheet that starts closed — so the one sentence standing between - * twenty-five invented people at real desks and a screenshot presented as a - * staff list was behind a hamburger, on the device most likely to take the - * screenshot. This slot is the right home for it anyway and not merely a - * visible one: `#source` is where this page already says what on screen is and - * is not real, it is fixed and always drawn, and the phone stylesheet calls it - * "the one caption that is never allowed to be dropped for space". - * - * It replaces the city's label rather than joining it, because while you are - * standing in the office the markers, the sky and the traffic are facts about - * a board behind you — and "live data" printed over invented colleagues is the - * exact species of lie the liveness wording was rewritten to stop telling. - */ - const sample = inside && presenceIsSample; - // A class rather than an inline style, so the stylesheet keeps the decision - // about how a phone lays this out and this keeps the decision about what it - // says. The rule it turns on is the one that stops the sentence being - // ellipsised down to the half that reads as reassuring. - document.body.classList.toggle("sample-occupancy", sample); - if (sample) { - const caption = "sample occupancy · these people are invented"; - if (source.textContent !== caption) source.textContent = caption; - source.hidden = false; - return; - } - const label = describeLiveness({ - markers: liveData, - // An override is a sky somebody invented, so it retires the claim for as - // long as it is up — the label is about what is on screen, not about what - // the deployment could have shown. - weather: weatherOverride === null && (weatherWatch?.current().live ?? false), - flights: cityFlights?.live() ?? false, - }); - if (source.textContent !== label) source.textContent = label; - source.hidden = label === ""; - /** - * The green. `.source.live` in `index.html` is the whole visual difference - * between this line and the rest of the chrome, and the rewrite that replaced - * `source.className = "source live"` with a `textContent`/`hidden` pair - * dropped it — so every live label rendered at `--ink-3`, the same muted grey - * as a key hint, and the stylesheet rule could no longer match anything. This - * label is only ever on screen when it has something to say; the colour is - * how it says it is worth reading. - */ - source.classList.toggle("live", label !== ""); - renderCredits(); -} - -/** - * Who to thank for what is on screen, in the `?` card. - * - * MET Norway and Open-Meteo publish under CC BY 4.0 and the server emits the - * credit line each of them asks for — `server/README.md` says in as many words - * that the consumer is expected to display it — and adsb.lol asks to be named - * for the positions. All of it arrived, was parsed into `WeatherFeed.attribution` - * and `FlightsBody.attribution`, and was then read by nobody: a licence - * obligation plumbed to within one line of being met. - * - * It goes in the `?` card rather than on the `#source` line, and that is a - * choice rather than convenience. The corner label is one short phrase and on a - * phone it is explicitly clamped to a single ellipsised line, so a licence - * sentence appended to it would be *truncated* — the one outcome worse than - * putting it a keypress away. The `?` card is reachable from every state the - * app can be in, on both layouts, and already carries the sentence about the - * markers being fabricated; the provenance of the map belongs in one place. - * - * Empty when nothing live is on screen, because a credit for data nobody is - * looking at is noise, and because the zero-config build owes nobody anything. - */ -function renderCredits() { - if (!credits) return; - const lines: string[] = []; - // The weather override is somebody's invention; it is not MET Norway's sky - // and must not be attributed to them. - if (weatherOverride === null) lines.push(...(weatherWatch?.current().attribution ?? [])); - lines.push(...(cityFlights?.attribution() ?? [])); - const unique = [...new Set(lines.filter((line) => line !== ""))]; - credits.textContent = unique.join(" · "); - credits.hidden = unique.length === 0; -} - -/** - * The one thing a public visitor is actually missing, said in the place where - * they would notice it missing. - * - * An empty office with no explanation reads as a bug — a floor that failed to - * load — and the fix for that is a sentence, not a disabled button. The - * sign-in link is offered *beside* the office rather than in front of it, so it - * is an upgrade and never a toll gate. - */ -function renderOfficeBadge() { - if (!officeBadge) return; - const publicOffice = inside && office !== null && office.depth === "public"; - const mediaSurfaces = inside && office !== null && office.depth === "full" - ? office.listMediaSurfaces() - : []; - const robotActivity = inside && office !== null ? office.robotActivityInfo() : null; - // The fabricated-occupancy caption used to be here too and is now on - // `#source` — see `renderSource`. This badge keeps the message that is a call - // to action rather than a disclosure, because that one belongs beside the - // office controls and survives being missed; the other one does not. - officeBadge.hidden = !publicOffice && mediaSurfaces.length === 0 && robotActivity === null; - if (!publicOffice) { - if (mediaSurfaces.length === 0) { - if (robotActivity) officeBadge.textContent = robotActivity.disclosure; - return; - } - const noun = mediaSurfaces.length === 1 ? "screen" : "screens"; - const active = mediaSurfaces.filter((surface) => surface.bound).length; - const media = active > 0 - ? `${active} of ${mediaSurfaces.length} ${noun} active · stop control in Office screens.` - : `${mediaSurfaces.length} ${noun} ready · media stays off until you opt in.`; - officeBadge.textContent = robotActivity ? `${media} ${robotActivity.disclosure}` : media; - return; - } - officeBadge.replaceChildren( - document.createTextNode("Public view — the building, not the people. "), - ); - if (access.signInUrl !== null) { - const link = document.createElement("a"); - link.href = access.signInUrl; - link.textContent = "Sign in for the live floor"; - officeBadge.append(link, document.createTextNode(".")); - } else { - officeBadge.append(document.createTextNode("Sign in to see who's in.")); - } - if (robotActivity) officeBadge.append(document.createTextNode(` ${robotActivity.disclosure}`)); -} - -/** - * Who the site thinks you are, in the corner, always. Three words and a name. - * - * It is here rather than buried in a menu because every other difference on - * this page — an empty office, sample markers, no godmode tab — is a - * *silence*, and a silence you cannot attribute is indistinguishable from a - * fault. This is the line that tells you which of the two you are looking at. - */ -function renderTierBadge() { - if (!tierBadge) return; - tierBadge.className = `card tier ${access.tier}`; - const label = document.createElement("span"); - /** - * The label names what you *get*, not who you are, and that is deliberate. - * "Signed in" was the first draft and it is a lie in the commonest case: - * a clean clone with no API at all resolves to `member`, and telling someone - * they are signed in to a server that does not exist is the sort of small - * dishonesty that makes the rest of the interface untrustworthy. "Full view" - * is true whether the tier came from a session or from there being nothing to - * have a session with; the subject, when there is one, says the rest. - */ - label.textContent = - access.tier === "god" ? "Godmode" : access.tier === "member" ? "Full view" : "Public view"; - tierBadge.replaceChildren(label); - if (access.subject !== null) { - const who = document.createElement("span"); - who.className = "who"; - who.textContent = localProfile?.displayName ?? access.subject; - tierBadge.append(who); - if (localProfile) { - const customize = document.createElement("button"); - customize.type = "button"; - customize.className = "profile-trigger"; - customize.textContent = "Character"; - customize.addEventListener("click", openProfileEditor); - tierBadge.append(customize); - } - } else if (access.signInUrl !== null) { - const link = document.createElement("a"); - link.href = access.signInUrl; - link.textContent = "Sign in"; - tierBadge.append(link); - } - tierBadge.hidden = false; + renderChrome(); } // ---- Hosted realtime presence -------------------------------------------- @@ -2560,7 +2704,9 @@ async function initializeRealtimePresence(): Promise { // after the page has already run its terminal cleanup. if (!realtimePageActive || access.subject === null || realtimeClient) return; presenceHost.hidden = false; - document.body.classList.add("presence-on"); + // The class that makes room for it is `chromeState`'s to add, from + // `presenceVisible` — one place decides what the top-right column contains. + presenceMounted = true; presenceIndicator = createPresenceIndicator({ container: presenceHost, onRetry: () => moveRealtimePresence(), @@ -2594,6 +2740,11 @@ window.addEventListener("pagehide", () => { realtimeClient = null; presenceIndicator?.dispose(); presenceIndicator = null; + // The one thing on this page that owns GPU memory neither scene created. It + // is disposed by whoever owns the `Stage`, which is this file, and never by a + // scene — the two kinds of environment are shared between every city and every + // office that ever existed on this page. + environment.dispose(); }); function applyProfilePreview(profile: LocalProfile): void { @@ -2725,7 +2876,7 @@ function ensureProfileEditor(): ProfileEditor | null { }); city?.setActorIdentity(signedInActorIdentity(profile)); applyProfilePreview(profile); - renderTierBadge(); + renderChrome(); profileOverlay.hidden = true; }, onCancel() { @@ -2769,7 +2920,7 @@ function screenBinding(surface: MediaSurfaceDescriptor) { function remotePanelStatus(screenId: string, status: OfficeScreenRemoteStatus): void { officeScreenPanel?.setRemoteStatus(screenId, status); - renderOfficeBadge(); + renderChrome(); } function isRemoteAuthorizationError(error: unknown): boolean { @@ -2949,7 +3100,7 @@ function stopLocalScreenShare(): void { shared.video.pause(); shared.video.srcObject = null; officeScreenPanel?.update(office?.listMediaSurfaces() ?? []); - renderOfficeBadge(); + renderChrome(); } function disposeOfficeScreenUi(): void { @@ -3015,7 +3166,7 @@ async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise stopLocalScreenShare(), { once: true }); officeScreenPanel?.update(office.listMediaSurfaces()); - renderOfficeBadge(); + renderChrome(); showDetail(`Sharing locally to ${surface.screenId}. Use Office screens to stop.`); // Anonymous/self-host-only behavior ends here exactly as before. The // transport chunk is fetched only for a signed-in explicit share. @@ -3085,7 +3236,8 @@ function openOfficeScreens(): void { panel.open(); } -screensButton?.addEventListener("click", openOfficeScreens); +// The `Office screens →` button is bound by `mount`; this is the overlay's own +// backdrop, which is not chrome and stays here with the panel it dismisses. screensOverlay?.addEventListener("click", (event) => { if (event.target !== screensOverlay) return; officeScreenPanel?.close(); @@ -3106,7 +3258,7 @@ function flyToIndex(index: number) { if (inside && office) { requestControlMode("office-overview"); office.flyTo(view.id); - renderLegend(); + renderChrome(); } else { const destination = cityId === "california" ? CALIFORNIA_DESTINATIONS.get(view.id) : undefined; @@ -3120,16 +3272,13 @@ function flyToIndex(index: number) { if (view.id === "la-sf-us-101" || view.id === "la-sf-i-5") { city?.flyTo(view.id); requestControlMode("drive"); - if (window.innerWidth <= 600) { - panelOpen = false; - applyPanel(); - } - renderLegend(); + closePanelForPlay(); + renderChrome(); return; } requestControlMode("overview"); city?.flyTo(view.id); - renderLegend(); + renderChrome(); } } @@ -3213,83 +3362,52 @@ async function toggleOffice() { return; } entering = true; - /** - * Say so on the button before anything else happens. - * - * The boot card comes up too, but it comes up on the *next* frame at the - * earliest, and on a slow connection the chunk is the long pole rather than - * the build. A door that does nothing visible for half a second gets clicked - * again; a door that says "Opening…" gets waited for. - */ - if (enterButton) { - enterButton.textContent = "Opening the office…"; - enterButton.setAttribute("aria-busy", "true"); - } try { + /* + * The boot card is the whole of the "something is happening" story now, and + * it is enough: `building()` puts it up before the fetch and takes it down + * only once a frame of the new scene is on the glass. The old arrangement + * also wrote "Opening the office…" onto the door itself, which meant this + * file reaching into a label `mount.ts` now owns to say something the card + * in front of it was already saying. + */ await building("Fetching the office…", () => enterOffice()); } finally { entering = false; - enterButton?.removeAttribute("aria-busy"); - // `renderLegend` writes the real label whichever way it went — "← Back to - // the city" if we are in, the door again if the fetch failed. - renderLegend(); + // Writes the real label whichever way it went — "← Back to the city" if we + // are in, the door again if the chunk fetch failed. + renderChrome(); } } -enterButton?.addEventListener("click", () => void toggleOffice()); - /** Switch between the authored dollhouse camera and the local possessed actor. */ function toggleOfficeWalk(): boolean { if (!inside) { if (!city?.actorState()) return false; const active = city.controlMode() !== "actor"; requestControlMode(active ? "actor" : "overview"); - if (active && window.innerWidth <= 600) { - panelOpen = false; - applyPanel(); - } - renderLegend(); + if (active) closePanelForPlay(); + renderChrome(); return true; } const walker = office?.walker; if (!walker) return false; const active = !walker.active(); requestControlMode(active ? "office-walk" : "office-overview"); - if (active && window.innerWidth <= 600) { - panelOpen = false; - applyPanel(); - } - renderLegend(); + if (active) closePanelForPlay(); + renderChrome(); return true; } -walkButton?.addEventListener("click", () => toggleOfficeWalk()); - function toggleAircraft(): boolean { if (inside || cityId !== "california" || !city?.aircraftState()) return false; const active = city.controlMode() !== "aircraft"; requestControlMode(active ? "aircraft" : "overview"); - if (active && window.innerWidth <= 600) { - panelOpen = false; - applyPanel(); - } - renderLegend(); + if (active) closePanelForPlay(); + renderChrome(); return true; } -flyButton?.addEventListener("click", () => toggleAircraft()); - -for (const button of modeDock?.querySelectorAll("[data-control-mode]") ?? []) { - button.addEventListener("click", () => { - const mode = button.dataset.controlMode as ControlMode; - requestControlMode(mode); - if (mode !== "overview" && mode !== "office-overview" && window.innerWidth <= 600) { - panelOpen = false; - applyPanel(); - } - }); -} - /** * Clicking a building on the city walks into it. * @@ -3313,103 +3431,76 @@ canvas.addEventListener("click", () => { officeId = id; void building(`Opening ${marker.label}…`, () => enterOffice()).finally(() => { entering = false; - renderLegend(); + renderChrome(); }); }); // ---- Panels, plan and overlays ---------------------------------------------- /** - * Two pieces of chrome are a *user* decision rather than a media query, and the - * distinction matters: a media query that hides the plan below 600px also makes - * `M` do nothing there, which is the width where a plan view is most useful and - * least affordable. So the width only seeds the initial state, and the moment - * someone presses the key the viewport stops having an opinion. + * Get the left column out of the way when a body takes over. + * + * Only on a phone, where `#panel` is a bottom sheet covering a third of the + * screen: possessing an actor and then being unable to see it is not a trade + * anybody would make, and the sheet has a toggle to bring it back. Above that + * width the column is furniture beside the map rather than on top of it, and + * closing it would be taking away a thing that costs nothing. + * + * `panelChosen` is set for the same reason a keypress sets it: once anything has + * had an opinion about this panel, the viewport stops having one. + * + * The plan view is deliberately **not** closed here any more. It used to be, and + * the reason was that on a phone it was a bottom sheet in exactly the place the + * joystick now occupies; `ui/chromeState.ts` moved it to a glanceable corner and + * closing it is now taking the map away at the moment somebody is navigating. */ -let panelOpen = window.innerWidth > 900; -let planOpen = window.innerWidth > 600; -let planChosen = false; - -function applyPanel() { - document.body.classList.toggle("panel-closed", !panelOpen); - panelToggle?.setAttribute("aria-expanded", String(panelOpen)); -} - -function applyPlan() { - document.body.classList.toggle("minimap-off", !planOpen); - planToggle?.setAttribute("aria-pressed", String(planOpen)); - touchMap?.setAttribute("aria-pressed", String(planOpen)); +function closePanelForPlay(): void { + if (window.innerWidth > 600) return; + panelOpen = false; + panelChosen = true; } /** * One body, two ways in, and the second one is the point. * * This lived inline in the `M` branch of the keydown handler and was reachable - * from nowhere else, which made the plan view **unreachable on any touch - * device**: `planOpen` is seeded `window.innerWidth > 600`, so a phone starts - * with it off, and a phone has no `M`. Every visible control at 390px was - * enumerated and none of them could turn it on. `index.html` has carried a - * designed phone layout for `.corner` — a bottom sheet above the rail, at - * `min(38dvh, 18rem)` — that no visitor to that layout could ever see, under a - * comment saying it "costs nothing until it is asked for". There was no way to - * ask. `#plan-toggle` is that way, shown wherever the pointer is coarse. - * - * So the key and the button call this, and `planChosen` is set by both for the - * same reason it always was: once somebody has an opinion, the viewport stops - * having one. + * from nowhere else, which made the plan view unreachable on any touch device: + * a phone starts without `M` and had no button. `#plan-toggle` and the touch + * pad's Map button are that way in, and both arrive here through `mount`'s + * `onTogglePlan`, so the key and the buttons cannot disagree about the state. */ -function togglePlan() { - planOpen = !planOpen; - planChosen = true; - applyPlan(); +function togglePlan(): void { + setPlanOpen(!planOpen); } -planToggle?.addEventListener("click", () => togglePlan()); +function setPlanOpen(open: boolean): void { + planOpen = open; + planChosen = true; + renderChrome(); +} -panelToggle?.addEventListener("click", () => { - panelOpen = !panelOpen; - applyPanel(); -}); +function setPanelOpen(open: boolean): void { + panelOpen = open; + panelChosen = true; + renderChrome(); +} /** - * The scrim behind the phone's panel sheet. It is `display: none` above 600px, - * so this listener is only ever reachable where the sheet exists. + * The viewport crossed a published breakpoint — a rotation, a window drag, a + * tablet turning over. + * + * Both seeds are re-run, and that "both" is the fix: `resize` used to re-seed + * the plan and, for no stated reason, not the panel, so rotating a tablet from + * portrait to landscape left the sheet closed over a layout wide enough that the + * toggle which would reopen it is not drawn. Either is skipped once its own + * control has been touched. */ -document.querySelector("#scrim")?.addEventListener("click", () => { - panelOpen = false; - applyPanel(); -}); - -document.querySelector("#detail-close")?.addEventListener("click", () => { - showDetail(null); -}); - -window.addEventListener("resize", () => { - if (!planChosen) { - planOpen = window.innerWidth > 600; - applyPlan(); - } -}); - -function openShortcuts() { - if (!shortcutsCard || !shortcutsCard.hidden) return; - shortcutsCard.hidden = false; - document.querySelector("#shortcuts-close")?.focus(); +function adoptLayout(_layout: ChromeLayout): void { + if (!panelChosen) panelOpen = seedPanelOpen(window.innerWidth); + if (!planChosen) planOpen = seedPlanOpen(window.innerWidth); + renderChrome(); } -function closeShortcuts() { - if (!shortcutsCard || shortcutsCard.hidden) return; - shortcutsCard.hidden = true; - helpButton?.focus(); -} - -helpButton?.addEventListener("click", () => openShortcuts()); -document.querySelector("#shortcuts-close")?.addEventListener("click", closeShortcuts); -shortcutsCard?.addEventListener("click", (event) => { - // The backdrop, not the sheet. Clicking the card itself must not close it. - if (event.target === shortcutsCard) closeShortcuts(); -}); - function routeDriveIsActive(): boolean { const state = !inside ? city?.vehicleState() : null; return state !== null && state !== undefined && city?.controlMode() === "drive" && @@ -3476,160 +3567,6 @@ function publishPlayActions(): boolean { return false; } -function controlForKey(key: string): PlayDigitalControl | null { - switch (key === " " ? key : key.toLowerCase()) { - case "w": return "forward"; - case "s": return "backward"; - case "a": return "left"; - case "d": return "right"; - case "q": return "descend"; - case "e": return "ascend"; - case "i": return "pitch-up"; - case "k": return "pitch-down"; - case " ": return "primary"; - case "g": return "secondary"; - default: return null; - } -} - -function bindPointerControls( - container: HTMLElement | null, - selector: "[data-drive-key]" | "[data-walk-key]", - attribute: "driveKey" | "walkKey", -): void { - for (const button of container?.querySelectorAll(selector) ?? []) { - const key = button.dataset[attribute]; - const control = key === undefined ? null : controlForKey(key); - if (!control) continue; - const release = (event: PointerEvent) => { - playInput.clearSource(`pointer:${event.pointerId}`); - button.setAttribute("aria-pressed", "false"); - publishPlayActions(); - event.preventDefault(); - }; - button.addEventListener("pointerdown", (event) => { - if (event.pointerType === "touch") document.body.classList.add("touch-capable"); - button.setPointerCapture(event.pointerId); - playInput.setDigital(`pointer:${event.pointerId}`, control, true); - button.setAttribute("aria-pressed", "true"); - publishPlayActions(); - event.preventDefault(); - }); - button.addEventListener("pointerup", release); - button.addEventListener("pointercancel", release); - button.addEventListener("lostpointercapture", release); - } -} - -bindPointerControls(driveControls, "[data-drive-key]", "driveKey"); -bindPointerControls(walkControls, "[data-walk-key]", "walkKey"); - -const pointerStick = new PointerStick(); - -function paintPlayStick(axes = { moveX: 0, moveY: 0 }): void { - if (!playStick) return; - const travel = Math.max(0, (playStick.getBoundingClientRect().width - 52) / 2); - playStick.style.setProperty("--stick-x", `${axes.moveX * travel}px`); - playStick.style.setProperty("--stick-y", `${-axes.moveY * travel}px`); -} - -function resetPlayStick(): void { - const pointerId = pointerStick.activePointer(); - if (pointerId !== null) { - pointerStick.end(pointerId); - playInput.clearSource(`stick:${pointerId}`); - } - playStick?.classList.remove("active"); - paintPlayStick(); -} - -if (playStick) { - playStick.addEventListener("pointerdown", (event) => { - const axes = pointerStick.begin(event.pointerId, event.clientX, event.clientY, playStick.getBoundingClientRect()); - if (!axes) return; - if (event.pointerType === "touch") document.body.classList.add("touch-capable"); - playStick.setPointerCapture(event.pointerId); - playStick.classList.add("active"); - playInput.setAxes(`stick:${event.pointerId}`, axes); - paintPlayStick(axes); - publishPlayActions(); - event.preventDefault(); - }); - playStick.addEventListener("pointermove", (event) => { - const axes = pointerStick.move(event.pointerId, event.clientX, event.clientY); - if (!axes) return; - playInput.setAxes(`stick:${event.pointerId}`, axes); - paintPlayStick(axes); - publishPlayActions(); - event.preventDefault(); - }); - const finishStick = (event: PointerEvent, cancelled: boolean) => { - const finished = cancelled ? pointerStick.cancel(event.pointerId) : pointerStick.end(event.pointerId); - if (!finished) return; - playInput.clearSource(`stick:${event.pointerId}`); - playStick.classList.remove("active"); - paintPlayStick(); - publishPlayActions(); - event.preventDefault(); - }; - playStick.addEventListener("pointerup", (event) => finishStick(event, false)); - playStick.addEventListener("pointercancel", (event) => finishStick(event, true)); - playStick.addEventListener("lostpointercapture", (event) => finishStick(event, true)); -} - -function digitalControlFromData(raw: string | undefined): PlayDigitalControl | null { - if ( - raw === "forward" || raw === "backward" || raw === "left" || raw === "right" || - raw === "ascend" || raw === "descend" || raw === "pitch-up" || raw === "pitch-down" || - raw === "primary" || raw === "secondary" - ) return raw; - return null; -} - -for (const button of touchPlayControls?.querySelectorAll("[data-play-control]") ?? []) { - const held = new Map(); - button.addEventListener("pointerdown", (event) => { - const control = digitalControlFromData(button.dataset.playControl); - if (!control) return; - if (event.pointerType === "touch") document.body.classList.add("touch-capable"); - button.setPointerCapture(event.pointerId); - held.set(event.pointerId, control); - playInput.setDigital(`action:${event.pointerId}`, control, true); - button.setAttribute("aria-pressed", "true"); - publishPlayActions(); - event.preventDefault(); - }); - const release = (event: PointerEvent) => { - if (!held.delete(event.pointerId)) return; - playInput.clearSource(`action:${event.pointerId}`); - button.setAttribute("aria-pressed", String(held.size > 0)); - publishPlayActions(); - event.preventDefault(); - }; - button.addEventListener("pointerup", release); - button.addEventListener("pointercancel", release); - button.addEventListener("lostpointercapture", release); -} - -touchAssist?.addEventListener("click", () => { playInput.request("assist"); publishPlayActions(); }); -touchReset?.addEventListener("click", () => { playInput.request("reset"); publishPlayActions(); }); -touchCamera?.addEventListener("click", () => { playInput.request("camera"); publishPlayActions(); }); -touchMap?.addEventListener("click", () => { - togglePlan(); - touchMap.setAttribute("aria-pressed", String(planOpen)); -}); - -driveControls?.querySelector("[data-drive-action='assist']") - ?.addEventListener("click", () => { playInput.request("assist"); publishPlayActions(); }); -driveControls?.querySelector("[data-drive-action='reset']") - ?.addEventListener("click", () => { playInput.request("reset"); publishPlayActions(); }); -driveControls?.querySelector("[data-drive-action='camera']") - ?.addEventListener("click", () => { playInput.request("camera"); publishPlayActions(); }); -walkControls?.querySelector("[data-aircraft-action='assist']") - ?.addEventListener("click", () => { playInput.request("assist"); publishPlayActions(); }); -walkControls?.querySelector("[data-aircraft-action='reset']") - ?.addEventListener("click", () => { playInput.request("reset"); publishPlayActions(); }); - window.addEventListener("keyup", (event) => { const control = controlForKey(event.key); if (!control) return; @@ -3675,13 +3612,20 @@ window.addEventListener("pointerdown", (event) => { }, { capture: true }); /** - * Keyboard access to everything the mouse can reach. + * Keyboard access to everything the pointer can reach. * * Bound to `window` rather than to the canvas, because the canvas is only * focusable by accident and a shortcut that stops working when you tab to the - * legend is worse than no shortcut. The guard is the usual one: a keystroke - * that lands in a text field or on the plan view's own arrow-key handler - * belongs to that control, not to this. + * legend is worse than no shortcut. The guard is the usual one: a keystroke that + * lands in a text field or on the plan view's own arrow-key handler belongs to + * that control, not to this. + * + * Every binding below is resolved through `ui/shortcuts.ts`, which is also what + * renders the `?` sheet — so a key that works and a key that is documented are + * now the same list. They were not: the sheet had no row for `G` while `g` was + * bound to glide *and* inserted at runtime meaning godmode, and Space was + * documented as "Handbrake while driving" while being the generic primary in + * every mode. */ window.addEventListener("keydown", (event) => { if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return; @@ -3696,7 +3640,10 @@ window.addEventListener("keydown", (event) => { } if (event.key === "Escape") { - if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts(); + // Through the applier rather than at `#shortcuts.hidden`, so the focus + // return and the `onShortcuts` callback stay correct whichever way the + // sheet was opened. + if (chrome?.shortcutsOpen() === true) chrome.closeShortcuts(); else if (inside) { if (!leaveToCity()) leaveOffice(); } @@ -3704,8 +3651,8 @@ window.addEventListener("keydown", (event) => { return; } if (event.key === "?") { - if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts(); - else openShortcuts(); + if (chrome?.shortcutsOpen() === true) chrome.closeShortcuts(); + else chrome?.openShortcuts(); event.preventDefault(); return; } @@ -3730,23 +3677,28 @@ window.addEventListener("keydown", (event) => { return; } } - if (lower === "p" && (routeDriveIsActive() || city?.controlMode() === "aircraft")) { - playInput.request("assist"); - publishPlayActions(); - event.preventDefault(); - return; - } - if (lower === "r" && (routeDriveIsActive() || city?.controlMode() === "aircraft")) { - playInput.request("reset"); - publishPlayActions(); - event.preventDefault(); - return; - } - if (lower === "c" && routeDriveIsActive()) { - playInput.request("camera"); - publishPlayActions(); - event.preventDefault(); - return; + /** + * The three one-shot requests — assist, reset, camera — from the same table + * the held controls come from. + * + * This was three hand-written `lower === "p"`-shaped branches, each carrying + * its own copy of "which modes is this meaningful in". The mode test is still + * here, because it is a fact about the simulation rather than about the + * keyboard: only a drive has a camera to swap and only a drive or a flight has + * an assist to resume, and a `P` that silently queued a request in overview + * would be a key that does nothing while claiming to do something. + */ + const edge = edgeForKey(event.key); + if (edge !== null) { + const driving = routeDriveIsActive(); + const flying = city?.controlMode() === "aircraft"; + const meaningful = edge === "camera" ? driving : driving || flying; + if (meaningful) { + playInput.request(edge); + publishPlayActions(); + event.preventDefault(); + return; + } } if (lower === "v" && toggleOfficeWalk()) { event.preventDefault(); @@ -3762,22 +3714,17 @@ window.addEventListener("keydown", (event) => { // ---- Time ------------------------------------------------------------------- /** - * The `#hour` slider and its `now` button are gone, replaced rather than kept. + * One writer for one override. * - * They were a second writer for one override, and the weaker of the two: - * `capabilitiesFor` hands `timeControl` and `debug` to exactly the same tier, so - * there was never an audience for the simple case — the only person who could - * see the scrubber was the same person who can open the godmode panel. Keeping - * both meant the slider wrote an hour onto *today* and silently discarded - * whatever date the panel had set, which is a bug with no upside. + * The `#hour` slider is gone: `capabilitiesFor` hands `timeControl` and `debug` + * to the same tier, so the only person who could see the scrubber was the person + * who can open the godmode panel — and it wrote an hour onto *today*, silently + * discarding whatever date the panel had set. * - * `#clock` stays exactly as it was, and stays visible to everyone: a map that - * will not say what time it is showing is worse than one you cannot scrub. - * - * What is left of the gate is one line, and it is belt and braces — the only - * writer of `instantOverride` is the panel, and the panel is not constructed - * unless `can.debug`. It stays because "no control" and "no override" are two - * different facts, and the second is the one the renderer depends on. + * The clock line stays visible to everyone; a map that will not say what time it + * is showing is worse than one you cannot scrub. This gate is belt and braces: + * "no control" and "no override" are two different facts, and the renderer + * depends on the second. */ function applyTimeControl() { if (!access.can.timeControl) instantOverride = null; @@ -3790,17 +3737,10 @@ function applyTimeControl() { * who has them. * * **Constructed, not hidden.** Everything in this section is behind - * `access.can.debug`, and for a member or an anonymous visitor the result is - * not a panel with `display: none` on it — it is no element, no `