diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index eb73737..0d6b1ce 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -136,14 +136,41 @@ So: Original expression, ours, Apache 2.0. This is already true for SF. - **Elevation**, when we want real terrain, comes from **USGS/SRTM**, which is US-government public domain. Not OSM. -- **Geocoded company coordinates never enter this repo.** They live in Workie's - private database and arrive over the API at runtime. +- **Geocoded company coordinates never enter this repo.** They live in a private + database and arrive over the API at runtime. -Note where that lands: the licence constraint and the privacy constraint want -exactly the same thing. Company positions and pipeline status both stay behind -the API; the open-source repo holds the city and the renderer. That is a nice -result and it should be defended, because the tempting shortcut — "just commit a -`sf-companies.json`" — breaks both at once. +There is a real result here: the licence constraint and the privacy constraint +want the same thing. Company positions and pipeline status both stay behind the +API; the open-source repo holds the city and the renderer. The tempting shortcut +— "just commit an `sf-companies.json`" — breaks both at once. + +#### Correction: containment is not discharge + +An earlier version of this section stopped at the paragraph above, and it was +**wrong** in a way worth recording rather than quietly fixing. + +Keeping OSM-derived coordinates out of the repo solves licence *mixing inside +the repo*. It does not touch ODbL's actual trigger. Serving a snapshot of those +coordinates from a public endpoint is **Publicly Using a Derivative Database**, +which brings ODbL §4.3 attribution and §4.4 share-alike onto the served data +**regardless of where the rows are stored**. Moving the table off-disk hides the +obligation; it does not discharge it. + +So the rule is about the *geocoder*, not the storage: + +- The sanctioned geocoder is the **US Census Geocoder** + (`geocoding.geo.census.gov`) — a US Government work in the public domain, + keyless, covering every city planned here. +- **Google, Mapbox and HERE do not solve this either.** Their terms restrict + storing and redistributing returned coordinates, which is precisely what a + public snapshot does. "Not OSM" is not the test; "may be redistributed" is. +- Every synced row carries a **provenance** field, and the public-shape + assertion rejects any row whose provenance is not on a non-ODbL allowlist. The + gate that already checks field *names* now also checks where a coordinate + came from. + +Nothing had been built against the old reasoning when this was caught, which is +the only reason it was cheap. ### 3.3 The engine takes no position on what a marker means @@ -254,11 +281,24 @@ original, consistent, Apache-2.0 library of desks, chairs, partitions, screens, doors, lighting rigs, floor and wall materials. Built once, used by every Lumbridge world and by anyone else who wants one. -Interiors share the engine's projection, camera and render loop, and swap the -city layer for a floorplan layer. Same `Marker` type — a desk with a person at -it is a marker with a different `colorKey`. The city view and the office view -are the outside and the inside of one world, and moving between them is a camera -transition, not a different application. +Interiors reuse the engine's renderer, camera machinery and render loop, and +swap the city layer for a floorplan layer. The city view and the office view are +the outside and the inside of one world, and moving between them is a scene swap +rather than a different application. + +Two claims that stood here earlier were wrong, and CONTRACT.md carries the +resolutions: + +- **They do not share a `THREE.Scene`.** They cannot. San Francisco's `latScale` + puts one scene unit at ~94 m with 3.6× vertical exaggeration; an office renders + at 1 unit = 1 m. There are two scenes over one renderer, and the city scene is + *retained and paused* when you step inside rather than disposed — rebuilding + its 336,864-point heightfield costs about a second on the way back out. +- **A person at a desk is not a `Marker`.** A marker is geographic — it has a + lat/lng. Presence binds to a **seat id** and never to a coordinate, which is + what lets the office geometry be public and open-source while who is sitting + in `eng-04` stays private data behind an API. Same shape as the marker rule, + one level in. Reference point: the per-office spatial products in this space (Simile and friends) are the shape to aim at. The difference Spaces is going for is that this diff --git a/CONTRACT.md b/CONTRACT.md new file mode 100644 index 0000000..be9c380 --- /dev/null +++ b/CONTRACT.md @@ -0,0 +1,257 @@ +# The contract + +Five designs were produced in parallel and three critics tore into them. They +collided on fifteen blocking points — four files were specified twice with +incompatible contents, three separate backends were designed for one box, and +one type name was exported twice meaning different things. + +This file is the resolution. **It wins over any individual design.** Anything +implemented here must match this document; where a design said otherwise, this +document is why it changed. + +--- + +## 0. Scope + +We are building **Lumbridge's own** Tera and office, plus a **dev kit** so +anyone can run their own on their own hardware. + +Not in scope, deliberately: renting or purchasing parcels, billing, per-tenant +provisioning, cluster placement, a membership/tenancy system. Federation — a +self-hosted office announcing itself to a shared Tera — is a maybe-later that +the adapter boundary leaves room for and nothing designs toward now. + +**The acceptance test for every decision below**, and the one that failed +end-to-end in the design round: + +> A stranger clones the repo, runs one command, gets a city, copies the +> reference office pack, and has their own office — with no Lumbridge account, +> no Supabase project, and no keys. + +Two CI jobs make that testable rather than aspirational, and they gate the repo: +`git clone && npm ci && npm run build`, and `docker compose up` under `env -i` +asserting `GET /api/v1/health` → 200. + +--- + +## 1. Stage and scene lifecycle + +Two designs both created `src/engine/stage.ts` with opposite lifecycles. The +retention argument wins on measured cost: SF's heightfield is 484 × 696 = +336,864 lattice points and `world.ts` records a ~1.0 s build. Disposing the city +every time someone steps into an office and paying a second of rebuild on the +way out is not acceptable. + +- **`Stage`** (`src/engine/stage.ts`) owns *only* the renderer, the RAF loop, + resize, and a swappable current scene. Nothing else. +- **`StageScene`** carries its own `THREE.Scene`, `PerspectiveCamera` and + `OrbitControls`. `stage.setScene(s)` swaps; the outgoing scene is **retained + and paused, never disposed**. +- Camera, controls, lights and picking live in a per-scene **`SceneKit`** + helper that both `createScene` (city) and `createOfficeScene` construct. + They do *not* live on Stage. + +Rationale for the split: the city and an office cannot share a `THREE.Scene` at +all. SF's `latScale` puts one scene unit at ~94 m with 3.6× vertical +exaggeration; an office renders at 1 unit = 1 m. Two scenes, one renderer. + +```ts +export interface StageScene { + scene: THREE.Scene + camera: THREE.PerspectiveCamera + controls: OrbitControls + onEnter?(): void + onExit?(): void + tick(dt: number, elapsed: number): void + dispose(): void +} +export interface Stage { + renderer: THREE.WebGLRenderer + setScene(s: StageScene): void + current(): StageScene | null + dispose(): void +} +``` + +## 2. The office data contract — one file, one type + +`src/interiors/plan.ts` was specified twice with incompatible contents, and a +fourth design imported an `OfficeDoc` no design produced. + +- **`src/interiors/types.ts` is the single authored contract.** It exports + `Office`, and must stay JSON-serialisable — no functions, no classes, no + THREE types — because a hand-written pack and one arriving over HTTP have to + be the same thing. +- Walls are an **explicit segment list with 1-D `openings`**. Room polygons + imply no walls; they are floor slabs. This wins because the opening-splitting + pass produces the walk-mode collision segments for free, and because + auto-generating walls from shared room edges needs float-equality dedup. +- **Doors and windows are `Opening` records punched out of a wall**, not + placeable assets. `shell.wall.ts`, `shell.door.ts` and `shell.window.ts` are + deleted from the asset library; `shell.ts` calls a parameterised `wallRun` + part per solid run. Shipping both would put every opening in the scene twice, + or leave the collider with no gaps. +- **`src/interiors/plan.ts` is the `Plan` class** — the `World` analogue. + Resolves an `Office` once into wall runs, seats, props, collision segments and + bounds. The other design's `Placement[]` is **`Plan`'s output**, an internal + build input, not a second authored format. +- `OfficeDoc = { id, name, floor: Office, … }` lives in `src/server/wire.ts`, + not in interiors. + +## 3. Assets + +- **Procedural TypeScript only.** Every mesh is a function composing cached unit + primitives; every texture is drawn on a 2D canvas from seeded noise. No binary + art is ever committed. This is the same property that gives the city zero + asset-licensing exposure, and it is worth more than the quality ceiling it + costs. +- The **assets registry wins** over the interiors one — it is strictly richer + (roles, quality, overrides, footprint-without-build). `src/assets/kit.ts` + defines `AssetDef` and `AssetId`; `src/assets/materials.ts` defines + `MaterialRegistry` keyed on a closed `SurfaceRole` union. +- **`Prop.kind` is an `AssetId`.** The prop registry and the asset registry are + the same registry. +- Namespace is **`tera:`** (`tera:desk.workstation`), not `lse:`. A self-hoster + registers `acme:desk.standing` with `overrides: "tera:desk.workstation"` and + reskins without forking. +- Assets are authored in metres, 1 unit = 1 m. +- `ghostOf()` moves onto the assets `MaterialRegistry` for the wall-occlusion + fade. + +### 3.1 The art licence lands with the first asset file + +Apache 2.0 covers the code. The **artistic output is additionally dedicated +under CC0-1.0**, so a mesh can leave this repo without dragging a NOTICE +obligation into someone else's project. + +This is decided now, not later, and `src/assets/LICENSE-ART` plus the +`CONTRIBUTING.md` inbound terms land **in the same commit as the first file +under `src/assets/`**. Deferring it is the expensive mistake: eighteen asset +builders and a ~180-prop reference office is a lot of authored work to +accumulate before anyone states the terms, and relicensing art once +contributors exist is close to impossible. + +The inbound grant must be **standing, not per-PR**. Apache 2.0 §5 supplies a +default inbound=outbound grant for Apache-2.0 only; there is no default inbound +CC0, so a single merged PR whose author never said the words leaves that +contribution Apache-only and makes a directory-wide claim false. `CONTRIBUTING.md` +therefore carries a DCO-style sentence — *by submitting a change under +`src/assets/`, you license it under Apache-2.0 and dedicate the artistic output +under CC0-1.0* — so submission itself is the grant. `LICENSE-ART` is worded as a +dedication made by the copyright holders of the material, not as a property of +the directory. NOTICE gains the carve-out, because NOTICE is what a downstream +consumer actually reads to learn the repo is not uniformly Apache-2.0. + +## 4. Lighting and environment — one owner, one direction + +`Environment` was exported twice meaning different things, and two modules both +constructed and mutated the same three lights. + +- **`Environment` is an observation**: `{ time, sun: SolarPosition, weather }`. +- The lighting *state* is renamed **`LightingState`**. +- **`Atmosphere` is the sole light owner.** `atmosphere.apply(env) → + LightingState`, which the scene applies. One direction, no write-backs. +- Solar position is computed locally with **no network** — a NOAA/Meeus + implementation in `src/engine/solar.ts`, dependency-free. +- An **office gets no Atmosphere**: `fog: null`, no `scene.background` drive, + interior lighting is its own fixed rig. Daylight through windows is a later + refinement, not a v1 coupling. + +## 5. One server + +Three backends were designed for one box — three ports, three frameworks, three +`deploy/Caddyfile.snippet` files that would overwrite each other. + +- **One Fastify workspace**, `server/`, listening on `127.0.0.1:8431`, serving + `/api/v1/*`. One systemd unit, one Caddy snippet. +- Weather and office routes fold in as route modules, not services. +- Env prefix is **`TERA_*`** throughout. +- The Workie sync oneshot stays the **only** second process — it is the sole + holder of a Workie credential, and that isolation earns itself. +- Private per-user markers are **never proxied**. The authenticated browser + calls Workie directly with its own token, so private rows never transit the + public box. +- `Cache-Control` is fail-closed: a global hook stamps `private, no-store`, and + a route opts in to public caching explicitly. + +### 5.1 Zero-config boot must actually boot + +Both server designs made the same independent mistake: a weather source +defaulting to a provider that requires a contact string, with a hard failure +when it is absent — which fails the very acceptance test they named. + +- `TERA_WEATHER_SOURCE` defaults to **`none`**. +- A source set without a contact is a **demotion, not a fatality**: log one loud + line and serve the `synthetic: true` clear-day body. +- The `env -i` CI job is what keeps this honest. + +### 5.2 Weather sources + +`api.weather.gov` (NWS) is US-government public domain, keyless, and the default +*once a contact is configured*. `met.no` is the global fallback. Open-Meteo is +opt-in and off by default: its data is CC-BY 4.0 but its free tier is +non-commercial, which is the wrong default for a product page. + +## 6. Auth + +Scope-corrected: no membership tables, no tenancy. + +- **`TERA_AUTH_MODE` defaults to `none`.** A self-hoster gets an open office and + never creates an account anywhere. +- Lumbridge's own office uses `sso` mode, reusing the pattern already running on + the fleet: the world holds **no credentials**, is handed an entry URL and a + **server-side revalidate URL**, and enforcement happens on the server. Both + are env vars, which is exactly what a dev kit needs. +- Where a JWT is verified directly, **HS256 against a shared secret is primary**; + JWKS sits behind an env switch. This is a correction from verified fact — the + fleet's Supabase issues `{"alg":"HS256"}`, so a JWKS-only implementation would + reject every real token. +- A private office returns **404, not 403**, so the endpoint cannot be used to + enumerate what exists. +- `@supabase/supabase-js` is a **real dependency**, and the "no surprise + dependencies" CI check becomes an **allowlist naming why each is permitted**, + not a count. A dynamic import of an uninstalled package fails the Vite build, + which would have made the `auth: none` default — the committed default — + unbuildable. + +## 7. The binary gate must not fire on the self-hoster + +The no-binary-art check is a promise about **this repo's committed art**, but as +designed it walked the working tree and would fail a self-hoster's build on +their own legally-clean `.glb` — while two other designs told them to put files +exactly there. + +- Enumerate via **`git ls-files`**, never a filesystem walk, so untracked local + assets are invisible to it. +- Strict over **`src/**`** — that is where the licensing argument lives. +- Hard-exempt `public/props/`, `public/kits/`, `public/offices/`, `docs/`, and + add them to `.gitignore` marked as self-hoster space. + +## 8. Geocoding — the correction that matters most + +`ARCHITECTURE.md` §3.2 argued that keeping geocoded company coordinates out of +the repo solved the ODbL problem. **That reasoning is wrong**, and this is the +sharpest thing the critics found. + +Containment solves licence *mixing inside the repo*. It does not touch ODbL's +actual trigger. Serving a snapshot of OSM-derived coordinates at a public +endpoint is **Publicly Using a Derivative Database**, which brings ODbL §4.3 +attribution and §4.4 share-alike onto the served data regardless of where the +rows are stored. Storing them off-repo hides the obligation; it does not +discharge it. + +Nothing is committed to yet — Workie has no geocoding code today — so: + +- The geocoder is the **US Census Geocoder** (`geocoding.geo.census.gov`): a US + Government work in the public domain, keyless, and covering SF, LA and NYC, + which is every city planned. +- **Google, Mapbox and HERE do not solve this either** — their terms restrict + storing and redistributing returned coordinates, which is precisely what a + public snapshot does. +- The sync script records a **per-row provenance field**, and the public-shape + assertion **rejects any row whose provenance is not on a non-ODbL allowlist**. + The gate that already checks field names now also checks where a coordinate + came from. +- `NOTICE`'s GEOGRAPHIC DATA block gains this next to the existing USGS/SRTM + sentence, and `ARCHITECTURE.md` §3.2's reasoning is corrected rather than + quietly left standing. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4ba06cb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,172 @@ +# Contributing to Tera + +Thanks for wanting to. Two sections matter before any others — the inbound +grant, because it is what keeps this repo's licensing claims true, and the four +hard rules, because a change that breaks one of them cannot be merged no matter +how good it is. + +--- + +## The inbound grant + +**By submitting a contribution to this repository — a pull request, a patch, a +commit, or a suggested change in any form — you agree to the following, and the +act of submitting it is the agreement. There is nothing extra to sign.** + +1. You license your contribution under the **Apache License, Version 2.0**, on + the terms in [LICENSE](LICENSE). + +2. **For any change under `src/assets/`**, you *additionally* dedicate the + **artistic output** of that contribution — the meshes, geometry, textures, + materials, palettes and images it produces — to the public domain under + **CC0 1.0**, on the terms in [`src/assets/LICENSE-ART`](src/assets/LICENSE-ART). + Where a dedication is not possible under the law that applies to you, you + grant the equivalent unconditional licence described in that file. + +3. You certify that the contribution is your own work, or that you have the + right to submit it under these terms; that you are not knowingly including + anyone else's copyrighted material, trademark, or data carrying obligations + this repository cannot meet (see the hard rules below); and that you + understand the contribution and this record of it are public and permanent. + +If your employer has rights in your work, get their sign-off before you submit. +If you cannot make all three certifications for some part of a change, say so in +the pull request and it will be sorted out before merge rather than after. + +### Why this is standing rather than per-PR + +Apache 2.0 §5 supplies a default inbound=outbound grant, so contributions arrive +Apache-2.0 whether or not anyone says so. **There is no equivalent default for +CC0.** A single merged pull request under `src/assets/` whose author never said +the words would leave that contribution Apache-only, which would make +`LICENSE-ART`'s dedication false for part of the library — and copyright cannot +be taken back afterwards, so it would also be unfixable. Stating the grant here +and treating submission as acceptance is what stops that from being possible. + +The reasoning is recorded in [CONTRACT.md](CONTRACT.md) §3.1, and the carve-out +is in [NOTICE](NOTICE), which is what a downstream consumer actually reads to +learn this repo is not uniformly Apache-2.0. + +--- + +## The four hard rules + +These are licensing and privacy constraints, not preferences. Each has its full +reasoning in [ARCHITECTURE.md](ARCHITECTURE.md) §3 or [CONTRACT.md](CONTRACT.md). + +**1. No binary art in `src/**`, ever.** No textures, no glTF, no logos, no +fonts, no images. Every mesh is a function composing cached unit primitives and +every texture is drawn on a 2D canvas from seeded noise. This is what gives the +repo zero asset-licensing exposure, and it is worth more than the quality +ceiling it costs. + +CI enforces this by enumerating tracked files with `git ls-files`, never by +walking the working tree — so your own legally-clean `.glb` sitting in +`public/props/` is invisible to the check and cannot fail your build. Those +directories (`public/props/`, `public/kits/`, `public/offices/`, `docs/`) are +self-hoster space and are git-ignored on purpose. + +**2. No OpenStreetMap-derived coordinates.** OSM and Nominatim output is ODbL, +whose share-alike terms cannot be reconciled with Apache 2.0. Note that keeping +such rows out of the repo does *not* discharge the obligation — serving a +snapshot of them publicly is Public Use of a Derivative Database either way. The +sanctioned geocoder is the US Census Geocoder (`geocoding.geo.census.gov`), a US +Government work in the public domain. Google, Mapbox and HERE do not solve this +either; their terms restrict storing and redistributing what they return. +Geography in `src/cities/` is traced by hand. + +**3. No trademarks.** Organisation logos are fetched at runtime by the +consuming application and are never committed here. + +**4. The engine takes no position on what data means.** `markers.ts` renders +`Marker[]` with an opaque `colorKey`; `src/assets/` renders a `SurfaceRole` and +an opaque colour key one level in. Neither will ever learn that a marker is a +company, that a status maps to a colour, or that a seat has a person in it. +That is what lets one engine serve a private map and a public one without either +being a fork — and, for interiors, what lets the office geometry be open-source +while who is sitting in `eng-04` stays private data behind an API. + +Everything must work with **no account, no Supabase project, no API key and no +network**. That is the acceptance test, and CI runs it: `docker compose up` +under `env -i`, asserting `GET /api/v1/health` returns 200. + +--- + +## Contributing an asset + +Assets live in `src/assets/` and are procedural TypeScript. The pieces: + +| file | what it holds | +| --- | --- | +| `kit.ts` | `AssetId`, `AssetDef`, the registry, `createAssetContext` | +| `materials.ts` | `MaterialRegistry`, keyed on the closed `SurfaceRole` union | +| `palette.ts` | the interior palette, derived from the city's by declared HSL shifts | +| `parts.ts` | the shared cached unit-primitive bin, and `MeshBin` | +| `textures.ts` | every texture, drawn with Canvas2D and seeded noise | + +Conventions, in order of how often they are got wrong: + +- **Metres. 1 unit = 1 m.** The city is not on this scale and cannot be; that is + why an office gets its own `THREE.Scene`. +- **Build out of `parts`, not out of fresh geometry.** Every unit part is 1 m in + each dimension with its base on `y = 0`, and assets place them with scaled + local matrices. This is what keeps a 1,200-object office at about thirty draw + calls, and it is also what makes independently-written assets look like one + library rather than eighteen dialects. +- **Ask for a `SurfaceRole`, never a colour.** Roles are named after the object + (`deskSurface`, `partitionFabric`), never after the finish. If you need a role + that does not exist, add it in three places — the union in `materials.ts`, its + spec below it, and its shift in `ROLE_SHIFTS` — and the compiler will not let + you forget the third. +- **Namespace built-ins `tera:`.** Your own assets get your own namespace, and + `overrides: "tera:desk.workstation"` reskins the reference office without + forking it. That mechanism is the point; use it rather than editing built-ins. +- **`footprint()` must not build anything.** Layout asks how big things are far + more often than it asks for their geometry. +- **Determinism.** Any randomness comes from `ctx.rand`, seeded per instance. A + world that reshuffles itself between visits is a lava lamp, not a place. +- **Nothing throws.** An unregistered id draws a placeholder box; an unknown + surface id falls back to a role. A pack with one typo in it should still open. + +Doors and windows are `Opening` records punched out of a wall, not assets. There +is deliberately no `tera:shell.door` — see `interiors/types.ts`. + +--- + +## Contributing a city + +Write `src/cities/.ts` exporting a `City`: coastline and parks traced by +hand, hills as radial peaks, a street bearing per district. A city pack is pure +data, which is what makes it reviewable. Rule 2 above applies with no exceptions. + +--- + +## House style + +- **Comments explain *why*, not *what*.** The line below a comment already says + what it does. What it cannot say is that an earlier version rejection-sampled + buildings uniformly and looked like rubble, or that four comparisons took the + terrain build from 2.3 s to 1.0 s. Write those down; they are the expensive + part. Read two or three existing files before you write your first one. +- Prose comments, full sentences. Section headers are `// ---- Name ----`, and + that is the only decoration in the repo. +- Strict TypeScript with `noUncheckedIndexedAccess`, `noUnusedLocals`, + `noUnusedParameters` and `verbatimModuleSyntax`. Index access needs a + null-check or a `?? fallback`, type-only imports say `import type`, and + relative imports carry the `.ts` extension. +- No new runtime dependency without a reason in the pull request. The dependency + check is an allowlist that names why each one is permitted, not a count. + +## Running it + +```bash +npm install +npm run dev # the demo app +npm run typecheck # tsc --noEmit +npm run build # typecheck, then vite build +``` + +The two CI jobs that gate the repo are `git clone && npm ci && npm run build`, +and the zero-config `docker compose up` health check described above. If a +change makes either of those need a key, an account or a network call, it is the +change that is wrong. diff --git a/NOTICE b/NOTICE index f40f041..c151163 100644 --- a/NOTICE +++ b/NOTICE @@ -40,6 +40,18 @@ database and cannot be reconciled with the permissive licence above. Where real elevation data is used in future, it will come from USGS/SRTM sources, which are United States government works in the public domain. +Where addresses are geocoded to coordinates and those coordinates are then +served publicly, the geocoder is the US Census Geocoder +(geocoding.geo.census.gov), a United States government work in the public +domain. This is a licence requirement rather than a preference: publishing a +snapshot of OSM-derived coordinates is Public Use of a Derivative Database under +ODbL and carries share-alike obligations no matter where the rows are stored. +Commercial geocoders are not an escape either — Google, Mapbox and HERE all +restrict storing and redistributing the coordinates they return. + +Every such row carries a provenance field, and rows whose provenance is not on +the non-ODbL allowlist are refused at publication time. + TRADEMARKS ---------- @@ -53,6 +65,20 @@ Any use is nominative — to identify the organisation referred to — and does imply affiliation with or endorsement by the trademark holder. +ARTISTIC OUTPUT (src/assets/) +----------------------------- + +The material under src/assets/ is source code and is licensed under Apache 2.0 +along with the rest of this repository. In addition, the copyright holders of +that material dedicate the artistic output it generates — meshes, geometry and +textures produced by running it — to the public domain under CC0 1.0. + +This repository is therefore NOT uniformly Apache-2.0, and that is deliberate: +it means a mesh generated from this library can be used in another project +without carrying a NOTICE obligation into it. See src/assets/LICENSE-ART for the +dedication and CONTRIBUTING.md for the inbound grant that keeps it true. + + THIRD-PARTY DEPENDENCIES ------------------------ diff --git a/deploy/Caddyfile.snippet b/deploy/Caddyfile.snippet new file mode 100644 index 0000000..195d2ff --- /dev/null +++ b/deploy/Caddyfile.snippet @@ -0,0 +1,42 @@ +# The one Caddy snippet. +# +# Three server designs each brought their own, on three different ports, and all +# three wrote to this filename. This is the one that replaced them: one service, +# one port, one prefix. CONTRACT.md §5. +# +# Install it beside your Caddyfile and import it into whichever site serves the +# Tera browser build: +# +# import /etc/caddy/snippets/tera-api.snippet +# +# tera.lumbridgecorp.com { +# import tera_api +# root * /srv/tera/dist +# file_server +# } +# +# The API and the static build are deliberately the same origin. Nothing here +# needs CORS, which is why TERA_CORS_ORIGIN defaults to empty — set it only for +# a Vite dev server on another port. + +(tera_api) { + handle /api/v1/* { + reverse_proxy 127.0.0.1:8431 { + # Fail fast rather than holding a browser connection open while the + # API is restarting. systemd brings it back in under two seconds. + transport http { + dial_timeout 2s + } + } + } + + # The API stamps its own Cache-Control — `private, no-store` by default, and + # `public, max-age=…` only where a route opted in. Do not add a cache + # directive here: this file cannot tell which route answered, and the + # fail-closed policy is only fail-closed if nothing downstream overrides it. + + header { + X-Content-Type-Options nosniff + Referrer-Policy strict-origin-when-cross-origin + } +} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..f1391cd --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,65 @@ +# The one compose file. +# +# cd deploy && docker compose up +# +# That is the whole quickstart, and it has to work under `env -i` with no .env +# file, no keys and no account — which is what the CI job asserts by curling +# /api/v1/health. Every variable below therefore carries a `:-` default, so an +# empty environment resolves to an empty string, and the config layer reads an +# empty string as "not set" and falls back. Nothing here is required. +# +# They are listed anyway because being able to run +# +# TERA_WEATHER_SOURCE=nws TERA_WEATHER_CONTACT=you@example.com docker compose up +# +# without editing a file is most of what makes this a dev kit. The full list of +# knobs is in ../server/README.md. + +name: tera + +services: + api: + build: + context: .. + dockerfile: server/Dockerfile + # Loopback only. This is meant to sit behind Caddy on the same box; see + # Caddyfile.snippet. Change the left-hand side, not the right. + ports: + - "127.0.0.1:8431:8431" + environment: + TERA_HOST: "0.0.0.0" + TERA_LOG_LEVEL: "${TERA_LOG_LEVEL:-info}" + TERA_ORIGIN_LAT: "${TERA_ORIGIN_LAT:-}" + TERA_ORIGIN_LNG: "${TERA_ORIGIN_LNG:-}" + TERA_WEATHER_SOURCE: "${TERA_WEATHER_SOURCE:-}" + TERA_WEATHER_CONTACT: "${TERA_WEATHER_CONTACT:-}" + TERA_FLIGHTS_SOURCE: "${TERA_FLIGHTS_SOURCE:-}" + TERA_MARKERS_SOURCE: "${TERA_MARKERS_SOURCE:-}" + TERA_MARKERS_FILE: "${TERA_MARKERS_FILE:-}" + TERA_OFFICES_DIR: "${TERA_OFFICES_DIR:-}" + TERA_AUTH_MODE: "${TERA_AUTH_MODE:-}" + TERA_AUTH_ENTRY_URL: "${TERA_AUTH_ENTRY_URL:-}" + TERA_AUTH_REVALIDATE_URL: "${TERA_AUTH_REVALIDATE_URL:-}" + TERA_AUTH_JWT_SECRET: "${TERA_AUTH_JWT_SECRET:-}" + # Offices and marker snapshots are files. Mount them read-only where you + # keep them; the container writes nothing, ever. + # + # volumes: + # - ../public/offices:/data/offices:ro + healthcheck: + # No curl in the image and none wanted. Node is already here. + test: + - CMD + - node + - -e + - "fetch('http://127.0.0.1:8431/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + interval: 15s + timeout: 5s + retries: 5 + start_period: 5s + restart: unless-stopped + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true diff --git a/deploy/tera-api.service b/deploy/tera-api.service new file mode 100644 index 0000000..4562cb8 --- /dev/null +++ b/deploy/tera-api.service @@ -0,0 +1,61 @@ +# The one systemd unit. +# +# sudo cp deploy/tera-api.service /etc/systemd/system/ +# sudo systemctl enable --now tera-api +# +# Note the `-` on EnvironmentFile: the file is optional, so this unit starts and +# serves /api/v1/health on a box where /etc/tera/tera.env was never created. That +# is the same promise the compose file and the tests make, expressed in the one +# place an operator is most likely to discover it the hard way. CONTRACT.md §5.1. +# +# Assumes the repo is checked out at /srv/tera with `npm ci --omit=dev` already +# run at the root. There is no build step — Node runs the TypeScript sources +# directly — so a deploy is a git pull and a restart. + +[Unit] +Description=Tera API — city data for the Tera map view +Documentation=https://github.com/lumbridge-public/tera +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=tera +Group=tera +WorkingDirectory=/srv/tera +EnvironmentFile=-/etc/tera/tera.env +ExecStart=/usr/bin/node server/src/index.ts +Restart=on-failure +RestartSec=2s + +# Requires Node >= 22.18, where type stripping runs without a flag. +Environment=NODE_ENV=production + +# Hardening. This process reads two directories, opens outbound HTTPS to at most +# one weather feed, and listens on loopback. It has no business doing anything +# else, and saying so here is cheaper than trusting that it never will. +NoNewPrivileges=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_INET AF_INET6 +RestrictNamespaces=yes +LockPersonality=yes +MemoryDenyWriteExecute=no +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM + +# Nothing is written at runtime. The marker snapshot is written by the sync +# oneshot, which is a different unit with a different user and the only holder of +# a Workie credential — add its directory here only if you run both as `tera`. +ReadOnlyPaths=/srv/tera + +MemoryMax=512M +TasksMax=64 + +[Install] +WantedBy=multi-user.target diff --git a/index.html b/index.html index ef73ef0..cb6b325 100644 --- a/index.html +++ b/index.html @@ -29,15 +29,32 @@ #detail { position: fixed; right: 1rem; bottom: 2.5rem; } #hint { position: fixed; right: 1rem; bottom: 1rem; font-size: 10px; color: rgba(20,30,40,0.5); } + .clock { margin: 0.35rem 0 0; font-size: 10px; letter-spacing: 0.08em; + color: rgba(255,255,255,0.42); } + .enter { font: inherit; font-size: 12px; padding: 0.5rem 0.7rem; cursor: pointer; + text-align: left; border: 0; border-radius: 6px; color: #10161d; + background: #f2b134; font-weight: 600; } + .enter:hover { background: #ffc555; } + .scrub { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.45rem; } + .scrub input { flex: 1; accent-color: #f2b134; height: 14px; } + .scrub button { font: inherit; font-size: 9px; text-transform: uppercase; + letter-spacing: 0.08em; padding: 0.15rem 0.35rem; cursor: pointer; border: 0; + border-radius: 3px; background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.7); }
-

San Francisco

-

Tera · Lumbridge Simulate

+

San Francisco

+

Tera · Lumbridge Simulate

+

+
+ + +
+

diff --git a/package-lock.json b/package-lock.json index 398b2e5..52e3bae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { - "name": "@lumbridge/simulate", + "name": "@lumbridge/tera", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@lumbridge/simulate", + "name": "@lumbridge/tera", "version": "0.1.0", "license": "Apache-2.0", + "workspaces": [ + "server" + ], "dependencies": { "three": "^0.182.0" }, @@ -466,6 +469,121 @@ "node": ">=18" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@lumbridge/tera-api": { + "resolved": "server", + "link": true + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -486,6 +604,12 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", @@ -889,6 +1013,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/stats.js": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", @@ -926,6 +1060,96 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -968,6 +1192,125 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz", + "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.11.2", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.11.2.tgz", + "integrity": "sha512-i/eJG7nXR9OkbFgoX4jFiPOHoRq0rXqDACVAXELh5Fdg6BFBErIVZ8MyibGCwup9Go1pYrxZJ0ogIub8vVdEcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -993,6 +1336,20 @@ "dev": true, "license": "MIT" }, + "node_modules/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1008,6 +1365,77 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/meshoptimizer": { "version": "0.22.0", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", @@ -1034,6 +1462,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1054,6 +1491,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", @@ -1083,6 +1557,71 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", @@ -1129,6 +1668,80 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1139,6 +1752,33 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/three": { "version": "0.182.0", "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", @@ -1162,6 +1802,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1176,6 +1825,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -1250,6 +1906,21 @@ "optional": true } } + }, + "server": { + "name": "@lumbridge/tera-api", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "fastify": "^5.2.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.8.0" + }, + "engines": { + "node": ">=22.18" + } } } } diff --git a/package.json b/package.json index c6c5f30..51626c6 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "license": "Apache-2.0", "type": "module", "main": "src/index.ts", + "workspaces": ["server"], "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..5e436b2 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,38 @@ +# The Tera API, as one small image. +# +# TypeScript is not compiled: Node runs the .ts sources directly by stripping +# types at load, which is why there is no build stage, no dist/ and no source +# maps to keep in sync. It is also why `erasableSyntaxOnly` is on in +# server/tsconfig.json — an enum or a parameter property would break the runtime +# and not the typecheck, which is the worst possible order to find out. +# +# The build context is the repo root, because the server's type contract lives +# in the root package at src/server/wire.ts. + +FROM node:24-alpine + +WORKDIR /app + +# The lockfile and both manifests, so the layer cache survives a source change. +COPY package.json package-lock.json ./ +COPY server/package.json ./server/ + +# Only the server workspace's production dependencies. Without --workspace this +# would also install three.js, which the API has no use for. +RUN npm ci --omit=dev --workspace @lumbridge/tera-api + +# The whole browser package's source, for the sake of src/server/wire.ts. Every +# import of it is type-only and is erased before anything is loaded, so none of +# this is read at runtime — it is here so that a typecheck inside the image tells +# the truth. +COPY src ./src +COPY server/src ./server/src + +ENV NODE_ENV=production +# Inside a container, loopback is a different loopback. This is the only reason +# TERA_HOST exists. +ENV TERA_HOST=0.0.0.0 +EXPOSE 8431 + +USER node +CMD ["node", "server/src/index.ts"] diff --git a/server/Dockerfile.dockerignore b/server/Dockerfile.dockerignore new file mode 100644 index 0000000..ba67e3b --- /dev/null +++ b/server/Dockerfile.dockerignore @@ -0,0 +1,12 @@ +# The build context is the repo root, so this keeps a 300 MB node_modules and a +# built dist/ out of the daemon. BuildKit reads `.dockerignore` +# before the context's own .dockerignore, which is what lets this live beside +# the Dockerfile it belongs to instead of at the root of somebody else's repo. +node_modules +**/node_modules +dist +.git +public/logos +public/offices +public/props +public/kits diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..5b346c4 --- /dev/null +++ b/server/README.md @@ -0,0 +1,191 @@ +# The Tera API + +One Fastify service on `127.0.0.1:8431`, serving `/api/v1/*` behind Caddy. It +answers with flight plans, weather, a public marker snapshot and office packs, +and it does all of it with a single runtime dependency. + +**It boots with no configuration at all.** No account, no Supabase project, no +API key, no network. That is not a nice property, it is the acceptance test — +`src/test/boot.test.ts` starts this server under a genuinely empty environment +and asks it for its health, and CI does the same thing from outside the +container. + +```bash +npm ci # from the repo root; server/ is a workspace +npm start -w @lumbridge/tera-api +curl -s localhost:8431/api/v1/health | jq +``` + +There is no build step. Node runs the TypeScript sources directly by stripping +types at load, which needs **Node ≥ 22.18** and is why `erasableSyntaxOnly` is +set in `tsconfig.json` — an enum or a parameter property would break the runtime +without breaking the typecheck, which is the wrong order to find out. + +## Routes + +| route | body | cached | +| --- | --- | --- | +| `GET /api/v1/health` | `HealthBody` | never | +| `GET /api/v1/flights` | `FlightsBody` | public, `TERA_FLIGHTS_TTL` | +| `GET /api/v1/weather` | `WeatherBody` | public, `TERA_WEATHER_TTL` | +| `GET /api/v1/markers` | `MarkersBody` | public, `TERA_MARKERS_TTL` | +| `GET /api/v1/offices/:id` | `OfficeDoc` | public offices only | + +Every body is declared once, in `src/server/wire.ts` in the **root** package — +type-only, so it compiles to nothing and both the browser build and this service +import the same declarations without either becoming a dependency of the other. + +`Cache-Control` is fail-closed: a global hook stamps `private, no-store` on every +reply before any route runs, and a route opts in explicitly. A request that +arrived with an `Authorization` header or a cookie never gets a public policy, +whatever the route asked for. + +## Configuration + +Everything is `TERA_*`, everything is optional, and **nothing is fatal**. A +source configured without what it needs is demoted, not fatal: the server logs +one loud `TERA DEGRADED:` line, serves the fallback, and lists the demotion in +the `degraded` array on `/api/v1/health`. Two earlier designs failed to boot on a +missing weather contact string; this is the correction. (CONTRACT.md §5.1.) + +| variable | default | what it does | +| --- | --- | --- | +| `TERA_HOST` | `127.0.0.1` | Bind address. `0.0.0.0` inside a container, nowhere else. | +| `TERA_PORT` | `8431` | | +| `TERA_LOG_LEVEL` | `info` | | +| `TERA_ORIGIN_LAT` / `_LNG` | SF | The city this box serves. Weather point and flight-plan centre. | +| `TERA_CORS_ORIGIN` | *(empty)* | Comma-separated. Empty means same-origin only. | +| `TERA_PUBLIC_MAX_AGE` | `60` | `max-age` for routes without their own TTL. | + +### Weather + +| variable | default | what it does | +| --- | --- | --- | +| `TERA_WEATHER_SOURCE` | `none` | `none`, `nws`, `metno`, `openmeteo`. | +| `TERA_WEATHER_CONTACT` | *(empty)* | An email or URL. Required by `nws` and `metno`. | +| `TERA_WEATHER_TTL` | `600` | Seconds between upstream fetches. | + +- **`nws`** — api.weather.gov. US only, keyless, and its output is a US + government work in the public domain, so nothing downstream of it owes anybody + attribution. The default *once a contact is set*. +- **`metno`** — the global fallback. CC BY 4.0, so the body carries an + `attribution` array the consumer is expected to display. +- **`openmeteo`** — opt-in and off by default. The data is CC BY 4.0, but the + free tier is non-commercial, which is the wrong default for a product page. + Turning it on records a line in `degraded` saying so. (CONTRACT.md §5.2.) + +`none` — the default — serves a synthetic clear day with `synthetic: true`. That +is a supported steady state, not an error path. + +### Flights + +| variable | default | what it does | +| --- | --- | --- | +| `TERA_FLIGHTS_SOURCE` | `sim` | `sim`, `adsb`, `dump1090`. | +| `TERA_ADSB_ENDPOINT` | `https://api.adsb.lol` | Also works with airplanes.live. | +| `TERA_ADSB_RADIUS_NM` | `40` | | +| `TERA_DUMP1090_PATH` | *(empty)* | Path to your receiver's `aircraft.json`. | +| `TERA_FLIGHTS_TTL` | `300` | Clamped to 15 s for live sources. | +| `TERA_FLIGHTS_SEED` | `4711` | | + +The simulated source is served as a **route plan**, not as positions: the routes, +a fixed phase origin and a seed, which every browser evaluates in closed form +against wall-clock time. One cacheable request replaces a poll per second, and +two people on different machines see the same aircraft in the same places. + +There is no FlightRadar24 client and there will not be one — their terms forbid +scraping and forbid redistribution, so shipping one in an Apache-2.0 repo would +be publishing instructions for breaking a ToS. An RTL-SDR and `dump1090` on a box +you own is the best of the three sources anyway: first-party data with nothing to +comply with. (ARCHITECTURE.md §4.) + +### Markers + +| variable | default | what it does | +| --- | --- | --- | +| `TERA_MARKERS_SOURCE` | `none` | `none` or `file`. | +| `TERA_MARKERS_FILE` | *(empty)* | JSON snapshot written by the sync oneshot. | +| `TERA_MARKERS_PROVENANCE_ALLOWLIST` | `us-census,hand-placed,synthetic` | | +| `TERA_MARKERS_TTL` | `300` | | + +The API serves a file. It holds no database and no credential, and private +per-user markers are never proxied through it — an authenticated browser calls +Workie directly with its own token, so a private row never enters this process. + +**Every row must declare where its coordinate came from, and the gate refuses +anything not on the allowlist.** Serving a snapshot of geocoded coordinates is +Public Use of a Derivative Database; if those coordinates came from Nominatim, +ODbL §4.3 and §4.4 attach to everything served alongside them, no matter where +the rows are stored. Google, Mapbox and HERE are not an escape either — their +terms restrict storing and redistributing what they return. The sanctioned +geocoder is the US Census Geocoder, whose output is public domain. (CONTRACT.md +§8. Adding to the allowlist is a licence decision, not a config tweak.) + +A row carrying a field the gate does not recognise is refused whole rather than +trimmed, and the refusal counts are served on the wire so a broken sync is +visible from outside instead of only in a log. + +### The sync oneshot + +```bash +TERA_SYNC_SOURCE_URL=https://workie.example/api/public/markers \ +TERA_SYNC_TOKEN=... \ +TERA_MARKERS_FILE=/var/lib/tera/markers.json \ +npm run sync -w @lumbridge/tera-api +``` + +The only second process, and the only holder of a credential. It runs on a +timer, puts every row through the same gate, and writes the snapshot atomically. +**One refused row aborts the whole sync and leaves the previous snapshot in +place** — a stale map is a cheap mistake, and publishing coordinates whose +licence nobody can vouch for is not one that a later fix undoes. + +`TERA_SYNC_PROVENANCE` asserts a provenance for rows that arrive without one. +Setting it is a licence claim you are making on the record. + +### Offices and auth + +| variable | default | what it does | +| --- | --- | --- | +| `TERA_OFFICES_DIR` | *(empty)* | One `.json` per office. Empty means no offices. | +| `TERA_AUTH_MODE` | `none` | `none`, `sso`, `jwt`. | +| `TERA_AUTH_ENTRY_URL` | *(empty)* | Where a browser sends someone to sign in. `sso`. | +| `TERA_AUTH_REVALIDATE_URL` | *(empty)* | Server-side token check. `sso`. | +| `TERA_AUTH_COOKIE` | `tera_session` | Cookie a session may arrive in. | +| `TERA_AUTH_JWT_SECRET` | *(empty)* | HS256 shared secret. `jwt`. | +| `TERA_AUTH_JWT_VERIFY` | `hs256` | Set to `jwks` for asymmetric verification. | +| `TERA_AUTH_JWKS_URL` | *(empty)* | | +| `TERA_AUTH_JWT_ISSUER` / `_AUDIENCE` | *(empty)* | Checked when set. | + +A self-hoster gets `none`, an open office, and never creates an account +anywhere. `sso` is what Lumbridge's own deployment uses: this world holds **no +credentials**, only an entry URL and a revalidate URL, and enforcement happens +here on the server. + +Where a JWT is verified directly, **HS256 against a shared secret is the primary +path** and JWKS sits behind an env switch. That ordering comes from verified +fact rather than taste: the issuer this runs against signs `{"alg":"HS256"}`, and +a JWKS-only implementation would reject every real token. (CONTRACT.md §6.) + +**A private office returns 404, not 403** — byte-identical to an office that was +never created — so the endpoint cannot be used to enumerate what exists. A pack +that does not declare its visibility is treated as private. + +## Deploying + +Three files in `../deploy`, and exactly one of each: + +- `Caddyfile.snippet` — `import tera_api` into the site that serves the build. +- `tera-api.service` — systemd, with an *optional* environment file so the unit + starts on a box where nobody wrote one. +- `docker-compose.yml` — `cd deploy && docker compose up`, under `env -i`. + +## Tests + +```bash +npm test -w @lumbridge/tera-api +npm run typecheck -w @lumbridge/tera-api +``` + +The three that are load-bearing: the empty-environment boot, the provenance +gate, and the office 404. diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..3db1ed9 --- /dev/null +++ b/server/package.json @@ -0,0 +1,26 @@ +{ + "name": "@lumbridge/tera-api", + "version": "0.1.0", + "private": true, + "description": "The one Tera API. Health, flights, weather, markers and office packs on 127.0.0.1:8431.", + "license": "Apache-2.0", + "type": "module", + "main": "src/index.ts", + "engines": { + "node": ">=22.18" + }, + "scripts": { + "start": "node src/index.ts", + "dev": "node --watch src/index.ts", + "typecheck": "tsc --noEmit", + "test": "node --test \"src/test/*.test.ts\"", + "sync": "node src/sync/workie.ts" + }, + "dependencies": { + "fastify": "^5.2.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.8.0" + } +} diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..6b111a5 --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,89 @@ +/** + * The one server. + * + * One Fastify instance on `127.0.0.1:8431` serving `/api/v1/*`, behind Caddy, + * with one systemd unit and one Caddy snippet. Three separate backends were + * designed for this box — three ports, three frameworks, three deploy files that + * would have overwritten each other — and this is the one that replaces them. + * Weather, flights, markers and offices are route modules here, not services on + * ports of their own. CONTRACT.md §5. + * + * `buildApp` never listens. Tests build one and use `inject()`; `index.ts` builds + * one and binds it. + */ + +import Fastify, { type FastifyInstance } from "fastify"; +import { registerCachePolicy } from "./cache.ts"; +import { loadConfig, type Config } from "./config.ts"; +import { registerFlights } from "./routes/flights.ts"; +import { registerHealth } from "./routes/health.ts"; +import { registerMarkers } from "./routes/markers.ts"; +import { registerOffices } from "./routes/offices.ts"; +import { registerWeather } from "./routes/weather.ts"; +import { createServices } from "./services.ts"; +import type { ErrorBody } from "../../src/server/wire.ts"; + +export function buildApp(config: Config = loadConfig()): FastifyInstance { + const app = Fastify({ + logger: { level: config.logLevel }, + // Caddy is the only thing that talks to this socket, so its X-Forwarded-For + // is the client address. Nothing else can reach the port to forge one. + trustProxy: true, + }); + + // One loud line per demotion, at boot, before anything can be served. The + // health body carries the same list for anyone without log access. + for (const line of config.degraded) { + app.log.warn(`TERA DEGRADED: ${line}`); + } + + registerCachePolicy(app); + registerCors(app, config.corsOrigins); + + const services = createServices(config, app.log); + registerHealth(app, services); + registerFlights(app, services); + registerWeather(app, services); + registerMarkers(app, services); + registerOffices(app, services); + + app.setNotFoundHandler(async (_req, reply) => { + const body: ErrorBody = { error: "not_found", message: "No such route." }; + return reply.code(404).send(body); + }); + + app.setErrorHandler(async (err, _req, reply) => { + app.log.error({ err }, "unhandled error"); + const body: ErrorBody = { error: "internal", message: "Something went wrong." }; + return reply.code(500).send(body); + }); + + return app; +} + +/** + * CORS, by hand. + * + * The default is an empty allowlist — the browser build is served from the same + * origin through Caddy, so nothing needs this until somebody runs the dev server + * on a different port and sets `TERA_CORS_ORIGIN`. A plugin whose entire job is + * fifteen lines of header-setting is not worth the dependency, and the fifteen + * lines being visible here is worth something on a route that decides who may + * read the API from where. + */ +function registerCors(app: FastifyInstance, allowed: string[]): void { + if (allowed.length === 0) return; + + app.addHook("onRequest", async (req, reply) => { + const origin = req.headers.origin; + if (typeof origin !== "string" || !allowed.includes(origin)) return; + + reply.header("access-control-allow-origin", origin); + reply.header("access-control-allow-headers", "authorization, content-type"); + reply.header("access-control-allow-methods", "GET, OPTIONS"); + // Whatever the response ends up being, it depended on the Origin header. + reply.header("vary", "Origin"); + + if (req.method === "OPTIONS") await reply.code(204).send(); + }); +} diff --git a/server/src/auth/index.ts b/server/src/auth/index.ts new file mode 100644 index 0000000..60c7ab4 --- /dev/null +++ b/server/src/auth/index.ts @@ -0,0 +1,112 @@ +/** + * Who is asking — in three modes, two of which most deployments never turn on. + * + * - **`none`** is the default and the one the acceptance test runs. Everything + * this box serves is public, nobody has an account anywhere, and a private + * office simply does not exist as far as the API is concerned. + * - **`sso`** is what Lumbridge's own deployment uses. This world holds **no + * credentials**: it is handed an entry URL to send people to and a + * revalidate URL to ask about a token, and the answer comes back from the + * thing that issued the session. Reusing the pattern already running on the + * fleet means there is no second identity system to keep secure, and both ends + * are env vars, which is what a dev kit needs. CONTRACT.md §6. + * - **`jwt`** verifies a token here, for a deployment that would rather not make + * an outbound call per request. See `jwt.ts` for why HS256 is the primary path. + * + * Enforcement is on the server in all three. A viewer object that says + * `authenticated: false` is the only thing a route ever sees, and the route + * answers 404 — never 403 — so the endpoint cannot be used to enumerate what + * exists. + */ + +import { createHash } from "node:crypto"; +import type { FastifyRequest } from "fastify"; +import type { AuthConfig } from "../config.ts"; +import { verifyJwt } from "./jwt.ts"; + +export interface Viewer { + authenticated: boolean; + /** Stable subject id where one is known. Never a token, never an email. */ + subject: string | null; +} + +export interface AuthService { + resolve(req: FastifyRequest): Promise; +} + +const ANONYMOUS: Viewer = { authenticated: false, subject: null }; + +/** Positive revalidations are held briefly; negative ones are not held at all. */ +const SESSION_TTL_MS = 60_000; + +export function createAuth(config: AuthConfig): AuthService { + const sessions = new Map(); + + async function revalidate(token: string): Promise { + // The cache is keyed on a hash so that a heap dump, a debugger or a stray + // log line never contains a usable session token. + const key = createHash("sha256").update(token).digest("hex"); + const hit = sessions.get(key); + if (hit !== undefined && Date.now() - hit.checkedAt < SESSION_TTL_MS) return hit.viewer; + + let viewer = ANONYMOUS; + try { + const res = await fetch(config.revalidateUrl, { + headers: { authorization: `Bearer ${token}`, accept: "application/json" }, + signal: AbortSignal.timeout(4000), + }); + if (res.ok) { + const body = (await res.json().catch(() => null)) as { sub?: unknown } | null; + const sub = typeof body?.sub === "string" ? body.sub : null; + viewer = { authenticated: true, subject: sub }; + } + } catch { + // An unreachable identity service means nobody is authenticated. That is + // the safe direction, and it is why this returns a viewer rather than + // throwing: the route still answers, it just answers 404. + return ANONYMOUS; + } + + if (viewer.authenticated) sessions.set(key, { viewer, checkedAt: Date.now() }); + return viewer; + } + + return { + async resolve(req: FastifyRequest): Promise { + if (config.mode === "none") return ANONYMOUS; + + const token = bearerToken(req) ?? cookieToken(req, config.cookieName); + if (token === null) return ANONYMOUS; + + if (config.mode === "sso") return revalidate(token); + + const claims = await verifyJwt(token, config); + if (claims === null) return ANONYMOUS; + return { authenticated: true, subject: typeof claims.sub === "string" ? claims.sub : null }; + }, + }; +} + +function bearerToken(req: FastifyRequest): string | null { + const header = req.headers.authorization; + if (typeof header !== "string") return null; + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + return match?.[1] ?? null; +} + +/** + * Cookies are parsed by hand rather than with a plugin. One header, one split, + * and the alternative is a dependency whose entire job is this function. + */ +function cookieToken(req: FastifyRequest, name: string): string | null { + const header = req.headers.cookie; + if (typeof header !== "string") return null; + for (const pair of header.split(";")) { + const eq = pair.indexOf("="); + if (eq === -1) continue; + if (pair.slice(0, eq).trim() !== name) continue; + const value = pair.slice(eq + 1).trim(); + return value === "" ? null : decodeURIComponent(value); + } + return null; +} diff --git a/server/src/auth/jwt.ts b/server/src/auth/jwt.ts new file mode 100644 index 0000000..e63efbc --- /dev/null +++ b/server/src/auth/jwt.ts @@ -0,0 +1,165 @@ +/** + * JWT verification, with HS256 as the primary path. + * + * That ordering is a correction from verified fact rather than a preference. The + * issuer this runs against — the fleet's Supabase — signs `{"alg":"HS256"}`, so + * a JWKS-only implementation would reject every real token that ever arrived. + * Asymmetric verification is here and works, but it sits behind + * `TERA_AUTH_JWT_VERIFY=jwks`. CONTRACT.md §6. + * + * Written against `node:crypto` rather than a JWT library on purpose: HS256 is + * an HMAC and a handful of claim checks, and this service's dependency list is + * short enough to read in one breath. The parts that are easy to get wrong — + * verifying before parsing, comparing signatures in constant time, rejecting + * `alg: none` and rejecting an algorithm the operator did not ask for — are all + * below and all deliberate. + */ + +import { createHmac, createPublicKey, timingSafeEqual, verify as cryptoVerify } from "node:crypto"; +import type { KeyObject } from "node:crypto"; +import { getJson } from "../http.ts"; +import type { AuthConfig } from "../config.ts"; + +export interface Claims { + sub?: string; + exp?: number; + nbf?: number; + iss?: string; + aud?: string | string[]; + [claim: string]: unknown; +} + +/** Sixty seconds of tolerance for clocks that disagree, which they do. */ +const CLOCK_SKEW_SECONDS = 60; + +export async function verifyJwt(token: string, config: AuthConfig): Promise { + const parts = token.split("."); + if (parts.length !== 3) return null; + const [headerPart, payloadPart, signaturePart] = parts; + if (headerPart === undefined || payloadPart === undefined || signaturePart === undefined) { + return null; + } + + const header = decodeJson<{ alg?: string; kid?: string }>(headerPart); + if (header === null) return null; + + const signed = `${headerPart}.${payloadPart}`; + const signature = Buffer.from(signaturePart, "base64url"); + + const signatureOk = + config.jwtVerify === "jwks" + ? await verifyAsymmetric(header, signed, signature, config) + : verifyHs256(header.alg, signed, signature, config.jwtSecret); + if (!signatureOk) return null; + + // Only now is the payload worth reading. Parsing claims out of an unverified + // token and checking the signature afterwards is how `alg: none` bugs happen. + const claims = decodeJson(payloadPart); + if (claims === null) return null; + return claimsValid(claims, config) ? claims : null; +} + +function verifyHs256( + alg: string | undefined, + signed: string, + signature: Buffer, + secret: string, +): boolean { + // Pinned, not merely checked against a list: an operator who configured a + // shared secret has said what algorithm they expect, and accepting anything + // else here is the classic confusion attack. + if (alg !== "HS256" || secret === "") return false; + const expected = createHmac("sha256", secret).update(signed).digest(); + if (expected.length !== signature.length) return false; + return timingSafeEqual(expected, signature); +} + +// ---- JWKS ----------------------------------------------------------------- + +interface Jwk { + kid?: string; + kty?: string; + alg?: string; + [field: string]: unknown; +} + +const ASYMMETRIC: Record = { + RS256: { algorithm: "RSA-SHA256", ieeeP1363: false }, + RS384: { algorithm: "RSA-SHA384", ieeeP1363: false }, + RS512: { algorithm: "RSA-SHA512", ieeeP1363: false }, + ES256: { algorithm: "SHA256", ieeeP1363: true }, + ES384: { algorithm: "SHA384", ieeeP1363: true }, +}; + +const keyCache = new Map(); +let keysFetchedAt = 0; + +async function verifyAsymmetric( + header: { alg?: string; kid?: string }, + signed: string, + signature: Buffer, + config: AuthConfig, +): Promise { + const alg = header.alg ?? ""; + const spec = ASYMMETRIC[alg]; + if (spec === undefined) return false; + + const key = await resolveKey(header.kid ?? "", config.jwksUrl); + if (key === null) return false; + + try { + return cryptoVerify(spec.algorithm, Buffer.from(signed), { + key, + // ECDSA signatures in a JWT are the raw r‖s pair, not the DER sequence + // OpenSSL expects. Without this, every ES256 token fails to verify. + ...(spec.ieeeP1363 ? { dsaEncoding: "ieee-p1363" as const } : {}), + }, signature); + } catch { + return false; + } +} + +async function resolveKey(kid: string, jwksUrl: string): Promise { + const cached = keyCache.get(kid); + if (cached !== undefined) return cached; + + // Refetch on an unseen kid, but not more than once a minute — a rotated key + // should be picked up quickly, and a token with a junk kid should not be able + // to turn one request into one upstream fetch. + if (Date.now() - keysFetchedAt < 60_000) return null; + keysFetchedAt = Date.now(); + + const jwks = await getJson<{ keys?: Jwk[] }>(jwksUrl); + for (const jwk of jwks?.keys ?? []) { + if (typeof jwk.kid !== "string") continue; + try { + keyCache.set(jwk.kid, createPublicKey({ key: jwk as never, format: "jwk" })); + } catch { + // A key this build of Node cannot represent is not a reason to drop the rest. + } + } + return keyCache.get(kid) ?? null; +} + +// ---- Claims --------------------------------------------------------------- + +function claimsValid(claims: Claims, config: AuthConfig): boolean { + const now = Math.floor(Date.now() / 1000); + if (typeof claims.exp === "number" && claims.exp + CLOCK_SKEW_SECONDS < now) return false; + if (typeof claims.nbf === "number" && claims.nbf - CLOCK_SKEW_SECONDS > now) return false; + if (config.issuer !== "" && claims.iss !== config.issuer) return false; + if (config.audience !== "") { + const aud = claims.aud; + const matches = Array.isArray(aud) ? aud.includes(config.audience) : aud === config.audience; + if (!matches) return false; + } + return true; +} + +function decodeJson(part: string): T | null { + try { + return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as T; + } catch { + return null; + } +} diff --git a/server/src/cache.ts b/server/src/cache.ts new file mode 100644 index 0000000..d1cf4ac --- /dev/null +++ b/server/src/cache.ts @@ -0,0 +1,36 @@ +/** + * `Cache-Control`, fail-closed. + * + * A global `onRequest` hook stamps `private, no-store` on every reply before any + * route runs, and a route that wants a CDN or a browser to keep a copy has to + * say so out loud with `publicCache()`. The order matters: doing this in + * `onSend` "only if the header is missing" would leave error paths, 404s and + * anything thrown before the handler with no policy at all, and the one body + * that must never be cached is the one that came out of a mistake. + * + * CONTRACT.md §5. + */ + +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; + +export function registerCachePolicy(app: FastifyInstance): void { + app.addHook("onRequest", async (_req, reply) => { + reply.header("cache-control", "private, no-store"); + }); +} + +/** + * Opt this reply in to shared caching. + * + * The credential check is not paranoia for its own sake. A route can be + * *usually* public and still be reached with a session attached, and a shared + * cache that stored that response would serve one viewer's body to the next. If + * the request carried anything that could personalise the answer, the fail-closed + * default stands. + */ +export function publicCache(req: FastifyRequest, reply: FastifyReply, seconds: number): void { + if (req.headers.authorization !== undefined || req.headers.cookie !== undefined) return; + const maxAge = Math.max(0, Math.floor(seconds)); + reply.header("cache-control", `public, max-age=${maxAge}`); + reply.header("vary", "Origin"); +} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..b7ab150 --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,352 @@ +/** + * The environment, read once, with every default chosen so that reading nothing + * still produces a working server. + * + * This module is where CONTRACT.md §5.1 is enforced, and it is worth stating the + * rule in one sentence because two independent designs got it wrong the same + * way: **a source configured without what it needs is demoted, not fatal.** The + * acceptance test for this whole repo is a stranger with no keys, and a boot + * that throws because `TERA_WEATHER_CONTACT` is unset fails it. Every demotion + * appends one loud sentence to `degraded`, which `index.ts` logs and + * `/api/v1/health` serves, so the state is visible without being terminal. + * + * The same applies to malformed values: `TERA_PORT=banana` warns and falls back + * to 8431. A process that will not start is worse than a process that starts on + * the default port and says so. + * + * Prefix is `TERA_*` throughout, with no exceptions and no legacy aliases. + */ + +import { readFileSync } from "node:fs"; +import type { + AuthMode, + FlightsSourceId, + MarkersSourceId, + WeatherSourceId, +} from "../../src/server/wire.ts"; + +export interface WeatherConfig { + source: WeatherSourceId; + /** Sent as the User-Agent to sources that require an identifiable caller. */ + contact: string; + ttlSeconds: number; +} + +export interface FlightsConfig { + source: FlightsSourceId; + /** Base URL for the `adsb` source. */ + endpoint: string; + /** Radius in nautical miles for the `adsb` source. */ + radiusNm: number; + /** Path to a local dump1090 `aircraft.json`. */ + dump1090Path: string; + /** Phase origin for the simulated plan. Fixed; see `flights/plan.ts`. */ + epochMs: number; + seed: number; + ttlSeconds: number; +} + +export interface MarkersConfig { + source: MarkersSourceId; + /** Path to the JSON snapshot written by the sync oneshot. */ + file: string; + /** + * Provenance values the public-shape gate will serve. Anything else is + * refused row by row. See CONTRACT.md §8 and `markers/gate.ts`. + */ + provenanceAllowlist: string[]; + ttlSeconds: number; +} + +export interface AuthConfig { + mode: AuthMode; + /** Where a browser sends someone to sign in. `sso` mode only. */ + entryUrl: string; + /** Server-side token check. `sso` mode only; this box holds no credentials. */ + revalidateUrl: string; + /** Cookie a browser session arrives in, when it is not an Authorization header. */ + cookieName: string; + /** HS256 shared secret. Primary, because the fleet's issuer signs HS256. */ + jwtSecret: string; + /** Set to `jwks` to verify asymmetric signatures instead. */ + jwtVerify: "hs256" | "jwks"; + jwksUrl: string; + issuer: string; + audience: string; +} + +export interface Config { + host: string; + port: number; + logLevel: string; + version: string; + /** The city this box is serving, in degrees. Used by weather and by flights. */ + origin: { lat: number; lng: number }; + /** Allowed CORS origins. Empty means same-origin only, which is the default. */ + corsOrigins: string[]; + /** `max-age` for routes that opt in to public caching. */ + publicMaxAge: number; + weather: WeatherConfig; + flights: FlightsConfig; + markers: MarkersConfig; + offices: { dir: string }; + auth: AuthConfig; + /** One sentence per demotion. Empty on a fully-configured box. */ + degraded: string[]; +} + +type Env = Record; + +export function loadConfig(env: Env = process.env): Config { + const degraded: string[] = []; + + const weather = loadWeather(env, degraded); + const flights = loadFlights(env, degraded); + const markers = loadMarkers(env, degraded); + const auth = loadAuth(env, degraded); + + return { + host: str(env, "TERA_HOST", "127.0.0.1"), + port: num(env, "TERA_PORT", 8431, degraded), + logLevel: str(env, "TERA_LOG_LEVEL", "info"), + version: readVersion(), + origin: { + lat: num(env, "TERA_ORIGIN_LAT", 37.7749, degraded), + lng: num(env, "TERA_ORIGIN_LNG", -122.4194, degraded), + }, + corsOrigins: list(env, "TERA_CORS_ORIGIN"), + publicMaxAge: num(env, "TERA_PUBLIC_MAX_AGE", 60, degraded), + weather, + flights, + markers, + offices: { dir: str(env, "TERA_OFFICES_DIR", "") }, + auth, + degraded, + }; +} + +// ---- Sections ------------------------------------------------------------- + +const WEATHER_SOURCES: WeatherSourceId[] = ["none", "nws", "metno", "openmeteo"]; + +function loadWeather(env: Env, degraded: string[]): WeatherConfig { + const ttlSeconds = num(env, "TERA_WEATHER_TTL", 600, degraded); + const contact = str(env, "TERA_WEATHER_CONTACT", ""); + const asked = str(env, "TERA_WEATHER_SOURCE", "none"); + + let source = oneOf(asked, WEATHER_SOURCES); + if (source === null) { + degraded.push( + `TERA_WEATHER_SOURCE="${asked}" is not one of ${WEATHER_SOURCES.join(", ")}; ` + + `serving synthetic weather instead.`, + ); + source = "none"; + } + + // NWS and MET Norway both require a contact string in the User-Agent and are + // entitled to block a caller who does not send one. Calling them anyway with a + // generic agent is the rude failure mode; refusing to boot is the useless one. + if ((source === "nws" || source === "metno") && contact === "") { + degraded.push( + `TERA_WEATHER_SOURCE=${source} needs TERA_WEATHER_CONTACT (an email or URL ` + + `the operator can be reached at) — ${source} requires an identifiable ` + + `caller. Demoted to synthetic weather; the server is otherwise fine.`, + ); + source = "none"; + } + + if (source === "openmeteo") { + // Not a demotion — a warning that stays on the record. Open-Meteo's *data* + // is CC-BY 4.0, but its free tier is non-commercial, so this is opt-in and + // never a default. CONTRACT.md §5.2. + degraded.push( + "TERA_WEATHER_SOURCE=openmeteo: Open-Meteo's free tier is non-commercial. " + + "Fine for a self-hosted map; check your terms before putting it behind a " + + "product page.", + ); + } + + return { source, contact, ttlSeconds }; +} + +const FLIGHT_SOURCES: FlightsSourceId[] = ["sim", "adsb", "dump1090"]; + +/** + * The simulated plan's phase origin. Deliberately a constant rather than boot + * time: `t0 = Date.now()` would put every aircraft back at the start of its leg + * on every restart, and a fleet of jets teleporting to their departure gates is + * a very visible way to announce a deploy. + */ +const PLAN_EPOCH_MS = Date.UTC(2026, 0, 1); + +function loadFlights(env: Env, degraded: string[]): FlightsConfig { + const asked = str(env, "TERA_FLIGHTS_SOURCE", "sim"); + let source = oneOf(asked, FLIGHT_SOURCES); + if (source === null) { + degraded.push( + `TERA_FLIGHTS_SOURCE="${asked}" is not one of ${FLIGHT_SOURCES.join(", ")}; ` + + `serving the simulated plan instead.`, + ); + source = "sim"; + } + + const dump1090Path = str(env, "TERA_DUMP1090_PATH", ""); + if (source === "dump1090" && dump1090Path === "") { + degraded.push( + "TERA_FLIGHTS_SOURCE=dump1090 needs TERA_DUMP1090_PATH pointing at your " + + "receiver's aircraft.json. Demoted to the simulated plan.", + ); + source = "sim"; + } + + return { + source, + endpoint: str(env, "TERA_ADSB_ENDPOINT", "https://api.adsb.lol"), + radiusNm: num(env, "TERA_ADSB_RADIUS_NM", 40, degraded), + dump1090Path, + epochMs: num(env, "TERA_FLIGHTS_EPOCH_MS", PLAN_EPOCH_MS, degraded), + seed: num(env, "TERA_FLIGHTS_SEED", 4711, degraded), + ttlSeconds: num(env, "TERA_FLIGHTS_TTL", 300, degraded), + }; +} + +const MARKER_SOURCES: MarkersSourceId[] = ["none", "file"]; + +/** CONTRACT.md §8. Adding to this list is a licence decision, not a config tweak. */ +export const DEFAULT_PROVENANCE_ALLOWLIST = ["us-census", "hand-placed", "synthetic"]; + +function loadMarkers(env: Env, degraded: string[]): MarkersConfig { + const asked = str(env, "TERA_MARKERS_SOURCE", "none"); + let source = oneOf(asked, MARKER_SOURCES); + if (source === null) { + degraded.push( + `TERA_MARKERS_SOURCE="${asked}" is not one of ${MARKER_SOURCES.join(", ")}; ` + + `serving no markers.`, + ); + source = "none"; + } + + const file = str(env, "TERA_MARKERS_FILE", ""); + if (source === "file" && file === "") { + degraded.push( + "TERA_MARKERS_SOURCE=file needs TERA_MARKERS_FILE. Serving no markers.", + ); + source = "none"; + } + + const allowlist = list(env, "TERA_MARKERS_PROVENANCE_ALLOWLIST"); + return { + source, + file, + provenanceAllowlist: allowlist.length > 0 ? allowlist : DEFAULT_PROVENANCE_ALLOWLIST, + ttlSeconds: num(env, "TERA_MARKERS_TTL", 300, degraded), + }; +} + +const AUTH_MODES: AuthMode[] = ["none", "sso", "jwt"]; + +function loadAuth(env: Env, degraded: string[]): AuthConfig { + const asked = str(env, "TERA_AUTH_MODE", "none"); + let mode = oneOf(asked, AUTH_MODES); + if (mode === null) { + degraded.push( + `TERA_AUTH_MODE="${asked}" is not one of ${AUTH_MODES.join(", ")}; ` + + `running open (mode=none).`, + ); + mode = "none"; + } + + const entryUrl = str(env, "TERA_AUTH_ENTRY_URL", ""); + const revalidateUrl = str(env, "TERA_AUTH_REVALIDATE_URL", ""); + const jwtSecret = str(env, "TERA_AUTH_JWT_SECRET", ""); + const jwksUrl = str(env, "TERA_AUTH_JWKS_URL", ""); + const jwtVerify = str(env, "TERA_AUTH_JWT_VERIFY", "hs256") === "jwks" ? "jwks" : "hs256"; + + // A demotion here has teeth: it takes private offices with it, which is the + // safe direction. Unverifiable credentials must never mean "let them in". + if (mode === "sso" && revalidateUrl === "") { + degraded.push( + "TERA_AUTH_MODE=sso needs TERA_AUTH_REVALIDATE_URL — this box holds no " + + "credentials and cannot check a session without somewhere to ask. " + + "Demoted to mode=none; private offices will answer 404 to everyone.", + ); + mode = "none"; + } + if (mode === "jwt" && jwtVerify === "hs256" && jwtSecret === "") { + degraded.push( + "TERA_AUTH_MODE=jwt needs TERA_AUTH_JWT_SECRET (or TERA_AUTH_JWT_VERIFY=jwks " + + "with TERA_AUTH_JWKS_URL). Demoted to mode=none; private offices will " + + "answer 404 to everyone.", + ); + mode = "none"; + } + if (mode === "jwt" && jwtVerify === "jwks" && jwksUrl === "") { + degraded.push( + "TERA_AUTH_JWT_VERIFY=jwks needs TERA_AUTH_JWKS_URL. Demoted to mode=none.", + ); + mode = "none"; + } + + return { + mode, + entryUrl, + revalidateUrl, + cookieName: str(env, "TERA_AUTH_COOKIE", "tera_session"), + jwtSecret, + jwtVerify, + jwksUrl, + issuer: str(env, "TERA_AUTH_JWT_ISSUER", ""), + audience: str(env, "TERA_AUTH_JWT_AUDIENCE", ""), + }; +} + +// ---- Readers -------------------------------------------------------------- + +function str(env: Env, key: string, fallback: string): string { + const raw = env[key]; + if (raw === undefined) return fallback; + const trimmed = raw.trim(); + return trimmed === "" ? fallback : trimmed; +} + +function num(env: Env, key: string, fallback: number, degraded: string[]): number { + const raw = env[key]; + if (raw === undefined || raw.trim() === "") return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) { + degraded.push(`${key}="${raw}" is not a number; using ${fallback}.`); + return fallback; + } + return parsed; +} + +/** Comma-separated, whitespace-tolerant, empties dropped. */ +function list(env: Env, key: string): string[] { + return str(env, key, "") + .split(",") + .map((s) => s.trim()) + .filter((s) => s !== ""); +} + +function oneOf(value: string, allowed: T[]): T | null { + return allowed.includes(value as T) ? (value as T) : null; +} + +/** + * The version served by `/health`, read from `package.json` so it cannot drift + * from the thing that is actually deployed. A missing or unreadable file is not + * worth dying over — nothing depends on this but a human reading a health check. + */ +function readVersion(): string { + try { + const url = new URL("../package.json", import.meta.url); + const parsed: unknown = JSON.parse(readFileSync(url, "utf8")); + if (parsed !== null && typeof parsed === "object" && "version" in parsed) { + const v = (parsed as { version: unknown }).version; + if (typeof v === "string") return v; + } + } catch { + // Fall through. + } + return "0.0.0"; +} diff --git a/server/src/flights/adsb.ts b/server/src/flights/adsb.ts new file mode 100644 index 0000000..0f0102e --- /dev/null +++ b/server/src/flights/adsb.ts @@ -0,0 +1,100 @@ +/** + * 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. + * + * 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. + */ + +import { readFile } from "node:fs/promises"; +import { getJson } from "../http.ts"; +import type { WireAircraft } from "../../../src/server/wire.ts"; + +/** The shared dump1090/readsb aircraft record, as both feeds emit it. */ +interface RawAircraft { + hex?: string; + flight?: string; + lat?: number; + lon?: number; + alt_baro?: number | string; + track?: number; +} + +interface AircraftEnvelope { + ac?: RawAircraft[]; + aircraft?: RawAircraft[]; + now?: number; +} + +export interface FlightsSnapshot { + aircraft: WireAircraft[]; + observedAt: number; +} + +export async function fetchAdsb( + endpoint: string, + center: { lat: number; lng: number }, + radiusNm: number, +): Promise { + const url = `${endpoint.replace(/\/$/, "")}/v2/point/${center.lat.toFixed(4)}/${center.lng.toFixed(4)}/${Math.round(radiusNm)}`; + const body = await getJson(url); + if (body === null) return null; + return normalise(body); +} + +/** + * A receiver's own `aircraft.json`, read off local disk. + * + * dump1090 rewrites this file every second, so a partial read is a real + * possibility rather than a theoretical one — which is why an unparseable body + * returns `null` and lets the caller keep the previous snapshot instead of + * emptying the sky for one tick. + */ +export async function readDump1090(path: string): Promise { + try { + const text = await readFile(path, "utf8"); + return normalise(JSON.parse(text) as AircraftEnvelope); + } catch { + return null; + } +} + +function normalise(body: AircraftEnvelope): FlightsSnapshot { + const rows = body.ac ?? body.aircraft ?? []; + const aircraft: WireAircraft[] = []; + for (const a of rows) { + if (typeof a.lat !== "number" || typeof a.lon !== "number") continue; + const callsign = a.flight?.trim(); + const id = a.hex ?? callsign; + if (id === undefined || id === "") continue; + aircraft.push({ + id, + callsign: callsign === "" ? undefined : callsign, + lat: a.lat, + lng: a.lon, + // The feeds report barometric altitude in feet, and send the string + // "ground" for anything that is not flying. The wire carries metres. + altitude: typeof a.alt_baro === "number" ? a.alt_baro * 0.3048 : 0, + heading: typeof a.track === "number" ? a.track : 0, + }); + } + return { aircraft, observedAt: observedAtMs(body.now) }; +} + +/** + * dump1090 stamps `now` in seconds and the hosted feeds stamp it in + * milliseconds, using the same field name. Anything past the year 2001 in + * milliseconds is already too large to be a plausible epoch in seconds, so the + * magnitude tells them apart without needing to know which feed answered. + */ +function observedAtMs(now: number | undefined): number { + if (typeof now !== "number" || !Number.isFinite(now)) return Date.now(); + return now > 1e12 ? Math.round(now) : Math.round(now * 1000); +} diff --git a/server/src/flights/index.ts b/server/src/flights/index.ts new file mode 100644 index 0000000..2f68c4f --- /dev/null +++ b/server/src/flights/index.ts @@ -0,0 +1,82 @@ +/** + * Which sky this box serves. + * + * The simulated source answers from a plan and never touches the network, so it + * is free and it is the default. The two real sources are polled on a timer and + * cached, with the same rule the weather service follows: a feed that stops + * answering serves its last snapshot, and a feed that has never answered falls + * back to the plan rather than to an empty sky. An operator who turned on ADS-B + * and got a blank map would reasonably conclude the renderer was broken. + */ + +import type { Config } from "../config.ts"; +import type { FlightsBody } from "../../../src/server/wire.ts"; +import { fetchAdsb, readDump1090, type FlightsSnapshot } from "./adsb.ts"; +import { planFor } from "./plan.ts"; + +export interface FlightsService { + current(): Promise; +} + +export interface FlightsLog { + warn(msg: string): void; +} + +/** + * How long a live snapshot may be cached. Aircraft move; the plan does not, so + * only the live path is clamped. + */ +const LIVE_MAX_TTL_SECONDS = 15; + +export function createFlightsService(config: Config, log: FlightsLog): FlightsService { + const { source, endpoint, radiusNm, dump1090Path, epochMs, seed, ttlSeconds } = config.flights; + const routes = planFor(config.origin); + + const plan = (): FlightsBody => ({ + mode: "plan", + source: "sim", + t0: epochMs, + seed, + routes, + ttlSeconds, + }); + + const liveTtl = Math.min(ttlSeconds, LIVE_MAX_TTL_SECONDS); + let snapshot: FlightsSnapshot | null = null; + let polledAt = 0; + + async function poll(): Promise { + const fresh = + source === "dump1090" + ? await readDump1090(dump1090Path) + : await fetchAdsb(endpoint, config.origin, radiusNm); + polledAt = Date.now(); + if (fresh !== null) { + snapshot = fresh; + return; + } + log.warn( + `flights: ${source} did not answer; serving ${snapshot === null ? "the simulated plan" : "the last snapshot"}`, + ); + } + + return { + async current(): Promise { + if (source === "sim") return plan(); + + if (Date.now() - polledAt > liveTtl * 1000) await poll(); + if (snapshot === null) return plan(); + + return { + mode: "live", + source, + observedAt: snapshot.observedAt, + aircraft: snapshot.aircraft, + ttlSeconds: liveTtl, + ...(source === "adsb" + ? { attribution: ["Aircraft positions from the adsb.lol community feed"] } + : {}), + }; + }, + }; +} diff --git a/server/src/flights/plan.ts b/server/src/flights/plan.ts new file mode 100644 index 0000000..113a752 --- /dev/null +++ b/server/src/flights/plan.ts @@ -0,0 +1,135 @@ +/** + * The simulated sky, as a plan rather than as positions. + * + * The engine's `SimulatedFlights` already evaluates a `SimRoute[]` in closed + * form against wall-clock time, so the server has nothing to simulate — it hands + * over the routes, a fixed phase origin and a seed, and every browser arrives at + * the same answer. One cacheable request replaces a poll per second, and two + * people on different machines see the same aircraft in the same places, which + * a per-client simulation cannot promise and which is the point of doing it this + * way. + * + * ### Where the coordinates came from + * + * The three Bay Area airport positions are published FAA airport reference + * points, typed in by hand. They are US government facts in the public domain, + * and — as with every other coordinate in this repo — emphatically not derived + * from OpenStreetMap. Everything else here is a waypoint someone made up so the + * legs go the right way. See ARCHITECTURE.md §3.2 and CONTRACT.md §8. + * + * The callsigns are invented, with operator prefixes that belong to nobody. A + * repo that refuses to ship other people's logos should not ship their flight + * numbers either. + */ + +import type { WireSimRoute } from "../../../src/server/wire.ts"; + +const SFO: [number, number] = [37.6188, -122.375]; +const OAK: [number, number] = [37.7213, -122.2207]; +const SJC: [number, number] = [37.3639, -121.9289]; + +/** + * Departures climb, arrivals descend, and a third of the traffic is just passing + * through at cruise. That mix is what makes the sky read as a working airspace + * rather than a carousel. + */ +const BAY_AREA: WireSimRoute[] = [ + // Departures — out over the Pacific, north-east over the Central Valley, and + // south down the peninsula. + { callsign: "LMB231", from: SFO, to: [37.3, -123.2], fromAlt: 20, toAlt: 10500, duration: 420 }, + { callsign: "LMB778", from: SFO, to: [38.4, -121.3], fromAlt: 20, toAlt: 11000, duration: 480 }, + { callsign: "PAC1082", from: SFO, to: [38.6, -122.9], fromAlt: 20, toAlt: 10000, duration: 460 }, + { callsign: "BAY412", from: OAK, to: [37.0, -121.2], fromAlt: 20, toAlt: 9500, duration: 400 }, + { callsign: "SIE1440", from: SJC, to: [36.6, -121.6], fromAlt: 20, toAlt: 9800, duration: 430 }, + + // Arrivals — the long descent over Point Reyes, the south-east downwind, and + // the two east-bay finals. + { callsign: "GLD566", from: [38.3, -123.1], to: SFO, fromAlt: 6000, toAlt: 20, duration: 500 }, + { callsign: "LMB1889", from: [37.1, -121.6], to: SFO, fromAlt: 5500, toAlt: 20, duration: 520 }, + { callsign: "RDW915", from: [37.9, -121.3], to: OAK, fromAlt: 5200, toAlt: 20, duration: 470 }, + { callsign: "PAC331", from: [38.1, -122.3], to: SJC, fromAlt: 5800, toAlt: 20, duration: 540 }, + + // Overflights, level the whole way. + { + callsign: "GLD55", + from: [38.8, -122.6], + to: [36.6, -121.4], + fromAlt: 11000, + toAlt: 11000, + duration: 620, + }, + { + callsign: "PAC21", + from: [37.9, -121.4], + to: [37.2, -123.4], + fromAlt: 10500, + toAlt: 10500, + duration: 700, + }, + { + callsign: "RDW1205", + from: [36.5, -122.4], + to: [38.9, -122.0], + fromAlt: 10800, + toAlt: 10800, + duration: 660, + }, + + // Low and slow across the bay: general aviation is most of what is actually + // visible from the ground, and it is the only traffic that reads as *near*. + { + callsign: "BAY7789", + from: [37.45, -122.1], + to: [38.05, -122.45], + fromAlt: 900, + toAlt: 900, + duration: 520, + }, + { + callsign: "SIE4402", + from: [37.95, -122.55], + to: [37.35, -121.85], + fromAlt: 1400, + toAlt: 1400, + duration: 560, + }, +]; + +/** Degrees, roughly the distance from downtown SF to the far end of the bay. */ +const BAY_AREA_RADIUS = 0.75; + +/** + * A plan for a city this file has never heard of. + * + * A self-hoster pointing `TERA_ORIGIN_LAT/LNG` at somewhere that is not San + * Francisco should get moving aircraft rather than an empty sky, so eight legs + * are laid out on evenly-spaced bearings through their origin. It is not their + * city's real airspace and does not pretend to be — it is motion in the right + * kind of place, which is all the map ever wanted from this. + */ +function genericPlan(lat: number, lng: number): WireSimRoute[] { + const routes: WireSimRoute[] = []; + const span = 0.55; + for (let i = 0; i < 8; i++) { + const bearing = (i / 8) * Math.PI * 2; + const dLat = Math.cos(bearing) * span; + const dLng = (Math.sin(bearing) * span) / Math.max(0.2, Math.cos((lat * Math.PI) / 180)); + const cruise = 8000 + (i % 4) * 900; + routes.push({ + callsign: `LMB${100 + i * 37}`, + from: [lat - dLat, lng - dLng], + to: [lat + dLat, lng + dLng], + fromAlt: i % 3 === 0 ? 600 : cruise, + toAlt: cruise, + duration: 380 + i * 45, + }); + } + return routes; +} + +export function planFor(origin: { lat: number; lng: number }): WireSimRoute[] { + const nearSf = + Math.abs(origin.lat - 37.7749) < BAY_AREA_RADIUS && + Math.abs(origin.lng + 122.4194) < BAY_AREA_RADIUS; + return nearSf ? BAY_AREA : genericPlan(origin.lat, origin.lng); +} diff --git a/server/src/http.ts b/server/src/http.ts new file mode 100644 index 0000000..3611d3d --- /dev/null +++ b/server/src/http.ts @@ -0,0 +1,43 @@ +/** + * The one place this service talks to somebody else's server. + * + * Every outbound call is bounded and every failure is a returned `null` rather + * than a thrown exception, because the callers are all route handlers whose + * contract is that they answer. An upstream that has gone away must degrade the + * body, never the response. + */ + +const DEFAULT_TIMEOUT_MS = 6000; + +export interface GetJsonOptions { + headers?: Record; + timeoutMs?: number; +} + +/** + * `null` on any failure at all — transport, status, or unparseable body. The + * caller decides what a missing answer means; nothing here does. + */ +export async function getJson(url: string, opts: GetJsonOptions = {}): Promise { + try { + const res = await fetch(url, { + headers: { accept: "application/json", ...opts.headers }, + signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS), + }); + if (!res.ok) return null; + return (await res.json()) as T; + } catch { + return null; + } +} + +/** + * A User-Agent that identifies this software and the operator running it. + * + * NWS and MET Norway both ask for a contact and are entitled to block a caller + * who sends a generic agent. This is also why an empty contact demotes the + * source in `config.ts` rather than being papered over here with a fake address. + */ +export function userAgent(contact: string): string { + return `tera-api (+https://github.com/lumbridge-public/tera; ${contact})`; +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..15ac27a --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,34 @@ +/** + * The entry point: read the environment, build the app, bind the socket. + * + * It binds `127.0.0.1` by default and is meant to stay there — Caddy terminates + * TLS and is the only thing that talks to this port. A container sets + * `TERA_HOST=0.0.0.0` because inside a container the loopback interface is a + * different loopback interface, which is the only reason that knob exists. + * + * Nothing is fetched at boot. A source is contacted the first time somebody asks + * for it, so this process is up and answering health before it has any opinion + * about the weather. + */ + +import { buildApp } from "./app.ts"; +import { loadConfig } from "./config.ts"; + +const config = loadConfig(); +const app = buildApp(config); + +try { + await app.listen({ host: config.host, port: config.port }); +} catch (err) { + app.log.error({ err }, `could not bind ${config.host}:${config.port}`); + process.exit(1); +} + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + // `once`, not `on`: a second SIGTERM during a shutdown should kill the process + // outright rather than start a second shutdown. + process.once(signal, () => { + app.log.info(`${signal} — closing`); + void app.close().then(() => process.exit(0)); + }); +} diff --git a/server/src/markers/gate.ts b/server/src/markers/gate.ts new file mode 100644 index 0000000..9b4e8bc --- /dev/null +++ b/server/src/markers/gate.ts @@ -0,0 +1,129 @@ +/** + * The public-shape gate — the last thing between a synced row and the internet. + * + * Two checks, and the second one is the one CONTRACT.md §8 exists for. + * + * **Fields.** Only the names on `PUBLIC_MARKER_FIELDS` may cross. A row carrying + * anything else is refused whole rather than trimmed: an unrecognised field + * means the upstream shape changed without anyone reviewing it, and quietly + * dropping it turns a thing somebody should look at into a thing nobody ever + * sees. This is the same fail-closed rule the fleet's `export-site.ts` already + * applies, restated here because this is a different process. + * + * **Provenance.** A row whose coordinate came from a source not on the allowlist + * is refused. Serving a snapshot of OSM-derived coordinates is Public Use of a + * Derivative Database, which brings ODbL §4.3 attribution and §4.4 share-alike + * onto everything served alongside it — regardless of where the rows are stored, + * which is exactly the reasoning ARCHITECTURE.md §3.2 originally got wrong. + * Google, Mapbox and HERE do not help either: their terms restrict storing and + * redistributing returned coordinates, which is what a public snapshot is. The + * sanctioned geocoder is the US Census Geocoder, whose output is a US government + * work in the public domain. + * + * The gate refuses rows; it never repairs them. Anything it drops is a bug in + * the sync oneshot, and the refusal counts are served on the wire so that the + * bug is visible from outside rather than only in a log nobody tails. + */ + +import type { WireMarker } from "../../../src/server/wire.ts"; + +/** + * Every field a marker may carry over the public wire. + * + * This tracks `Marker` in `src/engine/types.ts` plus `provenance`. When that + * type gains a field, this list has to gain it too — and until it does, rows + * carrying the new field are refused, which is the correct direction for a list + * whose job is to be conservative. + */ +export const PUBLIC_MARKER_FIELDS = [ + "id", + "label", + "colorKey", + "url", + "blurb", + "lat", + "lng", + "located", + "provenance", +]; + +export interface GateResult { + accepted: WireMarker[]; + /** Aggregated so a broken sync produces one line, not ten thousand. */ + refused: { reason: string; count: number }[]; +} + +export function assertPublicShape(rows: unknown, provenanceAllowlist: string[]): GateResult { + const accepted: WireMarker[] = []; + const refusals = new Map(); + const refuse = (reason: string): void => { + refusals.set(reason, (refusals.get(reason) ?? 0) + 1); + }; + + if (!Array.isArray(rows)) { + return { accepted, refused: [{ reason: "snapshot is not an array of markers", count: 1 }] }; + } + + for (const row of rows) { + if (row === null || typeof row !== "object" || Array.isArray(row)) { + refuse("row is not an object"); + continue; + } + const record = row as Record; + + const unknown = Object.keys(record).find((key) => !PUBLIC_MARKER_FIELDS.includes(key)); + if (unknown !== undefined) { + refuse(`unknown field "${unknown}"`); + continue; + } + + const provenance = record["provenance"]; + if (typeof provenance !== "string" || !provenanceAllowlist.includes(provenance)) { + refuse( + `provenance ${JSON.stringify(provenance)} is not on the non-ODbL allowlist ` + + `(${provenanceAllowlist.join(", ")})`, + ); + continue; + } + + const marker = readMarker(record, provenance); + if (marker === null) { + refuse("missing or malformed id, label, colorKey, lat or lng"); + continue; + } + accepted.push(marker); + } + + return { + accepted, + refused: [...refusals].map(([reason, count]) => ({ reason, count })), + }; +} + +function readMarker(record: Record, provenance: string): WireMarker | null { + const id = record["id"]; + const label = record["label"]; + const colorKey = record["colorKey"]; + const lat = record["lat"]; + const lng = record["lng"]; + + if (typeof id !== "string" || id === "") return null; + if (typeof label !== "string") return null; + if (typeof colorKey !== "string") return null; + if (!isDegrees(lat, 90) || !isDegrees(lng, 180)) return null; + + const marker: WireMarker = { id, label, colorKey, lat, lng, provenance }; + + const url = record["url"]; + if (typeof url === "string") marker.url = url; + const blurb = record["blurb"]; + if (typeof blurb === "string") marker.blurb = blurb; + const located = record["located"]; + if (typeof located === "boolean") marker.located = located; + + return marker; +} + +function isDegrees(value: unknown, limit: number): value is number { + return typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= limit; +} diff --git a/server/src/markers/store.ts b/server/src/markers/store.ts new file mode 100644 index 0000000..abf52c3 --- /dev/null +++ b/server/src/markers/store.ts @@ -0,0 +1,77 @@ +/** + * The marker snapshot: read from disk, put through the gate, cached. + * + * The API serves a file. It does not hold a Workie credential, does not have a + * database, and does not proxy anything — the sync oneshot writes the snapshot + * and this reads it, which is why a compromise of the public box yields a file + * that was already public. + * + * Private per-user markers never appear here at all. An authenticated browser + * calls Workie directly with its own token, so private rows never transit this + * process. CONTRACT.md §5. + */ + +import { readFile } from "node:fs/promises"; +import type { Config } from "../config.ts"; +import type { MarkersBody } from "../../../src/server/wire.ts"; +import { assertPublicShape } from "./gate.ts"; + +export interface MarkerStore { + current(): Promise; +} + +export interface MarkerLog { + warn(msg: string): void; +} + +interface Snapshot { + generatedAt?: string; + markers?: unknown; +} + +export function createMarkerStore(config: Config, log: MarkerLog): MarkerStore { + const { source, file, provenanceAllowlist, ttlSeconds } = config.markers; + + let cached: MarkersBody | null = null; + let readAt = 0; + + async function load(): Promise { + const now = new Date().toISOString(); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(file, "utf8")); + } catch (err) { + log.warn(`markers: cannot read ${file} (${String(err)}); serving no markers`); + return { markers: [], generatedAt: now, refused: [] }; + } + + // A bare array is accepted because it is the obvious thing to hand-write, + // and a self-hoster's first marker file should not need a wrapper object. + const snapshot: Snapshot = + Array.isArray(parsed) ? { markers: parsed } : ((parsed ?? {}) as Snapshot); + + const { accepted, refused } = assertPublicShape(snapshot.markers ?? [], provenanceAllowlist); + for (const entry of refused) { + log.warn(`markers: refused ${entry.count} row(s) — ${entry.reason}`); + } + + return { + markers: accepted, + generatedAt: snapshot.generatedAt ?? now, + refused, + }; + } + + return { + async current(): Promise { + if (source === "none") { + return { markers: [], generatedAt: new Date().toISOString(), refused: [] }; + } + if (cached === null || Date.now() - readAt > ttlSeconds * 1000) { + cached = await load(); + readAt = Date.now(); + } + return cached; + }, + }; +} diff --git a/server/src/offices/store.ts b/server/src/offices/store.ts new file mode 100644 index 0000000..d5c2d91 --- /dev/null +++ b/server/src/offices/store.ts @@ -0,0 +1,65 @@ +/** + * Office packs, off disk. + * + * `TERA_OFFICES_DIR` holds one `.json` per office, each one an `OfficeDoc` + * wrapping the `Office` that `src/interiors/types.ts` defines — the same bytes a + * self-hoster hand-writes and drops in the directory. There is no database and + * no build step, because the format's whole claim is that a pack written by hand + * and a pack arriving over HTTP are the same thing. + * + * A box with no offices directory configured has no offices. That is the + * default, and it answers 404 to everything. + */ + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { OfficeDoc } from "../../../src/server/wire.ts"; + +/** + * Ids are the filename, so this is a path-traversal boundary and not a style + * preference. Lowercase, digits and hyphens; nothing that can climb out of the + * directory and nothing that means something different on a case-insensitive + * filesystem. + */ +const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; + +export interface OfficeStore { + get(id: string): Promise; +} + +export function createOfficeStore(dir: string): OfficeStore { + return { + async get(id: string): Promise { + if (dir === "" || !ID_PATTERN.test(id)) return null; + try { + const parsed: unknown = JSON.parse(await readFile(join(dir, `${id}.json`), "utf8")); + return normalise(parsed, id); + } catch { + // Missing, unreadable and unparseable are the same answer to a caller: + // there is no office here. Distinguishing them out loud would leak the + // directory listing one status code at a time. + return null; + } + }, + }; +} + +function normalise(parsed: unknown, id: string): OfficeDoc | null { + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const doc = parsed as Partial; + if (doc.floor === undefined || typeof doc.floor !== "object") return null; + + // Visibility defaults to `private`. A pack that forgot to say is a pack whose + // author has not thought about it yet, and the fail-closed reading of that is + // the only one that cannot embarrass anybody. + const visibility = + doc.visibility === "public" || doc.visibility === "unlisted" ? doc.visibility : "private"; + + return { + id: typeof doc.id === "string" ? doc.id : id, + name: typeof doc.name === "string" ? doc.name : id, + floor: doc.floor, + visibility, + ...(typeof doc.updated === "string" ? { updated: doc.updated } : {}), + }; +} diff --git a/server/src/routes/flights.ts b/server/src/routes/flights.ts new file mode 100644 index 0000000..004df17 --- /dev/null +++ b/server/src/routes/flights.ts @@ -0,0 +1,19 @@ +/** + * `GET /api/v1/flights`. + * + * Publicly cacheable, because the whole design of the plan is that one response + * serves every viewer for its whole TTL. Aircraft are not personal data and this + * body never varies by who asked. + */ + +import type { FastifyInstance } from "fastify"; +import { publicCache } from "../cache.ts"; +import type { Services } from "../services.ts"; + +export function registerFlights(app: FastifyInstance, services: Services): void { + app.get("/api/v1/flights", async (req, reply) => { + const body = await services.flights.current(); + publicCache(req, reply, body.ttlSeconds); + return body; + }); +} diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts new file mode 100644 index 0000000..ff02684 --- /dev/null +++ b/server/src/routes/health.ts @@ -0,0 +1,43 @@ +/** + * `GET /api/v1/health`. + * + * The route the CI job asserts on, so it has one job: answer 200 on a box that + * was handed nothing. It touches no upstream, reads no file and takes no lock — + * a health check that can be made to fail by a third party's outage is a health + * check that will page somebody at four in the morning about somebody else's + * server. + * + * `degraded` is what makes it more than a liveness probe: every demotion the + * config made is printed here, so "why is the weather always clear" has an + * answer that does not require log access. + */ + +import type { FastifyInstance } from "fastify"; +import type { HealthBody } from "../../../src/server/wire.ts"; +import type { Services } from "../services.ts"; + +export function registerHealth(app: FastifyInstance, services: Services): void { + const { config, startedAt } = services; + + app.get("/api/v1/health", async () => { + const body: HealthBody = { + ok: true, + service: "tera-api", + version: config.version, + uptimeSeconds: Math.round((Date.now() - startedAt) / 1000), + sources: { + weather: config.weather.source, + flights: config.flights.source, + markers: config.markers.source, + }, + auth: { + mode: config.auth.mode, + entryUrl: config.auth.mode === "sso" && config.auth.entryUrl !== "" + ? config.auth.entryUrl + : null, + }, + degraded: config.degraded, + }; + return body; + }); +} diff --git a/server/src/routes/markers.ts b/server/src/routes/markers.ts new file mode 100644 index 0000000..0ed102b --- /dev/null +++ b/server/src/routes/markers.ts @@ -0,0 +1,24 @@ +/** + * `GET /api/v1/markers`. + * + * The public snapshot, and nothing else. Private per-user markers are never + * proxied through this box — an authenticated browser calls Workie directly with + * its own token, so a private row never enters this process and cannot leave it. + * CONTRACT.md §5. + * + * Every row served here has been through the provenance gate in `markers/gate.ts`, + * which is what keeps a public snapshot from quietly becoming a Publicly Used + * Derivative Database under ODbL. + */ + +import type { FastifyInstance } from "fastify"; +import { publicCache } from "../cache.ts"; +import type { Services } from "../services.ts"; + +export function registerMarkers(app: FastifyInstance, services: Services): void { + app.get("/api/v1/markers", async (req, reply) => { + const body = await services.markers.current(); + publicCache(req, reply, services.config.markers.ttlSeconds); + return body; + }); +} diff --git a/server/src/routes/offices.ts b/server/src/routes/offices.ts new file mode 100644 index 0000000..f9323f2 --- /dev/null +++ b/server/src/routes/offices.ts @@ -0,0 +1,37 @@ +/** + * `GET /api/v1/offices/:id`. + * + * The one authenticated route, and the one place the 404 rule matters: an office + * the caller may not see answers **404, not 403**, and answers it identically to + * an office that does not exist. A 403 is an existence oracle — walk the id space + * and the status code tells you every tenant on the box. CONTRACT.md §6. + * + * The same reasoning is why the not-found path does no work first: resolve the + * viewer, load the doc, and take one exit. + */ + +import type { FastifyInstance } from "fastify"; +import { publicCache } from "../cache.ts"; +import type { ErrorBody } from "../../../src/server/wire.ts"; +import type { Services } from "../services.ts"; + +const NOT_FOUND: ErrorBody = { error: "not_found", message: "No such office." }; + +export function registerOffices(app: FastifyInstance, services: Services): void { + app.get<{ Params: { id: string } }>("/api/v1/offices/:id", async (req, reply) => { + const doc = await services.offices.get(req.params.id); + if (doc === null) return reply.code(404).send(NOT_FOUND); + + if (doc.visibility === "private") { + const viewer = await services.auth.resolve(req); + if (!viewer.authenticated) return reply.code(404).send(NOT_FOUND); + } + + // Only a public office may be cached by anything shared. An unlisted one is + // reachable by anybody holding the id, but it should not accumulate in a CDN + // where the id is no longer needed to find it. + if (doc.visibility === "public") publicCache(req, reply, services.config.publicMaxAge); + + return doc; + }); +} diff --git a/server/src/routes/weather.ts b/server/src/routes/weather.ts new file mode 100644 index 0000000..1b64dd3 --- /dev/null +++ b/server/src/routes/weather.ts @@ -0,0 +1,20 @@ +/** + * `GET /api/v1/weather`. + * + * Always 200, always a body. A source that is down, misconfigured or absent + * produces `synthetic: true` and a clear day — there is no failure mode here in + * which the caller has to decide what to render, because the answer to "what is + * the sky doing" is never allowed to be a 503. + */ + +import type { FastifyInstance } from "fastify"; +import { publicCache } from "../cache.ts"; +import type { Services } from "../services.ts"; + +export function registerWeather(app: FastifyInstance, services: Services): void { + app.get("/api/v1/weather", async (req, reply) => { + const body = await services.weather.current(); + publicCache(req, reply, services.config.weather.ttlSeconds); + return body; + }); +} diff --git a/server/src/services.ts b/server/src/services.ts new file mode 100644 index 0000000..0a508cf --- /dev/null +++ b/server/src/services.ts @@ -0,0 +1,43 @@ +/** + * Everything a route needs, built once and handed in. + * + * Routes get this object rather than reaching for module-level singletons, which + * is what makes the tests able to stand a whole server up against a fake + * environment in-process. Each service owns its own cache and its own failure + * behaviour; none of them can throw at a route. + */ + +import { createAuth, type AuthService } from "./auth/index.ts"; +import { createFlightsService, type FlightsService } from "./flights/index.ts"; +import { createMarkerStore, type MarkerStore } from "./markers/store.ts"; +import { createOfficeStore, type OfficeStore } from "./offices/store.ts"; +import { createWeatherService, type WeatherService } from "./weather/index.ts"; +import type { Config } from "./config.ts"; + +export interface Services { + config: Config; + weather: WeatherService; + flights: FlightsService; + markers: MarkerStore; + offices: OfficeStore; + auth: AuthService; + /** Epoch milliseconds, for `uptimeSeconds` on the health body. */ + startedAt: number; +} + +/** The minimum a service needs from a logger. Fastify's satisfies it. */ +export interface ServiceLog { + warn(msg: string): void; +} + +export function createServices(config: Config, log: ServiceLog): Services { + return { + config, + weather: createWeatherService(config, log), + flights: createFlightsService(config, log), + markers: createMarkerStore(config, log), + offices: createOfficeStore(config.offices.dir), + auth: createAuth(config.auth), + startedAt: Date.now(), + }; +} diff --git a/server/src/sync/workie.ts b/server/src/sync/workie.ts new file mode 100644 index 0000000..a0374a9 --- /dev/null +++ b/server/src/sync/workie.ts @@ -0,0 +1,97 @@ +/** + * The sync oneshot — the only second process, and the only holder of a credential. + * + * It runs on a timer, asks Workie for the public marker set, puts every row + * through the same gate the API serves behind, and writes a snapshot the API + * reads off disk. The isolation is the point: the public box never holds a + * Workie token, never opens an outbound connection to a private service, and a + * compromise of it yields a file that was already public. CONTRACT.md §5. + * + * ### Provenance is not optional + * + * Every row must say where its coordinate came from, and the gate refuses + * anything whose answer is not on the non-ODbL allowlist. A row that arrives + * without a provenance is refused rather than assumed — `TERA_SYNC_PROVENANCE` + * exists so an operator can *assert* one for a feed that predates the field, and + * setting it is a licence claim they are making on the record. + * + * This is fail-closed by design: **one refused row aborts the whole sync and + * leaves the previous snapshot in place.** Publishing coordinates whose licence + * nobody can vouch for is the failure that cannot be undone by a later fix, + * because ODbL §4.4 would already have attached to everything served alongside + * them. A stale map is a much cheaper mistake. CONTRACT.md §8. + * + * Usage: TERA_SYNC_SOURCE_URL=... TERA_SYNC_TOKEN=... TERA_MARKERS_FILE=... npm run sync + */ + +import { rename, writeFile } from "node:fs/promises"; +import { getJson } from "../http.ts"; +import { assertPublicShape } from "../markers/gate.ts"; +import { DEFAULT_PROVENANCE_ALLOWLIST } from "../config.ts"; + +interface UpstreamBody { + markers?: unknown; + generatedAt?: string; +} + +async function main(): Promise { + const sourceUrl = process.env["TERA_SYNC_SOURCE_URL"] ?? ""; + const token = process.env["TERA_SYNC_TOKEN"] ?? ""; + const target = process.env["TERA_MARKERS_FILE"] ?? ""; + const stamp = process.env["TERA_SYNC_PROVENANCE"] ?? ""; + const allowlist = (process.env["TERA_MARKERS_PROVENANCE_ALLOWLIST"] ?? "") + .split(",") + .map((s) => s.trim()) + .filter((s) => s !== ""); + + if (sourceUrl === "" || target === "") { + console.error("sync: TERA_SYNC_SOURCE_URL and TERA_MARKERS_FILE are both required."); + return 2; + } + + const body = await getJson(sourceUrl, { + headers: token === "" ? {} : { authorization: `Bearer ${token}` }, + timeoutMs: 20_000, + }); + if (body === null) { + console.error(`sync: ${sourceUrl} did not answer; leaving the snapshot alone.`); + return 1; + } + + const rows = Array.isArray(body.markers) ? body.markers : []; + const stamped = stamp === "" ? rows : rows.map((row) => withProvenance(row, stamp)); + + const { accepted, refused } = assertPublicShape( + stamped, + allowlist.length > 0 ? allowlist : DEFAULT_PROVENANCE_ALLOWLIST, + ); + + if (refused.length > 0) { + for (const entry of refused) console.error(`sync: REFUSED ${entry.count} row(s) — ${entry.reason}`); + console.error("sync: aborting without writing. The previous snapshot is untouched."); + return 1; + } + + const snapshot = { + generatedAt: body.generatedAt ?? new Date().toISOString(), + markers: accepted, + }; + + // Write beside the target and rename, so the API can never read a half-written + // file — `rename` within a directory is atomic and the reader has no lock. + const temp = `${target}.tmp`; + await writeFile(temp, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8"); + await rename(temp, target); + + console.log(`sync: wrote ${accepted.length} marker(s) to ${target}`); + return 0; +} + +/** Assert a provenance on rows that do not carry one. Never overwrites. */ +function withProvenance(row: unknown, provenance: string): unknown { + if (row === null || typeof row !== "object" || Array.isArray(row)) return row; + const record = row as Record; + return "provenance" in record ? record : { ...record, provenance }; +} + +process.exitCode = await main(); diff --git a/server/src/test/boot.test.ts b/server/src/test/boot.test.ts new file mode 100644 index 0000000..145d0ca --- /dev/null +++ b/server/src/test/boot.test.ts @@ -0,0 +1,158 @@ +/** + * The acceptance test: a stranger with no keys, no account and no environment. + * + * This is CONTRACT.md §5.1 written as code, because it is the thing two + * independent server designs got wrong in the same way — a weather source that + * defaults to a provider needing a contact string, and a hard failure when it is + * absent. The last test in this file starts the real entry point under a + * genuinely empty environment and asks it for its health, which is the same + * assertion the `docker compose up` CI job makes from outside. + */ + +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { after, describe, it } from "node:test"; +import { buildApp } from "../app.ts"; +import { loadConfig } from "../config.ts"; +import type { + FlightsBody, + HealthBody, + MarkersBody, + WeatherBody, +} from "../../../src/server/wire.ts"; + +/** `loadConfig({})` is exactly what `env -i` produces, minus the process. */ +function emptyEnvApp() { + const config = loadConfig({}); + config.logLevel = "silent"; + return buildApp(config); +} + +describe("a box handed nothing", () => { + it("defaults every source to its keyless setting and reports no demotions", () => { + const config = loadConfig({}); + assert.equal(config.weather.source, "none"); + assert.equal(config.flights.source, "sim"); + assert.equal(config.markers.source, "none"); + assert.equal(config.auth.mode, "none"); + assert.equal(config.host, "127.0.0.1"); + assert.equal(config.port, 8431); + assert.deepEqual(config.degraded, []); + }); + + it("answers health", async () => { + const app = emptyEnvApp(); + 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.ok, true); + assert.equal(body.auth.mode, "none"); + assert.equal(body.auth.entryUrl, null); + assert.deepEqual(body.degraded, []); + }); + + it("serves a synthetic clear day rather than failing on a missing contact", async () => { + const app = emptyEnvApp(); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/weather" }); + assert.equal(res.statusCode, 200); + + const body = res.json(); + assert.equal(body.synthetic, true); + assert.equal(body.source, "none"); + assert.equal(body.condition, "clear"); + }); + + it("serves the simulated sky as a plan, not as positions", async () => { + const app = emptyEnvApp(); + after(() => app.close()); + + const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json(); + assert.equal(body.mode, "plan"); + assert.equal(body.source, "sim"); + assert.ok(body.mode === "plan" && body.routes.length > 0); + // A fixed origin, so a restart does not teleport every aircraft. + assert.ok(body.mode === "plan" && body.t0 < Date.now()); + }); + + it("serves no markers and says so, rather than 404ing the route", async () => { + const app = emptyEnvApp(); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/markers" }); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.json().markers, []); + }); + + it("has no offices", async () => { + const app = emptyEnvApp(); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/offices/anything" }); + assert.equal(res.statusCode, 404); + }); +}); + +describe("cache-control is fail-closed", () => { + it("stamps private, no-store on anything that did not opt in", async () => { + const app = emptyEnvApp(); + after(() => app.close()); + + const health = await app.inject({ method: "GET", url: "/api/v1/health" }); + assert.equal(health.headers["cache-control"], "private, no-store"); + + const missing = await app.inject({ method: "GET", url: "/api/v1/nope" }); + assert.equal(missing.statusCode, 404); + assert.equal(missing.headers["cache-control"], "private, no-store"); + }); + + it("lets a route opt in explicitly", async () => { + const app = emptyEnvApp(); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/flights" }); + assert.match(String(res.headers["cache-control"]), /^public, max-age=\d+$/); + }); + + it("refuses to opt in when the request carried a credential", async () => { + const app = emptyEnvApp(); + after(() => app.close()); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/flights", + headers: { authorization: "Bearer something" }, + }); + assert.equal(res.headers["cache-control"], "private, no-store"); + }); +}); + +describe("the real process under env -i", () => { + it("boots and answers health with no environment at all", async () => { + const entry = new URL("../index.ts", import.meta.url).pathname; + // A genuinely empty environment: no PATH, no HOME, no TERA_*. `execPath` is + // absolute, so the child needs nothing from the parent to start. + const child = spawn(process.execPath, [entry], { env: {}, stdio: "ignore" }); + after(() => child.kill("SIGKILL")); + + const url = "http://127.0.0.1:8431/api/v1/health"; + const deadline = Date.now() + 15_000; + let body: HealthBody | null = null; + while (Date.now() < deadline && body === null) { + try { + const res = await fetch(url); + if (res.ok) body = (await res.json()) as HealthBody; + } catch { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + + assert.ok(body !== null, "the server never answered on 127.0.0.1:8431"); + assert.equal(body.ok, true); + assert.equal(body.service, "tera-api"); + }); +}); diff --git a/server/src/test/demotion.test.ts b/server/src/test/demotion.test.ts new file mode 100644 index 0000000..6a23de0 --- /dev/null +++ b/server/src/test/demotion.test.ts @@ -0,0 +1,94 @@ +/** + * Demotion, not fatality. + * + * Every case here is a misconfiguration that an earlier design would have + * thrown on at boot. The rule is that the server comes up, says exactly what it + * gave up on, and serves the degraded body — a self-hoster who typed the wrong + * thing gets a working map and a sentence explaining it, not a process that + * refuses to start. CONTRACT.md §5.1. + */ + +import assert from "node:assert/strict"; +import { after, describe, it } from "node:test"; +import { buildApp } from "../app.ts"; +import { loadConfig } from "../config.ts"; +import type { HealthBody, WeatherBody } from "../../../src/server/wire.ts"; + +function appWith(env: Record) { + const config = loadConfig(env); + config.logLevel = "silent"; + return { config, app: buildApp(config) }; +} + +describe("a weather source configured without a contact", () => { + it("demotes to synthetic and says why", async () => { + const { config, app } = appWith({ TERA_WEATHER_SOURCE: "nws" }); + after(() => app.close()); + + assert.equal(config.weather.source, "none"); + assert.equal(config.degraded.length, 1); + assert.match(config.degraded[0] ?? "", /TERA_WEATHER_CONTACT/); + + const health = (await app.inject({ method: "GET", url: "/api/v1/health" })).json(); + assert.equal(health.ok, true); + assert.equal(health.sources.weather, "none"); + assert.equal(health.degraded.length, 1); + + const weather = ( + await app.inject({ method: "GET", url: "/api/v1/weather" }) + ).json(); + assert.equal(weather.synthetic, true); + }); + + it("keeps the source once a contact is present", () => { + const { config, app } = appWith({ + TERA_WEATHER_SOURCE: "nws", + TERA_WEATHER_CONTACT: "ops@example.com", + }); + after(() => app.close()); + assert.equal(config.weather.source, "nws"); + assert.deepEqual(config.degraded, []); + }); +}); + +describe("other misconfigurations", () => { + it("falls back on an unknown source name rather than exiting", () => { + const { config, app } = appWith({ TERA_WEATHER_SOURCE: "accuweather" }); + after(() => app.close()); + assert.equal(config.weather.source, "none"); + assert.match(config.degraded[0] ?? "", /accuweather/); + }); + + it("falls back on a port that is not a number", () => { + const config = loadConfig({ TERA_PORT: "banana" }); + assert.equal(config.port, 8431); + assert.match(config.degraded[0] ?? "", /TERA_PORT/); + }); + + it("records that Open-Meteo is a non-commercial tier without disabling it", () => { + const config = loadConfig({ TERA_WEATHER_SOURCE: "openmeteo" }); + assert.equal(config.weather.source, "openmeteo"); + assert.match(config.degraded[0] ?? "", /non-commercial/); + }); + + it("demotes sso with nowhere to revalidate, taking private offices with it", () => { + const config = loadConfig({ TERA_AUTH_MODE: "sso", TERA_AUTH_ENTRY_URL: "https://example" }); + assert.equal(config.auth.mode, "none"); + assert.match(config.degraded[0] ?? "", /TERA_AUTH_REVALIDATE_URL/); + }); + + it("demotes jwt with no secret and no JWKS", () => { + const config = loadConfig({ TERA_AUTH_MODE: "jwt" }); + assert.equal(config.auth.mode, "none"); + }); + + it("demotes dump1090 with no path to read", () => { + const config = loadConfig({ TERA_FLIGHTS_SOURCE: "dump1090" }); + assert.equal(config.flights.source, "sim"); + }); + + it("demotes a file marker source with no file", () => { + const config = loadConfig({ TERA_MARKERS_SOURCE: "file" }); + assert.equal(config.markers.source, "none"); + }); +}); diff --git a/server/src/test/gate.test.ts b/server/src/test/gate.test.ts new file mode 100644 index 0000000..253b1c6 --- /dev/null +++ b/server/src/test/gate.test.ts @@ -0,0 +1,80 @@ +/** + * The provenance gate, which is the sharpest correction in CONTRACT.md and the + * one with an actual licence behind it. + * + * The row that must be refused is the one that looks completely fine: correct + * fields, plausible coordinates, and a provenance of `nominatim`. Serving it + * would make this endpoint a Publicly Used Derivative Database and pull ODbL + * §4.3 and §4.4 onto everything served next to it. See CONTRACT.md §8. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { DEFAULT_PROVENANCE_ALLOWLIST } from "../config.ts"; +import { assertPublicShape } from "../markers/gate.ts"; + +const good = { + id: "acme-hq", + label: "Acme", + colorKey: "sector.industrial", + lat: 37.79, + lng: -122.4, + provenance: "us-census", +}; + +describe("the public-shape gate", () => { + it("accepts a US Census row", () => { + const { accepted, refused } = assertPublicShape([good], DEFAULT_PROVENANCE_ALLOWLIST); + assert.equal(accepted.length, 1); + assert.deepEqual(refused, []); + assert.equal(accepted[0]?.provenance, "us-census"); + }); + + it("refuses an OSM-derived row that is otherwise perfect", () => { + const row = { ...good, provenance: "nominatim" }; + const { accepted, refused } = assertPublicShape([row], DEFAULT_PROVENANCE_ALLOWLIST); + assert.equal(accepted.length, 0); + assert.equal(refused.length, 1); + assert.match(refused[0]?.reason ?? "", /allowlist/); + }); + + it("refuses a row with no provenance at all", () => { + const { provenance: _omitted, ...row } = good; + const { accepted } = assertPublicShape([row], DEFAULT_PROVENANCE_ALLOWLIST); + assert.equal(accepted.length, 0); + }); + + it("refuses commercial geocoders too — 'not OSM' is not the test", () => { + for (const provenance of ["google", "mapbox", "here"]) { + const { accepted } = assertPublicShape( + [{ ...good, provenance }], + DEFAULT_PROVENANCE_ALLOWLIST, + ); + assert.equal(accepted.length, 0, `${provenance} must not pass`); + } + }); + + it("refuses the whole row when it carries a field nobody reviewed", () => { + const row = { ...good, ownerEmail: "someone@example.com" }; + const { accepted, refused } = assertPublicShape([row], DEFAULT_PROVENANCE_ALLOWLIST); + assert.equal(accepted.length, 0); + assert.match(refused[0]?.reason ?? "", /unknown field "ownerEmail"/); + }); + + it("refuses malformed coordinates", () => { + const rows = [ + { ...good, lat: 200 }, + { ...good, lng: "west" }, + { ...good, id: "" }, + ]; + const { accepted } = assertPublicShape(rows, DEFAULT_PROVENANCE_ALLOWLIST); + assert.equal(accepted.length, 0); + }); + + it("aggregates refusals so a broken sync is one line, not ten thousand", () => { + const rows = Array.from({ length: 500 }, () => ({ ...good, provenance: "osm" })); + const { refused } = assertPublicShape(rows, DEFAULT_PROVENANCE_ALLOWLIST); + assert.equal(refused.length, 1); + assert.equal(refused[0]?.count, 500); + }); +}); diff --git a/server/src/test/offices.test.ts b/server/src/test/offices.test.ts new file mode 100644 index 0000000..fab1843 --- /dev/null +++ b/server/src/test/offices.test.ts @@ -0,0 +1,149 @@ +/** + * Offices, visibility, and the 404 rule. + * + * The assertion that matters is the negative one: a private office and an office + * that was never created must be indistinguishable from outside. If they differ + * — by status code, by body, by timing anybody could measure — the endpoint + * becomes a way to enumerate tenants. CONTRACT.md §6. + * + * HS256 is exercised directly here because it is the primary path: the issuer + * this runs against signs `{"alg":"HS256"}`, and a JWKS-only implementation + * would reject every real token. + */ + +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 type { OfficeDoc } from "../../../src/server/wire.ts"; + +const SECRET = "not-a-real-secret-and-never-was"; + +/** A minimal floor. `Plan` is what makes sense of it; the API only carries it. */ +const floor = { id: "hq", name: "HQ", levels: [], viewpoints: [] }; + +let dir = ""; + +before(async () => { + dir = await mkdtemp(join(tmpdir(), "tera-offices-")); + await writeFile( + join(dir, "open.json"), + JSON.stringify({ id: "open", name: "Open office", visibility: "public", floor }), + ); + await writeFile( + join(dir, "closed.json"), + JSON.stringify({ id: "closed", name: "Closed office", visibility: "private", floor }), + ); + await writeFile( + join(dir, "quiet.json"), + JSON.stringify({ id: "quiet", name: "Unlisted office", visibility: "unlisted", floor }), + ); + // No `visibility` at all: the fail-closed reading is `private`. + await writeFile(join(dir, "vague.json"), JSON.stringify({ id: "vague", name: "?", floor })); +}); + +function appWith(env: Record) { + const config = loadConfig({ TERA_OFFICES_DIR: dir, ...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")}`; +} + +describe("with auth off", () => { + it("serves a public office and lets it be cached", async () => { + const app = appWith({}); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/offices/open" }); + assert.equal(res.statusCode, 200); + assert.equal(res.json().floor.id, "hq"); + assert.match(String(res.headers["cache-control"]), /^public, max-age=/); + }); + + it("serves an unlisted office but never lets a shared cache keep it", async () => { + const app = appWith({}); + after(() => app.close()); + + const res = await app.inject({ method: "GET", url: "/api/v1/offices/quiet" }); + assert.equal(res.statusCode, 200); + assert.equal(res.headers["cache-control"], "private, no-store"); + }); + + it("answers 404 for a private office, identically to one that does not exist", async () => { + const app = appWith({}); + after(() => app.close()); + + const priv = await app.inject({ method: "GET", url: "/api/v1/offices/closed" }); + const absent = await app.inject({ method: "GET", url: "/api/v1/offices/no-such-office" }); + + assert.equal(priv.statusCode, 404); + assert.equal(absent.statusCode, 404); + assert.deepEqual(priv.json(), absent.json()); + }); + + it("treats a pack that forgot to declare visibility as private", async () => { + const app = appWith({}); + after(() => app.close()); + assert.equal((await app.inject({ method: "GET", url: "/api/v1/offices/vague" })).statusCode, 404); + }); + + it("refuses an id that could climb out of the directory", async () => { + const app = appWith({}); + after(() => app.close()); + for (const id of ["..", "..%2f..%2fetc%2fpasswd", "Open", "open.json"]) { + const res = await app.inject({ method: "GET", url: `/api/v1/offices/${id}` }); + assert.equal(res.statusCode, 404, `${id} must not resolve`); + } + }); +}); + +describe("with TERA_AUTH_MODE=jwt", () => { + const env = { TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: SECRET }; + + it("opens a private office to a valid HS256 token", async () => { + const app = appWith(env); + after(() => app.close()); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/offices/closed", + headers: { authorization: `Bearer ${hs256({ sub: "someone", exp: now() + 600 })}` }, + }); + assert.equal(res.statusCode, 200); + assert.equal(res.headers["cache-control"], "private, no-store"); + }); + + it("still answers 404 to an expired token, a wrong secret, or alg: none", async () => { + const app = appWith(env); + after(() => app.close()); + + const expired = hs256({ sub: "someone", exp: now() - 3600 }); + const wrong = `${hs256({ sub: "someone" }).split(".").slice(0, 2).join(".")}.deadbeef`; + const none = `${Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")}.${Buffer.from( + JSON.stringify({ sub: "someone" }), + ).toString("base64url")}.`; + + for (const token of [expired, wrong, none, "not-a-jwt"]) { + const res = await app.inject({ + method: "GET", + url: "/api/v1/offices/closed", + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(res.statusCode, 404); + } + }); +}); + +function now(): number { + return Math.floor(Date.now() / 1000); +} diff --git a/server/src/weather/condition.ts b/server/src/weather/condition.ts new file mode 100644 index 0000000..bc35738 --- /dev/null +++ b/server/src/weather/condition.ts @@ -0,0 +1,44 @@ +/** + * One reading of the sky, from whichever numbers a source happened to report. + * + * Every provider has its own vocabulary — NWS sends METAR cloud layers, MET + * Norway sends a symbol code, Open-Meteo sends a WMO weather code — and the + * renderer wants none of them. Deriving the condition from cloud cover, + * precipitation and visibility means one rule for all three sources, and it + * means a source that stops sending its symbol still produces a usable sky. + */ + +import type { WeatherCondition } from "../../../src/server/wire.ts"; + +export interface ConditionInput { + /** 0..1. */ + cloudCover: number; + /** 0..1 intensity. */ + precipitation: number; + visibilityKm: number | null; + /** The source said the precipitation was frozen. */ + frozen?: boolean; + thunder?: boolean; +} + +export function deriveCondition(input: ConditionInput): WeatherCondition { + if (input.thunder === true) return "thunderstorm"; + if (input.precipitation > 0.02) return input.frozen === true ? "snow" : "rain"; + // Fog is checked after precipitation because rain reduces visibility too, and + // "it is raining" is the more useful thing to say about a rainy afternoon. + if (input.visibilityKm !== null && input.visibilityKm < 1.5) return "fog"; + if (input.cloudCover >= 0.9) return "overcast"; + if (input.cloudCover >= 0.6) return "cloudy"; + if (input.cloudCover >= 0.25) return "partly-cloudy"; + return "clear"; +} + +/** Clamp to the 0..1 the wire promises, and turn a missing value into 0. */ +export function unitRange(value: number | null | undefined): number { + if (value === null || value === undefined || !Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} + +export function finiteOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} diff --git a/server/src/weather/index.ts b/server/src/weather/index.ts new file mode 100644 index 0000000..ad4e6ea --- /dev/null +++ b/server/src/weather/index.ts @@ -0,0 +1,73 @@ +/** + * Which source answers, how often it is asked, and what happens when it does not. + * + * Three rules, in order of how much trouble getting them wrong causes: + * + * 1. **This never throws.** `current()` always resolves to a `WeatherBody`. + * 2. **A dead upstream serves the last good observation**, and only falls back + * to the clear day if there has never been one. Ten-minute-old weather is + * better than no weather and much better than a 503. + * 3. **Nothing is fetched until somebody asks.** A box nobody visits makes no + * outbound requests, which matters when the source is a public good with a + * rate limit and a fair-use policy. + */ + +import type { Config } from "../config.ts"; +import type { WeatherBody } from "../../../src/server/wire.ts"; +import { fetchMetno } from "./metno.ts"; +import { fetchNws } from "./nws.ts"; +import { fetchOpenMeteo } from "./openmeteo.ts"; +import { clearDay } from "./synthetic.ts"; + +export interface WeatherService { + current(): Promise; +} + +export interface WeatherLog { + warn(msg: string): void; +} + +export function createWeatherService(config: Config, log: WeatherLog): WeatherService { + const { lat, lng } = config.origin; + const { source, contact, ttlSeconds } = config.weather; + + let cached: WeatherBody | null = null; + let fetchedAt = 0; + let inFlight: Promise | null = null; + + async function refresh(): Promise { + const fresh = + source === "nws" + ? await fetchNws(lat, lng, contact) + : source === "metno" + ? await fetchMetno(lat, lng, contact) + : source === "openmeteo" + ? await fetchOpenMeteo(lat, lng) + : null; + + // Stamp the clock either way. A source that is down should be retried on the + // same cadence as one that is up, not hammered once per request. + fetchedAt = Date.now(); + if (fresh !== null) { + cached = fresh; + return; + } + log.warn(`weather: ${source} did not answer; serving ${cached === null ? "a synthetic clear day" : "the last observation"}`); + } + + return { + async current(): Promise { + if (source === "none") return clearDay(lat, lng); + + const stale = Date.now() - fetchedAt > ttlSeconds * 1000; + if (stale) { + // Collapse concurrent misses into one upstream request. + inFlight ??= refresh().finally(() => { + inFlight = null; + }); + await inFlight; + } + return cached ?? clearDay(lat, lng); + }, + }; +} diff --git a/server/src/weather/metno.ts b/server/src/weather/metno.ts new file mode 100644 index 0000000..d21f839 --- /dev/null +++ b/server/src/weather/metno.ts @@ -0,0 +1,79 @@ +/** + * api.met.no — the Norwegian Meteorological Institute. + * + * The global fallback, for the same reason NWS is the default: it is free, + * keyless, and its licence (CC BY 4.0) is one a public map can actually satisfy + * by printing a line of attribution — which is what the `attribution` field on + * the wire carries, and which the consumer is expected to display. + * + * MET requires an identifiable User-Agent and will block callers who do not send + * one, which is why an empty `TERA_WEATHER_CONTACT` demotes this source in + * `config.ts` rather than being quietly worked around here. + */ + +import { getJson, userAgent } from "../http.ts"; +import type { WeatherBody } from "../../../src/server/wire.ts"; +import { deriveCondition, finiteOrNull, unitRange } from "./condition.ts"; + +const BASE = "https://api.met.no/weatherapi/locationforecast/2.0/compact"; + +interface ForecastResponse { + properties?: { + timeseries?: { + time?: string; + data?: { + instant?: { details?: Record }; + next_1_hours?: { + summary?: { symbol_code?: string }; + details?: { precipitation_amount?: number }; + }; + }; + }[]; + }; +} + +export async function fetchMetno( + lat: number, + lng: number, + contact: string, +): Promise { + // MET asks callers to truncate coordinates to four decimals so their cache + // works; a request for 37.774929 and one for 37.7749 are the same weather. + const url = `${BASE}?lat=${lat.toFixed(4)}&lon=${lng.toFixed(4)}`; + const body = await getJson(url, { + headers: { "user-agent": userAgent(contact) }, + }); + + const entry = body?.properties?.timeseries?.[0]; + const instant = entry?.data?.instant?.details; + if (entry === undefined || instant === undefined) return null; + + const symbol = entry.data?.next_1_hours?.summary?.symbol_code ?? ""; + const mm = entry.data?.next_1_hours?.details?.precipitation_amount ?? 0; + // Millimetres in the coming hour, read as an intensity dial: 4 mm/h is + // thoroughly wet, and the renderer has nothing to do with anything past that. + const precipitation = unitRange(mm / 4); + const cloudCover = unitRange((instant["cloud_area_fraction"] ?? 0) / 100); + const windMs = finiteOrNull(instant["wind_speed"]); + + return { + observedAt: entry.time ?? new Date().toISOString(), + source: "metno", + synthetic: false, + location: { lat, lng }, + temperatureC: finiteOrNull(instant["air_temperature"]), + windKph: windMs === null ? null : windMs * 3.6, + windDirDeg: finiteOrNull(instant["wind_from_direction"]), + cloudCover, + precipitation, + visibilityKm: null, + condition: deriveCondition({ + cloudCover, + precipitation, + visibilityKm: null, + frozen: symbol.includes("snow"), + thunder: symbol.includes("thunder"), + }), + attribution: ["Weather data from MET Norway (met.no), licensed CC BY 4.0"], + }; +} diff --git a/server/src/weather/nws.ts b/server/src/weather/nws.ts new file mode 100644 index 0000000..3993e3c --- /dev/null +++ b/server/src/weather/nws.ts @@ -0,0 +1,158 @@ +/** + * api.weather.gov — the National Weather Service. + * + * The default source once a contact is configured, and the reason is licensing + * rather than quality: NWS output is a work of the United States government and + * is in the public domain, so nothing served downstream of it carries an + * attribution or share-alike obligation. It is also keyless. The cost is that it + * covers the US only, which is why `metno` exists. + * + * Getting an observation takes three hops — point to station list, station list + * to nearest station, station to latest observation — so the first two are + * resolved once per process and kept. Stations do not move. + */ + +import { getJson, userAgent } from "../http.ts"; +import type { WeatherBody } from "../../../src/server/wire.ts"; +import { deriveCondition, finiteOrNull } from "./condition.ts"; + +const BASE = "https://api.weather.gov"; + +interface PointsResponse { + properties?: { observationStations?: string }; +} + +interface StationsResponse { + features?: { properties?: { stationIdentifier?: string } }[]; +} + +interface Measurement { + value?: number | null; + unitCode?: string; +} + +interface ObservationResponse { + properties?: { + timestamp?: string; + temperature?: Measurement; + windSpeed?: Measurement; + windDirection?: Measurement; + visibility?: Measurement; + cloudLayers?: { amount?: string }[]; + presentWeather?: { weather?: string; intensity?: string | null }[]; + }; +} + +const stationCache = new Map(); + +export async function fetchNws( + lat: number, + lng: number, + contact: string, +): Promise { + const headers = { "user-agent": userAgent(contact) }; + const station = await resolveStation(lat, lng, headers); + if (station === null) return null; + + const obs = await getJson( + `${BASE}/stations/${encodeURIComponent(station)}/observations/latest`, + { headers }, + ); + const p = obs?.properties; + if (p === undefined) return null; + + const cloudCover = cloudFromLayers(p.cloudLayers); + const present = p.presentWeather ?? []; + const precipitation = precipitationFrom(present); + const visibilityKm = metresToKm(p.visibility); + + return { + observedAt: p.timestamp ?? new Date().toISOString(), + source: "nws", + synthetic: false, + location: { lat, lng }, + temperatureC: finiteOrNull(p.temperature?.value), + windKph: toKph(p.windSpeed), + windDirDeg: finiteOrNull(p.windDirection?.value), + cloudCover, + precipitation, + visibilityKm, + condition: deriveCondition({ + cloudCover, + precipitation, + visibilityKm, + frozen: present.some((w) => (w.weather ?? "").includes("snow")), + thunder: present.some((w) => (w.weather ?? "").includes("thunder")), + }), + // No attribution block: US government works carry no such obligation, and + // claiming one would be inventing a licence term. + }; +} + +async function resolveStation( + lat: number, + lng: number, + headers: Record, +): Promise { + const key = `${lat.toFixed(4)},${lng.toFixed(4)}`; + const cached = stationCache.get(key); + if (cached !== undefined) return cached; + + const point = await getJson(`${BASE}/points/${key}`, { headers }); + const stationsUrl = point?.properties?.observationStations; + if (stationsUrl === undefined) return null; + + const stations = await getJson(stationsUrl, { headers }); + // The list arrives nearest-first, which is the only ordering guarantee needed. + const id = stations?.features?.[0]?.properties?.stationIdentifier; + if (id === undefined) return null; + + stationCache.set(key, id); + return id; +} + +/** METAR sky cover, as a fraction. The reported layers are cumulative, so the + * densest one is the sky. */ +function cloudFromLayers(layers: { amount?: string }[] | undefined): number { + if (layers === undefined || layers.length === 0) return 0; + let max = 0; + for (const layer of layers) { + const amount = layer.amount ?? ""; + const fraction = + amount === "OVC" || amount === "VV" + ? 1 + : amount === "BKN" + ? 0.75 + : amount === "SCT" + ? 0.4 + : amount === "FEW" + ? 0.15 + : 0; + if (fraction > max) max = fraction; + } + return max; +} + +function precipitationFrom(present: { weather?: string; intensity?: string | null }[]): number { + let max = 0; + for (const entry of present) { + const weather = entry.weather ?? ""; + if (!/rain|drizzle|snow|sleet|hail|thunder/.test(weather)) continue; + const intensity = entry.intensity ?? "moderate"; + const value = intensity === "light" ? 0.3 : intensity === "heavy" ? 0.9 : 0.6; + if (value > max) max = value; + } + return max; +} + +/** Wind arrives as km/h from most stations and m/s from a few. Both are labelled. */ +function toKph(m: Measurement | undefined): number | null { + const value = finiteOrNull(m?.value); + if (value === null) return null; + return (m?.unitCode ?? "").includes("m_s") ? value * 3.6 : value; +} + +function metresToKm(m: Measurement | undefined): number | null { + const value = finiteOrNull(m?.value); + return value === null ? null : value / 1000; +} diff --git a/server/src/weather/openmeteo.ts b/server/src/weather/openmeteo.ts new file mode 100644 index 0000000..6126928 --- /dev/null +++ b/server/src/weather/openmeteo.ts @@ -0,0 +1,77 @@ +/** + * open-meteo.com — opt-in, and off by default. + * + * This one needs its reason on the page, because on the numbers it is the best + * of the three: global, keyless, no contact string, one request, and it reports + * visibility and cloud cover directly. The problem is not the data — that is + * CC BY 4.0 — it is the *tier*. Open-Meteo's free API is for non-commercial use, + * and a product page is a commercial use. Shipping it as the default would put + * every self-hoster on a footing the project cannot vouch for. + * + * So it is here, it works, and turning it on is a decision the operator makes + * with `TERA_WEATHER_SOURCE=openmeteo`. CONTRACT.md §5.2. + */ + +import { getJson } from "../http.ts"; +import type { WeatherBody } from "../../../src/server/wire.ts"; +import { deriveCondition, finiteOrNull, unitRange } from "./condition.ts"; + +const BASE = "https://api.open-meteo.com/v1/forecast"; + +const FIELDS = [ + "temperature_2m", + "precipitation", + "cloud_cover", + "visibility", + "wind_speed_10m", + "wind_direction_10m", + "weather_code", +].join(","); + +interface ForecastResponse { + current?: Record; +} + +export async function fetchOpenMeteo(lat: number, lng: number): Promise { + const url = + `${BASE}?latitude=${lat.toFixed(4)}&longitude=${lng.toFixed(4)}` + + `¤t=${FIELDS}&wind_speed_unit=kmh&timezone=UTC`; + const body = await getJson(url); + const current = body?.current; + if (current === undefined) return null; + + const num = (key: string): number | null => finiteOrNull(current[key]); + const cloudCover = unitRange((num("cloud_cover") ?? 0) / 100); + const precipitation = unitRange((num("precipitation") ?? 0) / 4); + const visibilityM = num("visibility"); + const visibilityKm = visibilityM === null ? null : visibilityM / 1000; + const code = num("weather_code") ?? 0; + + return { + observedAt: isoTime(current["time"]), + source: "openmeteo", + synthetic: false, + location: { lat, lng }, + temperatureC: num("temperature_2m"), + windKph: num("wind_speed_10m"), + windDirDeg: num("wind_direction_10m"), + cloudCover, + precipitation, + visibilityKm, + condition: deriveCondition({ + cloudCover, + precipitation, + visibilityKm, + // WMO 4677 codes: 71-77 and 85-86 are the frozen ones, 95-99 thunder. + frozen: (code >= 71 && code <= 77) || code === 85 || code === 86, + thunder: code >= 95, + }), + attribution: ["Weather data by Open-Meteo.com, licensed CC BY 4.0"], + }; +} + +/** Open-Meteo stamps `2026-08-04T22:00` with no zone marker; we asked for UTC. */ +function isoTime(time: string | number | undefined): string { + if (typeof time !== "string") return new Date().toISOString(); + return time.endsWith("Z") ? time : `${time}Z`; +} diff --git a/server/src/weather/synthetic.ts b/server/src/weather/synthetic.ts new file mode 100644 index 0000000..2efe2fd --- /dev/null +++ b/server/src/weather/synthetic.ts @@ -0,0 +1,33 @@ +/** + * The clear day. + * + * This is what a box with no weather source configured serves, and it is a + * supported steady state rather than an error path — the default value of + * `TERA_WEATHER_SOURCE` is `none`, so this is what the acceptance test sees. + * It is also what a configured source falls back to when it has never once + * answered. CONTRACT.md §5.1. + * + * The numbers are deliberately unremarkable: a light scatter of cloud, no rain, + * good visibility. Nothing here pretends to be an observation, which is what + * `synthetic: true` is on the wire to say. + */ + +import type { WeatherBody } from "../../../src/server/wire.ts"; + +export function clearDay(lat: number, lng: number, now = new Date()): WeatherBody { + return { + observedAt: now.toISOString(), + source: "none", + synthetic: true, + location: { lat, lng }, + // Null rather than a plausible-looking number. A renderer reads cloud, + // precipitation and visibility; inventing 18 °C would only ever be wrong. + temperatureC: null, + windKph: null, + windDirDeg: null, + cloudCover: 0.08, + precipitation: 0, + visibilityKm: 40, + condition: "clear", + }; +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..0900d67 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/src/assets/LICENSE-ART b/src/assets/LICENSE-ART new file mode 100644 index 0000000..5a386a2 --- /dev/null +++ b/src/assets/LICENSE-ART @@ -0,0 +1,73 @@ +ARTISTIC OUTPUT — CC0 1.0 PUBLIC DOMAIN DEDICATION +================================================== + +This file states an additional grant made by the copyright holders of the +material described below. It is not a property of this directory, and it does +not attach to anything by virtue of where a file sits: it is a dedication those +holders have made, recorded here so that a downstream consumer can rely on it. + + +WHAT IS DEDICATED +----------------- + +The copyright holders of the material under src/assets/ dedicate the ARTISTIC +OUTPUT of that material to the public domain under the Creative Commons CC0 1.0 +Universal Public Domain Dedication. + +"Artistic output" means the visual and geometric result of running this code: +the meshes, geometry, textures, materials, colour palettes and images it +produces, and any file exported from them. Tera's assets are procedural, so +the art has no existence as a committed file — it is computed at runtime. That +is precisely why the dedication has to name the output rather than a set of +paths. A mesh generated from this library is CC0, whether it was generated in +this repository, in a self-hoster's deployment, or in an unrelated project that +imported one function. + +To the extent possible under law, those copyright holders have waived all +copyright and related or neighbouring rights to that artistic output. The work +is published from the United States. + +The full text of CC0 1.0 is at: + + https://creativecommons.org/publicdomain/zero/1.0/legalcode + +Where a waiver is not possible under applicable law, the copyright holders +grant to each person affected a royalty-free, non-transferable, +non-sublicensable, non-exclusive, irrevocable and unconditional licence to +exercise their rights in the artistic output for any purpose and by any means, +for the duration of those rights. Where even that is not possible, they waive +any right to assert those rights against any use of the artistic output. + +No warranty of any kind is given, and no rights in any trademark, patent or +right of publicity of any person are licensed or waived here. + + +WHAT IS NOT +----------- + +The SOURCE CODE under src/assets/ — the TypeScript modules, the registry, the +parameterisation, the algorithms — remains licensed under the Apache License, +Version 2.0, along with the rest of this repository. See LICENSE, and NOTICE +for the notices Apache 2.0 requires. + +The two grants sit on top of each other and neither replaces the other. Use the +code, and Apache 2.0 applies with its NOTICE obligation. Use a mesh that came +out of the code, and CC0 applies with no obligation at all. The second is the +point of the arrangement: geometry ought to be able to leave this project +without dragging an attribution requirement into somebody else's asset pipeline. + + +WHY IT IS SAID HERE AND NOW +--------------------------- + +This file landed in the same commit as the first file under src/assets/, before +any asset existed to relicense. Art is close to impossible to relicense once +contributors exist, and a library of desks, chairs, partitions and floor +finishes accumulates contributors quickly. See CONTRACT.md §3.1. + +The corresponding inbound grant is in CONTRIBUTING.md and is STANDING rather +than per-contribution. Apache 2.0 §5 supplies a default inbound=outbound grant +for Apache-2.0 only; there is no default inbound CC0. Without that standing +grant, one merged contribution whose author never said the words would leave +that contribution Apache-only and make this file's claim false — and, because +copyright cannot be taken back, unfixable. diff --git a/src/assets/kit.ts b/src/assets/kit.ts new file mode 100644 index 0000000..9418dce --- /dev/null +++ b/src/assets/kit.ts @@ -0,0 +1,291 @@ +/** + * The asset registry: what ids exist, what they build, and how a self-hoster + * replaces one without forking the repo. + * + * There is **one** registry, not two. `Prop.kind` in an office pack is an + * `AssetId` and resolves here (CONTRACT.md §3); there is no separate table of + * things you are allowed to put in a room. An office pack is data, this is the + * code that turns data into geometry, and the arrow only points one way — + * `interiors/types.ts` declares `AssetId` as a loose `string` precisely so that + * a pack can be parsed, validated and stored without importing a mesh library. + * + * ### Overrides are the point + * + * `tera:` is the namespace this repo ships. Somebody running their own office + * registers `acme:desk.standing` with `overrides: "tera:desk.workstation"`, and + * every desk in the reference pack becomes theirs — no fork, no edit to the + * pack, no patch to keep rebasing. That is the difference between an open-source + * asset library and an open-source asset library people actually use. + * + * ### Nothing throws + * + * An unregistered id builds a placeholder box instead of an exception. An office + * pack with one typo in it should still open, and a missing desk should look + * like a missing desk rather than a blank screen — the failure has to be visible + * in the room, because that is where the person who can fix it is looking. + */ + +import * as THREE from "three"; +import type { MaterialQuality, MaterialRegistry } from "./materials.ts"; +import type { InteriorPalette } from "./palette.ts"; +import { MeshBin, parts, type PartBin } from "./parts.ts"; + +/** + * A namespaced id — `"tera:desk.workstation"`, `"acme:desk.standing"`. + * + * The template type is enforced where assets are *registered*, so a typo in a + * built-in is caught by the compiler. Lookups take a plain `string`, because + * the ids arriving from an office pack are strings that came out of JSON and no + * amount of type declaration makes them anything else. Validation is `get` + * returning `undefined`, not a cast. + */ +export type AssetId = `${string}:${string}`; + +/** What an asset's parameters may be. JSON values, for the same reason a pack is. */ +export type AssetParamValue = + | string + | number + | boolean + | null + | undefined + | readonly AssetParamValue[]; + +export type AssetParams = Readonly>; + +/** + * How much room an asset needs, in metres, in its own local frame — X across, + * Z deep, Y tall, with the origin on the floor at the centre of the footprint. + * + * Separate from `build` because layout wants the numbers without the geometry: + * a desk bank spacing itself, a room checking a sofa fits, an editor drawing a + * plan view. Building 1,200 objects to find out how big they are is the kind of + * thing that is fine until the day it is not. + */ +export interface Footprint { + width: number; + depth: number; + height: number; + /** + * Metres of floor the asset wants kept clear in front of it — a chair to pull + * out, a drawer to open. Advisory; nothing enforces it. + */ + clearance?: number; +} + +/** + * Everything an asset builder is handed. + * + * Note what is *not* here: no `Office`, no `Level`, no room, no seat, no + * neighbours. An asset builds one object in its own local frame and knows + * nothing about where it is going, which is what keeps assets independently + * writable and independently testable. + */ +export interface AssetContext { + materials: MaterialRegistry; + /** The shared unit-primitive bin. Almost always the module-level `parts`. */ + parts: PartBin; + palette: InteriorPalette; + quality: MaterialQuality; + /** + * Deterministic randomness for this instance — book angles, cushion sag, the + * rotation of a mug. Seeded per prop by whoever builds the context, so the + * office looks the same on every reload. A world that reshuffles itself + * between visits is a lava lamp. + */ + rand: () => number; + /** The placed instance's opaque palette key, if it had one. */ + colorKey?: string; + /** + * Resolve an opaque `colorKey` to a colour, or `undefined` for "no opinion". + * + * Supplied by the caller exactly as `MarkerPalette` is, and for the same + * reason: the library will never learn that a colour key means a status, a + * team or a company (ARCHITECTURE.md §3.3). + */ + colorFor?: (key: string) => number | undefined; + /** Build another asset — how a composite places a child. */ + build: (id: string, params?: AssetParams) => THREE.Object3D; +} + +/** + * One asset: an id, its parameters, how big it is, and how to build it. + * + * `footprint` and `build` are method shorthand rather than arrow properties so + * that a concretely-parameterised def is assignable to the erased one the + * registry stores. That is bivariance, and it is the intended amount of + * looseness here — the registry cannot know every asset's parameter type and + * should not pretend to. + */ +export interface AssetDef

{ + id: AssetId; + /** + * The built-in id this asset stands in for. Registering with this set means + * every request for the overridden id resolves here instead. + */ + overrides?: AssetId; + /** Human-readable, for editors and error messages. Never rendered. */ + label?: string; + defaults: P; + footprint(params: P): Footprint; + build(params: P, ctx: AssetContext): THREE.Object3D; +} + +/** + * An asset with its parameter type erased — what the registry stores and hands + * back. The `any` is deliberate and confined to this line: the registry cannot + * know every asset's parameter type, and pretending otherwise with a union + * would mean editing a central file every time somebody adds a chair. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyAsset = AssetDef; + +/** Identity helper that keeps a def's parameter type inferred at the call site. */ +export function defineAsset

(def: AssetDef

): AssetDef

{ + return def; +} + +const PLACEHOLDER_FOOTPRINT: Footprint = { width: 0.6, depth: 0.6, height: 0.6 }; + +export class AssetRegistry { + private readonly defs = new Map(); + /** overridden id -> the id that replaced it. */ + private readonly overriddenBy = new Map(); + private readonly warned = new Set(); + + /** + * Later registrations of the same id win, so a self-hoster can re-register a + * built-in outright as well as override it. Both are supported because they + * mean different things: re-registering replaces one asset, overriding + * redirects one id at another asset that keeps its own name. + */ + register

(def: AssetDef

): this { + this.defs.set(def.id, def as AnyAsset); + if (def.overrides) this.overriddenBy.set(def.overrides, def.id); + return this; + } + + registerAll(defs: readonly AnyAsset[]): this { + for (const def of defs) this.register(def); + return this; + } + + /** + * Follow the override chain to the id that will actually be built. + * + * The visited set is not paranoia: `acme:a` overriding `tera:b` while + * `acme:b` overrides `tera:a` is a plausible thing for two half-finished + * packs to do between them, and an infinite loop inside a scene build is a + * hung tab with no message in it. + */ + resolveId(id: string): string { + let current = id; + const seen = new Set([current]); + for (;;) { + const next = this.overriddenBy.get(current); + if (!next || seen.has(next)) return current; + seen.add(next); + current = next; + } + } + + get(id: string): AnyAsset | undefined { + return this.defs.get(this.resolveId(id)); + } + + has(id: string): boolean { + return this.get(id) !== undefined; + } + + /** Every registered id, including ones only reachable as an override target. */ + ids(): AssetId[] { + return [...this.defs.keys()] as AssetId[]; + } + + /** Size without building. Unknown ids give the placeholder's size. */ + footprintOf(id: string, params?: AssetParams): Footprint { + const def = this.get(id); + if (!def) return PLACEHOLDER_FOOTPRINT; + return def.footprint({ ...def.defaults, ...params }); + } + + /** + * Build one asset. The returned object's origin sits on the floor at the + * centre of its footprint, facing -Z at yaw zero, matching `Yaw` in + * `interiors/types.ts`. + */ + build(id: string, ctx: AssetContext, params?: AssetParams): THREE.Object3D { + const def = this.get(id); + if (!def) return this.placeholder(id, ctx); + const object = def.build({ ...def.defaults, ...params }, ctx); + object.userData.assetId = def.id; + return object; + } + + /** + * A box, in the accent colour, where the asset should have been. + * + * Warned once per id rather than once per instance — a pack referring to a + * missing desk 120 times should say so once. + */ + private placeholder(id: string, ctx: AssetContext): THREE.Object3D { + if (!this.warned.has(id)) { + this.warned.add(id); + console.warn(`[tera/assets] no asset registered for "${id}" — drawing a placeholder`); + } + const bin = new MeshBin(); + const { width, depth, height } = PLACEHOLDER_FOOTPRINT; + bin.box(ctx.materials.get("accent"), { size: [width, height, depth] }); + const group = bin.build("placeholder"); + group.userData.assetId = id; + group.userData.missing = true; + return group; + } +} + +/** + * The registry the built-in `tera:` assets register into and the one an office + * uses unless it is handed another. One registry per page is the normal case; + * the class is exported for tests and for anyone rendering two differently + * skinned worlds side by side. + */ +export const kit = new AssetRegistry(); + +export interface AssetContextOptions { + materials: MaterialRegistry; + registry?: AssetRegistry; + parts?: PartBin; + rand?: () => number; + colorKey?: string; + colorFor?: (key: string) => number | undefined; +} + +/** + * Wire up an `AssetContext`, including the recursive `build` a composite asset + * uses to place its children. Callers should not assemble one by hand — the + * recursion is the fiddly part and it belongs in one place. + */ +export function createAssetContext(options: AssetContextOptions): AssetContext { + const registry = options.registry ?? kit; + const ctx: AssetContext = { + materials: options.materials, + parts: options.parts ?? parts, + palette: options.materials.palette, + quality: options.materials.quality, + rand: options.rand ?? Math.random, + colorKey: options.colorKey, + colorFor: options.colorFor, + build: (id, params) => registry.build(id, ctx, params), + }; + return ctx; +} + +/** + * The colour an asset should use for a tintable part: the caller's answer for + * this instance's `colorKey`, or the role's palette colour when there is no key + * or the caller has no opinion about it. + */ +export function tintFor(ctx: AssetContext, fallbackRole: keyof InteriorPalette): number { + const key = ctx.colorKey; + const resolved = key !== undefined ? ctx.colorFor?.(key) : undefined; + return resolved ?? ctx.palette[fallbackRole]; +} diff --git a/src/assets/materials.ts b/src/assets/materials.ts new file mode 100644 index 0000000..2887f7e --- /dev/null +++ b/src/assets/materials.ts @@ -0,0 +1,312 @@ +/** + * Every surface in the library, keyed on what it *is* rather than what colour + * it happens to be. + * + * `SurfaceRole` is a closed union on purpose. A registry keyed on strings would + * let an asset ask for `"grey"` and get one, and then the day somebody wants a + * warm office there would be forty files to edit and no list of what to edit. + * Keyed on roles, the whole appearance of the world is `palette.ts` plus the + * table below, and an asset that asks for `deskSurface` gets whatever a desk + * surface is *here* — which is what makes eighteen independently-written asset + * builders look like one library. + * + * The roles are named after the object, never after the finish: `partitionFabric` + * and not `blueFelt`, `screenDisplay` and not `black`. Same rule as + * `Marker.colorKey` one level in (ARCHITECTURE.md §3.3) — this module renders + * surfaces and takes no position on what they mean. + * + * There is exactly one `THREE.Material` per role per registry, and every mesh in + * the office shares it. That sharing is half of the draw-call budget; the other + * half is `parts.ts` merging geometry per material. + */ + +import * as THREE from "three"; +import { DEFAULT_INTERIOR_PALETTE, type InteriorPalette } from "./palette.ts"; +import { TextureBin, type TextureKind } from "./textures.ts"; + +/** + * The closed set of surfaces this library knows how to be. + * + * Adding a role is a three-line change — here, in `ROLE_SPECS`, and in + * `ROLE_SHIFTS` in `palette.ts` — and the compiler will not let you forget the + * third. + */ +export type SurfaceRole = + // Floors + | "floorSlab" + | "carpet" + | "carpetAccent" + | "woodFloor" + | "polishedConcrete" + | "tile" + // Ceilings + | "ceilingTile" + | "ceilingBaffle" + // The vertical shell + | "plaster" + | "plasterAccent" + | "skirting" + | "glazing" + | "glazingFrame" + | "doorLeaf" + // Partitions + | "partitionFabric" + | "partitionFrame" + // Furniture + | "deskSurface" + | "deskFrame" + | "tableTop" + | "cabinet" + | "shelf" + | "chairShell" + | "chairFabric" + | "chairBase" + | "upholstery" + // Fittings + | "metalTrim" + | "screenBezel" + | "screenDisplay" + | "lightHousing" + | "lightDiffuser" + | "whiteboard" + // Objects + | "foliage" + | "planter" + | "paper" + | "accent"; + +/** + * `low` is flat Lambert with no maps — the same material class the city uses, + * and the setting that makes an office open on an integrated GPU. `medium` and + * `high` are physically-shaded and differ only in texture resolution. + */ +export type MaterialQuality = "low" | "medium" | "high"; + +export type SurfaceMaterial = THREE.MeshStandardMaterial | THREE.MeshLambertMaterial; + +interface RoleSpec { + /** 0 = mirror, 1 = chalk. Ignored at `low` quality. */ + roughness: number; + /** Ignored at `low` quality. */ + metalness: number; + texture?: TextureKind; + /** Fraction of the role's own colour emitted. Screens and diffusers only. */ + glow?: number; + /** Opacity below 1 makes the material transparent. */ + opacity?: number; + /** Leaf cards and glass want both faces. */ + doubleSided?: boolean; +} + +const ROLE_SPECS: Record = { + floorSlab: { roughness: 0.9, metalness: 0, texture: "polishedConcrete" }, + carpet: { roughness: 0.98, metalness: 0, texture: "carpetLoop" }, + carpetAccent: { roughness: 0.98, metalness: 0, texture: "carpetLoop" }, + woodFloor: { roughness: 0.55, metalness: 0, texture: "woodPlank" }, + polishedConcrete: { roughness: 0.4, metalness: 0.05, texture: "polishedConcrete" }, + tile: { roughness: 0.3, metalness: 0, texture: "tileGrid" }, + + ceilingTile: { roughness: 0.95, metalness: 0, texture: "ceilingTile" }, + ceilingBaffle: { roughness: 0.9, metalness: 0, texture: "fabricWeave" }, + + plaster: { roughness: 0.92, metalness: 0, texture: "plasterPaint" }, + plasterAccent: { roughness: 0.92, metalness: 0, texture: "plasterPaint" }, + skirting: { roughness: 0.6, metalness: 0 }, + // 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 }, + glazingFrame: { roughness: 0.35, metalness: 0.7 }, + doorLeaf: { roughness: 0.6, metalness: 0 }, + + partitionFabric: { roughness: 0.95, metalness: 0, texture: "fabricWeave" }, + partitionFrame: { roughness: 0.4, metalness: 0.6 }, + + deskSurface: { roughness: 0.45, metalness: 0, texture: "woodPlank" }, + deskFrame: { roughness: 0.4, metalness: 0.65 }, + tableTop: { roughness: 0.4, metalness: 0, texture: "woodPlank" }, + cabinet: { roughness: 0.6, metalness: 0.05 }, + shelf: { roughness: 0.55, metalness: 0, texture: "woodPlank" }, + chairShell: { roughness: 0.55, metalness: 0.05 }, + chairFabric: { roughness: 0.95, metalness: 0, texture: "fabricWeave" }, + chairBase: { roughness: 0.35, metalness: 0.75 }, + upholstery: { roughness: 0.92, metalness: 0, texture: "fabricWeave" }, + + metalTrim: { roughness: 0.3, metalness: 0.85 }, + screenBezel: { roughness: 0.5, metalness: 0.2 }, + screenDisplay: { roughness: 0.2, metalness: 0, glow: 0.4 }, + lightHousing: { roughness: 0.4, metalness: 0.5 }, + 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 }, + planter: { roughness: 0.7, metalness: 0 }, + paper: { roughness: 0.9, metalness: 0 }, + accent: { roughness: 0.6, metalness: 0.1 }, +}; + +/** + * Authored `SurfaceId` strings to roles. + * + * An office pack carries `SurfaceId` — a loose namespaced string like + * `"tera:carpet.loop"` — because a pack is data and must not depend on this + * module to be parsed or stored (`interiors/types.ts`). Resolution happens here, + * once, and unknown ids fall back rather than throwing: a pack with one typo in + * it should still open, the same way an unregistered `AssetId` gets a + * placeholder box. + * + * The general rule is that the first dot-segment after the namespace is the + * role, so `tera:carpet.loop`, `tera:carpet.broadloom` and a self-hoster's + * `acme:carpet.whatever` all land on `carpet` for free. This table is only for + * the names where that reads badly. + */ +const SURFACE_ALIASES: Record = { + paint: "plaster", + plasterboard: "plaster", + wall: "plaster", + wood: "woodFloor", + timber: "woodFloor", + concrete: "polishedConcrete", + glass: "glazing", + ceiling: "ceilingTile", + felt: "partitionFabric", + fabric: "partitionFabric", + laminate: "deskSurface", + steel: "metalTrim", + metal: "metalTrim", + aluminium: "metalTrim", + screen: "screenDisplay", + plant: "foliage", +}; + +const ROLE_NAMES = new Set(Object.keys(ROLE_SPECS)); + +export interface MaterialRegistryOptions { + palette?: InteriorPalette; + quality?: MaterialQuality; + /** + * Share a bin with another registry — two registries in one page (an office + * being previewed beside the one you are in) should not draw the carpet + * twice. The registry disposes only a bin it made itself. + */ + textures?: TextureBin; +} + +export class MaterialRegistry { + readonly palette: InteriorPalette; + readonly quality: MaterialQuality; + readonly textures: TextureBin; + + private readonly ownsTextures: boolean; + private readonly base = new Map(); + private readonly ghosts = new Map(); + private readonly tints = new Map(); + + constructor(options: MaterialRegistryOptions = {}) { + this.palette = options.palette ?? DEFAULT_INTERIOR_PALETTE; + this.quality = options.quality ?? "high"; + this.ownsTextures = options.textures === undefined; + this.textures = options.textures ?? new TextureBin(this.quality); + } + + /** The one shared material for a role. Do not mutate it. */ + get(role: SurfaceRole): SurfaceMaterial { + const hit = this.base.get(role); + if (hit) return hit; + const made = this.create(role, this.palette[role]); + made.name = role; + this.base.set(role, made); + return made; + } + + /** + * A translucent copy of a role, for the wall-occlusion fade — the walls + * between the camera and where you are looking go ghost rather than being + * hidden, so the floorplan stays readable from outside. + * + * 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. + */ + ghostOf(role: SurfaceRole): SurfaceMaterial { + const hit = this.ghosts.get(role); + if (hit) return hit; + const ghost = this.get(role).clone(); + ghost.name = `${role}:ghost`; + ghost.map = null; + ghost.transparent = true; + ghost.opacity = 0.18; + ghost.depthWrite = false; + ghost.side = THREE.FrontSide; + this.ghosts.set(role, ghost); + return ghost; + } + + /** + * A role recoloured for one instance — what an asset calls once the caller's + * palette has turned a `Prop.colorKey` into a number. Cached, because a + * hundred chairs in three colours should still be three materials. + */ + tinted(role: SurfaceRole, color: number): SurfaceMaterial { + const key = `${role}:${color.toString(16)}`; + const hit = this.tints.get(key); + if (hit) return hit; + const made = this.create(role, color); + made.name = key; + this.tints.set(key, made); + return made; + } + + /** + * Turn an authored `SurfaceId` into a role. Unknown ids give `fallback`. + * + * `undefined` in gives `fallback` too, so a caller can pass an optional field + * straight through: `materials.resolve(room.floor, "carpet")`. + */ + resolve(surface: string | undefined, fallback: SurfaceRole): SurfaceRole { + if (!surface) return fallback; + const local = surface.includes(":") ? surface.slice(surface.indexOf(":") + 1) : surface; + const head = local.split(".")[0] ?? ""; + if (ROLE_NAMES.has(head)) return head as SurfaceRole; + return SURFACE_ALIASES[head] ?? fallback; + } + + /** Convenience for the common `resolve` then `get`. */ + forSurface(surface: string | undefined, fallback: SurfaceRole): SurfaceMaterial { + return this.get(this.resolve(surface, fallback)); + } + + private create(role: SurfaceRole, color: number): SurfaceMaterial { + const spec = ROLE_SPECS[role]; + const map = spec.texture ? this.textures.get(spec.texture) : null; + const transparent = spec.opacity !== undefined && spec.opacity < 1; + + const shared = { + color, + map, + side: spec.doubleSided ? THREE.DoubleSide : THREE.FrontSide, + transparent, + opacity: spec.opacity ?? 1, + depthWrite: !transparent, + emissive: spec.glow ? color : 0x000000, + emissiveIntensity: spec.glow ?? 0, + }; + + if (this.quality === "low") return new THREE.MeshLambertMaterial(shared); + return new THREE.MeshStandardMaterial({ + ...shared, + roughness: spec.roughness, + metalness: spec.metalness, + }); + } + + dispose(): void { + for (const m of this.base.values()) m.dispose(); + for (const m of this.ghosts.values()) m.dispose(); + for (const m of this.tints.values()) m.dispose(); + this.base.clear(); + this.ghosts.clear(); + this.tints.clear(); + if (this.ownsTextures) this.textures.dispose(); + } +} diff --git a/src/assets/office/common.ts b/src/assets/office/common.ts new file mode 100644 index 0000000..ee08a90 --- /dev/null +++ b/src/assets/office/common.ts @@ -0,0 +1,178 @@ +/** + * The conventions every asset in this directory agrees on, and the three + * helpers that would otherwise be copied into seventeen files. + * + * ### Which way an asset faces + * + * An asset is built in its own frame with the **origin at the centre of its + * footprint, on the floor**, and at yaw zero it faces **−Z** — the same sense as + * `Yaw` in `interiors/types.ts`, which is `object.rotation.y` with no + * conversion. "Faces −Z" here means what it means for a person: the direction + * the thing is pointed, not the side you see. A desk, the chair pulled up to it + * and the person in the chair therefore all carry **one rotation**, which is + * what lets `DeskBank` hand the same `rotation` to its desk and its chair. + * + * The consequence, and it is worth stating because it is the opposite of what + * you might guess: the *used* side of an asset is at **+Z**, because that is + * where the user is. Drawer fronts, the open front of a shelf, the face of a + * monitor and the writing side of a whiteboard all point +Z, and the back of a + * thing that stands against a wall is at −Z. + * + * ### Two things that are not on the floor + * + * `light.pendant` and `light.troffer` hang from a ceiling, and a ceiling is + * their datum the way the floor is everything else's. They are authored with + * the origin at the **mounting plane** and their geometry below it, `y ≤ 0`, so + * a pack writes `elevation: 2.9` and gets a lamp hanging at 2.9 m rather than a + * lamp whose author had to know the ceiling height. `footprint().height` is the + * total drop. Nothing else in the library does this. + * + * Anything that stands on a desk or hangs on a wall — a monitor, a wall display, + * a whiteboard — is still authored on the floor. The monitor's foot sits at + * `y = 0` and the pack raises it with `Prop.elevation`; the wall-mounted things + * carry their own `mount` parameter, because the height of a whiteboard is a + * property of the whiteboard and not of the room. + * + * ### One rule that will bite you + * + * Every part an asset puts under a given material must be **either all indexed + * or all non-indexed**. `mergeGeometries` refuses a mixture, `MeshBin` treats + * 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. + * + * ### Light fixtures emit no light + * + * A luminaire here is geometry with a glowing diffuser and nothing else. The + * office's lighting is a fixed rig owned by the scene (CONTRACT.md §4); a + * hundred props each adding a `PointLight` is both the wrong owner and, at four + * shadow-casting lights, the end of the frame budget. + */ + +import { tintFor, type AssetContext } from "../kit.ts"; +import type { SurfaceMaterial, SurfaceRole } from "../materials.ts"; +import type { MeshBin } from "../parts.ts"; + +/** + * 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 + * tintable role in its own comment; there is at most one per asset, because + * "the blue meeting room" wants one thing to be blue and not six. + * + * Falling back to the shared role material rather than `tinted(role, + * palette[role])` is not a micro-optimisation: an identical-but-distinct + * material is a second merge bucket and a second draw call on every instance, + * for a colour nobody can tell apart from the one next to it. + */ +export function tintable(ctx: AssetContext, role: SurfaceRole): SurfaceMaterial { + const color = tintFor(ctx, role); + return color === ctx.palette[role] ? ctx.materials.get(role) : ctx.materials.tinted(role, color); +} + +/** + * A horizontal slab — a desktop, a tabletop, a shelf board — with the grain the + * right size on the face you actually look at. + * + * The body is a scaled unit box, whose 0..1 UVs stretch; the top face is a + * `metricQuad`, whose UVs are in metres. Without the second part a 1.6 m desk + * and a 2.4 m table would each show exactly one repeat of the wood and read as + * 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. + * + * `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 }, +): void { + const x = s.x ?? 0; + const z = s.z ?? 0; + bin.add(ctx.parts.box(), material, { + x, + y: s.y, + z, + size: [s.width, s.thickness, s.depth], + }); + bin.add(ctx.parts.metricQuad(s.width, s.depth), material, { + x, + y: s.y + s.thickness + 0.0006, + z, + }); +} + +/** + * A standing panel — a partition, a modesty panel, a board — with metric UVs on + * the faces. + * + * The only trick is how a floor-plane `metricQuad` is stood up: pitching it by + * +π/2 sends its up-normal to +Z and its depth extent to Y, which gives a + * vertical rectangle whose UVs are still in metres. `panel()` would have been + * shorter and would have smeared one tile of felt across a 1.4 m screen. + * + * `y` is the bottom edge; the panel is centred on `z`. + */ +export function panelSlab( + bin: MeshBin, + ctx: AssetContext, + material: SurfaceMaterial, + s: { + x?: number; + y: number; + z?: number; + width: number; + height: number; + thickness: number; + /** `"front"` skips the −Z face, for a panel hung flat against a wall. */ + faces?: "both" | "front"; + }, +): void { + const x = s.x ?? 0; + const z = s.z ?? 0; + bin.add(ctx.parts.box(), material, { x, y: s.y, z, size: [s.width, s.height, s.thickness] }); + + const face = ctx.parts.metricQuad(s.width, s.height); + const yMid = s.y + s.height / 2; + bin.add(face, material, { + x, + y: yMid, + z: z + s.thickness / 2 + 0.0006, + pitch: Math.PI / 2, + }); + if (s.faces !== "front") { + bin.add(face, material, { + x, + y: yMid, + z: z - s.thickness / 2 - 0.0006, + pitch: -Math.PI / 2, + }); + } +} + +/** + * Where a point `d` in front of a pitched part ends up. + * + * `Placement` applies its offset in the parent frame and its rotation about the + * part's own base, so gluing a screen to a tilted bezel by writing `z: 0.013` + * leaves the screen poking through the top of the bezel by `height × sin(tilt)`. + * Rotating the offset first is the fix, and it is small enough that doing it by + * hand twice would have been two chances to get the sign wrong. + */ +export function alongFacing(pitch: number, d: number): { y: number; z: number } { + return { y: -d * Math.sin(pitch), z: d * Math.cos(pitch) }; +} + +/** Symmetric jitter of ±`amount`, for the small deliberate untidiness. */ +export function jitter(rand: () => number, amount: number): number { + return (rand() - 0.5) * 2 * amount; +} + +export function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} diff --git a/src/assets/office/desks.ts b/src/assets/office/desks.ts new file mode 100644 index 0000000..cc380fe --- /dev/null +++ b/src/assets/office/desks.ts @@ -0,0 +1,233 @@ +/** + * Desks: the workstation, the pedestal that lives under it, and the screen that + * separates it from the next one. + * + * `desk.workstation` is the anchor of the library. It is the asset a `DeskBank` + * repeats, it is the one every self-hoster will override first, and its + * dimensions are what the rest of the furniture is sized against: a 730 mm + * working height, a 1.6 × 0.8 m desktop, and a person sitting at +Z facing −Z. + */ + +import { defineAsset } from "../kit.ts"; +import { MeshBin } from "../parts.ts"; +import { clamp, panelSlab, slab, tintable } from "./common.ts"; + +type WorkstationParams = { + width: number; + depth: number; + /** Working height of the desktop, metres. 1.05 or so for a standing desk. */ + height: number; + /** + * `"loop"` is the cantilever frame most office desks actually have — a foot + * bar, two uprights and a top rail at each end. `"post"` is four legs, which + * reads as a table and is here for the rooms where that is wanted. + */ + legs: "loop" | "post"; + /** The panel across the far edge. Tintable; this is the desk's colour key. */ + modesty: boolean; +}; + +const TOP_THICKNESS = 0.03; + +export const deskWorkstation = defineAsset({ + id: "tera:desk.workstation", + label: "Workstation", + defaults: { width: 1.6, depth: 0.8, height: 0.73, legs: "loop", modesty: true }, + + footprint(p) { + // The clearance is a chair pulled out, not a chair tucked in: 900 mm is + // what a person needs to stand up and leave without moving the desk. + return { width: p.width, depth: p.depth, height: p.height, clearance: 0.9 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const frame = ctx.materials.get("deskFrame"); + const deckY = p.height - TOP_THICKNESS; + + slab(bin, ctx, ctx.materials.get("deskSurface"), { + y: deckY, + width: p.width, + depth: p.depth, + thickness: TOP_THICKNESS, + }); + + const legX = p.width / 2 - 0.09; + const legZ = p.depth / 2 - 0.11; + + if (p.legs === "post") { + for (const sx of [-1, 1]) { + for (const sz of [-1, 1]) { + bin.add(P.rod(), frame, { + x: sx * legX, + z: sz * legZ, + size: [0.055, deckY, 0.055], + }); + } + } + } else { + const barDepth = p.depth - 0.18; + for (const sx of [-1, 1]) { + const x = sx * legX; + bin.add(P.box(), frame, { x, size: [0.07, 0.045, barDepth] }); + bin.add(P.box(), frame, { x, y: deckY - 0.05, size: [0.07, 0.05, barDepth] }); + for (const sz of [-1, 1]) { + bin.add(P.box(), frame, { + x, + z: sz * legZ, + size: [0.05, deckY - 0.05, 0.05], + }); + } + } + // The spine between the two end frames. Without it a cantilever desk + // looks like two separate trestles that happen to be under one board. + bin.add(P.box(), frame, { + y: deckY - 0.19, + size: [Math.max(0.2, p.width - 0.28), 0.055, 0.055], + }); + } + + if (p.modesty) { + const height = clamp(deckY - 0.3, 0.14, 0.4); + panelSlab(bin, ctx, tintable(ctx, "partitionFabric"), { + y: deckY - 0.05 - height, + z: -(p.depth / 2 - 0.08), + width: p.width - 0.24, + height, + thickness: 0.018, + }); + } + + return bin.build("desk.workstation"); + }, +}); + +type PedestalParams = { + width: number; + depth: number; + height: number; + drawers: number; + /** Castors, for the pedestal that gets rolled out and sat on. */ + mobile: boolean; +}; + +/** + * The under-desk drawer unit. Its fronts are at +Z — the same side the person + * is on — so a pack gives it the same rotation as the desk it belongs to and + * the drawers open toward the chair. + */ +export const deskPedestal = defineAsset({ + id: "tera:desk.pedestal", + label: "Desk pedestal", + defaults: { width: 0.42, depth: 0.6, height: 0.6, drawers: 3, mobile: true }, + + footprint(p) { + return { width: p.width, depth: p.depth, height: p.height, clearance: 0.5 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const carcass = ctx.materials.get("cabinet"); + const trim = ctx.materials.get("metalTrim"); + + const lift = p.mobile ? 0.05 : 0.02; + const bodyH = p.height - lift; + bin.add(P.box(), carcass, { y: lift, size: [p.width, bodyH, p.depth] }); + + if (p.mobile) { + for (const sx of [-1, 1]) { + for (const sz of [-1, 1]) { + bin.add(P.cylinder(8), trim, { + x: sx * (p.width / 2 - 0.07), + z: sz * (p.depth / 2 - 0.07), + size: [0.05, lift, 0.05], + }); + } + } + } + + const count = Math.max(1, Math.round(p.drawers)); + const pitch = (bodyH - 0.03) / count; + const face = tintable(ctx, "cabinet"); + for (let i = 0; i < count; i++) { + const y = lift + 0.015 + i * pitch; + bin.add(P.box(), face, { + y, + z: p.depth / 2, + size: [p.width - 0.03, pitch - 0.012, 0.02], + }); + // A recessed pull rather than a handle: a D-handle at this scale is four + // more parts and reads as a smudge from any distance you see a pedestal. + bin.add(P.box(), trim, { + y: y + pitch - 0.05, + z: p.depth / 2 + 0.012, + size: [p.width * 0.42, 0.014, 0.012], + }); + } + + return bin.build("desk.pedestal"); + }, +}); + +type PartitionParams = { + width: number; + height: number; + thickness: number; + /** Floor-standing feet. Off by default: most of these clamp to a desk. */ + feet: boolean; +}; + +/** + * A fabric screen. Authored standing on the floor like everything else, so the + * desk-mounted case is `elevation: 0.73` in the pack rather than a `mount` + * parameter here — the same screen clamps to a desk, stands on the floor and + * caps a bench run, and only the pack knows which. + * + * The fabric is the tintable part. + */ +export const deskPartition = defineAsset({ + id: "tera:desk.partition", + label: "Desk partition", + defaults: { width: 1.4, height: 0.45, thickness: 0.04, feet: false }, + + footprint(p) { + return { width: p.width, depth: p.feet ? 0.34 : p.thickness + 0.02, height: p.height }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const frame = ctx.materials.get("partitionFrame"); + + panelSlab(bin, ctx, tintable(ctx, "partitionFabric"), { + y: 0, + width: p.width - 0.03, + height: p.height, + thickness: p.thickness, + }); + + bin.add(P.box(), frame, { + y: p.height - 0.018, + size: [p.width, 0.018, p.thickness + 0.012], + }); + for (const sx of [-1, 1]) { + bin.add(P.box(), frame, { + x: sx * (p.width / 2 - 0.008), + size: [0.016, p.height, p.thickness + 0.012], + }); + } + + if (p.feet) { + for (const sx of [-1, 1]) { + bin.add(P.box(), frame, { + x: sx * (p.width / 2 - 0.1), + size: [0.05, 0.02, 0.32], + }); + } + } + + return bin.build("desk.partition"); + }, +}); diff --git a/src/assets/office/greenery.ts b/src/assets/office/greenery.ts new file mode 100644 index 0000000..b9a3bfd --- /dev/null +++ b/src/assets/office/greenery.ts @@ -0,0 +1,176 @@ +/** + * 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. + * + * Neither takes a `colorKey`. A plant is the colour a plant is. + */ + +import { defineAsset } from "../kit.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)); + +type PottedParams = { + /** Overall height including the pot. */ + height: number; + potDiameter: number; + leaves: number; +}; + +export const plantPotted = defineAsset({ + id: "tera:plant.potted", + label: "Potted plant", + defaults: { height: 0.6, potDiameter: 0.28, leaves: 16 }, + + footprint(p) { + // A plant is wider than its pot. The spread is what a passer-by brushes, + // so it is the spread that layout should be told about. + const spread = Math.max(p.potDiameter, (p.height - p.potDiameter * 0.6) * 0.9); + return { width: spread, depth: spread, height: p.height }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const pot = ctx.materials.get("planter"); + const leaf = ctx.materials.get("foliage"); + + const potH = clamp(p.height * 0.42, 0.1, 0.36); + bin.add(P.cylinder(14), pot, { size: [p.potDiameter * 0.72, potH, p.potDiameter * 0.72] }); + bin.add(P.cylinder(14), pot, { + y: potH - 0.03, + size: [p.potDiameter, 0.03, p.potDiameter], + }); + bin.add(P.disc(14), pot, { + y: potH - 0.012, + size: [p.potDiameter * 0.9, 1, p.potDiameter * 0.9], + }); + + const count = Math.max(3, Math.round(p.leaves)); + const reach = p.height - potH; + 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, { + y: potH - 0.02, + size: [length * 0.34, length, 1], + 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), + }); + } + + return bin.build("plant.potted"); + }, +}); + +type TallParams = { + height: number; + potDiameter: number; + /** Whorls of leaves up the trunk. Three is a dracaena, one is a palm. */ + tiers: number; +}; + +/** 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; + +/** + * The one piece of arithmetic `footprint` and `build` have to agree on. + * + * `footprint` may not build geometry, so it cannot measure the plant; if it + * guesses instead, the number layout uses and the shape in the room drift + * 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. + */ +function tallCanopy(p: TallParams): { + potHeight: number; + trunk: number; + leaf: number; + spread: number; +} { + const potHeight = clamp(p.height * 0.26, 0.24, 0.55); + const trunk = (p.height - potHeight) * 0.55; + const rise = p.height - potHeight - trunk; + // 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); + return { + potHeight, + trunk, + leaf, + spread: Math.max(p.potDiameter, 2 * leaf * Math.sin(TALL_DROOP)), + }; +} + +export const plantTall = defineAsset({ + id: "tera:plant.tall", + label: "Tall plant", + defaults: { height: 1.8, potDiameter: 0.44, tiers: 3 }, + + footprint(p) { + const { spread } = tallCanopy(p); + return { width: spread, depth: spread, height: p.height }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const pot = ctx.materials.get("planter"); + const leaf = ctx.materials.get("foliage"); + + const { potHeight: potH, trunk: trunkH, leaf: leafLen } = tallCanopy(p); + bin.add(P.cylinder(16), pot, { size: [p.potDiameter * 0.78, potH, p.potDiameter * 0.78] }); + bin.add(P.cylinder(16), pot, { + y: potH - 0.04, + size: [p.potDiameter, 0.04, p.potDiameter], + }); + bin.add(P.disc(16), pot, { + y: potH - 0.015, + size: [p.potDiameter * 0.9, 1, p.potDiameter * 0.9], + }); + + // Two trunk segments with a slight kink. A single straight rod reads as a + // pole with leaves stapled to it. + const lean = jitter(ctx.rand, 0.05); + bin.add(P.rod(), pot, { y: potH - 0.05, size: [0.06, trunkH * 0.6, 0.06], roll: lean }); + bin.add(P.rod(), pot, { + x: -Math.sin(lean) * trunkH * 0.6, + y: potH - 0.05 + trunkH * 0.6, + size: [0.05, trunkH * 0.5, 0.05], + roll: -lean * 0.6, + }); + + // Whorls from the droop at the bottom to the crown at the top, stacked over + // the last third of the canopy. The lowest blades arch over and hang, which + // is the silhouette that makes this read as a plant rather than a sheaf. + const tiers = Math.max(1, Math.round(p.tiers)); + const rise = p.height - potH - trunkH; + let n = 0; + for (let tier = 0; tier < tiers; tier++) { + const t = tiers === 1 ? 1 : tier / (tiers - 1); + const y = potH + trunkH + rise * 0.34 * t; + const blades = 7 - tier; + for (let i = 0; i < blades; i++) { + bin.add(P.panel(), leaf, { + y, + size: [leafLen * 0.26, leafLen * (0.85 + ctx.rand() * 0.3), 1], + yaw: n++ * GOLDEN_ANGLE + jitter(ctx.rand, 0.25), + pitch: TALL_DROOP + (TALL_CROWN - TALL_DROOP) * t + jitter(ctx.rand, 0.15), + }); + } + } + + return bin.build("plant.tall"); + }, +}); diff --git a/src/assets/office/index.ts b/src/assets/office/index.ts new file mode 100644 index 0000000..04e1da6 --- /dev/null +++ b/src/assets/office/index.ts @@ -0,0 +1,81 @@ +/** + * The `tera:` office catalogue — every built-in asset, in one list. + * + * Importing this module registers all of them into the shared `kit`, which is + * what `kit.ts` says that registry is for. A self-hoster who wants their own + * registry instead calls `registerOfficeAssets(mine)`; one who wants ours plus + * theirs registers `acme:desk.standing` with `overrides: "tera:desk.workstation"` + * afterwards and every desk in every pack becomes theirs, with no fork. + * + * ### What is deliberately not here + * + * There is no `shell.wall`, `shell.door` or `shell.window` (CONTRACT.md §2). + * Walls are the `Floorplan`'s wall runs and a door or a window is an `Opening` + * punched out of one; `Plan` hands each solid run left over to the `wallRun` + * part in `parts.ts`. Shipping door props beside door-shaped holes would put + * every opening in the scene twice, or — worse, because it is invisible until + * somebody walks through a wall — leave the collider with no gap where the door + * is. + * + * Seventeen 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. + */ + +import { kit, type AnyAsset, type AssetRegistry } from "../kit.ts"; +import { deskPartition, deskPedestal, deskWorkstation } from "./desks.ts"; +import { plantPotted, plantTall } from "./greenery.ts"; +import { lightPendant, lightTroffer } from "./lighting.ts"; +import { screenMonitor, screenWallDisplay } from "./screens.ts"; +import { seatLounge, seatTaskChair } from "./seating.ts"; +import { storageLocker, storageShelf } from "./storage.ts"; +import { rug, whiteboard } from "./surfaces.ts"; +import { tableMeeting, tableSide } from "./tables.ts"; + +export const OFFICE_ASSETS: readonly AnyAsset[] = [ + deskWorkstation, + deskPedestal, + deskPartition, + seatTaskChair, + seatLounge, + tableMeeting, + tableSide, + storageShelf, + storageLocker, + screenMonitor, + screenWallDisplay, + plantPotted, + plantTall, + lightPendant, + lightTroffer, + rug, + whiteboard, +]; + +/** Register the built-in catalogue into a registry. Defaults to the shared one. */ +export function registerOfficeAssets(registry: AssetRegistry = kit): AssetRegistry { + return registry.registerAll(OFFICE_ASSETS); +} + +registerOfficeAssets(); + +export { + deskPartition, + deskPedestal, + deskWorkstation, + lightPendant, + lightTroffer, + plantPotted, + plantTall, + rug, + screenMonitor, + screenWallDisplay, + seatLounge, + seatTaskChair, + storageLocker, + storageShelf, + tableMeeting, + tableSide, + whiteboard, +}; diff --git a/src/assets/office/lighting.ts b/src/assets/office/lighting.ts new file mode 100644 index 0000000..10da85a --- /dev/null +++ b/src/assets/office/lighting.ts @@ -0,0 +1,131 @@ +/** + * Luminaires: a pendant and a recessed troffer. + * + * These are the two assets in the library whose datum is the ceiling rather + * than the floor. Their origin is the **mounting plane** and all of their + * geometry is below it (`y ≤ 0`), so a pack writes `elevation: 2.9` and gets a + * lamp hanging at 2.9 m — rather than a lamp whose author had to know the + * ceiling height of a room they have never seen. `footprint().height` is the + * total drop. + * + * They emit no light. The office's lighting is a fixed rig owned by the scene + * (CONTRACT.md §4), and a hundred fixtures each carrying a `PointLight` is both + * the wrong owner and, past about four shadow-casting lights, the end of the + * frame budget. What a fixture contributes is a glowing `lightDiffuser`, which + * is what you actually see. + */ + +import * as THREE from "three"; +import { defineAsset } from "../kit.ts"; +import { MeshBin } from "../parts.ts"; + +/** + * A diffuser is the one thing in the office that should never cast a shadow — + * it is the thing the light is coming out of, and a lamp that shadows the room + * beneath it looks broken in a way nobody can name. + */ +function litGroup(name: string, body: MeshBin, glow: MeshBin): THREE.Group { + const group = new THREE.Group(); + group.name = name; + group.add(body.build(`${name}:body`)); + group.add(glow.build(`${name}:glow`, { castShadow: false, receiveShadow: false })); + return group; +} + +type PendantParams = { + /** Mounting plane to the bottom of the shade. */ + drop: number; + shadeDiameter: number; + shadeHeight: number; +}; + +export const lightPendant = defineAsset({ + id: "tera:light.pendant", + label: "Pendant lamp", + defaults: { drop: 0.9, shadeDiameter: 0.34, shadeHeight: 0.22 }, + + footprint(p) { + return { width: p.shadeDiameter, depth: p.shadeDiameter, height: p.drop }; + }, + + build(p, ctx) { + const P = ctx.parts; + const body = new MeshBin(); + const glow = new MeshBin(); + const housing = ctx.materials.get("lightHousing"); + + // A ceiling rose, then the flex, then the shade. The rose matters more than + // it should: a cord that stops dead at the ceiling plane reads as a + // modelling mistake from the first time anybody looks up. + body.add(P.cylinder(12), housing, { y: -0.028, size: [0.1, 0.028, 0.1] }); + + const cord = Math.max(0.02, p.drop - p.shadeHeight); + body.add(P.rod(), housing, { y: -cord, size: [0.012, cord, 0.012] }); + // The cone's wide end is its base, so dropped to the bottom of the shade it + // is already the right way up for a pendant. + body.add(P.cone(20), housing, { + y: -p.drop, + size: [p.shadeDiameter, p.shadeHeight, p.shadeDiameter], + }); + glow.add(P.disc(20), ctx.materials.get("lightDiffuser"), { + y: -p.drop + 0.006, + size: [p.shadeDiameter * 0.9, 1, p.shadeDiameter * 0.9], + // A floor-plane disc faces up. Flipped, it faces the room. + pitch: Math.PI, + }); + + return litGroup("light.pendant", body, glow); + }, +}); + +type TrofferParams = { + length: number; + width: number; + /** How far the housing hangs below the mounting plane. */ + housingDepth: number; +}; + +export const lightTroffer = defineAsset({ + id: "tera:light.troffer", + label: "Ceiling troffer", + defaults: { length: 1.2, width: 0.3, housingDepth: 0.08 }, + + footprint(p) { + return { width: p.length, depth: p.width, height: p.housingDepth }; + }, + + build(p, ctx) { + const P = ctx.parts; + const body = new MeshBin(); + const glow = new MeshBin(); + + const tray = Math.max(0.02, p.housingDepth - 0.02); + body.add(P.box(), ctx.materials.get("lightHousing"), { + y: -tray, + size: [p.length, tray, p.width], + }); + // The frame is four bars rather than a slab behind the diffuser: from below + // — the only angle a recessed fitting is ever seen from — a slab is + // invisible and the bars are the whole of what reads as a light fitting. + const trim = ctx.materials.get("metalTrim"); + const bar = 0.02; + for (const sz of [-1, 1]) { + body.add(P.box(), trim, { + y: -p.housingDepth, + z: (sz * (p.width - bar)) / 2, + size: [p.length, bar, bar], + }); + body.add(P.box(), trim, { + x: (sz * (p.length - bar)) / 2, + y: -p.housingDepth, + size: [bar, bar, p.width - 2 * bar], + }); + } + glow.add(P.box(), ctx.materials.get("lightDiffuser"), { + y: -p.housingDepth + 0.002, + size: [p.length - 2 * bar, 0.018, p.width - 2 * bar], + }); + + return litGroup("light.troffer", body, glow); + }, +}); diff --git a/src/assets/office/screens.ts b/src/assets/office/screens.ts new file mode 100644 index 0000000..428256d --- /dev/null +++ b/src/assets/office/screens.ts @@ -0,0 +1,106 @@ +/** + * Screens: the one on a desk and the one on a wall. + * + * Both have their glass at +Z, facing the person, and both are authored with + * their origin on the floor. The monitor's foot sits at `y = 0` and a pack + * raises it onto a desk with `Prop.elevation`; the wall display carries its own + * `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. + */ + +import { defineAsset } from "../kit.ts"; +import { MeshBin } from "../parts.ts"; +import { alongFacing } from "./common.ts"; + +type MonitorParams = { + /** Bezel width, metres. 0.56 is a 24-inch panel. */ + width: number; + height: number; + /** Floor of the stand to the bottom of the bezel. */ + standHeight: number; + /** Radians the panel leans back. */ + tilt: number; +}; + +export const screenMonitor = defineAsset({ + id: "tera:screen.monitor", + label: "Monitor", + defaults: { width: 0.56, height: 0.34, standHeight: 0.14, tilt: 0.07 }, + + footprint(p) { + return { width: p.width, depth: 0.19, height: p.standHeight + p.height + 0.02 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + 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] }); + // 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] }); + + const baseY = p.standHeight + 0.02; + const pitch = -p.tilt; + const front = alongFacing(pitch, 0.014); + bin.add(P.roundedBox(0.03), bezel, { + y: baseY, + size: [p.width, p.height, 0.024], + pitch, + }); + bin.add(P.panel(), ctx.materials.get("screenDisplay"), { + y: baseY + 0.012 + front.y, + z: front.z, + size: [p.width - 0.018, p.height - 0.026, 1], + pitch, + }); + + return bin.build("screen.monitor"); + }, +}); + +type WallDisplayParams = { + width: number; + height: number; + /** Floor to the bottom edge of the screen. */ + mount: number; +}; + +export const screenWallDisplay = defineAsset({ + id: "tera:screen.wall-display", + label: "Wall display", + defaults: { width: 1.62, height: 0.94, mount: 0.86 }, + + footprint(p) { + // The depth is the whole assembly off the wall face, bracket included, so a + // pack can push the prop `depth / 2` off the wall and have it sit flush. + return { width: p.width, depth: 0.12, height: p.mount + p.height }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + + 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], + }); + bin.add(P.roundedBox(0.02), ctx.materials.get("screenBezel"), { + y: p.mount, + size: [p.width, p.height, 0.05], + }); + 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], + }); + + return bin.build("screen.wall-display"); + }, +}); diff --git a/src/assets/office/seating.ts b/src/assets/office/seating.ts new file mode 100644 index 0000000..1c60d91 --- /dev/null +++ b/src/assets/office/seating.ts @@ -0,0 +1,174 @@ +/** + * Chairs. Two of them: the one at a desk and the one you wait in. + * + * Both face −Z, which puts the backrest at +Z — a chair at yaw zero has its + * occupant looking the same way a desk at yaw zero does, and that is the whole + * reason `DeskBank` can hand one rotation to both. + */ + +import { defineAsset } from "../kit.ts"; +import { MeshBin } from "../parts.ts"; +import { tintable } from "./common.ts"; + +type TaskChairParams = { + width: number; + /** Height of the seat pan, metres. */ + seatHeight: number; + backHeight: number; + arms: boolean; +}; + +/** + * A five-star task chair. The fabric is the tintable part, which is why a + * `colorKey` on a chair reads as upholstery and not as a coloured base. + */ +export const seatTaskChair = defineAsset({ + id: "tera:seat.task-chair", + label: "Task chair", + defaults: { width: 0.5, seatHeight: 0.46, backHeight: 0.56, arms: true }, + + footprint(p) { + // The star base is the widest part of a task chair and it is wider than the + // seat. 0.64 m is a 320 mm arm, which is a real chair. + const span = Math.max(0.64, p.width + 0.14); + return { width: span, depth: span, height: p.seatHeight + p.backHeight, clearance: 0.3 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const base = ctx.materials.get("chairBase"); + const shell = ctx.materials.get("chairShell"); + const fabric = tintable(ctx, "chairFabric"); + + const reach = 0.28; + for (let i = 0; i < 5; i++) { + const yaw = (i * Math.PI * 2) / 5; + const sin = Math.sin(yaw); + const cos = Math.cos(yaw); + bin.add(P.box(), base, { + x: sin * reach * 0.5, + y: 0.035, + z: cos * reach * 0.5, + size: [0.05, 0.03, reach], + yaw, + }); + bin.add(P.cylinder(8), base, { + x: sin * reach, + z: cos * reach, + size: [0.055, 0.048, 0.055], + }); + } + + const panY = p.seatHeight - 0.08; + bin.add(P.rod(), base, { y: 0.05, size: [0.06, panY - 0.05, 0.06] }); + // The parts sharing a material must all be indexed or all not be, or the + // merge fails and drops the material entirely (see `common.ts`). Extruded + // `roundedBox` carries no index, so the shell keeps to plain boxes and the + // soft parts — arm pads included — go in with the fabric. + bin.add(P.box(), shell, { y: panY - 0.04, size: [p.width * 0.6, 0.05, 0.24] }); + bin.add(P.roundedBox(0.07), fabric, { y: panY, size: [p.width, 0.08, 0.48] }); + + // The backrest leans back by rotating about its own base, so the lumbar + // stays where the spine is and only the shoulders move. + const lean = 0.13; + bin.add(P.box(), shell, { y: panY, z: 0.2, size: [0.08, 0.14, 0.14] }); + bin.add(P.roundedBox(0.06), fabric, { + y: p.seatHeight + 0.04, + z: 0.21, + size: [p.width - 0.05, p.backHeight - 0.04, 0.06], + pitch: lean, + }); + + if (p.arms) { + for (const sx of [-1, 1]) { + const x = sx * (p.width / 2 + 0.02); + bin.add(P.box(), shell, { x, y: panY, size: [0.03, 0.19, 0.03] }); + bin.add(P.roundedBox(0.08), fabric, { + x, + y: panY + 0.19, + size: [0.06, 0.025, 0.24], + }); + } + } + + return bin.build("seat.task-chair"); + }, +}); + +type LoungeParams = { + width: number; + depth: number; + seatHeight: number; + backHeight: number; + arms: boolean; +}; + +/** + * A low armchair for a breakout or a reception. `width: 1.6, arms: true` is a + * two-seat sofa and looks like one, which is why there is no separate sofa + * asset — the difference between the two is one number. + * + * The upholstery is the tintable part. + */ +export const seatLounge = defineAsset({ + id: "tera:seat.lounge", + label: "Lounge chair", + defaults: { width: 0.84, depth: 0.82, seatHeight: 0.4, backHeight: 0.76, arms: true }, + + footprint(p) { + return { width: p.width, depth: p.depth, height: p.backHeight, clearance: 0.5 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const base = ctx.materials.get("chairBase"); + const shell = ctx.materials.get("chairShell"); + const cushion = tintable(ctx, "upholstery"); + + const foot = 0.08; + for (const sx of [-1, 1]) { + for (const sz of [-1, 1]) { + bin.add(P.rod(), base, { + x: sx * (p.width / 2 - 0.1), + z: sz * (p.depth / 2 - 0.1), + size: [0.045, foot, 0.045], + }); + } + } + + const cushionH = 0.16; + const seatY = p.seatHeight - cushionH; + bin.add(P.box(), shell, { + y: foot, + size: [p.width - 0.12, seatY - foot, p.depth - 0.14], + }); + bin.add(P.roundedBox(0.07), cushion, { + y: seatY, + z: 0.02, + size: [p.width - (p.arms ? 0.3 : 0.08), cushionH, p.depth - 0.2], + }); + + // The back cushion leans, and it is the lean that stops a lounge chair from + // reading as a cardboard box with a pillow on it. + bin.add(P.roundedBox(0.07), cushion, { + y: seatY + 0.02, + z: p.depth / 2 - 0.12, + size: [p.width - (p.arms ? 0.3 : 0.08), p.backHeight - seatY - 0.02, 0.16], + pitch: 0.12, + }); + + if (p.arms) { + for (const sx of [-1, 1]) { + bin.add(P.roundedBox(0.09), cushion, { + x: sx * (p.width / 2 - 0.07), + y: foot, + size: [0.14, p.seatHeight + 0.18 - foot, p.depth - 0.1], + }); + } + } + + return bin.build("seat.lounge"); + }, +}); diff --git a/src/assets/office/storage.ts b/src/assets/office/storage.ts new file mode 100644 index 0000000..7dd97e5 --- /dev/null +++ b/src/assets/office/storage.ts @@ -0,0 +1,168 @@ +/** + * Storage: an open shelf unit and a bank of lockers. + * + * Both are used from +Z and have a solid back at −Z, so a pack stands one + * against a wall by giving it the rotation that turns its back to the wall — + * the same rotation it would give a person standing in front of it. + */ + +import { defineAsset } from "../kit.ts"; +import { MeshBin } from "../parts.ts"; +import { clamp, jitter, tintable } from "./common.ts"; + +type ShelfParams = { + width: number; + depth: number; + height: number; + /** Open bays, not boards. Four bays is five boards. */ + shelves: number; + /** Fill the bays with books. Seeded from `ctx.rand`, so it is stable. */ + books: boolean; +}; + +const BOARD = 0.02; + +/** An open shelf unit. The boards are the tintable part. */ +export const storageShelf = defineAsset({ + id: "tera:storage.shelf", + label: "Shelf unit", + defaults: { width: 0.9, depth: 0.35, height: 1.6, shelves: 4, books: true }, + + footprint(p) { + return { width: p.width, depth: p.depth, height: p.height, clearance: 0.6 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const board = tintable(ctx, "shelf"); + const bays = Math.max(1, Math.round(p.shelves)); + const bayH = (p.height - (bays + 1) * BOARD) / bays; + const inner = p.width - 2 * 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.box(), ctx.materials.get("cabinet"), { + z: -(p.depth / 2 - 0.006), + size: [inner, p.height, 0.012], + }); + + for (let i = 0; i <= bays; i++) { + bin.add(P.box(), board, { + y: i * (bayH + BOARD), + size: [inner, BOARD, p.depth], + }); + } + + if (p.books) { + // Books are three materials and nothing else, so a full wall of shelving + // is three more merged meshes rather than three hundred. + const spines = [ + ctx.materials.get("paper"), + ctx.materials.get("accent"), + ctx.materials.get("cabinet"), + ]; + for (let i = 0; i < bays; i++) { + const shelfY = i * (bayH + BOARD) + BOARD; + let x = -inner / 2 + 0.015; + while (x < inner / 2 - 0.06) { + if (ctx.rand() < 0.14) { + x += 0.04 + ctx.rand() * 0.08; + continue; + } + const w = 0.018 + ctx.rand() * 0.038; + const h = bayH * (0.62 + ctx.rand() * 0.26); + const material = spines[Math.floor(ctx.rand() * spines.length)] ?? spines[0]; + if (!material) break; + bin.add(P.box(), material, { + x: x + w / 2, + y: shelfY, + z: 0.02 + jitter(ctx.rand, 0.015), + size: [w, h, clamp(p.depth * 0.6, 0.12, 0.26)], + roll: jitter(ctx.rand, 0.03), + }); + x += w + 0.004; + } + } + } + + return bin.build("storage.shelf"); + }, +}); + +type LockerParams = { + width: number; + depth: number; + height: number; + columns: number; + /** Doors stacked per column. Two is the usual personal-locker bank. */ + tiers: number; +}; + +/** A bank of lockers. The doors are the tintable part. */ +export const storageLocker = defineAsset({ + id: "tera:storage.locker", + label: "Locker bank", + defaults: { width: 1.2, depth: 0.5, height: 1.8, columns: 3, tiers: 2 }, + + 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 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const carcass = ctx.materials.get("cabinet"); + const door = tintable(ctx, "cabinet"); + const trim = ctx.materials.get("metalTrim"); + + const plinth = 0.08; + bin.add(P.box(), trim, { y: 0, size: [p.width - 0.04, plinth, p.depth - 0.04] }); + bin.add(P.box(), carcass, { + y: plinth, + size: [p.width, p.height - plinth, p.depth], + }); + + const columns = Math.max(1, Math.round(p.columns)); + const tiers = Math.max(1, Math.round(p.tiers)); + const cellW = p.width / columns; + const cellH = (p.height - plinth) / tiers; + + for (let c = 0; c < columns; c++) { + const x = -p.width / 2 + cellW * (c + 0.5); + for (let t = 0; t < tiers; t++) { + const y = plinth + cellH * t + 0.008; + bin.add(P.box(), door, { + x, + y, + z: p.depth / 2, + size: [cellW - 0.016, cellH - 0.016, 0.022], + }); + // A vertical pull on the leading edge, plus the vent slot that is the + // one detail that makes a painted box read as a locker. + bin.add(P.box(), trim, { + x: x + cellW / 2 - 0.05, + y: y + cellH * 0.32, + z: p.depth / 2 + 0.016, + size: [0.016, cellH * 0.3, 0.016], + }); + for (let s = 0; s < 3; s++) { + bin.add(P.box(), trim, { + x, + y: y + cellH - 0.09 + s * 0.022, + z: p.depth / 2 + 0.012, + size: [cellW * 0.4, 0.008, 0.006], + }); + } + } + } + + return bin.build("storage.locker"); + }, +}); diff --git a/src/assets/office/surfaces.ts b/src/assets/office/surfaces.ts new file mode 100644 index 0000000..7cf8eab --- /dev/null +++ b/src/assets/office/surfaces.ts @@ -0,0 +1,126 @@ +/** + * The two flat things: a rug on the floor and a board on the wall. + * + * Neither is part of the shell. A room's floor finish and its walls come from + * the `Floorplan` (CONTRACT.md §2); a rug is a prop laid on top of whatever the + * room's floor already is, which is exactly how a rug works. + */ + +import { defineAsset } from "../kit.ts"; +import { MeshBin } from "../parts.ts"; +import { panelSlab, tintable } from "./common.ts"; + +type RugParams = { + width: number; + depth: number; + /** Pile thickness. Small, but not zero — a rug at zero z-fights the floor. */ + pile: number; + /** A plain band of the base carpet around the tinted field. */ + border: boolean; +}; + +/** The pile is the tintable part; the border, when there is one, is not. */ +export const rug = defineAsset({ + id: "tera:rug", + label: "Rug", + defaults: { width: 2.4, depth: 1.7, pile: 0.014, border: true }, + + footprint(p) { + return { width: p.width, depth: p.depth, height: p.pile }; + }, + + build(p, ctx) { + const bin = new MeshBin(); + const field = tintable(ctx, "carpetAccent"); + const edge = ctx.materials.get("carpet"); + + bin.add(ctx.parts.box(), field, { size: [p.width, p.pile, p.depth] }); + + if (p.border) { + const band = Math.min(0.12, Math.min(p.width, p.depth) * 0.08); + bin.add(ctx.parts.metricQuad(p.width, p.depth), edge, { y: p.pile + 0.0006 }); + bin.add(ctx.parts.metricQuad(p.width - band * 2, p.depth - band * 2), field, { + y: p.pile + 0.0012, + }); + } else { + bin.add(ctx.parts.metricQuad(p.width, p.depth), field, { y: p.pile + 0.0006 }); + } + + return bin.build("rug", { castShadow: false }); + }, +}); + +type WhiteboardParams = { + width: number; + height: number; + /** Floor to the bottom edge of the writing surface. */ + mount: number; + tray: boolean; +}; + +/** + * A wall-hung board. Authored standing on the floor with the writing surface + * from `mount` up, so a pack places it against the wall and never has to work + * out how high a whiteboard goes. + * + * 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. + */ +export const whiteboard = defineAsset({ + id: "tera:whiteboard", + label: "Whiteboard", + defaults: { width: 1.8, height: 1.2, mount: 0.9, tray: true }, + + footprint(p) { + return { width: p.width, depth: 0.1, height: p.mount + p.height }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const trim = ctx.materials.get("metalTrim"); + + panelSlab(bin, ctx, ctx.materials.get("whiteboard"), { + y: p.mount, + width: p.width - 0.05, + height: p.height - 0.05, + thickness: 0.022, + faces: "front", + }); + + const bar = 0.025; + for (const s of [-1, 1]) { + bin.add(P.box(), trim, { + y: p.mount + (s < 0 ? 0 : p.height - bar), + size: [p.width, bar, 0.03], + }); + bin.add(P.box(), trim, { + x: (s * (p.width - bar)) / 2, + y: p.mount, + size: [bar, p.height, 0.03], + }); + } + + if (p.tray) { + bin.add(P.box(), trim, { + y: p.mount - 0.03, + z: 0.035, + size: [p.width * 0.55, 0.016, 0.07], + }); + const pens = ctx.materials.get("accent"); + for (let i = 0; i < 3; i++) { + // Rolled a quarter turn, a rod lies along −X from where it is placed. + bin.add(P.rod(), pens, { + x: 0.02 + i * 0.045, + y: p.mount - 0.014, + z: 0.04, + size: [0.014, 0.13, 0.014], + roll: Math.PI / 2, + }); + } + } + + return bin.build("whiteboard"); + }, +}); diff --git a/src/assets/office/tables.ts b/src/assets/office/tables.ts new file mode 100644 index 0000000..8f4417f --- /dev/null +++ b/src/assets/office/tables.ts @@ -0,0 +1,112 @@ +/** + * Tables. A meeting table that is either a rectangle on trestles or a round one + * on a pedestal, and the small round one that goes between two lounge chairs. + * + * A table has no front, so its rotation only matters for a rectangle. Its + * footprint is still centred on its origin like everything else. + */ + +import { defineAsset } from "../kit.ts"; +import { MeshBin } from "../parts.ts"; +import { slab } from "./common.ts"; + +type MeetingParams = { + /** Length along local X. For `shape: "round"` this is the diameter. */ + length: number; + /** Depth along local Z. Ignored when round. */ + width: number; + height: number; + shape: "rect" | "round"; + legs: "trestle" | "post"; +}; + +const TOP = 0.04; + +export const tableMeeting = defineAsset({ + id: "tera:table.meeting", + label: "Meeting table", + defaults: { length: 2.4, width: 1.2, height: 0.74, shape: "rect", legs: "trestle" }, + + footprint(p) { + const depth = p.shape === "round" ? p.length : p.width; + // A metre of clearance is a chair pushed back plus somebody edging past it, + // which is what the room around a meeting table has to actually allow for. + return { width: p.length, depth, height: p.height, clearance: 1 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const frame = ctx.materials.get("deskFrame"); + const top = ctx.materials.get("tableTop"); + const deckY = p.height - TOP; + + if (p.shape === "round") { + // A round top takes its map as one sheet across the whole disc, which is + // what a veneered table looks like anyway; `metricQuad` is a rectangle and + // has nothing to say about a circle. + bin.add(P.cylinder(28), top, { y: deckY, size: [p.length, TOP, p.length] }); + bin.add(P.cylinder(20), frame, { y: 0.02, size: [0.14, deckY - 0.02, 0.14] }); + bin.add(P.cylinder(24), frame, { size: [p.length * 0.42, 0.03, p.length * 0.42] }); + return bin.build("table.meeting"); + } + + slab(bin, ctx, top, { y: deckY, width: p.length, depth: p.width, thickness: TOP }); + + if (p.legs === "post") { + for (const sx of [-1, 1]) { + for (const sz of [-1, 1]) { + bin.add(P.rod(), frame, { + x: sx * (p.length / 2 - 0.12), + z: sz * (p.width / 2 - 0.12), + size: [0.07, deckY, 0.07], + }); + } + } + return bin.build("table.meeting"); + } + + const endX = Math.max(0.2, p.length / 2 - 0.36); + for (const sx of [-1, 1]) { + const x = sx * endX; + bin.add(P.box(), frame, { x, size: [0.09, 0.05, p.width - 0.24] }); + bin.add(P.box(), frame, { x, y: 0.05, size: [0.1, deckY - 0.05, 0.1] }); + bin.add(P.box(), frame, { x, y: deckY - 0.09, size: [0.12, 0.09, p.width - 0.3] }); + } + bin.add(P.box(), frame, { y: deckY - 0.24, size: [endX * 2, 0.09, 0.09] }); + + return bin.build("table.meeting"); + }, +}); + +type SideParams = { + diameter: number; + height: number; +}; + +/** The little round one. Coffee height by default. */ +export const tableSide = defineAsset({ + id: "tera:table.side", + label: "Side table", + defaults: { diameter: 0.5, height: 0.45 }, + + footprint(p) { + return { width: p.diameter, depth: p.diameter, height: p.height, clearance: 0.2 }; + }, + + build(p, ctx) { + const P = ctx.parts; + const bin = new MeshBin(); + const frame = ctx.materials.get("metalTrim"); + const deckY = p.height - 0.028; + + bin.add(P.cylinder(24), ctx.materials.get("tableTop"), { + y: deckY, + size: [p.diameter, 0.028, p.diameter], + }); + bin.add(P.cylinder(12), frame, { y: 0.015, size: [0.06, deckY - 0.015, 0.06] }); + bin.add(P.cylinder(20), frame, { size: [p.diameter * 0.62, 0.015, p.diameter * 0.62] }); + + return bin.build("table.side"); + }, +}); diff --git a/src/assets/palette.ts b/src/assets/palette.ts new file mode 100644 index 0000000..b7d0869 --- /dev/null +++ b/src/assets/palette.ts @@ -0,0 +1,185 @@ +/** + * The interior palette, derived from the city's rather than invented beside it. + * + * The problem this solves is that an office and the city it stands in are two + * scenes built by different code, and "they look like they belong together" is + * the kind of property that survives exactly as long as one person is holding + * both files open. So it is not left to taste: every `SurfaceRole` declares an + * **HSL shift from a named entry of the city's `ScenePalette`**, and the + * interior palette is computed from whatever the city actually ships. Recolour + * `DEFAULT_PALETTE`, or hand a city its own `palette` override, and the offices + * inside it move with it. + * + * ### The band + * + * Every derived role is clamped back into the city's own saturation and + * lightness band — the min and max across the city palette's ten entries — + * before it is returned. That clamp is the mechanism; the shifts are only + * allowed to move a colour *within* the range the city already occupies, so no + * office can be more chromatic than the world outside it. + * + * Lightness gets one declared concession, `LIGHTNESS_HEADROOM`. A city seen + * from two kilometres up has no analogue for a black screen bezel or a sheet of + * paper, and clamping the darkest role to San Francisco's darkest hillside + * (L≈0.39) produced an office with no shadow in it and no white in it either. + * The headroom is a single number applied to both ends, stated here, rather + * than a per-role escape hatch — the moment roles can opt out of the band + * individually, the band stops being a constraint and goes back to being a + * vibe. Saturation gets no such concession and is clamped hard. + */ + +import * as THREE from "three"; +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; + +/** One role's derivation: a city colour, and how far to move it. */ +export interface RoleShift { + /** Which entry of the city palette this role descends from. */ + from: keyof ScenePalette; + /** Hue shift in degrees. Wraps. */ + dh: number; + /** Saturation shift, absolute, in 0..1. */ + ds: number; + /** Lightness shift, absolute, in 0..1. */ + dl: number; +} + +export type InteriorPalette = Record; + +/** + * The derivation table. + * + * Read it as a sentence: carpet is the city's upland green-grey, nudged warm + * and taken down a tenth; glazing is the horizon sky, barely touched, because + * glass seen from inside is the sky. Where a role has no obvious ancestor it + * descends from `flats`, which is the city's most neutral colour and the right + * parent for anything that wants to be quiet. + */ +export const ROLE_SHIFTS: Record = { + // Floors + floorSlab: { from: "flats", dh: 0, ds: 0, dl: -0.06 }, + carpet: { from: "upland", dh: 8, ds: 0.01, dl: -0.1 }, + carpetAccent: { from: "park", dh: -6, ds: 0.04, dl: -0.06 }, + woodFloor: { from: "sand", dh: -8, ds: 0.1, dl: -0.1 }, + polishedConcrete: { from: "flats", dh: 4, ds: -0.01, dl: -0.02 }, + tile: { from: "shore", dh: 6, ds: -0.01, dl: 0.14 }, + + // Ceilings + ceilingTile: { from: "sand", dh: 6, ds: -0.05, dl: 0.22 }, + ceilingBaffle: { from: "upland", dh: 10, ds: 0, dl: -0.14 }, + + // The vertical shell + plaster: { from: "shore", dh: 4, ds: -0.02, dl: 0.2 }, + plasterAccent: { from: "park", dh: -10, ds: 0.03, dl: 0.02 }, + skirting: { from: "upland", dh: 0, ds: 0, dl: -0.16 }, + glazing: { from: "skyHorizon", dh: -6, ds: 0.02, dl: -0.02 }, + glazingFrame: { from: "flats", dh: 6, ds: 0, dl: -0.22 }, + doorLeaf: { from: "sand", dh: -6, ds: 0.04, dl: -0.12 }, + + // Partitions + partitionFabric: { from: "sea", dh: 6, ds: -0.1, dl: 0.02 }, + partitionFrame: { from: "flats", dh: 2, ds: 0, dl: -0.18 }, + + // Furniture + deskSurface: { from: "sand", dh: -4, ds: 0.02, dl: -0.02 }, + deskFrame: { from: "upland", dh: 6, ds: -0.01, dl: -0.24 }, + tableTop: { from: "sand", dh: -10, ds: 0.06, dl: -0.08 }, + cabinet: { from: "shore", dh: 2, ds: 0, dl: 0.06 }, + shelf: { from: "sand", dh: -6, ds: 0.03, dl: -0.04 }, + chairShell: { from: "flats", dh: 6, ds: 0, dl: -0.26 }, + chairFabric: { from: "sea", dh: 10, ds: -0.06, dl: -0.1 }, + chairBase: { from: "upland", dh: 0, ds: -0.02, dl: -0.28 }, + upholstery: { from: "lake", dh: 14, ds: -0.08, dl: -0.04 }, + + // Fittings + metalTrim: { from: "flats", dh: 0, ds: -0.02, dl: 0.06 }, + screenBezel: { from: "upland", dh: 4, ds: -0.02, dl: -0.3 }, + screenDisplay: { from: "sea", dh: 4, ds: -0.12, dl: -0.18 }, + lightHousing: { from: "shore", dh: 4, ds: -0.02, dl: 0.1 }, + lightDiffuser: { from: "skyHorizon", dh: 6, ds: -0.2, dl: 0.1 }, + whiteboard: { from: "shore", dh: 8, ds: -0.03, dl: 0.26 }, + + // Objects + foliage: { from: "park", dh: 4, ds: 0.06, dl: -0.06 }, + planter: { from: "shore", dh: -4, ds: 0.02, dl: -0.06 }, + paper: { from: "sand", dh: 4, ds: -0.06, dl: 0.24 }, + accent: { from: "sea", dh: -6, ds: 0.08, dl: 0 }, +}; + +/** The saturation and lightness range a derived role must land in. */ +export interface Band { + minS: number; + maxS: number; + minL: number; + maxL: number; +} + +/** The saturation and lightness range the city palette actually occupies. */ +function bandOf(city: ScenePalette): Band { + const scratch = new THREE.Color(); + const hsl = { h: 0, s: 0, l: 0 }; + let minS = Infinity; + let maxS = -Infinity; + let minL = Infinity; + let maxL = -Infinity; + for (const hex of Object.values(city)) { + scratch.setHex(hex).getHSL(hsl, THREE.SRGBColorSpace); + if (hsl.s < minS) minS = hsl.s; + if (hsl.s > maxS) maxS = hsl.s; + if (hsl.l < minL) minL = hsl.l; + if (hsl.l > maxL) maxL = hsl.l; + } + return { + minS, + maxS, + minL: Math.max(0, minL - LIGHTNESS_HEADROOM), + maxL: Math.min(1, maxL + LIGHTNESS_HEADROOM), + }; +} + +function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} + +/** Apply one shift to one city colour and clamp the result into the band. */ +export function applyShift(hex: number, shift: RoleShift, band: Band): number { + const color = new THREE.Color(hex); + const hsl = { h: 0, s: 0, l: 0 }; + // Both ends of this must name the colour space. `getHSL` defaults to the + // working space (linear-sRGB) while `setHSL` defaults to sRGB, so leaving + // them implicit reads a colour in one space and writes it back in another — + // which silently crushed every derived lightness toward black the first time + // this was written. + color.getHSL(hsl, THREE.SRGBColorSpace); + const h = (((hsl.h + shift.dh / 360) % 1) + 1) % 1; + const s = clamp(hsl.s + shift.ds, band.minS, band.maxS); + const l = clamp(hsl.l + shift.dl, band.minL, band.maxL); + return color.setHSL(h, s, l, THREE.SRGBColorSpace).getHex(THREE.SRGBColorSpace); +} + +/** + * The interior palette for a city palette. + * + * Pass `paletteFor(world)` to get the palette of a city that overrides some of + * `DEFAULT_PALETTE`; pass nothing for the reference one. + */ +export function derivePalette(city: ScenePalette = DEFAULT_PALETTE): InteriorPalette { + const band = bandOf(city); + const out = {} as InteriorPalette; + for (const key of Object.keys(ROLE_SHIFTS) as SurfaceRole[]) { + const shift = ROLE_SHIFTS[key]; + out[key] = applyShift(city[shift.from], shift, band); + } + return out; +} + +/** + * The palette every office gets unless it is told otherwise — the one derived + * from the engine's own `DEFAULT_PALETTE`. Computed once at module load, which + * is thirty-five colour conversions and not worth deferring. + */ +export const DEFAULT_INTERIOR_PALETTE: InteriorPalette = derivePalette(DEFAULT_PALETTE); diff --git a/src/assets/parts.ts b/src/assets/parts.ts new file mode 100644 index 0000000..05287b0 --- /dev/null +++ b/src/assets/parts.ts @@ -0,0 +1,323 @@ +/** + * The shared bin of unit primitives every asset is built out of. + * + * Two jobs, and they are the same job seen from two ends. + * + * **Draw calls.** The reference office is about 1,200 objects. Built naively — + * one `THREE.Mesh` per box, one `BoxGeometry` per mesh — that is 1,200 draw + * calls and about as many geometries, and it drops a laptop to single-figure + * frame rates before a single desk has anything on it. Built out of this bin it + * is roughly thirty: every asset composes *cached* unit geometries placed by a + * scaled local matrix, and `MeshBin` merges everything sharing a material into + * one buffer. Thirty draw calls is one per `SurfaceRole` actually used. + * + * **Coherence.** Eighteen people writing eighteen asset builders produce + * eighteen dialects unless they are all reaching for the same shapes. A desk leg + * and a chair column that are both `rod()` scaled differently look related in a + * way that two hand-tuned cylinders never quite do. The bin is the library's + * accent. + * + * ### Conventions + * + * Assets are authored in **metres, 1 unit = 1 m** (CONTRACT.md §3), and every + * unit part is 1 m in each dimension with its **base on y = 0**, centred in X + * and Z. So `scale(0.8, 0.73, 0.6)` on a `box()` is a 800 × 600 desktop 730 mm + * off the floor, and nothing has to remember whether a primitive is + * origin-centred or base-centred. The exceptions are stated on each method. + * + * Unit parts carry 0..1 UVs and therefore stretch under scale. That is fine for + * the roles that carry no texture map, and it is why the two parts that *do* + * meet textured surfaces — `metricQuad` and `wallRun` — generate their UVs in + * metres instead. + */ + +import * as THREE from "three"; +import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; +import { TEXTURE_TILE_METRES } from "./textures.ts"; + +/** Quantise a dimension to millimetres, so near-identical runs share a cache slot. */ +function mm(v: number): number { + return Math.round(v * 1000); +} + +export class PartBin { + private readonly cache = new Map(); + + private memo(key: string, make: () => THREE.BufferGeometry): THREE.BufferGeometry { + const hit = this.cache.get(key); + if (hit) return hit; + const made = make(); + made.name = key; + this.cache.set(key, made); + return made; + } + + // ---- Solids ------------------------------------------------------------- + + /** 1 m cube, base on the floor. The workhorse. */ + box(): THREE.BufferGeometry { + return this.memo("box", () => new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0)); + } + + /** + * 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. + */ + 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); + + // 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; + }); + } + + /** Unit-diameter cylinder, base on the floor. */ + cylinder(segments = 16): 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 + * that nobody will count the sides, which is most of the office. + */ + rod(): THREE.BufferGeometry { + return this.cylinder(6); + } + + /** Unit-diameter cone, base on the floor. */ + cone(segments = 12): THREE.BufferGeometry { + return this.memo(`cone:${segments}`, () => + new THREE.ConeGeometry(0.5, 1, segments).translate(0, 0.5, 0), + ); + } + + /** Unit-diameter sphere, resting on the floor rather than centred on it. */ + sphere(segments = 16): THREE.BufferGeometry { + return this.memo(`sph:${segments}`, () => + new THREE.SphereGeometry(0.5, segments, Math.max(4, segments >> 1)).translate(0, 0.5, 0), + ); + } + + // ---- Surfaces ----------------------------------------------------------- + + /** 1 × 1 plane lying in XZ, facing up. Centred, not base-offset. */ + quad(): THREE.BufferGeometry { + return this.memo("quad", () => new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2)); + } + + /** 1 × 1 plane standing in XY, facing +Z, base on the floor. */ + panel(): THREE.BufferGeometry { + return this.memo("panel", () => new THREE.PlaneGeometry(1, 1).translate(0, 0.5, 0)); + } + + /** Unit-diameter disc lying in XZ, facing up. */ + disc(segments = 24): THREE.BufferGeometry { + return this.memo(`disc:${segments}`, () => + new THREE.CircleGeometry(0.5, segments).rotateX(-Math.PI / 2), + ); + } + + /** + * A floor-plane rectangle of a given size in metres, with **UVs in metres** + * so a carpet reads the same size in a 3 m booth and a 30 m floor plate. + * + * This is the part to use under any textured surface. `quad()` scaled to the + * same size would smear one tile of carpet across the whole room. + */ + metricQuad(width: number, depth: number): THREE.BufferGeometry { + return this.memo(`mq:${mm(width)}:${mm(depth)}`, () => { + const geo = new THREE.PlaneGeometry(width, depth).rotateX(-Math.PI / 2); + const uv = geo.getAttribute("uv"); + for (let i = 0; i < uv.count; i++) { + uv.setXY( + i, + (uv.getX(i) * width) / TEXTURE_TILE_METRES, + (uv.getY(i) * depth) / TEXTURE_TILE_METRES, + ); + } + uv.needsUpdate = true; + return geo; + }); + } + + /** + * One solid run of wall: a box `length` long, `height` tall and `thickness` + * deep, running along local +X with its base on the floor and centred on its + * line. + * + * This is the part `Plan` hands each piece of wall left over after its + * openings are punched out (CONTRACT.md §2). There is deliberately no door or + * window asset to go with it — shipping both a door prop and a door-shaped + * hole puts every opening in the scene twice, or leaves the collider with no + * gap where the door is. + * + * Each face gets UVs in metres over its own two extents, so the finish does + * not stretch on a long wall or squash on the reveal at its end. + */ + wallRun(length: number, height: number, thickness: number): THREE.BufferGeometry { + return this.memo(`wall:${mm(length)}:${mm(height)}:${mm(thickness)}`, () => { + const geo = new THREE.BoxGeometry(length, height, thickness); + // BoxGeometry emits its faces in a fixed order — +X, -X, +Y, -Y, +Z, -Z — + // four vertices each at the default one segment per side. + const extents: [number, number][] = [ + [thickness, height], + [thickness, height], + [length, thickness], + [length, thickness], + [length, height], + [length, height], + ]; + const uv = geo.getAttribute("uv"); + for (let face = 0; face < 6; face++) { + const extent = extents[face] ?? [1, 1]; + for (let v = 0; v < 4; v++) { + const i = face * 4 + v; + uv.setXY( + i, + (uv.getX(i) * extent[0]) / TEXTURE_TILE_METRES, + (uv.getY(i) * extent[1]) / TEXTURE_TILE_METRES, + ); + } + } + uv.needsUpdate = true; + return geo.translate(0, height / 2, 0); + }); + } + + dispose(): void { + for (const geo of this.cache.values()) geo.dispose(); + this.cache.clear(); + } +} + +/** + * The bin every asset uses. Module-level and shared on purpose — a second bin + * is a second copy of every geometry, and the whole argument for the bin is + * that there is one of each. + */ +export const parts = new PartBin(); + +// ---- Placement ------------------------------------------------------------ + +export interface Placement { + /** Metres. `y` is the base of the part, matching the unit convention. */ + x?: number; + y?: number; + z?: number; + /** Metres in each axis. A number scales all three. */ + size?: number | [number, number, number]; + /** Yaw about +Y, radians, three.js sense. */ + yaw?: number; + /** Pitch about +X and roll about +Z, for the rare tilted part. */ + pitch?: number; + roll?: number; +} + +const scratchPosition = new THREE.Vector3(); +const scratchQuaternion = new THREE.Quaternion(); +const scratchEuler = new THREE.Euler(); +const scratchScale = new THREE.Vector3(); + +export function placementMatrix(p: Placement, into = new THREE.Matrix4()): THREE.Matrix4 { + const size = p.size ?? 1; + scratchPosition.set(p.x ?? 0, p.y ?? 0, p.z ?? 0); + scratchEuler.set(p.pitch ?? 0, p.yaw ?? 0, p.roll ?? 0, "YXZ"); + scratchQuaternion.setFromEuler(scratchEuler); + if (typeof size === "number") scratchScale.set(size, size, size); + else scratchScale.set(size[0], size[1], size[2]); + return into.compose(scratchPosition, scratchQuaternion, scratchScale); +} + +/** + * Collects transformed parts and emits **one mesh per material**. + * + * This is where the draw-call budget is actually spent. An asset builder adds + * forty boxes across five materials and gets a `THREE.Group` of five meshes + * back; a `Plan` that pours a whole floor's props into one bin gets five meshes + * for the floor. Merging costs a geometry clone per part during the build and + * nothing afterwards, which is the right trade for something built once and + * looked at for an hour. + * + * The cost of merging is that the parts stop being individually addressable — + * you cannot move one chair after the fact. Anything that has to move on its own + * (a door leaf, a hovering label, a selected prop) belongs in its own object + * rather than in a bin. + */ +export class MeshBin { + private readonly groups = new Map(); + private readonly matrix = new THREE.Matrix4(); + + /** Add a part under a transform. The geometry is cloned, never mutated. */ + add(geometry: THREE.BufferGeometry, material: THREE.Material, place: Placement = {}): this { + const clone = geometry.clone(); + clone.applyMatrix4(placementMatrix(place, this.matrix)); + const list = this.groups.get(material); + if (list) list.push(clone); + else this.groups.set(material, [clone]); + return this; + } + + /** `add(parts.box(), …)`, which is most of what any asset does. */ + box(material: THREE.Material, place: Placement): this { + return this.add(parts.box(), material, place); + } + + /** Number of parts waiting to be merged. Handy in an asset's own tests. */ + get size(): number { + let n = 0; + for (const list of this.groups.values()) n += list.length; + return n; + } + + build( + name = "parts", + options: { castShadow?: boolean; receiveShadow?: boolean } = {}, + ): THREE.Group { + const group = new THREE.Group(); + group.name = name; + for (const [material, list] of this.groups) { + const merged = list.length === 1 ? list[0] : mergeGeometries(list, false); + if (!merged) continue; + if (list.length > 1) for (const geo of list) geo.dispose(); + const mesh = new THREE.Mesh(merged, material); + mesh.name = `${name}:${material.name || "material"}`; + mesh.castShadow = options.castShadow ?? true; + mesh.receiveShadow = options.receiveShadow ?? true; + group.add(mesh); + } + this.groups.clear(); + return group; + } +} diff --git a/src/assets/textures.ts b/src/assets/textures.ts new file mode 100644 index 0000000..35e25b8 --- /dev/null +++ b/src/assets/textures.ts @@ -0,0 +1,355 @@ +/** + * Every surface texture in the library, drawn at runtime on a 2D canvas. + * + * Nothing here is fetched, imported or base64'd, and that is a licensing + * decision before it is a graphics one (CONTRACT.md §3): a repo that ships no + * binary art has nothing in it whose provenance anyone has to take on trust. + * + * Three properties are load-bearing and easy to lose: + * + * 1. **Textures are neutral, not coloured.** Each one is drawn near-white and + * modulates *downward*, so `material.color` supplies the hue and the map + * 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. + * 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. + * 3. **They are cheap to build.** The noise is evaluated on a coarse grid and + * bilinearly upsampled rather than per-pixel — four `fbm` calls per pixel + * 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. + * + * 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. + */ + +import * as THREE from "three"; +import { fbm, seededRandom } from "../engine/world.ts"; + +/** + * How many metres one repeat of a texture covers. + * + * Geometry that carries a textured surface must therefore generate UVs in + * metres divided by this — `PartBin.metricQuad` and `PartBin.wallRun` do, which + * is why a 12 m wall and a 3 m wall show the same size of grain. Unit + * primitives with 0..1 UVs will stretch, and are only used for roles that carry + * no map. + */ +export const TEXTURE_TILE_METRES = 2; + +export type TextureKind = + | "carpetLoop" + | "woodPlank" + | "polishedConcrete" + | "ceilingTile" + | "plasterPaint" + | "fabricWeave" + | "tileGrid" + | "whiteboard"; + +export type TextureQuality = "low" | "medium" | "high"; + +/** `low` draws nothing at all: the materials fall back to flat colour. */ +const RESOLUTION: Record = { low: 0, medium: 256, high: 512 }; + +// ---- Noise ---------------------------------------------------------------- + +/** + * A tileable noise field, sampled on a coarse grid. + * + * `fbm` is not periodic, so the four-way blend below is what makes the left + * edge equal the right edge: each point is mixed with its wrapped neighbours + * weighted by how close it is to them, which is exactly zero contribution in + * the middle of the tile and a perfect match at the seam. + */ +function tileableNoise(res: number, scale: number, offset: number): Float32Array { + const field = new Float32Array(res * res); + for (let y = 0; y < res; y++) { + const v = y / res; + for (let x = 0; x < res; x++) { + const u = x / res; + const a = fbm(offset + u * scale, offset + v * scale); + const b = fbm(offset + (u - 1) * scale, offset + v * scale); + const c = fbm(offset + u * scale, offset + (v - 1) * scale); + const d = fbm(offset + (u - 1) * scale, offset + (v - 1) * scale); + const top = a * (1 - u) + b * u; + const bottom = c * (1 - u) + d * u; + field[y * res + x] = top * (1 - v) + bottom * v; + } + } + return field; +} + +/** Bilinear read of a wrapped coarse field, in 0..1 texture space. */ +function sampleField(field: Float32Array, res: number, u: number, v: number): number { + const fx = u * res; + const fy = v * res; + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const tx = fx - x0; + const ty = fy - y0; + const xa = ((x0 % res) + res) % res; + const ya = ((y0 % res) + res) % res; + const xb = (xa + 1) % res; + const yb = (ya + 1) % res; + const a = field[ya * res + xa] ?? 0; + const b = field[ya * res + xb] ?? 0; + const c = field[yb * res + xa] ?? 0; + const d = field[yb * res + xb] ?? 0; + return (a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty; +} + +/** + * Multiply the canvas down by a noise field. + * + * `amount` is the depth of the darkest dip, as a fraction. Everything stays at + * or below the colour already on the canvas, which is what keeps the map a + * tint-preserving multiplier rather than something that lightens a dark + * palette back toward white. + */ +function grain( + ctx: CanvasRenderingContext2D, + size: number, + scale: number, + amount: number, + offset: number, +): void { + const res = 64; + const field = tileableNoise(res, scale, offset); + const image = ctx.getImageData(0, 0, size, size); + 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); + // 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; + data[i] = (data[i] ?? 0) * k; + data[i + 1] = (data[i + 1] ?? 0) * k; + data[i + 2] = (data[i + 2] ?? 0) * k; + } + } + ctx.putImageData(image, 0, 0); +} + +// ---- The drawings --------------------------------------------------------- + +type Draw = (ctx: CanvasRenderingContext2D, size: number) => void; + +const DRAW: Record = { + /** Loop pile: dense fine speckle, plus the faint rows a loop carpet lays in. */ + carpetLoop(ctx, size) { + 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); + const rand = seededRandom(0x9e11); + ctx.strokeStyle = "rgba(0,0,0,0.035)"; + ctx.lineWidth = 1; + for (let y = 0; y < size; y += 4) { + ctx.beginPath(); + ctx.moveTo(0, y + rand() * 1.5); + ctx.lineTo(size, y + rand() * 1.5); + ctx.stroke(); + } + }, + + /** Boards along +U, with grain stretched hard along the board. */ + woodPlank(ctx, size) { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, size, size); + grain(ctx, size, 4, 0.1, 21.4); + const rand = seededRandom(0x7a03); + const boards = 5; + const pitch = size / boards; + // Grain lines run along the board, which is the whole reason wood reads as + // wood; a rotationally symmetric noise reads as stone. + ctx.lineWidth = 1; + for (let b = 0; b < boards; b++) { + const y0 = b * pitch; + for (let i = 0; i < 26; i++) { + const y = y0 + rand() * pitch; + ctx.strokeStyle = `rgba(0,0,0,${0.02 + rand() * 0.05})`; + ctx.beginPath(); + ctx.moveTo(0, y); + for (let x = 0; x <= size; x += size / 8) { + ctx.lineTo(x, y + Math.sin(x / 37 + b * 2.3) * 1.6); + } + ctx.stroke(); + } + ctx.strokeStyle = "rgba(0,0,0,0.12)"; + ctx.beginPath(); + ctx.moveTo(0, y0); + ctx.lineTo(size, y0); + ctx.stroke(); + } + }, + + /** Power-floated slab: broad mottle and a scatter of exposed aggregate. */ + polishedConcrete(ctx, size) { + 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); + const rand = seededRandom(0x51c0); + for (let i = 0; i < size * 1.5; i++) { + const r = 0.5 + rand() * 1.4; + ctx.fillStyle = `rgba(0,0,0,${0.03 + rand() * 0.06})`; + ctx.beginPath(); + ctx.arc(rand() * size, rand() * size, r, 0, Math.PI * 2); + ctx.fill(); + } + }, + + /** Mineral fibre tile: a 600 mm grid — one tile per 600 mm at a 2 m repeat. */ + ceilingTile(ctx, size) { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, size, size); + grain(ctx, 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})`; + ctx.fillRect(rand() * size, rand() * size, 1, 1); + } + // 2 m of repeat covers a little over three 600 mm tiles; three is the + // number that tiles cleanly, and nobody counts ceiling tiles. + const cells = 3; + const pitch = size / cells; + ctx.strokeStyle = "rgba(0,0,0,0.16)"; + ctx.lineWidth = Math.max(1, size / 256); + for (let i = 0; i < cells; i++) { + ctx.beginPath(); + ctx.moveTo(i * pitch, 0); + ctx.lineTo(i * pitch, size); + ctx.moveTo(0, i * pitch); + ctx.lineTo(size, i * pitch); + ctx.stroke(); + } + }, + + /** Emulsion over plasterboard: almost nothing, which is the point. */ + plasterPaint(ctx, size) { + 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); + }, + + /** Upholstery weave: two crossed sets of threads, low contrast. */ + fabricWeave(ctx, size) { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, size, size); + grain(ctx, 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)"; + for (let x = 0; x < size; x += pitch) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, size); + ctx.stroke(); + } + ctx.strokeStyle = "rgba(0,0,0,0.07)"; + for (let y = 0; y < size; y += pitch) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(size, y); + ctx.stroke(); + } + }, + + /** Square tile with a grout line — kitchens, WCs, entrance mats. */ + tileGrid(ctx, size) { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, size, size); + grain(ctx, size, 14, 0.05, 4.4); + const cells = 4; + const pitch = size / cells; + ctx.strokeStyle = "rgba(0,0,0,0.2)"; + ctx.lineWidth = Math.max(2, size / 128); + for (let i = 0; i < cells; i++) { + ctx.beginPath(); + ctx.moveTo(i * pitch, 0); + ctx.lineTo(i * pitch, size); + ctx.moveTo(0, i * pitch); + ctx.lineTo(size, i * pitch); + ctx.stroke(); + } + }, + + /** A wiped-down board: faint ghosting, no writing. */ + whiteboard(ctx, size) { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, size, size); + grain(ctx, size, 5, 0.03, 9.1); + const rand = seededRandom(0x0b0a); + ctx.lineCap = "round"; + for (let i = 0; i < 14; i++) { + ctx.strokeStyle = `rgba(0,0,0,${0.012 + rand() * 0.018})`; + ctx.lineWidth = 4 + rand() * 10; + const y = rand() * size; + ctx.beginPath(); + ctx.moveTo(rand() * size * 0.4, y); + ctx.lineTo(size * 0.5 + rand() * size * 0.5, y + (rand() - 0.5) * 20); + ctx.stroke(); + } + }, +}; + +// ---- The bin -------------------------------------------------------------- + +/** + * Draws each texture at most once and hands out the same `THREE.Texture` to + * every material that wants it. + * + * `get` returns `null` rather than throwing when there is no canvas to draw on. + * That happens for real: the server workspace and the CI typecheck run under + * Node, and an asset module that explodes on import there would make the + * zero-config boot in CONTRACT.md §5.1 impossible to test. + */ +export class TextureBin { + readonly quality: TextureQuality; + private readonly cache = new Map(); + + constructor(quality: TextureQuality = "high") { + this.quality = quality; + } + + get(kind: TextureKind): THREE.Texture | null { + const hit = this.cache.get(kind); + if (hit !== undefined) return hit; + const texture = this.draw(kind); + this.cache.set(kind, texture); + return texture; + } + + private draw(kind: TextureKind): THREE.Texture | null { + const size = RESOLUTION[this.quality]; + if (size === 0 || typeof document === "undefined") return null; + + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + DRAW[kind](ctx, size); + + const texture = new THREE.CanvasTexture(canvas); + texture.name = kind; + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 4; + texture.needsUpdate = true; + return texture; + } + + dispose(): void { + for (const texture of this.cache.values()) texture?.dispose(); + this.cache.clear(); + } +} diff --git a/src/engine/atmosphere.ts b/src/engine/atmosphere.ts new file mode 100644 index 0000000..0a8e260 --- /dev/null +++ b/src/engine/atmosphere.ts @@ -0,0 +1,955 @@ +/** + * The sky: what the sun and the weather mean for the light rig. + * + * `Atmosphere` is the **sole owner of lighting**. It takes an `Environment` — an + * observation of the world, `{ time, sun, weather }` — and returns a + * `LightingState`, which a `SceneKit` applies. One direction, no write-backs, + * and nothing else in the engine is allowed to reach into the three lights. Two + * modules both constructing and mutating the same `DirectionalLight` is the + * failure this shape exists to prevent; see CONTRACT.md §4. + * + * Two things follow from that ownership and are worth stating up front: + * + * 1. **An office gets no Atmosphere at all.** An interior has walls, no + * horizon and no weather: it wants `fog: null`, `sky: null` — which tells + * `SceneKit` to leave `scene.background` alone entirely — and a fixed + * interior rig of its own that never moves. Daylight through windows is a + * later refinement and deliberately not a v1 coupling. Nothing in this + * file is for an interior, and an office importing it is a mistake. + * 2. **`apply` is pure.** It reads its argument, allocates one small object, + * and touches nothing. Call it every frame or once a minute; the cost is + * the same table lookup either way. + * + * The solar half is computed locally by `solar.ts` with no network, and the + * weather half degrades to `null` — which this file reads as a clear day with + * the local climatology still running. The whole engine has to work with no + * account, no key and no network, and a sky that goes flat grey the moment the + * wifi drops would fail that in the most visible way possible. + * + * Wiring one to a city, in full: + * + * ```ts + * const atmosphere = createAtmosphere({ + * lng: city.center.lng, + * // Degrees of latitude are ~111.32 km everywhere, so the city's own + * // `latScale` is the scale conversion already. + * metresPerUnit: 111_320 / city.latScale, + * marineLayer: PACIFIC_MARINE_LAYER, + * }); + * handle.setLighting(atmosphere.apply(observe(city.center.lat, city.center.lng, new Date()))); + * ``` + * + * The marine layer is opt-in and off by default, because it is a fact about a + * coast and this module has no idea which one it is looking at. + */ + +import { solarPosition, sunDirection, type SolarPosition } from "./solar.ts"; +import type { LightingState } from "./types.ts"; + +// ---- The observation ------------------------------------------------------ + +/** + * Sky conditions as a renderer can use them. + * + * This mirrors `WeatherCondition` in `src/server/wire.ts` member for member, + * and is named differently on purpose. The engine must not import the wire — + * `wire.ts` already imports `engine/types.ts`, and pointing the arrow back + * would make the engine's type graph depend on the server's. A `WeatherBody` + * off the wire is structurally assignable to `WeatherObservation` as it stands, + * so the adapter is a pass-through and not a translation; if a member is ever + * added on one side, add it on the other. + */ +export type SkyCondition = + | "clear" + | "partly-cloudy" + | "cloudy" + | "overcast" + | "fog" + | "rain" + | "snow" + | "thunderstorm"; + +/** + * What the sky is doing, reduced to the four things that change a light rig. + * + * `null` fields mean *not reported*, never zero. The difference matters: a + * station that does not measure visibility is not a station reporting perfect + * visibility, and treating the two the same is how a foggy morning renders + * clear. + */ +export interface WeatherObservation { + /** 0..1. */ + cloudCover: number; + /** 0..1, an intensity rather than a rate. */ + precipitation: number; + visibilityKm: number | null; + windKph: number | null; + /** Degrees clockwise from true north, the direction the wind blows *from*. */ + windDirDeg: number | null; + condition: SkyCondition; +} + +/** + * Everything the light rig is a consequence of. + * + * An *observation*, emphatically not a state: this is what the world is doing, + * and a `LightingState` is what that means for the three lights. The two were + * once one type called `Environment` in two places meaning both things at once, + * which is why the names are now this far apart. + */ +export interface Environment { + time: Date; + sun: SolarPosition; + /** `null` when nobody was asked. A supported state, not an error. */ + weather: WeatherObservation | null; +} + +/** Build an `Environment` for a place and an instant, computing the sun locally. */ +export function observe( + lat: number, + lng: number, + when: Date, + weather: WeatherObservation | null = null, +): Environment { + return { time: when, sun: solarPosition(lat, lng, when), weather }; +} + +// ---- Options -------------------------------------------------------------- + +/** + * Coastal advection fog, as a season, a clock and a wind gate. + * + * Parameterised rather than hard-coded because the model is climatology, not + * geography: the same three curves describe the eastern Pacific layer, the + * Namibian one and the Peruvian one, with different numbers in them. + */ +export interface MarineLayerOptions { + /** Day of year, 1-366, at which the season peaks. */ + peakDay: number; + /** Half-width of the season in days. Outside it, `offSeason` applies. */ + seasonDays: number; + /** Residual strength out of season, 0..1. */ + offSeason: number; + /** Bearing the onshore wind blows *from*, degrees clockwise from true north. */ + onshoreBearing: number; + /** How far off that bearing still counts as onshore, in degrees. */ + onshoreHalfWidth: number; + /** Strength at the peak of the season, 0..1. */ + strength: number; + /** Visibility inside the layer, in metres. */ + visibilityM: number; +} + +/** + * The eastern-Pacific summer layer: San Francisco's fog. + * + * This is the single most recognisable atmospheric fact about the city and it + * is worth getting specifically right rather than approximating with generic + * haze. The layer is cold air over the California Current, dragged inland + * through the one sea-level gap in the coast range by the Central Valley's + * afternoon heat low. That gives it a shape a renderer can actually use: + * + * - **A season.** It is a summer phenomenon, peaking in July, essentially + * gone by the clear warm October that surprises every visitor who packed + * for August. Winter fog in the Bay Area is a different animal — radiation + * fog, mostly inland — and the small `offSeason` residual is all of it that + * belongs here. + * - **A clock.** In through the Gate in the late afternoon, thickest from + * midnight to a couple of hours after sunrise, burning off through the late + * morning and back again from about five. + * - **A wind.** It is *advection* fog: it has to be blown in. A westerly at + * 15-30 km/h is the engine of it; an offshore easterly kills it outright, + * and a gale mixes it up into stratus instead. + * + * What is deliberately not modelled: the layer is shallow — a few hundred + * metres — so downtown's towers and Twin Peaks stand in clear air above a white + * floor, and the Sunset is buried while the Mission is in sunshine. Both facts + * need height fog and a horizontal gradient; `THREE.Fog` is a single global + * linear ramp and can express neither. Rather than fake it, the strength is + * capped so that the city dims and flattens instead of disappearing. + */ +export const PACIFIC_MARINE_LAYER: MarineLayerOptions = { + peakDay: 196, // 15 July + seasonDays: 88, // roughly mid-April to mid-October + offSeason: 0.1, + onshoreBearing: 275, // just north of due west, straight in through the Gate + onshoreHalfWidth: 75, + strength: 1, + visibilityM: 5000, +}; + +export interface AtmosphereOptions { + /** + * Observer longitude, degrees east. Needed for apparent solar time, which is + * what the marine layer's clock runs on — a fog that burns off at 11 a.m. + * needs to know when 11 a.m. is, and we ship no timezone database. + */ + lng: number; + /** + * Metres in one scene unit. Visibility arrives in kilometres and fog + * distances leave in scene units, and this is the only thing that knows the + * exchange rate: ~94 m per unit for San Francisco, 1 m for an interior that + * will never call this anyway. + */ + metresPerUnit: number; + /** + * The clear-noon sky this city wants. Every other stop in the table is a + * consequence of where the sun is and is not a city's business to override. + */ + sky?: { top: number; horizon: number }; + /** Fog on a clear day, in scene units. */ + clearFog?: { near: number; far: number }; + /** + * Visibility, in metres, that fog is never allowed to fall below. + * + * A deliberate lie. Real advection fog at the Golden Gate has visibility + * under 400 m, and rendering that honestly produces a white rectangle with a + * city somewhere inside it. What reads as fog is the *look* — no horizon, no + * shadows, a dead sun, everything the same flat grey — at a range you can + * still fly a camera through. + */ + minVisibilityM?: number; + /** + * Degrees. The light direction is never allowed below this elevation. `0` + * turns the lift off; see `liftedElevation` for why it exists. + */ + shadowFloorDeg?: number; + /** Coastal fog model, or nothing. Off unless a city asks for it. */ + marineLayer?: MarineLayerOptions | null; +} + +export interface Atmosphere { + /** The rig this observation implies. Pure; the caller applies the result. */ + apply(env: Environment): LightingState; +} + +// ---- Constants ------------------------------------------------------------ + +const MS_PER_MINUTE = 60_000; +const MS_PER_DAY = 86_400_000; + +/** Matches `cityDaylight()` in `scene.ts`, so switching to a live sun at noon does not jolt. */ +const DEFAULT_SKY_TOP = 0x8fb8d8; +const DEFAULT_SKY_HORIZON = 0xd9e6ee; +const DEFAULT_FOG_NEAR = 210; +const DEFAULT_FOG_FAR = 460; +const DEFAULT_MIN_VISIBILITY_M = 4500; +const DEFAULT_SHADOW_FLOOR_DEG = 7; + +/** + * Where fog starts, as a fraction of where it ends. `cityDaylight`'s 210/460 is + * 0.457 and looks right, so the ratio is held rather than the distance: a fog + * that closes to 60 units with its near plane still at 210 is not fog, it is a + * solid wall. + */ +const FOG_NEAR_RATIO = 0.45; + +/** + * Kilometres at and above which a reported visibility means "as far as anyone + * bothered to look". + * + * This is the correction that stops every clear day rendering hazy. A METAR of + * `10SM` is the *maximum value the report can carry*, not a measurement of ten + * miles — the observer stopped counting. Taking it literally puts the fog plane + * at 170 scene units, inside the camera's own orbit range, and washes out a sky + * that is in fact unlimited. The blend from `VISIBILITY_HAZY_KM` upward also + * keeps the transition smooth, because a cliff at exactly 16 km would make the + * whole city snap between hazy and crisp on a one-decimal change upstream. + */ +const VISIBILITY_UNLIMITED_KM = 16; +const VISIBILITY_HAZY_KM = 8; + +/** Visibility assumed when a source says "fog" and reports no number. */ +const FOG_CONDITION_VISIBILITY_KM = 1.5; + +// ---- The daylight table --------------------------------------------------- + +interface Rig { + skyTop: number; + skyHorizon: number; + sunColor: number; + sunIntensity: number; + hemiSky: number; + hemiGround: number; + hemiIntensity: number; + ambientColor: number; + ambientIntensity: number; +} + +interface Keyframe extends Rig { + /** Solar elevation, in degrees, that this frame describes exactly. */ + elevation: number; +} + +/** + * The sky at eight solar elevations, interpolated between. + * + * The stops sit on the twilight boundaries `solar.ts` already names — -18, -12, + * -6, the refracted horizon, and up through golden hour into full day — so that + * `daylightPhase` and this table agree about where a phase begins. A table + * rather than a formula because the interesting part of a sunset is not + * physical: the horizon band goes salmon while the zenith is still deep blue, + * and the ratio between them is a thing you tune by looking, not by deriving. + * + * Two stops carry the city's own daylight colours (see `AtmosphereOptions.sky`), + * 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. + */ +function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] { + return [ + { + // Full night. The directional light is not the sun and is not the moon + // either; it is a token sidelight standing in for moonlight and city + // glow, because a scene lit by hemisphere alone has no silhouettes in it + // and reads as a bug rather than as darkness. + elevation: -18, + skyTop: 0x05070f, + skyHorizon: 0x0b1120, + sunColor: 0x2e3c66, + sunIntensity: 0.05, + hemiSky: 0x121a30, + hemiGround: 0x080a10, + hemiIntensity: 0.25, + ambientColor: 0x28304a, + ambientIntensity: 0.1, + }, + { + elevation: -12, + skyTop: 0x080d1e, + skyHorizon: 0x141d38, + sunColor: 0x3d4a76, + sunIntensity: 0.07, + hemiSky: 0x18223c, + hemiGround: 0x0a0d16, + hemiIntensity: 0.28, + ambientColor: 0x2c3552, + ambientIntensity: 0.11, + }, + { + elevation: -6, + skyTop: 0x101a3a, + skyHorizon: 0x2b3560, + sunColor: 0x5b5d8e, + sunIntensity: 0.12, + hemiSky: 0x22304f, + hemiGround: 0x121520, + hemiIntensity: 0.35, + ambientColor: 0x38406a, + ambientIntensity: 0.14, + }, + { + // The sun on the horizon. Warm at the bottom, cold at the top, and the + // widest colour spread the sky ever has. + elevation: -0.4, + skyTop: 0x2a4275, + skyHorizon: 0x9a6a63, + sunColor: 0xc2795c, + sunIntensity: 0.45, + hemiSky: 0x4a5f8c, + hemiGround: 0x2a2a2c, + hemiIntensity: 0.6, + ambientColor: 0x6a6a80, + ambientIntensity: 0.2, + }, + { + elevation: 3, + skyTop: 0x4d76ac, + skyHorizon: 0xdba078, + sunColor: 0xff9c56, + sunIntensity: 1.25, + hemiSky: 0x86a6cc, + hemiGround: 0x54503f, + hemiIntensity: 0.85, + ambientColor: 0xffd9b8, + ambientIntensity: 0.24, + }, + { + elevation: 8, + skyTop: 0x6b96c6, + skyHorizon: 0xebc9a4, + sunColor: 0xffc489, + sunIntensity: 1.8, + hemiSky: 0xb2cbe4, + hemiGround: 0x6a6752, + hemiIntensity: 0.98, + ambientColor: 0xffe7cf, + ambientIntensity: 0.28, + }, + { + // Ordinary daylight, and the one stop that reproduces `cityDaylight()`. + elevation: 25, + skyTop: dayTop, + skyHorizon: dayHorizon, + sunColor: 0xfff3e0, + sunIntensity: 2.1, + hemiSky: 0xdcecf7, + hemiGround: 0x6b6f5e, + hemiIntensity: 1.05, + ambientColor: 0xffffff, + ambientIntensity: 0.32, + }, + { + // A high sun. The zenith deepens — less air to scatter through overhead — + // while the horizon whitens, so the gradient is at its steepest at noon. + elevation: 65, + skyTop: mixHex(dayTop, 0x2f6bb0, 0.35), + skyHorizon: mixHex(dayHorizon, 0xffffff, 0.2), + sunColor: 0xfffdf6, + sunIntensity: 2.35, + hemiSky: 0xe6f2fb, + hemiGround: 0x74786a, + hemiIntensity: 1.1, + ambientColor: 0xffffff, + ambientIntensity: 0.3, + }, + ]; +} + +/** + * The frame for an elevation, blended between the two stops around it. + * + * Eased rather than linear. Straight lerp between table rows leaves a visible + * crease every time the sun crosses a stop — the rate of change jumps, and on + * a sky the eye reads that as a seam sliding down the screen. Smoothstep makes + * the derivative zero at each stop, so the stops stop being findable. + */ +function sample(frames: readonly Keyframe[], elevation: number): Rig { + const first = frames[0]; + const last = frames[frames.length - 1]; + if (!first || !last) throw new Error("atmosphere: empty keyframe table"); + if (elevation <= first.elevation) return first; + if (elevation >= last.elevation) return last; + + for (let i = 1; i < frames.length; i++) { + const a = frames[i - 1]; + const b = frames[i]; + if (!a || !b) continue; + if (elevation <= b.elevation) { + const t = ease((elevation - a.elevation) / (b.elevation - a.elevation)); + return { + skyTop: mixHex(a.skyTop, b.skyTop, t), + skyHorizon: mixHex(a.skyHorizon, b.skyHorizon, t), + sunColor: mixHex(a.sunColor, b.sunColor, t), + sunIntensity: lerp(a.sunIntensity, b.sunIntensity, t), + hemiSky: mixHex(a.hemiSky, b.hemiSky, t), + hemiGround: mixHex(a.hemiGround, b.hemiGround, t), + hemiIntensity: lerp(a.hemiIntensity, b.hemiIntensity, t), + ambientColor: mixHex(a.ambientColor, b.ambientColor, t), + ambientIntensity: lerp(a.ambientIntensity, b.ambientIntensity, t), + }; + } + } + return last; +} + +// ---- The atmosphere ------------------------------------------------------- + +export function createAtmosphere(options: AtmosphereOptions): Atmosphere { + const { lng, metresPerUnit } = options; + const table = keyframes( + options.sky?.top ?? DEFAULT_SKY_TOP, + options.sky?.horizon ?? DEFAULT_SKY_HORIZON, + ); + const clearNear = options.clearFog?.near ?? DEFAULT_FOG_NEAR; + const clearFar = options.clearFog?.far ?? DEFAULT_FOG_FAR; + const floorFar = (options.minVisibilityM ?? DEFAULT_MIN_VISIBILITY_M) / metresPerUnit; + const shadowFloor = options.shadowFloorDeg ?? DEFAULT_SHADOW_FLOOR_DEG; + const marineOptions = options.marineLayer ?? null; + + function apply(env: Environment): LightingState { + const elevation = env.sun.elevation; + // Copied, because `sample` hands back a table row unchanged when the + // elevation is off either end of it and everything below mutates in place. + const rig = { ...sample(table, elevation) }; + + // How much of the light is daylight at all. Used to keep the weather from + // brightening the night: overcast at noon is grey, overcast at 2 a.m. is + // still black, and a modifier that does not know the difference will + // cheerfully raise the small hours to a uniform slate. + const day = smoothstep(-6, 6, elevation); + + const weather = env.weather; + const cloud = clamp(weather?.cloudCover ?? 0, 0, 1); + const precipitation = clamp(weather?.precipitation ?? 0, 0, 1); + const condition = weather?.condition ?? "clear"; + + applyCloud(rig, cloud, day); + applyPrecipitation(rig, precipitation, condition, day); + + // An observation always beats the climatology: `null` lets the model run + // free, which is what gives an offline San Francisco its summer fog, but a + // station reporting sunshine ends the argument. See `observedObscuration`. + const observed = weather ? observedObscuration(weather) : null; + const modelled = marineOptions ? marineStrength(marineOptions, env, lng) : 0; + const obscuration = + observed === null ? modelled : observed === 0 ? 0 : Math.max(observed, modelled); + + let fogFar = visibilityFar(weather, condition, clearFar, metresPerUnit); + if (weather === null || weather.visibilityKm === null) { + // Rain shortens the view; a source that measured visibility has already + // said so, and applying both would count it twice. + fogFar *= 1 - 0.45 * precipitation; + } + if (marineOptions && obscuration > 0) { + const inside = marineOptions.visibilityM / metresPerUnit; + fogFar = Math.min(fogFar, lerp(fogFar, inside, obscuration)); + } + fogFar = Math.max(fogFar, floorFar); + + const fogColor = applyObscuration(rig, obscuration, day, condition); + + // A clear day keeps the near plane it was given; anything shorter holds the + // ratio instead, because fog that starts where the clear day's did and ends + // sixty units out is not fog, it is a wall. + const near = clamp(fogFar >= clearFar ? clearNear : fogFar * FOG_NEAR_RATIO, 2, fogFar * 0.9); + + return { + sun: { + direction: lightDirection(env.sun, shadowFloor), + color: rig.sunColor, + intensity: Math.max(0, rig.sunIntensity), + }, + hemisphere: { + sky: rig.hemiSky, + ground: rig.hemiGround, + intensity: Math.max(0, rig.hemiIntensity), + }, + ambient: { color: rig.ambientColor, intensity: Math.max(0, rig.ambientIntensity) }, + sky: { top: rig.skyTop, horizon: rig.skyHorizon }, + fog: { color: fogColor, near, far: fogFar }, + }; + } + + return { apply }; +} + +// ---- The sun's direction, and the shadow camera --------------------------- + +/** + * A unit vector toward the sun, never allowed below `floorDeg`. + * + * This is the whole of the atmosphere's shadow handling, and it is a lift on + * the light direction rather than a change to the shadow camera because a + * `LightingState` deliberately carries no shadow fields — the extents belong to + * the scene, which knows its own scale, and letting the sun reach into them + * would be exactly the write-back CONTRACT.md §4 forbids. So the rule is + * inverted: never ask for a direction the shadow camera cannot serve. + * + * What goes wrong without it, at `SceneKit`'s defaults — a 2048 map over a + * 340-unit box, so one texel is ~0.17 units or about 16 m of San Francisco: + * + * - Depth across a texel grows as 1/tan(elevation). At 20° a texel spans 0.46 + * units of depth; at 3° it spans 3.2. A single fixed `shadow.bias` tuned at + * one of those is acne or peter-panning at the other, and there is no value + * that is right at both. + * - A shadow three hundred metres long and two metres wide is a shape a 16 m + * texel cannot represent at all. It comes out as a dashed line that crawls + * when the camera moves. + * - Below the horizon the direction points *up* from underneath, lighting the + * undersides of everything and leaving the roofs black. That is not a + * subtle artefact; it is a scene that looks inside out. + * + * The visible cost is that shadows stop lengthening a few degrees before + * sunset. Against a sunset in which they shimmer, dash and then invert, that is + * a cheap trade — and by the time the lift is doing real work the sun's + * intensity is already down near a tenth, so there is very little shadow left + * to be wrong about. A scene that genuinely wants grazing shadows widens its + * own `shadowExtent`/`shadowMapSize` and passes `shadowFloorDeg: 0`. + */ +function lightDirection(sun: SolarPosition, floorDeg: number): [number, number, number] { + const lifted = floorDeg > 0 ? Math.max(sun.elevation, floorDeg) : sun.elevation; + const dir = sunDirection({ ...sun, elevation: lifted }); + return [dir.x, dir.y, dir.z]; +} + +// ---- Weather -------------------------------------------------------------- + +/** + * Cloud flattens and desaturates, and it does both by taking the sun away. + * + * Overcast is not "the same scene, dimmer": the directional component collapses + * and what is left is a uniform dome, so shadows vanish, the sky's gradient + * closes up and every colour loses its warmth because it is being lit by grey. + * Raising ambient while cutting the sun is what reproduces that — the total + * falls, but the ratio falls much further, which is the part the eye reads. + */ +function applyCloud(rig: Rig, cloud: number, day: number): void { + if (cloud <= 0) return; + + rig.sunIntensity *= 1 - 0.78 * cloud; + rig.sunColor = mixHex(rig.sunColor, 0xf2f4f6, 0.6 * cloud); + + // The zenith comes down to meet the horizon: an overcast sky has almost no + // gradient left in it, which is why an overcast photograph has no top. + rig.skyTop = desaturate(mixHex(rig.skyTop, rig.skyHorizon, 0.55 * cloud), 0.7 * cloud); + rig.skyHorizon = desaturate(rig.skyHorizon, 0.6 * cloud); + const dim = 1 - 0.16 * cloud * day; + rig.skyTop = scale(rig.skyTop, dim); + rig.skyHorizon = scale(rig.skyHorizon, dim); + + rig.hemiSky = desaturate(rig.hemiSky, 0.55 * cloud); + rig.hemiIntensity *= 1 + 0.18 * cloud * day; + rig.ambientColor = desaturate(rig.ambientColor, 0.7 * cloud); + rig.ambientIntensity *= 1 + 0.55 * cloud * day; +} + +function applyPrecipitation( + rig: Rig, + precipitation: number, + condition: SkyCondition, + day: number, +): void { + if (condition === "snow") { + // Snow is the largest reflector a scene ever acquires: the bounce light off + // the ground stops being dirt-coloured and starts being sky-coloured, which + // is most of why a snowy day looks the way it does from below. + rig.hemiGround = mixHex(rig.hemiGround, 0xe9eef2, 0.75); + rig.hemiIntensity *= 1 + 0.2 * day; + rig.ambientIntensity *= 1 + 0.15 * day; + } + if (precipitation <= 0 && condition !== "thunderstorm") return; + + const heavy = condition === "thunderstorm" ? Math.max(0.75, precipitation) : precipitation; + const dim = 1 - 0.3 * heavy * day; + rig.sunIntensity *= 1 - 0.4 * heavy; + rig.hemiIntensity *= dim; + rig.ambientIntensity *= dim; + rig.skyTop = scale(desaturate(rig.skyTop, 0.4 * heavy), dim); + rig.skyHorizon = scale(desaturate(rig.skyHorizon, 0.4 * heavy), dim); +} + +/** + * Fog distance from a reported visibility, in scene units. + * + * `THREE.Fog` is linear and fully opaque at `far`, and meteorological + * visibility is the range at which contrast is essentially gone, so the two are + * the same number by definition — once the "10 miles means we stopped counting" + * problem above is dealt with. + */ +function visibilityFar( + weather: WeatherObservation | null, + condition: SkyCondition, + clearFar: number, + metresPerUnit: number, +): number { + let km = weather?.visibilityKm ?? null; + if (km === null && condition === "fog") km = FOG_CONDITION_VISIBILITY_KM; + if (km === null) return clearFar; + + const observed = (km * 1000) / metresPerUnit; + return lerp(observed, clearFar, smoothstep(VISIBILITY_HAZY_KM, VISIBILITY_UNLIMITED_KM, km)); +} + +// ---- The marine layer ----------------------------------------------------- + +/** + * How thoroughly the air itself is in the way, 0..1, as reported. + * + * Kept separate from the marine layer, and generic, because obscuration is not + * a San Francisco phenomenon even though the model of where it comes from is: + * a city with no `marineLayer` configured must still render a reported fog as + * fog — flat, shadowless, no horizon — rather than as a blue sky with the far + * shore mysteriously missing. The layer's job is to *supply* this number when + * nobody was asked; this is what to do with one once it exists. + */ +function observedObscuration(weather: WeatherObservation): number { + if (weather.condition === "fog") return 1; + const visibility = weather.visibilityKm; + // A continuous ramp rather than a threshold: 10 km is where a distant hill + // starts losing its edges and half a kilometre is where everything has gone, + // and a step anywhere between them would make the sky snap on a rounding + // difference upstream. + if (visibility !== null && visibility < 10) return clamp(1 - (visibility - 0.5) / 9.5, 0, 1); + // High stratus: the same air mass a few hundred metres up. Locally this is + // the overcast that gets called May grey and June gloom, and it flattens the + // light the same way without ever touching the ground. + if (weather.cloudCover > 0.8 && (visibility === null || visibility < 12)) return 0.35; + return 0; +} + +function marineStrength(layer: MarineLayerOptions, env: Environment, lng: number): number { + const season = seasonFactor(layer, dayOfYear(env.time)); + const diurnal = diurnalFactor(solarHours(env.time, lng, env.sun.equationOfTime)); + const wind = windGate(layer, env.weather); + return clamp(layer.strength * season * diurnal * wind, 0, 1); +} + +/** A smooth bump centred on the season's peak, on a residual floor. */ +function seasonFactor(layer: MarineLayerOptions, doy: number): number { + const d = Math.abs(wrapSigned(doy - layer.peakDay, 365.25)); + if (d >= layer.seasonDays) return layer.offSeason; + const bump = 0.5 * (1 + Math.cos((Math.PI * d) / layer.seasonDays)); + return layer.offSeason + (1 - layer.offSeason) * bump; +} + +/** + * The day's shape: thickest before dawn, burnt off through the late morning, + * back in from mid-afternoon. + * + * The hours are **apparent solar**, not civil, which is both physically right — + * burn-off is the sun doing work, and it starts when the sun does — and the + * only clock available offline. It does mean the curve reads early against a + * wristwatch: San Francisco's solar noon is around 13:07 PDT, so the 11.0 here + * where the layer is thinnest is a little after midday on the clock, and the + * 16.0 where it starts coming back is around five. + */ +const DIURNAL: readonly (readonly [number, number])[] = [ + [0, 0.95], + [5, 1], + [7, 0.95], + [9, 0.6], + [11, 0.2], + [14, 0.12], + [16, 0.4], + [18, 0.8], + [20, 0.92], + [24, 0.95], +]; + +function diurnalFactor(hours: number): number { + const h = mod(hours, 24); + for (let i = 1; i < DIURNAL.length; i++) { + const a = DIURNAL[i - 1]; + const b = DIURNAL[i]; + if (!a || !b) continue; + if (h <= b[0]) return lerp(a[1], b[1], ease((h - a[0]) / (b[0] - a[0]))); + } + return 0.95; +} + +/** + * Advection fog has to be blown in, so the wind is a gate and not a garnish. + * + * This is also the only place wind touches the rig at all, and that is + * deliberate: wind moves clouds and shreds fog, and this renderer has no cloud + * layer for it to move. Wiring it to anything else — a brightness, a colour — + * would be decoration dressed as physics. + * + * Missing wind data returns 1. An unreported wind is not a calm. + */ +function windGate(layer: MarineLayerOptions, weather: WeatherObservation | null): number { + const speed = weather?.windKph ?? null; + const from = weather?.windDirDeg ?? null; + if (speed === null && from === null) return 1; + + let gate = 1; + if (from !== null) { + const off = Math.abs(wrapSigned(from - layer.onshoreBearing, 360)); + gate *= + off >= layer.onshoreHalfWidth + ? 0.1 + : 0.1 + 0.9 * (0.5 * (1 + Math.cos((Math.PI * off) / layer.onshoreHalfWidth))); + } + if (speed !== null) { + // Calm: the layer sits offshore and never arrives. Gale: mechanical mixing + // lifts it clear of the ground into stratus, which is why the foggiest days + // are breezy rather than windy. + if (speed < 3) gate *= 0.55; + else if (speed < 8) gate *= 0.55 + 0.45 * ((speed - 3) / 5); + else if (speed > 35) gate *= Math.max(0.25, 1 - (speed - 35) / 35); + } + return gate; +} + +/** + * Fold obscuration into the rig and return the fog colour. + * + * Everything converges: the zenith, the horizon and the fog all end up the same + * flat grey-white, which is what kills the horizon line — there is no boundary + * left between sky and distance. The sun goes out but the total light does not, + * because fog is a diffuser and not a lid; it is bright, shadowless and + * directionless, and getting that combination right is the difference between + * fog and dusk. + * + * At zero the fog colour is the horizon colour exactly, which is both what + * `cityDaylight()` commits to and the physically honest answer — distant haze + * is lit by the sky it sits in front of, so it goes warm at sunset along with + * everything else rather than staying a neutral grey. + */ +function applyObscuration( + rig: Rig, + obscuration: number, + day: number, + condition: SkyCondition, +): number { + let thick = mixHex(desaturate(rig.skyHorizon, 0.9), 0xbfc8cc, 0.5 * day); + if (condition === "snow") thick = mixHex(thick, 0xeef2f5, 0.4); + const fogColor = mixHex(rig.skyHorizon, thick, obscuration); + if (obscuration <= 0) return fogColor; + + rig.skyTop = mixHex(rig.skyTop, fogColor, 0.85 * obscuration); + rig.skyHorizon = mixHex(rig.skyHorizon, fogColor, 0.92 * obscuration); + + rig.sunIntensity *= 1 - 0.88 * obscuration; + rig.sunColor = mixHex(rig.sunColor, 0xdfe6ea, 0.7 * obscuration); + + rig.hemiSky = mixHex(rig.hemiSky, fogColor, 0.7 * obscuration); + rig.hemiGround = desaturate(rig.hemiGround, 0.6 * obscuration); + rig.hemiIntensity *= 1 + 0.12 * obscuration * day; + rig.ambientColor = mixHex(rig.ambientColor, fogColor, 0.6 * obscuration); + rig.ambientIntensity *= 1 + 0.45 * obscuration * day; + + return fogColor; +} + +// ---- Time ----------------------------------------------------------------- + +/** + * Apparent solar hours since local midnight. + * + * `solar.ts` computes this same quantity on its way to an azimuth and does not + * export it. Three lines here is cheaper than widening `SolarPosition` with a + * field only the marine layer reads — and the equation of time, which is the + * hard part, does come across on the observation. + */ +function solarHours(when: Date, lng: number, equationOfTime: number): number { + const utcMinutes = mod(when.getTime() / MS_PER_MINUTE, 1440); + return mod(utcMinutes + equationOfTime + 4 * lng, 1440) / 60; +} + +/** + * Day of the year, 1-366, in UTC. The seasonal curve is nearly three months + * wide, so which side of midnight the local day falls on is not a difference it + * can express. + */ +function dayOfYear(when: Date): number { + const start = Date.UTC(when.getUTCFullYear(), 0, 1); + return Math.floor((when.getTime() - start) / MS_PER_DAY) + 1; +} + +// ---- Colour --------------------------------------------------------------- + +/** + * Blend two colours the way light blends, not the way bytes do. + * + * sRGB is a display encoding, and lerping in it sends the midpoint between a + * twilight blue and a sunset orange through a dead brown-grey that neither + * colour has any of. Squaring into approximately linear light, mixing there and + * taking the square root back is the cheapest fix that removes it, and at dusk + * — when almost every colour in the table is being interpolated at once — the + * difference is the whole mood of the frame. + */ +function mixHex(a: number, b: number, t: number): number { + const k = clamp(t, 0, 1); + const [ar, ag, ab] = linear(a); + const [br, bg, bb] = linear(b); + return encode(lerp(ar, br, k), lerp(ag, bg, k), lerp(ab, bb, k)); +} + +/** Pull a colour toward its own brightness. `t = 1` is grey. */ +function desaturate(hex: number, t: number): number { + const k = clamp(t, 0, 1); + const [r, g, b] = linear(hex); + // Rec. 709 luminance, on linear values, which is the only place it means + // anything. + const y = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return encode(lerp(r, y, k), lerp(g, y, k), lerp(b, y, k)); +} + +/** Multiply a colour's light, not its bytes. */ +function scale(hex: number, factor: number): number { + const [r, g, b] = linear(hex); + const f = Math.max(0, factor); + return encode(r * f, g * f, b * f); +} + +function linear(hex: number): [number, number, number] { + const r = ((hex >> 16) & 0xff) / 255; + const g = ((hex >> 8) & 0xff) / 255; + const b = (hex & 0xff) / 255; + return [r * r, g * g, b * b]; +} + +function encode(r: number, g: number, b: number): number { + const to = (v: number) => Math.round(clamp(Math.sqrt(Math.max(0, v)), 0, 1) * 255); + return (to(r) << 16) | (to(g) << 8) | to(b); +} + +// ---- Helpers -------------------------------------------------------------- + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +function clamp(x: number, lo: number, hi: number): number { + return x < lo ? lo : x > hi ? hi : x; +} + +/** Hermite ease over a span, flat at both ends. */ +function smoothstep(edge0: number, edge1: number, x: number): number { + if (edge1 === edge0) return x < edge0 ? 0 : 1; + return ease((x - edge0) / (edge1 - edge0)); +} + +function ease(t: number): number { + const k = clamp(t, 0, 1); + return k * k * (3 - 2 * k); +} + +/** `%` keeps the sign of the dividend, which is wrong for angles and clocks. */ +function mod(x: number, n: number): number { + return ((x % n) + n) % n; +} + +/** The shortest signed distance around a cycle: -180..180 for degrees. */ +function wrapSigned(x: number, period: number): number { + return mod(x + period / 2, period) - period / 2; +} + +// ---- Sanity checks -------------------------------------------------------- + +/** + * Values this file actually produces, so the numbers above can be argued with + * rather than only read. + * + * All of these are San Francisco — 37.7749 N, 122.4194 W, `metresPerUnit` of + * 94.34, `marineLayer: PACIFIC_MARINE_LAYER` — with fog distances in scene + * units, where the clear-day baseline is 210/460. + * + * **With no weather at all, which is the offline case:** + * + * - **Solar noon, 21 June** (75.6°): sun 2.03, fog 179/397. The layer's + * diurnal curve is near its minimum and its season factor near its + * maximum, which nets out as the faint July haze that softens the far side + * of the bay without touching the light. + * - **08:00 PDT, 21 June** (23.4°): sun 0.61, hemisphere 1.15, ambient 0.43, + * fog 60/133. A high sun almost entirely extinguished, the sky and the fog + * converged on one grey, and more fill light than at noon. That is the + * single most San Franciscan frame this engine can produce, and the fact + * that the *fill goes up* as the *sun goes down* is the whole trick. + * - **08:00 PDT, 21 October** (6.1°): sun 1.49, fog 190/421, horizon + * #e6bd98. The same hour, four months later, and the model has to give + * back a clear golden morning or it is not a model of anything — October is + * the month San Francisco is warm and cloudless and every visitor is + * surprised by it. + * - **03:00 PDT** (-23.7°): sun 0.01, hemisphere 0.25, ambient 0.10, and the + * light direction's `y` pinned at 0.122, which is sin 7° — the shadow + * floor, keeping the token night sidelight from shining up through the + * ground. + * - **Solar noon, 21 December** (28.8°): sun 2.07, fog 204/453, sky exactly + * the palette's own. Out of season, the layer is not there. + * + * **With weather, at solar noon on 21 June:** + * + * - `cloudCover: 0.05, visibilityKm: 16` — sun 2.26, fog 210/460 exactly. + * The observation says clear and the climatology is overruled; 16 km is + * read as "unlimited" rather than as 170 units of haze, which is the `10SM` + * correction doing its job. + * - `cloudCover: 1, visibilityKm: 14, condition: "overcast"` — sun 0.52, + * ambient 0.31 → 0.46, fog 185/411. Bright and shadowless, not dusk. + * - `condition: "fog"`, no visibility number — sun 0.06, hemisphere 1.45, + * ambient 0.67, fog 21/48. 48 units is the 4500 m floor: fog this thick is + * capped deliberately, because the honest number renders a white rectangle. + * - `visibilityKm: 6` under light cloud — sun 1.13, fog 27/59. Haze, not fog. + * + * **Without a `marineLayer` at all**, a reported fog still renders as fog — + * sun 0.06, one flat grey, no horizon — and the same city with no weather + * renders the clear baseline exactly. The layer decides where obscuration + * *comes from* when nobody was asked; it is not what makes obscuration look + * like anything. + * + * Tromsø on 5 January, at -2.98°, returns sun 0.30 against a twilight-blue sky + * and nothing non-finite anywhere, which is the polar-night path through + * `solar.ts` arriving here intact. + */ diff --git a/src/engine/scene.ts b/src/engine/scene.ts index 80b30cd..642a5d4 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -1,21 +1,39 @@ /** - * The scene: lights, sky, layers, camera flights, render loop. + * The city scene: layers, chapter flights, markers, and the handle the app + * drives it all through. * * `createScene` owns a canvas and a `City` and nothing else. It knows nothing * about React, about any API, or about what the markers mean — the caller hands * it data and gets back a small imperative handle. That boundary is what lets * one renderer serve a private map coloured by pipeline state and a public one * coloured by sector without either being a fork. + * + * The renderer and the loop live in `Stage`; the camera, lights, flights and + * picking live in a `SceneKit`. What is left here — and it is the only thing + * that ought to be here — is the city itself: which layers go in the scene, + * where a chapter puts the camera, and what a pick means. An office builds the + * same two pieces with its own answers and swaps in on the same `Stage`, which + * keeps this city alive and paused rather than rebuilding its ~1.0 s + * heightfield on the way back. See CONTRACT.md §1. */ import * as THREE from "three"; -import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { createBlocks, createLandmarks } from "./blocks.ts"; import { createFlightLayer, type FlightLayer } from "./flights.ts"; import { createMarkerLayer, type MarkerLayer } from "./markers.ts"; +import { createSceneKit, type Pose } from "./scenekit.ts"; +import { createStage, type Stage, type StageScene } from "./stage.ts"; import { createBridges, createRoads } from "./structures.ts"; import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts"; -import type { Chapter, City, FlightSource, Marker, MarkerPalette } from "./types.ts"; +import type { + Chapter, + City, + FlightSource, + LightingState, + Marker, + MarkerPalette, + ScenePalette, +} from "./types.ts"; import { World } from "./world.ts"; export interface SceneOptions { @@ -24,11 +42,27 @@ export interface SceneOptions { flights?: FlightSource; /** Fires on hover/click of a marker head. */ onMarkerPick?: (marker: Marker | null) => void; + /** + * Opening light rig. Comes from an `Atmosphere` when there is one; without + * one the city gets `cityDaylight()`, because a scene that renders black + * until somebody wires up the sun is not a scene that boots with no config. + */ + lighting?: LightingState; } export interface SceneHandle { world: World; chapters: Chapter[]; + /** + * The renderer and the loop. An office is swapped in with + * `stage.setScene(officeScene)` and this city back in the same way; the one + * that steps out is paused, not thrown away. + */ + stage: Stage; + /** This city, as the thing `stage.setScene` takes. */ + stageScene: StageScene; + /** Applies a rig computed elsewhere. The scene never works one out itself. */ + setLighting(state: LightingState): void; flyTo(chapterId: string): void; current(): string; onChapterChange(fn: (id: string) => void): void; @@ -41,47 +75,21 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S const world = new World(city); const pal = paletteFor(world); + const stage = createStage(canvas); const scene = new THREE.Scene(); - scene.background = makeSkyTexture(pal.skyTop, pal.skyHorizon); - scene.fog = new THREE.Fog(pal.skyHorizon, 210, 460); - const camera = new THREE.PerspectiveCamera( - 42, - canvas.clientWidth / Math.max(1, canvas.clientHeight), - 0.1, - 900, - ); - - const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - renderer.setSize(canvas.clientWidth, canvas.clientHeight, false); - renderer.shadowMap.enabled = true; - renderer.shadowMap.type = THREE.PCFSoftShadowMap; - - const controls = new OrbitControls(camera, renderer.domElement); - controls.enableDamping = true; - controls.dampingFactor = 0.07; - controls.maxPolarAngle = Math.PI / 2.12; // never dip under the ground plane - controls.minDistance = 12; - controls.maxDistance = 340; - - // Late-afternoon sun from the west, which throws the hills' shadows east - // across the flats. - const sun = new THREE.DirectionalLight(0xfff3e0, 2.1); - sun.position.set(-150, 170, 70); - sun.castShadow = true; - sun.shadow.mapSize.set(2048, 2048); - sun.shadow.camera.near = 10; - sun.shadow.camera.far = 520; - const extent = 170; - sun.shadow.camera.left = -extent; - sun.shadow.camera.right = extent; - sun.shadow.camera.top = extent; - sun.shadow.camera.bottom = -extent; - sun.shadow.bias = -0.0012; - scene.add(sun); - scene.add(new THREE.HemisphereLight(0xdcecf7, 0x6b6f5e, 1.05)); - scene.add(new THREE.AmbientLight(0xffffff, 0.32)); + const kit = createSceneKit({ + scene, + dom: stage.renderer.domElement, + fov: 42, + near: 0.1, + far: 900, + minDistance: 12, + maxDistance: 340, + shadowExtent: 170, + shadowFar: 520, + }); + kit.applyLighting(options.lighting ?? cityDaylight(pal)); scene.add(createWater(world)); scene.add(createShorePlates(world)); @@ -101,26 +109,21 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S scene.add(flightLayer.group); } - // ---- Camera flights ----------------------------------------------------- + // ---- Chapters ----------------------------------------------------------- const chapterById = Object.fromEntries(city.chapters.map((c) => [c.id, c])); const first = city.chapters[0]; if (!first) throw new Error(`City "${city.id}" declares no chapters`); - const desiredTarget = new THREE.Vector3(); - const desiredPosition = new THREE.Vector3(); - const flightFrom = { pos: new THREE.Vector3(), target: new THREE.Vector3() }; - let flying = false; - let flightT = 0; let currentChapter = first.id; const chapterListeners: ((id: string) => void)[] = []; - function chapterPose(ch: Chapter) { + function chapterPose(ch: Chapter): Pose { const [x, z] = world.project(ch.focus.lat, ch.focus.lng); const groundY = world.groundAt(ch.focus.lat, ch.focus.lng); return { target: new THREE.Vector3(x, groundY, z), - pos: new THREE.Vector3( + position: new THREE.Vector3( x + Math.sin(ch.focus.rotation) * ch.focus.distance, groundY + ch.focus.height, z + Math.cos(ch.focus.rotation) * ch.focus.distance, @@ -131,96 +134,67 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S function flyTo(chapterId: string) { const ch = chapterById[chapterId]; if (!ch) return; - const pose = chapterPose(ch); - flightFrom.pos.copy(camera.position); - flightFrom.target.copy(controls.target); - desiredPosition.copy(pose.pos); - desiredTarget.copy(pose.target); - flightT = 0; - flying = true; + kit.flyTo(chapterPose(ch)); if (currentChapter !== chapterId) { currentChapter = chapterId; for (const fn of chapterListeners) fn(chapterId); } } - { - const pose = chapterPose(first); - camera.position.copy(pose.pos); - controls.target.copy(pose.target); - controls.update(); - } + kit.setPose(chapterPose(first)); // ---- Picking ------------------------------------------------------------ - const raycaster = new THREE.Raycaster(); - const pointer = new THREE.Vector2(); - let hovered: Marker | null = null; + // `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), + }); - function onPointerMove(event: PointerEvent) { - const rect = canvas.getBoundingClientRect(); - pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; - pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; - raycaster.setFromCamera(pointer, camera); - const hit = raycaster.intersectObjects(markerLayer.pickables, false)[0]; - const marker = (hit?.object.userData.marker as Marker | undefined) ?? null; - if (marker !== hovered) { - hovered = marker; - canvas.style.cursor = marker ? "pointer" : ""; - options.onMarkerPick?.(marker); - } - } - canvas.addEventListener("pointermove", onPointerMove); + // ---- The scene, as the stage sees it ------------------------------------ - // ---- Loop --------------------------------------------------------------- - - const clock = new THREE.Clock(); - let raf = 0; - - function resize() { - const w = canvas.clientWidth; - const h = canvas.clientHeight; - if (w === 0 || h === 0) return; - if (canvas.width !== w || canvas.height !== h) { - renderer.setSize(w, h, false); - camera.aspect = w / h; - camera.updateProjectionMatrix(); - } - } - - function tick() { - raf = requestAnimationFrame(tick); - const dt = Math.min(clock.getDelta(), 0.05); - resize(); - - if (flying) { - flightT = Math.min(1, flightT + dt * 0.65); - // easeInOutCubic — a flight that starts and lands gently - const e = flightT < 0.5 ? 4 * flightT ** 3 : 1 - (-2 * flightT + 2) ** 3 / 2; - camera.position.lerpVectors(flightFrom.pos, desiredPosition, e); - controls.target.lerpVectors(flightFrom.target, desiredTarget, e); - if (flightT >= 1) flying = false; - } - - if (options.flights && flightLayer) { - flightTimer -= dt; - if (flightTimer <= 0) { - flightTimer = options.flights.interval; - void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac)); + const stageScene: StageScene = { + scene, + camera: kit.camera, + controls: kit.controls, + // Leaving for an office should retire the hover with it; coming back to a + // stale detail card for something the pointer is nowhere near reads as a + // bug. + onExit: () => kit.resetPick(), + tick(dt) { + kit.tick(dt); + if (options.flights && flightLayer) { + flightTimer -= dt; + if (flightTimer <= 0) { + flightTimer = options.flights.interval; + void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac)); + } } - } - - controls.update(); - renderer.render(scene, camera); - } - tick(); - - const onWindowResize = () => resize(); - window.addEventListener("resize", onWindowResize); + }, + dispose() { + options.flights?.dispose?.(); + flightLayer?.dispose(); + markerLayer.dispose(); + kit.dispose(); + scene.traverse((obj) => { + const mesh = obj as THREE.Mesh; + mesh.geometry?.dispose(); + const mat = mesh.material; + if (Array.isArray(mat)) mat.forEach((m) => m.dispose()); + else if (mat) (mat as THREE.Material).dispose(); + }); + }, + }; + stage.setScene(stageScene); return { world, chapters: city.chapters, + stage, + stageScene, + setLighting: (state) => kit.applyLighting(state), flyTo, current: () => currentChapter, onChapterChange(fn) { @@ -230,38 +204,28 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S markerLayer.setMarkers(markers); }, dispose() { - cancelAnimationFrame(raf); - window.removeEventListener("resize", onWindowResize); - canvas.removeEventListener("pointermove", onPointerMove); - options.flights?.dispose?.(); - flightLayer?.dispose(); - markerLayer.dispose(); - controls.dispose(); - scene.traverse((obj) => { - const mesh = obj as THREE.Mesh; - mesh.geometry?.dispose(); - const mat = mesh.material; - if (Array.isArray(mat)) mat.forEach((m) => m.dispose()); - else if (mat) (mat as THREE.Material).dispose(); - }); - renderer.dispose(); + // Stage first, so nothing ticks a half-disposed scene. + stage.dispose(); + stageScene.dispose(); }, }; } -function makeSkyTexture(top: number, horizon: number): THREE.Texture { - const canvas = document.createElement("canvas"); - canvas.width = 2; - canvas.height = 256; - const ctx = canvas.getContext("2d"); - if (!ctx) throw new Error("2D canvas context unavailable"); - const grad = ctx.createLinearGradient(0, 0, 0, 256); - grad.addColorStop(0, `#${top.toString(16).padStart(6, "0")}`); - grad.addColorStop(1, `#${horizon.toString(16).padStart(6, "0")}`); - ctx.fillStyle = grad; - ctx.fillRect(0, 0, 2, 256); - const tex = new THREE.CanvasTexture(canvas); - tex.magFilter = THREE.LinearFilter; - tex.colorSpace = THREE.SRGBColorSpace; - return tex; +/** + * The committed default rig: a late-afternoon sun from the west, which throws + * the hills' shadows east across the flats. + * + * Not an `Atmosphere` and not a substitute for one — it computes nothing from + * time or weather, it is a constant with the city's own sky colours poured in. + * It exists so the engine renders with no server, no clock and no config, which + * is the acceptance test the whole repo is held to. + */ +export function cityDaylight(palette: ScenePalette): LightingState { + return { + sun: { direction: [-0.632, 0.717, 0.295], color: 0xfff3e0, intensity: 2.1 }, + hemisphere: { sky: 0xdcecf7, ground: 0x6b6f5e, intensity: 1.05 }, + ambient: { color: 0xffffff, intensity: 0.32 }, + sky: { top: palette.skyTop, horizon: palette.skyHorizon }, + fog: { color: palette.skyHorizon, near: 210, far: 460 }, + }; } diff --git a/src/engine/scenekit.ts b/src/engine/scenekit.ts new file mode 100644 index 0000000..884e1bb --- /dev/null +++ b/src/engine/scenekit.ts @@ -0,0 +1,304 @@ +/** + * The per-scene half of the renderer: camera, controls, the light rig, camera + * flights and picking. + * + * Everything here is per-scene rather than per-stage, because the city and an + * office want different answers to all of it — different near/far planes, + * different orbit limits, a fixed interior rig against a driven daylight one. + * `Stage` keeps the renderer and the loop; a `SceneKit` is what a `StageScene` + * is built out of. See CONTRACT.md §1. + * + * The kit *applies* a `LightingState`; it never works one out. Whoever owns + * the sun — `Atmosphere` for a city, a fixed constant for an office — computes + * the state and hands it over, and nothing writes back. That is the one + * direction CONTRACT.md §4 asks for. + */ + +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import type { LightingState } from "./types.ts"; + +/** Where the camera sits and what it looks at. Scene units, whatever they mean. */ +export interface Pose { + position: THREE.Vector3; + target: THREE.Vector3; +} + +/** + * Picking, with the meaning left to the caller. + * + * `resolve` turns a raycast hit into whatever the caller considers picked — the + * kit never reads `userData` itself, because it has no idea what is in there. + */ +export interface PickOptions { + /** A live array is fine; layers that rebuild theirs can pass a getter. */ + targets: THREE.Object3D[] | (() => THREE.Object3D[]); + resolve(hit: THREE.Intersection): T | null; + /** Fires only on change, including the change back to `null`. */ + onChange(picked: T | null): void; +} + +export interface SceneKitOptions { + scene: THREE.Scene; + /** The element pointer coordinates are read against — the renderer's canvas. */ + dom: HTMLElement; + fov?: number; + near?: number; + far?: number; + minDistance?: number; + maxDistance?: number; + maxPolarAngle?: number; + dampingFactor?: number; + /** Shadow-camera half-extent, in scene units. */ + shadowExtent?: number; + shadowMapSize?: number; + shadowNear?: number; + shadowFar?: number; + shadowBias?: number; + /** + * How far along its direction the sun is placed. A `LightingState` carries a + * unit direction and no distance, because distance is a fact about the scale + * of the scene — 94 m per unit outdoors, 1 m per unit indoors — and not about + * where the sun is. + */ + sunDistance?: number; + /** Flight rate, in fractions of the flight per second. */ + flightSpeed?: number; + /** Cursor while something is picked. */ + hoverCursor?: string; +} + +export interface SceneKit { + camera: THREE.PerspectiveCamera; + controls: OrbitControls; + sun: THREE.DirectionalLight; + hemisphere: THREE.HemisphereLight; + ambient: THREE.AmbientLight; + applyLighting(state: LightingState): void; + /** Jump. Used for the opening pose, where a flight from nowhere is nonsense. */ + setPose(pose: Pose): void; + flyTo(pose: Pose): void; + flying(): boolean; + setPicking(options: PickOptions): void; + /** Forget what is under the pointer and say so. */ + resetPick(): void; + tick(dt: number): void; + dispose(): void; +} + +export function createSceneKit(options: SceneKitOptions): SceneKit { + const { scene, dom } = options; + const sunDistance = options.sunDistance ?? 240; + const flightSpeed = options.flightSpeed ?? 0.65; + const hoverCursor = options.hoverCursor ?? "pointer"; + + const camera = new THREE.PerspectiveCamera( + options.fov ?? 42, + dom.clientWidth / Math.max(1, dom.clientHeight), + options.near ?? 0.1, + options.far ?? 900, + ); + + const controls = new OrbitControls(camera, dom); + controls.enableDamping = true; + controls.dampingFactor = options.dampingFactor ?? 0.07; + controls.maxPolarAngle = options.maxPolarAngle ?? Math.PI / 2.12; // never dip under the ground plane + controls.minDistance = options.minDistance ?? 12; + controls.maxDistance = options.maxDistance ?? 340; + + // ---- Light rig ---------------------------------------------------------- + + const sun = new THREE.DirectionalLight(0xffffff, 1); + sun.castShadow = true; + const mapSize = options.shadowMapSize ?? 2048; + sun.shadow.mapSize.set(mapSize, mapSize); + sun.shadow.camera.near = options.shadowNear ?? 10; + sun.shadow.camera.far = options.shadowFar ?? 520; + const extent = options.shadowExtent ?? 170; + sun.shadow.camera.left = -extent; + sun.shadow.camera.right = extent; + sun.shadow.camera.top = extent; + sun.shadow.camera.bottom = -extent; + sun.shadow.bias = options.shadowBias ?? -0.0012; + const hemisphere = new THREE.HemisphereLight(0xffffff, 0x808080, 1); + const ambient = new THREE.AmbientLight(0xffffff, 0.3); + scene.add(sun, hemisphere, ambient); + + const sunDirection = new THREE.Vector3(); + let sky: THREE.Texture | null = null; + let skyTop = -1; + let skyHorizon = -1; + + function applyLighting(state: LightingState) { + const [dx, dy, dz] = state.sun.direction; + sunDirection.set(dx, dy, dz); + // A zero direction would put the sun inside the ground and black the scene + // out; leaving it where it was is the kinder failure. + if (sunDirection.lengthSq() > 0) { + sun.position.copy(sunDirection.normalize().multiplyScalar(sunDistance)); + } + sun.color.setHex(state.sun.color); + sun.intensity = state.sun.intensity; + + hemisphere.color.setHex(state.hemisphere.sky); + hemisphere.groundColor.setHex(state.hemisphere.ground); + hemisphere.intensity = state.hemisphere.intensity; + + ambient.color.setHex(state.ambient.color); + ambient.intensity = state.ambient.intensity; + + // A null sky leaves `scene.background` alone entirely, which is what an + // office wants: it has walls, and whatever is behind them is not sky. + if (state.sky && (state.sky.top !== skyTop || state.sky.horizon !== skyHorizon)) { + sky?.dispose(); + sky = makeSkyTexture(state.sky.top, state.sky.horizon); + skyTop = state.sky.top; + skyHorizon = state.sky.horizon; + scene.background = sky; + } + + if (!state.fog) { + scene.fog = null; + } else if (scene.fog instanceof THREE.Fog) { + scene.fog.color.setHex(state.fog.color); + scene.fog.near = state.fog.near; + scene.fog.far = state.fog.far; + } else { + scene.fog = new THREE.Fog(state.fog.color, state.fog.near, state.fog.far); + } + } + + // ---- Camera flights ----------------------------------------------------- + + const from: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() }; + const to: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() }; + let flying = false; + let flightT = 0; + + function setPose(pose: Pose) { + flying = false; + camera.position.copy(pose.position); + controls.target.copy(pose.target); + controls.update(); + } + + function flyTo(pose: Pose) { + from.position.copy(camera.position); + from.target.copy(controls.target); + to.position.copy(pose.position); + to.target.copy(pose.target); + flightT = 0; + flying = true; + } + + // ---- Picking ------------------------------------------------------------ + + const raycaster = new THREE.Raycaster(); + const pointer = new THREE.Vector2(); + let picking: PickOptions | null = null; + let picked: unknown = null; + // The raycast runs at most once a frame, off the last pointer position, + // rather than once per `pointermove` — a fast drag across the canvas fires + // dozens of those between two frames and every one of them but the last is + // thrown away. + let pointerDirty = false; + + function onPointerMove(event: PointerEvent) { + const rect = dom.getBoundingClientRect(); + pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + pointerDirty = true; + } + dom.addEventListener("pointermove", onPointerMove); + + function resetPick() { + pointerDirty = false; + if (picked === null) return; + const wasPicking = picking; + picked = null; + dom.style.cursor = ""; + wasPicking?.onChange(null); + } + dom.addEventListener("pointerleave", resetPick); + + function repick() { + if (!picking || !pointerDirty) return; + pointerDirty = false; + const targets = typeof picking.targets === "function" ? picking.targets() : picking.targets; + const hit = targets.length === 0 ? undefined : raycastFirst(targets); + const next = hit ? picking.resolve(hit) : null; + if (next === picked) return; + picked = next; + dom.style.cursor = next ? hoverCursor : ""; + picking.onChange(next); + } + + function raycastFirst(targets: THREE.Object3D[]): THREE.Intersection | undefined { + raycaster.setFromCamera(pointer, camera); + return raycaster.intersectObjects(targets, false)[0]; + } + + return { + camera, + controls, + sun, + hemisphere, + ambient, + applyLighting, + setPose, + flyTo, + flying: () => flying, + setPicking(pick) { + picking = pick as PickOptions; + }, + resetPick, + tick(dt) { + if (flying) { + flightT = Math.min(1, flightT + dt * flightSpeed); + // easeInOutCubic — a flight that starts and lands gently + const e = flightT < 0.5 ? 4 * flightT ** 3 : 1 - (-2 * flightT + 2) ** 3 / 2; + camera.position.lerpVectors(from.position, to.position, e); + controls.target.lerpVectors(from.target, to.target, e); + if (flightT >= 1) flying = false; + // The pointer has not moved but the world under it has. + pointerDirty = true; + } + controls.update(); + repick(); + }, + dispose() { + dom.removeEventListener("pointermove", onPointerMove); + dom.removeEventListener("pointerleave", resetPick); + dom.style.cursor = ""; + picking = null; + controls.dispose(); + scene.remove(sun, hemisphere, ambient); + sun.dispose(); + hemisphere.dispose(); + ambient.dispose(); + sky?.dispose(); + if (scene.background === sky) scene.background = null; + }, + }; +} + +/** + * A two-pixel-wide vertical gradient. Cheap, and a `Scene.background` texture + * is stretched to fill regardless, so the width buys nothing. + */ +function makeSkyTexture(top: number, horizon: number): THREE.Texture { + const canvas = document.createElement("canvas"); + canvas.width = 2; + canvas.height = 256; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("2D canvas context unavailable"); + const grad = ctx.createLinearGradient(0, 0, 0, 256); + grad.addColorStop(0, `#${top.toString(16).padStart(6, "0")}`); + grad.addColorStop(1, `#${horizon.toString(16).padStart(6, "0")}`); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 2, 256); + const tex = new THREE.CanvasTexture(canvas); + tex.magFilter = THREE.LinearFilter; + tex.colorSpace = THREE.SRGBColorSpace; + return tex; +} diff --git a/src/engine/solar.ts b/src/engine/solar.ts new file mode 100644 index 0000000..888ed5d --- /dev/null +++ b/src/engine/solar.ts @@ -0,0 +1,379 @@ +/** + * Where the sun actually is, computed rather than fetched. + * + * This is the NOAA Solar Calculator's algorithm, which is Jean Meeus, + * *Astronomical Algorithms* (2nd ed., 1998) — chapter 22 for nutation and + * obliquity, 25 for the sun's coordinates, 28 for the equation of time — at the + * low-precision truncation NOAA uses. It is good to roughly 0.01°, a minute of + * arc, for dates between about 1800 and 2100. That is a fifth of the sun's own + * half-degree disc, so nothing a renderer does with it can see the error. + * Sunrise and sunset land within a minute or so at temperate latitudes and + * degrade towards the poles, where the sun crosses the horizon at a shallow + * enough angle that a minute of arc is a long time. + * + * There is a perfectly good web service for this and we do not use it. The + * whole engine has to work with no account, no key and no network, and a + * time-of-day that silently stops moving when the wifi drops is worse than one + * that was never real. Two hundred lines of trigonometry buys that outright. + * + * Nothing here imports three.js: this is arithmetic, and `atmosphere.ts` is + * what turns it into light. + */ + +const DEG = Math.PI / 180; +const RAD = 180 / Math.PI; + +const MS_PER_MINUTE = 60_000; +const MS_PER_DAY = 86_400_000; + +/** + * Zenith angle counted as sunrise. 90° puts the sun's centre on the horizon; + * the extra 0.833° is its 16' semidiameter plus the 34' of refraction that + * lifts the upper limb into view before it geometrically arrives. + */ +const SUNRISE_ZENITH = 90.833; + +export interface SolarPosition { + /** Degrees clockwise from true north: 90 is due east, 180 due south. */ + azimuth: number; + /** Degrees above the horizon, with atmospheric refraction applied. */ + elevation: number; + /** The sun's declination in degrees — its latitude on the celestial sphere. */ + declination: number; + /** Apparent solar time minus mean solar time, in minutes. Roughly ±16. */ + equationOfTime: number; +} + +/** + * The standard twilight bands, plus the one photographers named. + * + * The boundaries are applied to the refraction-corrected elevation this module + * returns, so `daylightPhase` and `sunTimes` agree about where the horizon is + * to within a few seconds rather than the ~90 s they would disagree by if the + * horizon sat at a flat zero. + */ +export type DaylightPhase = "night" | "astronomical" | "nautical" | "civil" | "golden" | "day"; + +// ---- The sun's coordinates ------------------------------------------------ + +interface SolarTerms { + declination: number; + equationOfTime: number; +} + +/** + * The Unix epoch is JD 2440587.5, and `Date` is already a count of + * milliseconds, so the Gregorian calendar arithmetic of Meeus chapter 7 never + * has to happen here. + */ +function julianDay(when: Date): number { + return when.getTime() / MS_PER_DAY + 2_440_587.5; +} + +/** Julian centuries since J2000.0, the argument every series below is in. */ +function julianCentury(jd: number): number { + return (jd - 2_451_545) / 36_525; +} + +function solarTerms(t: number): SolarTerms { + const meanLongitude = mod(280.46646 + t * (36_000.76983 + t * 0.0003032), 360); + const meanAnomaly = 357.52911 + t * (35_999.05029 - 0.0001537 * t); + const eccentricity = 0.016708634 - t * (0.000042037 + 0.0000001267 * t); + + // Equation of centre: the correction from the fictitious mean sun, which + // moves uniformly, to the real one, which does not because the orbit is an + // ellipse. Three sine terms are plenty at this precision. + const centre = + Math.sin(meanAnomaly * DEG) * (1.914602 - t * (0.004817 + 0.000014 * t)) + + Math.sin(2 * meanAnomaly * DEG) * (0.019993 - 0.000101 * t) + + Math.sin(3 * meanAnomaly * DEG) * 0.000289; + const trueLongitude = meanLongitude + centre; + + // Nutation and aberration, both driven by the moon's ascending node, folded + // into the one term Meeus gives for them together. + const omega = 125.04 - 1934.136 * t; + const apparentLongitude = trueLongitude - 0.00569 - 0.00478 * Math.sin(omega * DEG); + + const meanObliquity = 23 + (26 + (21.448 - t * (46.815 + t * (0.00059 - t * 0.001813))) / 60) / 60; + const obliquity = meanObliquity + 0.00256 * Math.cos(omega * DEG); + + const declination = + Math.asin(Math.sin(obliquity * DEG) * Math.sin(apparentLongitude * DEG)) * RAD; + + // Equation of time, Meeus 28.3. `y` is tan²(ε/2); the series is in the mean + // longitude and anomaly, not the true ones, which is easy to get wrong. + const y = Math.tan((obliquity / 2) * DEG) ** 2; + const equationOfTime = + 4 * + RAD * + (y * Math.sin(2 * meanLongitude * DEG) - + 2 * eccentricity * Math.sin(meanAnomaly * DEG) + + 4 * eccentricity * y * Math.sin(meanAnomaly * DEG) * Math.cos(2 * meanLongitude * DEG) - + 0.5 * y * y * Math.sin(4 * meanLongitude * DEG) - + 1.25 * eccentricity ** 2 * Math.sin(2 * meanAnomaly * DEG)); + + return { declination, equationOfTime }; +} + +/** + * Atmospheric refraction in degrees, to be added to the geometric elevation. + * + * The atmosphere bends light down over the horizon, so a low sun appears + * higher than it is — by more than its own diameter at the horizon itself, + * which is why a sunset you can see has already happened. NOAA's piecewise fit + * in arcseconds; it assumes ordinary sea-level pressure and temperature, and + * the last branch is a fiction that keeps the curve continuous below the + * horizon rather than a claim about anything observable. + */ +function refraction(elevation: number): number { + if (elevation > 85) return 0; + const te = Math.tan(elevation * DEG); + let arcseconds: number; + if (elevation > 5) { + arcseconds = 58.1 / te - 0.07 / te ** 3 + 0.000086 / te ** 5; + } else if (elevation > -0.575) { + arcseconds = + 1735 + + elevation * (-518.2 + elevation * (103.4 + elevation * (-12.79 + elevation * 0.711))); + } else { + arcseconds = -20.772 / te; + } + return arcseconds / 3600; +} + +/** + * Azimuth and elevation for an observer at `lat`/`lng`, in degrees, at an + * instant. Longitude is positive east, which is the sign convention the rest + * of the engine uses and the opposite of NOAA's own spreadsheet. + */ +export function solarPosition(lat: number, lng: number, when: Date): SolarPosition { + const { declination, equationOfTime } = solarTerms(julianCentury(julianDay(when))); + + // Apparent solar time: minutes of UTC, carried east four minutes per degree + // of longitude, then bent from mean to apparent by the equation of time. + const utcMinutes = mod(when.getTime() / MS_PER_MINUTE, 1440); + const trueSolarTime = mod(utcMinutes + equationOfTime + 4 * lng, 1440); + const hourAngle = trueSolarTime / 4 - 180; + + const latRad = lat * DEG; + const decRad = declination * DEG; + const cosZenith = clamp( + Math.sin(latRad) * Math.sin(decRad) + + Math.cos(latRad) * Math.cos(decRad) * Math.cos(hourAngle * DEG), + -1, + 1, + ); + const zenith = Math.acos(cosZenith) * RAD; + const geometric = 90 - zenith; + + const sinZenith = Math.sin(zenith * DEG); + const cosLat = Math.cos(latRad); + let azimuth: number; + if (Math.abs(sinZenith) < 1e-9 || Math.abs(cosLat) < 1e-9) { + // The sun within a hair of the zenith, or the observer standing on a pole. + // Azimuth is genuinely undefined at both, and at the first it also does not + // matter — the light is coming straight down. Fall back to the hour angle, + // which is continuous and gets the meridian crossing right. + azimuth = mod(hourAngle + 180, 360); + } else { + const cosAzimuth = clamp( + (Math.sin(latRad) * cosZenith - Math.sin(decRad)) / (cosLat * sinZenith), + -1, + 1, + ); + const a = Math.acos(cosAzimuth) * RAD; + // `acos` cannot tell morning from afternoon; the hour angle can. + azimuth = hourAngle > 0 ? mod(a + 180, 360) : mod(540 - a, 360); + } + + return { + azimuth, + elevation: geometric + refraction(geometric), + declination, + equationOfTime, + }; +} + +// ---- Rise, set and noon --------------------------------------------------- + +/** + * Sunrise, sunset and solar noon for the local solar day containing `when`. + * + * `sunrise` and `sunset` are `null` through a polar summer or winter, when the + * sun does not cross the horizon at all. That is a legitimate answer, not an + * error, and a caller rendering Tromsø in January should get a long blue day + * rather than an exception. + */ +export function sunTimes( + lat: number, + lng: number, + when: Date, +): { sunrise: Date | null; sunset: Date | null; solarNoon: Date } { + const dayStart = solarDayStart(lng, when); + + // Solar noon is local mean noon pulled back by the equation of time. Two + // passes because the equation of time itself wants evaluating at the answer, + // and it moves slowly enough that two is convergence. + let solarNoonMs = dayStart + MS_PER_DAY / 2; + for (let i = 0; i < 2; i++) { + const terms = solarTerms(julianCentury(julianDay(new Date(solarNoonMs)))); + solarNoonMs = dayStart + MS_PER_DAY / 2 - terms.equationOfTime * MS_PER_MINUTE; + } + + return { + sunrise: refineEvent(lat, solarNoonMs, -1), + sunset: refineEvent(lat, solarNoonMs, 1), + solarNoon: new Date(solarNoonMs), + }; +} + +/** + * Local *mean solar* midnight, as a UTC timestamp — four minutes of day per + * degree of longitude. + * + * Using the solar day rather than the UTC calendar day is what makes a caller + * in San Francisco at 23:00 local get tonight's sunset instead of tomorrow's, + * and it needs no timezone database, which is just as well: we could not ship + * one and still claim to work offline forever. + */ +function solarDayStart(lng: number, when: Date): number { + const offset = lng * 4 * MS_PER_MINUTE; + const local = when.getTime() + offset; + return Math.floor(local / MS_PER_DAY) * MS_PER_DAY - offset; +} + +/** + * The hour angle at which the sun reaches `SUNRISE_ZENITH`, in degrees, or + * `null` if it never does. The cosine leaving [-1, 1] is exactly the polar + * day/night case; at the poles themselves the denominator also collapses, so + * the finiteness check has to come first. + */ +function sunriseHourAngle(lat: number, declination: number): number | null { + const latRad = lat * DEG; + const decRad = declination * DEG; + const c = + Math.cos(SUNRISE_ZENITH * DEG) / (Math.cos(latRad) * Math.cos(decRad)) - + Math.tan(latRad) * Math.tan(decRad); + if (!Number.isFinite(c) || c > 1 || c < -1) return null; + return Math.acos(c) * RAD; +} + +/** + * Walk an event in from solar noon. The declination is evaluated at the event's + * own approximate time rather than at noon, which is worth several seconds at + * the solstices and much more than that at high latitude. + */ +function refineEvent(lat: number, solarNoonMs: number, sign: 1 | -1): Date | null { + let ms = solarNoonMs; + for (let i = 0; i < 2; i++) { + const { declination } = solarTerms(julianCentury(julianDay(new Date(ms)))); + const hourAngle = sunriseHourAngle(lat, declination); + if (hourAngle === null) return null; + ms = solarNoonMs + sign * hourAngle * 4 * MS_PER_MINUTE; + } + return new Date(ms); +} + +// ---- Consumption ---------------------------------------------------------- + +/** + * The elevation `solarPosition` reports at the instant `sunTimes` calls + * sunrise: the sun's centre is 0.833° below the true horizon and refraction is + * lifting it back by about 0.41° of that. Deriving it rather than writing + * -0.42 down keeps the two functions agreeing if `SUNRISE_ZENITH` ever moves. + */ +const HORIZON = 90 - SUNRISE_ZENITH + refraction(90 - SUNRISE_ZENITH); + +/** + * Which band of light we are in, from an elevation in degrees. + * + * Astronomical, nautical and civil twilight are defined on the *geometric* + * elevation, but refraction is under a tenth of a degree by 6° down, so + * applying them to the refracted value costs nothing and saves the caller + * carrying two elevations around. + */ +export function daylightPhase(elevation: number): DaylightPhase { + if (elevation >= 6) return "day"; + if (elevation >= HORIZON) return "golden"; + if (elevation >= -6) return "civil"; + if (elevation >= -12) return "nautical"; + if (elevation >= -18) return "astronomical"; + return "night"; +} + +/** + * A unit vector pointing *from the scene towards the sun*, in the engine's + * axes: `x` east, `z` south, `y` up — the same convention `World.project` + * establishes, where north is `-z`. + * + * This is the direction to put a light in, not the direction light travels; + * negate it for the latter. A `THREE.DirectionalLight` wants + * `light.position.copy(dir).multiplyScalar(distance)` with its target at the + * origin, because three.js reads a directional light's direction off the vector + * between the two. + */ +export function sunDirection(pos: SolarPosition): { x: number; y: number; z: number } { + const el = pos.elevation * DEG; + const az = pos.azimuth * DEG; + const horizontal = Math.cos(el); + return { + x: horizontal * Math.sin(az), + // Azimuth is measured from north and north is -z, so the northward + // component is negated on the way in. A midday sun in the northern + // hemisphere sits due south at azimuth 180, which lands on +z. + z: -horizontal * Math.cos(az), + y: Math.sin(el), + }; +} + +// ---- Helpers -------------------------------------------------------------- + +/** `%` keeps the sign of the dividend, which is wrong for angles and clocks. */ +function mod(x: number, n: number): number { + return ((x % n) + n) % n; +} + +function clamp(x: number, lo: number, hi: number): number { + return x < lo ? lo : x > hi ? hi : x; +} + +// ---- Sanity checks -------------------------------------------------------- + +/** + * Values a reviewer can check against an almanac without running anything, or + * against a reference implementation without trusting this one. + * + * San Francisco is 37.7749° N, 122.4194° W. Its noon sun is always due south, + * because the latitude is north of the tropic, so the noon azimuth is 180.00 + * every day of the year. The noon elevation is 90° minus the latitude plus the + * declination: + * + * June solstice 90 - 37.7749 + 23.44 = 75.67° (returned: 75.67) + * equinox 90 - 37.7749 + 0 = 52.23° (returned: 52.30, the + * extra being the declination not being exactly zero at + * solar noon on the day the equinox happens to fall) + * December solstice 90 - 37.7749 - 23.44 = 28.79° (returned: 28.82) + * + * The returned figures run a hundredth of a degree over the geometric ones at + * noon and about four tenths over at the horizon; that gap is the refraction + * term, and it is the whole reason a sunset you can watch has already happened. + * + * The equation of time reaches about -14.2 min around 11 February and +16.5 min + * around 3 November, and passes through zero near 15 April, 13 June, 1 + * September and 25 December. Solar noon in San Francisco therefore lands near + * 20:10 UTC in mid-April and near 19:53 UTC in early November — a seventeen + * minute swing in when noon is, from a clock that never moves. + * + * `sunTimes` for San Francisco at the June solstice: sunrise 12:48 UTC, sunset + * 03:35 UTC the following day — 05:48 and 20:35 Pacific — a day 14 h 47 m long. + * At the December solstice: 15:21 and 00:54 UTC, which is 07:21 and 16:54 + * Pacific and 9 h 33 m of daylight. + * + * Tromsø at 69.65° N returns `null` for both events from 18 May to 26 July and + * again from 28 November to 15 January, which is the midnight sun and the polar + * night to the day. `solarNoon` is still returned in every one of those cases, + * and the poles themselves return nulls rather than a NaN or a throw. + * + * `sunDirection` of an azimuth of 180 and an elevation of 0 is `{x: 0, y: 0, + * z: 1}` — due south is +z. Azimuth 90 gives +x, east; azimuth 0 gives -z. + */ diff --git a/src/engine/stage.ts b/src/engine/stage.ts new file mode 100644 index 0000000..e6b85ed --- /dev/null +++ b/src/engine/stage.ts @@ -0,0 +1,127 @@ +/** + * The stage: one renderer, one loop, one canvas, and a scene you can swap. + * + * Stage owns *only* the WebGL renderer, the RAF loop and resize. It has no + * camera, no lights and no picking — those are per-scene and live in + * `SceneKit`, because a city and an office cannot share a `THREE.Scene` at all: + * SF's `latScale` puts one scene unit at ~94 m with 3.6x vertical + * exaggeration, and an office renders at 1 unit = 1 m. + * + * The swap **retains and pauses** the outgoing scene rather than disposing it. + * That is a measured choice, not a preference: SF's heightfield is 484 x 696 + * lattice points and `world.ts` records a ~1.0 s build, so throwing the city + * away every time somebody steps into an office means paying a second of + * rebuild on the way back out. Stage therefore disposes nothing it did not + * create — whoever built a `StageScene` disposes it, when they actually mean + * to be rid of it. See CONTRACT.md §1. + */ + +import * as THREE from "three"; +import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; + +export interface StageScene { + scene: THREE.Scene; + camera: THREE.PerspectiveCamera; + controls: OrbitControls; + onEnter?(): void; + onExit?(): void; + tick(dt: number, elapsed: number): void; + dispose(): void; +} + +export interface Stage { + renderer: THREE.WebGLRenderer; + setScene(s: StageScene): void; + current(): StageScene | null; + dispose(): void; +} + +export interface StageOptions { + antialias?: boolean; + /** Device pixel ratio ceiling. Above 2 the cost is real and the gain is not. */ + maxPixelRatio?: number; + shadows?: boolean; +} + +export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {}): Stage { + const renderer = new THREE.WebGLRenderer({ canvas, antialias: options.antialias ?? true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, options.maxPixelRatio ?? 2)); + renderer.setSize(canvas.clientWidth, canvas.clientHeight, false); + if (options.shadows ?? true) { + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + } + + let currentScene: StageScene | null = null; + + // Elapsed time is tracked per scene rather than per stage. A scene that sat + // paused for forty seconds should come back where it left off, not jump + // forty seconds into whatever it animates. + const elapsedByScene = new WeakMap(); + + // Compared against CSS pixels, because `canvas.width` is in device pixels and + // differs from `clientWidth` on every retina display — checking it would call + // `setSize` on every single frame. + let lastWidth = 0; + let lastHeight = 0; + + function applyViewport(target: StageScene) { + const w = canvas.clientWidth; + const h = canvas.clientHeight; + if (w === 0 || h === 0) return; + target.camera.aspect = w / h; + target.camera.updateProjectionMatrix(); + } + + function resize() { + const w = canvas.clientWidth; + const h = canvas.clientHeight; + if (w === 0 || h === 0) return; + if (w === lastWidth && h === lastHeight) return; + lastWidth = w; + lastHeight = h; + renderer.setSize(w, h, false); + if (currentScene) applyViewport(currentScene); + } + + const clock = new THREE.Clock(); + let raf = 0; + + function tick() { + raf = requestAnimationFrame(tick); + // Clamped, so a backgrounded tab returning does not advance every animation + // by however long it was gone. + const dt = Math.min(clock.getDelta(), 0.05); + resize(); + const active = currentScene; + if (!active) return; + const elapsed = (elapsedByScene.get(active) ?? 0) + dt; + elapsedByScene.set(active, elapsed); + active.tick(dt, elapsed); + renderer.render(active.scene, active.camera); + } + tick(); + + const onWindowResize = () => resize(); + window.addEventListener("resize", onWindowResize); + + return { + renderer, + setScene(s) { + if (s === currentScene) return; + currentScene?.onExit?.(); + currentScene = s; + // The incoming camera may never have seen this canvas, and the canvas may + // have been resized while the scene was paused. + applyViewport(s); + s.onEnter?.(); + }, + current: () => currentScene, + dispose() { + cancelAnimationFrame(raf); + window.removeEventListener("resize", onWindowResize); + currentScene = null; + renderer.dispose(); + }, + }; +} diff --git a/src/engine/types.ts b/src/engine/types.ts index dba741b..d650f2c 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -85,12 +85,29 @@ export interface Road { kind: "street" | "freeway"; } -/** A camera destination, and a sentence about why it is on the map. */ -export interface Chapter { +/** + * A named destination, as the interface knows it: what the legend prints and + * what `flyTo` is keyed on. + * + * Split out of `Chapter` because an office has exactly the same idea — a short + * list of places you can jump to — but positions them in metres, not in + * latitude and longitude. Only this half of a chapter is shared; the pose is + * not. `number` and `description` are optional here and required on `Chapter`, + * because a city's chapters are a numbered tour with a sentence each and an + * office's views are usually just "Reception" and "The desk bay". + */ +export interface View { id: string; - number: string; label: string; shortLabel: string; + number?: string; + description?: string; +} + +/** A camera destination, and a sentence about why it is on the map. */ +export interface Chapter extends View { + number: string; + description: string; focus: { lat: number; lng: number; @@ -98,7 +115,6 @@ export interface Chapter { height: number; rotation: number; }; - description: string; } /** @@ -179,24 +195,70 @@ export interface ScenePalette { parkHigh: number; } +// ---- Lighting ------------------------------------------------------------- + +/** + * Everything the light rig needs, as plain numbers. + * + * This is a *state*, not an observation: `Environment` — `{ time, sun, weather }` + * — is what the world is doing, and a `LightingState` is what that means for the + * rig. `Atmosphere` owns the conversion and is the only thing allowed to make + * one; a scene applies it and never writes back. Two modules both constructing + * and mutating the same three lights is the failure this shape exists to + * prevent. See CONTRACT.md §4. + * + * Colours are `0xrrggbb`, matching `ScenePalette` and three.js. + */ +export interface LightingState { + sun: { + /** + * Unit vector from the scene toward the sun. Distance is deliberately + * absent: how far away to place the light is a fact about the scale of the + * scene, and the sun does not know whether it is shining on 94 m per unit + * or on 1 m per unit. + */ + direction: [number, number, number]; + color: number; + intensity: number; + }; + hemisphere: { sky: number; ground: number; intensity: number }; + ambient: { color: number; intensity: number }; + /** + * Background gradient, or `null` to leave the background alone — which is + * what an interior wants, since it has walls and no horizon. + */ + sky: { top: number; horizon: number } | null; + /** `null` for no fog at all. An office gets none. */ + fog: { color: number; near: number; far: number } | null; +} + // ---- Markers -------------------------------------------------------------- /** - * A thing on the map. + * A thing worth pointing at, minus where it is. * * `colorKey` is deliberately opaque to the engine — it indexes into a palette * the caller supplies. The engine will not learn what "rejected" means. + * + * This is the half that survives a change of coordinate system: a pin on a city + * at 37.79 N, -122.40 E and a pin on a desk 4.2 m along the east wall are the + * same kind of thing to everything downstream of the geometry, so an office can + * carry its own positions and still hand a `Pin` to the same detail card. */ -export interface Marker { +export interface Pin { id: string; - lat: number; - lng: number; label: string; colorKey: string; /** Optional href for the detail card. */ url?: string; /** Optional one-liner for the detail card. */ blurb?: string; +} + +/** A `Pin` placed on a city, in degrees. */ +export interface Marker extends Pin { + lat: number; + lng: number; /** * False when the position is a placeholder rather than a real address. * Rendered distinctly, because inventing a location on a map whose premise diff --git a/src/interiors/furnish.ts b/src/interiors/furnish.ts new file mode 100644 index 0000000..d611d68 Binary files /dev/null and b/src/interiors/furnish.ts differ diff --git a/src/interiors/officeScene.ts b/src/interiors/officeScene.ts new file mode 100644 index 0000000..41bde52 --- /dev/null +++ b/src/interiors/officeScene.ts @@ -0,0 +1,383 @@ +/** + * An `Office` as a `StageScene`: the shell, the furniture, the people, a camera + * at office scale and a fixed interior light rig. + * + * This is `scene.ts`'s opposite number and it is deliberately the same shape. + * The renderer and the loop live in `Stage`; the camera, controls, flights and + * picking live in a `SceneKit`; what is left here is the office itself. Swap it + * in with `stage.setScene(office)` and the city is *paused, not disposed* — + * rebuilding SF's 336,864-point heightfield on the way back out costs about a + * second, which is the measurement CONTRACT.md §1 is built on. + * + * Whoever builds one of these disposes it. `Stage` disposes nothing it did not + * create, and the city handle's `dispose()` does not reach in here. + * + * ### One unit is one metre, and the camera has to know + * + * Nothing here is shared with the city's camera settings, because none of them + * transfer: SF puts a scene unit at ~94 m and clips at 900, and using those + * numbers indoors gives you a near plane thicker than a desk. An office runs + * near 0.05, far 300, and orbits between about a metre and the width of the + * building. + * + * ### No Atmosphere + * + * CONTRACT.md §4: an office gets `fog: null`, no `scene.background` drive and + * its own fixed rig. Daylight through the windows is a later refinement and + * explicitly not a v1 coupling — an office that dims at dusk because a weather + * station said so is a nice idea and a bad dependency for a room that has to + * render with no network at all. + * + * ### Orbit dollhouse is the only navigation mode + * + * Walk mode is not built here. `Plan` already produces the collision segments it + * will need, which is the point of doing the wall split once, but v1 orbits: the + * ceilings come off, the walls between you and what you are looking at go + * translucent, and the existing camera, flight and picking machinery is reused + * verbatim. + */ + +import * as THREE from "three"; +import { createSceneKit, type Pose } from "../engine/scenekit.ts"; +import type { StageScene } from "../engine/stage.ts"; +import type { LightingState, View } from "../engine/types.ts"; +import 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 +// `kit`. A caller who passes their own registry is left alone with it — theirs +// is theirs, and re-registering ours over the top would clobber a deliberate +// replacement of a built-in id. +import "../assets/office/index.ts"; +import { createFurnishings, type Furnishings } from "./furnish.ts"; +import { Plan, type PlanOptions } from "./plan.ts"; +import { createPresenceLayer, type PresenceLayer, type PresencePalette } from "./presence.ts"; +import { createShell, type Shell, type WallInfo } from "./shell.ts"; +import type { Office, Point2, Presence, Viewpoint } from "./types.ts"; + +export interface OfficeSceneOptions { + /** + * The renderer's canvas. Orbit input and pointer coordinates are read against + * it, so this is `stage.renderer.domElement` — the office shares the city's + * renderer and has its own everything else. + */ + dom: HTMLElement; + /** + * Bring your own, to share one set of materials and textures across two + * offices. Made here otherwise, and disposed here only if it was made here. + */ + materials?: MaterialRegistry; + quality?: MaterialQuality; + palette?: InteriorPalette; + /** Defaults to the shared `kit`. */ + registry?: AssetRegistry; + /** Resolves a `Prop.colorKey` to a colour. Opaque to everything in here. */ + colorFor?: (key: string) => number | undefined; + /** Resolves a `Presence.colorKey` to a colour. Also opaque. */ + presencePalette?: PresencePalette; + onPresencePick?: (presence: Presence | null) => void; + /** Overrides the fixed interior rig. Must carry `sky: null` and `fog: null`. */ + lighting?: LightingState; + /** Defaults to false — the lid comes off, because that is the whole view. */ + showCeilings?: boolean; + /** Fade the walls you are looking through. Defaults to true. */ + occlusionFade?: boolean; + /** + * Flat colour behind the building. `null` leaves `scene.background` alone, + * which shows the page through the canvas. + */ + background?: number | null; + plan?: PlanOptions; +} + +export interface OfficeScene extends StageScene { + plan: Plan; + /** The pack's viewpoints, as the thing a legend prints and `flyTo` is keyed on. */ + views: View[]; + flyTo(viewId: string): void; + current(): string | null; + onViewChange(fn: (id: string) => void): void; + /** Occupancy, bound by seat id. Safe to call before the scene is shown. */ + setPresence(people: Presence[]): void; + /** Scene-space label anchors per presence id, for an HTML overlay. */ + anchors: Map; + setCeilingsVisible(visible: boolean): void; + setLighting(state: LightingState): void; +} + +export function createOfficeScene(office: Office, options: OfficeSceneOptions): OfficeScene { + const plan = new Plan(office, options.plan ?? {}); + const scene = new THREE.Scene(); + scene.name = `office:${office.id}`; + + const ownsMaterials = options.materials === undefined; + const materials = + options.materials ?? + new MaterialRegistry({ + quality: options.quality ?? "high", + ...(options.palette ? { palette: options.palette } : {}), + }); + + // The building's own size decides the camera limits, the shadow extent and how + // far away to put the sun. A 12 m studio and a 60 m floor plate want different + // answers to all three, and none of them is a constant anybody should be + // tuning by hand per pack. + const span = Math.max(plan.bounds.width, plan.bounds.depth, 8); + + const kit = createSceneKit({ + scene, + dom: options.dom, + fov: 50, + near: 0.05, + far: 300, + minDistance: 1.2, + maxDistance: span * 1.8, + // Just short of horizontal, so the camera cannot get under the floor slab + // and look up at the building's unlit underside. + maxPolarAngle: Math.PI / 2.04, + dampingFactor: 0.08, + shadowExtent: Math.max(8, span * 0.7), + shadowMapSize: 2048, + shadowNear: 0.5, + shadowFar: span * 4, + // An office is a hundredth of the city's scale, and the default bias is + // tuned for the city: at 1 unit = 1 m it detaches every contact shadow. + shadowBias: -0.0004, + sunDistance: Math.max(24, span * 1.4), + // Offices are small and a flight across one is short. At the city's rate it + // reads as a stall. + flightSpeed: 0.95, + }); + kit.applyLighting(options.lighting ?? officeInterior()); + + if (options.background !== null) { + // A room has walls and no horizon, so nothing here computes a sky + // (CONTRACT.md §4) — but with the ceilings off you are looking at the + // building from outside it, and the outside cannot be nothing. One flat + // colour, set once, derived from the floor so it belongs to the palette + // rather than being picked. + scene.background = + options.background !== undefined + ? new THREE.Color(options.background) + : new THREE.Color(materials.palette.floorSlab).multiplyScalar(0.45); + } + + const shell: Shell = createShell(plan, { materials }); + const furnishings: Furnishings = createFurnishings(plan, { + materials, + ...(options.registry ? { registry: options.registry } : {}), + ...(options.colorFor ? { colorFor: options.colorFor } : {}), + }); + const presence: PresenceLayer = createPresenceLayer(plan, options.presencePalette ?? {}); + scene.add(shell.group, furnishings.group, presence.group); + shell.ceilings.visible = options.showCeilings ?? false; + + // ---- Viewpoints --------------------------------------------------------- + + const viewpointById = new Map(plan.viewpoints.map((v) => [v.id, v])); + const views: View[] = [...plan.viewpoints]; + let currentView: string | null = plan.arrival()?.id ?? null; + const viewListeners: ((id: string) => void)[] = []; + + /** + * `height` raises the CAMERA above the floor; the target stays down near it. + * + * This is the city's meaning of the same two field names — read + * `chapterPose` in engine/scene.ts, where the target sits on the ground and + * only the camera is lifted — and matching it matters more than any argument + * for the alternative. An earlier version put target *and* camera at + * `floorY + height`, i.e. a horizontal look from that altitude, and the + * reference pack's establishing shot (`height: 14`, a building with 2.8 m + * ceilings) aimed the camera at empty air fourteen metres above the roof with + * the office out of frame below. Two readings of one field, and the pack + * author's was the reasonable one. + * + * `TARGET_Y` is a little above the floor rather than on it so an eye-level + * viewpoint looks at a room instead of at people's shoes. + */ + function poseFor(viewpoint: Viewpoint): Pose { + const floorY = plan.level(viewpoint.levelId)?.floorY ?? 0; + const focus = viewpoint.focus; + const TARGET_Y = 1.2; + return { + target: new THREE.Vector3(focus.at.x, floorY + TARGET_Y, focus.at.z), + position: new THREE.Vector3( + focus.at.x + Math.sin(focus.rotation) * focus.distance, + floorY + Math.max(focus.height, TARGET_Y + 0.3), + focus.at.z + Math.cos(focus.rotation) * focus.distance, + ), + }; + } + + /** + * Where you arrive when the pack declares no viewpoints at all. + * + * Deliberately a dollhouse rather than an eye-level shot: with nothing + * authored there is no first impression to honour, and the useful default is + * the one that shows you what you have got. + */ + function overview(): Pose { + const floorY = plan.levels[0]?.floorY ?? 0; + const c = plan.bounds.center; + return { + target: new THREE.Vector3(c.x, floorY + 1, c.z), + position: new THREE.Vector3(c.x, floorY + span * 0.75, c.z + span * 0.85), + }; + } + + const arrival = plan.arrival(); + kit.setPose(arrival ? poseFor(arrival) : overview()); + + function flyTo(viewId: string) { + const viewpoint = viewpointById.get(viewId); + if (!viewpoint) return; + kit.flyTo(poseFor(viewpoint)); + if (currentView !== viewId) { + currentView = viewId; + for (const fn of viewListeners) fn(viewId); + } + } + + // ---- Picking ------------------------------------------------------------ + + // `pickables` is rebuilt in place whenever occupancy changes, so the getter + // rather than the array: the office outlives any one set of people in it. + kit.setPicking({ + targets: () => presence.pickables, + resolve: (hit) => (hit.object.userData.presence as Presence | undefined) ?? null, + onChange: (person) => options.onPresencePick?.(person), + }); + + // ---- Occlusion fade ----------------------------------------------------- + + const fade = options.occlusionFade ?? true; + const lastEye = new THREE.Vector3(NaN, NaN, NaN); + const lastTarget = new THREE.Vector3(NaN, NaN, NaN); + const eye: Point2 = { x: 0, z: 0 }; + const look: Point2 = { x: 0, z: 0 }; + + /** + * Walls between the camera and what it is looking at go translucent. + * + * A 2-D crossing test against each wall's centreline, which is why `shell.ts` + * stamps the segment on the mesh — the alternative is a raycast per wall per + * frame against geometry that has already been merged past recognition. Walls + * whose top is below the target are left alone: you can see over a 1.4 m + * partition, so it is not in the way, and fading it only makes the floor look + * unfinished. + * + * Recomputed only when the camera has actually moved. Orbit damping means it + * settles within a few frames of the pointer stopping, and then this costs + * nothing at all. + */ + function updateOcclusion() { + if (!fade) return; + const camera = kit.camera; + const target = kit.controls.target; + if (camera.position.distanceToSquared(lastEye) < 4e-4 && target.distanceToSquared(lastTarget) < 4e-4) { + return; + } + lastEye.copy(camera.position); + lastTarget.copy(target); + eye.x = camera.position.x; + eye.z = camera.position.z; + look.x = target.x; + look.z = target.z; + + for (const mesh of shell.wallMeshes) { + const info = mesh.userData.wall as WallInfo | undefined; + if (!info) continue; + const blocking = info.top > target.y + 0.25 && segmentsCross(eye, look, info.from, info.to); + shell.setGhosted(mesh, blocking); + } + } + updateOcclusion(); + + // ---- The scene, as the stage sees it ------------------------------------ + + return { + scene, + camera: kit.camera, + controls: kit.controls, + plan, + views, + anchors: presence.anchors, + flyTo, + current: () => currentView, + onViewChange(fn) { + viewListeners.push(fn); + }, + setPresence(people) { + presence.setPresence(people); + }, + setCeilingsVisible(visible) { + shell.ceilings.visible = visible; + }, + setLighting(state) { + kit.applyLighting(state); + }, + // Stepping back out to the city should retire the hover with it, or the + // detail card for whoever the pointer was over survives the journey. + onExit: () => kit.resetPick(), + tick(dt) { + kit.tick(dt); + updateOcclusion(); + }, + dispose() { + presence.dispose(); + furnishings.dispose(); + shell.dispose(); + kit.dispose(); + // The shared `PartBin` is never disposed — it is module-level and every + // other asset in the page is still using it. + if (ownsMaterials) materials.dispose(); + scene.clear(); + }, + }; +} + +/** + * The fixed interior rig: a soft high sun, a strong hemisphere for the bounce a + * real room has and a real-time renderer does not, no sky and no fog. + * + * It computes nothing. There is no `Atmosphere` indoors, on purpose + * (CONTRACT.md §4) — the numbers below are a lighting designer's, not a + * physicist's, and their job is that a room looks like a room with no server, no + * clock and no configuration, which is the acceptance test the whole repo is + * held to. + * + * The ambient term is high by outdoor standards and has to be: a directional + * light and a hemisphere between them put nothing at all on the underside of a + * desk, and with the ceilings off there is no surface left to bounce from. + */ +export function officeInterior(): LightingState { + return { + // Steeply down and a little to one side. A low interior sun rakes across the + // floor and throws desk shadows halfway across the room, which reads as late + // afternoon through a window that has not been built yet. + sun: { direction: [0.32, 0.89, 0.32], color: 0xfff4e6, intensity: 1.15 }, + hemisphere: { sky: 0xf3f6f9, ground: 0x70737a, intensity: 1.45 }, + ambient: { color: 0xffffff, intensity: 0.42 }, + sky: null, + fog: null, + }; +} + +/** + * Whether two segments properly cross in plan. + * + * The same test `Plan` uses on outlines, which does not export it — six lines + * duplicated rather than a query added to `Plan` for something that is a fact + * about two segments and not about an office. + */ +function segmentsCross(a1: Point2, a2: Point2, b1: Point2, b2: Point2): boolean { + const d1 = cross(a1, a2, b1); + const d2 = cross(a1, a2, b2); + const d3 = cross(b1, b2, a1); + const d4 = cross(b1, b2, a2); + return d1 * d2 < 0 && d3 * d4 < 0; +} + +function cross(o: Point2, a: Point2, b: Point2): number { + return (a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x); +} diff --git a/src/interiors/plan.ts b/src/interiors/plan.ts new file mode 100644 index 0000000..e044c8c --- /dev/null +++ b/src/interiors/plan.ts @@ -0,0 +1,1242 @@ +/** + * An `Office` resolved into something the renderer and the walk controller can + * ask questions of: wall runs, collision segments, seats, props, zones and + * bounds. + * + * This is `World`'s opposite number, and it keeps the same discipline: build + * everything once in the constructor, then answer questions. An office pack is + * static data — nothing in it changes between frames — so anything that can be + * computed at load time should be, and every consumer should be reading the same + * answer rather than deriving its own. + * + * Like `World`, and unlike everything downstream of it, **`Plan` imports no + * three.js**. Its output is plain numbers and plain records. That keeps the + * splitting pass testable without a WebGL context, and it keeps the office + * contract (`types.ts`) and its resolution (this file) on the same side of the + * line from the mesh library that consumes them. + * + * ### The wall pass is the point of this file + * + * CONTRACT.md §2 chose an explicit wall list with 1-D openings over walls + * inferred from room edges, and the reason is here: splitting a wall around its + * openings has to happen anyway to draw the solid parts, and the *same* + * decomposition says where a walker can and cannot go. Two products, one pass, + * no second list to keep in sync. A lintel over a door is drawn and is not + * collided with; the apron under a window is drawn and *is* collided with; and + * neither of those facts is stated twice. + * + * ### Everything here is in office-world metres + * + * The authored contract measures heights "above this level's floor", because + * that is how a person describes a wall. A `LevelPlan` has already added + * `Level.elevation` to every `y`, `bottom`, `top`, `sill` and `head` it exposes. + * Mixing the two conventions in one build product is how a mezzanine ends up in + * the basement, so the conversion happens exactly once, here, and what comes out + * can be dropped into a single scene at the origin. + * + * ### It drops rather than throws + * + * A self-hoster authoring their first office will write a polygon that crosses + * itself, a door that runs off the end of its wall, and two things with the same + * id. Every one of those is reported through `problems` — and, in dev, through + * `console.warn` — and then the offending item is dropped or repaired. An + * exception with no context in the middle of a 180-prop pack tells the author + * nothing and loses the other 179. + */ + +import type { + AssetId, + DeskBank, + Level, + Office, + Opening, + OpeningKind, + Outline, + Point2, + Prop, + Room, + Seat, + SeatPose, + SurfaceId, + Viewpoint, + Wall, + Yaw, +} from "./types.ts"; + +/** Documented in `types.ts` as the fallback when a level names no thickness. */ +const DEFAULT_WALL_THICKNESS = 0.12; + +/** + * Desk centre to seat centre when a `DeskBank` does not say. A 0.75 m desk plus + * a chair pulled most of the way under it; far enough that the chair reads as a + * separate object, close enough that nobody looks like they are reaching. + */ +const DEFAULT_SEAT_OFFSET = 0.6; + +/** + * How much of a hole a walker needs before it counts as a way through, in metres + * above the floor. A door clears it, a window does not, and a serving hatch does + * not — which is the answer you want in all three cases without the format + * having to name any of them. + */ +const DEFAULT_WALK_HEIGHT = 1.1; + +/** Below this, two floats are the same number and a length is zero. */ +const EPS = 1e-6; + +// ---- Build products ------------------------------------------------------- + +/** An axis-aligned extent on the floor plane, in metres. */ +export interface Bounds { + minX: number; + maxX: number; + minZ: number; + maxZ: number; + width: number; + depth: number; + center: Point2; +} + +/** + * One solid piece of wall: a box `length` long, `thickness` deep and + * `top - bottom` tall, standing on the floor plane at `center` and turned by + * `yaw`. + * + * These map one-to-one onto `parts.wallRun(length, top - bottom, thickness)` + * placed at `{ x: center.x, y: bottom, z: center.z, yaw }` — that part runs + * along local +X with its base at y = 0, which is why the run reports a centre + * and a base rather than two endpoints. + */ +export interface WallRun { + wallId: string; + center: Point2; + length: number; + thickness: number; + /** Office-world metres, level elevation included. */ + bottom: number; + top: number; + yaw: Yaw; + surface: SurfaceId | undefined; + /** Distance along the wall from its `from` end, for anyone who wants it back in 1-D. */ + start: number; + end: number; + /** + * What this run is. A `solid` run spans the wall's full height; a `lintel` + * sits over an opening and an `apron` under one, and both name the opening so + * a reveal or a frame can be drawn against the same numbers. + */ + role: "solid" | "lintel" | "apron"; + openingId?: string; +} + +/** + * A hole in a wall, resolved out of its 1-D interval into a rectangle standing + * in the world. + * + * The opening keeps its own record because the leaf, frame or glazing is drawn + * by the opening rather than by the runs around it — see the note on `Opening` + * in `types.ts` about why there is no `tera:shell.door`. + */ +export interface ResolvedOpening { + /** `"${wallId}#0"`. Openings are not authored with ids; a mesh needs one. */ + id: string; + wallId: string; + kind: OpeningKind; + /** Centre of the hole on the floor plane. */ + center: Point2; + width: number; + /** Office-world metres, level elevation included. */ + sill: number; + head: number; + thickness: number; + yaw: Yaw; + start: number; + end: number; + /** True when a walker can pass through this hole. See `DEFAULT_WALK_HEIGHT`. */ + passable: boolean; +} + +/** + * A stretch of wall a walker cannot cross, as a centreline segment. + * + * Adjacent blocking stretches are merged, so a wall of five windows is one + * segment rather than eleven. The consumer inflates by `thickness / 2` plus + * whatever radius it gives the walker; `Plan.blocked()` does exactly that and is + * the reason this is a segment and not a box. + */ +export interface Segment { + wallId: string; + from: Point2; + to: Point2; + thickness: number; +} + +/** + * One prop, placed. The build input the mesh layer walks. + * + * This is CONTRACT.md §2's `Placement[]`, renamed. `src/assets/parts.ts` already + * exports a `Placement` meaning a part inside one asset's mesh, and exporting + * that name twice meaning two different things is the exact failure the contract + * exists to stop. This one is an asset instance in an office; that one is a box + * inside an asset. + */ +export interface PropPlacement { + id: string; + kind: AssetId; + levelId: string; + /** Office-world metres. `y` is the base of the prop, level elevation included. */ + position: { x: number; y: number; z: number }; + rotation: Yaw; + /** Always three numbers here, whatever the pack wrote. */ + scale: [number, number, number]; + colorKey: string | undefined; + /** The seat this prop belongs to, if it was bound to one. Purely an address. */ + seat: string | undefined; + /** Set when this prop came out of a `DeskBank` rather than being authored. */ + source: { bankId: string; station: number; part: "desk" | "chair" } | undefined; +} + +/** A seat, resolved onto a level. Still an address, now with a floor under it. */ +export interface ResolvedSeat { + id: string; + levelId: string; + position: Point2; + /** Office-world metres: the floor the seat stands on. */ + y: number; + facing: Yaw; + pose: SeatPose; + source: { bankId: string; station: number } | undefined; +} + +/** A room's floor slab, cleaned, re-wound and measured. */ +export interface ResolvedRoom { + id: string; + name: string; + levelId: string; + /** Counter-clockwise in plan view, no repeated closing point. */ + outline: Outline; + floor: SurfaceId; + /** `null` is an atrium: the pack said so explicitly. */ + ceiling: { height: number; surface: SurfaceId | undefined } | null; + /** Office-world metres: the top of the floor slab. */ + y: number; + bounds: Bounds; + /** Square metres. Always positive. */ + area: number; + centroid: Point2; +} + +/** A zone, cleaned and measured. Still means nothing to the engine. */ +export interface ResolvedZone { + id: string; + name: string; + levelId: string; + outline: Outline; + colorKey: string | undefined; + y: number; + bounds: Bounds; + area: number; + centroid: Point2; +} + +/** One storey, resolved. Everything in office-world metres. */ +export interface LevelPlan { + id: string; + name: string; + index: number; + /** Office-world metres: this storey's floor. */ + floorY: number; + wallHeight: number; + wallThickness: number; + wallSurface: SurfaceId | undefined; + rooms: readonly ResolvedRoom[]; + runs: readonly WallRun[]; + openings: readonly ResolvedOpening[]; + props: readonly PropPlacement[]; + seats: readonly ResolvedSeat[]; + zones: readonly ResolvedZone[]; + collision: readonly Segment[]; + bounds: Bounds; +} + +// ---- Problems ------------------------------------------------------------- + +/** + * One thing the pack got wrong, and what was done about it. + * + * Kept as data rather than only as a log line so that a pack can be checked in a + * test, or by a self-hoster's own tooling, without capturing `console`. + */ +export interface PlanProblem { + /** Where it was, as a path into the pack: `"levels[0].walls[3].openings[1]"`. */ + where: string; + message: string; + /** `dropped` means it is not in the build product; `repaired` means it was fixed in place. */ + action: "dropped" | "repaired"; +} + +/** 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; + +export interface PlanOptions { + /** + * How high a hole must clear for a walker to pass through it, in metres. + * Defaults to 1.1. + */ + walkHeight?: number; + /** + * Whether to `console.warn` each problem. Defaults to true under a dev build + * and false otherwise — an author wants to hear about the door that runs off + * the end of its wall, a visitor does not. `plan.problems` is populated either + * way. + */ + warn?: boolean; +} + +/** + * Vite replaces `import.meta.env.DEV` at build time; Node leaves `env` + * undefined, which is the answer we want there anyway. Read defensively so this + * module stays importable from a plain test runner. + */ +function devBuild(): boolean { + try { + return Boolean((import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV); + } catch { + return false; + } +} + +// ---- Plan ----------------------------------------------------------------- + +export class Plan { + readonly office: Office; + readonly levels: readonly LevelPlan[]; + /** Only those whose `levelId` resolves. `viewpoints[0]` is still the arrival pose. */ + readonly viewpoints: readonly Viewpoint[]; + /** Everything the validation pass dropped or repaired, in build order. */ + readonly problems: readonly PlanProblem[]; + /** The whole office, every level unioned. */ + readonly bounds: Bounds; + + private readonly walkHeight: number; + private readonly levelsById = new Map(); + private readonly seatsById = new Map(); + private readonly propsById = new Map(); + private readonly viewpointsById = new Map(); + + constructor(office: Office, options: PlanOptions = {}) { + this.office = office; + this.walkHeight = options.walkHeight ?? DEFAULT_WALK_HEIGHT; + + const problems: PlanProblem[] = []; + const warn = options.warn ?? devBuild(); + const report: Report = (where, message, action) => { + problems.push({ where, message, action }); + if (warn) console.warn(`[office ${office.id}] ${where}: ${message} (${action})`); + }; + + // Ids are checked per kind and across the whole office, not per level. A + // seat id is what a `Presence` binds to and a prop id is what an occupancy + // layer dims, so both have to mean one thing in the building, not one thing + // per storey. + const seen = { + level: new Set(), + room: new Set(), + wall: new Set(), + prop: new Set(), + seat: new Set(), + zone: new Set(), + viewpoint: 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. + const levels: LevelPlan[] = []; + (office.levels ?? []).forEach((level, li) => { + const where = `levels[${li}]`; + if (seen.level.has(level.id)) { + report(where, `duplicate level id "${level.id}"`, "dropped"); + return; + } + seen.level.add(level.id); + const built = this.buildLevel(level, levels.length, where, seen, report); + levels.push(built); + this.levelsById.set(built.id, built); + for (const seat of built.seats) this.seatsById.set(seat.id, seat); + for (const prop of built.props) this.propsById.set(prop.id, prop); + }); + + // Prop-to-seat bindings are resolved last, because a prop on level 1 may + // legitimately name a seat declared on level 2 and the check would be a + // false positive if it ran during the level pass. + for (const level of levels) { + for (const prop of level.props) { + if (prop.seat !== undefined && !this.seatsById.has(prop.seat)) { + report( + `levels[${level.index}].props "${prop.id}"`, + `bound to unknown seat "${prop.seat}"`, + "repaired", + ); + prop.seat = undefined; + } + } + } + + const viewpoints: Viewpoint[] = []; + (office.viewpoints ?? []).forEach((viewpoint, vi) => { + const where = `viewpoints[${vi}]`; + if (seen.viewpoint.has(viewpoint.id)) { + report(where, `duplicate viewpoint id "${viewpoint.id}"`, "dropped"); + return; + } + if (!this.levelsById.has(viewpoint.levelId)) { + report(where, `on unknown level "${viewpoint.levelId}"`, "dropped"); + return; + } + seen.viewpoint.add(viewpoint.id); + viewpoints.push(viewpoint); + this.viewpointsById.set(viewpoint.id, viewpoint); + }); + + const extent = new Extent(); + for (const level of levels) extent.addBounds(level.bounds); + + this.levels = levels; + this.viewpoints = viewpoints; + this.problems = problems; + this.bounds = extent.finish(); + } + + // ---- Queries ------------------------------------------------------------ + + level(id: string): LevelPlan | null { + return this.levelsById.get(id) ?? null; + } + + seat(id: string): ResolvedSeat | null { + return this.seatsById.get(id) ?? null; + } + + prop(id: string): PropPlacement | null { + return this.propsById.get(id) ?? null; + } + + viewpoint(id: string): Viewpoint | null { + return this.viewpointsById.get(id) ?? null; + } + + /** Where you arrive. `viewpoints[0]`, or nothing if the pack declared none. */ + arrival(): Viewpoint | null { + return this.viewpoints[0] ?? null; + } + + /** Every seat in the building, in declaration order, banks expanded. */ + allSeats(): ResolvedSeat[] { + return [...this.seatsById.values()]; + } + + /** + * Which room a point is in. + * + * Later rooms win, because a pack lays the open floor down first and then puts + * the meeting rooms on top of it — the same order it would be drawn in, and + * the same order a reader of the source expects. + */ + roomAt(levelId: string, point: Point2): ResolvedRoom | null { + const level = this.levelsById.get(levelId); + if (!level) return null; + for (let i = level.rooms.length - 1; i >= 0; i--) { + const room = level.rooms[i]; + if (!room) continue; + if (point.x < room.bounds.minX || point.x > room.bounds.maxX) continue; + if (point.z < room.bounds.minZ || point.z > room.bounds.maxZ) continue; + if (pointInOutline(point, room.outline)) return room; + } + return null; + } + + collisionAt(levelId: string): readonly Segment[] { + return this.levelsById.get(levelId)?.collision ?? []; + } + + /** + * Whether a walker of `radius` moving from one point to another crosses a + * wall on this level. + * + * A capsule-versus-segment test rather than a point-in-box one, so a fast + * walker cannot tunnel through a 0.12 m partition between two frames. It lives + * here rather than in the controller because inflating the centreline by half + * the wall thickness is the sort of detail that gets forgotten in one of the + * three places that needs it. + */ + blocked(levelId: string, from: Point2, to: Point2, radius = 0.3): boolean { + for (const seg of this.collisionAt(levelId)) { + const clearance = radius + seg.thickness / 2; + if (segmentDistance(from, to, seg.from, seg.to) < clearance) return true; + } + return false; + } + + // ---- Build -------------------------------------------------------------- + + private buildLevel( + level: Level, + index: number, + where: string, + seen: Record<"room" | "wall" | "prop" | "seat" | "zone", Set>, + report: Report, + ): LevelPlan { + const floorY = level.elevation; + const wallThickness = level.wallThickness ?? DEFAULT_WALL_THICKNESS; + const floorplan = level.floorplan; + const extent = new Extent(); + + const rooms: ResolvedRoom[] = []; + (floorplan.rooms ?? []).forEach((room, ri) => { + const at = `${where}.rooms[${ri}]`; + if (seen.room.has(room.id)) { + report(at, `duplicate room id "${room.id}"`, "dropped"); + return; + } + const outline = cleanOutline(room.outline, at, report); + if (!outline) return; + seen.room.add(room.id); + const bounds = outlineBounds(outline); + extent.addBounds(bounds); + rooms.push({ + id: room.id, + name: room.name, + levelId: level.id, + outline, + floor: room.floor, + ceiling: resolveCeiling(room, level, floorY), + y: floorY, + bounds, + area: Math.abs(shoelace(outline)) / 2, + centroid: centroidOf(outline), + }); + }); + + const runs: WallRun[] = []; + const openings: ResolvedOpening[] = []; + const collision: Segment[] = []; + (floorplan.walls ?? []).forEach((wall, wi) => { + const at = `${where}.walls[${wi}]`; + if (seen.wall.has(wall.id)) { + report(at, `duplicate wall id "${wall.id}"`, "dropped"); + return; + } + seen.wall.add(wall.id); + const split = this.splitWall(wall, level, wallThickness, floorY, at, report); + if (!split) return; + runs.push(...split.runs); + openings.push(...split.openings); + collision.push(...split.collision); + extent.add(wall.from); + extent.add(wall.to); + }); + + // Desk banks expand before authored props and seats so that an authored seat + // colliding with a generated one is reported against the hand-written line, + // which is the one whose author can do something about it. + const props: PropPlacement[] = []; + const seats: ResolvedSeat[] = []; + (floorplan.deskBanks ?? []).forEach((bank, bi) => { + const at = `${where}.deskBanks[${bi}]`; + this.expandBank(bank, level, floorY, at, seen, report, props, seats); + }); + + (floorplan.props ?? []).forEach((prop, pi) => { + const at = `${where}.props[${pi}]`; + if (seen.prop.has(prop.id)) { + report(at, `duplicate prop id "${prop.id}"`, "dropped"); + return; + } + seen.prop.add(prop.id); + props.push(placeProp(prop, level.id, floorY)); + }); + + (floorplan.seats ?? []).forEach((seat, si) => { + const at = `${where}.seats[${si}]`; + if (seen.seat.has(seat.id)) { + report(at, `duplicate seat id "${seat.id}"`, "dropped"); + return; + } + seen.seat.add(seat.id); + seats.push(placeSeat(seat, level.id, floorY)); + }); + + const zones: ResolvedZone[] = []; + (floorplan.zones ?? []).forEach((zone, zi) => { + const at = `${where}.zones[${zi}]`; + if (seen.zone.has(zone.id)) { + report(at, `duplicate zone id "${zone.id}"`, "dropped"); + return; + } + const outline = cleanOutline(zone.outline, at, report); + if (!outline) return; + seen.zone.add(zone.id); + const bounds = outlineBounds(outline); + extent.addBounds(bounds); + zones.push({ + id: zone.id, + name: zone.name, + levelId: level.id, + outline, + colorKey: zone.colorKey, + y: floorY, + bounds, + area: Math.abs(shoelace(outline)) / 2, + centroid: centroidOf(outline), + }); + }); + + // 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. + for (const prop of props) extent.add(prop.position); + for (const seat of seats) extent.add(seat.position); + + return { + id: level.id, + name: level.name, + index, + floorY, + wallHeight: level.wallHeight, + wallThickness, + wallSurface: level.wallSurface, + rooms, + runs, + openings, + props, + seats, + zones, + collision, + bounds: extent.finish(), + }; + } + + /** + * The pass CONTRACT.md §2 is built around: one wall, its openings sorted and + * validated, decomposed into intervals along its length. + * + * Each interval is either solid — one full-height run, and it blocks — or a + * hole, which contributes an apron below and a lintel above, and blocks only + * if the hole does not clear `walkHeight` from the floor. Blocking intervals + * are merged as they are walked, so a wall of five windows produces one + * collision segment rather than eleven. + */ + private splitWall( + wall: Wall, + level: Level, + levelThickness: number, + floorY: number, + where: string, + report: Report, + ): { runs: WallRun[]; openings: ResolvedOpening[]; collision: Segment[] } | null { + const dx = wall.to.x - wall.from.x; + const dz = wall.to.z - wall.from.z; + const length = Math.hypot(dx, dz); + if (length < EPS) { + report(where, `wall "${wall.id}" has zero length`, "dropped"); + return null; + } + + const ux = dx / length; + const uz = dz / length; + // A run's mesh lies along its local +X, which for yaw φ points at + // (cos φ, -sin φ) — three.js's rotation about +Y, as `Yaw` promises. The + // `+ 0` turns IEEE's negative zero back into zero: an east-west wall + // otherwise reports a yaw of `-0`, which renders identically and looks like + // a bug in every diff and every snapshot. + const yaw = Math.atan2(-uz, ux) + 0; + const height = wall.height ?? level.wallHeight; + const thickness = wall.thickness ?? levelThickness; + const surface = wall.surface ?? level.wallSurface; + const at = (u: number): Point2 => ({ x: wall.from.x + ux * u, z: wall.from.z + uz * u }); + + const holes = acceptOpenings(wall, length, height, where, report); + + const runs: WallRun[] = []; + const openings: ResolvedOpening[] = []; + const collision: Segment[] = []; + let blockStart: number | null = null; + + const pushRun = ( + u0: number, + u1: number, + bottom: number, + top: number, + role: WallRun["role"], + openingId?: string, + ): void => { + if (u1 - u0 < EPS || top - bottom < EPS) return; + runs.push({ + wallId: wall.id, + center: at((u0 + u1) / 2), + length: u1 - u0, + thickness, + bottom: floorY + bottom, + top: floorY + top, + yaw, + surface, + start: u0, + end: u1, + role, + openingId, + }); + }; + + /** + * Extend or close the run of blocked wall. `u0` is where the interval just + * decided about begins, which is also where an open interval ends — so a + * stretch of solid wall, a window and more solid wall arrives as one segment + * rather than three. + */ + const block = (u0: number, blocks: boolean): void => { + if (blocks) { + if (blockStart === null) blockStart = u0; + return; + } + const from = blockStart; + if (from !== null) { + collision.push({ wallId: wall.id, from: at(from), to: at(u0), thickness }); + blockStart = null; + } + }; + + let cursor = 0; + for (const hole of holes) { + pushRun(cursor, hole.start, 0, height, "solid"); + block(cursor, hole.start - cursor > EPS); + + // Numbered by the opening's position in the *authored* list, so that + // dropping a bad opening does not renumber its siblings and move whatever + // a mesh cached against the id. + const id = `${wall.id}#${hole.index}`; + pushRun(hole.start, hole.end, 0, hole.sill, "apron", id); + pushRun(hole.start, hole.end, hole.head, height, "lintel", id); + + const passable = hole.sill <= EPS && hole.head >= this.walkHeight - EPS; + block(hole.start, !passable); + + openings.push({ + id, + wallId: wall.id, + kind: hole.kind, + center: at((hole.start + hole.end) / 2), + width: hole.end - hole.start, + sill: floorY + hole.sill, + head: floorY + hole.head, + thickness, + yaw, + start: hole.start, + end: hole.end, + passable, + }); + cursor = hole.end; + } + + pushRun(cursor, length, 0, height, "solid"); + block(cursor, length - cursor > EPS); + // Close whatever was still blocking when the wall ran out. + const tail = blockStart; + if (tail !== null) { + collision.push({ wallId: wall.id, from: at(tail), to: at(length), thickness }); + } + + return { runs, openings, collision }; + } + + /** + * A `DeskBank` into props and seats. + * + * The generated ids are contractual — `types.ts` promises a pack author that + * the fourth station of bank `eng` is seat `eng-04`, because a `Presence` + * binds to that string and is written by hand somewhere else entirely. Nothing + * here may renumber. + */ + private expandBank( + bank: DeskBank, + level: Level, + floorY: number, + where: string, + seen: Record<"prop" | "seat", Set>, + report: Report, + props: PropPlacement[], + seats: ResolvedSeat[], + ): void { + const columns = Math.floor(bank.columns); + const rows = Math.floor(bank.rows); + if (!(columns >= 1) || !(rows >= 1)) { + report(where, `bank "${bank.id}" has ${bank.columns} x ${bank.rows} stations`, "dropped"); + return; + } + if (!(bank.pitch > 0)) { + report(where, `bank "${bank.id}" has a pitch of ${bank.pitch} m`, "dropped"); + return; + } + + const rowPitch = bank.rowPitch ?? bank.pitch; + const seatOffset = bank.seatOffset ?? DEFAULT_SEAT_OFFSET; + const pose = bank.pose ?? "sit"; + const prefix = bank.seatPrefix ?? bank.id; + const cos = Math.cos(bank.rotation); + const sin = Math.sin(bank.rotation); + + // Named arguments, because desk and chair differ in three of six fields and + // a positional call would eventually put a chair where a desk goes. + const pushProp = (p: { + id: string; + kind: AssetId; + at: Point2; + rotation: Yaw; + seat: string; + part: "desk" | "chair"; + station: number; + }): void => { + if (seen.prop.has(p.id)) { + report(where, `station ${p.station} generates the taken prop id "${p.id}"`, "dropped"); + return; + } + seen.prop.add(p.id); + props.push({ + id: p.id, + kind: p.kind, + levelId: level.id, + position: { x: p.at.x, y: floorY, z: p.at.z }, + rotation: p.rotation, + scale: [1, 1, 1], + colorKey: undefined, + seat: p.seat, + source: { bankId: bank.id, station: p.station, part: p.part }, + }); + }; + + for (let r = 0; r < rows; r++) { + // With `facingRows`, the first row of each pair turns to look back down + // the bank, which puts its desk between the two occupants and its chair on + // the outside — a bench, rather than two rows of people staring at the + // back of each other's heads. + const flipped = bank.facingRows === true && r % 2 === 0; + const facing = bank.rotation + (flipped ? Math.PI : 0); + // Where a seat sits relative to its desk: behind it, so that the occupant + // looks out over the desktop. That is the desk's local +Z, which for yaw φ + // points at (sin φ, cos φ). + const seatX = Math.sin(facing) * seatOffset; + const seatZ = Math.cos(facing) * seatOffset; + + for (let c = 0; c < columns; c++) { + const station = r * columns + c + 1; + const n = String(station).padStart(2, "0"); + const u = c * bank.pitch; + const v = r * rowPitch; + const x = bank.origin.x + u * cos + v * sin; + const z = bank.origin.z - u * sin + v * cos; + + const seatId = `${prefix}-${n}`; + if (seen.seat.has(seatId)) { + report(where, `station ${station} generates the taken seat id "${seatId}"`, "dropped"); + continue; + } + seen.seat.add(seatId); + seats.push({ + id: seatId, + levelId: level.id, + position: { x: x + seatX, z: z + seatZ }, + y: floorY, + facing, + pose, + source: { bankId: bank.id, station }, + }); + + pushProp({ + id: `${bank.id}-desk-${n}`, + kind: bank.desk, + at: { x, z }, + rotation: facing, + seat: seatId, + part: "desk", + station, + }); + + if (bank.chair !== undefined) { + pushProp({ + id: `${bank.id}-chair-${n}`, + kind: bank.chair, + at: { x: x + seatX, z: z + seatZ }, + rotation: facing, + seat: seatId, + part: "chair", + station, + }); + } + } + } + } +} + +// ---- Placement helpers ---------------------------------------------------- + +function placeProp(prop: Prop, levelId: string, floorY: number): PropPlacement { + const s = prop.scale ?? 1; + const scale: [number, number, number] = + typeof s === "number" ? [s, s, s] : [s[0], s[1], s[2]]; + return { + id: prop.id, + kind: prop.kind, + levelId, + position: { x: prop.position.x, y: floorY + (prop.elevation ?? 0), z: prop.position.z }, + rotation: prop.rotation, + scale, + colorKey: prop.colorKey, + seat: prop.seat, + source: undefined, + }; +} + +function placeSeat(seat: Seat, levelId: string, floorY: number): ResolvedSeat { + return { + id: seat.id, + levelId, + position: { x: seat.position.x, z: seat.position.z }, + y: floorY, + facing: seat.facing, + pose: seat.pose, + source: undefined, + }; +} + +/** + * A room's ceiling. `undefined` means the level's default, explicit `null` means + * there is not one. + * + * The surface is allowed to stay undefined: a level has a default *wall* finish + * but no default ceiling finish, and inventing one here would be worse than + * letting the material registry fall back to its own `ceilingTile` role — which + * is where that decision belongs. + */ +function resolveCeiling(room: Room, level: Level, floorY: number): ResolvedRoom["ceiling"] { + if (room.ceiling === null) return null; + return { + height: floorY + (room.ceiling?.height ?? level.wallHeight), + surface: room.ceiling?.surface, + }; +} + +/** An opening that survived validation. `index` is its position in the authored list. */ +interface AcceptedOpening { + index: number; + kind: OpeningKind; + start: number; + end: number; + sill: number; + head: number; +} + +/** + * The openings that survive, sorted along the wall and guaranteed not to + * overlap — which is what lets the splitting pass be a single left-to-right + * walk rather than an interval-tree problem. + */ +function acceptOpenings( + wall: Wall, + length: number, + height: number, + where: string, + report: Report, +): AcceptedOpening[] { + const indexed = (wall.openings ?? []).map((opening, index) => ({ opening, index })); + indexed.sort((a, b) => a.opening.start - b.opening.start); + + const kept: AcceptedOpening[] = []; + for (const { opening, index } of indexed) { + const at = `${where}.openings[${index}]`; + const accepted = normaliseOpening(opening, index, length, height, at, report); + if (!accepted) continue; + // Sorted by `start`, so only the previous survivor can be in the way. + const previous = kept[kept.length - 1]; + if (previous && accepted.start < previous.end - EPS) { + report(at, `overlaps the opening starting at ${previous.start.toFixed(2)} m`, "dropped"); + continue; + } + kept.push(accepted); + } + return kept; +} + +/** + * One opening, checked against its wall. + * + * The horizontal errors are fatal to the opening — a hole that runs off the end + * of a wall has no sensible repair, and clamping it would silently move a door. + * The vertical ones are clamped, because a head above the wall is unambiguously + * "all the way up" and a negative sill is unambiguously "on the floor". + */ +function normaliseOpening( + opening: Opening, + index: number, + length: number, + height: number, + where: string, + report: Report, +): AcceptedOpening | null { + if (!(opening.width > EPS)) { + report(where, `width is ${opening.width} m`, "dropped"); + return null; + } + if (opening.start < -EPS) { + report(where, `starts ${(-opening.start).toFixed(2)} m before its wall`, "dropped"); + return null; + } + const end = opening.start + opening.width; + if (end > length + EPS) { + report( + where, + `runs ${(end - length).toFixed(2)} m past the end of a ${length.toFixed(2)} m wall`, + "dropped", + ); + return null; + } + + let sill = opening.sill; + let head = opening.head; + if (sill < 0) { + report(where, `sill is ${sill} m; clamped to the floor`, "repaired"); + sill = 0; + } + if (head > height + EPS) { + report(where, `head is above a ${height.toFixed(2)} m wall; clamped`, "repaired"); + head = height; + } + if (head - sill < EPS) { + report(where, `head ${head} m is not above sill ${sill} m`, "dropped"); + return null; + } + + return { index, kind: opening.kind, start: opening.start, end, sill, head }; +} + +// ---- Polygons ------------------------------------------------------------- + +/** + * A usable outline, or nothing. + * + * Three repairs and two rejections. Repeated points collapse — including a + * repeated *first* point, which is the single most common thing a new author + * writes, because every other polygon format they have met wanted the ring + * closed by hand. Reversed winding is re-wound silently, as `types.ts` promises. + * Fewer than three distinct points, or an outline that crosses itself, is + * dropped: a self-intersecting floor slab triangulates into a black bowtie, and + * every downstream area, centroid and point-in-polygon answer about it is + * meaningless. + */ +function cleanOutline( + outline: Outline, + where: string, + report: Report, +): Outline | null { + const points: Point2[] = []; + for (const p of outline) { + if (!Number.isFinite(p.x) || !Number.isFinite(p.z)) { + report(where, `outline has a non-finite point`, "dropped"); + return null; + } + const last = points[points.length - 1]; + if (last && Math.abs(last.x - p.x) < EPS && Math.abs(last.z - p.z) < EPS) continue; + points.push({ x: p.x, z: p.z }); + } + const first = points[0]; + const last = points[points.length - 1]; + if (points.length > 1 && first && last && Math.abs(first.x - last.x) < EPS && Math.abs(first.z - last.z) < EPS) { + report(where, "outline repeats its first point; outlines are implicitly closed", "repaired"); + points.pop(); + } + + if (points.length < 3) { + report(where, `outline has ${points.length} distinct points`, "dropped"); + return null; + } + + const area = shoelace(points); + if (Math.abs(area) / 2 < EPS) { + report(where, "outline encloses no area", "dropped"); + return null; + } + if (selfIntersects(points)) { + report(where, "outline crosses itself", "dropped"); + return null; + } + + // Positive shoelace over (x, z) is *clockwise* in plan view, because +Z runs + // down the page. Counter-clockwise in plan view is also what a floor slab + // needs to triangulate into +Y-facing triangles, which is why the convention + // is worth normalising rather than merely documenting. + if (area > 0) points.reverse(); + return points; +} + +function shoelace(outline: Outline): number { + let sum = 0; + for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) { + const a = outline[j]; + const b = outline[i]; + if (!a || !b) continue; + sum += a.x * b.z - b.x * a.z; + } + return sum; +} + +function centroidOf(outline: Outline): Point2 { + let cx = 0; + let cz = 0; + let twiceArea = 0; + for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) { + const a = outline[j]; + const b = outline[i]; + if (!a || !b) continue; + const cross = a.x * b.z - b.x * a.z; + twiceArea += cross; + cx += (a.x + b.x) * cross; + cz += (a.z + b.z) * cross; + } + if (Math.abs(twiceArea) < EPS) { + // Degenerate rings never reach here, but a caller with its own outline + // might; the vertex mean is at least inside the hull. + let mx = 0; + let mz = 0; + for (const p of outline) { + mx += p.x; + mz += p.z; + } + const n = Math.max(1, outline.length); + return { x: mx / n, z: mz / n }; + } + return { x: cx / (3 * twiceArea), z: cz / (3 * twiceArea) }; +} + +/** + * Whether any two non-adjacent edges cross. + * + * O(n²), and deliberately so: an office room is a dozen points, a sweep-line is + * fifty lines of code that would be wrong in the collinear cases, and this runs + * once at load. + */ +function selfIntersects(outline: Outline): boolean { + const n = outline.length; + for (let i = 0; i < n; i++) { + const a1 = outline[i]; + const a2 = outline[(i + 1) % n]; + if (!a1 || !a2) continue; + for (let j = i + 1; j < n; j++) { + // Skip the shared-vertex pairs: consecutive edges always touch, and the + // last edge always touches the first. + if (j === i || (j + 1) % n === i || (i + 1) % n === j) continue; + const b1 = outline[j]; + const b2 = outline[(j + 1) % n]; + if (!b1 || !b2) continue; + if (segmentsCross(a1, a2, b1, b2)) return true; + } + } + return false; +} + +function cross(o: Point2, a: Point2, b: Point2): number { + return (a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x); +} + +/** Proper crossing only. Touching endpoints and collinear overlap do not count. */ +function segmentsCross(a1: Point2, a2: Point2, b1: Point2, b2: Point2): boolean { + const d1 = cross(a1, a2, b1); + const d2 = cross(a1, a2, b2); + const d3 = cross(b1, b2, a1); + const d4 = cross(b1, b2, a2); + return d1 * d2 < 0 && d3 * d4 < 0; +} + +/** Ray casting, in the XZ plane. The same algorithm `World` uses on lat/lng. */ +function pointInOutline(point: Point2, outline: Outline): boolean { + let inside = false; + for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) { + const a = outline[i]; + const b = outline[j]; + if (!a || !b) continue; + if (a.z > point.z !== b.z > point.z) { + const x = ((b.x - a.x) * (point.z - a.z)) / (b.z - a.z) + a.x; + if (point.x < x) inside = !inside; + } + } + return inside; +} + +// ---- Distance ------------------------------------------------------------- + +function pointToSegment(p: Point2, a: Point2, b: Point2): number { + const dx = b.x - a.x; + const dz = b.z - a.z; + const lenSq = dx * dx + dz * dz; + let t = lenSq < EPS ? 0 : ((p.x - a.x) * dx + (p.z - a.z) * dz) / lenSq; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(p.x - (a.x + t * dx), p.z - (a.z + t * dz)); +} + +/** + * Closest approach between two segments in 2D. + * + * Crossing segments are zero apart; otherwise the minimum is attained at one of + * the four endpoints, which is a well-known property of convex sets and much + * less error-prone than solving the parametric system. + */ +function segmentDistance(a1: Point2, a2: Point2, b1: Point2, b2: Point2): number { + if (segmentsCross(a1, a2, b1, b2)) return 0; + return Math.min( + pointToSegment(a1, b1, b2), + pointToSegment(a2, b1, b2), + pointToSegment(b1, a1, a2), + pointToSegment(b2, a1, a2), + ); +} + +// ---- Extents -------------------------------------------------------------- + +class Extent { + private minX = Infinity; + private maxX = -Infinity; + private minZ = Infinity; + private maxZ = -Infinity; + + add(p: Point2): void { + if (p.x < this.minX) this.minX = p.x; + if (p.x > this.maxX) this.maxX = p.x; + if (p.z < this.minZ) this.minZ = p.z; + if (p.z > this.maxZ) this.maxZ = p.z; + } + + addBounds(b: Bounds): void { + this.add({ x: b.minX, z: b.minZ }); + this.add({ x: b.maxX, z: b.maxZ }); + } + + finish(): Bounds { + if (!Number.isFinite(this.minX)) { + return { minX: 0, maxX: 0, minZ: 0, maxZ: 0, width: 0, depth: 0, center: { x: 0, z: 0 } }; + } + return { + minX: this.minX, + maxX: this.maxX, + minZ: this.minZ, + maxZ: this.maxZ, + width: this.maxX - this.minX, + depth: this.maxZ - this.minZ, + center: { x: (this.minX + this.maxX) / 2, z: (this.minZ + this.maxZ) / 2 }, + }; + } +} + +function outlineBounds(outline: Outline): Bounds { + const extent = new Extent(); + for (const p of outline) extent.add(p); + return extent.finish(); +} diff --git a/src/interiors/presence.ts b/src/interiors/presence.ts new file mode 100644 index 0000000..66720ea --- /dev/null +++ b/src/interiors/presence.ts @@ -0,0 +1,246 @@ +/** + * People at seats. + * + * This is `markers.ts` one level in, and it is deliberately just as ignorant. It + * renders `Presence[]`, looks colours up by `colorKey` in a palette the caller + * supplies, and does not know that a presence is a person, that a colour means + * "in today", or that anybody works anywhere. That mapping belongs to the + * adapter in the consuming app. See ARCHITECTURE.md §3.3. + * + * ### Bound to a seat id, never to a coordinate + * + * A `Presence` carries `seatId` and no position, and this file is where that + * pays off: the office pack knows where `eng-04` is, a private API knows who is + * in it, and neither one has to know the other. Publishing the geometry and + * publishing the people are therefore two separate acts, which is what lets the + * first happen at all. A presence whose seat is not in the plan is dropped — + * there is nowhere to put it, and inventing a spot on the floor would quietly + * turn a private id into a public coordinate, which is the exact thing the split + * exists to prevent. + * + * ### Figures + * + * Two poses, one merged geometry each, one material per colour, one mesh per + * person. Low-poly on purpose: at fifty people that is fifty draw calls, which + * is the same order as the whole rest of the office, and a room reads as + * occupied from the silhouette long before anyone counts the polygons. + */ + +import * as THREE from "three"; +import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; +import { + parts as sharedParts, + placementMatrix, + type PartBin, + type Placement, +} from "../assets/parts.ts"; +import type { Plan } from "./plan.ts"; +import type { Presence, SeatPose } from "./types.ts"; + +/** + * Caller-supplied `colorKey` -> colour. The same shape as the city's + * `MarkerPalette` and named separately on purpose: they are two palettes with + * two sets of keys, and one type exported twice meaning two things is the + * failure CONTRACT.md exists to stop. + */ +export type PresencePalette = Record; + +const FALLBACK_COLOR = 0x9aa4ad; + +/** How far above the crown an HTML label should hang, in metres. */ +const LABEL_LIFT = 0.16; + +export interface PresenceLayer { + group: THREE.Group; + /** Raycast targets, for hover and click. One per person. */ + pickables: THREE.Object3D[]; + /** Scene-space head position per presence id, for the HTML label layer. */ + anchors: Map; + setPresence(people: Presence[]): void; + dispose(): void; +} + +export interface PresenceOptions { + parts?: PartBin; +} + +export function createPresenceLayer( + plan: Plan, + palette: PresencePalette, + options: PresenceOptions = {}, +): PresenceLayer { + const parts = options.parts ?? sharedParts; + const group = new THREE.Group(); + group.name = "presence"; + const pickables: THREE.Object3D[] = []; + const anchors = new Map(); + + // Built on first use rather than up front: an office with nobody in it should + // not pay for two figures it never draws. + const figures = new Map(); + const materials = new Map(); + const warned = new Set(); + + function figureFor(pose: SeatPose) { + const hit = figures.get(pose); + if (hit) return hit; + const made = buildFigure(parts, pose); + figures.set(pose, made); + return made; + } + + function materialFor(key: string): THREE.Material { + const hit = materials.get(key); + if (hit) return hit; + // Standard rather than Lambert, unlike the city's pins: an office is lit by + // a physically-shaded rig and a Lambert figure in it reads as a cardboard + // cutout. No map — a `colorKey` is a colour and nothing else. + const made = new THREE.MeshStandardMaterial({ + color: palette[key] ?? FALLBACK_COLOR, + roughness: 0.72, + metalness: 0.02, + }); + made.name = `presence:${key}`; + materials.set(key, made); + return made; + } + + function clear() { + for (const child of [...group.children]) group.remove(child); + pickables.length = 0; + anchors.clear(); + } + + function setPresence(people: Presence[]) { + clear(); + for (const person of people) { + const seat = plan.seat(person.seatId); + if (!seat) { + // Once per seat id, not once per update: a feed pointed at last + // quarter's floorplan should say so, not fill the console. + if (!warned.has(person.seatId)) { + warned.add(person.seatId); + console.warn(`[tera/interiors] presence "${person.id}" sits at unknown seat "${person.seatId}"`); + } + continue; + } + + const figure = figureFor(seat.pose); + const mesh = new THREE.Mesh(figure.geometry, materialFor(person.colorKey)); + mesh.name = `presence:${person.id}`; + mesh.position.set(seat.position.x, seat.y, seat.position.z); + // The seat's own facing, unconverted. A seat, the chair at it and the + // person in it all carry one rotation — see `Yaw` in `types.ts`. + mesh.rotation.y = seat.facing; + mesh.castShadow = true; + mesh.receiveShadow = true; + mesh.userData.presence = person; + group.add(mesh); + pickables.push(mesh); + anchors.set( + person.id, + new THREE.Vector3(seat.position.x, seat.y + figure.crown + LABEL_LIFT, seat.position.z), + ); + } + } + + return { + group, + pickables, + anchors, + setPresence, + dispose() { + clear(); + for (const figure of figures.values()) figure.geometry.dispose(); + figures.clear(); + for (const material of materials.values()) material.dispose(); + materials.clear(); + }, + }; +} + +// ---- Figures -------------------------------------------------------------- + +/** + * One person, as eleven primitives merged into one buffer. + * + * Built out of the shared `PartBin` rather than hand-rolled cylinders for the + * same reason every asset is: a limb that is the same `rod()` as a chair leg + * looks like it belongs in the same world. The proportions are a 1.72 m adult; + * the seated pose puts the hips at a 460 mm seat pan, which is the height + * `tera:seat.task-chair` is authored at. + * + * A figure faces **-Z** at yaw zero, which is what a seat's `facing` means and + * which is why an occupant, their chair and their desk all take one rotation. + * Knees therefore run toward -Z and the backrest is behind the figure at +Z. + * + * Returns the crown height as well, because the label anchor wants it and + * measuring a merged buffer afterwards to find out how tall a person is would be + * silly. + */ +function buildFigure( + parts: PartBin, + pose: SeatPose, +): { geometry: THREE.BufferGeometry; crown: number } { + const pieces: { geometry: THREE.BufferGeometry; place: Placement }[] = []; + const add = (geometry: THREE.BufferGeometry, place: Placement) => { + pieces.push({ geometry, place }); + }; + + const limb = parts.cylinder(8); + const torso = parts.cylinder(10); + const head = parts.sphere(10); + const hipSpan = 0.11; + const armSpan = 0.21; + + let crown: number; + if (pose === "stand") { + const hip = 0.86; + const shoulder = 1.4; + for (const side of [-1, 1]) { + add(limb, { x: side * hipSpan, y: 0, size: [0.15, hip, 0.16] }); + add(limb, { x: side * armSpan, y: shoulder - 0.46, size: [0.11, 0.46, 0.12] }); + } + add(torso, { y: hip, size: [0.42, shoulder - hip, 0.26] }); + add(limb, { y: shoulder, size: [0.11, 0.07, 0.11] }); + add(head, { y: shoulder + 0.05, size: [0.22, 0.24, 0.23] }); + crown = shoulder + 0.29; + } else { + // Seated: shins down in front of the seat, thighs forward at pan height, + // torso up from the hips. A thigh is a limb pitched by -pi/2, which sends + // the unit cylinder's +Y along -Z — out in front of the occupant. + const pan = 0.46; + const knee = 0.3; + const shoulder = pan + 0.5; + for (const side of [-1, 1]) { + add(limb, { x: side * hipSpan, y: 0, z: -knee, size: [0.14, pan - 0.02, 0.15] }); + add(limb, { + x: side * hipSpan, + y: pan - 0.02, + z: 0.02, + size: [0.15, knee + 0.06, 0.16], + pitch: -Math.PI / 2, + }); + add(limb, { x: side * armSpan, y: shoulder - 0.42, size: [0.1, 0.42, 0.11] }); + } + add(torso, { y: pan, size: [0.4, shoulder - pan, 0.26] }); + add(limb, { y: shoulder, size: [0.1, 0.06, 0.1] }); + add(head, { y: shoulder + 0.04, size: [0.21, 0.23, 0.22] }); + crown = shoulder + 0.27; + } + + const matrix = new THREE.Matrix4(); + const transformed = pieces.map((piece) => { + const clone = piece.geometry.clone(); + clone.applyMatrix4(placementMatrix(piece.place, matrix)); + return clone; + }); + // Every part here is an indexed primitive from the same bin, so the merge + // cannot hit the indexed/non-indexed refusal documented in `assets/office/ + // common.ts`. If a future figure grows a `roundedBox` shoulder, it will. + const merged = mergeGeometries(transformed, false); + for (const geometry of transformed) geometry.dispose(); + if (!merged) throw new Error("presence: could not merge the figure geometry"); + merged.name = `presence:${pose}`; + return { geometry: merged, crown }; +} diff --git a/src/interiors/shell.ts b/src/interiors/shell.ts new file mode 100644 index 0000000..301d697 --- /dev/null +++ b/src/interiors/shell.ts @@ -0,0 +1,449 @@ +/** + * The building itself: walls, floor slabs, ceilings, and the frames and glazing + * that line the holes in the walls. + * + * Everything here comes out of a `Plan` and nothing here reads an `Office`. The + * wall pass has already happened — a run is a solid piece of wall with its + * openings taken out of it, and its numbers are already in office-world metres + * with the level's elevation baked in — so this file is the arithmetic-free half + * of the job: place a `wallRun` part per run, triangulate a polygon per room, + * and merge. + * + * ### One mesh per wall, and why not fewer + * + * Merging every wall on a floor into one buffer would be one draw call instead + * of forty, and it is the wrong trade. The occlusion fade — the walls between + * the camera and what you are looking at going translucent, so the floorplan + * stays readable from outside — swaps a *material* on a whole object, and an + * object has to be one wall for that to mean anything. Forty extra draw calls is + * a rounding error next to the ~1,200 objects `parts.ts` was written to + * collapse; losing the ability to fade one wall is not. + * + * Each wall mesh therefore carries its 2-D segment and its top height in + * `userData.wall`, which is everything the fade needs to decide without walking + * geometry, and `setGhosted` is the swap. See CONTRACT.md §3, which is where + * `ghostOf()` landed on the material registry for exactly this. + * + * ### Ceilings are a group, not a clip plane + * + * Orbit mode hides them wholesale (`shell.ceilings.visible = false`) and that is + * the entire mechanism. No CSG, no clipping planes, no per-camera cutaway: a + * dollhouse is a room with its lid off, and a lid is a thing you can take off. + */ + +import * as THREE from "three"; +import type { MaterialRegistry, SurfaceRole } from "../assets/materials.ts"; +import { MeshBin, parts as sharedParts, type PartBin } from "../assets/parts.ts"; +import { TEXTURE_TILE_METRES } from "../assets/textures.ts"; +import type { LevelPlan, Plan, ResolvedOpening, ResolvedRoom, WallRun } from "./plan.ts"; +import type { Outline, Point2 } from "./types.ts"; + +/** Jamb and head width on an opening's lining, in metres. */ +const FRAME_WIDTH = 0.045; +/** How far a lining stands proud of its wall on each face, so it reads as a reveal. */ +const FRAME_PROUD = 0.008; +/** Depth of a window's sill board past the wall face, per side. */ +const SILL_PROUD = 0.03; + +export interface ShellOptions { + materials: MaterialRegistry; + /** Defaults to the shared bin, which is what everything else uses. */ + parts?: PartBin; + /** Which levels to build. Defaults to every level in the plan. */ + levelIds?: readonly string[]; + /** Line the openings with frames and glaze the windows. Defaults to true. */ + openings?: boolean; +} + +/** + * What a wall mesh knows about itself, stamped on `userData.wall`. + * + * The segment is the wall's centreline in plan, which is what an occlusion test + * wants: a camera-to-target ray crossing this line is looking through this wall. + * `top` is there so a knee-high partition is never faded — you can see over it, + * so it is not in the way. + */ +export interface WallInfo { + wallId: string; + levelId: string; + from: Point2; + to: Point2; + /** Office-world metres. */ + bottom: number; + top: number; + role: SurfaceRole; +} + +export interface Shell { + /** Everything below, as one object to add to a scene. */ + group: THREE.Group; + walls: THREE.Group; + floors: THREE.Group; + /** Hide this to get the dollhouse. */ + ceilings: THREE.Group; + /** Frames and glazing. Separate because glass must not cast a shadow. */ + openings: THREE.Group; + /** Every wall mesh, each carrying a `WallInfo` on `userData.wall`. */ + wallMeshes: readonly THREE.Mesh[]; + /** Swap one wall between its own finish and the translucent copy of it. */ + setGhosted(mesh: THREE.Mesh, ghosted: boolean): void; + dispose(): void; +} + +export function createShell(plan: Plan, options: ShellOptions): Shell { + const { materials } = options; + const parts = options.parts ?? sharedParts; + const drawOpenings = options.openings ?? true; + + const group = new THREE.Group(); + group.name = "shell"; + const walls = new THREE.Group(); + walls.name = "walls"; + const floors = new THREE.Group(); + floors.name = "floors"; + const ceilings = new THREE.Group(); + ceilings.name = "ceilings"; + const openings = new THREE.Group(); + openings.name = "openings"; + group.add(walls, floors, ceilings, openings); + + const wallMeshes: THREE.Mesh[] = []; + // Every geometry this file makes is a merge or a triangulation it owns + // outright, so disposal is a list rather than a traversal. The materials + // belong to the registry and are emphatically not ours to dispose. + const owned: THREE.BufferGeometry[] = []; + + const levels = options.levelIds + ? options.levelIds.map((id) => plan.level(id)).filter((l): l is LevelPlan => l !== null) + : plan.levels; + + // Frames and glazing are merged across the whole shell rather than per level: + // nothing ever fades or hides one on its own, so there is no reason to pay for + // the addressability. + const frameBin = new MeshBin(); + const glassBin = new MeshBin(); + + for (const level of levels) { + const holesByWall = groupBy(level.openings, (o) => o.wallId); + for (const [wallId, runs] of groupBy(level.runs, (r) => r.wallId)) { + buildWall(level.id, wallId, runs, holesByWall.get(wallId) ?? []); + } + for (const room of level.rooms) { + buildFloor(room); + buildCeiling(room); + } + if (drawOpenings) { + for (const opening of level.openings) lineOpening(opening); + } + } + + if (drawOpenings) { + for (const mesh of frameBin.build("openings").children) openings.add(mesh); + // Glass casts no shadow and receives none. A shadow-casting pane makes a + // window read as a solid panel, which is the one thing a window must not do. + for (const mesh of glassBin + .build("glazing", { castShadow: false, receiveShadow: false }) + .children) { + // Drawn after the opaque shell, since the material writes no depth and + // cannot sort itself against the room behind it. + mesh.renderOrder = 1; + openings.add(mesh); + } + for (const mesh of openings.children) { + const geo = (mesh as THREE.Mesh).geometry; + if (geo) owned.push(geo); + } + } + + function buildWall( + levelId: string, + wallId: string, + runs: WallRun[], + holes: readonly ResolvedOpening[], + ): void { + const first = runs[0]; + if (!first) return; + // Every run of a wall carries the same surface — it is resolved from the + // wall, or from the level, and never per run — so a wall is one material and + // therefore one mesh. The loop below still handles a group of them, because + // a `Shell` that silently drew three quarters of a wall would be worse than + // one that drew an unexpected extra mesh. + const role = materials.resolve(first.surface, "plaster"); + const material = materials.get(role); + + const bin = new MeshBin(); + let bottom = Infinity; + let top = -Infinity; + for (const run of runs) { + const height = run.top - run.bottom; + if (height <= 0) continue; + bin.add(parts.wallRun(run.length, height, run.thickness), material, { + x: run.center.x, + y: run.bottom, + z: run.center.z, + yaw: run.yaw, + }); + bottom = Math.min(bottom, run.bottom); + top = Math.max(top, run.top); + } + if (!Number.isFinite(top)) return; + + const info: WallInfo = { + wallId, + levelId, + ...extentOf([...runs, ...holes]), + bottom, + top, + role, + }; + + for (const child of [...bin.build(`wall:${wallId}`).children]) { + const mesh = child as THREE.Mesh; + mesh.userData.wall = info; + owned.push(mesh.geometry); + wallMeshes.push(mesh); + walls.add(mesh); + } + } + + function buildFloor(room: ResolvedRoom): void { + const geometry = slabGeometry(room.outline, room.y, true); + if (!geometry) return; + owned.push(geometry); + const mesh = new THREE.Mesh(geometry, materials.forSurface(room.floor, "carpet")); + mesh.name = `floor:${room.id}`; + mesh.receiveShadow = true; + // A floor slab casts nothing — there is nothing under it, and asking the + // shadow camera to render the largest polygon in the office for no result is + // a straight waste of its budget. + mesh.castShadow = false; + mesh.userData.roomId = room.id; + floors.add(mesh); + } + + function buildCeiling(room: ResolvedRoom): void { + const ceiling = room.ceiling; + if (!ceiling) return; + const geometry = slabGeometry(room.outline, ceiling.height, false); + if (!geometry) return; + owned.push(geometry); + const mesh = new THREE.Mesh(geometry, materials.forSurface(ceiling.surface, "ceilingTile")); + mesh.name = `ceiling:${room.id}`; + // A ceiling that casts a shadow puts the whole room in shade, because the + // rig's sun is above it. The room is lit by the rig, not through the slab. + mesh.castShadow = false; + mesh.receiveShadow = true; + mesh.userData.roomId = room.id; + ceilings.add(mesh); + } + + /** + * The lining of one hole: two jambs and a head, a sill board under a window, + * and a pane in it. + * + * A door gets a frame and no leaf. A leaf either stands open — and then it is + * a prop in the way of the dollhouse view — or stands shut, and then the room + * behind it is invisible from every angle. The collider already has the gap; + * the eye should have it too. + */ + function lineOpening(opening: ResolvedOpening): void { + const height = opening.head - opening.sill; + if (height <= 0 || opening.width <= 0) return; + + // Windows are trimmed in the glazing frame's finish, doors and arches in the + // door's. Same geometry, and the difference is the one a joiner would make. + const trim = materials.get(opening.kind === "window" ? "glazingFrame" : "doorLeaf"); + const depth = opening.thickness + FRAME_PROUD * 2; + const half = opening.width / 2; + + for (const side of [-1, 1]) { + const at = along(opening.center, opening.yaw, side * (half - FRAME_WIDTH / 2)); + frameBin.add(parts.box(), trim, { + x: at.x, + y: opening.sill, + z: at.z, + size: [FRAME_WIDTH, height, depth], + yaw: opening.yaw, + }); + } + frameBin.add(parts.box(), trim, { + x: opening.center.x, + y: opening.head - FRAME_WIDTH, + z: opening.center.z, + size: [opening.width, FRAME_WIDTH, depth], + yaw: opening.yaw, + }); + + if (opening.kind !== "window") return; + + frameBin.add(parts.box(), trim, { + x: opening.center.x, + y: opening.sill - 0.03, + z: opening.center.z, + size: [opening.width + FRAME_WIDTH, 0.03, opening.thickness + SILL_PROUD * 2], + yaw: opening.yaw, + }); + glassBin.add(parts.box(), materials.get("glazing"), { + x: opening.center.x, + y: opening.sill + 0.005, + z: opening.center.z, + size: [opening.width - FRAME_WIDTH, height - FRAME_WIDTH, 0.012], + yaw: opening.yaw, + }); + } + + return { + group, + walls, + floors, + ceilings, + openings, + wallMeshes, + setGhosted(mesh, ghosted) { + if (Boolean(mesh.userData.ghosted) === ghosted) return; + const info = mesh.userData.wall as WallInfo | undefined; + if (!info) return; + mesh.userData.ghosted = ghosted; + mesh.material = ghosted ? materials.ghostOf(info.role) : materials.get(info.role); + // A ghost that still casts a solid shadow gives itself away instantly. + mesh.castShadow = !ghosted; + }, + dispose() { + for (const geo of owned) geo.dispose(); + owned.length = 0; + wallMeshes.length = 0; + group.clear(); + walls.clear(); + floors.clear(); + ceilings.clear(); + openings.clear(); + }, + }; +} + +// ---- Geometry ------------------------------------------------------------- + +/** + * A room's polygon as a flat slab at `y`, facing up for a floor and down for a + * ceiling. + * + * It is a surface and not a box. Nothing is ever underneath a floor or above a + * ceiling in an office, and the only place the missing thickness would show is + * the outer edge of the building seen from below, which the orbit limits do not + * let you get to. + * + * **UVs are the room's own world coordinates in metres**, not a 0..1 unwrap. + * Carpet in one room therefore lines up with carpet in the room next door + * exactly as laid carpet does, and a 3 m booth and a 30 m floor plate show the + * same size of loop. `parts.metricQuad` does this for rectangles; a room is a + * polygon, which is why this lives here. + */ +function slabGeometry(outline: Outline, y: number, up: boolean): THREE.BufferGeometry | null { + const count = outline.length; + if (count < 3) return null; + + const contour = outline.map((p) => new THREE.Vector2(p.x, p.z)); + const faces = THREE.ShapeUtils.triangulateShape(contour, []); + if (faces.length === 0) return null; + + const position = new Float32Array(count * 3); + const normal = new Float32Array(count * 3); + const uv = new Float32Array(count * 2); + const ny = up ? 1 : -1; + for (let i = 0; i < count; i++) { + const p = outline[i]; + if (!p) continue; + position[i * 3] = p.x; + position[i * 3 + 1] = y; + position[i * 3 + 2] = p.z; + normal[i * 3 + 1] = ny; + uv[i * 2] = p.x / TEXTURE_TILE_METRES; + uv[i * 2 + 1] = p.z / TEXTURE_TILE_METRES; + } + + // `Plan` hands over a known winding, but the triangulator's output order is + // its own business and a back-facing floor is invisible rather than wrong- + // looking. Each triangle is oriented from its own cross product, which costs + // three subtractions and cannot be got wrong by a later change of convention. + const index: number[] = []; + for (const face of faces) { + const a = face[0]; + const b = face[1]; + const c = face[2]; + if (a === undefined || b === undefined || c === undefined) continue; + const pa = outline[a]; + const pb = outline[b]; + const pc = outline[c]; + if (!pa || !pb || !pc) continue; + const facing = (pb.z - pa.z) * (pc.x - pa.x) - (pb.x - pa.x) * (pc.z - pa.z); + if (facing * ny > 0) index.push(a, b, c); + else index.push(a, c, b); + } + if (index.length === 0) return null; + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(position, 3)); + geometry.setAttribute("normal", new THREE.BufferAttribute(normal, 3)); + geometry.setAttribute("uv", new THREE.BufferAttribute(uv, 2)); + geometry.setIndex(index); + return geometry; +} + +/** A point `d` metres along a wall of yaw `yaw` from its centre. */ +function along(center: Point2, yaw: number, d: number): Point2 { + // A run's mesh lies along its local +X, which for yaw φ points at + // (cos φ, -sin φ) — the same derivation `Plan` uses to place its runs. The + // `+ 0` normalises IEEE negative zero for the same reason `Plan` does it: a + // north-south wall otherwise reports an x of `-0`, which renders identically + // and looks like a bug in every diff. + return { x: center.x + Math.cos(yaw) * d + 0, z: center.z - Math.sin(yaw) * d + 0 }; +} + +/** Anything that knows where it sits along its wall. Runs and openings both do. */ +interface Interval { + center: Point2; + yaw: number; + start: number; + end: number; +} + +/** + * The endpoints of the wall a set of runs and openings came from. + * + * A run knows where its own centre is and how far along the wall it starts and + * ends, which is enough to recover the wall's origin and therefore both of its + * ends. Doing it this way rather than reading `Wall.from`/`Wall.to` off the pack + * means the segment stamped on the mesh is the segment that was actually drawn, + * and a wall `Plan` repaired stays consistent with itself. + * + * The openings are in the list because a full-height door at the very end of a + * wall leaves no run out there — no apron under it, no lintel over it — and the + * segment would come up short by the width of the door. + */ +function extentOf(intervals: readonly Interval[]): { from: Point2; to: Point2 } { + const first = intervals[0]; + if (!first) return { from: { x: 0, z: 0 }, to: { x: 0, z: 0 } }; + const mid = (first.start + first.end) / 2; + const origin = along(first.center, first.yaw, -mid); + let start = first.start; + let end = first.end; + for (const interval of intervals) { + start = Math.min(start, interval.start); + end = Math.max(end, interval.end); + } + return { + from: along(origin, first.yaw, start), + to: along(origin, first.yaw, end), + }; +} + +function groupBy(items: readonly T[], key: (item: T) => K): Map { + const out = new Map(); + for (const item of items) { + const k = key(item); + const list = out.get(k); + if (list) list.push(item); + else out.set(k, [item]); + } + return out; +} diff --git a/src/interiors/types.ts b/src/interiors/types.ts new file mode 100644 index 0000000..9fc7a4c --- /dev/null +++ b/src/interiors/types.ts @@ -0,0 +1,481 @@ +/** + * The office contract — what an office pack is allowed to say. + * + * This is the interiors half of `engine/types.ts`, and it obeys the same rule: + * the engine renders what an `Office` describes and takes no position on what it + * means. It does not know that a room is a room because people meet in it, that + * `zone: "eng"` is a team, or that anybody is sitting anywhere. See + * ARCHITECTURE.md §3.3 and CONTRACT.md §2. + * + * **Everything here is strictly JSON-serialisable.** No functions, no classes, + * no getters, no `THREE` types, no `Date`. A pack hand-written as a `.ts` module + * and a pack arriving as a `.json` body over HTTP have to be literally the same + * thing — the moment one of them can carry a callback, the other stops being a + * pack and starts being a second format nobody maintains. + * + * `Plan` (`src/interiors/plan.ts`) is the only thing that turns an `Office` into + * geometry. Its output — wall runs, collision segments, resolved placements — is + * a build product and is deliberately not authorable here. + * + * ### Coordinates and units + * + * Offices are authored in **metres, 1 unit = 1 m**, which is also how assets are + * authored (CONTRACT.md §3). This is not the city's scale and cannot be: SF puts + * one scene unit at ~94 m with 3.6x vertical exaggeration, which is why an + * office gets its own `THREE.Scene`. + * + * The floor is the **XZ plane** with **+Y up**, three.js's convention. Plan view + * throughout this file means looking down at that plane with +X to the right and + * +Z down the page. + */ + +import type { Pin, View } from "../engine/types.ts"; + +// ---- Geometry ------------------------------------------------------------- + +/** + * A point on the floor plane, in metres. + * + * Named fields rather than a `[number, number]` tuple — unlike the city's + * `LatLng`, where the pair has an obvious reading, `[4, 6]` in an office gives a + * reader no way to tell whether the second number is depth or height. The field + * is called `z` precisely so that the answer is on the page. + */ +export interface Point2 { + x: number; + z: number; +} + +/** + * A polygon, in plan. + * + * Do **not** repeat the first point at the end; the outline is implicitly + * closed. Author them counter-clockwise in plan view. `Plan` re-winds anything + * that arrives the other way round rather than rendering a black hole, so this + * is a style rule and not a trap. + */ +export type Outline = Point2[]; + +/** + * Yaw about the +Y axis, in radians. Zero faces **-Z**, and the angle increases + * counter-clockwise seen from above. + * + * That is exactly three.js's `object.rotation.y`, and it is stated in those + * terms on purpose. A plan-space "degrees clockwise from north" angle — which is + * what `District.gridAngle` and `Aircraft.heading` use, because for a city it + * reads better — would need a sign flip on the way into the scene, and a sign + * flip that lives in one place is a sign flip that eventually gets applied + * twice. Nothing converts this. + */ +export type Yaw = number; + +// ---- Identifiers ---------------------------------------------------------- + +/** + * A namespaced asset id, like `"tera:desk.workstation"`. + * + * **`src/assets/kit.ts` is the authority** on what ids exist and what they + * build; this alias exists so that the office contract does not import the mesh + * library. It is the same type by the same name in both places — one string, one + * meaning — not two ideas that collided. Interiors is data; assets is code that + * turns data into geometry, and data should not depend on it to be parsed, + * validated or stored. + * + * The `tera:` namespace is the one this repo ships. A self-hoster registers + * `acme:desk.standing` with `overrides: "tera:desk.workstation"` and reskins the + * reference office without forking it. An id with no registration resolves to a + * placeholder box rather than throwing, because an office pack with one typo in + * it should still open. + */ +export type AssetId = string; + +/** + * A material id for a floor, wall or ceiling finish, like `"tera:carpet.loop"`. + * + * Same arrangement as `AssetId`, one level down: `src/assets/materials.ts` holds + * the `MaterialRegistry` and the closed `SurfaceRole` union it is keyed on, and + * this alias is the loose string an authored pack carries. The name differs from + * `SurfaceRole` deliberately — a type name exported twice meaning two different + * things is the exact failure CONTRACT.md §4 was written to stop. + * + * There is no per-instance tint here, unlike `Prop.colorKey`. One blue meeting + * room is a *material* — register `acme:paint.blue` and point the wall at it — + * whereas one red chair in a row of grey ones is genuinely an instance. Giving + * surfaces a tint key as well would duplicate the override mechanism that + * already exists and leave two ways to answer the same question. + */ +export type SurfaceId = string; + +// ---- The office ----------------------------------------------------------- + +/** + * One building's interior: the whole authored pack, and the thing a self-hoster + * copies to make their own. + * + * An office contains no people. `Presence` is runtime data that arrives + * separately and binds by seat id — see the note on that type, which is the + * single most important paragraph in this file. + */ +export interface Office { + id: string; + name: string; + + /** + * Ground floor first. An office with one storey declares one level; nothing + * else in the format changes. + */ + levels: Level[]; + + /** + * Named camera poses. `viewpoints[0]` is where you arrive, so put reception — + * or whatever the pack wants a first impression to be — at the front. + */ + viewpoints: Viewpoint[]; + + meta?: OfficeMeta; +} + +/** + * Provenance for a pack, and nothing the renderer reads. + * + * Optional, but a pack meant to be shared should fill in `author` and `license`. + * The art in this repo is Apache-2.0 with the artistic output additionally + * dedicated under CC0-1.0 (CONTRACT.md §3.1); a pack built elsewhere is under + * whatever its author says here, and saying nothing helps nobody. + */ +export interface OfficeMeta { + description?: string; + author?: string; + /** SPDX identifier where there is one, e.g. `"CC0-1.0"`. */ + license?: string; + version?: string; + /** ISO-8601 date string. A string, not a `Date` — this has to survive JSON. */ + updated?: string; +} + +/** + * One storey. + * + * The three `wall*` fields are the storey's defaults, not a constraint: an + * individual `Wall` overrides any of them. They live here because a floor of + * forty walls that are all 3 m of painted plasterboard should say so once, and + * the interesting wall — the 1.4 m partition around the desk bay — should be the + * one that stands out in the source. + */ +export interface Level { + id: string; + name: string; + + /** Floor slab height above the office origin, in metres. Ground is `0`. */ + elevation: number; + + /** Storey height: the default top of a wall, measured from this floor. */ + wallHeight: number; + /** Default wall thickness in metres. `Plan` uses 0.12 when this is absent. */ + wallThickness?: number; + /** Default wall finish. */ + wallSurface?: SurfaceId; + + floorplan: Floorplan; +} + +/** + * Everything on one storey. + * + * `rooms` and `walls` are required because a level without them is not a level; + * the rest are optional because a bare lobby genuinely has no desk banks, and a + * pack arriving over HTTP will drop empty arrays. Consumers read the optional + * ones as `?? []`. + */ +export interface Floorplan { + rooms: Room[]; + walls: Wall[]; + props?: Prop[]; + deskBanks?: DeskBank[]; + seats?: Seat[]; + zones?: Zone[]; +} + +// ---- Rooms ---------------------------------------------------------------- + +/** + * A floor slab with a name. + * + * **A room implies no walls.** This is the load-bearing half of CONTRACT.md §2: + * rooms are surfaces, walls are a separate explicit list, and the two are not + * derived from each other. Deriving walls from shared room edges sounds tidy + * until it needs float-equality dedup to decide whether two rooms touch, and + * then it is a source of gaps that only appear in one build out of ten. + * + * Rooms may overlap and may leave gaps. An open-plan floor is one big room with + * a handful of walls standing on it. + */ +export interface Room { + id: string; + name: string; + outline: Outline; + floor: SurfaceId; + /** + * Omit for the level default. Explicit `null` means **no ceiling at all** — + * an atrium, a double-height void, or a cutaway you want to look down into. + */ + ceiling?: RoomCeiling | null; +} + +/** A ceiling override for one room. Both fields fall back to the level. */ +export interface RoomCeiling { + /** Metres above this level's floor. */ + height?: number; + surface?: SurfaceId; +} + +// ---- Walls and openings --------------------------------------------------- + +/** + * A single straight wall segment, from `from` to `to`, centred on that line. + * + * Walls are an explicit list rather than something inferred from room edges, and + * this is the decision the rest of the interiors code is built on. The pass that + * splits a wall around its openings has to run anyway to produce the solid runs + * you can see; running it once produces the **walk-mode collision segments for + * free**, with the gaps in exactly the places you can walk through. Any other + * arrangement keeps two lists in sync by hand. + * + * A wall belongs to no room. It stands where it is put. + */ +export interface Wall { + id: string; + from: Point2; + to: Point2; + /** Metres. Falls back to the level's `wallThickness`. */ + thickness?: number; + /** Metres above this level's floor. Falls back to the level's `wallHeight`. */ + height?: number; + surface?: SurfaceId; + /** Doors, windows and arches punched out of this wall. Order is irrelevant. */ + openings?: Opening[]; +} + +export type OpeningKind = "door" | "window" | "arch"; + +/** + * A hole in a wall, described as a 1-D interval along it. + * + * `start` is measured **from the wall's `from` end**, along the wall, in metres; + * `width` runs on from there. That is the whole of the horizontal placement — + * an opening has no position of its own and cannot drift off its wall, which is + * the point of expressing it this way. + * + * Doors and windows are openings, never placeable assets. There is no + * `tera:shell.door`: shipping both a door prop and a door-shaped hole would put + * every opening in the scene twice, or — worse, because it is invisible until + * somebody walks through a wall — leave the collider with no gap where the door + * is. `Plan` hands each solid run to a parameterised `wallRun` part, and the + * frame, leaf or glazing is drawn by the opening itself. + * + * Typical values, in metres: a door is `sill: 0, head: 2.1`; a window is + * `sill: 0.9, head: 2.2`; an arch is `sill: 0, head: 2.4`. They are required + * rather than defaulted by kind, because a data contract with hidden per-kind + * defaults is one where the numbers you read are not the numbers you get. + */ +export interface Opening { + kind: OpeningKind; + /** Metres along the wall from the `from` end to the near edge of the hole. */ + start: number; + /** Metres. Must be positive, and must fit inside the wall. */ + width: number; + /** Bottom of the hole, metres above this level's floor. */ + sill: number; + /** Top of the hole, metres above this level's floor. */ + head: number; +} + +// ---- Props ---------------------------------------------------------------- + +/** + * One instance of one asset, placed. + * + * `kind` is an `AssetId` because the prop registry and the asset registry are + * the same registry (CONTRACT.md §3) — there is no separate table of things you + * are allowed to put in a room. + */ +export interface Prop { + id: string; + kind: AssetId; + /** Where it stands, on the floor plane. */ + position: Point2; + /** See `Yaw`. */ + rotation: Yaw; + /** + * Metres above this level's floor. Omitted means standing on it, which is + * true of nearly everything; a wall-mounted screen or a monitor on a desktop + * says so here. + */ + elevation?: number; + /** + * Uniform scale, or per-axis. Use sparingly — an asset that is wanted at + * another size is usually better registered as its own id. + */ + scale?: number | [number, number, number]; + /** + * An opaque palette key, resolved by the caller's palette exactly as + * `Pin.colorKey` is. The engine will not learn that `"focus"` means a quiet + * booth or that red means anything at all. + */ + colorKey?: string; + /** + * The id of a `Seat` this prop belongs to — the chair pulled up to `eng-04`. + * + * Purely an address. The prop is still positioned by its own `position`; + * binding it to a seat is what lets an occupancy layer dim the empty chairs + * without knowing which mesh is which. + */ + seat?: string; +} + +/** + * A row or grid of identical desks, declared once. + * + * `Plan` expands one of these into props and seats. It exists for a plain + * reason: the reference office is about 180 props, and 180 hand-written literals + * is not a file anybody edits twice. A bank of twelve is six lines here. + * + * The grid is laid out in the bank's own frame — `columns` run along its local + * +X, `rows` step along its local +Z — and then rotated by `rotation` about + * `origin`, which is the centre of station (1, 1). + * + * ### Generated ids + * + * These are part of the contract, because a `Presence` binds to a seat id and a + * pack author has to be able to predict what it will be without running the + * expansion. Stations are numbered from 1, along each row and then down the + * rows, and the number is zero-padded to two digits: + * + * - seat `${seatPrefix ?? id}-01`, `-02`, … + * - desk prop `${id}-desk-01`, chair prop `${id}-chair-01` + * + * So a bank with `id: "eng"` and no `seatPrefix` gives you `eng-04` as the + * fourth seat, which is the id the private occupancy API is expected to know. + */ +export interface DeskBank { + id: string; + /** The asset placed at every station. */ + desk: AssetId; + /** Placed at every seat, if given. */ + chair?: AssetId; + + /** Centre of the first station, before rotation. */ + origin: Point2; + /** Orientation of the whole bank. See `Yaw`. */ + rotation: Yaw; + + /** Stations across, along the bank's local +X. At least 1. */ + columns: number; + /** Rows deep, along the bank's local +Z. At least 1. */ + rows: number; + /** Centre-to-centre spacing between columns, in metres. */ + pitch: number; + /** Centre-to-centre spacing between rows, in metres. Defaults to `pitch`. */ + rowPitch?: number; + + /** + * When true, consecutive rows face each other rather than all facing the same + * way — bench seating, where two rows share a run of desktop. This is the + * difference between an office that looks laid out and one that looks like a + * spreadsheet, which is why it is here rather than left to the author to fake + * with two banks. + */ + facingRows?: boolean; + + /** Metres from the desk centre to the seat, on the seated side. */ + seatOffset?: number; + /** Pose for every seat in the bank. Defaults to `"sit"`. */ + pose?: SeatPose; + /** Overrides the bank `id` as the seat-id prefix. */ + seatPrefix?: string; +} + +// ---- Seats and zones ------------------------------------------------------ + +export type SeatPose = "sit" | "stand"; + +/** + * A place a person can be. + * + * **Seats are addresses.** A seat is not a chair and not a person; it is a + * stable name for a spot on the floor, so that something outside this repo can + * say "eng-04" and mean somewhere without ever being told a coordinate. Ids + * should be stable across pack edits for the same reason street numbers are. + */ +export interface Seat { + id: string; + position: Point2; + /** Which way an occupant looks. See `Yaw`. */ + facing: Yaw; + pose: SeatPose; +} + +/** + * A named region of floor. + * + * A zone has no behaviour and no effect on geometry. It is a label on an area — + * a team's corner, a quiet zone, a phone-booth cluster — that a consuming app + * can highlight, filter or count against. Whether membership of a zone *means* + * anything is not the engine's business, which is why `colorKey` is opaque and + * there is no `kind` field. + */ +export interface Zone { + id: string; + name: string; + outline: Outline; + /** Opaque palette key, resolved by the caller. */ + colorKey?: string; +} + +// ---- Viewpoints ----------------------------------------------------------- + +/** + * A named camera pose — the office analogue of a city `Chapter`. + * + * It shares `View` with the city rather than redeclaring it, because the half + * that the interface cares about — what the legend prints, what `flyTo` is keyed + * on — is identical, and only the pose differs: a chapter focuses on a latitude + * and longitude, a viewpoint focuses on a point in metres on a particular level. + */ +export interface Viewpoint extends View { + levelId: string; + focus: { + /** What the camera looks at, on the floor plane. */ + at: Point2; + /** Metres from the target to the camera. */ + distance: number; + /** Metres above this level's floor, for both target and camera height. */ + height: number; + /** Camera azimuth about the target. See `Yaw`. */ + rotation: Yaw; + }; +} + +// ---- Presence ------------------------------------------------------------- + +/** + * Somebody at a seat. + * + * **A `Presence` binds to a `seatId` and never to a coordinate, and it never + * appears in an office pack.** This is the whole trick, and it is the marker + * rule one level in. + * + * The pack knows where seat `eng-04` is. A private API knows who is sitting in + * it. Neither knows the other, so occupancy can be private data behind + * authentication — names, faces, who is in today — while the office geometry + * stays public, open-source and copyable by anyone. If a presence carried an + * `{x, z}`, then publishing the geometry and publishing the people would be the + * same act, and one of them could never be published at all. + * + * It extends `Pin` for the same reason `Marker` does: a thing worth pointing at, + * with a label and an opaque colour key, that a detail card can render without + * caring whether it was placed by latitude or by seat id. + */ +export interface Presence extends Pin { + seatId: string; +} diff --git a/src/main.ts b/src/main.ts index 29fa778..f641f43 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,17 +1,25 @@ /** - * The standalone demo: San Francisco, simulated traffic, chapter legend. + * The standalone demo: San Francisco under a real sun, and one office you can + * step into. * * Deliberately ships **no company data**. Markers are demonstrated using the * city's own landmarks — buildings, not businesses — because company positions - * are geocoded (ODbL) and company pipeline status is private, and neither - * belongs in this repo. Real markers arrive at runtime from an adapter; see - * `src/adapters/` and ARCHITECTURE.md §3. + * are geocoded and company pipeline status is private, and neither belongs in + * this repo. Real markers arrive at runtime from an adapter; see `src/adapters/` + * and ARCHITECTURE.md §3. + * + * It also makes no network calls. The sun is computed locally, the traffic is + * simulated and the office is a data file, so a clone of this repo runs. */ -import { createScene } from "./engine/scene.ts"; +import { createAtmosphere, observe, PACIFIC_MARINE_LAYER } from "./engine/atmosphere.ts"; +import { daylightPhase } from "./engine/solar.ts"; import { SimulatedFlights, type SimRoute } from "./engine/flights.ts"; -import type { Marker, MarkerPalette } from "./engine/types.ts"; +import { createScene } from "./engine/scene.ts"; +import type { Marker, MarkerPalette, View } from "./engine/types.ts"; import SAN_FRANCISCO from "./cities/sf.ts"; +import { createOfficeScene, type OfficeScene } from "./interiors/officeScene.ts"; +import LUMBRIDGE_HQ from "./offices/lumbridge-hq.ts"; /** * Bay Area traffic, roughly where it actually is: SFO sits south of frame and @@ -28,31 +36,76 @@ const ROUTES: SimRoute[] = [ { callsign: "JBU 915", from: [37.96, -122.48], to: [37.63, -122.36], fromAlt: 3100, toAlt: 600, duration: 205 }, ]; -const MARKER_PALETTE: MarkerPalette = { - landmark: 0xf2b134, - neutral: 0x9aa4ad, -}; +const MARKER_PALETTE: MarkerPalette = { landmark: 0xf2b134, neutral: 0x9aa4ad }; const canvas = document.querySelector("#scene"); if (!canvas) throw new Error("#scene canvas missing"); -const scene = createScene(canvas, { +const city = createScene(canvas, { city: SAN_FRANCISCO, markerPalette: MARKER_PALETTE, flights: new SimulatedFlights(ROUTES), - onMarkerPick: (marker) => { - const card = document.querySelector("#detail"); - if (!card) return; - if (!marker) { - card.hidden = true; - return; - } - card.hidden = false; - card.textContent = marker.label; - }, + onMarkerPick: (marker) => showDetail(marker?.label ?? null), }); -// Demo markers: the city's own named buildings. +// ---- The sun -------------------------------------------------------------- + +/** + * Real solar position for San Francisco, right now, recomputed every minute. + * + * No network and no timezone database — `solar.ts` is arithmetic — so this + * keeps working on a laptop in a field. The marine layer is switched on because + * the fog is the single most recognisable atmospheric fact about this city. + */ +const atmosphere = createAtmosphere({ + lng: SAN_FRANCISCO.center.lng, + metresPerUnit: city.world.metresPerUnit, + marineLayer: PACIFIC_MARINE_LAYER, +}); + +/** + * `null` follows the wall clock. A number is an hour-of-day override from the + * scrubber, which exists because the honest answer at 2 a.m. is a black + * rectangle — correct, and impossible to look at. Being able to drag the sun is + * also the only practical way to eyeball whether the solar maths is right. + */ +let hourOverride: number | null = null; + +function currentInstant(): Date { + const now = new Date(); + if (hourOverride === null) return now; + const d = new Date(now); + d.setHours(Math.floor(hourOverride), Math.round((hourOverride % 1) * 60), 0, 0); + return d; +} + +function updateSun() { + const env = observe(SAN_FRANCISCO.center.lat, SAN_FRANCISCO.center.lng, currentInstant()); + city.setLighting(atmosphere.apply(env)); + 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 phase = daylightPhase(el); + clock.textContent = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${phase}${hourOverride === null ? "" : " (held)"}`; +} + +const scrubber = document.querySelector("#hour"); +scrubber?.addEventListener("input", () => { + hourOverride = Number(scrubber.value); + updateSun(); +}); +document.querySelector("#now")?.addEventListener("click", () => { + hourOverride = null; + if (scrubber) scrubber.value = String(new Date().getHours()); + updateSun(); +}); + +updateSun(); +window.setInterval(() => hourOverride === null && updateSun(), 60_000); + +// ---- Demo markers --------------------------------------------------------- + const demoMarkers: Marker[] = SAN_FRANCISCO.landmarks .filter((l) => l.label) .map((l) => ({ @@ -63,26 +116,86 @@ const demoMarkers: Marker[] = SAN_FRANCISCO.landmarks colorKey: "landmark", located: true, })); -scene.setMarkers(demoMarkers); +city.setMarkers(demoMarkers); -// ---- Chapter legend ------------------------------------------------------- +// ---- The office ----------------------------------------------------------- + +/** + * Built on first entry and then kept, for the same reason the city is paused + * rather than disposed on the way in: rebuilding either scene costs far more + * than holding it. + */ +let office: OfficeScene | null = null; +let inside = false; + +function enterOffice() { + if (!office) { + office = createOfficeScene(LUMBRIDGE_HQ, { + dom: city.stage.renderer.domElement, + background: 0x11161c, + }); + office.onViewChange(() => renderLegend()); + } + city.stage.setScene(office); + inside = true; + showDetail(null); + renderLegend(); +} + +function leaveOffice() { + city.stage.setScene(city.stageScene); + inside = false; + showDetail(null); + renderLegend(); +} + +// ---- Chrome --------------------------------------------------------------- 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"); -function renderLegend(activeId: string) { - if (!nav) return; - nav.replaceChildren(); - for (const chapter of scene.chapters) { - const button = document.createElement("button"); - button.className = chapter.id === activeId ? "chapter active" : "chapter"; - button.innerHTML = `${chapter.number}${chapter.shortLabel}`; - button.addEventListener("click", () => scene.flyTo(chapter.id)); - nav.append(button); - } - const active = scene.chapters.find((c) => c.id === activeId); - if (blurb && active) blurb.textContent = active.description; +function showDetail(text: string | null) { + const card = document.querySelector("#detail"); + if (!card) return; + card.hidden = text === null; + card.textContent = text ?? ""; } -renderLegend(scene.current()); -scene.onChapterChange(renderLegend); +/** + * One legend for both places. A city chapter and an office viewpoint are both + * `View`s, which is the whole reason that type was extracted. + */ +function renderLegend() { + if (!nav) 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 = view.id === activeId ? "chapter active" : "chapter"; + const number = view.number ?? String(i + 1).padStart(2, "0"); + button.innerHTML = `${number}${view.shortLabel}`; + button.addEventListener("click", () => { + if (inside && office) office.flyTo(view.id); + else city.flyTo(view.id); + }); + nav.append(button); + }); + + const active = views.find((v) => v.id === activeId); + if (blurb) { + blurb.textContent = active?.description ?? ""; + blurb.hidden = !active?.description; + } + if (title) title.textContent = inside ? LUMBRIDGE_HQ.name : SAN_FRANCISCO.name; + if (subtitle) subtitle.textContent = inside ? "Spaces · a Lumbridge office" : "Tera · Lumbridge Simulate"; + if (enterButton) enterButton.textContent = inside ? "← Back to the city" : "Enter the office →"; +} + +enterButton?.addEventListener("click", () => (inside ? leaveOffice() : enterOffice())); +city.onChapterChange(() => renderLegend()); +renderLegend(); diff --git a/src/offices/README.md b/src/offices/README.md new file mode 100644 index 0000000..6afe40c --- /dev/null +++ b/src/offices/README.md @@ -0,0 +1,459 @@ +# Authoring an office + +An **office pack** is one JSON-shaped object describing the inside of a +building: floor slabs, walls, holes in the walls, furniture, seats, and a few +camera poses. `src/interiors/types.ts` is the contract — it is short, it is +commented, and it wins any argument with this document. `lumbridge-hq.ts` in +this directory is a worked example of every feature described here, and copying +it is the intended way to start. + +Nothing in a pack requires an account, a key or a network. If you can run the +repo you can author an office, and if you can author an office you can hand +someone the file. + +## The shape of it + +```ts +import type { Office } from "../interiors/types.ts"; + +export const ACME_HQ: Office = { + id: "acme-hq", + name: "Acme HQ", + levels: [ + { + id: "level-1", + name: "Level 1", + elevation: 0, // floor height above the office origin + wallHeight: 2.8, // the storey's default wall top + wallThickness: 0.12, + wallSurface: "tera:paint.matt", + floorplan: { + rooms: [...], // required + walls: [...], // required + props: [...], // optional, read as [] when absent + deskBanks: [...], + seats: [...], + zones: [...], + }, + }, + ], + viewpoints: [...], // viewpoints[0] is where you arrive + meta: { author: "you", license: "CC0-1.0" }, +}; +``` + +`Office` must stay **strictly JSON-serialisable**: no functions, no classes, no +`Date`, no `THREE` types. A pack you hand-write as a `.ts` module and a pack +that arrives as a `.json` body over HTTP have to be literally the same thing, +because the server (`GET /api/v1/offices/:id`) serves exactly this object inside +an `OfficeDoc`. + +Helper *functions* in your source file are fine — `lumbridge-hq.ts` uses five, +and they all run at module load and return plain objects. The rule is about the +value, not the file. + +## The coordinate frame + +Metres, `1 unit = 1 m`. The floor is the **XZ plane** with **+Y up**, which is +three.js's convention with no conversion anywhere. + +"Plan view" throughout means looking down at that plane with **+X to the right +and +Z down the page**. That is the one thing worth internalising, because +Z +going *down* is what makes the winding rule counter-intuitive: + +- Outlines are **implicitly closed** — do not repeat the first point. +- Author them **counter-clockwise in plan view**, which is NW → SW → SE → NE for + a rectangle, and which has a *negative* shoelace area over `(x, z)`. +- Get it wrong and nothing breaks: `Plan` silently re-winds a reversed polygon. + +An office has no orientation on the earth. Calling an edge "north" is a +convenience for reading your own file. Offices get no `Atmosphere`, no sun and +no sky (CONTRACT.md §4); interior lighting is a fixed rig owned by the scene. + +## Rooms are slabs. Walls are segments. + +This is the load-bearing idea, and the thing most people get backwards on the +first attempt. + +A **`Room` is a floor finish with a name and a polygon. It implies no walls.** +A **`Wall` is a separate straight segment that belongs to no room** and stands +wherever you put it. The two lists are not derived from each other. + +Consequences worth stating out loud: + +- An open plan is simply rooms with nothing standing between them. In the + reference pack the lounge, the kitchen and the desk floor share three edges and + only one of those edges carries a wall. +- A room can be a corridor, a lift lobby, a zone of different carpet. Give it an + id if you ever want to name it. +- Rooms may overlap and may leave gaps. `Plan.roomAt` resolves *later* rooms + first, so a room declared after another wins the lookup where they cross. + Overlapping is legal but the reference pack avoids it — two coplanar slabs at + the same height is a z-fight waiting for the wrong GPU, so the open floor is + notched around the focus booths rather than passing underneath them. + +Rooms and walls should be authored against the **same numbers**. A wall is +centred on its line and straddles the boundary between the two slabs meeting +there. If the slab edge says `10.4` and the wall line says `10.42` you get a +seam you will never find again. + +### Ceilings + +`ceiling` omitted gives a ceiling at the level's `wallHeight`. `ceiling: null` +means **no ceiling at all** — an atrium, a void, or a room you want to look down +into. `{ height, surface }` overrides one room. + +An office you look down into from an establishing viewpoint cannot have lids on +the rooms you are trying to see, so most of the reference pack declares +`ceiling: null`. The three that keep theirs — the focus booths, the server room, +the store — are the three you are never meant to see inside, and a ceiling is a +cheap way of saying so. + +## Doors and windows are openings, not props + +There is no `tera:shell.door`. A door is a 1-D interval punched out of a wall: + +```ts +{ kind: "door", start: 5.6, width: 0.9, sill: 0, head: 2.1 } +``` + +`start` is measured **from the wall's `from` end**, along the wall, in metres. +That is the whole of the horizontal placement, which is why an opening can never +drift off its wall. It also means **the direction you write a wall in is the +direction its openings are measured in** — author every wall consistently (the +reference pack goes west-to-east and north-to-south, without exception) or you +will eventually put a door at the wrong end of a room. + +`sill` and `head` are metres above the level's floor, and both are required. +There are deliberately no per-kind defaults: a contract where the numbers you +read are not the numbers you get is worse than a contract that makes you type +`sill: 0`. Typical values: + +| kind | sill | head | note | +| --- | --- | --- | --- | +| `door` | 0 | 2.1 | 0.9 m wide for a single leaf, 1.8 m for a pair | +| `arch` | 0 | 2.4 | a cased opening with no leaf | +| `window` | 0.9 | 2.2 | punched window | +| `window` | 0.75 | 2.35 | ribbon glazing on a façade | + +### The passability rule, which is the whole reason for this design + +The pass that splits a wall around its openings has to run anyway to produce the +solid runs you can see. Running it once also produces the **walk-mode collision +segments for free**, with gaps in exactly the places you can walk through. + +An opening is a way through **iff `sill <= 0` and `head >= 1.1 m`**. There is no +per-kind special-casing: a door passes, an arch passes, a window with a 0.9 m +sill does not, a serving hatch does not. Which means a glazed opening at ankle +height quietly becomes a hole in your wall that people walk through, so keep +sills honest. + +`Plan.blocked(levelId, from, to, radius)` is the test to use from a walk +controller; do not re-derive the inflation yourself. + +### Glass walls versus window openings + +Full-height glazing is a **wall with a glass surface**, not one enormous +opening: + +```ts +{ id: "ext-east", from: { x: 34, z: 0 }, to: { x: 34, z: 12.2 }, surface: "tera:glass.curtain" } +``` + +An opening is a hole, and a hole is something you can sometimes walk through. A +curtain wall is a solid you happen to be able to see through. Modelling one as +the other hands the collider a twelve-metre gap and puts your lounge on the +pavement. + +## Desk banks + +Hundreds of hand-written desk literals is not a file anybody edits twice. A +`DeskBank` is one declaration that `Plan` expands into a desk prop, a chair prop +and a seat per station: + +```ts +{ + id: "eng", + desk: "tera:desk.workstation", + chair: "tera:seat.task-chair", + origin: { x: 9.2, z: 1.9 }, // centre of station (1, 1), before rotation + rotation: 0, + columns: 6, rows: 2, + pitch: 1.7, // centre-to-centre across + rowPitch: 0.85, // centre-to-centre down; defaults to `pitch` + facingRows: true, + seatOffset: 0.6, // desk centre to seat; defaults to 0.6 + pose: "sit", // defaults to "sit" + seatPrefix: undefined, // defaults to `id` +} +``` + +The grid is laid out in the bank's own frame — `columns` along local +X, `rows` +along local +Z — then rotated by `rotation` about `origin`. Rotating a bank does +not move the origin to a corner of the building; the origin stays station +(1, 1). At `rotation: Math.PI` the bank's local +X is world −X, so the origin is +the *east* end of the run. + +**The generated ids are contractual**, because a `Presence` binds to a seat id +and you have to be able to predict it without running anything. Stations are +numbered from 1, along each row and then down the rows, zero-padded to two: + +- seat `${seatPrefix ?? id}-01`, `-02`, … +- desk prop `${id}-desk-01`, chair prop `${id}-chair-01` + +So the bank above gives you `eng-01` … `eng-12`, and `eng-04` is the fourth desk +in the front row. + +### Why every bench in the reference pack is two rows + +`facingRows` turns the first row of each pair around so a pair shares a run of +desktop — a bench, rather than two rows of people looking at the back of each +other's heads. It does that at the bank's **single** `rowPitch`, and the pitch +that makes two rows meet back-to-back (0.85 m: a desk deep, plus a cable trough) +is nothing like the pitch you need between one bench and the next (2.4 m of +chair, aisle and chair). One bank cannot express both. **A second bench is a +second bank.** That is a real limit of the format, not an oversight. + +Seat ids restart at `01` in every bank, so two banks that want to share a +numbering series cannot; give them distinct prefixes (`eng`, `ops`, `design`) +rather than trying. + +A bank places a desk and a chair and **no third thing**. If you want a monitor +on every desk, do not write thirty-nine monitor props that have to be kept in +step with a bank you will move next week — register your own desk asset (see +below) that builds a desk with a monitor on it, and every station in every bank +in every pack gets one. + +## Seats + +```ts +{ id: "bernal-04", position: { x: 10.8, z: 14.1 }, facing: Math.PI / 2, pose: "sit" } +``` + +**A seat is an address, not a chair.** The chair is a separate prop that happens +to be at the same coordinate; a room with no chairs in it can still have seats, +and a chair with nobody's name on it needs no seat. + +This is the most important thing in the format. A `Presence` — somebody at a +desk — binds to a **`seatId` and never to a coordinate**, and never appears in a +pack. The pack knows where `eng-04` is; a private API knows who is sitting in +it; neither knows the other. That is what lets occupancy be private data behind +authentication while the geometry stays public, open-source and copyable. If a +presence carried an `{x, z}`, publishing the building and publishing the people +would be the same act, and one of them could never be published at all. + +So: **keep seat ids stable across edits**, for the same reason street numbers +survive repainting the house. Move the table 200 mm; do not renumber the seats. + +`facing` is where the occupant looks. `pose` is `"sit"` or `"stand"` and is the +only thing that tells a consumer how tall to draw an occupant — people perch at +a kitchen island, so those seats are `"stand"`. + +## Props, and which way things face + +```ts +{ id: "lobby-monitor", kind: "tera:screen.monitor", position: { x: 3.4, z: 5.2 }, + rotation: Math.PI / 2, elevation: 0.73, scale: 1, colorKey: "accent", seat: "reception-01" } +``` + +`kind` is an `AssetId`. The prop registry and the asset registry are the same +registry — there is no separate table of things you are allowed to put in a +room, and an unregistered id resolves to a placeholder box rather than throwing, +so one typo does not stop the office opening. + +`position` is on the floor plane; `elevation` is metres above the level's floor +and is omitted for the ninety per cent of things that stand on it. `colorKey` is +an **opaque** palette key resolved by the consuming app, exactly as +`Pin.colorKey` is — the engine will never learn that `"focus"` means a quiet +booth. `seat` binds a prop to a seat id as an address, which is what lets an +occupancy layer dim the empty chairs without knowing which mesh is which. + +### The yaw convention + +`Yaw` is radians about +Y. **Zero faces −Z**, and the angle increases +counter-clockwise seen from above. That is exactly `object.rotation.y`, and +nothing anywhere converts it — there is no sign flip between your file and the +scene graph. (The city's `District.gridAngle` uses degrees clockwise from north, +which reads better for a map; interiors deliberately does not.) + +An asset is built with its **origin at the centre of its footprint on the +floor**, facing −Z at yaw zero, and its **working side at +Z** — drawer fronts, +the open front of a shelf, a monitor's glass, a whiteboard's writing face — with +the solid back of anything that stands against a wall at −Z. + +Those combine into one rule that covers every prop: + +> A prop takes the yaw of **the wall it backs onto**. A seat takes the yaw of +> **the direction the occupant looks**. + +A shelf against the north wall is `0`. A locker against the east wall is +`-Math.PI / 2`. A desk against the south wall is `Math.PI`, and the person at it +is also `Math.PI`, looking south at it. This is why a `DeskBank` can hand one +`rotation` to its desk, its chair and the seat between them. + +### The one exception: ceiling fixtures + +`tera:light.pendant` and `tera:light.troffer` are authored with their origin at +the **mounting plane** and all their geometry below it. Write `elevation: 2.8` +and you get a fitting hanging from a 2.8 m ceiling, rather than a fitting whose +author had to know your ceiling height. Everything else, including things that +stand on a desk or hang on a wall, is authored on the floor. + +Light fittings emit **no light**. The interior rig belongs to the scene +(CONTRACT.md §4). + +### Wall-mounted things + +A prop's origin is the centre of its footprint, so a panel hangs *on* a wall +only if you place its centre half its own depth off the wall's face: +`wallLine + thickness / 2 + depth / 2`. `tera:screen.wall-display` is 0.12 m +deep and `tera:whiteboard` is 0.10 m. Name those offsets as constants; you will +use them a dozen times. + +## Zones + +A named region of floor with an opaque `colorKey`, no behaviour and no effect on +geometry. A consuming app can highlight it, filter against it or count what is +inside it. Whether "eng" is a team, a cost centre or a colour scheme is not the +engine's business, which is why there is no `kind` field to be tempted by. + +## Viewpoints + +```ts +{ id: "floor", label: "The Whole Floor", shortLabel: "The Floor", number: "01", + description: "…", levelId: "level-1", + focus: { at: { x: 17, z: 9 }, distance: 32, height: 14, rotation: 0.55 } } +``` + +`distance` is metres from the target to the camera; `height` is metres above the +level's floor. An office is not a city — 30 m is the whole building and 6 m is +standing on a mezzanine, where the equivalent city numbers are in the hundreds. + +**`viewpoints[0]` is the arrival pose.** Reception is the obvious choice; the +reference pack puts the establishing shot there instead, on the grounds that +arriving inside a room before you have seen the shape of the building is +disorienting. Either is fine — that is a choice a pack gets to make, which is +why the field is an order and not an id. + +## Surfaces + +`Room.floor`, `Wall.surface`, `RoomCeiling.surface` and `Level.wallSurface` take +a `SurfaceId` string. `MaterialRegistry.resolve` throws away the namespace, +reads the **first dot-segment** as a role name, tries a small alias table, and +falls back rather than throwing: + +``` +tera:carpet.loop -> carpet +acme:carpet.broadloom -> carpet +tera:wood.plank -> woodFloor (via the alias table) +tera:glass.curtain -> glazing (alias) +tera:carpetAccent.x -> carpetAccent (an exact role name) +tera:nonsense.at.all -> the caller's fallback +``` + +The closed list of roles lives in `src/assets/materials.ts`. Aliases today +include `paint`, `plasterboard`, `wall`, `wood`, `timber`, `concrete`, `glass`, +`ceiling`, `felt`, `fabric`, `laminate`, `steel`, `metal`, `aluminium`, +`screen`, `plant`. + +There is deliberately **no per-instance tint on a surface**, unlike +`Prop.colorKey`. One blue meeting room is a *material* — register +`acme:paint.blue` and point the wall at it. One red chair in a row of grey ones +is genuinely an instance. + +## Registering your own assets instead of forking + +You do not fork this repo to change what a desk looks like. Register your own +asset with an `overrides:` pointing at the built-in id: + +```ts +import { defineAsset, kit } from "../assets/kit.ts"; + +type StandingParams = { width: number; depth: number; height: number }; + +kit.register(defineAsset({ + id: "acme:desk.standing", + overrides: "tera:desk.workstation", // ← every reference to the tera id resolves here + label: "Standing desk", + defaults: { width: 1.6, depth: 0.8, height: 1.05 }, + footprint(p) { + return { width: p.width, depth: p.depth, height: p.height, clearance: 0.9 }; + }, + build(p, ctx) { + /* compose cached unit primitives from ctx.parts into a MeshBin */ + }, +})); +``` + +Declare the parameter type as a `type` alias and not an `interface`: an +`interface` has no implicit index signature and will not satisfy `AssetParams`. + +Every pack in the world that says `tera:desk.workstation` — including the +reference office, unmodified — now draws yours. That is the mechanism working as +designed: reskin, do not fork. Point an id at your own namespace *in a pack* +only when you want that one building to differ. + +**Everything is procedural.** A mesh is a function composing cached unit +primitives; a texture is drawn on a 2D canvas from seeded noise. **No binary art +is ever committed to `src/`** — no `.glb`, no `.png`, no fonts, no logos. This +is the same property that gives the city zero asset-licensing exposure and it is +worth more than the quality ceiling it costs. Your *own* binary assets are your +business: `public/props/`, `public/kits/` and `public/offices/` are hard-exempt +from the repo's binary gate and gitignored as self-hoster space. + +If you contribute an asset back, `CONTRIBUTING.md` and `src/assets/LICENSE-ART` +are the terms: Apache-2.0 on the code, with the artistic output additionally +dedicated under CC0-1.0. + +## Validation: what `Plan` forgives and what it drops + +`new Plan(office)` resolves the whole thing once and never throws. Every +complaint lands in `plan.problems`, so a pack can be asserted on in a test +without capturing console output. In a dev build it also warns. + +**Dropped** (the offending item disappears, the rest of the pack survives): + +- self-intersecting or degenerate room outlines, zero-length walls +- an opening that runs past either end of its wall, or overlaps another opening +- `head <= sill` +- a desk bank with fewer than one station, or a non-positive pitch +- a viewpoint on a level that does not exist +- duplicate ids — **ids are unique per kind and building-wide, not per level**, + because a `Presence` binds to a seat id and an occupancy layer dims a prop id, + so both have to mean one thing in the building. Later loses. + +**Repaired** (silently, and recorded): + +- an outline hand-closed with a duplicated first point +- an outline wound the wrong way +- a negative sill clamped to the floor; a head above the wall clamped down +- a prop bound to an unknown seat id — the binding is cleared, the prop stays + +Missing required arrays read as `[]`, because a pack that arrived over HTTP has +been through no type checker. + +Opening ids are `"${wallId}#${authoredIndex}"` using the **authored** index, so +dropping one bad opening does not renumber its siblings. + +## Shipping a pack + +- **In the browser.** Import it and hand it to `Plan`. The reference office is + the default; that is the zero-config path and it needs nothing else. +- **Over HTTP.** Drop the JSON in the server's office directory and it is served + at `GET /api/v1/offices/:id` wrapped in an `OfficeDoc` + (`{ id, name, floor, visibility, updated? }`, defined in `src/server/wire.ts`). + A pack that omits `visibility` is treated as **private**, and a private office + returns **404, not 403**, so the endpoint cannot be used to enumerate what + exists. + +## A checklist before you call it done + +1. `new Plan(office).problems` is empty. +2. Every room you can walk into has a `door` or `arch` with `sill: 0` reaching + at least 1.1 m of head. Walk the graph, or spot-check with `Plan.blocked`. +3. No window opening has `sill: 0` unless you meant a doorway. +4. Seat ids are the ones you are willing to live with for a year. +5. Corridors are at least 1.2 m clear, doors 0.9 m, desks 1.4–1.6 m. Numbers a + person would recognise are the whole difference between a floor plan and a + diagram. +6. Nothing binary landed under `src/`. diff --git a/src/offices/lumbridge-hq.ts b/src/offices/lumbridge-hq.ts new file mode 100644 index 0000000..b97c560 --- /dev/null +++ b/src/offices/lumbridge-hq.ts @@ -0,0 +1,1292 @@ +/** + * Lumbridge HQ — the reference office pack, and the file you copy. + * + * Pure data against `src/interiors/types.ts`. One level, fifteen rooms, three + * hundred props and seventy-six seats, on a 34 x 18 m floor plate: a + * lobby, an open desk floor, three meeting rooms at three different sizes, a + * strip of focus booths, a kitchen, a lounge, a quiet room, a workshop, a server + * room, a store, and the circulation that ties them together. + * + * **This file is the dev kit.** It is not here because Lumbridge needs an office + * in a repo; it is here because a stranger who clones this should be able to + * open one file, recognise a floor plan, change six numbers and have their own + * office. So the dimensions are real ones — 2.8 m ceilings, 0.9 m doors, 1.6 m + * desks, 1.8 m corridors — and the reasoning is on the page next to the numbers + * rather than in a design document nobody reads. `src/offices/README.md` is the + * companion: it explains the format, this explains the choices. + * + * The engine still knows nothing. A room called "Alcatraz" is a polygon with a + * label, `colorKey: "focus"` is an opaque string, and seat `eng-04` is a spot on + * the floor with a name. Nothing here tells the renderer that anybody works in + * this building — see ARCHITECTURE.md §3.3, and the note on `Presence` in + * `interiors/types.ts`, which is the same rule one level in. + * + * ### The coordinate frame + * + * Metres, `1 unit = 1 m`, the floor on the XZ plane with +Y up. The origin is + * the **north-west corner** of the slab, +X runs east and +Z runs south, so in + * plan view — looking down, +X to the right — +Z goes *down the page* and the + * glazed façade along `z = 0` is at the top. + * + * Calling one edge "north" is a convenience for reading this file and nothing + * more. An `Office` has no orientation on the earth and no sun; it is not a + * city. If you rotate the whole building in your head, the only thing that + * changes is the vocabulary in these comments. + * + * ### Which way things face + * + * `Yaw` is `object.rotation.y`: zero points at −Z, and the angle increases + * counter-clockwise seen from above. An asset's **front is at −Z and its working + * side at +Z** (`src/assets/office/common.ts`), which combine into one rule that + * covers every prop in this file: + * + * > A prop takes the yaw of *the wall it backs onto*, and a seat takes the yaw + * > of *the direction the occupant looks*. + * + * So a shelf against the north wall is `NORTH`, a locker against the east wall + * is `EAST`, and someone at a desk that also reads `NORTH` is looking north at + * it. Both halves fall out of the same convention, which is why a `DeskBank` + * can hand one `rotation` to a desk, its chair and the seat between them. + */ + +import type { + AssetId, + DeskBank, + Level, + Office, + Opening, + Outline, + Point2, + Prop, + Room, + Seat, + Viewpoint, + Wall, + Yaw, + Zone, +} from "../interiors/types.ts"; + +// ---- The floor plate ------------------------------------------------------ + +/** + * The building, in six numbers. Everything else in this file is measured off + * these, so a self-hoster with a different slab starts here. + * + * 34 x 18 m is 612 m², which is a plausible single floor of a mid-rise: deep + * enough for a row of rooms behind a corridor, shallow enough that the desks + * still get daylight. Go much deeper and the middle of the plate becomes a + * place nobody wants to sit, which is a real constraint and not a rendering one. + */ +const WIDTH = 34.0; +const DEPTH = 18.0; + +/** Floor to underside of ceiling. 2.8 m is an ordinary commercial storey. */ +const CEILING = 2.8; + +/** + * The booths get a lower lid than the room they stand in. That is what makes a + * booth feel like a booth rather than a cupboard with the top cut off, and it + * is the reason `Room.ceiling` is a per-room override at all. + */ +const BOOTH_CEILING = 2.3; + +/** + * Exterior walls are thicker than partitions and say so; the level's default + * (0.12 m) covers the thirty-odd internal ones. A wall that does not name a + * thickness inherits the level's, which is the whole point of the defaults — + * the interesting wall should be the one that stands out in the source. + */ +const EXT_THICKNESS = 0.25; +const INT_THICKNESS = 0.12; + +/** Half-thicknesses, used constantly to sit a prop against a wall face. */ +const EXT_FACE = EXT_THICKNESS / 2; +const INT_FACE = INT_THICKNESS / 2; + +/** + * How far the centre of a wall-hung panel sits off the wall's face. + * + * `tera:screen.wall-display` is 0.12 m deep and `tera:whiteboard` 0.10 m, and a + * prop's origin is the centre of its footprint — so half the depth plus a + * millimetre of reveal is what makes a display hang *on* a wall rather than + * half inside it. Worth naming rather than sprinkling 0.07 through the file, + * because the moment somebody registers a thicker panel over `overrides:` these + * are the two numbers that have to move with it. + */ +const DISPLAY_OFFSET = 0.07; +const BOARD_OFFSET = 0.06; + +// The lines the plan is built on. Rooms and walls are authored against the same +// numbers on purpose: a wall is centred on its line and straddles the boundary +// between the two floor slabs that meet there, so if the slab edge and the wall +// line disagree by a centimetre you get a seam you will never find again. +const SPINE_N = 10.4; // open floor / lobby / kitchen give way to the corridor +const SPINE_S = 12.2; // corridor gives way to the south rooms +const LOBBY_E = 7.2; // lobby / open desk floor +const SOCIAL_W = 25.6; // open desk floor / lounge and kitchen +const KITCHEN_N = 6.0; // lounge / kitchen, an open boundary with no wall on it +const BOOTH_N = 8.6; // front of the focus booths + +// South-room divisions, west to east. These are the only widths in the file +// that were chosen by what has to fit inside rather than by a grid. +const X_ALCATRAZ = 0.0; +const X_BERNAL = 7.2; +const X_BOLINAS = 12.4; +const X_QUIET = 16.0; +const X_WORKSHOP = 19.6; +const X_SERVER = 26.4; +const X_STORE = 30.2; + +// Booth divisions. Two metres each, sharing party walls, backing onto the +// corridor wall — three booths out of eleven metres of otherwise dead frontage. +const X_BOOTH_1 = 8.4; +const X_BOOTH_2 = 10.4; +const X_BOOTH_3 = 12.4; +const X_BOOTH_E = 14.4; + +// ---- Yaw ------------------------------------------------------------------ + +/** + * The four right angles, named for the direction a prop's *front* points. + * + * Reading them takes the one rule from the header: a thing standing against a + * wall points its front at that wall, so `NORTH` is both "the shelf on the north + * wall" and "the person looking north". There is no sign flip anywhere between + * here and `object.rotation.y`. + */ +const NORTH: Yaw = 0; +const EAST: Yaw = -Math.PI / 2; +const SOUTH: Yaw = Math.PI; +const WEST: Yaw = Math.PI / 2; + +// ---- Assets --------------------------------------------------------------- + +/** + * Every asset this pack places, named once. + * + * They are all `tera:` ids from `src/assets/office/`. Bind them to constants + * rather than repeating the strings, because the day you swap every workstation + * in the building for `acme:desk.standing` should be a one-line day — and if it + * is more than one line, you wanted the `overrides:` mechanism instead, which + * reskins `tera:desk.workstation` itself and needs no edit here at all. The + * README's "Registering your own assets" section is the longer version. + */ +const DESK: AssetId = "tera:desk.workstation"; +const PEDESTAL: AssetId = "tera:desk.pedestal"; +const PARTITION: AssetId = "tera:desk.partition"; +const TASK_CHAIR: AssetId = "tera:seat.task-chair"; +const LOUNGE_CHAIR: AssetId = "tera:seat.lounge"; +const MEETING_TABLE: AssetId = "tera:table.meeting"; +const SIDE_TABLE: AssetId = "tera:table.side"; +const SHELF: AssetId = "tera:storage.shelf"; +const LOCKER: AssetId = "tera:storage.locker"; +const MONITOR: AssetId = "tera:screen.monitor"; +const DISPLAY: AssetId = "tera:screen.wall-display"; +const PLANT: AssetId = "tera:plant.potted"; +const TREE: AssetId = "tera:plant.tall"; +const PENDANT: AssetId = "tera:light.pendant"; +const TROFFER: AssetId = "tera:light.troffer"; +const RUG: AssetId = "tera:rug"; +const WHITEBOARD: AssetId = "tera:whiteboard"; + +/** + * Surface ids, which are looser than they look. + * + * `MaterialRegistry.resolve` throws away the namespace and reads the **first + * dot-segment** as a role, through a small alias table, and falls back rather + * than throwing. So `tera:wood.plank`, `acme:wood.reclaimed` and a bare `wood` + * are all the `woodFloor` role, and a typo renders in the fallback material + * instead of failing the build. Naming them here keeps the whole palette of the + * building in one screen, which is what you actually want when you decide the + * meeting rooms should have carpet after all. + */ +const WOOD = "tera:wood.plank"; +const CARPET = "tera:carpet.loop"; +const CARPET_ACCENT = "tera:carpetAccent.broadloom"; +const CONCRETE = "tera:concrete.polished"; +const TERRAZZO = "tera:tile.terrazzo"; +const RAISED_FLOOR = "tera:floorSlab.raised"; +const PAINT = "tera:paint.matt"; +const ACCENT_PAINT = "tera:plasterAccent.deep"; +const GLASS = "tera:glass.curtain"; +const FELT = "tera:felt.acoustic"; +const CEILING_TILE = "tera:ceiling.tile"; + +// ---- Authoring helpers ---------------------------------------------------- + +/* + * Five functions, and they all run at module load and return plain objects. + * + * The exported `Office` is still exactly what `interiors/types.ts` demands — + * JSON-serialisable data with no functions in it — and would survive + * `JSON.parse(JSON.stringify(…))` unchanged. These exist because the + * alternative is four hundred literals in which every seventh one has a typo: + * a floor of ceiling lights is a grid, a bench of monitor arms is a line, and + * the winding rule for an outline is the sort of thing a human gets right + * fourteen times and wrong on the fifteenth. + * + * A pack shipped as `.json` spells all of this out. That is fine; it is a build + * product of a file like this one. + */ + +/** + * An axis-aligned room outline, wound counter-clockwise **in plan view**. + * + * Worth being precise about, since it is the one place the frame bites: plan + * view has +Z going down the page, so a polygon that looks clockwise on paper + * has a *negative* shoelace area over (x, z). Going NW → SW → SE → NE, as this + * does, is the counter-clockwise one. `Plan` silently re-winds anything that + * arrives the other way round, so getting it wrong costs nothing — but getting + * it right means the outlines in `plan.problems` are the ones with real + * problems. + */ +function rect(x0: number, z0: number, x1: number, z1: number): Outline { + return [ + { x: x0, z: z0 }, + { x: x0, z: z1 }, + { x: x1, z: z1 }, + { x: x1, z: z0 }, + ]; +} + +/** + * A regular grid of points, columns first. `rows: 1` gives a line, which is how + * most of the calls below use it. + */ +function grid( + x0: number, + z0: number, + columns: number, + rows: number, + dx: number, + dz: number, +): Point2[] { + const points: Point2[] = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < columns; c++) { + points.push({ x: x0 + c * dx, z: z0 + r * dz }); + } + } + return points; +} + +/** + * One asset at each of `points`, with ids `${prefix}-01`, `-02`, … + * + * Same numbering scheme as `DeskBank` expansion, deliberately: two digits from + * one, in the order the points were generated, so a prop id is something you can + * work out on paper instead of by running the code. + */ +function scatter( + prefix: string, + kind: AssetId, + points: Point2[], + opts: { rotation?: Yaw; elevation?: number; colorKey?: string } = {}, +): Prop[] { + return points.map((position, i) => ({ + id: `${prefix}-${String(i + 1).padStart(2, "0")}`, + kind, + position, + rotation: opts.rotation ?? NORTH, + elevation: opts.elevation, + colorKey: opts.colorKey, + })); +} + +/** + * A doorway. 0.9 m x 2.1 m is a standard single leaf; the wide ones in this + * building say so at the call site. + * + * An opening with `sill: 0` that clears 1.1 m of head is what `Plan` treats as + * walkable, so this is also the thing that punches the gap in the collision + * segments. That is the whole reason doors are openings and not props. + */ +function doorway(start: number, width = 0.9, head = 2.1): Opening { + return { kind: "door", start, width, sill: 0, head }; +} + +/** + * A window. The sill is what keeps it out of the collider — an opening a walker + * could step through is a door whatever you call it, so a glazed hole at + * ankle height would quietly become a hole in the wall you can walk through. + */ +function pane(start: number, width: number, sill = 0.9, head = 2.2): Opening { + return { kind: "window", start, width, sill, head }; +} + +/** A cased opening with no leaf in it — the way you get from A to B indoors. */ +function archway(start: number, width: number, head = 2.4): Opening { + return { kind: "arch", start, width, sill: 0, head }; +} + +// ---- Rooms ---------------------------------------------------------------- + +/** + * The fifteen slabs. + * + * A `Room` is a floor finish with a name and **implies no walls** — the walls + * are the separate list below, and the two are not derived from each other. + * That means an open plan is simply rooms with nothing standing between them: + * the lounge, the kitchen and the desk floor share three edges and only one of + * those edges carries a wall. + * + * `ceiling: null` means no ceiling at all, and nearly every room here declares + * it. The reason is the establishing viewpoint: an office you look down into + * cannot have lids on the rooms you are trying to look into. The booths, the + * server room and the store keep theirs, because those are the three rooms you + * are never meant to see inside, and a ceiling is a cheap way of saying so. + * + * Order matters in one respect: `Plan.roomAt` resolves later rooms first, so a + * room laid on top of another wins the lookup. Nothing here overlaps — the open + * floor is notched around the booths rather than passing underneath them, since + * two coplanar slabs at y = 0 is a z-fight waiting for the wrong GPU. + */ +const ROOMS: Room[] = [ + // -- North band: the daylit half ------------------------------------------ + { + id: "lobby", + name: "Reception", + outline: rect(0, 0, LOBBY_E, SPINE_N), + floor: WOOD, + ceiling: null, + }, + { + id: "open-floor", + name: "The Floor", + // Notched around the booth block on its southern edge. Eight points rather + // than four, and the only non-rectangular room in the building. + outline: [ + { x: LOBBY_E, z: 0 }, + { x: LOBBY_E, z: SPINE_N }, + { x: X_BOOTH_1, z: SPINE_N }, + { x: X_BOOTH_1, z: BOOTH_N }, + { x: X_BOOTH_E, z: BOOTH_N }, + { x: X_BOOTH_E, z: SPINE_N }, + { x: SOCIAL_W, z: SPINE_N }, + { x: SOCIAL_W, z: 0 }, + ], + floor: CARPET, + ceiling: null, + }, + { + id: "lounge", + name: "The Lounge", + outline: rect(SOCIAL_W, 0, WIDTH, KITCHEN_N), + floor: CARPET_ACCENT, + ceiling: null, + }, + { + id: "kitchen", + name: "The Kitchen", + outline: rect(SOCIAL_W, KITCHEN_N, WIDTH, SPINE_N), + floor: TERRAZZO, + ceiling: null, + }, + + // -- The booths ----------------------------------------------------------- + // Three rooms of four square metres. They are rooms and not props because + // they have walls, a door and a lid, which is the whole difference. + { + id: "booth-1", + name: "Booth 1", + outline: rect(X_BOOTH_1, BOOTH_N, X_BOOTH_2, SPINE_N), + floor: CARPET_ACCENT, + ceiling: { height: BOOTH_CEILING, surface: CEILING_TILE }, + }, + { + id: "booth-2", + name: "Booth 2", + outline: rect(X_BOOTH_2, BOOTH_N, X_BOOTH_3, SPINE_N), + floor: CARPET_ACCENT, + ceiling: { height: BOOTH_CEILING, surface: CEILING_TILE }, + }, + { + id: "booth-3", + name: "Booth 3", + outline: rect(X_BOOTH_3, BOOTH_N, X_BOOTH_E, SPINE_N), + floor: CARPET_ACCENT, + ceiling: { height: BOOTH_CEILING, surface: CEILING_TILE }, + }, + + // -- Circulation ---------------------------------------------------------- + { + id: "corridor", + name: "Circulation", + // 1.8 m clear, running the full 34 m. A corridor is a room like any other; + // giving it an id is what lets a walk-mode controller, a wayfinding overlay + // or a cleaning schedule refer to it without inventing a second concept. + outline: rect(0, SPINE_N, WIDTH, SPINE_S), + floor: CONCRETE, + ceiling: null, + }, + + // -- South band: the enclosed half ---------------------------------------- + { + id: "alcatraz", + name: "Alcatraz", + outline: rect(X_ALCATRAZ, SPINE_S, X_BERNAL, DEPTH), + floor: CARPET, + ceiling: null, + }, + { + id: "bernal", + name: "Bernal", + outline: rect(X_BERNAL, SPINE_S, X_BOLINAS, DEPTH), + floor: CARPET, + ceiling: null, + }, + { + id: "bolinas", + name: "Bolinas", + outline: rect(X_BOLINAS, SPINE_S, X_QUIET, DEPTH), + floor: CARPET, + ceiling: null, + }, + { + id: "lands-end", + name: "Lands End", + outline: rect(X_QUIET, SPINE_S, X_WORKSHOP, DEPTH), + floor: CARPET_ACCENT, + ceiling: null, + }, + { + id: "workshop", + name: "Dogpatch", + outline: rect(X_WORKSHOP, SPINE_S, X_SERVER, DEPTH), + floor: CONCRETE, + ceiling: null, + }, + { + id: "server-room", + name: "Farallon", + // A raised floor and a lid, because this is the one room in the building + // whose finishes are doing a job rather than making an impression. + outline: rect(X_SERVER, SPINE_S, X_STORE, DEPTH), + floor: RAISED_FLOOR, + ceiling: { height: 2.6, surface: CEILING_TILE }, + }, + { + id: "store", + name: "Facilities", + outline: rect(X_STORE, SPINE_S, WIDTH, DEPTH), + floor: CONCRETE, + ceiling: { height: 2.6, surface: CEILING_TILE }, + }, +]; + +// ---- Walls ---------------------------------------------------------------- + +/** + * Twenty-eight segments, and every hole in the building. + * + * A wall belongs to no room; it stands where it is put, from `from` to `to`, + * centred on that line. `Opening.start` is measured **from the `from` end**, so + * the direction a wall is written in is the direction its openings are measured + * in — which is why every wall below is written west-to-east or north-to-south, + * with no exceptions. Reversing one and forgetting is how a door ends up at the + * wrong end of a room. + */ +const WALLS: Wall[] = [ + // -- The envelope --------------------------------------------------------- + { + id: "ext-north", + // The daylight side. Six 4 m ribbon windows on 1.2 m piers: enough glass + // that the desk floor reads as a place with a view, enough wall that the + // building still has corners. + from: { x: 0, z: 0 }, + to: { x: WIDTH, z: 0 }, + thickness: EXT_THICKNESS, + surface: PAINT, + openings: [ + pane(1.2, 4.0, 0.75, 2.35), + pane(6.4, 4.0, 0.75, 2.35), + pane(11.6, 4.0, 0.75, 2.35), + pane(16.8, 4.0, 0.75, 2.35), + pane(22.0, 4.0, 0.75, 2.35), + pane(27.2, 4.0, 0.75, 2.35), + ], + }, + { + id: "ext-east-glazed", + // Full-height glazing as a *wall with a glass surface*, not as one enormous + // opening. This is the distinction the format turns on: an opening is a hole + // and a hole is something you can see and sometimes walk through, whereas a + // curtain wall is a solid you happen to be able to see through. Making it an + // opening would hand the collider a 12 m gap and put the lounge on the + // pavement. + from: { x: WIDTH, z: 0 }, + to: { x: WIDTH, z: SPINE_S }, + thickness: EXT_THICKNESS, + surface: GLASS, + }, + { + id: "ext-east-solid", + from: { x: WIDTH, z: SPINE_S }, + to: { x: WIDTH, z: DEPTH }, + thickness: EXT_THICKNESS, + surface: PAINT, + }, + { + id: "ext-south", + // Written west-to-east like everything else, so the openings read left to + // right on the plan. One window per room that deserves one; the server room + // (26.4 → 30.2) deliberately gets none. + from: { x: 0, z: DEPTH }, + to: { x: WIDTH, z: DEPTH }, + thickness: EXT_THICKNESS, + surface: PAINT, + openings: [ + pane(1.0, 4.4), + pane(7.8, 3.8), + pane(12.8, 2.6), + pane(16.4, 2.6), + pane(20.2, 5.4), + doorway(32.0), // the escape stair, through the store + ], + }, + { + id: "ext-west", + from: { x: 0, z: 0 }, + to: { x: 0, z: DEPTH }, + thickness: EXT_THICKNESS, + surface: PAINT, + openings: [ + // The way in, from the lift lobby that is not part of this pack. A 1.8 m + // pair of leaves and a 2.4 m head, because an entrance that reads like an + // internal door is the first thing that makes a model feel like a model. + doorway(4.0, 1.8, 2.4), + pane(7.4, 2.4), + ], + }, + + // -- Internal, north band ------------------------------------------------- + { + id: "lobby-east", + from: { x: LOBBY_E, z: 0 }, + to: { x: LOBBY_E, z: SPINE_N }, + surface: ACCENT_PAINT, + openings: [archway(6.4, 2.4)], + }, + { + id: "kitchen-west", + // The kitchen's only wall. You can also reach it by walking round through + // the lounge, which is exactly how real offices work and is why the lounge + // has no walls at all. + from: { x: SOCIAL_W, z: KITCHEN_N }, + to: { x: SOCIAL_W, z: SPINE_N }, + openings: [archway(1.6, 1.8)], + }, + { + id: "spine-north", + // One 34 m wall with three holes in it, rather than five walls that have to + // agree about where they meet. The stretch from 8.4 to 14.4 is the back of + // the focus booths and is solid for that reason. + from: { x: 0, z: SPINE_N }, + to: { x: WIDTH, z: SPINE_N }, + openings: [ + doorway(1.6), // lobby + archway(16.0, 3.0), // the main way off the desk floor + doorway(31.6), // kitchen + ], + }, + + // -- The focus booths ----------------------------------------------------- + // Seven short walls at 2.3 m — a head above a standing person and well below + // the ceiling, so the booths read as furniture-scale objects standing on the + // floor rather than as rooms carved out of it. Their fourth side is + // `spine-north`, which they share with the corridor: a wall belongs to no + // room, so nothing needs to be said about that here. + { + id: "booth-w", + from: { x: X_BOOTH_1, z: BOOTH_N }, + to: { x: X_BOOTH_1, z: SPINE_N }, + height: BOOTH_CEILING, + surface: FELT, + }, + { + id: "booth-p1", + from: { x: X_BOOTH_2, z: BOOTH_N }, + to: { x: X_BOOTH_2, z: SPINE_N }, + height: BOOTH_CEILING, + surface: FELT, + }, + { + id: "booth-p2", + from: { x: X_BOOTH_3, z: BOOTH_N }, + to: { x: X_BOOTH_3, z: SPINE_N }, + height: BOOTH_CEILING, + surface: FELT, + }, + { + id: "booth-e", + from: { x: X_BOOTH_E, z: BOOTH_N }, + to: { x: X_BOOTH_E, z: SPINE_N }, + height: BOOTH_CEILING, + surface: FELT, + }, + { + id: "booth-1-front", + from: { x: X_BOOTH_1, z: BOOTH_N }, + to: { x: X_BOOTH_2, z: BOOTH_N }, + height: BOOTH_CEILING, + surface: FELT, + openings: [doorway(0.55)], + }, + { + id: "booth-2-front", + from: { x: X_BOOTH_2, z: BOOTH_N }, + to: { x: X_BOOTH_3, z: BOOTH_N }, + height: BOOTH_CEILING, + surface: FELT, + openings: [doorway(0.55)], + }, + { + id: "booth-3-front", + from: { x: X_BOOTH_3, z: BOOTH_N }, + to: { x: X_BOOTH_E, z: BOOTH_N }, + height: BOOTH_CEILING, + surface: FELT, + openings: [doorway(0.55)], + }, + + // -- Room fronts on the corridor ------------------------------------------ + // One wall per room rather than one long wall with seven doors, because these + // are the walls whose *surface* differs: the two glazed meeting rooms are the + // reason you can tell from the corridor whether a room is free. + { + id: "front-alcatraz", + from: { x: X_ALCATRAZ, z: SPINE_S }, + to: { x: X_BERNAL, z: SPINE_S }, + // A vision panel rather than a glass wall — the board room is the one room + // people want to be able to close. Sill at 0.9 keeps it out of the collider. + openings: [pane(1.0, 3.2, 0.9, 2.3), doorway(5.6)], + }, + { + id: "front-bernal", + from: { x: X_BERNAL, z: SPINE_S }, + to: { x: X_BOLINAS, z: SPINE_S }, + surface: GLASS, + openings: [doorway(0.6)], + }, + { + id: "front-bolinas", + from: { x: X_BOLINAS, z: SPINE_S }, + to: { x: X_QUIET, z: SPINE_S }, + surface: GLASS, + openings: [doorway(0.5)], + }, + { + id: "front-lands-end", + from: { x: X_QUIET, z: SPINE_S }, + to: { x: X_WORKSHOP, z: SPINE_S }, + openings: [doorway(1.4)], + }, + { + id: "front-workshop", + from: { x: X_WORKSHOP, z: SPINE_S }, + to: { x: X_SERVER, z: SPINE_S }, + // 1.6 m, because things arrive here on trolleys. + openings: [doorway(2.0, 1.6)], + }, + { + id: "front-server", + from: { x: X_SERVER, z: SPINE_S }, + to: { x: X_STORE, z: SPINE_S }, + openings: [doorway(0.9)], + }, + { + id: "front-store", + from: { x: X_STORE, z: SPINE_S }, + to: { x: WIDTH, z: SPINE_S }, + openings: [doorway(1.5)], + }, + + // -- South band cross walls ----------------------------------------------- + // No openings: you get into every one of these rooms from the corridor and + // nowhere else, which is what makes the corridor worth having. + { + id: "cross-bernal", + from: { x: X_BERNAL, z: SPINE_S }, + to: { x: X_BERNAL, z: DEPTH }, + }, + { + id: "cross-bolinas", + from: { x: X_BOLINAS, z: SPINE_S }, + to: { x: X_BOLINAS, z: DEPTH }, + }, + { + id: "cross-lands-end", + from: { x: X_QUIET, z: SPINE_S }, + to: { x: X_QUIET, z: DEPTH }, + }, + { + id: "cross-workshop", + from: { x: X_WORKSHOP, z: SPINE_S }, + to: { x: X_WORKSHOP, z: DEPTH }, + }, + { + id: "cross-server", + from: { x: X_SERVER, z: SPINE_S }, + to: { x: X_SERVER, z: DEPTH }, + }, + { + id: "cross-store", + from: { x: X_STORE, z: SPINE_S }, + to: { x: X_STORE, z: DEPTH }, + }, +]; + +// ---- Desk banks ----------------------------------------------------------- + +/** + * Thirty-nine desks in five declarations. + * + * `Plan` expands each of these into a desk prop, a chair prop and a seat per + * station, with ids you can predict without running anything: seats are + * `${seatPrefix ?? id}-01` counting along each row and then down the rows, and + * the props are `${id}-desk-01` and `${id}-chair-01`. Seat `eng-04` is the + * fourth desk in the front row of the window bench, today and after the next + * six edits to this file, which is the property a `Presence` needs. + * + * ### Why every bench is two rows and not four + * + * `facingRows` turns the first row of each pair around so a pair shares a run + * of desktop — a bench, rather than two rows of people looking at the back of + * each other's heads. It does that at the bank's single `rowPitch`, and the + * pitch that makes two rows meet back-to-back (0.85 m, a desk deep plus a cable + * trough) is nothing like the pitch you need between one bench and the next + * (2.4 m of chair, aisle and chair). One bank cannot express both, so a second + * bench is a second bank. That is a real limit of the format and not an + * oversight; the alternative is a per-pair pitch field that exists to describe + * one furniture layout. + * + * The four floor banks sit in two rows of two with a 1.3 m aisle up the middle, + * and the workshop's is a single row of three against the south wall. + */ +const DESK_BANKS: DeskBank[] = [ + { + id: "eng", + desk: DESK, + chair: TASK_CHAIR, + origin: { x: 9.2, z: 1.9 }, + rotation: NORTH, + columns: 6, + rows: 2, + pitch: 1.7, // 1.6 m desks with a 0.1 m gap + rowPitch: 0.85, + facingRows: true, + seatOffset: 0.6, + }, + { + id: "ops", + desk: DESK, + chair: TASK_CHAIR, + origin: { x: 9.2, z: 5.6 }, + rotation: NORTH, + columns: 6, + rows: 2, + pitch: 1.7, + rowPitch: 0.85, + facingRows: true, + seatOffset: 0.6, + }, + { + id: "design", + desk: DESK, + chair: TASK_CHAIR, + origin: { x: 20.6, z: 1.9 }, + rotation: NORTH, + columns: 3, + rows: 2, + pitch: 1.7, + rowPitch: 0.85, + facingRows: true, + seatOffset: 0.6, + }, + { + id: "sales", + desk: DESK, + chair: TASK_CHAIR, + origin: { x: 20.6, z: 5.6 }, + rotation: NORTH, + columns: 3, + rows: 2, + pitch: 1.7, + rowPitch: 0.85, + facingRows: true, + seatOffset: 0.6, + }, + { + id: "lab", + desk: DESK, + chair: TASK_CHAIR, + // Against the workshop's south wall, so the bench runs east-to-west and the + // columns march the other way: at `rotation: SOUTH` the bank's local +X is + // world −X, which is why the origin is the *east* end of the run. Rotate a + // bank and the origin stays station (1, 1); it does not become a corner of + // the building. + origin: { x: 24.8, z: 17.4 }, + rotation: SOUTH, + columns: 3, + rows: 1, + pitch: 1.8, + seatOffset: 0.6, + }, +]; + +// ---- Seats ---------------------------------------------------------------- + +/** + * The thirty-nine seats a `DeskBank` cannot generate: chairs round a table, + * chairs in a lounge, one chair behind a reception desk. + * + * A seat is an **address**, not a chair — the chair is a separate prop that + * happens to be at the same coordinate, and a room with no chairs in it can + * still have seats. Ids are meant to be stable across edits to this file for + * the same reason street numbers are stable across repainting the house: a + * `Presence` arriving from a private API says `bernal-04` and nothing else, and + * it has no way to notice that the table moved 200 mm. + * + * The `facing` is where the occupant looks, which for a chair at a table is + * across it. + */ +const SEATS: Seat[] = [ + // Reception + { id: "reception-01", position: { x: 4.0, z: 4.9 }, facing: WEST, pose: "sit" }, + + // The lobby's waiting pair + { id: "lobby-01", position: { x: 4.4, z: 7.35 }, facing: SOUTH, pose: "sit" }, + { id: "lobby-02", position: { x: 4.4, z: 9.05 }, facing: NORTH, pose: "sit" }, + + // Focus booths. One seat each, which is the point of them. + { id: "booth-01", position: { x: 9.4, z: 9.3 }, facing: SOUTH, pose: "sit" }, + { id: "booth-02", position: { x: 11.4, z: 9.3 }, facing: SOUTH, pose: "sit" }, + { id: "booth-03", position: { x: 13.4, z: 9.3 }, facing: SOUTH, pose: "sit" }, + + // Alcatraz — ten round a 4.8 m table, five a side, ends left clear so the + // people at them can see the display on the west wall. + { id: "alcatraz-01", position: { x: 1.8, z: 14.0 }, facing: SOUTH, pose: "sit" }, + { id: "alcatraz-02", position: { x: 2.7, z: 14.0 }, facing: SOUTH, pose: "sit" }, + { id: "alcatraz-03", position: { x: 3.6, z: 14.0 }, facing: SOUTH, pose: "sit" }, + { id: "alcatraz-04", position: { x: 4.5, z: 14.0 }, facing: SOUTH, pose: "sit" }, + { id: "alcatraz-05", position: { x: 5.4, z: 14.0 }, facing: SOUTH, pose: "sit" }, + { id: "alcatraz-06", position: { x: 1.8, z: 16.0 }, facing: NORTH, pose: "sit" }, + { id: "alcatraz-07", position: { x: 2.7, z: 16.0 }, facing: NORTH, pose: "sit" }, + { id: "alcatraz-08", position: { x: 3.6, z: 16.0 }, facing: NORTH, pose: "sit" }, + { id: "alcatraz-09", position: { x: 4.5, z: 16.0 }, facing: NORTH, pose: "sit" }, + { id: "alcatraz-10", position: { x: 5.4, z: 16.0 }, facing: NORTH, pose: "sit" }, + + // Bernal — six, three a side of a table turned through ninety degrees. + { id: "bernal-01", position: { x: 8.8, z: 14.1 }, facing: EAST, pose: "sit" }, + { id: "bernal-02", position: { x: 8.8, z: 15.0 }, facing: EAST, pose: "sit" }, + { id: "bernal-03", position: { x: 8.8, z: 15.9 }, facing: EAST, pose: "sit" }, + { id: "bernal-04", position: { x: 10.8, z: 14.1 }, facing: WEST, pose: "sit" }, + { id: "bernal-05", position: { x: 10.8, z: 15.0 }, facing: WEST, pose: "sit" }, + { id: "bernal-06", position: { x: 10.8, z: 15.9 }, facing: WEST, pose: "sit" }, + + // Bolinas — four. The smallest room that is still worth booking. + { id: "bolinas-01", position: { x: 13.2, z: 14.45 }, facing: EAST, pose: "sit" }, + { id: "bolinas-02", position: { x: 13.2, z: 15.55 }, facing: EAST, pose: "sit" }, + { id: "bolinas-03", position: { x: 15.2, z: 14.45 }, facing: WEST, pose: "sit" }, + { id: "bolinas-04", position: { x: 15.2, z: 15.55 }, facing: WEST, pose: "sit" }, + + // Lands End, the quiet room + { id: "quiet-01", position: { x: 17.8, z: 14.0 }, facing: SOUTH, pose: "sit" }, + { id: "quiet-02", position: { x: 17.8, z: 16.0 }, facing: NORTH, pose: "sit" }, + + // The lounge, four chairs round a low table + { id: "lounge-01", position: { x: 29.6, z: 1.7 }, facing: SOUTH, pose: "sit" }, + { id: "lounge-02", position: { x: 29.6, z: 4.3 }, facing: NORTH, pose: "sit" }, + { id: "lounge-03", position: { x: 28.2, z: 3.0 }, facing: EAST, pose: "sit" }, + { id: "lounge-04", position: { x: 31.0, z: 3.0 }, facing: WEST, pose: "sit" }, + + // The kitchen: three at the island, two by the glass. `pose: "stand"` at the + // island because people perch there, and the pose is the only thing that tells + // a consumer how tall an occupant should be drawn. + { id: "kitchen-01", position: { x: 28.2, z: 8.6 }, facing: NORTH, pose: "stand" }, + { id: "kitchen-02", position: { x: 29.0, z: 8.6 }, facing: NORTH, pose: "stand" }, + { id: "kitchen-03", position: { x: 29.8, z: 8.6 }, facing: NORTH, pose: "stand" }, + { id: "kitchen-04", position: { x: 32.6, z: 6.9 }, facing: SOUTH, pose: "sit" }, + { id: "kitchen-05", position: { x: 32.6, z: 8.6 }, facing: NORTH, pose: "sit" }, +]; + +// ---- Props ---------------------------------------------------------------- + +/** + * Everything that is not a wall, a floor or a desk bank. + * + * Grouped by room and in the order you would walk through the building, because + * that is the order in which you will want to change them. Ids carry their room + * as a prefix so that a prop id is legible on its own — `alcatraz-display` tells + * you where to look; `prop-142` does not. + * + * ### On monitors, and the thing this pack deliberately does not do + * + * There are thirty-nine desks here and no monitors on any of them, which at + * first looks like an omission. `DeskBank` places a desk and a chair and no + * third thing, so monitors would be thirty-nine hand-written props that have to + * be kept in step with a bank you are going to move next week. + * + * The format's answer is the asset override. Register + * `acme:desk.workstation` with `overrides: "tera:desk.workstation"`, build a + * desk with a monitor on it, and every station in every bank in every pack has + * one — with no edit to this file, and no fork. That is the mechanism working + * as designed, and it is why the monitors that *are* here are the ones that + * belong to a place rather than to a desk: reception, and the two meeting rooms. + */ +const PROPS: Prop[] = [ + // -- Reception ------------------------------------------------------------ + // Set back four metres from the entrance and turned to face it. The desk's + // working side is at its local +Z, so at `WEST` the receptionist is on its + // east flank looking back at the door. + { id: "lobby-desk", kind: DESK, position: { x: 3.4, z: 4.9 }, rotation: WEST }, + { id: "lobby-chair", kind: TASK_CHAIR, position: { x: 4.0, z: 4.9 }, rotation: WEST }, + { + id: "lobby-monitor", + kind: MONITOR, + position: { x: 3.4, z: 5.2 }, + rotation: WEST, + // Standing on the desktop. Desk assets are 0.73 m to the deck, and a monitor + // is authored on the floor like everything else, so the pack supplies the + // height rather than the asset assuming one. + elevation: 0.73, + }, + { id: "lobby-pedestal", kind: PEDESTAL, position: { x: 3.4, z: 3.8 }, rotation: WEST }, + + // The waiting pair, on a rug, under two pendants. + { id: "lobby-rug", kind: RUG, position: { x: 4.4, z: 8.2 }, rotation: NORTH }, + { id: "lobby-sofa-n", kind: LOUNGE_CHAIR, position: { x: 4.4, z: 7.35 }, rotation: SOUTH }, + { id: "lobby-sofa-s", kind: LOUNGE_CHAIR, position: { x: 4.4, z: 9.05 }, rotation: NORTH }, + { id: "lobby-table", kind: SIDE_TABLE, position: { x: 4.4, z: 8.2 }, rotation: NORTH }, + { id: "lobby-shelf", kind: SHELF, position: { x: 1.2, z: SPINE_N - INT_FACE - 0.18 }, rotation: SOUTH }, + { id: "lobby-tree-1", kind: TREE, position: { x: 0.8, z: 8.9 }, rotation: NORTH }, + { id: "lobby-tree-2", kind: TREE, position: { x: 6.6, z: 1.0 }, rotation: NORTH }, + ...scatter("lobby-pendant", PENDANT, grid(3.9, 8.2, 2, 1, 1.0, 0), { elevation: CEILING }), + ...scatter("lobby-light", TROFFER, grid(1.4, 1.4, 3, 3, 2.4, 3.0), { elevation: CEILING }), + + // -- The open desk floor -------------------------------------------------- + // A screen down the spine of each bench, one per station. 1.4 m against a + // 1.7 m pitch leaves a gap you can talk through, which is the entire argument + // for a 0.45 m screen over a 1.2 m one. + ...scatter("eng-screen", PARTITION, grid(9.2, 2.325, 6, 1, 1.7, 0)), + ...scatter("ops-screen", PARTITION, grid(9.2, 6.025, 6, 1, 1.7, 0)), + ...scatter("design-screen", PARTITION, grid(20.6, 2.325, 3, 1, 1.7, 0)), + ...scatter("sales-screen", PARTITION, grid(20.6, 6.025, 3, 1, 1.7, 0)), + + // Pedestals park at the aisle ends rather than under the desks, which is + // where they end up in a building where people move seats. + ...scatter("open-pedestal", PEDESTAL, [ + { x: 18.75, z: 1.9 }, + { x: 18.75, z: 5.6 }, + { x: 25.2, z: 1.9 }, + { x: 25.2, z: 5.6 }, + ]), + + // Lockers and a whiteboard along the lobby wall — the one wall on this floor + // with nothing on the other side of it. At `WEST` a locker's 1.2 m face turns + // to run along Z, so they stack down the wall at 1.3 m centres. + ...scatter("open-locker", LOCKER, grid(LOBBY_E + INT_FACE + 0.25, 1.0, 1, 3, 0, 1.3), { + rotation: WEST, + }), + { + id: "open-whiteboard", + kind: WHITEBOARD, + position: { x: LOBBY_E + INT_FACE + BOARD_OFFSET, z: 5.2 }, + rotation: WEST, + }, + { + id: "open-display", + kind: DISPLAY, + // On the corridor wall, clear of the 3 m archway at x 16–19. + position: { x: 21.0, z: SPINE_N - INT_FACE - DISPLAY_OFFSET }, + rotation: SOUTH, + }, + ...scatter("open-sill-plant", PLANT, grid(8.6, 0.55, 5, 1, 3.4, 0)), + { id: "open-tree-1", kind: TREE, position: { x: 19.2, z: 3.8 }, rotation: NORTH }, + { id: "open-tree-2", kind: TREE, position: { x: 19.2, z: 9.6 }, rotation: NORTH }, + { id: "open-tree-3", kind: TREE, position: { x: 25.0, z: 9.6 }, rotation: NORTH }, + ...scatter("open-light", TROFFER, grid(8.8, 1.5, 6, 3, 3.2, 3.6), { elevation: CEILING }), + + // -- Focus booths --------------------------------------------------------- + // Desk against the corridor wall, occupant facing it. Three booths, three + // identical fit-outs, one line each. + ...scatter("booth-desk", DESK, grid(9.4, 9.9, 3, 1, 2.0, 0), { rotation: SOUTH }), + ...scatter("booth-chair", TASK_CHAIR, grid(9.4, 9.3, 3, 1, 2.0, 0), { rotation: SOUTH }), + ...scatter("booth-light", TROFFER, grid(9.4, 9.5, 3, 1, 2.0, 0), { elevation: BOOTH_CEILING }), + + // -- Circulation ---------------------------------------------------------- + ...scatter("corridor-shelf", SHELF, grid(22.6, SPINE_N + INT_FACE + 0.18, 2, 1, 1.0, 0)), + { id: "corridor-plant-w", kind: PLANT, position: { x: 0.7, z: 11.3 }, rotation: NORTH }, + { id: "corridor-plant-e", kind: PLANT, position: { x: 33.3, z: 11.3 }, rotation: NORTH }, + ...scatter("corridor-light", TROFFER, grid(2.0, 11.3, 9, 1, 3.6, 0), { elevation: CEILING }), + + // -- The lounge ----------------------------------------------------------- + // Four chairs facing a low table, which is the arrangement people actually + // sit in; a row of chairs against a window is furniture nobody uses. + { id: "lounge-rug", kind: RUG, position: { x: 29.6, z: 3.0 }, rotation: NORTH }, + { id: "lounge-chair-n", kind: LOUNGE_CHAIR, position: { x: 29.6, z: 1.7 }, rotation: SOUTH }, + { id: "lounge-chair-s", kind: LOUNGE_CHAIR, position: { x: 29.6, z: 4.3 }, rotation: NORTH }, + { id: "lounge-chair-w", kind: LOUNGE_CHAIR, position: { x: 28.2, z: 3.0 }, rotation: EAST }, + { id: "lounge-chair-e", kind: LOUNGE_CHAIR, position: { x: 31.0, z: 3.0 }, rotation: WEST }, + { id: "lounge-table", kind: SIDE_TABLE, position: { x: 29.6, z: 3.0 }, rotation: NORTH }, + { id: "lounge-tree-1", kind: TREE, position: { x: 33.2, z: 1.0 }, rotation: NORTH }, + { id: "lounge-tree-2", kind: TREE, position: { x: 26.2, z: 5.2 }, rotation: NORTH }, + ...scatter("lounge-pendant", PENDANT, grid(29.0, 3.0, 2, 1, 1.2, 0), { elevation: CEILING }), + ...scatter("lounge-light", TROFFER, grid(27.0, 1.4, 3, 2, 2.8, 3.0), { elevation: CEILING }), + + // -- The kitchen ---------------------------------------------------------- + // A meeting table doing duty as an island. There is no `tera:kitchen.island` + // and there should not be: seventeen assets is the set that gets a floor + // plate looking like an office, and the honest way to get an island is to + // register one in your own namespace. + { id: "kitchen-island", kind: MEETING_TABLE, position: { x: 29.0, z: 7.4 }, rotation: NORTH }, + { id: "kitchen-island-plant", kind: PLANT, position: { x: 29.0, z: 7.4 }, rotation: NORTH, elevation: 0.74 }, + ...scatter("kitchen-stool", TASK_CHAIR, grid(28.2, 8.6, 3, 1, 0.8, 0), { rotation: NORTH }), + ...scatter("kitchen-counter", LOCKER, grid(26.5, SPINE_N - INT_FACE - 0.25, 3, 1, 1.3, 0), { + rotation: SOUTH, + }), + { id: "kitchen-shelf", kind: SHELF, position: { x: 30.6, z: SPINE_N - INT_FACE - 0.18 }, rotation: SOUTH }, + { id: "kitchen-seat-n", kind: LOUNGE_CHAIR, position: { x: 32.6, z: 6.9 }, rotation: SOUTH }, + { id: "kitchen-seat-s", kind: LOUNGE_CHAIR, position: { x: 32.6, z: 8.6 }, rotation: NORTH }, + { id: "kitchen-table", kind: SIDE_TABLE, position: { x: 32.6, z: 7.75 }, rotation: NORTH }, + ...scatter("kitchen-pendant", PENDANT, grid(28.4, 7.4, 2, 1, 1.2, 0), { elevation: CEILING }), + ...scatter("kitchen-light", TROFFER, grid(27.0, 7.0, 3, 2, 2.8, 2.4), { elevation: CEILING }), + + // -- Alcatraz, the board room --------------------------------------------- + // Two 2.4 m tables end to end. A `Prop` carries no parameters — it is an id + // and a placement — so the 4.8 m table this room wants is two of the 2.4 m + // one, and the alternative is registering `acme:table.board` at the length + // you want. Both are one line; only one of them is in this file. + { id: "alcatraz-table-w", kind: MEETING_TABLE, position: { x: 2.4, z: 15.0 }, rotation: NORTH }, + { id: "alcatraz-table-e", kind: MEETING_TABLE, position: { x: 4.8, z: 15.0 }, rotation: NORTH }, + ...scatter("alcatraz-chair-n", TASK_CHAIR, grid(1.8, 14.0, 5, 1, 0.9, 0), { rotation: SOUTH }), + ...scatter("alcatraz-chair-s", TASK_CHAIR, grid(1.8, 16.0, 5, 1, 0.9, 0), { rotation: NORTH }), + { + id: "alcatraz-display", + kind: DISPLAY, + // The west wall is the one blank wall in the room: the south wall has the + // window, the north wall has the vision panel and the door. Rooms get laid + // out around where a screen can go more often than anyone admits. + position: { x: EXT_FACE + DISPLAY_OFFSET, z: 15.0 }, + rotation: WEST, + }, + { + id: "alcatraz-whiteboard", + kind: WHITEBOARD, + position: { x: X_BERNAL - INT_FACE - BOARD_OFFSET, z: 15.0 }, + rotation: EAST, + }, + { id: "alcatraz-tree", kind: TREE, position: { x: 6.6, z: 17.2 }, rotation: NORTH }, + ...scatter("alcatraz-pendant", PENDANT, grid(2.0, 15.0, 3, 1, 1.6, 0), { elevation: CEILING }), + ...scatter("alcatraz-light", TROFFER, grid(1.6, 13.2, 3, 2, 2.4, 3.2), { elevation: CEILING }), + + // -- Bernal, six people --------------------------------------------------- + // The table turns through ninety degrees so its 2.4 m length runs down the + // room. `rotation: WEST` on a table is not the table facing anywhere — it is + // just a right angle, and the yaw convention has no separate vocabulary for + // things without a front. + { id: "bernal-table", kind: MEETING_TABLE, position: { x: 9.8, z: 15.0 }, rotation: WEST }, + ...scatter("bernal-chair-w", TASK_CHAIR, grid(8.8, 14.1, 1, 3, 0, 0.9), { rotation: EAST }), + ...scatter("bernal-chair-e", TASK_CHAIR, grid(10.8, 14.1, 1, 3, 0, 0.9), { rotation: WEST }), + { + id: "bernal-display", + kind: DISPLAY, + position: { x: X_BERNAL + INT_FACE + DISPLAY_OFFSET, z: 15.0 }, + rotation: WEST, + }, + { id: "bernal-tree", kind: TREE, position: { x: 11.8, z: 17.2 }, rotation: NORTH }, + ...scatter("bernal-pendant", PENDANT, grid(9.8, 14.2, 1, 2, 0, 1.6), { elevation: CEILING }), + ...scatter("bernal-light", TROFFER, grid(8.4, 13.4, 2, 2, 2.8, 3.2), { elevation: CEILING }), + + // -- Bolinas, four people ------------------------------------------------- + { id: "bolinas-table", kind: MEETING_TABLE, position: { x: 14.2, z: 15.0 }, rotation: WEST }, + ...scatter("bolinas-chair-w", TASK_CHAIR, grid(13.2, 14.45, 1, 2, 0, 1.1), { rotation: EAST }), + ...scatter("bolinas-chair-e", TASK_CHAIR, grid(15.2, 14.45, 1, 2, 0, 1.1), { rotation: WEST }), + { + id: "bolinas-whiteboard", + kind: WHITEBOARD, + position: { x: X_QUIET - INT_FACE - BOARD_OFFSET, z: 15.0 }, + rotation: EAST, + }, + { id: "bolinas-plant", kind: PLANT, position: { x: 12.9, z: 17.3 }, rotation: NORTH }, + ...scatter("bolinas-pendant", PENDANT, grid(14.2, 15.0, 1, 1, 0, 0), { elevation: CEILING }), + ...scatter("bolinas-light", TROFFER, grid(13.2, 13.4, 2, 2, 2.0, 3.2), { elevation: CEILING }), + + // -- Lands End, the quiet room -------------------------------------------- + // No table you can put a laptop on, on purpose. + { id: "quiet-rug", kind: RUG, position: { x: 17.8, z: 15.0 }, rotation: NORTH }, + { id: "quiet-chair-n", kind: LOUNGE_CHAIR, position: { x: 17.8, z: 14.0 }, rotation: SOUTH }, + { id: "quiet-chair-s", kind: LOUNGE_CHAIR, position: { x: 17.8, z: 16.0 }, rotation: NORTH }, + { id: "quiet-table", kind: SIDE_TABLE, position: { x: 17.8, z: 15.0 }, rotation: NORTH }, + { id: "quiet-shelf", kind: SHELF, position: { x: 16.7, z: SPINE_S + INT_FACE + 0.18 }, rotation: NORTH }, + { id: "quiet-tree", kind: TREE, position: { x: 19.0, z: 17.2 }, rotation: NORTH }, + ...scatter("quiet-pendant", PENDANT, grid(17.8, 15.0, 1, 1, 0, 0), { elevation: CEILING }), + ...scatter("quiet-light", TROFFER, grid(16.9, 13.4, 2, 2, 1.8, 3.2), { elevation: CEILING }), + + // -- Dogpatch, the workshop ----------------------------------------------- + // The bench is the `lab` desk bank; everything here is what goes round it. + ...scatter("shop-shelf", SHELF, grid(24.6, SPINE_S + INT_FACE + 0.18, 2, 1, 1.0, 0)), + ...scatter("shop-locker", LOCKER, grid(X_SERVER - INT_FACE - 0.25, 13.4, 1, 2, 0, 1.3), { + rotation: EAST, + }), + { + id: "shop-whiteboard", + kind: WHITEBOARD, + position: { x: X_WORKSHOP + INT_FACE + BOARD_OFFSET, z: 15.0 }, + rotation: WEST, + }, + { id: "shop-tree", kind: TREE, position: { x: 20.3, z: 14.0 }, rotation: NORTH }, + ...scatter("shop-light", TROFFER, grid(20.6, 13.4, 3, 2, 2.4, 3.2), { elevation: CEILING }), + + // -- Farallon, the server room -------------------------------------------- + // Two rows of cabinets facing each other across a 2.4 m aisle, which is the + // hot-aisle arrangement and also the only way two rows of anything read as + // deliberate. A storage locker is not a rack, but it is a 1.2 x 0.5 x 1.8 m + // box with a front and a back, and at this scale that is a rack. + ...scatter("mdf-rack-n", LOCKER, grid(27.4, 13.4, 2, 1, 1.3, 0), { rotation: NORTH }), + ...scatter("mdf-rack-s", LOCKER, grid(27.4, 15.8, 2, 1, 1.3, 0), { rotation: SOUTH }), + { id: "mdf-shelf", kind: SHELF, position: { x: 29.4, z: DEPTH - EXT_FACE - 0.18 }, rotation: SOUTH }, + ...scatter("mdf-light", TROFFER, grid(27.6, 14.0, 2, 2, 2.0, 2.4), { elevation: 2.6 }), + + // -- Facilities ----------------------------------------------------------- + ...scatter("fac-locker", LOCKER, grid(X_STORE + INT_FACE + 0.25, 13.2, 1, 4, 0, 1.3), { + rotation: WEST, + }), + ...scatter("fac-shelf", SHELF, grid(WIDTH - EXT_FACE - 0.2, 13.4, 1, 2, 0, 1.0), { + rotation: EAST, + }), + ...scatter("fac-light", TROFFER, grid(32.2, 14.0, 1, 2, 0, 2.4), { elevation: 2.6 }), +]; + +// ---- Zones ---------------------------------------------------------------- + +/** + * Four labelled regions of floor, and nothing else. + * + * A zone has no behaviour, no geometry and no meaning the engine can see. It is + * an area with a name and an opaque `colorKey`, so that something outside this + * repo can highlight it, filter against it or count what is inside it. Whether + * "eng" is a team, a cost centre or a colour scheme is not the engine's + * business — the same rule as `Marker.colorKey`, which is why there is no + * `kind` field to be tempted by. + */ +const ZONES: Zone[] = [ + { id: "zone-eng", name: "Engineering", outline: rect(8.0, 0.6, 18.9, 7.6), colorKey: "team-a" }, + { id: "zone-studio", name: "Studio", outline: rect(19.4, 0.6, 25.2, 7.6), colorKey: "team-b" }, + { id: "zone-social", name: "Social", outline: rect(SOCIAL_W, 0, WIDTH, SPINE_N), colorKey: "social" }, + { id: "zone-focus", name: "Focus", outline: rect(X_BOOTH_1, BOOTH_N, X_BOOTH_E, SPINE_N), colorKey: "focus" }, +]; + +// ---- Viewpoints ----------------------------------------------------------- + +/** + * Five poses, and the first one is where you arrive. + * + * `viewpoints[0]` is the arrival pose. `interiors/types.ts` suggests putting + * reception there, and this pack does not: arriving inside a room before you + * have seen the shape of the building is disorienting in a way that a plan view + * is not, so the establishing shot goes first and reception is second. That is a + * choice a pack gets to make, which is the point of the field being an order + * rather than an id. + * + * `distance` is metres from the target to the camera and `height` is metres + * above this level's floor. An office is not a city: 30 m is the whole building + * and 6 m is standing on a mezzanine, where the equivalent city numbers are in + * the hundreds. + */ +const VIEWPOINTS: Viewpoint[] = [ + { + id: "floor", + number: "01", + label: "The Whole Floor", + shortLabel: "The Floor", + levelId: "level-1", + focus: { at: { x: 17.0, z: 9.0 }, distance: 32, height: 14, rotation: 0.55 }, + description: + "Thirty-four metres by eighteen, one storey, glazed along the north edge. Everything in this building is somewhere in this frame.", + }, + { + id: "reception", + number: "02", + label: "Reception", + shortLabel: "Reception", + levelId: "level-1", + focus: { at: { x: 3.8, z: 5.4 }, distance: 8.5, height: 2.0, rotation: 1.9 }, + description: + "Four metres in from the entrance, looking back at the door. The desk faces the way you came in, which is the only thing a reception desk has to do.", + }, + { + id: "desks", + number: "03", + label: "The Desk Floor", + shortLabel: "Desks", + levelId: "level-1", + focus: { at: { x: 16.4, z: 4.0 }, distance: 14, height: 4.4, rotation: 0.35 }, + description: + "Thirty-six seats in four benches, two rows each, facing each other across a shared run of desktop. The window bench is eng-01 through eng-12.", + }, + { + id: "alcatraz", + number: "04", + label: "Alcatraz", + shortLabel: "Alcatraz", + levelId: "level-1", + focus: { at: { x: 3.6, z: 15.2 }, distance: 6.5, height: 2.3, rotation: 5.6 }, + description: + "The board room: ten seats, a vision panel onto the corridor and a window onto whatever is south of here. The largest of the three meeting rooms.", + }, + { + id: "kitchen", + number: "05", + label: "The Kitchen", + shortLabel: "Kitchen", + levelId: "level-1", + focus: { at: { x: 29.8, z: 7.6 }, distance: 8.0, height: 2.6, rotation: 2.6 }, + description: + "The social corner, where the lounge and the kitchen share a glazed edge and the only wall between them and the desk floor is the one with the archway in it.", + }, +]; + +// ---- The pack ------------------------------------------------------------- + +const LEVEL_1: Level = { + id: "level-1", + name: "Level 1", + // Ground. A second storey would repeat this object with `elevation: 4.2` — + // storey height, not ceiling height — and `Plan` adds the offset to every + // coordinate it resolves, exactly once, so both levels can go into one scene + // group sitting at the origin. + elevation: 0, + wallHeight: CEILING, + wallThickness: INT_THICKNESS, + wallSurface: PAINT, + floorplan: { + rooms: ROOMS, + walls: WALLS, + props: PROPS, + deskBanks: DESK_BANKS, + seats: SEATS, + zones: ZONES, + }, +}; + +export const LUMBRIDGE_HQ: Office = { + id: "lumbridge-hq", + name: "Lumbridge HQ", + levels: [LEVEL_1], + viewpoints: VIEWPOINTS, + meta: { + description: + "The reference office: one level, fifteen rooms, seventy-six seats. Copy this file, change the numbers, keep the seat ids.", + author: "Lumbridge", + // The art in this repo is Apache-2.0 with the artistic output additionally + // dedicated under CC0-1.0 (CONTRACT.md §3.1). A pack is artistic output, so + // this one says CC0 and means it: take the plan, take the seat ids, take the + // whole building, and owe nobody anything. + license: "CC0-1.0", + version: "1.0.0", + updated: "2026-08-04", + }, +}; + +export default LUMBRIDGE_HQ; diff --git a/src/server/wire.ts b/src/server/wire.ts new file mode 100644 index 0000000..e8b33ff --- /dev/null +++ b/src/server/wire.ts @@ -0,0 +1,284 @@ +/** + * The HTTP contract: every body that crosses between the browser build and the + * Tera API, and nothing else. + * + * **This file is types only.** It compiles to nothing, which is the whole point. + * The browser can import it without paying for a runtime module, and the server + * can import it without the browser package becoming one of its dependencies — + * so one declaration of each body serves both sides and there is no second + * implementation to drift. Everything that imports from here must use + * `import type`, which `verbatimModuleSyntax` already enforces. + * + * Two types here deliberately mirror engine types rather than importing them: + * `WireSimRoute` is structurally `SimRoute` from `engine/flights.ts`. The server + * must be able to build one and it must not pull three.js in to do it, so the + * shape is restated. `WireMarker`, by contrast, genuinely *extends* `Marker`, + * because a marker off the wire is handed straight to `setMarkers()` and the two + * being the same type is what guarantees that stays true. + * + * Routes, all under `/api/v1`: + * + * | route | body | cacheable | + * | ---------------- | --------------- | --------- | + * | `GET /health` | `HealthBody` | no | + * | `GET /flights` | `FlightsBody` | yes | + * | `GET /weather` | `WeatherBody` | yes | + * | `GET /markers` | `MarkersBody` | yes | + * | `GET /offices/:id` | `OfficeDoc` | public offices only | + * + * See CONTRACT.md §5. + */ + +import type { Marker } from "../engine/types.ts"; +import type { Office } from "../interiors/types.ts"; + +/** Path prefix every route lives under. Stated here so both sides read it once. */ +export type ApiBase = "/api/v1"; + +// ---- Errors --------------------------------------------------------------- + +/** + * The only error shape. `error` is a stable machine token; `message` is for a + * human reading a log and may change without notice. + * + * Note what is absent: there is no `403`. A request for something the caller may + * not see gets `not_found`, because an endpoint that distinguishes "does not + * exist" from "exists, but not for you" is an enumeration oracle. See + * CONTRACT.md §6. + */ +export interface ErrorBody { + error: "not_found" | "bad_request" | "unauthorized" | "upstream_unavailable" | "internal"; + message: string; +} + +// ---- Health --------------------------------------------------------------- + +export type WeatherSourceId = "none" | "nws" | "metno" | "openmeteo"; +export type FlightsSourceId = "sim" | "adsb" | "dump1090"; +export type MarkersSourceId = "none" | "file"; +export type AuthMode = "none" | "sso" | "jwt"; + +/** + * What this deployment turned out to be, once the environment had its say. + * + * `degraded` is the load-bearing field. A source configured without what it + * needs is demoted rather than fatal (CONTRACT.md §5.1), and this is where the + * demotion is visible to anybody who did not read the boot log — without it, a + * misconfigured contact string looks exactly like a clear day. + */ +export interface HealthBody { + ok: true; + service: "tera-api"; + version: string; + uptimeSeconds: number; + sources: { + weather: WeatherSourceId; + flights: FlightsSourceId; + markers: MarkersSourceId; + }; + auth: { + mode: AuthMode; + /** Where a browser sends someone to sign in. `null` unless mode is `sso`. */ + entryUrl: string | null; + }; + /** One human sentence per demotion. Empty on a fully-configured box. */ + degraded: string[]; +} + +// ---- Flights -------------------------------------------------------------- + +/** + * A leg the simulator flies. Structurally identical to `SimRoute` in + * `engine/flights.ts`; see the note at the top of this file for why it is + * restated rather than imported. + */ +export interface WireSimRoute { + callsign: string; + from: [number, number]; + to: [number, number]; + /** Metres at the start and end of the leg. */ + fromAlt: number; + toAlt: number; + /** Seconds for a full traversal. */ + duration: number; +} + +/** One aircraft, as `engine/types.ts` `Aircraft` wants it. */ +export interface WireAircraft { + id: string; + lat: number; + lng: number; + /** Metres. The wire never carries feet, whatever the upstream feed used. */ + altitude: number; + /** Degrees clockwise from true north. */ + heading: number; + callsign?: string; +} + +/** + * The simulated sky, sent as a *plan* rather than as positions. + * + * The server hands over the routes, a phase origin and a seed, and every browser + * evaluates the same closed-form function of wall-clock time. That means one + * cheap cacheable request instead of a poll every second, and — more usefully — + * two people looking at the map from different machines see the same aircraft in + * the same places, which a per-client simulation cannot promise. + * + * `t0` is a fixed epoch and emphatically **not** the server's start time: if it + * moved on restart, every aircraft would teleport. + */ +export interface FlightsPlanBody { + mode: "plan"; + source: "sim"; + /** Epoch milliseconds. The instant at which every route's phase is zero. */ + t0: number; + /** Seed for the per-route phase offsets, so all viewers agree. */ + seed: number; + routes: WireSimRoute[]; + /** How long the plan may be cached, in seconds. */ + ttlSeconds: number; +} + +/** Real traffic, as positions, because there is no closed form for the sky. */ +export interface FlightsLiveBody { + mode: "live"; + source: "adsb" | "dump1090"; + /** Epoch milliseconds at which this snapshot was taken. */ + observedAt: number; + aircraft: WireAircraft[]; + ttlSeconds: number; + /** Attribution the consumer is expected to display, if the feed asks for it. */ + attribution?: string[]; +} + +export type FlightsBody = FlightsPlanBody | FlightsLiveBody; + +// ---- Weather -------------------------------------------------------------- + +/** + * Sky conditions, reduced to what a light rig can actually use. + * + * Not a met report: no dew point, no pressure, no station id. `Atmosphere` + * consumes cloud cover, precipitation and visibility and nothing else, and a + * field that no renderer reads is a field that gets wrong without anyone + * noticing. + */ +export type WeatherCondition = + | "clear" + | "partly-cloudy" + | "cloudy" + | "overcast" + | "fog" + | "rain" + | "snow" + | "thunderstorm"; + +export interface WeatherBody { + /** ISO-8601. The observation time, not the fetch time. */ + observedAt: string; + source: WeatherSourceId; + /** + * True when nobody was asked and this is the fallback clear day. + * + * A zero-config box serves `synthetic: true` forever and that is a supported + * state, not an error — which is why this is a field and not a 503. + */ + synthetic: boolean; + location: { lat: number; lng: number }; + /** `null` where the source did not report it. Never silently zeroed. */ + temperatureC: number | null; + windKph: number | null; + /** Degrees clockwise from true north, the direction the wind blows *from*. */ + windDirDeg: number | null; + /** 0..1. */ + cloudCover: number; + /** 0..1, an intensity rather than a rate — the renderer wants a dial. */ + precipitation: number; + visibilityKm: number | null; + condition: WeatherCondition; + /** Attribution the consumer must display for this source, where one is owed. */ + attribution?: string[]; +} + +// ---- Markers -------------------------------------------------------------- + +/** + * Where a coordinate came from, and the only thing standing between this repo + * and an ODbL share-alike obligation. + * + * Publishing a snapshot of geocoded coordinates is Public Use of a Derivative + * Database. If those coordinates came out of Nominatim, ODbL §4.3 and §4.4 + * attach to everything served here — no matter that the rows live in a private + * database rather than in the repo. Containment was never the discharge. + * CONTRACT.md §8. + * + * The type is a plain string on purpose. The entire point of the gate is that a + * value nobody anticipated can arrive and must be *refused at serve time*, which + * a closed union would quietly turn into a compile error somewhere upstream + * instead. + */ +export type CoordinateProvenance = KnownProvenance | (string & {}); + +/** + * The values the public gate accepts by default. + * + * - `us-census` — geocoding.geo.census.gov, a US Government work in the public + * domain. The sanctioned geocoder. + * - `hand-placed` — typed by a human from a published address. Original. + * - `synthetic` — invented for a demo. Owes nobody anything. + * + * Deliberately absent: `nominatim`, `osm`, `google`, `mapbox`, `here`. The first + * two are share-alike; the rest restrict storing and redistributing what they + * return, which is exactly what a public snapshot does. + */ +export type KnownProvenance = "us-census" | "hand-placed" | "synthetic"; + +/** + * A marker as it crosses the wire: an engine `Marker` plus where its coordinate + * came from. + * + * It extends `Marker` rather than restating it so that the adapter is a cast and + * not a copy — the engine still knows nothing about provenance, and the day + * `Marker` gains a field, this follows it. + */ +export interface WireMarker extends Marker { + provenance: CoordinateProvenance; +} + +export interface MarkersBody { + markers: WireMarker[]; + /** ISO-8601 timestamp of the snapshot these rows came from. */ + generatedAt: string; + /** + * Rows the public-shape gate refused, by count and reason. Served rather than + * only logged: a silent drop looks identical to an empty database. + */ + refused: { reason: string; count: number }[]; + attribution?: string[]; +} + +// ---- Offices -------------------------------------------------------------- + +/** + * One office pack, addressed and wrapped for delivery. + * + * `floor` is the authored `Office` exactly as `src/interiors/types.ts` defines + * it — the pack a self-hoster writes by hand and the pack that arrives over HTTP + * are the same bytes, which is the rule that keeps the format from forking. + * Everything outside `floor` is deployment metadata the renderer never reads. + */ +export interface OfficeDoc { + id: string; + name: string; + floor: Office; + visibility: OfficeVisibility; + /** ISO-8601. */ + updated?: string; +} + +/** + * `private` means the endpoint answers 404 to anyone who may not see it — see + * `ErrorBody`. `unlisted` is served to anybody with the id but never appears in + * an index and is never publicly cached. + */ +export type OfficeVisibility = "public" | "unlisted" | "private";