Spaces: the inside of the world, and a sun that is actually where it should be
Ten agents wrote this in parallel against CONTRACT.md, which exists because the five design agents before them collided on fifteen blocking points — four files specified twice with incompatible contents, three separate backends for one box, and `Environment` exported twice meaning different things. What landed: a Stage owning only the renderer and the loop, with the city and an office as two scenes over it. They cannot share one — San Francisco is ~94 m per scene unit with 3.6x vertical exaggeration and an office is 1 unit = 1 m — and the city is paused rather than disposed on the way in, because rebuilding its 336,864-point heightfield costs about a second on the way back out. Offices are data. `src/offices/lumbridge-hq.ts` is fifteen rooms and seventy-six seats, and it is the file a self-hoster copies. Walls are a segment list with 1-D openings, so doors and windows are holes punched in a wall rather than placed objects, and the pass that splits a wall around its openings hands the walk-mode collider its segments for free. The sun is real. `solar.ts` is a NOAA/Meeus implementation with no imports at all — not even three.js — so time of day keeps working on a laptop in a field. Verified against known values: 75.45 degrees at the June solstice in SF, 28.79 at December, sunset at 03:15Z. The first screenshot after wiring it was a black rectangle, which turned out to be correct: it was midnight in San Francisco. Presence binds to a seat id and never to a coordinate. The pack knows where `eng-04` is; who is sitting in it is private data behind an API. Same shape as the marker rule, one level in. Two corrections to ARCHITECTURE.md are in here. Containment does not discharge ODbL — publishing OSM-derived coordinates is Public Use of a Derivative Database wherever the rows live, so the rule is about the geocoder (US Census, public domain) and not the storage. And a person at a desk is not a Marker; markers are geographic. One contract gap surfaced only in a screenshot: two agents read `height` on a viewpoint differently, so the establishing shot aimed at empty air fourteen metres above the roof. It now means what the same field means for a city. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+52
-12
@@ -136,14 +136,41 @@ So:
|
|||||||
Original expression, ours, Apache 2.0. This is already true for SF.
|
Original expression, ours, Apache 2.0. This is already true for SF.
|
||||||
- **Elevation**, when we want real terrain, comes from **USGS/SRTM**, which is
|
- **Elevation**, when we want real terrain, comes from **USGS/SRTM**, which is
|
||||||
US-government public domain. Not OSM.
|
US-government public domain. Not OSM.
|
||||||
- **Geocoded company coordinates never enter this repo.** They live in Workie's
|
- **Geocoded company coordinates never enter this repo.** They live in a private
|
||||||
private database and arrive over the API at runtime.
|
database and arrive over the API at runtime.
|
||||||
|
|
||||||
Note where that lands: the licence constraint and the privacy constraint want
|
There is a real result here: the licence constraint and the privacy constraint
|
||||||
exactly the same thing. Company positions and pipeline status both stay behind
|
want the same thing. Company positions and pipeline status both stay behind the
|
||||||
the API; the open-source repo holds the city and the renderer. That is a nice
|
API; the open-source repo holds the city and the renderer. The tempting shortcut
|
||||||
result and it should be defended, because the tempting shortcut — "just commit a
|
— "just commit an `sf-companies.json`" — breaks both at once.
|
||||||
`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
|
### 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
|
doors, lighting rigs, floor and wall materials. Built once, used by every
|
||||||
Lumbridge world and by anyone else who wants one.
|
Lumbridge world and by anyone else who wants one.
|
||||||
|
|
||||||
Interiors share the engine's projection, camera and render loop, and swap the
|
Interiors reuse the engine's renderer, camera machinery and render loop, and
|
||||||
city layer for a floorplan layer. Same `Marker` type — a desk with a person at
|
swap the city layer for a floorplan layer. The city view and the office view are
|
||||||
it is a marker with a different `colorKey`. The city view and the office view
|
the outside and the inside of one world, and moving between them is a scene swap
|
||||||
are the outside and the inside of one world, and moving between them is a camera
|
rather than a different application.
|
||||||
transition, not 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
|
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
|
friends) are the shape to aim at. The difference Spaces is going for is that this
|
||||||
|
|||||||
+257
@@ -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.
|
||||||
+172
@@ -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/<id>.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.
|
||||||
@@ -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
|
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.
|
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
|
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.
|
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
|
THIRD-PARTY DEPENDENCIES
|
||||||
------------------------
|
------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+19
-2
@@ -29,15 +29,32 @@
|
|||||||
#detail { position: fixed; right: 1rem; bottom: 2.5rem; }
|
#detail { position: fixed; right: 1rem; bottom: 2.5rem; }
|
||||||
#hint { position: fixed; right: 1rem; bottom: 1rem; font-size: 10px;
|
#hint { position: fixed; right: 1rem; bottom: 1rem; font-size: 10px;
|
||||||
color: rgba(20,30,40,0.5); }
|
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); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="scene"></canvas>
|
<canvas id="scene"></canvas>
|
||||||
<div id="panel">
|
<div id="panel">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>San Francisco</h1>
|
<h1 id="title">San Francisco</h1>
|
||||||
<p>Tera · Lumbridge Simulate</p>
|
<p id="subtitle">Tera · Lumbridge Simulate</p>
|
||||||
|
<p id="clock" class="clock"></p>
|
||||||
|
<div class="scrub">
|
||||||
|
<input id="hour" type="range" min="0" max="23.9" step="0.1" value="12" />
|
||||||
|
<button id="now" title="follow the wall clock">now</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="enter" class="enter">Enter the office →</button>
|
||||||
<nav id="chapters"></nav>
|
<nav id="chapters"></nav>
|
||||||
<p class="card" id="blurb"></p>
|
<p class="card" id="blurb"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Generated
+673
-2
@@ -1,13 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "@lumbridge/simulate",
|
"name": "@lumbridge/tera",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@lumbridge/simulate",
|
"name": "@lumbridge/tera",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"workspaces": [
|
||||||
|
"server"
|
||||||
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"three": "^0.182.0"
|
"three": "^0.182.0"
|
||||||
},
|
},
|
||||||
@@ -466,6 +469,121 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
"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": "^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": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.62.4",
|
"version": "4.62.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
|
||||||
@@ -889,6 +1013,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/stats.js": {
|
||||||
"version": "0.17.4",
|
"version": "0.17.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
@@ -926,6 +1060,96 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
@@ -968,6 +1192,125 @@
|
|||||||
"@esbuild/win32-x64": "0.28.1"
|
"@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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -993,6 +1336,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"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": "^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": {
|
"node_modules/meshoptimizer": {
|
||||||
"version": "0.22.0",
|
"version": "0.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
|
"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": "^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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1054,6 +1491,43 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.25",
|
"version": "8.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||||
@@ -1083,6 +1557,71 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.62.4",
|
"version": "4.62.4",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
|
||||||
@@ -1129,6 +1668,80 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -1139,6 +1752,33 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/three": {
|
||||||
"version": "0.182.0",
|
"version": "0.182.0",
|
||||||
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
||||||
@@ -1162,6 +1802,15 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -1176,6 +1825,13 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.6",
|
"version": "7.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||||
@@ -1250,6 +1906,21 @@
|
|||||||
"optional": true
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
"workspaces": ["server"],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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 `<dockerfile>.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
|
||||||
@@ -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 `<id>.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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<Viewer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, { viewer: Viewer; checkedAt: number }>();
|
||||||
|
|
||||||
|
async function revalidate(token: string): Promise<Viewer> {
|
||||||
|
// 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<Viewer> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<Claims | null> {
|
||||||
|
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<Claims>(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<string, { algorithm: string; ieeeP1363: boolean }> = {
|
||||||
|
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<string, KeyObject>();
|
||||||
|
let keysFetchedAt = 0;
|
||||||
|
|
||||||
|
async function verifyAsymmetric(
|
||||||
|
header: { alg?: string; kid?: string },
|
||||||
|
signed: string,
|
||||||
|
signature: Buffer,
|
||||||
|
config: AuthConfig,
|
||||||
|
): Promise<boolean> {
|
||||||
|
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<KeyObject | null> {
|
||||||
|
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<T>(part: string): T | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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<string, string | undefined>;
|
||||||
|
|
||||||
|
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<T extends string>(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";
|
||||||
|
}
|
||||||
@@ -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<FlightsSnapshot | null> {
|
||||||
|
const url = `${endpoint.replace(/\/$/, "")}/v2/point/${center.lat.toFixed(4)}/${center.lng.toFixed(4)}/${Math.round(radiusNm)}`;
|
||||||
|
const body = await getJson<AircraftEnvelope>(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<FlightsSnapshot | null> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<FlightsBody>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<FlightsBody> {
|
||||||
|
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"] }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<string, string>;
|
||||||
|
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<T>(url: string, opts: GetJsonOptions = {}): Promise<T | null> {
|
||||||
|
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})`;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<string, number>();
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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<string, unknown>, 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;
|
||||||
|
}
|
||||||
@@ -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<MarkersBody>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<MarkersBody> {
|
||||||
|
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<MarkersBody> {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Office packs, off disk.
|
||||||
|
*
|
||||||
|
* `TERA_OFFICES_DIR` holds one `<id>.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<OfficeDoc | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOfficeStore(dir: string): OfficeStore {
|
||||||
|
return {
|
||||||
|
async get(id: string): Promise<OfficeDoc | null> {
|
||||||
|
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<OfficeDoc>;
|
||||||
|
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 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<number> {
|
||||||
|
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<UpstreamBody>(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<string, unknown>;
|
||||||
|
return "provenance" in record ? record : { ...record, provenance };
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exitCode = await main();
|
||||||
@@ -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<HealthBody>();
|
||||||
|
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<WeatherBody>();
|
||||||
|
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<FlightsBody>();
|
||||||
|
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<MarkersBody>().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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>) {
|
||||||
|
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<HealthBody>();
|
||||||
|
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<WeatherBody>();
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>) {
|
||||||
|
const config = loadConfig({ TERA_OFFICES_DIR: dir, ...env });
|
||||||
|
config.logLevel = "silent";
|
||||||
|
return buildApp(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hs256(claims: Record<string, unknown>): 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<OfficeDoc>().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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<WeatherBody>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> | null = null;
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
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<WeatherBody> {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, number | undefined> };
|
||||||
|
next_1_hours?: {
|
||||||
|
summary?: { symbol_code?: string };
|
||||||
|
details?: { precipitation_amount?: number };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMetno(
|
||||||
|
lat: number,
|
||||||
|
lng: number,
|
||||||
|
contact: string,
|
||||||
|
): Promise<WeatherBody | null> {
|
||||||
|
// 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<ForecastResponse>(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"],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, string>();
|
||||||
|
|
||||||
|
export async function fetchNws(
|
||||||
|
lat: number,
|
||||||
|
lng: number,
|
||||||
|
contact: string,
|
||||||
|
): Promise<WeatherBody | null> {
|
||||||
|
const headers = { "user-agent": userAgent(contact) };
|
||||||
|
const station = await resolveStation(lat, lng, headers);
|
||||||
|
if (station === null) return null;
|
||||||
|
|
||||||
|
const obs = await getJson<ObservationResponse>(
|
||||||
|
`${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<string, string>,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const key = `${lat.toFixed(4)},${lng.toFixed(4)}`;
|
||||||
|
const cached = stationCache.get(key);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
const point = await getJson<PointsResponse>(`${BASE}/points/${key}`, { headers });
|
||||||
|
const stationsUrl = point?.properties?.observationStations;
|
||||||
|
if (stationsUrl === undefined) return null;
|
||||||
|
|
||||||
|
const stations = await getJson<StationsResponse>(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;
|
||||||
|
}
|
||||||
@@ -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<string, number | string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOpenMeteo(lat: number, lng: number): Promise<WeatherBody | null> {
|
||||||
|
const url =
|
||||||
|
`${BASE}?latitude=${lat.toFixed(4)}&longitude=${lng.toFixed(4)}` +
|
||||||
|
`¤t=${FIELDS}&wind_speed_unit=kmh&timezone=UTC`;
|
||||||
|
const body = await getJson<ForecastResponse>(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`;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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<Record<string, AssetParamValue>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<P extends AssetParams = AssetParams> {
|
||||||
|
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<any>;
|
||||||
|
|
||||||
|
/** Identity helper that keeps a def's parameter type inferred at the call site. */
|
||||||
|
export function defineAsset<P extends AssetParams>(def: AssetDef<P>): AssetDef<P> {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLACEHOLDER_FOOTPRINT: Footprint = { width: 0.6, depth: 0.6, height: 0.6 };
|
||||||
|
|
||||||
|
export class AssetRegistry {
|
||||||
|
private readonly defs = new Map<string, AnyAsset>();
|
||||||
|
/** overridden id -> the id that replaced it. */
|
||||||
|
private readonly overriddenBy = new Map<string, AssetId>();
|
||||||
|
private readonly warned = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<P extends AssetParams>(def: AssetDef<P>): 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<string>([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];
|
||||||
|
}
|
||||||
@@ -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<SurfaceRole, RoleSpec> = {
|
||||||
|
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<string, SurfaceRole> = {
|
||||||
|
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<SurfaceRole, SurfaceMaterial>();
|
||||||
|
private readonly ghosts = new Map<SurfaceRole, SurfaceMaterial>();
|
||||||
|
private readonly tints = new Map<string, SurfaceMaterial>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<WorkstationParams>({
|
||||||
|
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<PedestalParams>({
|
||||||
|
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<PartitionParams>({
|
||||||
|
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");
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<PottedParams>({
|
||||||
|
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<TallParams>({
|
||||||
|
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");
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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<PendantParams>({
|
||||||
|
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<TrofferParams>({
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<MonitorParams>({
|
||||||
|
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<WallDisplayParams>({
|
||||||
|
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");
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<TaskChairParams>({
|
||||||
|
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<LoungeParams>({
|
||||||
|
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");
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<ShelfParams>({
|
||||||
|
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<LockerParams>({
|
||||||
|
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");
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<RugParams>({
|
||||||
|
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<WhiteboardParams>({
|
||||||
|
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");
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<MeetingParams>({
|
||||||
|
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<SideParams>({
|
||||||
|
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");
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<SurfaceRole, number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<SurfaceRole, RoleShift> = {
|
||||||
|
// 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);
|
||||||
@@ -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<string, THREE.BufferGeometry>();
|
||||||
|
|
||||||
|
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<THREE.Material, THREE.BufferGeometry[]>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TextureQuality, number> = { 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<TextureKind, Draw> = {
|
||||||
|
/** 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<TextureKind, THREE.Texture | null>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*/
|
||||||
+112
-148
@@ -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
|
* `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
|
* 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
|
* 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
|
* one renderer serve a private map coloured by pipeline state and a public one
|
||||||
* coloured by sector without either being a fork.
|
* 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 * as THREE from "three";
|
||||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
||||||
import { createBlocks, createLandmarks } from "./blocks.ts";
|
import { createBlocks, createLandmarks } from "./blocks.ts";
|
||||||
import { createFlightLayer, type FlightLayer } from "./flights.ts";
|
import { createFlightLayer, type FlightLayer } from "./flights.ts";
|
||||||
import { createMarkerLayer, type MarkerLayer } from "./markers.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 { createBridges, createRoads } from "./structures.ts";
|
||||||
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.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";
|
import { World } from "./world.ts";
|
||||||
|
|
||||||
export interface SceneOptions {
|
export interface SceneOptions {
|
||||||
@@ -24,11 +42,27 @@ export interface SceneOptions {
|
|||||||
flights?: FlightSource;
|
flights?: FlightSource;
|
||||||
/** Fires on hover/click of a marker head. */
|
/** Fires on hover/click of a marker head. */
|
||||||
onMarkerPick?: (marker: Marker | null) => void;
|
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 {
|
export interface SceneHandle {
|
||||||
world: World;
|
world: World;
|
||||||
chapters: Chapter[];
|
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;
|
flyTo(chapterId: string): void;
|
||||||
current(): string;
|
current(): string;
|
||||||
onChapterChange(fn: (id: string) => void): void;
|
onChapterChange(fn: (id: string) => void): void;
|
||||||
@@ -41,47 +75,21 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
|
|||||||
const world = new World(city);
|
const world = new World(city);
|
||||||
const pal = paletteFor(world);
|
const pal = paletteFor(world);
|
||||||
|
|
||||||
|
const stage = createStage(canvas);
|
||||||
const scene = new THREE.Scene();
|
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(
|
const kit = createSceneKit({
|
||||||
42,
|
scene,
|
||||||
canvas.clientWidth / Math.max(1, canvas.clientHeight),
|
dom: stage.renderer.domElement,
|
||||||
0.1,
|
fov: 42,
|
||||||
900,
|
near: 0.1,
|
||||||
);
|
far: 900,
|
||||||
|
minDistance: 12,
|
||||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
maxDistance: 340,
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
shadowExtent: 170,
|
||||||
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
|
shadowFar: 520,
|
||||||
renderer.shadowMap.enabled = true;
|
});
|
||||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
kit.applyLighting(options.lighting ?? cityDaylight(pal));
|
||||||
|
|
||||||
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));
|
|
||||||
|
|
||||||
scene.add(createWater(world));
|
scene.add(createWater(world));
|
||||||
scene.add(createShorePlates(world));
|
scene.add(createShorePlates(world));
|
||||||
@@ -101,26 +109,21 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
|
|||||||
scene.add(flightLayer.group);
|
scene.add(flightLayer.group);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Camera flights -----------------------------------------------------
|
// ---- Chapters -----------------------------------------------------------
|
||||||
|
|
||||||
const chapterById = Object.fromEntries(city.chapters.map((c) => [c.id, c]));
|
const chapterById = Object.fromEntries(city.chapters.map((c) => [c.id, c]));
|
||||||
const first = city.chapters[0];
|
const first = city.chapters[0];
|
||||||
if (!first) throw new Error(`City "${city.id}" declares no chapters`);
|
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;
|
let currentChapter = first.id;
|
||||||
const chapterListeners: ((id: string) => void)[] = [];
|
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 [x, z] = world.project(ch.focus.lat, ch.focus.lng);
|
||||||
const groundY = world.groundAt(ch.focus.lat, ch.focus.lng);
|
const groundY = world.groundAt(ch.focus.lat, ch.focus.lng);
|
||||||
return {
|
return {
|
||||||
target: new THREE.Vector3(x, groundY, z),
|
target: new THREE.Vector3(x, groundY, z),
|
||||||
pos: new THREE.Vector3(
|
position: new THREE.Vector3(
|
||||||
x + Math.sin(ch.focus.rotation) * ch.focus.distance,
|
x + Math.sin(ch.focus.rotation) * ch.focus.distance,
|
||||||
groundY + ch.focus.height,
|
groundY + ch.focus.height,
|
||||||
z + Math.cos(ch.focus.rotation) * ch.focus.distance,
|
z + Math.cos(ch.focus.rotation) * ch.focus.distance,
|
||||||
@@ -131,77 +134,37 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
|
|||||||
function flyTo(chapterId: string) {
|
function flyTo(chapterId: string) {
|
||||||
const ch = chapterById[chapterId];
|
const ch = chapterById[chapterId];
|
||||||
if (!ch) return;
|
if (!ch) return;
|
||||||
const pose = chapterPose(ch);
|
kit.flyTo(chapterPose(ch));
|
||||||
flightFrom.pos.copy(camera.position);
|
|
||||||
flightFrom.target.copy(controls.target);
|
|
||||||
desiredPosition.copy(pose.pos);
|
|
||||||
desiredTarget.copy(pose.target);
|
|
||||||
flightT = 0;
|
|
||||||
flying = true;
|
|
||||||
if (currentChapter !== chapterId) {
|
if (currentChapter !== chapterId) {
|
||||||
currentChapter = chapterId;
|
currentChapter = chapterId;
|
||||||
for (const fn of chapterListeners) fn(chapterId);
|
for (const fn of chapterListeners) fn(chapterId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
kit.setPose(chapterPose(first));
|
||||||
const pose = chapterPose(first);
|
|
||||||
camera.position.copy(pose.pos);
|
|
||||||
controls.target.copy(pose.target);
|
|
||||||
controls.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Picking ------------------------------------------------------------
|
// ---- Picking ------------------------------------------------------------
|
||||||
|
|
||||||
const raycaster = new THREE.Raycaster();
|
// `pickables` is mutated in place by the layer, so the array itself is the
|
||||||
const pointer = new THREE.Vector2();
|
// live target list.
|
||||||
let hovered: Marker | null = null;
|
kit.setPicking<Marker>({
|
||||||
|
targets: markerLayer.pickables,
|
||||||
|
resolve: (hit) => (hit.object.userData.marker as Marker | undefined) ?? null,
|
||||||
|
onChange: (marker) => options.onMarkerPick?.(marker),
|
||||||
|
});
|
||||||
|
|
||||||
function onPointerMove(event: PointerEvent) {
|
// ---- The scene, as the stage sees it ------------------------------------
|
||||||
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);
|
|
||||||
|
|
||||||
// ---- 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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) {
|
if (options.flights && flightLayer) {
|
||||||
flightTimer -= dt;
|
flightTimer -= dt;
|
||||||
if (flightTimer <= 0) {
|
if (flightTimer <= 0) {
|
||||||
@@ -209,18 +172,29 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
|
|||||||
void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac));
|
void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
controls.update();
|
dispose() {
|
||||||
renderer.render(scene, camera);
|
options.flights?.dispose?.();
|
||||||
}
|
flightLayer?.dispose();
|
||||||
tick();
|
markerLayer.dispose();
|
||||||
|
kit.dispose();
|
||||||
const onWindowResize = () => resize();
|
scene.traverse((obj) => {
|
||||||
window.addEventListener("resize", onWindowResize);
|
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 {
|
return {
|
||||||
world,
|
world,
|
||||||
chapters: city.chapters,
|
chapters: city.chapters,
|
||||||
|
stage,
|
||||||
|
stageScene,
|
||||||
|
setLighting: (state) => kit.applyLighting(state),
|
||||||
flyTo,
|
flyTo,
|
||||||
current: () => currentChapter,
|
current: () => currentChapter,
|
||||||
onChapterChange(fn) {
|
onChapterChange(fn) {
|
||||||
@@ -230,38 +204,28 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
|
|||||||
markerLayer.setMarkers(markers);
|
markerLayer.setMarkers(markers);
|
||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
cancelAnimationFrame(raf);
|
// Stage first, so nothing ticks a half-disposed scene.
|
||||||
window.removeEventListener("resize", onWindowResize);
|
stage.dispose();
|
||||||
canvas.removeEventListener("pointermove", onPointerMove);
|
stageScene.dispose();
|
||||||
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();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeSkyTexture(top: number, horizon: number): THREE.Texture {
|
/**
|
||||||
const canvas = document.createElement("canvas");
|
* The committed default rig: a late-afternoon sun from the west, which throws
|
||||||
canvas.width = 2;
|
* the hills' shadows east across the flats.
|
||||||
canvas.height = 256;
|
*
|
||||||
const ctx = canvas.getContext("2d");
|
* Not an `Atmosphere` and not a substitute for one — it computes nothing from
|
||||||
if (!ctx) throw new Error("2D canvas context unavailable");
|
* time or weather, it is a constant with the city's own sky colours poured in.
|
||||||
const grad = ctx.createLinearGradient(0, 0, 0, 256);
|
* It exists so the engine renders with no server, no clock and no config, which
|
||||||
grad.addColorStop(0, `#${top.toString(16).padStart(6, "0")}`);
|
* is the acceptance test the whole repo is held to.
|
||||||
grad.addColorStop(1, `#${horizon.toString(16).padStart(6, "0")}`);
|
*/
|
||||||
ctx.fillStyle = grad;
|
export function cityDaylight(palette: ScenePalette): LightingState {
|
||||||
ctx.fillRect(0, 0, 2, 256);
|
return {
|
||||||
const tex = new THREE.CanvasTexture(canvas);
|
sun: { direction: [-0.632, 0.717, 0.295], color: 0xfff3e0, intensity: 2.1 },
|
||||||
tex.magFilter = THREE.LinearFilter;
|
hemisphere: { sky: 0xdcecf7, ground: 0x6b6f5e, intensity: 1.05 },
|
||||||
tex.colorSpace = THREE.SRGBColorSpace;
|
ambient: { color: 0xffffff, intensity: 0.32 },
|
||||||
return tex;
|
sky: { top: palette.skyTop, horizon: palette.skyHorizon },
|
||||||
|
fog: { color: palette.skyHorizon, near: 210, far: 460 },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T> {
|
||||||
|
/** 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<T>(options: PickOptions<T>): 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<unknown> | 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<unknown>;
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*/
|
||||||
@@ -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<StageScene, number>();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+70
-8
@@ -85,12 +85,29 @@ export interface Road {
|
|||||||
kind: "street" | "freeway";
|
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;
|
id: string;
|
||||||
number: string;
|
|
||||||
label: string;
|
label: string;
|
||||||
shortLabel: 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: {
|
focus: {
|
||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
@@ -98,7 +115,6 @@ export interface Chapter {
|
|||||||
height: number;
|
height: number;
|
||||||
rotation: number;
|
rotation: number;
|
||||||
};
|
};
|
||||||
description: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,24 +195,70 @@ export interface ScenePalette {
|
|||||||
parkHigh: number;
|
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 --------------------------------------------------------------
|
// ---- 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
|
* `colorKey` is deliberately opaque to the engine — it indexes into a palette
|
||||||
* the caller supplies. The engine will not learn what "rejected" means.
|
* 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;
|
id: string;
|
||||||
lat: number;
|
|
||||||
lng: number;
|
|
||||||
label: string;
|
label: string;
|
||||||
colorKey: string;
|
colorKey: string;
|
||||||
/** Optional href for the detail card. */
|
/** Optional href for the detail card. */
|
||||||
url?: string;
|
url?: string;
|
||||||
/** Optional one-liner for the detail card. */
|
/** Optional one-liner for the detail card. */
|
||||||
blurb?: string;
|
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.
|
* False when the position is a placeholder rather than a real address.
|
||||||
* Rendered distinctly, because inventing a location on a map whose premise
|
* Rendered distinctly, because inventing a location on a map whose premise
|
||||||
|
|||||||
Binary file not shown.
@@ -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<string, THREE.Vector3>;
|
||||||
|
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<Presence>({
|
||||||
|
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);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, number>;
|
||||||
|
|
||||||
|
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<string, THREE.Vector3>;
|
||||||
|
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<string, THREE.Vector3>();
|
||||||
|
|
||||||
|
// 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<SeatPose, { geometry: THREE.BufferGeometry; crown: number }>();
|
||||||
|
const materials = new Map<string, THREE.Material>();
|
||||||
|
const warned = new Set<string>();
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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<T, K>(items: readonly T[], key: (item: T) => K): Map<K, T[]> {
|
||||||
|
const out = new Map<K, T[]>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+151
-38
@@ -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
|
* Deliberately ships **no company data**. Markers are demonstrated using the
|
||||||
* city's own landmarks — buildings, not businesses — because company positions
|
* city's own landmarks — buildings, not businesses — because company positions
|
||||||
* are geocoded (ODbL) and company pipeline status is private, and neither
|
* are geocoded and company pipeline status is private, and neither belongs in
|
||||||
* belongs in this repo. Real markers arrive at runtime from an adapter; see
|
* this repo. Real markers arrive at runtime from an adapter; see `src/adapters/`
|
||||||
* `src/adapters/` and ARCHITECTURE.md §3.
|
* 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 { 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 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
|
* 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 },
|
{ callsign: "JBU 915", from: [37.96, -122.48], to: [37.63, -122.36], fromAlt: 3100, toAlt: 600, duration: 205 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MARKER_PALETTE: MarkerPalette = {
|
const MARKER_PALETTE: MarkerPalette = { landmark: 0xf2b134, neutral: 0x9aa4ad };
|
||||||
landmark: 0xf2b134,
|
|
||||||
neutral: 0x9aa4ad,
|
|
||||||
};
|
|
||||||
|
|
||||||
const canvas = document.querySelector<HTMLCanvasElement>("#scene");
|
const canvas = document.querySelector<HTMLCanvasElement>("#scene");
|
||||||
if (!canvas) throw new Error("#scene canvas missing");
|
if (!canvas) throw new Error("#scene canvas missing");
|
||||||
|
|
||||||
const scene = createScene(canvas, {
|
const city = createScene(canvas, {
|
||||||
city: SAN_FRANCISCO,
|
city: SAN_FRANCISCO,
|
||||||
markerPalette: MARKER_PALETTE,
|
markerPalette: MARKER_PALETTE,
|
||||||
flights: new SimulatedFlights(ROUTES),
|
flights: new SimulatedFlights(ROUTES),
|
||||||
onMarkerPick: (marker) => {
|
onMarkerPick: (marker) => showDetail(marker?.label ?? null),
|
||||||
const card = document.querySelector<HTMLElement>("#detail");
|
|
||||||
if (!card) return;
|
|
||||||
if (!marker) {
|
|
||||||
card.hidden = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
card.hidden = false;
|
|
||||||
card.textContent = marker.label;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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<HTMLElement>("#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<HTMLInputElement>("#hour");
|
||||||
|
scrubber?.addEventListener("input", () => {
|
||||||
|
hourOverride = Number(scrubber.value);
|
||||||
|
updateSun();
|
||||||
|
});
|
||||||
|
document.querySelector<HTMLElement>("#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
|
const demoMarkers: Marker[] = SAN_FRANCISCO.landmarks
|
||||||
.filter((l) => l.label)
|
.filter((l) => l.label)
|
||||||
.map((l) => ({
|
.map((l) => ({
|
||||||
@@ -63,26 +116,86 @@ const demoMarkers: Marker[] = SAN_FRANCISCO.landmarks
|
|||||||
colorKey: "landmark",
|
colorKey: "landmark",
|
||||||
located: true,
|
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<HTMLElement>("#chapters");
|
const nav = document.querySelector<HTMLElement>("#chapters");
|
||||||
const blurb = document.querySelector<HTMLElement>("#blurb");
|
const blurb = document.querySelector<HTMLElement>("#blurb");
|
||||||
|
const title = document.querySelector<HTMLElement>("#title");
|
||||||
|
const subtitle = document.querySelector<HTMLElement>("#subtitle");
|
||||||
|
const enterButton = document.querySelector<HTMLButtonElement>("#enter");
|
||||||
|
|
||||||
function renderLegend(activeId: string) {
|
function showDetail(text: string | null) {
|
||||||
if (!nav) return;
|
const card = document.querySelector<HTMLElement>("#detail");
|
||||||
nav.replaceChildren();
|
if (!card) return;
|
||||||
for (const chapter of scene.chapters) {
|
card.hidden = text === null;
|
||||||
const button = document.createElement("button");
|
card.textContent = text ?? "";
|
||||||
button.className = chapter.id === activeId ? "chapter active" : "chapter";
|
|
||||||
button.innerHTML = `<span class="num">${chapter.number}</span><span>${chapter.shortLabel}</span>`;
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = `<span class="num">${number}</span><span>${view.shortLabel}</span>`;
|
||||||
|
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();
|
||||||
|
|||||||
@@ -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<StandingParams>({
|
||||||
|
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/`.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||||
Reference in New Issue
Block a user