feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul
The build the studios needed, across eight workstreams and one strict file partition. **The render rig was the quality ceiling.** The renderer ran three's NoToneMapping default while atmosphere drove the sun to 2.35 and assets set emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is why walls blew out and every fitting looked like a white rectangle. ACES filmic tone mapping and an explicit output colour space land in `stage.ts`, and the atmosphere intensity table and palette headroom are re-tuned against the new curve rather than left tuned for the clipping we removed. `engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally, so nothing binary is committed. There was no environment map anywhere before, so every `metalness > 0` role had nothing to reflect and rendered dull grey — a defect the code already documented against itself in `office/optimus.ts`, where a whole material role was abandoned over it, and worked around in `modelX.ts` with a fake emissive that this change deletes. Atmosphere remains the sole light owner; the rig derives from the `LightingState` it already produced. **Studio hardware exists.** There was no device concept anywhere in the product: no type, no route, no state. `devices/types.ts` fixes a declaration/state/ capability/command contract that a smart light, a thermostat, a door sensor and a charger all fit without a schema change, and both studios now carry a desk mic and a computer speaker with deterministic simulated behaviour behind an adapter seam a real API can occupy later. Reads are the demo and are open; commands are a signed-in action and are kept off the read body entirely, because a shared cache replaying a GET that turned a microphone on is exactly what the fail-closed cache default exists to prevent. **The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the response was served publicly cacheable, and the attribution hardcoded adsb.lol regardless of where the endpoint pointed — one env var away from republishing non-redistributable data under an open-terms credit. The host is now allowlisted, the credit is derived from the host actually configured, public cacheability is conditional on redistributability, and a refused endpoint demotes to simulated flights and says so in `degraded[]`. The gate is on the source, not the feature: live aircraft and their detail cards stay open to anonymous visitors. **The LA studio was never the smaller pack** — 16 rooms and 248 props against SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were ceiling troffers, it bound no props to seats, placed none of the habitat kit, and 12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather than more instances, because `furnish.ts` draws once per kind and folds colour into the batch key, so repeat instances add nothing the eye can read. **The interface stops being forty imperative mutations.** Every visibility decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the chrome has coverage for the first time. Deleted: ~100 lines of CSS and two bindings targeting elements that no longer exist, and a `body:has()` rule that shifted the desktop layout by 160px for touch controls hidden there. Fixed: the office picker tabs that drew their label and their badge on top of each other. Added: a first-run flow, because the product is two verbs and neither was ever stated on screen. Mobile is designed on its own terms instead of being the desktop with things hidden — the plan view comes back, and the keyboard-only shortcuts button is replaced by touch controls. `arena/studioOps.ts` frames the whole thing as the multi-variable environment it is, wrapping the same simulators the renderer drives rather than a headless copy. Also removed `input/vehicle.ts`, which nothing but its own test imported. Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six matrix cells, no-binaries, provenance, dependency licences, zero-config boot and arena source hashes all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+69
-4
@@ -135,6 +135,16 @@ export interface Capabilities {
|
||||
export interface Feeds {
|
||||
weather: boolean;
|
||||
flights: boolean;
|
||||
/**
|
||||
* Device state for the studios.
|
||||
*
|
||||
* `false` on a zero-config box and on every clone, which is the default and
|
||||
* is not a gap: `src/devices/adapter.ts` reads this and runs the bundled
|
||||
* simulator in the tab instead, so the studio is alive either way. What the
|
||||
* flag actually prevents is a poll against a box that will answer 404 to all
|
||||
* of it, forever, on every open tab.
|
||||
*/
|
||||
devices: boolean;
|
||||
/**
|
||||
* A satellite catalogue. Off on almost every box, including this repo's own
|
||||
* default — see `loadSatellites` in the server's `config.ts` for why a clone
|
||||
@@ -160,6 +170,34 @@ export interface Access {
|
||||
* read a different field of the same body is a request nobody needs to make.
|
||||
*/
|
||||
feeds: Feeds | null;
|
||||
/**
|
||||
* Every demotion this deployment made, in the server's own words.
|
||||
*
|
||||
* `/api/v1/health` has carried this since the config learned to demote rather
|
||||
* than to die (CONTRACT.md §5.1), and until now **nothing in `src/` read
|
||||
* it**: it was built, served, logged and then dropped on the floor by the one
|
||||
* consumer that could put it in front of a person. So an operator whose
|
||||
* `TERA_WEATHER_CONTACT` was missing saw a permanently clear sky, with the
|
||||
* sentence explaining exactly that sitting in a JSON body one fetch away.
|
||||
*
|
||||
* It rides along here because this module has already paid for the round
|
||||
* trip — `/health` is the first thing boot asks for — and a second identical
|
||||
* GET to read a different field of the same body is a request nobody needs to
|
||||
* make. Empty on a fully-configured box, and empty when nothing answered:
|
||||
* a deployment that does not exist has not demoted anything.
|
||||
*
|
||||
* The interface shows it to admin-tier viewers. It names environment
|
||||
* variables and internal source ids, which is diagnostic detail rather than a
|
||||
* secret — but it is also noise to everybody who cannot act on it.
|
||||
*
|
||||
* **Optional in the type and always present in practice**: every `Access`
|
||||
* `resolveAccess` returns carries one, empty when there is nothing to report.
|
||||
* The `?` is there only so that a hand-written pre-boot literal — the closed
|
||||
* default `main.ts` holds before `resolveAccess()` settles — does not have to
|
||||
* restate an empty array to keep compiling. Read it as `access.degraded ?? []`
|
||||
* and the two cases are the same case.
|
||||
*/
|
||||
degraded?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,6 +259,7 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<
|
||||
const health = await getJson<{
|
||||
auth?: { mode?: unknown; entryUrl?: unknown };
|
||||
sources?: unknown;
|
||||
degraded?: unknown;
|
||||
}>(fetcher, "/health");
|
||||
|
||||
// Something is mounted at `/api/v1` and it is unwell. That is not the same
|
||||
@@ -233,6 +272,7 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<
|
||||
// because we do not yet know which door this deployment uses.
|
||||
if (health.kind === "broken") return access("anon", null, null);
|
||||
|
||||
|
||||
// Nothing answered. Clone-and-run: full experience, no door, no godmode.
|
||||
if (health.kind === "gone") return access("member", null, null);
|
||||
|
||||
@@ -240,10 +280,11 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<
|
||||
const mode = typeof body.auth?.mode === "string" ? body.auth.mode : "none";
|
||||
const entryUrl = entryHref(body.auth?.entryUrl);
|
||||
const feeds = feedsFrom(body.sources);
|
||||
const degraded = degradedFrom(body.degraded);
|
||||
|
||||
// A box with auth switched off is a self-host that chose to stay open. Same
|
||||
// deal as no API at all, and for the same reason it is `member` and not `god`.
|
||||
if (mode === "none") return access("member", null, null, feeds);
|
||||
if (mode === "none") return access("member", null, null, feeds, degraded);
|
||||
|
||||
const fetched = await getJson<{
|
||||
authenticated?: unknown;
|
||||
@@ -286,8 +327,8 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<
|
||||
*/
|
||||
const signInUrl = entryUrl ?? (passwordLogin ? "/login.html" : null);
|
||||
|
||||
if (!authenticated) return access("anon", null, signInUrl, feeds);
|
||||
return access(admin ? "god" : "member", subject, signInUrl, feeds);
|
||||
if (!authenticated) return access("anon", null, signInUrl, feeds, degraded);
|
||||
return access(admin ? "god" : "member", subject, signInUrl, feeds, degraded);
|
||||
}
|
||||
|
||||
function access(
|
||||
@@ -295,8 +336,9 @@ function access(
|
||||
subject: string | null,
|
||||
signInUrl: string | null,
|
||||
feeds: Feeds | null = null,
|
||||
degraded: string[] = [],
|
||||
): Access {
|
||||
return { tier, subject, signInUrl, can: capabilitiesFor(tier), feeds };
|
||||
return { tier, subject, signInUrl, can: capabilitiesFor(tier), feeds, degraded };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,9 +358,32 @@ function feedsFrom(raw: unknown): Feeds {
|
||||
flights: wired("flights"),
|
||||
satellites: wired("satellites"),
|
||||
markers: wired("markers"),
|
||||
devices: wired("devices"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `/health`'s `degraded` block, read as sentences.
|
||||
*
|
||||
* Filtered rather than cast, and for a reason beyond tidiness: these strings go
|
||||
* into the interface, so a body carrying numbers, objects or `null` in that
|
||||
* array would put `[object Object]` in front of an operator who is already
|
||||
* looking at this list because something is wrong. Anything that is not a
|
||||
* string is not a sentence and is dropped.
|
||||
*
|
||||
* Bounded as well. The list is one line per demotion and a fully-configured box
|
||||
* has none, so a body with thousands in it is a server this client should not
|
||||
* be rendering unboundedly — the same disposition `presence/store.ts` takes to
|
||||
* a roster with fifty thousand rows in it.
|
||||
*/
|
||||
function degradedFrom(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((line): line is string => typeof line === "string").slice(0, MAX_DEGRADED);
|
||||
}
|
||||
|
||||
/** More demotions than any real configuration can produce. See `degradedFrom`. */
|
||||
const MAX_DEGRADED = 32;
|
||||
|
||||
/**
|
||||
* The three answers a request to `/api/v1` can carry, which is one more than
|
||||
* this used to have.
|
||||
|
||||
+67
-2
@@ -16,6 +16,10 @@ else builds without any of them being a fork. See ARCHITECTURE.md §3.3.
|
||||
| `sample.ts` | fabricated demo markers and flight routes, so a fresh clone has something on it |
|
||||
| `http.ts` | the real adapter — the Tera API described in `src/server/wire.ts`, falling back to `sample.ts` |
|
||||
|
||||
Devices have a seam of their own next door, in `src/devices/adapter.ts`, for a
|
||||
reason worth stating: it is the one feed with a **write** on it, and the write
|
||||
does not degrade the way a read does. See "Devices" below.
|
||||
|
||||
## The fallback is the product, not the safety net
|
||||
|
||||
`http.ts` never throws and never leaves the map empty. No server, a 404, a
|
||||
@@ -62,6 +66,67 @@ markers have to be awaited first. `markers.palette` is the sample palette when
|
||||
the feed is the sample set and the palette you passed in `TeraApiOptions` when it
|
||||
is real — the sample keys are not your keys.
|
||||
|
||||
## Clicking an aircraft
|
||||
|
||||
`TrafficSource.detail(id)` answers from what `poll()` last handed over —
|
||||
callsign, ICAO 24-bit address, altitude in both units, heading as degrees and as
|
||||
a compass point, position, distance from the board centre, whether anybody
|
||||
observed it, and the credit lines owed for it. Synchronous, so a click opens a
|
||||
card in the same frame rather than behind a round trip, and `null` for an
|
||||
aircraft that has left the feed rather than a card showing where something was
|
||||
two minutes ago.
|
||||
|
||||
**It is available to an anonymous visitor**, and that is a decision rather than
|
||||
an oversight. An ADS-B position is broadcast unencrypted by the aircraft to
|
||||
anybody with a forty-dollar receiver; there is nothing here an account could
|
||||
grant access to, and gating it would cost the first-visit moment this map exists
|
||||
for while protecting nothing.
|
||||
|
||||
Two fields are about the data rather than about the aeroplane. `observed`
|
||||
travels with the aircraft because a card is read on its own, away from any
|
||||
corner label, and a fabricated flight number in the same frame as a real one is
|
||||
the confusion `live` exists to prevent. `attribution` travels with it because a
|
||||
card is where the data is *displayed*, which is what an ODbL notice is about.
|
||||
The address is `null` unless the feed gave a real one: the simulator's ids are
|
||||
route names and the `~`-prefixed ids both community feeds emit for TIS-B and
|
||||
MLAT targets are not ICAO addresses, and somebody pastes that field into a
|
||||
registry lookup.
|
||||
|
||||
## Devices: reading is the demo, writing is the account
|
||||
|
||||
`src/devices/adapter.ts` is the seam, and it chooses between two strategies once,
|
||||
at construction:
|
||||
|
||||
- **the API**, when the deployment has a device source (`/health` says so) and
|
||||
the viewer may read it; or
|
||||
- **the simulator in this tab** — `src/devices/sim.ts`, seeded, deterministic,
|
||||
`synthetic: true` and `live: false` — for everybody else.
|
||||
|
||||
The second is not a degraded mode, it is the anonymous visitor's studio and the
|
||||
zero-config clone's studio, and it is the same argument the marker fallback
|
||||
makes one section up: a studio is never dark. `GET /offices/:id/devices` refuses
|
||||
an anonymous caller, and it should — a reading describes a room somebody is
|
||||
standing in — so the refusal produces a working, honestly-labelled instrument
|
||||
rather than a dead one.
|
||||
|
||||
**Commands do not get the same treatment**, and this is the one place in this
|
||||
directory where a failure is reported rather than papered over. On the API
|
||||
strategy a refused command is `null` and the interface says so; it is never
|
||||
quietly applied to a local copy, because a control that appears to work and
|
||||
changes nothing anybody else can see is worse than one that says no. On the
|
||||
simulated strategy a command is applied locally and openly, because nothing
|
||||
there claims to be a real room.
|
||||
|
||||
A command travels as a **POST on a route of its own** and never in a read body.
|
||||
A shared cache that kept a GET which turned a microphone on could replay it, and
|
||||
that is precisely what the fail-closed `Cache-Control` default in CONTRACT.md §5
|
||||
exists to prevent.
|
||||
|
||||
`src/devices/adapter.ts` owns no timer: the simulated strategy is advanced by
|
||||
`tick(dt)` from whatever render loop already exists, and the API strategy's
|
||||
polling lives in `watchDevices` here in `http.ts`, where every other watch's
|
||||
timer already is.
|
||||
|
||||
## No real company data ships in this repo
|
||||
|
||||
Two separate constraints want the same thing here, which is the reason this
|
||||
@@ -110,8 +175,8 @@ Two things to keep if you write one:
|
||||
body's own TTL in the background. A `poll()` that awaits a slow fetch puts a
|
||||
frame behind a round trip.
|
||||
|
||||
**FlightRadar24 is not an option.** Their terms forbid scraping and forbid
|
||||
redistributing the data, so an Apache-2.0 repo containing an FR24 client would be
|
||||
**FlightRadar24 is not an option.** Their terms do not permit scraping and do
|
||||
not permit redistributing the data, so an Apache-2.0 repo containing an FR24 client would be
|
||||
publishing instructions for breaking a ToS and shipping data it has no right to
|
||||
relicense. `src/engine/flights.ts` ships a simulator and points at the open
|
||||
community ADS-B feeds instead; anything commercial belongs in an adapter in a
|
||||
|
||||
+464
-8
@@ -32,12 +32,16 @@
|
||||
*/
|
||||
|
||||
import type { WeatherObservation } from "../engine/atmosphere.ts";
|
||||
import type { DeviceCommand, DeviceState } from "../devices/types.ts";
|
||||
import { deviceStateSignature } from "../devices/types.ts";
|
||||
import {
|
||||
aircraftDetail,
|
||||
distanceNm,
|
||||
inRegion,
|
||||
sampleRoute,
|
||||
SimulatedFlights,
|
||||
syntheticRoutes,
|
||||
type AircraftDetail,
|
||||
type Place,
|
||||
type SimRoute,
|
||||
type SkyRegion,
|
||||
@@ -46,6 +50,10 @@ import type { SatelliteElements } from "../engine/satellites.ts";
|
||||
import type { Aircraft, FlightSource, Marker, MarkerPalette } from "../engine/types.ts";
|
||||
import { seededRandom } from "../engine/world.ts";
|
||||
import type {
|
||||
DeviceCommandBody,
|
||||
DeviceCommandResultBody,
|
||||
DevicesBody,
|
||||
DevicesSourceId,
|
||||
FlightsBody,
|
||||
FlightsPlanBody,
|
||||
HealthBody,
|
||||
@@ -54,6 +62,7 @@ import type {
|
||||
PresenceBody,
|
||||
SatellitesBody,
|
||||
WeatherBody,
|
||||
WireAircraft,
|
||||
} from "../server/wire.ts";
|
||||
import { SAMPLE_MARKERS, SAMPLE_PALETTE } from "./sample.ts";
|
||||
|
||||
@@ -197,6 +206,54 @@ export interface PresenceWatch {
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One office's hardware, plus whether anybody actually asked a server about it.
|
||||
*
|
||||
* `live` here means *this deployment answered*, and it is not the same claim as
|
||||
* `synthetic`, which means *nobody observed these readings*. All four
|
||||
* combinations are real deployments and the interface has to be able to say
|
||||
* each of them:
|
||||
*
|
||||
* | live | synthetic | what it is |
|
||||
* | --- | --- | --- |
|
||||
* | false | true | no server, or an anonymous viewer: the local simulator |
|
||||
* | true | true | a server running `TERA_DEVICES_SOURCE=sim` |
|
||||
* | true | false | a real bridge to real hardware |
|
||||
* | false | false | impossible, and nothing constructs it |
|
||||
*
|
||||
* The second row is the reference deployment and the first is every clone of
|
||||
* this repo, which is why both of them have to look alive and both have to say
|
||||
* so. `DeviceDeclaration.disclosure` is the sentence a viewer reads; these two
|
||||
* booleans are what the interface branches on.
|
||||
*/
|
||||
export interface DeviceFeed extends Feed<DeviceState[]> {
|
||||
/** Which source the server said it was using, or `"none"` when nobody answered. */
|
||||
source: DevicesSourceId;
|
||||
/** True when nobody observed these readings. True for everything this build ships. */
|
||||
synthetic: boolean;
|
||||
/** Epoch milliseconds of the snapshot, or `null` when there is no snapshot. */
|
||||
observedAt: number | null;
|
||||
attribution: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A running poll of one office's hardware.
|
||||
*
|
||||
* `PresenceWatch` with a `current()` on it. Devices need the accessor and
|
||||
* occupancy does not, because a device panel is opened *after* the room has
|
||||
* been drawn — the panel wants the last reading immediately rather than waiting
|
||||
* up to a TTL for the next publish, and re-fetching to answer that would spend
|
||||
* a request on a body this object is already holding.
|
||||
*/
|
||||
export interface DeviceWatch {
|
||||
/** The latest feed. Empty and not live until an answer lands. */
|
||||
current(): DeviceFeed;
|
||||
/** Ask now rather than at the next tick. Ignored while a request is in flight. */
|
||||
refresh(): void;
|
||||
/** Stop polling, abort anything in flight, and drop any late answer. */
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface TeraClient {
|
||||
/** What the deployment turned out to be, or `null` if there is no server. */
|
||||
health(): Promise<HealthBody | null>;
|
||||
@@ -262,6 +319,40 @@ export interface TeraClient {
|
||||
* to be able to draw.
|
||||
*/
|
||||
watchPresence(officeId: string, onBody: (body: PresenceBody | null) => void): PresenceWatch;
|
||||
/**
|
||||
* What the hardware in one office is doing.
|
||||
*
|
||||
* Always an authenticated call, exactly like `presence` and for a related
|
||||
* reason: `routes/devices.ts` refuses an anonymous one whatever else the
|
||||
* deployment is set to. A refusal — no API, no session, an office this
|
||||
* viewer may not see — is an empty feed with `live: false`, and the caller's
|
||||
* answer to that is the *locally simulated* studio in `src/devices/sim.ts`,
|
||||
* not an empty panel. See `src/devices/adapter.ts`, which is where that
|
||||
* decision is made once instead of at every call site.
|
||||
*/
|
||||
devices(officeId: string, options?: { signal?: AbortSignal }): Promise<DeviceFeed>;
|
||||
/**
|
||||
* The same question, asked repeatedly, until the caller stops it.
|
||||
*
|
||||
* Modelled on `watchPresence` rather than on `watchWeather`, because it
|
||||
* describes the room somebody is standing in rather than the sky: it stops
|
||||
* dead while the tab is hidden, wakes the moment it comes back, and publishes
|
||||
* only when a reading a viewer could see has changed.
|
||||
*/
|
||||
watchDevices(officeId: string, onFeed: (feed: DeviceFeed) => void): DeviceWatch;
|
||||
/**
|
||||
* Ask one device to do something. Resolves to the state it ended up in, or
|
||||
* `null` for any refusal.
|
||||
*
|
||||
* **A POST, on a route of its own, never folded into the read.** A command
|
||||
* riding in a GET response could be replayed by any shared cache that kept a
|
||||
* copy, and a cache that turned a microphone on by replaying a read is
|
||||
* exactly what the fail-closed `Cache-Control` default in CONTRACT.md §5
|
||||
* exists to prevent. It is also the first write surface in this product that
|
||||
* changes something another viewer can see, which is the other half of why
|
||||
* it is separate: reading is the demo, writing is the account.
|
||||
*/
|
||||
commandDevice(officeId: string, command: DeviceCommand): Promise<DeviceState | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,6 +417,40 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* One POST, and `null` for every way it can go wrong.
|
||||
*
|
||||
* The same deliberate coarseness as `get` — a 401, a 400, a timeout and a
|
||||
* static host answering with its own HTML are one outcome to the caller — with
|
||||
* one difference that matters: **nothing here retries**. A GET that failed can
|
||||
* be repeated because asking twice costs a request; a command that failed may
|
||||
* have been applied before the connection died, and repeating it is the
|
||||
* difference between "turn the microphone on" and "turn the microphone on
|
||||
* twice". Idempotence is not a property this can assume on the caller's
|
||||
* behalf, so a refusal is reported and the panel asks the person.
|
||||
*/
|
||||
const post = async <T,>(path: string, body: unknown): Promise<T | null> => {
|
||||
if (!doFetch) return null;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await doFetch(`${base}${path}`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const type = res.headers.get("content-type") ?? "";
|
||||
if (!type.includes("json")) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
health: () => get<HealthBody>("/health"),
|
||||
|
||||
@@ -410,6 +535,30 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
|
||||
watchPresence(officeId, onBody) {
|
||||
return watchPresence(get, officeId, onBody);
|
||||
},
|
||||
|
||||
async devices(officeId, opts: { signal?: AbortSignal } = {}): Promise<DeviceFeed> {
|
||||
const body = await get<DevicesBody>(devicesPath(officeId), {
|
||||
...(opts.signal ? { signal: opts.signal } : {}),
|
||||
});
|
||||
return deviceFeed(body);
|
||||
},
|
||||
|
||||
watchDevices(officeId, onFeed) {
|
||||
return watchDevices(get, officeId, onFeed);
|
||||
},
|
||||
|
||||
async commandDevice(officeId, command): Promise<DeviceState | null> {
|
||||
const request: DeviceCommandBody = { command };
|
||||
const body = await post<DeviceCommandResultBody>(
|
||||
`${devicesPath(officeId)}/command`,
|
||||
request,
|
||||
);
|
||||
// Checked rather than trusted, like every other body this file adopts: a
|
||||
// 200 with the wrong shape in it is what a server one version behind this
|
||||
// one sends, and `null` is already the caller's "it did not happen".
|
||||
if (!body || body.device === null || typeof body.device !== "object") return null;
|
||||
return body.device;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -755,6 +904,16 @@ function queryString(query: Record<string, string | number> | undefined): string
|
||||
* it.
|
||||
*/
|
||||
export interface TrafficSource extends FlightSource {
|
||||
/**
|
||||
* Narrowed from `FlightSource.poll()`, which may return a promise.
|
||||
*
|
||||
* Not a convenience: it is the file's central promise made into a type. This
|
||||
* is called from the render loop, so it answers from whatever is in hand and
|
||||
* refreshes on the body's own TTL in the background — a `poll()` that awaited
|
||||
* a slow fetch would put a frame's aircraft update behind a round trip. An
|
||||
* adapter that cannot promise that is a `FlightSource` and not one of these.
|
||||
*/
|
||||
poll(): Aircraft[];
|
||||
/** True while the aircraft `poll()` returns are observed positions for this region. */
|
||||
live(): boolean;
|
||||
/**
|
||||
@@ -767,6 +926,26 @@ export interface TrafficSource extends FlightSource {
|
||||
* exists. `describeLiveness` says what is live; this says who to thank for it.
|
||||
*/
|
||||
attribution(): string[];
|
||||
/**
|
||||
* Everything known about one aircraft that is currently being drawn, or
|
||||
* `null` for an id that is not.
|
||||
*
|
||||
* The click target of the whole city board, and it is on this interface for
|
||||
* the same reason `live()` and `attribution()` are: the engine draws darts at
|
||||
* coordinates and the *deployment* knows what those coordinates are. It is
|
||||
* synchronous and answers from what `poll()` last handed over, so a click can
|
||||
* open a card in the same frame — going back to the network for a record this
|
||||
* object already holds would put a panel behind a round trip, and would ask a
|
||||
* volunteer-funded feed for a row it just sent.
|
||||
*
|
||||
* **Available to an anonymous visitor**, and that is a decision rather than
|
||||
* an oversight. An ADS-B position is broadcast unencrypted by the aircraft to
|
||||
* anybody with a receiver; there is nothing here an account could grant
|
||||
* access to, and gating it would cost the first-visit moment this map exists
|
||||
* for while protecting nothing. `access.ts` makes the same argument about the
|
||||
* sky at greater length.
|
||||
*/
|
||||
detail(id: string): AircraftDetail | null;
|
||||
/** Stop fetching and abort anything in flight. Idempotent. */
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -809,30 +988,91 @@ class HttpFlights implements TrafficSource {
|
||||
*/
|
||||
readonly interval = 1;
|
||||
|
||||
private readonly get: Get;
|
||||
private readonly region: SkyRegion;
|
||||
private readonly fallback: SimulatedFlights;
|
||||
private mode: "fallback" | "plan" | "live" = "fallback";
|
||||
private plan: FlightsPlanBody | null = null;
|
||||
private planPhase: number[] = [];
|
||||
private aircraft: Aircraft[] = [];
|
||||
private credits: string[] = [];
|
||||
/**
|
||||
* Everything the live body said about each aircraft, keyed by id.
|
||||
*
|
||||
* Only the live path fills this, because only the live path is told anything
|
||||
* an `Aircraft` cannot carry — `WireAircraft.icao24` in particular, which is
|
||||
* the transponder address and is the field a detail card is actually about.
|
||||
* Cleared and rebuilt with every adopted body, so it can never outlive the
|
||||
* positions it describes.
|
||||
*/
|
||||
private readonly wire = new Map<string, WireAircraft>();
|
||||
private latest: Aircraft[] = [];
|
||||
private nextFetchAt = 0;
|
||||
private inFlight: AbortController | null = null;
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
private readonly get: Get,
|
||||
private readonly region: SkyRegion,
|
||||
fallbackRoutes: SimRoute[],
|
||||
) {
|
||||
/**
|
||||
* Fields assigned in the constructor body rather than declared as parameter
|
||||
* properties.
|
||||
*
|
||||
* **This is not a style preference and it must stay this way.** A parameter
|
||||
* property is the one piece of TypeScript syntax that emits code — it is a
|
||||
* hidden assignment, not a type annotation — so Node's type stripping refuses
|
||||
* the *whole module* with `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. Vite never
|
||||
* cared, so for the entire life of this file `node --test` could not import
|
||||
* `adapters/http.ts` at all: every degrade path, every rung of the back-off
|
||||
* ladder and the whole region filter below had zero coverage, in the module
|
||||
* whose job is to be correct when everything else has failed.
|
||||
*
|
||||
* `engine/flights.ts` says the same thing over `AdsbFlights` and adds the
|
||||
* consequence: the module with the worst bug this project has shipped was, by
|
||||
* construction, the one module that could not be tested. Two modules had that
|
||||
* property; this was the second.
|
||||
*/
|
||||
constructor(get: Get, region: SkyRegion, fallbackRoutes: SimRoute[]) {
|
||||
this.get = get;
|
||||
this.region = region;
|
||||
this.fallback = new SimulatedFlights(fallbackRoutes);
|
||||
}
|
||||
|
||||
poll(): Aircraft[] {
|
||||
this.refreshIfStale();
|
||||
const { mode, plan, planPhase } = this;
|
||||
if (mode === "plan" && plan) return evaluatePlan(plan, planPhase, Date.now());
|
||||
if (this.mode === "live") return this.aircraft;
|
||||
return this.fallback.poll();
|
||||
// Kept, so that `detail()` can answer a click about the aircraft that were
|
||||
// actually drawn rather than about a fresh evaluation a few milliseconds
|
||||
// later — the plan path is a function of `Date.now()`, so re-evaluating it
|
||||
// for a lookup would return a position slightly ahead of the dart the
|
||||
// viewer aimed at.
|
||||
this.latest =
|
||||
mode === "plan" && plan
|
||||
? evaluatePlan(plan, planPhase, Date.now())
|
||||
: mode === "live"
|
||||
? this.aircraft
|
||||
: this.fallback.poll();
|
||||
return this.latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* One aircraft, as a card.
|
||||
*
|
||||
* Looked up in what was last polled, which is also what is on screen. An id
|
||||
* that has left the feed answers `null` rather than the last known position:
|
||||
* a card showing where something was two minutes ago, with no way to say so,
|
||||
* is the same class of quiet staleness `WeatherFeed.observedAt` exists to
|
||||
* prevent — and the caller's honest response is to close the card.
|
||||
*/
|
||||
detail(id: string): AircraftDetail | null {
|
||||
const found = this.latest.find((a) => a.id === id);
|
||||
if (found === undefined) return null;
|
||||
const wire = this.wire.get(id);
|
||||
return aircraftDetail(found, {
|
||||
// Only a live body carries an address, and only a live body was observed.
|
||||
// The plan's aircraft are this repo's own arithmetic and say so.
|
||||
...(wire?.icao24 === undefined ? {} : { icao24: wire.icao24 }),
|
||||
observed: this.mode === "live",
|
||||
attribution: this.attribution(),
|
||||
from: this.region.center,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -924,6 +1164,12 @@ class HttpFlights implements TrafficSource {
|
||||
private adopt(body: FlightsBody): number {
|
||||
const ttl = Number.isFinite(body.ttlSeconds) ? Math.max(1, body.ttlSeconds) : RETRY_SECONDS;
|
||||
this.credits = [];
|
||||
// Both of these describe the body that is about to be adopted, so both are
|
||||
// dropped before it is looked at rather than on each of the four ways this
|
||||
// method can decide not to adopt it. A transponder address left over from a
|
||||
// previous body would otherwise be attached, by id collision, to whatever
|
||||
// the next one draws.
|
||||
this.wire.clear();
|
||||
|
||||
if (body.mode === "plan") {
|
||||
if (!Array.isArray(body.routes)) {
|
||||
@@ -981,6 +1227,11 @@ class HttpFlights implements TrafficSource {
|
||||
return ELSEWHERE_SECONDS;
|
||||
}
|
||||
this.aircraft = here;
|
||||
// Only the live path has anything to record: a plan carries routes, not
|
||||
// transponders. Cleared at the top of this method, so a record that has
|
||||
// left the feed leaves this map with it rather than surviving to answer a
|
||||
// click about an aircraft nobody is drawing.
|
||||
for (const a of here) this.wire.set(a.id, a);
|
||||
this.plan = null;
|
||||
this.mode = "live";
|
||||
// Only the live body carries credits — `wire.ts` puts `attribution` on
|
||||
@@ -1232,3 +1483,208 @@ function watchPresence(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Devices --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* How often to ask what the hardware is doing, when the server does not say.
|
||||
*
|
||||
* Five seconds, and the number comes from the data rather than from the
|
||||
* network. Occupancy moves when somebody stands up and is polled every thirty;
|
||||
* a level meter moves continuously and a mute button moves the instant it is
|
||||
* pressed, and a panel that took half a minute to notice somebody else had
|
||||
* muted the room would read as broken. The server's `ttlSeconds` overrides this
|
||||
* whenever it answers — `TERA_DEVICES_TTL` is the operator's dial and this is
|
||||
* only what to do before they have had their say.
|
||||
*
|
||||
* It is still a poll rather than a stream. The realtime service exists and this
|
||||
* deliberately does not use it: a device panel is open for a minute at a time
|
||||
* on a handful of tabs, and a socket per viewer for a body this size is a
|
||||
* standing cost for an occasional need. If the panels are ever open all day,
|
||||
* that is the moment to revisit it.
|
||||
*/
|
||||
const DEVICES_INTERVAL_MS = 5_000;
|
||||
|
||||
/** Bounds on whatever the server asks for, so one bad TTL cannot become a flood. */
|
||||
const DEVICES_MIN_INTERVAL_MS = 2_000;
|
||||
const DEVICES_MAX_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* The ceiling on the back-off ladder, five minutes — `watchPresence`'s number,
|
||||
* not the weather watch's hour, and for the same reason it gives: somebody is
|
||||
* standing in the room this describes.
|
||||
*/
|
||||
const DEVICES_MAX_BACKOFF_MS = 5 * 60_000;
|
||||
|
||||
/** Nobody answered. Empty, not live, and not claiming to have observed anything. */
|
||||
function noDevices(): DeviceFeed {
|
||||
return { value: [], live: false, source: "none", synthetic: true, observedAt: null, attribution: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* One devices body, judged.
|
||||
*
|
||||
* Two outcomes, and the check is on the array rather than on its length. A body
|
||||
* with `devices: []` is a real answer — an office nobody has declared any
|
||||
* hardware in, which is most offices — and it is `live`, because the deployment
|
||||
* answered and said so. A body with no array in it at all is not an answer, and
|
||||
* it is the shape a server one version behind this one sends. The same
|
||||
* distinction `adsb.ts` draws between an empty circle of sky and an unreadable
|
||||
* envelope, for the same reason: coercing the second into the first serves
|
||||
* fiction under a live badge.
|
||||
*/
|
||||
function deviceFeed(body: DevicesBody | null): DeviceFeed {
|
||||
if (!body || !Array.isArray(body.devices)) return noDevices();
|
||||
return {
|
||||
value: body.devices,
|
||||
live: true,
|
||||
source: body.source ?? "none",
|
||||
// Absent means synthetic. A body that did not say whether anybody observed
|
||||
// its readings is not a body that may be presented as observation.
|
||||
synthetic: body.synthetic !== false,
|
||||
observedAt: typeof body.observedAt === "number" ? body.observedAt : null,
|
||||
attribution: Array.isArray(body.attribution) ? body.attribution : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll one office's hardware until told to stop.
|
||||
*
|
||||
* `watchPresence` with a TTL from the body and a signature that covers readings
|
||||
* instead of seats. The three properties it inherits are the ones that matter
|
||||
* and they are argued for at length there:
|
||||
*
|
||||
* - **stops dead while the tab is hidden**, and asks immediately on the way
|
||||
* back, because a backgrounded panel polling a meter nobody can see is
|
||||
* waste at both ends;
|
||||
* - **publishes only on change**, by comparing `deviceStateSignature` — every
|
||||
* answer is a fresh array, so identity says nothing, and a still studio
|
||||
* would otherwise rebuild its panel every few seconds forever;
|
||||
* - **reports a refusal** rather than freezing on the last good reading, so
|
||||
* "the room went quiet" and "I have stopped hearing about the room" stay
|
||||
* distinguishable.
|
||||
*
|
||||
* The signature deliberately ignores `observedAt`, which moves on every poll of
|
||||
* an unchanged studio and would defeat the whole comparison.
|
||||
*/
|
||||
function watchDevices(
|
||||
get: Get,
|
||||
officeId: string,
|
||||
onFeed: (feed: DeviceFeed) => void,
|
||||
): DeviceWatch {
|
||||
let feed = noDevices();
|
||||
let signature: string | null = null;
|
||||
let published = false;
|
||||
let failures = 0;
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let inFlight: AbortController | null = null;
|
||||
|
||||
const path = devicesPath(officeId);
|
||||
|
||||
function schedule(delayMs: number) {
|
||||
if (stopped) return;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = setTimeout(() => void tick(), delayMs);
|
||||
}
|
||||
|
||||
function publish(next: DeviceFeed) {
|
||||
const nextSignature = `${next.live ? "1" : "0"}${deviceStateSignature(next.value)}`;
|
||||
// The first answer always goes through, even when it matches the empty
|
||||
// signature the caller may have assumed. "I asked and there is nothing" and
|
||||
// "I have not asked yet" are different states and only one of them should
|
||||
// leave a panel saying so on purpose.
|
||||
feed = next;
|
||||
if (published && nextSignature === signature) return;
|
||||
signature = nextSignature;
|
||||
published = true;
|
||||
onFeed(next);
|
||||
}
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
timer = null;
|
||||
if (stopped) return;
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||
// Nothing reaches this with a request already out, but a watch that stalled
|
||||
// would stay stalled until the page reloaded, and that is too quiet a
|
||||
// failure to leave to the reasoning being right.
|
||||
if (inFlight) {
|
||||
schedule(DEVICES_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
inFlight = new AbortController();
|
||||
const body = await get<DevicesBody>(path, { signal: inFlight.signal });
|
||||
inFlight = null;
|
||||
// Stopped while this was in the air: the office was left or the panel was
|
||||
// closed. Whatever came back describes a room nobody is looking at, and it
|
||||
// is also why a cancelled request must not count as a failure below.
|
||||
if (stopped) return;
|
||||
|
||||
publish(deviceFeed(body));
|
||||
|
||||
if (body === null) {
|
||||
failures += 1;
|
||||
schedule(Math.min(DEVICES_INTERVAL_MS * 2 ** (failures - 1), DEVICES_MAX_BACKOFF_MS));
|
||||
return;
|
||||
}
|
||||
failures = 0;
|
||||
schedule(intervalFor(body));
|
||||
}
|
||||
|
||||
function onVisibility() {
|
||||
if (stopped) return;
|
||||
if (document.visibilityState === "visible") {
|
||||
// Immediately rather than at the next tick: somebody has just come back
|
||||
// to this tab and the panel in front of them is the stale thing.
|
||||
failures = 0;
|
||||
schedule(0);
|
||||
} else if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
}
|
||||
|
||||
void tick();
|
||||
|
||||
return {
|
||||
current: () => feed,
|
||||
refresh() {
|
||||
if (stopped || inFlight) return;
|
||||
schedule(0);
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = null;
|
||||
inFlight?.abort();
|
||||
inFlight = null;
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The cadence the server asked for, clamped.
|
||||
*
|
||||
* Checked rather than trusted, exactly as `HttpFlights.adopt` learned to be: an
|
||||
* absent `ttlSeconds` makes `Math.max(1, undefined)` a `NaN`, `NaN` clears every
|
||||
* comparison, and the poll interval quietly becomes the frame rate. The floor
|
||||
* is the load-bearing half — a `TERA_DEVICES_TTL=0` that reads like "as fresh
|
||||
* as possible" would otherwise be one request per tick per open tab.
|
||||
*/
|
||||
function intervalFor(body: DevicesBody): number {
|
||||
const asked = Number.isFinite(body.ttlSeconds) ? body.ttlSeconds * 1000 : DEVICES_INTERVAL_MS;
|
||||
return Math.min(DEVICES_MAX_INTERVAL_MS, Math.max(DEVICES_MIN_INTERVAL_MS, asked));
|
||||
}
|
||||
|
||||
/** One place the route is spelled, so the read and the command cannot drift apart. */
|
||||
function devicesPath(officeId: string): string {
|
||||
return `/offices/${encodeURIComponent(officeId)}/devices`;
|
||||
}
|
||||
|
||||
+35
-5
@@ -1,4 +1,4 @@
|
||||
import { arenaChecksum } from "./checksum.ts";
|
||||
import { arenaChecksum, quantizeForChecksum } from "./checksum.ts";
|
||||
import type { ArenaScenarioRegistry } from "./scenarios.ts";
|
||||
import {
|
||||
ARENA_API_VERSION,
|
||||
@@ -108,6 +108,9 @@ export abstract class BaseArenaEnvironment<
|
||||
envId: this.manifest.id,
|
||||
envVersion: this.manifest.version,
|
||||
envHash: this.envHash(),
|
||||
// Inside the checksummed core, so a snapshot cannot be re-pinned to a
|
||||
// different simulator without invalidating itself. See `ArenaSnapshot`.
|
||||
sourceHashes: this.sourceHashes,
|
||||
seed: scenario.seed,
|
||||
scenarioId: scenario.id,
|
||||
scenarioSplit: scenario.split,
|
||||
@@ -127,7 +130,8 @@ export abstract class BaseArenaEnvironment<
|
||||
if (arenaChecksum(core) !== checksum) throw new Error("arena snapshot checksum mismatch");
|
||||
if (
|
||||
snapshot.apiVersion !== ARENA_API_VERSION || snapshot.envId !== this.manifest.id ||
|
||||
snapshot.envVersion !== this.manifest.version || snapshot.envHash !== this.envHash()
|
||||
snapshot.envVersion !== this.manifest.version || snapshot.envHash !== this.envHash() ||
|
||||
arenaChecksum(snapshot.sourceHashes) !== arenaChecksum(this.sourceHashes)
|
||||
) throw new Error("arena snapshot is incompatible with this environment");
|
||||
if (
|
||||
!Number.isSafeInteger(snapshot.step) || snapshot.step < 0 ||
|
||||
@@ -148,7 +152,27 @@ export abstract class BaseArenaEnvironment<
|
||||
this.truncated = snapshot.truncated;
|
||||
this.terminalReason = snapshot.terminalReason;
|
||||
this.frames = [];
|
||||
const observation = this.restoreSimulation(structuredClone(snapshot.simulation));
|
||||
let observation: O;
|
||||
try {
|
||||
observation = this.restoreSimulation(structuredClone(snapshot.simulation));
|
||||
} catch (error) {
|
||||
// A concrete environment can reject the simulation payload after the
|
||||
// episode bookkeeping above has already been written — and several do,
|
||||
// because validating a controller's snapshot means handing it to the
|
||||
// controller. Leaving the object in that state was the worst of the three
|
||||
// options: the step index, the cumulative reward and the terminal flags
|
||||
// would say one thing and the simulators another, and the *next* call
|
||||
// would succeed and quietly produce a mixture of two episodes.
|
||||
//
|
||||
// Rolling back is not available either: `restoreSimulation` has usually
|
||||
// rebuilt the simulators from the scenario before it validates, so the
|
||||
// state it threw from is not the state it started in. So the environment
|
||||
// is marked un-reset, which makes every subsequent call fail loudly until
|
||||
// somebody calls `reset` or `restore` again — the same disposition
|
||||
// `devices/sim.ts` takes when it refuses a snapshot outright.
|
||||
this.scenario = null;
|
||||
throw error;
|
||||
}
|
||||
this.initialStateChecksum = arenaChecksum(this.statePayload());
|
||||
return { observation, info: this.info() };
|
||||
}
|
||||
@@ -200,7 +224,13 @@ export abstract class BaseArenaEnvironment<
|
||||
const actual = this.step(expected.action);
|
||||
observation = actual.observation;
|
||||
if (
|
||||
actual.info.stateChecksum !== expected.stateChecksum || actual.reward !== expected.reward ||
|
||||
actual.info.stateChecksum !== expected.stateChecksum ||
|
||||
// Quantised rather than `!==`, for the reason `checksum.ts` sets out at
|
||||
// length: a reward is a float, a verifier runs on hardware the producer
|
||||
// never saw, and `Math.pow` is not required to be correctly rounded. An
|
||||
// exact comparison here would have re-introduced on one line precisely
|
||||
// the cross-runtime failure the checksum was hardened against.
|
||||
quantizeForChecksum(actual.reward) !== quantizeForChecksum(expected.reward) ||
|
||||
actual.terminated !== expected.terminated || actual.truncated !== expected.truncated ||
|
||||
arenaChecksum(actual.rewardComponents) !== arenaChecksum(expected.rewardComponents)
|
||||
) throw new Error(`arena trace diverged at step ${expected.index}`);
|
||||
@@ -208,7 +238,7 @@ export abstract class BaseArenaEnvironment<
|
||||
const replayed = this.trace();
|
||||
if (
|
||||
replayed.finalStateChecksum !== trace.finalStateChecksum ||
|
||||
replayed.cumulativeReward !== trace.cumulativeReward
|
||||
quantizeForChecksum(replayed.cumulativeReward) !== quantizeForChecksum(trace.cumulativeReward)
|
||||
) throw new Error("arena trace final state mismatch");
|
||||
return {
|
||||
observation,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
|
||||
import { ArenaScenarioRegistry } from "./scenarios.ts";
|
||||
import {
|
||||
ARENA_API_VERSION,
|
||||
type ArenaFieldSpec,
|
||||
type ArenaManifest,
|
||||
type ArenaScenario,
|
||||
} from "./types.ts";
|
||||
@@ -119,7 +120,13 @@ export const CALIFORNIA_FLIGHT_SCENARIOS = new ArenaScenarioRegistry<FlightScena
|
||||
export const CALIFORNIA_FLIGHT_MANIFEST: ArenaManifest = Object.freeze({
|
||||
apiVersion: ARENA_API_VERSION,
|
||||
id: "california-flight-v1",
|
||||
version: 1,
|
||||
// 2 rather than 1: `ArenaScenarioRegistry` now selects a bare `{ split }`
|
||||
// request by hashing the seed against each scenario id instead of indexing
|
||||
// definition order, so a seed run before that change may resolve to a
|
||||
// different scenario after it. `version` is what a snapshot, a trace and a
|
||||
// results table are pinned to, and a selection change that nothing recorded
|
||||
// is exactly the silent remap the new selector exists to prevent.
|
||||
version: 2,
|
||||
title: "California electric-flight waypoint",
|
||||
description: "Manual fixed-wing waypoint control over Tera's renderer-neutral aircraft simulator.",
|
||||
simulator: "AircraftController",
|
||||
@@ -131,6 +138,33 @@ export const CALIFORNIA_FLIGHT_MANIFEST: ArenaManifest = Object.freeze({
|
||||
"verticalSpeedMps", "goalLat", "goalLng", "goalAltitudeM", "distanceToGoalM",
|
||||
"bearingToGoalDeg", "altitudeErrorM", "envelopeContact",
|
||||
],
|
||||
actionSpace: [
|
||||
{ name: "throttle", kind: "float", low: 0, high: 1, unit: "fraction" },
|
||||
{ name: "yaw", kind: "float", low: -1, high: 1, unit: "fraction" },
|
||||
{ name: "pitch", kind: "float", low: -1, high: 1, unit: "fraction" },
|
||||
{ name: "roll", kind: "float", low: -1, high: 1, unit: "fraction" },
|
||||
] satisfies readonly ArenaFieldSpec[],
|
||||
// Latitude and longitude are bounded to California rather than to the globe.
|
||||
// A ±90/±180 box would put every scenario in this environment inside four
|
||||
// decimal places of the same normalized value, which is a constant input
|
||||
// wearing a coordinate's clothes.
|
||||
observationSpace: [
|
||||
{ name: "lat", kind: "float", low: 32, high: 42.2, unit: "deg north" },
|
||||
{ name: "lng", kind: "float", low: -124.5, high: -114, unit: "deg east" },
|
||||
{ name: "altitudeM", kind: "float", low: 0, high: 4000, unit: "m" },
|
||||
{ name: "headingDeg", kind: "float", low: 0, high: 360, unit: "deg true" },
|
||||
{ name: "pitchDeg", kind: "float", low: -90, high: 90, unit: "deg" },
|
||||
{ name: "rollDeg", kind: "float", low: -90, high: 90, unit: "deg" },
|
||||
{ name: "speedMps", kind: "float", low: 0, high: 260, unit: "m/s" },
|
||||
{ name: "verticalSpeedMps", kind: "float", low: -60, high: 60, unit: "m/s" },
|
||||
{ name: "goalLat", kind: "float", low: 32, high: 42.2, unit: "deg north" },
|
||||
{ name: "goalLng", kind: "float", low: -124.5, high: -114, unit: "deg east" },
|
||||
{ name: "goalAltitudeM", kind: "float", low: 0, high: 4000, unit: "m" },
|
||||
{ name: "distanceToGoalM", kind: "float", low: 0, high: 20000, unit: "m" },
|
||||
{ name: "bearingToGoalDeg", kind: "float", low: 0, high: 360, unit: "deg true" },
|
||||
{ name: "altitudeErrorM", kind: "float", low: -2000, high: 2000, unit: "m" },
|
||||
{ name: "envelopeContact", kind: "bool" },
|
||||
] satisfies readonly ArenaFieldSpec[],
|
||||
rewardComponents: {
|
||||
progress: "Reduction in three-dimensional waypoint distance.",
|
||||
success: "Sparse arrival bonus.",
|
||||
@@ -158,14 +192,16 @@ function horizontalDistanceM(a: AircraftGeographicPoint, b: AircraftGeographicPo
|
||||
const mean = (a.lat + b.lat) / 2 * Math.PI / 180;
|
||||
const north = (b.lat - a.lat) * Math.PI / 180 * EARTH_RADIUS_M;
|
||||
const east = (b.lng - a.lng) * Math.PI / 180 * Math.cos(mean) * EARTH_RADIUS_M;
|
||||
return Math.hypot(north, east);
|
||||
return Math.sqrt(north * north + east * east);
|
||||
}
|
||||
|
||||
function distance3dM(
|
||||
a: AircraftGeographicPoint & { altitudeM: number },
|
||||
b: AircraftGeographicPoint & { altitudeM: number },
|
||||
): number {
|
||||
return Math.hypot(horizontalDistanceM(a, b), b.altitudeM - a.altitudeM);
|
||||
const horizontal = horizontalDistanceM(a, b);
|
||||
const vertical = b.altitudeM - a.altitudeM;
|
||||
return Math.sqrt(horizontal * horizontal + vertical * vertical);
|
||||
}
|
||||
|
||||
function bearingDeg(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number {
|
||||
|
||||
+129
-2
@@ -1,11 +1,132 @@
|
||||
/** Canonical JSON and a small cross-runtime checksum (no Node or Web APIs). */
|
||||
/**
|
||||
* Canonical JSON and a small cross-runtime checksum (no Node or Web APIs).
|
||||
*
|
||||
* Two properties matter here and they pull in opposite directions.
|
||||
*
|
||||
* **Nothing may hash to the same string as something it is not.** A checksum
|
||||
* that silently accepts a value it cannot describe is worse than one that
|
||||
* refuses: `canonical` used to reach `typeof value === "object"` for a `Map`, a
|
||||
* `Set` and a `Date` alike, read their *own enumerable* keys — of which those
|
||||
* three have none — and emit `{}`. So a `Map` with a thousand entries in it
|
||||
* checksummed identically to an empty object, and to every other `Map`. Nothing
|
||||
* in the five shipped environments carries one, but `ResolvedRobotOperations`
|
||||
* already holds `ReadonlyMap`s and `studio-ops-v1` reaches into it: the
|
||||
* environment was one careless snapshot field away from a trace that verified
|
||||
* against a state it had never seen. Anything that is not a plain object, an
|
||||
* array, a string, a boolean, a finite number or `null` now throws.
|
||||
*
|
||||
* **Two honest runs of the same rollout on different hardware must agree.**
|
||||
* That is the harder one, and it is a float problem rather than a shape
|
||||
* problem. `Math.sin`, `Math.atan2`, `Math.pow` and friends are not required by
|
||||
* IEEE-754 or by ECMA-262 to be correctly rounded — only `+`, `-`, `*`, `/` and
|
||||
* `Math.sqrt` are — so two conforming engines may return results a unit in the
|
||||
* last place apart for the same input. `studio-ops-v1` runs a solar position
|
||||
* and a set of bearings through its observation on every step. Against an
|
||||
* *exact-equality* checksum that is a verifier on different hardware rejecting
|
||||
* an honest rollout, which is the single worst failure this file can have: it
|
||||
* is silent, it looks like fraud, and it only happens to somebody else.
|
||||
*
|
||||
* The same argument rules out `Math.hypot`, which is a library function with no
|
||||
* correctly-rounded guarantee and which every environment in this package used
|
||||
* to reach for. All of them now compute `Math.sqrt(a * a + b * b)` instead:
|
||||
* identical arithmetic, one guarantee more, and marginally faster.
|
||||
*
|
||||
* `quantizeToPlaces` is the answer, applied in two places:
|
||||
*
|
||||
* 1. here, to every non-integer that is hashed, so last-place noise on a
|
||||
* value that is *reported* but never fed back cannot change a checksum;
|
||||
* 2. and — this is the load-bearing one — at the point a transcendental is
|
||||
* called, by the environment itself, so that the quantised value is the
|
||||
* one that propagates. See `quantizeObservable` in `studioOps.ts`.
|
||||
*
|
||||
* Be clear about what (1) alone cannot do: once a simulation has *accumulated*
|
||||
* a divergence, no amount of rounding at the boundary brings the two runs back
|
||||
* together, because the difference grows with every step rather than staying in
|
||||
* the last place. Quantising at the source is what keeps the divergence from
|
||||
* ever starting; quantising at the checksum is what keeps a value that is
|
||||
* computed fresh each step, reported and then discarded from tripping the
|
||||
* comparison. Both, or neither is worth much.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decimal places kept when a number is hashed.
|
||||
*
|
||||
* Nine, which is a quantum of 1e-9 in the units of whatever is being hashed —
|
||||
* a nanometre for a position, a nano-newton-metre of reward. Every quantity
|
||||
* this package hashes is comfortably inside ±9e6, where a double's own spacing
|
||||
* is at most ~2e-9 and the ratio of that spacing to the quantum bounds how
|
||||
* often two values a last place apart can land either side of a rounding
|
||||
* boundary. Coarser would buy a wider margin and start throwing away signal a
|
||||
* reward shaper can see; finer stops being a quantisation at all.
|
||||
*/
|
||||
export const ARENA_CHECKSUM_DECIMALS = 9;
|
||||
|
||||
/**
|
||||
* `value` rounded to `places` decimals, or returned unchanged where rounding
|
||||
* cannot be done exactly.
|
||||
*
|
||||
* Integers pass through untouched: they are already exact, and scaling a large
|
||||
* one by 1e9 would push it out of the safe-integer range and *lose* precision
|
||||
* in the name of adding some. The same bail-out covers a magnitude so large
|
||||
* that the requested quantum is finer than the double's own spacing, where
|
||||
* rounding is a no-op that cannot be computed. Both cases return the input
|
||||
* rather than an approximation of it.
|
||||
*
|
||||
* `Math.round` and the multiply/divide either side of it are all exact
|
||||
* operations that every conforming engine performs identically, which is the
|
||||
* entire reason this is decimal scaling and not `Math.log2`-based mantissa
|
||||
* surgery: the fix must not be built out of the family of functions it exists
|
||||
* to defend against.
|
||||
*/
|
||||
export function quantizeToPlaces(value: number, places: number): number {
|
||||
if (!Number.isFinite(value)) throw new TypeError("arena checksums require finite numbers");
|
||||
if (Number.isInteger(value)) return value;
|
||||
const scale = 10 ** places;
|
||||
const scaled = value * scale;
|
||||
if (!Number.isFinite(scaled) || Math.abs(scaled) > Number.MAX_SAFE_INTEGER) return value;
|
||||
// `+ 0` collapses a rounded -0 back to 0 so the sign of a vanishing quantity
|
||||
// cannot change a checksum. `canonical` below does the same for a literal -0.
|
||||
return Math.round(scaled) / scale + 0;
|
||||
}
|
||||
|
||||
/** `quantizeToPlaces(value, ARENA_CHECKSUM_DECIMALS)`. */
|
||||
export function quantizeForChecksum(value: number): number {
|
||||
return quantizeToPlaces(value, ARENA_CHECKSUM_DECIMALS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `value` is a plain data object rather than an instance of something.
|
||||
*
|
||||
* Prototype identity rather than a `constructor` name or a `Symbol.toStringTag`
|
||||
* sniff, because the question is not "what does this call itself" but "are its
|
||||
* own enumerable keys the whole of it". `Object.create(null)` passes for the
|
||||
* same reason a `{}` literal does.
|
||||
*/
|
||||
function isPlainObject(value: object): boolean {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name to put in the refusal.
|
||||
*
|
||||
* Worth the eight lines: "arena checksums do not accept Map" sends the reader
|
||||
* to the field they added, and "arena checksums do not accept object" sends
|
||||
* them here to find out what this function meant.
|
||||
*/
|
||||
function describe(value: object): string {
|
||||
const named = (value as { constructor?: { name?: unknown } }).constructor;
|
||||
const name = typeof named?.name === "string" && named.name.length > 0 ? named.name : null;
|
||||
return name ?? "a non-plain object";
|
||||
}
|
||||
|
||||
function canonical(value: unknown, stack: Set<object>): string {
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new TypeError("arena checksums require finite numbers");
|
||||
return Object.is(value, -0) ? "0" : JSON.stringify(value);
|
||||
if (Object.is(value, -0)) return "0";
|
||||
return JSON.stringify(quantizeForChecksum(value));
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (stack.has(value)) throw new TypeError("arena checksums do not accept cycles");
|
||||
@@ -15,6 +136,12 @@ function canonical(value: unknown, stack: Set<object>): string {
|
||||
return result;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
// The refusal that gives this module its point. A `Map`, a `Set`, a `Date`,
|
||||
// a typed array and a class instance all reach here, all have no own
|
||||
// enumerable keys worth reading, and all used to hash as `{}`.
|
||||
if (!isPlainObject(value)) {
|
||||
throw new TypeError(`arena checksums do not accept ${describe(value)}`);
|
||||
}
|
||||
if (stack.has(value)) throw new TypeError("arena checksums do not accept cycles");
|
||||
stack.add(value);
|
||||
const record = value as Record<string, unknown>;
|
||||
|
||||
+40
-2
@@ -7,6 +7,7 @@ import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
|
||||
import { ArenaScenarioRegistry } from "./scenarios.ts";
|
||||
import {
|
||||
ARENA_API_VERSION,
|
||||
type ArenaFieldSpec,
|
||||
type ArenaManifest,
|
||||
type ArenaScenario,
|
||||
} from "./types.ts";
|
||||
@@ -103,7 +104,13 @@ export const CROW_NAV_SCENARIOS = new ArenaScenarioRegistry<CrowScenarioParamete
|
||||
export const CROW_NAV_MANIFEST: ArenaManifest = Object.freeze({
|
||||
apiVersion: ARENA_API_VERSION,
|
||||
id: "crow-nav-v1",
|
||||
version: 1,
|
||||
// 2 rather than 1: `ArenaScenarioRegistry` now selects a bare `{ split }`
|
||||
// request by hashing the seed against each scenario id instead of indexing
|
||||
// definition order, so a seed run before that change may resolve to a
|
||||
// different scenario after it. `version` is what a snapshot, a trace and a
|
||||
// results table are pinned to, and a selection change that nothing recorded
|
||||
// is exactly the silent remap the new selector exists to prevent.
|
||||
version: 2,
|
||||
title: "Crow waypoint navigation",
|
||||
description: "Three-dimensional waypoint control over Tera's deterministic crow flight controller.",
|
||||
simulator: "ActorController(kind=crow, mode=flight)",
|
||||
@@ -115,6 +122,34 @@ export const CROW_NAV_MANIFEST: ArenaManifest = Object.freeze({
|
||||
"goalX", "goalY", "goalZ", "deltaX", "deltaY", "deltaZ",
|
||||
"distanceToGoalM", "altitudeBoundContact",
|
||||
],
|
||||
actionSpace: [
|
||||
{ name: "forward", kind: "float", low: -1, high: 1, unit: "fraction" },
|
||||
{ name: "turn", kind: "float", low: -1, high: 1, unit: "fraction" },
|
||||
{ name: "pitch", kind: "float", low: -1, high: 1, unit: "fraction" },
|
||||
{ name: "climb", kind: "float", low: -1, high: 1, unit: "fraction" },
|
||||
{ name: "glide", kind: "bool" },
|
||||
] satisfies readonly ArenaFieldSpec[],
|
||||
// The horizontal bounds are `BOUNDS` above and the altitude band is the
|
||||
// controller's own 2..40 m envelope, both stated here rather than restated:
|
||||
// a policy normalising against a wider box than the simulator enforces learns
|
||||
// a state distribution the environment never produces.
|
||||
observationSpace: [
|
||||
{ name: "x", kind: "float", low: -120, high: 120, unit: "m" },
|
||||
{ name: "y", kind: "float", low: 0, high: 40, unit: "m" },
|
||||
{ name: "z", kind: "float", low: -120, high: 120, unit: "m" },
|
||||
{ name: "yaw", kind: "float", low: -Math.PI, high: Math.PI, unit: "rad" },
|
||||
{ name: "pitch", kind: "float", low: -Math.PI / 2, high: Math.PI / 2, unit: "rad" },
|
||||
{ name: "speedMps", kind: "float", low: 0, high: 30, unit: "m/s" },
|
||||
{ name: "verticalSpeedMps", kind: "float", low: -20, high: 20, unit: "m/s" },
|
||||
{ name: "goalX", kind: "float", low: -120, high: 120, unit: "m" },
|
||||
{ name: "goalY", kind: "float", low: 0, high: 40, unit: "m" },
|
||||
{ name: "goalZ", kind: "float", low: -120, high: 120, unit: "m" },
|
||||
{ name: "deltaX", kind: "float", low: -240, high: 240, unit: "m" },
|
||||
{ name: "deltaY", kind: "float", low: -40, high: 40, unit: "m" },
|
||||
{ name: "deltaZ", kind: "float", low: -240, high: 240, unit: "m" },
|
||||
{ name: "distanceToGoalM", kind: "float", low: 0, high: 350, unit: "m" },
|
||||
{ name: "altitudeBoundContact", kind: "enum", values: ["none", "minimum", "maximum"] },
|
||||
] satisfies readonly ArenaFieldSpec[],
|
||||
rewardComponents: {
|
||||
progress: "Reduction in 3D distance to the waypoint.",
|
||||
success: "Sparse waypoint completion bonus.",
|
||||
@@ -275,7 +310,10 @@ export class CrowNavEnvironment extends BaseArenaEnvironment<
|
||||
private goalDistance(): number {
|
||||
const state = this.requireController().state();
|
||||
const goal = this.currentScenario().parameters;
|
||||
return Math.hypot(goal.goalX - state.x, goal.goalY - state.y, goal.goalZ - state.z);
|
||||
const dx = goal.goalX - state.x;
|
||||
const dy = goal.goalY - state.y;
|
||||
const dz = goal.goalZ - state.z;
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
private requireController(): ActorController {
|
||||
|
||||
+29
-1
@@ -8,6 +8,7 @@ import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
|
||||
import { ArenaScenarioRegistry } from "./scenarios.ts";
|
||||
import {
|
||||
ARENA_API_VERSION,
|
||||
type ArenaFieldSpec,
|
||||
type ArenaManifest,
|
||||
type ArenaScenario,
|
||||
} from "./types.ts";
|
||||
@@ -104,7 +105,13 @@ export const DRIVE_101_SCENARIOS = new ArenaScenarioRegistry<DriveScenarioParame
|
||||
export const DRIVE_101_MANIFEST: ArenaManifest = Object.freeze({
|
||||
apiVersion: ARENA_API_VERSION,
|
||||
id: "drive-101-v1",
|
||||
version: 1,
|
||||
// 2 rather than 1: `ArenaScenarioRegistry` now selects a bare `{ split }`
|
||||
// request by hashing the seed against each scenario id instead of indexing
|
||||
// definition order, so a seed run before that change may resolve to a
|
||||
// different scenario after it. `version` is what a snapshot, a trace and a
|
||||
// results table are pinned to, and a selection change that nothing recorded
|
||||
// is exactly the silent remap the new selector exists to prevent.
|
||||
version: 2,
|
||||
title: "California corridor driving",
|
||||
description: "Manual route-relative driving on Tera's authored US-101 and I-5 plans.",
|
||||
simulator: "VehicleController + CALIFORNIA_TRANSPORT",
|
||||
@@ -115,6 +122,27 @@ export const DRIVE_101_MANIFEST: ArenaManifest = Object.freeze({
|
||||
"routeId", "progressM", "remainingM", "lateralOffsetM", "speedMps",
|
||||
"speedLimitMps", "steering", "guardrailContact", "roadName",
|
||||
],
|
||||
actionSpace: [
|
||||
{ name: "throttle", kind: "float", low: 0, high: 1, unit: "fraction" },
|
||||
{ name: "brake", kind: "float", low: 0, high: 1, unit: "fraction" },
|
||||
{ name: "steering", kind: "float", low: -1, high: 1, unit: "fraction, + is right" },
|
||||
{ name: "handbrake", kind: "bool" },
|
||||
] satisfies readonly ArenaFieldSpec[],
|
||||
observationSpace: [
|
||||
{ name: "routeId", kind: "id" },
|
||||
// Bounded by the episode rather than by the corridor: `progressM` counts
|
||||
// distance travelled *this episode*, and 360 steps at the 42 m/s ceiling
|
||||
// cannot exceed 504 m — 1200 is generous headroom on a target of ~850 that
|
||||
// no policy reaches, and the bound is what a consumer normalises with.
|
||||
{ name: "progressM", kind: "float", low: 0, high: 1200, unit: "m" },
|
||||
{ name: "remainingM", kind: "float", low: 0, high: 1200, unit: "m" },
|
||||
{ name: "lateralOffsetM", kind: "float", low: -12, high: 12, unit: "m from centreline" },
|
||||
{ name: "speedMps", kind: "float", low: 0, high: 45, unit: "m/s" },
|
||||
{ name: "speedLimitMps", kind: "float", low: 0, high: 45, unit: "m/s" },
|
||||
{ name: "steering", kind: "float", low: -1, high: 1, unit: "fraction" },
|
||||
{ name: "guardrailContact", kind: "bool" },
|
||||
{ name: "roadName", kind: "id" },
|
||||
] satisfies readonly ArenaFieldSpec[],
|
||||
rewardComponents: {
|
||||
progress: "Forward route progress, normalized by the episode target.",
|
||||
success: "Sparse completion bonus.",
|
||||
|
||||
+87
-6
@@ -1,5 +1,11 @@
|
||||
export { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
|
||||
export { arenaChecksum, canonicalJson } from "./checksum.ts";
|
||||
export {
|
||||
ARENA_CHECKSUM_DECIMALS,
|
||||
arenaChecksum,
|
||||
canonicalJson,
|
||||
quantizeForChecksum,
|
||||
quantizeToPlaces,
|
||||
} from "./checksum.ts";
|
||||
export { ArenaRandom, deriveArenaSeed, normalizeArenaSeed } from "./random.ts";
|
||||
export {
|
||||
ArenaScenarioRegistry,
|
||||
@@ -7,9 +13,22 @@ export {
|
||||
type ScenarioSampler,
|
||||
} from "./scenarios.ts";
|
||||
export { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
|
||||
export {
|
||||
actionWidth,
|
||||
arenaEnvironmentIds,
|
||||
arenaFieldWidth,
|
||||
arenaManifest,
|
||||
flattenAction,
|
||||
flattenObservation,
|
||||
observationWidth,
|
||||
structureAction,
|
||||
} from "./spaces.ts";
|
||||
export { rollout, type ArenaPolicy, type RolloutOptions, type RolloutResult } from "./rollout.ts";
|
||||
export {
|
||||
ARENA_API_VERSION,
|
||||
type ArenaEnvironment,
|
||||
type ArenaFieldKind,
|
||||
type ArenaFieldSpec,
|
||||
type ArenaInfo,
|
||||
type ArenaManifest,
|
||||
type ArenaReplayResult,
|
||||
@@ -74,12 +93,41 @@ export {
|
||||
type CaliforniaFlightObservation,
|
||||
type CaliforniaFlightReward,
|
||||
} from "./californiaFlight.ts";
|
||||
export {
|
||||
STUDIO_OPS_INACTION,
|
||||
STUDIO_OPS_MANIFEST,
|
||||
STUDIO_OPS_SCENARIOS,
|
||||
StudioOpsEnvironment,
|
||||
localSolarHour,
|
||||
quantizeObservable,
|
||||
studioDeviceKw,
|
||||
studioHvacKw,
|
||||
studioOpsEnergyPenalty,
|
||||
studioOpsNoisePenalty,
|
||||
studioOpsScriptedBaseline,
|
||||
studioOverflights,
|
||||
studioSkyAt,
|
||||
studioSolarKw,
|
||||
studioVehicleReadiness,
|
||||
studioWeatherAt,
|
||||
weatherConditionOf,
|
||||
type OverflightTrack,
|
||||
type StudioNoiseInput,
|
||||
type StudioOpsAction,
|
||||
type StudioOpsObservation,
|
||||
type StudioOpsReward,
|
||||
type StudioOpsScenarioParameters,
|
||||
type StudioSky,
|
||||
type StudioWeather,
|
||||
} from "./studioOps.ts";
|
||||
|
||||
import { CALIFORNIA_FLIGHT_MANIFEST } from "./californiaFlight.ts";
|
||||
import { CROW_NAV_MANIFEST } from "./crowNav.ts";
|
||||
import { DRIVE_101_MANIFEST } from "./drive101.ts";
|
||||
import { OFFICE_NAV_MANIFEST } from "./officeNav.ts";
|
||||
import { OFFICE_JOBS_MANIFEST } from "./officeJobs.ts";
|
||||
import { CALIFORNIA_FLIGHT_MANIFEST, CaliforniaFlightEnvironment } from "./californiaFlight.ts";
|
||||
import { CROW_NAV_MANIFEST, CrowNavEnvironment } from "./crowNav.ts";
|
||||
import { DRIVE_101_MANIFEST, Drive101Environment } from "./drive101.ts";
|
||||
import { OFFICE_NAV_MANIFEST, OfficeNavEnvironment } from "./officeNav.ts";
|
||||
import { OFFICE_JOBS_MANIFEST, OfficeJobsEnvironment } from "./officeJobs.ts";
|
||||
import { STUDIO_OPS_MANIFEST, StudioOpsEnvironment } from "./studioOps.ts";
|
||||
import type { ArenaEnvironment } from "./types.ts";
|
||||
|
||||
/** Machine-readable public environment catalogue. */
|
||||
export const ARENA_MANIFESTS = Object.freeze([
|
||||
@@ -88,4 +136,37 @@ export const ARENA_MANIFESTS = Object.freeze([
|
||||
OFFICE_JOBS_MANIFEST,
|
||||
CROW_NAV_MANIFEST,
|
||||
CALIFORNIA_FLIGHT_MANIFEST,
|
||||
STUDIO_OPS_MANIFEST,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Env id to constructor.
|
||||
*
|
||||
* `ARENA_MANIFESTS` describes six environments and, until this existed, gave a
|
||||
* harness no supported way to *build* any of them: a caller handed the string
|
||||
* `"drive-101-v1"` — off a config file, a command line, a results table — had
|
||||
* to maintain its own switch mapping ids to classes, which is a copy of this
|
||||
* catalogue kept outside the package and silently wrong the day a sixth
|
||||
* environment lands. Which is today.
|
||||
*
|
||||
* Constructors rather than instances, because an `ArenaEnvironment` is
|
||||
* stateful: two rollouts in flight need two objects, and a frozen map of shared
|
||||
* singletons would have them stepping each other's episodes.
|
||||
*
|
||||
* `any` in the value type is deliberate and is the one place in this package it
|
||||
* appears. A registry keyed by a runtime string cannot promise a caller which
|
||||
* action and observation types it will get back — that is the nature of a
|
||||
* dynamic lookup — and the alternative is a union that every consumer would
|
||||
* immediately have to narrow by the same string it just looked up. A caller
|
||||
* that knows the type imports the class.
|
||||
*/
|
||||
export const ARENA_ENVIRONMENTS: Readonly<
|
||||
Record<string, () => ArenaEnvironment<any, any, Record<string, number>, any>>
|
||||
> = Object.freeze({
|
||||
"drive-101-v1": () => new Drive101Environment(),
|
||||
"office-nav-v1": () => new OfficeNavEnvironment(),
|
||||
"office-jobs-v1": () => new OfficeJobsEnvironment(),
|
||||
"crow-nav-v1": () => new CrowNavEnvironment(),
|
||||
"california-flight-v1": () => new CaliforniaFlightEnvironment(),
|
||||
"studio-ops-v1": () => new StudioOpsEnvironment(),
|
||||
});
|
||||
|
||||
+78
-7
@@ -17,7 +17,7 @@ import { MATEO_COURT_ROBOT_OPERATIONS } from "../offices/operations/mateo-court.
|
||||
import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
|
||||
import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
|
||||
import { ArenaScenarioRegistry } from "./scenarios.ts";
|
||||
import { ARENA_API_VERSION, type ArenaManifest, type ArenaScenario } from "./types.ts";
|
||||
import { ARENA_API_VERSION, type ArenaFieldSpec, type ArenaManifest, type ArenaScenario } from "./types.ts";
|
||||
|
||||
export interface OfficeJobsAction {
|
||||
x: number;
|
||||
@@ -112,7 +112,13 @@ export const OFFICE_JOBS_SCENARIOS = new ArenaScenarioRegistry<OfficeJobsScenari
|
||||
export const OFFICE_JOBS_MANIFEST: ArenaManifest = Object.freeze({
|
||||
apiVersion: ARENA_API_VERSION,
|
||||
id: "office-jobs-v1",
|
||||
version: 1,
|
||||
// 2 rather than 1: `ArenaScenarioRegistry` now selects a bare `{ split }`
|
||||
// request by hashing the seed against each scenario id instead of indexing
|
||||
// definition order, so a seed run before that change may resolve to a
|
||||
// different scenario after it. `version` is what a snapshot, a trace and a
|
||||
// results table are pinned to, and a selection change that nothing recorded
|
||||
// is exactly the silent remap the new selector exists to prevent.
|
||||
version: 2,
|
||||
title: "Seeded office robot jobs",
|
||||
description: "Headless job execution over explicit simulated SF/LA operations and resolved office collision.",
|
||||
simulator: "Plan + robotRoutes + fixed-step robotActivity",
|
||||
@@ -124,6 +130,64 @@ export const OFFICE_JOBS_MANIFEST: ArenaManifest = Object.freeze({
|
||||
"battery", "jobProgress", "nextStationId", "nextX", "nextZ", "deltaX", "deltaZ",
|
||||
"distanceToNextM", "canInteract", "blockedStreak", "recoveryCount", "completedJobs",
|
||||
],
|
||||
actionSpace: [
|
||||
{ name: "x", kind: "float", low: -1, high: 1, unit: "normalized drive demand" },
|
||||
{ name: "z", kind: "float", low: -1, high: 1, unit: "normalized drive demand" },
|
||||
{ name: "interact", kind: "bool" },
|
||||
] satisfies readonly ArenaFieldSpec[],
|
||||
// `mode`, `phase`, `jobKind` and `payload` are closed vocabularies in
|
||||
// `robotActivity.ts` and `robotOperations.ts` and are one-hot here rather than
|
||||
// hashed: a controller with five modes that a trainer sees as five unrelated
|
||||
// reals is a controller a trainer cannot condition on.
|
||||
observationSpace: [
|
||||
{ name: "officeId", kind: "enum", values: ["lumbridge-hq", "mateo-court"] },
|
||||
{ name: "robotId", kind: "id" },
|
||||
{ name: "levelId", kind: "enum", values: ["level-1", "level-2"] },
|
||||
{ name: "x", kind: "float", low: 0, high: 40, unit: "m" },
|
||||
{ name: "z", kind: "float", low: 0, high: 40, unit: "m" },
|
||||
// `RobotActivityMode` in robotOperations.ts, member for member.
|
||||
{
|
||||
name: "mode",
|
||||
kind: "enum",
|
||||
values: ["idle", "patrol", "deliver", "inspect", "charge", "blocked-recovery"],
|
||||
},
|
||||
// Every string `navigationPhase`, `activityPhase` and the recovery paths in
|
||||
// robotActivity.ts can assign. `phase` is typed `string` there rather than
|
||||
// as a union, so this list is a transcription and the spaces test walks a
|
||||
// rollout of every scenario asserting nothing outside it is ever observed.
|
||||
{
|
||||
name: "phase",
|
||||
kind: "enum",
|
||||
values: [
|
||||
"scheduled-idle", "patrolling", "patrol-check", "to-pickup", "loading-parcel",
|
||||
"to-dropoff", "delivering-parcel", "to-inspection", "inspecting", "to-charge",
|
||||
"charging", "awaiting-interaction", "replanning", "backoff-and-replan", "terminal",
|
||||
],
|
||||
},
|
||||
// `RobotJobKind` plus the `"none"` this observation substitutes for `null`.
|
||||
{
|
||||
name: "jobKind",
|
||||
kind: "enum",
|
||||
values: ["none", "patrol", "deliver", "inspect", "charge"],
|
||||
},
|
||||
// One member and a legal absence: `payload` is `"parcel" | null`, and a null
|
||||
// encodes as the zero vector rather than as a second category. That is the
|
||||
// documented meaning of an out-of-vocabulary value and it is the right one
|
||||
// here — "carrying nothing" is not a thing being carried.
|
||||
{ name: "payload", kind: "enum", values: ["parcel"] },
|
||||
{ name: "battery", kind: "float", low: 0, high: 1, unit: "fraction" },
|
||||
{ name: "jobProgress", kind: "float", low: 0, high: 1, unit: "fraction" },
|
||||
{ name: "nextStationId", kind: "id" },
|
||||
{ name: "nextX", kind: "float", low: 0, high: 40, unit: "m" },
|
||||
{ name: "nextZ", kind: "float", low: 0, high: 40, unit: "m" },
|
||||
{ name: "deltaX", kind: "float", low: -40, high: 40, unit: "m" },
|
||||
{ name: "deltaZ", kind: "float", low: -40, high: 40, unit: "m" },
|
||||
{ name: "distanceToNextM", kind: "float", low: 0, high: 60, unit: "m" },
|
||||
{ name: "canInteract", kind: "bool" },
|
||||
{ name: "blockedStreak", kind: "float", low: 0, high: 720, unit: "steps" },
|
||||
{ name: "recoveryCount", kind: "float", low: 0, high: 8, unit: "count" },
|
||||
{ name: "completedJobs", kind: "float", low: 0, high: 32, unit: "count" },
|
||||
] satisfies readonly ArenaFieldSpec[],
|
||||
rewardComponents: {
|
||||
navigation: "Bounded reduction in distance to the current authored job station.",
|
||||
job: "Dense progress through pickup, delivery, inspection, or charge phases.",
|
||||
@@ -153,7 +217,9 @@ export const OFFICE_JOBS_INACTION: Readonly<OfficeJobsAction> = Object.freeze({
|
||||
|
||||
export function officeJobsScriptedBaseline(observation: OfficeJobsObservation): OfficeJobsAction {
|
||||
if (observation.canInteract) return { x: 0, z: 0, interact: true };
|
||||
const length = Math.hypot(observation.deltaX, observation.deltaZ);
|
||||
const length = Math.sqrt(
|
||||
observation.deltaX * observation.deltaX + observation.deltaZ * observation.deltaZ,
|
||||
);
|
||||
if (length <= 1e-9) return { x: 0, z: 0, interact: false };
|
||||
const magnitude = Math.min(1, length / (1.05 * OFFICE_JOBS_MANIFEST.fixedStepSeconds));
|
||||
return {
|
||||
@@ -206,7 +272,7 @@ export class OfficeJobsEnvironment extends BaseArenaEnvironment<
|
||||
protected normalizeAction(action: OfficeJobsAction): OfficeJobsAction {
|
||||
const x = Number.isFinite(action?.x) ? action.x : 0;
|
||||
const z = Number.isFinite(action?.z) ? action.z : 0;
|
||||
const length = Math.hypot(x, z);
|
||||
const length = Math.sqrt(x * x + z * z);
|
||||
return {
|
||||
x: length > 1 ? x / length : x,
|
||||
z: length > 1 ? z / length : z,
|
||||
@@ -222,8 +288,10 @@ export class OfficeJobsEnvironment extends BaseArenaEnvironment<
|
||||
const canInteract = this.canInteract(before);
|
||||
if (action.interact && !canInteract) this.wrongInteractions += 1;
|
||||
const after = this.requireActivity().step({ [this.robotId]: action }).robots[0]!;
|
||||
const moved = Math.hypot(after.position.x - beforePosition.x, after.position.z - beforePosition.z);
|
||||
const demand = Math.hypot(action.x, action.z);
|
||||
const movedX = after.position.x - beforePosition.x;
|
||||
const movedZ = after.position.z - beforePosition.z;
|
||||
const moved = Math.sqrt(movedX * movedX + movedZ * movedZ);
|
||||
const demand = Math.sqrt(action.x * action.x + action.z * action.z);
|
||||
const blocked = demand > 0.2 && moved < 0.012;
|
||||
this.blockedStreak = blocked ? this.blockedStreak + 1 : 0;
|
||||
const distance = this.distanceToNext(after);
|
||||
@@ -325,7 +393,10 @@ export class OfficeJobsEnvironment extends BaseArenaEnvironment<
|
||||
|
||||
private distanceToNext(robot: RobotActivityState): number {
|
||||
const station = this.nextStation(robot);
|
||||
return station ? Math.hypot(station.position.x - robot.position.x, station.position.z - robot.position.z) : 0;
|
||||
if (!station) return 0;
|
||||
const dx = station.position.x - robot.position.x;
|
||||
const dz = station.position.z - robot.position.z;
|
||||
return Math.sqrt(dx * dx + dz * dz);
|
||||
}
|
||||
|
||||
private canInteract(robot: RobotActivityState): boolean {
|
||||
|
||||
+38
-9
@@ -10,6 +10,7 @@ import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
|
||||
import { ArenaScenarioRegistry } from "./scenarios.ts";
|
||||
import {
|
||||
ARENA_API_VERSION,
|
||||
type ArenaFieldSpec,
|
||||
type ArenaManifest,
|
||||
type ArenaScenario,
|
||||
} from "./types.ts";
|
||||
@@ -98,7 +99,13 @@ export const OFFICE_NAV_SCENARIOS = new ArenaScenarioRegistry<OfficeScenarioPara
|
||||
export const OFFICE_NAV_MANIFEST: ArenaManifest = Object.freeze({
|
||||
apiVersion: ARENA_API_VERSION,
|
||||
id: "office-nav-v1",
|
||||
version: 1,
|
||||
// 2 rather than 1: `ArenaScenarioRegistry` now selects a bare `{ split }`
|
||||
// request by hashing the seed against each scenario id instead of indexing
|
||||
// definition order, so a seed run before that change may resolve to a
|
||||
// different scenario after it. `version` is what a snapshot, a trace and a
|
||||
// results table are pinned to, and a selection change that nothing recorded
|
||||
// is exactly the silent remap the new selector exists to prevent.
|
||||
version: 2,
|
||||
title: "Frontier Valley office navigation",
|
||||
description: "Headless navigation through the resolved public office plan and exact wall collision.",
|
||||
simulator: "Plan(FRONTIER_VALLEY) + createWalker",
|
||||
@@ -109,6 +116,25 @@ export const OFFICE_NAV_MANIFEST: ArenaManifest = Object.freeze({
|
||||
"levelId", "x", "z", "goalX", "goalZ", "deltaX", "deltaZ",
|
||||
"distanceToGoalM", "travelledM", "blockedStreak",
|
||||
],
|
||||
actionSpace: [
|
||||
{ name: "x", kind: "float", low: -1, high: 1, unit: "normalized walk demand" },
|
||||
{ name: "z", kind: "float", low: -1, high: 1, unit: "normalized walk demand" },
|
||||
] satisfies readonly ArenaFieldSpec[],
|
||||
// Office-world metres, and the bounds are the reference pack's floor plate
|
||||
// with slack: Frontier Valley's authored waypoints run from x=18 to x=46, and
|
||||
// a walker resolved against collision cannot leave the building.
|
||||
observationSpace: [
|
||||
{ name: "levelId", kind: "id" },
|
||||
{ name: "x", kind: "float", low: 0, high: 80, unit: "m" },
|
||||
{ name: "z", kind: "float", low: 0, high: 80, unit: "m" },
|
||||
{ name: "goalX", kind: "float", low: 0, high: 80, unit: "m" },
|
||||
{ name: "goalZ", kind: "float", low: 0, high: 80, unit: "m" },
|
||||
{ name: "deltaX", kind: "float", low: -80, high: 80, unit: "m" },
|
||||
{ name: "deltaZ", kind: "float", low: -80, high: 80, unit: "m" },
|
||||
{ name: "distanceToGoalM", kind: "float", low: 0, high: 120, unit: "m" },
|
||||
{ name: "travelledM", kind: "float", low: 0, high: 400, unit: "m" },
|
||||
{ name: "blockedStreak", kind: "float", low: 0, high: 160, unit: "steps" },
|
||||
] satisfies readonly ArenaFieldSpec[],
|
||||
rewardComponents: {
|
||||
progress: "Reduction in Euclidean distance to the goal.",
|
||||
success: "Sparse arrival bonus.",
|
||||
@@ -131,7 +157,9 @@ export const OFFICE_NAV_MANIFEST: ArenaManifest = Object.freeze({
|
||||
export const OFFICE_NAV_INACTION: Readonly<OfficeNavAction> = Object.freeze({ x: 0, z: 0 });
|
||||
|
||||
export function officeNavScriptedBaseline(observation: OfficeNavObservation): OfficeNavAction {
|
||||
const length = Math.hypot(observation.deltaX, observation.deltaZ);
|
||||
const length = Math.sqrt(
|
||||
observation.deltaX * observation.deltaX + observation.deltaZ * observation.deltaZ,
|
||||
);
|
||||
if (length === 0) return { x: 0, z: 0 };
|
||||
return { x: observation.deltaX / length, z: observation.deltaZ / length };
|
||||
}
|
||||
@@ -167,7 +195,7 @@ export class OfficeNavEnvironment extends BaseArenaEnvironment<
|
||||
protected normalizeAction(action: OfficeNavAction): OfficeNavAction {
|
||||
const x = Number.isFinite(action?.x) ? action.x : 0;
|
||||
const z = Number.isFinite(action?.z) ? action.z : 0;
|
||||
const length = Math.hypot(x, z);
|
||||
const length = Math.sqrt(x * x + z * z);
|
||||
return length > 1 ? { x: x / length, z: z / length } : { x, z };
|
||||
}
|
||||
|
||||
@@ -177,11 +205,10 @@ export class OfficeNavEnvironment extends BaseArenaEnvironment<
|
||||
const walker = this.requireWalker();
|
||||
const before = walker.state();
|
||||
const after = walker.tick(FIXED_STEP, action);
|
||||
const moved = Math.hypot(
|
||||
after.position.x - before.position.x,
|
||||
after.position.z - before.position.z,
|
||||
);
|
||||
const demand = Math.hypot(action.x, action.z);
|
||||
const movedX = after.position.x - before.position.x;
|
||||
const movedZ = after.position.z - before.position.z;
|
||||
const moved = Math.sqrt(movedX * movedX + movedZ * movedZ);
|
||||
const demand = Math.sqrt(action.x * action.x + action.z * action.z);
|
||||
const blocked = demand > 0.2 && moved < demand * 2 * FIXED_STEP * 0.2;
|
||||
this.blockedStreak = blocked ? this.blockedStreak + 1 : 0;
|
||||
const distance = this.goalDistance();
|
||||
@@ -240,7 +267,9 @@ export class OfficeNavEnvironment extends BaseArenaEnvironment<
|
||||
private goalDistance(): number {
|
||||
const state = this.requireWalker().state();
|
||||
const goal = this.currentScenario().parameters;
|
||||
return Math.hypot(goal.goalX - state.position.x, goal.goalZ - state.position.z);
|
||||
const dx = goal.goalX - state.position.x;
|
||||
const dz = goal.goalZ - state.position.z;
|
||||
return Math.sqrt(dx * dx + dz * dz);
|
||||
}
|
||||
|
||||
private requireWalker(): WalkerController {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* One episode, run to its end.
|
||||
*
|
||||
* Nine lines of loop that every consumer of this package had written for
|
||||
* itself: the test file, both baseline proofs, and every example in ARENA.md.
|
||||
* Four copies of a loop is four places for the step budget to be off by one,
|
||||
* for `truncated` to be checked and `terminated` not to be, or for the returned
|
||||
* total to be the environment's cumulative reward in one copy and the caller's
|
||||
* own running sum in another. They agree today; nothing made them.
|
||||
*
|
||||
* It is deliberately thin. A trainer does not want a framework here — it wants
|
||||
* the loop it was going to write, exported once so that a regression in it is a
|
||||
* regression in one place. Anything richer (vectorised environments, batching,
|
||||
* an action buffer) belongs in the trainer, which knows things this package
|
||||
* cannot: how many workers there are, what device the policy is on, and whether
|
||||
* an episode is worth finishing.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ArenaEnvironment,
|
||||
ArenaScenarioRequest,
|
||||
ArenaStepResult,
|
||||
} from "./types.ts";
|
||||
|
||||
/**
|
||||
* What a policy is, from this package's point of view.
|
||||
*
|
||||
* The step index is passed as well as the observation because a *scripted*
|
||||
* baseline sometimes needs it — the studio-ops targeted policies switch
|
||||
* behaviour at a known step to reach a particular terminal — and because a
|
||||
* policy that wants only the observation can ignore a second argument for free.
|
||||
* It is the step that is about to be taken, counting from zero.
|
||||
*/
|
||||
export type ArenaPolicy<A, O> = (observation: O, step: number) => A;
|
||||
|
||||
export interface RolloutOptions {
|
||||
/** Defaults to 0, which is a legal seed and a boring one. */
|
||||
seed?: number;
|
||||
/** An exact public scenario id, or a split request. Defaults to `train`. */
|
||||
scenario?: string | ArenaScenarioRequest;
|
||||
/**
|
||||
* Stop after this many steps even if the episode has not ended.
|
||||
*
|
||||
* Clamped to the manifest's own cap, because a caller cannot extend an
|
||||
* episode past the point where the environment sets `truncated` — asking for
|
||||
* more steps than the manifest allows is a mistake worth silently correcting
|
||||
* rather than a request worth honouring. Below the cap it is a genuine early
|
||||
* cut, and `final.terminated`/`final.truncated` will both be false, which is
|
||||
* how a caller tells "I stopped it" from "it ended".
|
||||
*/
|
||||
maxSteps?: number;
|
||||
}
|
||||
|
||||
export interface RolloutResult<O, R extends Record<string, number>> {
|
||||
/** The sum of every step's total reward. */
|
||||
total: number;
|
||||
/** How many steps were actually taken. */
|
||||
steps: number;
|
||||
/** The last transition, carrying the terminal reason and final observation. */
|
||||
final: ArenaStepResult<O, R>;
|
||||
}
|
||||
|
||||
export function rollout<A, O, R extends Record<string, number>, S>(
|
||||
environment: ArenaEnvironment<A, O, R, S>,
|
||||
policy: ArenaPolicy<A, O>,
|
||||
options: RolloutOptions = {},
|
||||
): RolloutResult<O, R> {
|
||||
const budget = Math.min(
|
||||
environment.manifest.maxSteps,
|
||||
options.maxSteps ?? environment.manifest.maxSteps,
|
||||
);
|
||||
if (!Number.isSafeInteger(budget) || budget < 1) {
|
||||
throw new RangeError("arena rollout needs a budget of at least one step");
|
||||
}
|
||||
|
||||
let observation = environment.reset(
|
||||
options.seed ?? 0,
|
||||
options.scenario ?? { split: "train" },
|
||||
).observation;
|
||||
|
||||
let total = 0;
|
||||
let steps = 0;
|
||||
let final: ArenaStepResult<O, R> | undefined;
|
||||
for (let step = 0; step < budget; step += 1) {
|
||||
const result = environment.step(policy(observation, step));
|
||||
observation = result.observation;
|
||||
total += result.reward;
|
||||
steps += 1;
|
||||
final = result;
|
||||
if (result.terminated || result.truncated) break;
|
||||
}
|
||||
// Unreachable while `budget >= 1`, and asserted rather than assumed because
|
||||
// the alternative is a non-null assertion that would go stale the moment the
|
||||
// loop above grows a `continue`.
|
||||
if (!final) throw new Error("arena rollout took no steps");
|
||||
return { total, steps, final };
|
||||
}
|
||||
+41
-2
@@ -42,6 +42,46 @@ export class ArenaScenarioRegistry<P extends object> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The scenario a bare `{ split }` request resolves to, chosen by hashing the
|
||||
* seed against each candidate's **id** rather than by indexing definition
|
||||
* order.
|
||||
*
|
||||
* This used to be `candidates[seed % candidates.length]`, which is one
|
||||
* character shorter and quietly binds every seed anybody has ever run to the
|
||||
* position of a scenario in an array literal. Insert a scenario in the middle
|
||||
* of a split — the most ordinary edit there is, and one a reviewer reads as
|
||||
* purely additive — and every seed past it silently resolves to a different
|
||||
* task. Nothing fails; the numbers in a results table just stop meaning what
|
||||
* they meant, and there is no artefact anywhere that records the change.
|
||||
*
|
||||
* Highest-random-weight selection instead: each candidate scores
|
||||
* `deriveArenaSeed(seed, "<env>:<split>:<id>")` and the highest wins. Adding a
|
||||
* scenario moves only the seeds the new id actually wins, which is the
|
||||
* irreducible minimum for a set that grew; removing one moves only the seeds
|
||||
* it held; reordering the literal moves nothing at all, because position is
|
||||
* not an input any more.
|
||||
*
|
||||
* The tie-break is the id rather than the array index, so that even a 32-bit
|
||||
* score collision between two ids in one split resolves the same way whatever
|
||||
* order they were declared in. Ties are the one case where "first wins" would
|
||||
* have smuggled definition order back in through the door it was shown out
|
||||
* of.
|
||||
*/
|
||||
private select(seed: number, split: ArenaSplit): ArenaScenarioDefinition<P> | undefined {
|
||||
let best: ArenaScenarioDefinition<P> | undefined;
|
||||
let bestScore = -1;
|
||||
for (const candidate of this.definitions) {
|
||||
if (candidate.split !== split) continue;
|
||||
const score = deriveArenaSeed(seed, `${this.envId}:${split}:${candidate.id}`);
|
||||
if (score > bestScore || (score === bestScore && best !== undefined && candidate.id < best.id)) {
|
||||
best = candidate;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
ids(split: ArenaSplit): readonly string[] {
|
||||
return this.definitions
|
||||
.filter((definition) => definition.split === split)
|
||||
@@ -57,8 +97,7 @@ export class ArenaScenarioRegistry<P extends object> {
|
||||
definition = this.byId.get(request.id);
|
||||
if (definition?.split !== request.split) definition = undefined;
|
||||
} else {
|
||||
const candidates = this.definitions.filter((entry) => entry.split === request.split);
|
||||
definition = candidates[seed % candidates.length];
|
||||
definition = this.select(seed, request.split);
|
||||
}
|
||||
if (!definition) throw new RangeError(`unknown ${this.envId} scenario`);
|
||||
const random = new ArenaRandom(deriveArenaSeed(seed, `${this.envId}:${definition.id}`));
|
||||
|
||||
@@ -6,23 +6,27 @@ import type { ArenaSourceHashes } from "./types.ts";
|
||||
*/
|
||||
export const ARENA_SOURCE_HASHES: Readonly<Record<string, ArenaSourceHashes>> = Object.freeze({
|
||||
"drive-101-v1": {
|
||||
environment: "sha256:a8f3bb5c04985215a2b98b75dd2ce82a13b794b9f4933536c5821a49cf1f8125",
|
||||
simulator: "sha256:be24cacb480279e88c64e72803d2a8a94db1b084312b64da55050d2ca95c90af",
|
||||
environment: "sha256:394b6892c13966ab999ba88d912b3fb86083a057aabcba2ae82a53ae29726b40",
|
||||
simulator: "sha256:eae9a74358885691a3ea7c1ef58bb58bcfff171de7fa8fb1611d6900dcaead4a",
|
||||
},
|
||||
"office-nav-v1": {
|
||||
environment: "sha256:870b1924a7ac641d2523d52a537f6b1538803f9cc732bbfc422522a65b4eb09a",
|
||||
simulator: "sha256:34bb82d70b471cb3734d196f6ad1c685b9154083cf7e319c6089127f7a87eaf3",
|
||||
environment: "sha256:ba6de0b6f940c20c1a2af3a8b2c332034a1270c2110b9bc403253705a645cd8c",
|
||||
simulator: "sha256:d88513546aecacd950cb0c29d6e11874f51c0756f2d62f744e5b76670a6d69b6",
|
||||
},
|
||||
"office-jobs-v1": {
|
||||
environment: "sha256:76cb8ec23745b05d1f9d408b948618d8fcbd93cf3ac46e018163702bffdc3e0f",
|
||||
simulator: "sha256:aca85f5c0ec1430b8c5fab38233af757f93eefda1341c4c9d0fb5dfad24b0476",
|
||||
environment: "sha256:446a8216784bee2875c3a14bfebf17d40e3ee2b2f55afbdd5f085e8aecdd570c",
|
||||
simulator: "sha256:841391e89e83a98feeb9e503f15e2ca8e5840f00fd312166a2e16fdaba58e965",
|
||||
},
|
||||
"crow-nav-v1": {
|
||||
environment: "sha256:141b1850ac01b1922a7db88ea6c30b15521302d720099de5f91c07472633f797",
|
||||
environment: "sha256:448f061a182826decfdb0b6c54cdc62f39df2b2351b7dcaf33c0c77a0607dc51",
|
||||
simulator: "sha256:f03ba9ff320d5231a728a7f9733492ed41fa8608509e476c6d84ae567b1f24d8",
|
||||
},
|
||||
"california-flight-v1": {
|
||||
environment: "sha256:8833a25d5da376278ae56da1056ae7fa20ed243b8de0b3ef1c10d5317afbcb3f",
|
||||
environment: "sha256:50dc69d1f7541b1e98bea1ec8a0a69dc13a7b3cf75f1785fd2a2e309cb4d56ea",
|
||||
simulator: "sha256:997aa7c63779ae77af44d584758f55b6679836305115aef5e13f207232ec4d6f",
|
||||
},
|
||||
"studio-ops-v1": {
|
||||
environment: "sha256:18375ef89e9f890356428a7b62fc6b48b94fc019dd8ca1ac05eabede6d70e03f",
|
||||
simulator: "sha256:6884955c43d7bf6488769b1c38a94a87591042322ee01ffb5e0ef013976dff1f",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* The observation and action *shapes*, and the encoder every trainer was
|
||||
* writing by hand.
|
||||
*
|
||||
* `ArenaManifest` has always published `observationFields` — a list of names.
|
||||
* A name list answers "what does this environment see"; it does not answer the
|
||||
* only question a trainer asks before it can allocate anything, which is "how
|
||||
* many numbers is that, and what are they". So every consumer wrote its own
|
||||
* encoder against the environment's TypeScript source: a fork of the
|
||||
* observation contract, kept outside this repository, silently invalidated by
|
||||
* any field this repository adds.
|
||||
*
|
||||
* `ArenaFieldSpec` (in `types.ts`) closes that. Each field declares how it
|
||||
* becomes numbers, `flattenObservation` performs exactly that encoding, and the
|
||||
* width is a function of the manifest rather than of a comment.
|
||||
*
|
||||
* ### Raw values, declared bounds
|
||||
*
|
||||
* A float lands in the vector as its **clamped raw value**, not as a 0..1
|
||||
* normalisation of it. Two reasons. Normalisation is a modelling decision — a
|
||||
* layer norm, a running mean, a tanh squash are all defensible and the trainer
|
||||
* is the only party that knows which it is using — and baking one in here would
|
||||
* make it impossible to recover the metre or the decibel on the other side.
|
||||
* And `low`/`high` are *declared* bounds rather than guarantees: they are what
|
||||
* an author believes the field spans, published so a consumer can normalise,
|
||||
* and quietly wrong bounds should show up as a saturated input rather than as
|
||||
* a silently rescaled one.
|
||||
*
|
||||
* ### Why this module imports the manifests directly
|
||||
*
|
||||
* `index.ts` re-exports this file, so importing `ARENA_MANIFESTS` from there
|
||||
* would close a cycle. Six named imports instead, which is the same list
|
||||
* `index.ts` itself keeps, and `src/test/arena/spaces.test.ts` asserts the two
|
||||
* catalogues hold the same ids so the duplication cannot drift.
|
||||
*/
|
||||
|
||||
import { deriveArenaSeed } from "./random.ts";
|
||||
import { CALIFORNIA_FLIGHT_MANIFEST } from "./californiaFlight.ts";
|
||||
import { CROW_NAV_MANIFEST } from "./crowNav.ts";
|
||||
import { DRIVE_101_MANIFEST } from "./drive101.ts";
|
||||
import { OFFICE_JOBS_MANIFEST } from "./officeJobs.ts";
|
||||
import { OFFICE_NAV_MANIFEST } from "./officeNav.ts";
|
||||
import { STUDIO_OPS_MANIFEST } from "./studioOps.ts";
|
||||
import type { ArenaFieldSpec, ArenaManifest } from "./types.ts";
|
||||
|
||||
const BY_ID: ReadonlyMap<string, ArenaManifest> = new Map(
|
||||
[
|
||||
DRIVE_101_MANIFEST,
|
||||
OFFICE_NAV_MANIFEST,
|
||||
OFFICE_JOBS_MANIFEST,
|
||||
CROW_NAV_MANIFEST,
|
||||
CALIFORNIA_FLIGHT_MANIFEST,
|
||||
STUDIO_OPS_MANIFEST,
|
||||
].map((manifest) => [manifest.id, manifest]),
|
||||
);
|
||||
|
||||
/** Every environment id this package can encode for, in catalogue order. */
|
||||
export function arenaEnvironmentIds(): readonly string[] {
|
||||
return [...BY_ID.keys()];
|
||||
}
|
||||
|
||||
/** The manifest for an id. Throws rather than returning `undefined`: a caller
|
||||
* that mistyped an env id wants to know now, not to receive a zero-width
|
||||
* vector fifty thousand steps into a run. */
|
||||
export function arenaManifest(envId: string): ArenaManifest {
|
||||
const manifest = BY_ID.get(envId);
|
||||
if (!manifest) throw new RangeError(`unknown arena environment: ${envId}`);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many slots one field occupies.
|
||||
*
|
||||
* This is also where a malformed spec is caught, because it is on the path of
|
||||
* every width and every encode: a float without finite ordered bounds and an
|
||||
* enum without a usable vocabulary are authoring mistakes that would otherwise
|
||||
* surface as a vector of the wrong length or a slot that is always zero.
|
||||
*/
|
||||
export function arenaFieldWidth(spec: ArenaFieldSpec): number {
|
||||
switch (spec.kind) {
|
||||
case "float": {
|
||||
const { low, high } = spec;
|
||||
if (
|
||||
typeof low !== "number" || typeof high !== "number" ||
|
||||
!Number.isFinite(low) || !Number.isFinite(high) || !(low < high)
|
||||
) throw new RangeError(`arena float field ${spec.name} needs finite low < high`);
|
||||
return 1;
|
||||
}
|
||||
case "bool":
|
||||
case "id":
|
||||
return 1;
|
||||
case "enum": {
|
||||
const values = spec.values;
|
||||
if (!Array.isArray(values) || values.length === 0) {
|
||||
throw new RangeError(`arena enum field ${spec.name} needs a non-empty vocabulary`);
|
||||
}
|
||||
if (new Set(values).size !== values.length) {
|
||||
throw new RangeError(`arena enum field ${spec.name} has a duplicate value`);
|
||||
}
|
||||
return values.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Total slots in a flattened observation for this environment. */
|
||||
export function observationWidth(envId: string): number {
|
||||
return spaceWidth(arenaManifest(envId).observationSpace);
|
||||
}
|
||||
|
||||
/** Total slots in a flattened action for this environment. */
|
||||
export function actionWidth(envId: string): number {
|
||||
return spaceWidth(arenaManifest(envId).actionSpace);
|
||||
}
|
||||
|
||||
function spaceWidth(space: readonly ArenaFieldSpec[]): number {
|
||||
let width = 0;
|
||||
for (const spec of space) width += arenaFieldWidth(spec);
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* One observation as a fixed-width vector of finite numbers.
|
||||
*
|
||||
* Fixed-width is the whole contract and it holds unconditionally: a field the
|
||||
* observation does not carry, carries as `undefined`, or carries as the wrong
|
||||
* type still occupies its slots. That is not leniency, it is the property a
|
||||
* trainer depends on — a vector whose length changes at step 400 because a
|
||||
* station id went null is a crash a long way from its cause, and a batch that
|
||||
* silently absorbs a `NaN` is worse than one that never sees the value at all.
|
||||
*
|
||||
* What an unusable value encodes as is chosen to be *inert* rather than
|
||||
* plausible: a float falls back to its declared `low`, a bool to 0, an enum to
|
||||
* all-zeros, an id to 0. None of those can be mistaken for a measurement, and
|
||||
* `observationFields` plus the environment's own tests are what keep the case
|
||||
* from arising.
|
||||
*/
|
||||
export function flattenObservation(envId: string, observation: unknown): number[] {
|
||||
const space = arenaManifest(envId).observationSpace;
|
||||
const record = isRecord(observation) ? observation : {};
|
||||
const vector: number[] = [];
|
||||
for (const spec of space) encodeField(spec, record[spec.name], vector);
|
||||
return vector;
|
||||
}
|
||||
|
||||
/** The same encoding applied to an action struct, for a trainer logging what
|
||||
* it did as well as what it saw. */
|
||||
export function flattenAction(envId: string, action: unknown): number[] {
|
||||
const space = arenaManifest(envId).actionSpace;
|
||||
const record = isRecord(action) ? action : {};
|
||||
const vector: number[] = [];
|
||||
for (const spec of space) encodeField(spec, record[spec.name], vector);
|
||||
return vector;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inverse of `flattenAction`: a policy's output vector as the struct
|
||||
* `step()` takes.
|
||||
*
|
||||
* A harness that can read an observation vector but cannot emit an action is
|
||||
* half a harness, and the half it is missing is the one every consumer has to
|
||||
* hand-write against a field order it read out of a source file.
|
||||
*
|
||||
* Only `float` and `bool` are accepted, because that is what the six action
|
||||
* spaces contain and because the alternatives are worse than a refusal: an
|
||||
* `enum` action would need an argmax whose tie-break this module would be
|
||||
* inventing, and an `id` action cannot be inverted from a hash at all. If an
|
||||
* environment ever wants a categorical action, this function should learn about
|
||||
* it deliberately rather than by falling through to a default.
|
||||
*
|
||||
* Floats are clamped into their declared bounds and a bool is `value >= 0.5`,
|
||||
* so a raw network output needs no squashing before it gets here.
|
||||
*/
|
||||
export function structureAction(
|
||||
envId: string,
|
||||
vector: readonly number[],
|
||||
): Record<string, number | boolean> {
|
||||
const space = arenaManifest(envId).actionSpace;
|
||||
const width = spaceWidth(space);
|
||||
if (vector.length !== width) {
|
||||
throw new RangeError(`arena ${envId} action needs exactly ${width} values`);
|
||||
}
|
||||
const action: Record<string, number | boolean> = {};
|
||||
let cursor = 0;
|
||||
for (const spec of space) {
|
||||
const value = vector[cursor] ?? 0;
|
||||
cursor += arenaFieldWidth(spec);
|
||||
if (spec.kind === "float") {
|
||||
action[spec.name] = Number.isFinite(value)
|
||||
? clamp(value, spec.low as number, spec.high as number)
|
||||
: (spec.low as number);
|
||||
} else if (spec.kind === "bool") {
|
||||
action[spec.name] = Number.isFinite(value) && value >= 0.5;
|
||||
} else {
|
||||
throw new RangeError(`arena ${envId} action field ${spec.name} is not invertible`);
|
||||
}
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
function encodeField(spec: ArenaFieldSpec, value: unknown, into: number[]): void {
|
||||
// Validated first, always: `arenaFieldWidth` is where a malformed spec is
|
||||
// caught, and reading `spec.low` before asking whether it is a number would
|
||||
// be the one path on which a bad manifest reached a vector instead of a throw.
|
||||
arenaFieldWidth(spec);
|
||||
switch (spec.kind) {
|
||||
case "float": {
|
||||
const low = spec.low as number;
|
||||
const high = spec.high as number;
|
||||
into.push(typeof value === "number" && Number.isFinite(value) ? clamp(value, low, high) : low);
|
||||
return;
|
||||
}
|
||||
case "bool":
|
||||
into.push(value === true ? 1 : 0);
|
||||
return;
|
||||
case "enum": {
|
||||
for (const member of spec.values as readonly string[]) {
|
||||
into.push(value === member ? 1 : 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "id":
|
||||
into.push(typeof value === "string" && value.length > 0 ? idHash(value) : 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A string as a stable number in `[0, 1)`.
|
||||
*
|
||||
* `deriveArenaSeed` rather than a second hash written here: it is already the
|
||||
* package's string mixer, it is already exercised by every scenario
|
||||
* materialization, and two hashes over identifiers is two things to keep in
|
||||
* step. The constant is an arbitrary non-zero base — it exists so that this
|
||||
* stream is not the same one scenario seeds are drawn from.
|
||||
*/
|
||||
function idHash(value: string): number {
|
||||
return deriveArenaSeed(0x9e3779b9, value) / 4_294_967_296;
|
||||
}
|
||||
|
||||
function clamp(value: number, low: number, high: number): number {
|
||||
return Math.min(high, Math.max(low, value));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,48 @@ export interface ArenaStepResult<O, R extends Record<string, number>> {
|
||||
info: ArenaInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* How one observation or action field is encoded as numbers.
|
||||
*
|
||||
* `observationFields` was always a list of names, which tells a reader what the
|
||||
* environment observes and tells a trainer nothing it can allocate against: how
|
||||
* wide is the vector, which slots are angles, what does `2` mean in the slot
|
||||
* holding a phase. Every consumer had to read the environment's source and
|
||||
* write the encoder by hand, which is a fork of the observation contract kept
|
||||
* in a file this repository cannot see.
|
||||
*
|
||||
* Four kinds, because four is what the six environments actually contain:
|
||||
*
|
||||
* - `float` — a quantity, with `low`/`high` as *declared bounds* rather than
|
||||
* guarantees. `flattenObservation` clamps into them; a policy normalises with
|
||||
* them. One slot.
|
||||
* - `bool` — one slot, 0 or 1.
|
||||
* - `enum` — a closed vocabulary, one-hot over `values`. As many slots as
|
||||
* there are values. A value outside the vocabulary encodes as all zeros,
|
||||
* which reads as "none of these" rather than silently colliding with the
|
||||
* first member.
|
||||
* - `id` — an open-vocabulary identifier: a station id, a robot id, a road
|
||||
* name. One slot holding a stable hash in `[0, 1)`. Nothing should try to
|
||||
* *learn* from that slot — it has no order and no metric — but dropping the
|
||||
* field would make the vector and `observationFields` incommensurable, and a
|
||||
* hash at least changes exactly when the identity does, which is enough to
|
||||
* detect "the station I am walking to is not the one I was walking to".
|
||||
*/
|
||||
export type ArenaFieldKind = "float" | "bool" | "enum" | "id";
|
||||
|
||||
export interface ArenaFieldSpec {
|
||||
/** Matches the key in the observation or action object, exactly. */
|
||||
name: string;
|
||||
kind: ArenaFieldKind;
|
||||
/** `float` only. Both required for a float, finite, and `low < high`. */
|
||||
low?: number;
|
||||
high?: number;
|
||||
/** `enum` only. Non-empty and free of duplicates. */
|
||||
values?: readonly string[];
|
||||
/** Documentation for a reader of the manifest. Never parsed. */
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface ArenaManifest {
|
||||
apiVersion: typeof ARENA_API_VERSION;
|
||||
id: string;
|
||||
@@ -68,6 +110,17 @@ export interface ArenaManifest {
|
||||
maxSteps: number;
|
||||
actionFields: readonly string[];
|
||||
observationFields: readonly string[];
|
||||
/**
|
||||
* The same fields as `actionFields`/`observationFields`, in the same order,
|
||||
* with an encoding for each.
|
||||
*
|
||||
* Two lists rather than one because the name lists are the human-readable
|
||||
* contract that has been published since v1 and the spaces are the machine
|
||||
* one; `src/test/arena/spaces.test.ts` asserts they agree name for name and
|
||||
* in order, so the redundancy cannot rot into a disagreement.
|
||||
*/
|
||||
actionSpace: readonly ArenaFieldSpec[];
|
||||
observationSpace: readonly ArenaFieldSpec[];
|
||||
rewardComponents: Readonly<Record<string, string>>;
|
||||
safetyTerminals: readonly string[];
|
||||
scenarioIds: Readonly<Record<ArenaSplit, readonly string[]>>;
|
||||
@@ -82,6 +135,26 @@ export interface ArenaSnapshot<S> {
|
||||
envId: string;
|
||||
envVersion: number;
|
||||
envHash: string;
|
||||
/**
|
||||
* The source pins the snapshot was taken under.
|
||||
*
|
||||
* `envHash` is a hash of the *manifest*, which is semantics: the id, the
|
||||
* version, the field names, the reward vocabulary. It does not move when the
|
||||
* physics under it does. Edit the walker's collision epsilon or the device
|
||||
* simulator's release rate, leave the manifest alone, and a snapshot taken
|
||||
* before the edit restored cleanly afterwards and resumed into a different
|
||||
* simulation — silently, mid-episode, with a checksum that still verified
|
||||
* because it was recomputed over the new state.
|
||||
*
|
||||
* `replay()` has always guarded this by comparing the trace envelope's
|
||||
* `sourceHashes`; `restore()` did not, and a checkpoint is exactly as
|
||||
* dangerous as a trace. So the pins travel in the snapshot too, inside the
|
||||
* checksummed core, and `restore()` refuses a snapshot whose simulator or
|
||||
* environment source has moved. The cost is that a legitimate source edit
|
||||
* invalidates outstanding checkpoints, which is the correct thing for it to
|
||||
* do: they were checkpoints of a program that no longer exists.
|
||||
*/
|
||||
sourceHashes: ArenaSourceHashes;
|
||||
seed: number;
|
||||
scenarioId: string;
|
||||
scenarioSplit: ArenaSplit;
|
||||
|
||||
+196
-6
@@ -69,6 +69,11 @@ export type SurfaceRole =
|
||||
| "lightHousing"
|
||||
| "lightDiffuser"
|
||||
| "whiteboard"
|
||||
// Devices
|
||||
| "deviceShell"
|
||||
| "deviceMesh"
|
||||
| "deviceIndicator"
|
||||
| "screenContent"
|
||||
// Objects
|
||||
| "foliage"
|
||||
| "planter"
|
||||
@@ -82,6 +87,11 @@ export type SurfaceRole =
|
||||
*/
|
||||
export type MaterialQuality = "low" | "medium" | "high";
|
||||
|
||||
/**
|
||||
* `MeshPhysicalMaterial` is a subclass of `MeshStandardMaterial`, so it needs no
|
||||
* arm of its own here — but it is worth knowing it is in the union, because
|
||||
* `glazing` is one at `medium` and `high` and a `MeshStandardMaterial` at `low`.
|
||||
*/
|
||||
export type SurfaceMaterial = THREE.MeshStandardMaterial | THREE.MeshLambertMaterial;
|
||||
|
||||
interface RoleSpec {
|
||||
@@ -92,10 +102,50 @@ interface RoleSpec {
|
||||
texture?: TextureKind;
|
||||
/** Fraction of the role's own colour emitted. Screens and diffusers only. */
|
||||
glow?: number;
|
||||
/**
|
||||
* Emit through `texture` rather than flat across the surface.
|
||||
*
|
||||
* Only meaningful with a `texture` that carries content rather than grain, and
|
||||
* `screenContent` is the only such role. It is the difference between a
|
||||
* monitor and a light box: with a flat `glow` the whole panel emits and the
|
||||
* drawn interface is a pattern printed on a lamp, and with the map bound to
|
||||
* `emissiveMap` the lit pixels emit and the chrome around them does not.
|
||||
*/
|
||||
emissiveFromMap?: boolean;
|
||||
/**
|
||||
* A coverage map, and the threshold a fragment has to clear to be drawn.
|
||||
*
|
||||
* Cutout, not blend. `alphaTest` discards below the threshold and leaves the
|
||||
* material opaque, so a leaf still writes depth, still sorts like solid
|
||||
* geometry and still casts a correctly-shaped shadow — three's depth material
|
||||
* copies `alphaMap` and `alphaTest` across for exactly this. Making foliage
|
||||
* `transparent` instead would buy a soft edge and cost the shadow, the depth
|
||||
* write and the sort order, on the one class of object there are hundreds of.
|
||||
*/
|
||||
alphaTexture?: TextureKind;
|
||||
alphaTest?: number;
|
||||
/** Opacity below 1 makes the material transparent. */
|
||||
opacity?: number;
|
||||
/** Leaf cards and glass want both faces. */
|
||||
doubleSided?: boolean;
|
||||
/**
|
||||
* Refract through the surface instead of blending over it.
|
||||
*
|
||||
* `MeshPhysicalMaterial`'s transmission is the difference between glass and a
|
||||
* grey film: it takes the *lit* colour of what is behind the surface, tints it
|
||||
* by `color`, bends it by `ior` over `thickness`, and — the part that actually
|
||||
* sells it — leaves a specular highlight and an environment reflection on top
|
||||
* that a 22%-opacity blend cannot have. `roughness` becomes frosting rather
|
||||
* than a matte grey, which is what a fritted partition wants.
|
||||
*
|
||||
* It costs a copy of the render target per transmissive draw, which is why it
|
||||
* is `medium` and `high` only and why exactly one role uses it.
|
||||
*/
|
||||
transmission?: number;
|
||||
/** Refractive index. 1.5 is soda-lime glass. Only read with `transmission`. */
|
||||
ior?: number;
|
||||
/** Metres of glass the refraction is integrated over. Only read with `transmission`. */
|
||||
thickness?: number;
|
||||
}
|
||||
|
||||
const ROLE_SPECS: Record<SurfaceRole, RoleSpec> = {
|
||||
@@ -115,7 +165,31 @@ const ROLE_SPECS: Record<SurfaceRole, RoleSpec> = {
|
||||
// Glass writes no depth. With it on, anything behind a window disappears
|
||||
// depending on which mesh the sorter happens to draw first, and a meeting
|
||||
// room made of glass is exactly the case where that is most visible.
|
||||
glazing: { roughness: 0.05, metalness: 0.1, opacity: 0.22, doubleSided: true },
|
||||
//
|
||||
// That reasoning survives the move to transmission unchanged, and it has to be
|
||||
// said out loud because three.js *encourages* the opposite: a transmissive
|
||||
// material is drawn in the transmission pass and the usual advice is to let it
|
||||
// write depth. Here it must not. An office is a box of glass boxes — a meeting
|
||||
// room seen through a corridor screen through an external window is three
|
||||
// sheets deep — and depth-writing glass makes whichever sheet the sorter
|
||||
// reached first erase the other two. The `opacity` stays as well: it is what
|
||||
// `low` quality falls back to, and it is what keeps the frame visible against
|
||||
// the glass in the ghosted wall-occlusion copy.
|
||||
glazing: {
|
||||
roughness: 0.05,
|
||||
// Was 0.1, and had to go: three.js scales transmission by `1 - metalness`
|
||||
// because a metal is opaque by definition, so a tenth of metalness is a
|
||||
// tenth of the glass quietly turned back into a mirror.
|
||||
metalness: 0,
|
||||
opacity: 0.22,
|
||||
doubleSided: true,
|
||||
transmission: 0.92,
|
||||
ior: 1.5,
|
||||
// Millimetres, not metres of solid glass: `thickness` scales the volumetric
|
||||
// tint, and a 6 mm pane that tints like a 6 m aquarium is the classic way
|
||||
// this parameter goes wrong.
|
||||
thickness: 0.006,
|
||||
},
|
||||
glazingFrame: { roughness: 0.35, metalness: 0.7 },
|
||||
doorLeaf: { roughness: 0.6, metalness: 0 },
|
||||
|
||||
@@ -139,7 +213,38 @@ const ROLE_SPECS: Record<SurfaceRole, RoleSpec> = {
|
||||
lightDiffuser: { roughness: 0.9, metalness: 0, glow: 0.85 },
|
||||
whiteboard: { roughness: 0.15, metalness: 0, texture: "whiteboard" },
|
||||
|
||||
foliage: { roughness: 0.8, metalness: 0, doubleSided: true },
|
||||
/**
|
||||
* The four device roles.
|
||||
*
|
||||
* `deviceMesh` is a grille or a windscreen — the perforated part — and it is
|
||||
* double-sided because you can see through it to the inside of the housing at
|
||||
* a glancing angle, which is most of what makes a speaker look like a speaker.
|
||||
* `deviceIndicator` is the only role in the table with a `glow` of 1: an LED
|
||||
* is a light source rather than a lit surface, and under the tone curve
|
||||
* `stage.ts` now runs, a full-strength emissive reads as a lamp instead of
|
||||
* saturating to the same white as the housing beside it.
|
||||
*/
|
||||
deviceShell: { roughness: 0.42, metalness: 0.28 },
|
||||
deviceMesh: { roughness: 0.52, metalness: 0.8, doubleSided: true },
|
||||
deviceIndicator: { roughness: 0.35, metalness: 0, glow: 1 },
|
||||
screenContent: {
|
||||
roughness: 0.18,
|
||||
metalness: 0,
|
||||
texture: "screenUI",
|
||||
glow: 0.9,
|
||||
emissiveFromMap: true,
|
||||
},
|
||||
|
||||
// A leaf is a quad with a leaf cut out of it. See `leafAlpha` in textures.ts
|
||||
// for why that is worth a texture channel, and `alphaTest` at 0.5 for why the
|
||||
// threshold sits in the middle of a hard-edged drawing rather than at its toe.
|
||||
foliage: {
|
||||
roughness: 0.8,
|
||||
metalness: 0,
|
||||
doubleSided: true,
|
||||
alphaTexture: "leafAlpha",
|
||||
alphaTest: 0.5,
|
||||
},
|
||||
planter: { roughness: 0.7, metalness: 0 },
|
||||
paper: { roughness: 0.9, metalness: 0 },
|
||||
accent: { roughness: 0.6, metalness: 0.1 },
|
||||
@@ -227,6 +332,11 @@ export class MaterialRegistry {
|
||||
* The map is dropped deliberately: carpet grain at 18% opacity is visual
|
||||
* noise on top of whatever it is supposed to be letting you see. Depth
|
||||
* writing goes with it, for the same reason glazing does not write depth.
|
||||
*
|
||||
* `alphaMap` is deliberately *not* dropped with it. The colour map is
|
||||
* decoration and the coverage map is shape — a ghosted leaf with its cutout
|
||||
* removed is not a faint leaf, it is the flat green shard the cutout exists to
|
||||
* get rid of, at 18% opacity.
|
||||
*/
|
||||
ghostOf(role: SurfaceRole): SurfaceMaterial {
|
||||
const hit = this.ghosts.get(role);
|
||||
@@ -234,6 +344,16 @@ export class MaterialRegistry {
|
||||
const ghost = this.get(role).clone();
|
||||
ghost.name = `${role}:ghost`;
|
||||
ghost.map = null;
|
||||
// The relief goes with the colour map and for the same reason. It also has
|
||||
// to: a normal map on a surface that is 82% see-through is a lighting cue
|
||||
// for a surface nobody is being asked to look at.
|
||||
if ("normalMap" in ghost) ghost.normalMap = null;
|
||||
// A ghost is a hint, not a window. Leaving transmission on would put the
|
||||
// occlusion fade — which exists to be cheap and is redrawn as the camera
|
||||
// moves — through the transmission pass and its render-target copy.
|
||||
if ("transmission" in ghost) {
|
||||
(ghost as THREE.MeshPhysicalMaterial).transmission = 0;
|
||||
}
|
||||
ghost.transparent = true;
|
||||
ghost.opacity = 0.18;
|
||||
ghost.depthWrite = false;
|
||||
@@ -257,6 +377,38 @@ export class MaterialRegistry {
|
||||
return made;
|
||||
}
|
||||
|
||||
/**
|
||||
* A role drawn with a different layout of its own texture.
|
||||
*
|
||||
* Only `screenContent` has more than one today (`SCREEN_UI_VARIANTS` of them),
|
||||
* and this exists because of a constraint one layer up rather than a wish for
|
||||
* variety: `furnish.ts` batches props per kind and draws `ctx.rand` **once per
|
||||
* kind**, so a screen asset cannot roll for a layout per instance. Variety has
|
||||
* to arrive as a parameter, from a pack authoring separate batches, which
|
||||
* means it has to arrive as a separate material — one material per layout, all
|
||||
* of them cached here, and the draw-call cost is one call per layout actually
|
||||
* used rather than one per screen.
|
||||
*
|
||||
* `color` is optional so the common case reads `variant(role, n)`; pass one to
|
||||
* get a tinted layout, which is the same shape `tinted` offers.
|
||||
*/
|
||||
variant(role: SurfaceRole, variant: number, color?: number): SurfaceMaterial {
|
||||
const texture = ROLE_SPECS[role].texture;
|
||||
const count = texture ? this.textures.variants(texture) : 1;
|
||||
const index = count <= 1 ? 0 : (((variant % count) + count) % count) | 0;
|
||||
const hue = color ?? this.palette[role];
|
||||
// Variant 0 with the role's own colour *is* the base material. Minting a
|
||||
// second identical one would be a second draw call for the same picture.
|
||||
if (index === 0 && color === undefined) return this.get(role);
|
||||
const key = `${role}:${hue.toString(16)}:${index}`;
|
||||
const hit = this.tints.get(key);
|
||||
if (hit) return hit;
|
||||
const made = this.create(role, hue, index);
|
||||
made.name = key;
|
||||
this.tints.set(key, made);
|
||||
return made;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an authored `SurfaceId` into a role. Unknown ids give `fallback`.
|
||||
*
|
||||
@@ -276,27 +428,65 @@ export class MaterialRegistry {
|
||||
return this.get(this.resolve(surface, fallback));
|
||||
}
|
||||
|
||||
private create(role: SurfaceRole, color: number): SurfaceMaterial {
|
||||
private create(role: SurfaceRole, color: number, variant = 0): SurfaceMaterial {
|
||||
const spec = ROLE_SPECS[role];
|
||||
const map = spec.texture ? this.textures.get(spec.texture) : null;
|
||||
const map = spec.texture ? this.textures.get(spec.texture, variant) : null;
|
||||
const alphaMap = spec.alphaTexture ? this.textures.get(spec.alphaTexture) : null;
|
||||
// Relief comes from the same kind as the colour, and the bin answers `null`
|
||||
// for the kinds that have none — a whiteboard and a display are flat, and
|
||||
// `low` quality has no maps at all. No role opts in separately: a surface
|
||||
// either has a texture or it does not, and asking for the grain without the
|
||||
// relief that produced it is not a combination worth spelling.
|
||||
const normalMap = spec.texture ? this.textures.normal(spec.texture) : null;
|
||||
const transparent = spec.opacity !== undefined && spec.opacity < 1;
|
||||
|
||||
const shared = {
|
||||
color,
|
||||
map,
|
||||
// `alphaTest` is only set when there is a map to test against. Left on
|
||||
// with a null `alphaMap` at `low` quality it would test the material's
|
||||
// flat opacity of 1 against the threshold on every fragment — which
|
||||
// passes, but compiles a branch into the shader for nothing.
|
||||
alphaMap,
|
||||
alphaTest: alphaMap ? (spec.alphaTest ?? 0.5) : 0,
|
||||
side: spec.doubleSided ? THREE.DoubleSide : THREE.FrontSide,
|
||||
transparent,
|
||||
opacity: spec.opacity ?? 1,
|
||||
depthWrite: !transparent,
|
||||
emissive: spec.glow ? color : 0x000000,
|
||||
// White rather than the role's colour when the map is doing the emitting:
|
||||
// `emissive` multiplies `emissiveMap`, so anything but white would tint
|
||||
// the drawn interface a second time on top of `color` already tinting it.
|
||||
emissive: spec.emissiveFromMap ? 0xffffff : spec.glow ? color : 0x000000,
|
||||
emissiveMap: spec.emissiveFromMap ? map : null,
|
||||
emissiveIntensity: spec.glow ?? 0,
|
||||
};
|
||||
|
||||
// `low` is flat Lambert: no maps, no roughness, no transmission. The
|
||||
// `normalMap` is not merely unused there — `MeshLambertMaterial` does have
|
||||
// one, but the whole point of `low` is to compile the cheap shader.
|
||||
if (this.quality === "low") return new THREE.MeshLambertMaterial(shared);
|
||||
return new THREE.MeshStandardMaterial({
|
||||
|
||||
const physical = {
|
||||
...shared,
|
||||
normalMap,
|
||||
roughness: spec.roughness,
|
||||
metalness: spec.metalness,
|
||||
};
|
||||
|
||||
if (spec.transmission === undefined) return new THREE.MeshStandardMaterial(physical);
|
||||
return new THREE.MeshPhysicalMaterial({
|
||||
...physical,
|
||||
transmission: spec.transmission,
|
||||
ior: spec.ior ?? 1.5,
|
||||
thickness: spec.thickness ?? 0.01,
|
||||
// Transmission carries the see-through, so the blend must not do it a
|
||||
// second time. Left transparent at 0.22 the sheet would be four fifths
|
||||
// invisible *and* refracting the fifth that was left, which reads as a
|
||||
// smear rather than as glass. `depthWrite` stays false regardless — see
|
||||
// the note on the role.
|
||||
transparent: false,
|
||||
opacity: 1,
|
||||
depthWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,17 @@
|
||||
* the refusal as "skip this material", and the result is not an error but a
|
||||
* chair with no shell on it — which is a lot harder to notice than a crash.
|
||||
*
|
||||
* In practice: `roundedBox` is an `ExtrudeGeometry` and carries no index, while
|
||||
* every other part in `parts.ts` does. So a material is a rounded material or a
|
||||
* boxy one, and where that forces a choice the honest fix is to move the part
|
||||
* to the material it belongs to anyway — a task chair's arm pads are upholstery
|
||||
* as readily as they are shell.
|
||||
* In practice: `roundedBox` and `roundedBoxOf` are `ExtrudeGeometry` and carry
|
||||
* no index, while every other part in `parts.ts` does. So a material is a
|
||||
* rounded material or a boxy one, and where that forces a choice the honest fix
|
||||
* is to move the part to the material it belongs to anyway — a task chair's arm
|
||||
* pads are upholstery as readily as they are shell.
|
||||
*
|
||||
* `slab`'s `chamfer` option is the one place that rule had to be worked around
|
||||
* rather than worked with, and `nonIndexed` below is how. A chamfered desktop
|
||||
* wants an extruded body *and* a metric top face in the same material, and those
|
||||
* two are on opposite sides of the rule; converting the quad is four vertices of
|
||||
* cost and keeps the whole desktop in one merge.
|
||||
*
|
||||
* ### Light fixtures emit no light
|
||||
*
|
||||
@@ -54,10 +60,35 @@
|
||||
* shadow-casting lights, the end of the frame budget.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { tintFor, type AssetContext } from "../kit.ts";
|
||||
import type { SurfaceMaterial, SurfaceRole } from "../materials.ts";
|
||||
import type { MeshBin } from "../parts.ts";
|
||||
|
||||
/**
|
||||
* A de-indexed copy of one of the shared parts, memoised per source geometry.
|
||||
*
|
||||
* `mergeGeometries` refuses a mixture of indexed and non-indexed inputs, so a
|
||||
* material carrying an `ExtrudeGeometry` cannot also carry a `PlaneGeometry`.
|
||||
* Rather than give up either the chamfer or the metric UVs, the quad is
|
||||
* converted once and cached.
|
||||
*
|
||||
* A `WeakMap` rather than a `Map` because the key is a geometry owned by
|
||||
* `PartBin`: if the bin is ever disposed and rebuilt, the derived copies become
|
||||
* unreachable with their sources instead of pinning a disposed buffer forever.
|
||||
*/
|
||||
const NON_INDEXED = new WeakMap<THREE.BufferGeometry, THREE.BufferGeometry>();
|
||||
|
||||
function nonIndexed(source: THREE.BufferGeometry): THREE.BufferGeometry {
|
||||
if (source.getIndex() === null) return source;
|
||||
const hit = NON_INDEXED.get(source);
|
||||
if (hit) return hit;
|
||||
const made = source.toNonIndexed();
|
||||
made.name = `${source.name || "part"}:flat`;
|
||||
NON_INDEXED.set(source, made);
|
||||
return made;
|
||||
}
|
||||
|
||||
/**
|
||||
* The material for the one part of an asset that answers to `Prop.colorKey` —
|
||||
* a chair's fabric, a locker's doors, a rug's pile. Every asset names its
|
||||
@@ -84,16 +115,59 @@ export function tintable(ctx: AssetContext, role: SurfaceRole): SurfaceMaterial
|
||||
* two different materials (see the UV note in `parts.ts`). The quad sits 0.6 mm
|
||||
* proud of the box so the two never z-fight.
|
||||
*
|
||||
* ### `chamfer`, and why three millimetres is worth a whole code path
|
||||
*
|
||||
* A sharp arris is the single most reliable tell that a surface was rendered
|
||||
* rather than built. Every real desktop, worktop and shelf board has a small
|
||||
* radius on its edge, and what that radius does is catch a *line* of specular
|
||||
* highlight along the whole length of the board — one bright edge that separates
|
||||
* the top from the front and tells you where the object stops. Without it a
|
||||
* desktop and the wall behind it meet in a hard colour change and the desk reads
|
||||
* as a decal.
|
||||
*
|
||||
* Three to six millimetres is the range; past that it starts reading as a
|
||||
* moulded plastic table. `roundedBoxOf` takes the radius in metres and applies it
|
||||
* after the proportions are known, which is the only way to get a circular
|
||||
* corner on a board that is fifty times wider than it is thick.
|
||||
*
|
||||
* It is opt-in rather than the default because it changes the primitive class of
|
||||
* the whole material (see the header): every other part an asset draws in the
|
||||
* same material has to become an extrusion too, and for a shelf board carrying
|
||||
* books that trade is not worth making.
|
||||
*
|
||||
* `y` is the underside of the slab.
|
||||
*/
|
||||
export function slab(
|
||||
bin: MeshBin,
|
||||
ctx: AssetContext,
|
||||
material: SurfaceMaterial,
|
||||
s: { x?: number; y: number; z?: number; width: number; depth: number; thickness: number },
|
||||
s: {
|
||||
x?: number;
|
||||
y: number;
|
||||
z?: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
thickness: number;
|
||||
/** Edge radius in metres. 0.003–0.006 for a board; omit for a sharp edge. */
|
||||
chamfer?: number;
|
||||
},
|
||||
): void {
|
||||
const x = s.x ?? 0;
|
||||
const z = s.z ?? 0;
|
||||
const chamfer = s.chamfer ?? 0;
|
||||
if (chamfer > 0) {
|
||||
bin.add(ctx.parts.roundedBoxOf(s.width, s.thickness, s.depth, chamfer), material, {
|
||||
x,
|
||||
y: s.y,
|
||||
z,
|
||||
});
|
||||
bin.add(nonIndexed(ctx.parts.metricQuad(s.width - chamfer * 2, s.depth - chamfer * 2)), material, {
|
||||
x,
|
||||
y: s.y + s.thickness + 0.0006,
|
||||
z,
|
||||
});
|
||||
return;
|
||||
}
|
||||
bin.add(ctx.parts.box(), material, {
|
||||
x,
|
||||
y: s.y,
|
||||
|
||||
@@ -46,11 +46,15 @@ export const deskWorkstation = defineAsset<WorkstationParams>({
|
||||
const frame = ctx.materials.get("deskFrame");
|
||||
const deckY = p.height - TOP_THICKNESS;
|
||||
|
||||
// 4 mm on the edge of the desktop. `deskSurface` is used by nothing else in
|
||||
// this asset, which is what makes the chamfer affordable here — see the
|
||||
// primitive-class note on `slab`.
|
||||
slab(bin, ctx, ctx.materials.get("deskSurface"), {
|
||||
y: deckY,
|
||||
width: p.width,
|
||||
depth: p.depth,
|
||||
thickness: TOP_THICKNESS,
|
||||
chamfer: 0.004,
|
||||
});
|
||||
|
||||
const legX = p.width / 2 - 0.09;
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* The two pieces of hardware the smart-device layer drives: a desk microphone
|
||||
* and a desk monitor speaker.
|
||||
*
|
||||
* These are not props with a light glued on. They are the *instruments* the
|
||||
* studio simulation observes and commands — a mic that can be muted and gained,
|
||||
* a speaker that can be turned up and played through — so they are modelled to
|
||||
* be looked at from the distance somebody sits from their own desk, which is
|
||||
* about sixty centimetres. At that range a speaker with a painted-on grille is a
|
||||
* lie you can see, which is why the grille here is slats with gaps between them
|
||||
* and the driver is a real cone in a real surround.
|
||||
*
|
||||
* ### The id convention is load-bearing
|
||||
*
|
||||
* `<namespace>:device.<kind>.<placement>` — `tera:device.mic.desk`,
|
||||
* `tera:device.speaker.desk`. `deviceKindOfAssetId()` in `src/devices/types.ts`
|
||||
* parses the kind straight back out of the id, and the device API refuses a
|
||||
* declaration whose asset kind disagrees with its declared kind. That is what
|
||||
* lets a self-hoster register `acme:device.mic.boom` and have it read as a
|
||||
* microphone for free, with no table to edit and no fork; and it is why anything
|
||||
* not matching the pattern is treated as "not device hardware" rather than as a
|
||||
* silently mis-typed device.
|
||||
*
|
||||
* ### Every device exposes a sub-object named `indicator`
|
||||
*
|
||||
* The device render layer (`src/interiors/devices.ts`) tints one part of each
|
||||
* device per state — powered, muted, idle — by swapping the material on it with
|
||||
* `materials.tinted("deviceIndicator", colour)`, which reaches both `color` and
|
||||
* `emissive`. It finds that part by **name**, not by material and not by index:
|
||||
* a name survives a self-hoster's override, a material does not (two devices
|
||||
* sharing `deviceIndicator` would both light up), and an index does not survive
|
||||
* anybody adding a part.
|
||||
*
|
||||
* So the contract is exactly: `object.getObjectByName("indicator")` returns a
|
||||
* group holding the LED and nothing else. It is a separate group rather than a
|
||||
* separate mesh because `MeshBin.build` names its meshes after their material,
|
||||
* and the layer above should not have to know what material an LED happens to
|
||||
* be made of.
|
||||
*
|
||||
* ### Roles, and the primitive-class rule
|
||||
*
|
||||
* `deviceShell` is the moulded housing, `deviceMesh` the perforated parts —
|
||||
* grille, basket, windscreen — and `deviceIndicator` the LED. `common.ts` warns
|
||||
* that every part under one material must be all-indexed or all-non-indexed or
|
||||
* `mergeGeometries` silently drops it; both assets here are built entirely from
|
||||
* indexed primitives (`box`, `cylinder`, `rod`, `cone`, `sphere`, `disc`), which
|
||||
* makes that rule impossible to break by accident rather than merely remembered.
|
||||
*
|
||||
* Neither takes a `colorKey`. A studio microphone is the colour a studio
|
||||
* microphone is, and the one part that changes colour changes it because of
|
||||
* *state*, which is the device layer's business and not a pack's.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { defineAsset, type AssetContext, type AssetId } from "../kit.ts";
|
||||
import { MeshBin } from "../parts.ts";
|
||||
|
||||
/** The built-in device ids, in the order `src/devices` expects to find them. */
|
||||
export const DEVICE_ASSET_IDS: readonly AssetId[] = [
|
||||
"tera:device.mic.desk",
|
||||
"tera:device.speaker.desk",
|
||||
];
|
||||
|
||||
/** The name the device render layer looks up to find the tintable LED. */
|
||||
export const DEVICE_INDICATOR_NAME = "indicator";
|
||||
|
||||
/**
|
||||
* Assemble a device: its static hardware, plus the one sub-object that changes
|
||||
* colour, under the agreed name.
|
||||
*
|
||||
* The LED bin is built with shadows off in both directions. A four-millimetre
|
||||
* emissive dot has nothing meaningful to cast and nothing meaningful to receive,
|
||||
* and leaving it in the shadow pass costs a draw call in every cascade for a
|
||||
* part that is smaller than a shadow-map texel at any sensible resolution.
|
||||
*/
|
||||
function deviceGroup(name: string, hardware: MeshBin, led: MeshBin): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.name = name;
|
||||
group.add(hardware.build(name));
|
||||
const indicator = led.build(DEVICE_INDICATOR_NAME, {
|
||||
castShadow: false,
|
||||
receiveShadow: false,
|
||||
});
|
||||
indicator.name = DEVICE_INDICATOR_NAME;
|
||||
group.add(indicator);
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ring of `count` short bars around the Y axis at `radius`.
|
||||
*
|
||||
* Used for the shock mount's elastic suspension and for the speaker's driver
|
||||
* surround. Worth a helper rather than three copies because getting the local
|
||||
* yaw wrong produces a ring of bars that all face the same way, which reads as a
|
||||
* mistake rather than as a detail.
|
||||
*/
|
||||
function ringOfBars(
|
||||
bin: MeshBin,
|
||||
ctx: AssetContext,
|
||||
material: Parameters<MeshBin["add"]>[1],
|
||||
r: {
|
||||
count: number;
|
||||
radius: number;
|
||||
y: number;
|
||||
size: [number, number, number];
|
||||
pitch?: number;
|
||||
phase?: number;
|
||||
},
|
||||
): void {
|
||||
for (let i = 0; i < r.count; i++) {
|
||||
const yaw = (i / r.count) * Math.PI * 2 + (r.phase ?? 0);
|
||||
bin.add(ctx.parts.box(), material, {
|
||||
x: Math.sin(yaw) * r.radius,
|
||||
z: Math.cos(yaw) * r.radius,
|
||||
y: r.y,
|
||||
size: r.size,
|
||||
yaw,
|
||||
pitch: r.pitch ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Microphone -----------------------------------------------------------
|
||||
|
||||
type MicParams = {
|
||||
/** Length of the capsule body itself, metres. A large-diaphragm condenser. */
|
||||
bodyLength: number;
|
||||
/** Diameter of the weighted desk base. */
|
||||
baseDiameter: number;
|
||||
/** Floor of the base to the centre of the capsule. */
|
||||
standHeight: number;
|
||||
/** Radians the capsule leans back from vertical, toward the person at +Z. */
|
||||
tilt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A shock-mounted desk condenser.
|
||||
*
|
||||
* Four things carry it, in the order they matter at desk distance:
|
||||
*
|
||||
* 1. **The shock mount.** A microphone hanging inside a ring on visible elastic
|
||||
* is the single most recognisable thing about a studio desk, and it is the
|
||||
* part that says "this is a real microphone" before any of the rest resolves.
|
||||
* 2. **The basket.** A grille with gaps in it, not a painted cylinder — eight
|
||||
* vertical wires and two hoops, in the double-sided `deviceMesh` role, so you
|
||||
* see through it to the shaded inside of the head at a glancing angle.
|
||||
* 3. **The windscreen**, as a foam sphere pushed over the top of the basket. It
|
||||
* is what breaks the hard cylinder silhouette.
|
||||
* 4. **The mute LED**, on the front of the body under the basket, where the
|
||||
* hardware button is on almost every one of these.
|
||||
*
|
||||
* Faces −Z at yaw zero like everything else, which puts the front of the capsule
|
||||
* and the LED at +Z, toward the person (`common.ts`).
|
||||
*/
|
||||
export const deviceMicDesk = defineAsset<MicParams>({
|
||||
id: "tera:device.mic.desk",
|
||||
label: "Desk microphone",
|
||||
defaults: { bodyLength: 0.09, baseDiameter: 0.12, standHeight: 0.21, tilt: 0.16 },
|
||||
|
||||
footprint(p) {
|
||||
// The shock mount is wider than the base and the capsule leans out of it, so
|
||||
// the footprint is the ring's swept width rather than the base's diameter.
|
||||
// 1.5 × the body, measured off the built mesh rather than guessed: the ring
|
||||
// is `bodyLength × 0.66` in radius and the suspension bars stick out past it,
|
||||
// and the foam windscreen is wider again than the basket it is pushed over.
|
||||
const ring = p.bodyLength * 1.5;
|
||||
const height = p.standHeight + p.bodyLength * 0.95 + 0.05;
|
||||
return { width: Math.max(p.baseDiameter, ring), depth: ring + 0.04, height, clearance: 0.12 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const led = new MeshBin();
|
||||
const shell = ctx.materials.get("deviceShell");
|
||||
const grille = ctx.materials.get("deviceMesh");
|
||||
const metal = ctx.materials.get("metalTrim");
|
||||
|
||||
// ---- The weighted base. Two discs and a fillet: a microphone base is heavy
|
||||
// and low, and a straight cylinder reads as a cotton reel.
|
||||
bin.add(P.cylinder(20), shell, { size: [p.baseDiameter, 0.014, p.baseDiameter] });
|
||||
bin.add(P.cylinder(20), shell, {
|
||||
y: 0.014,
|
||||
size: [p.baseDiameter * 0.82, 0.012, p.baseDiameter * 0.82],
|
||||
});
|
||||
bin.add(P.cylinder(16), metal, {
|
||||
y: 0.026,
|
||||
size: [p.baseDiameter * 0.34, 0.01, p.baseDiameter * 0.34],
|
||||
});
|
||||
|
||||
// ---- The post, and the yoke that carries the ring.
|
||||
const ringY = p.standHeight;
|
||||
bin.add(P.rod(), metal, { y: 0.03, size: [0.014, ringY - 0.03 - 0.01, 0.014] });
|
||||
const ringR = p.bodyLength * 0.66;
|
||||
for (const sx of [-1, 1]) {
|
||||
bin.add(P.box(), metal, {
|
||||
x: sx * ringR * 0.7,
|
||||
y: ringY - 0.055,
|
||||
size: [0.008, 0.075, 0.008],
|
||||
roll: sx * 0.32,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- The shock-mount ring. Twelve short bars around the circle rather than
|
||||
// a torus: `parts.ts` has no torus, and twelve boxes is both cheaper and, at
|
||||
// this size, indistinguishable from one.
|
||||
ringOfBars(bin, ctx, metal, {
|
||||
count: 12,
|
||||
radius: ringR,
|
||||
y: ringY - 0.006,
|
||||
size: [ringR * 0.58, 0.012, 0.009],
|
||||
pitch: 0,
|
||||
});
|
||||
// The elastic. Six lines from the ring in to the body, alternating up and
|
||||
// down the capsule the way a real suspension is strung.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const yaw = (i / 6) * Math.PI * 2 + 0.26;
|
||||
const lift = i % 2 === 0 ? 0.022 : -0.022;
|
||||
bin.add(P.box(), metal, {
|
||||
x: (Math.sin(yaw) * ringR) / 2,
|
||||
z: (Math.cos(yaw) * ringR) / 2,
|
||||
y: ringY + lift * 0.5,
|
||||
size: [ringR, 0.004, 0.004],
|
||||
yaw: yaw + Math.PI / 2,
|
||||
roll: lift > 0 ? 0.42 : -0.42,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- The capsule. Built about the ring's centre and tilted back, so the
|
||||
// whole head — body, basket, windscreen, LED — leans as one piece.
|
||||
const tilt = p.tilt;
|
||||
const dia = p.bodyLength * 0.54;
|
||||
const lean = (d: number): { y: number; z: number } => ({
|
||||
y: Math.cos(tilt) * d,
|
||||
z: Math.sin(tilt) * d,
|
||||
});
|
||||
|
||||
const bodyBase = lean(-p.bodyLength * 0.5);
|
||||
bin.add(P.cylinder(18), shell, {
|
||||
y: ringY + bodyBase.y,
|
||||
z: bodyBase.z,
|
||||
size: [dia, p.bodyLength * 0.62, dia],
|
||||
pitch: -tilt,
|
||||
});
|
||||
// A collar where the body meets the head. Every one of these has one and it
|
||||
// is what stops the capsule reading as a single extruded tube.
|
||||
const collar = lean(p.bodyLength * 0.1);
|
||||
bin.add(P.cylinder(18), metal, {
|
||||
y: ringY + collar.y,
|
||||
z: collar.z,
|
||||
size: [dia * 1.08, 0.006, dia * 1.08],
|
||||
pitch: -tilt,
|
||||
});
|
||||
|
||||
// ---- The basket: two hoops and eight wires, with a dome on top.
|
||||
const headBase = lean(p.bodyLength * 0.12);
|
||||
const headLength = p.bodyLength * 0.5;
|
||||
const headDia = dia * 1.12;
|
||||
for (const at of [0.12, 0.62]) {
|
||||
const hoop = lean(p.bodyLength * (0.12 + at * 0.5));
|
||||
bin.add(P.cylinder(18), metal, {
|
||||
y: ringY + hoop.y,
|
||||
z: hoop.z,
|
||||
size: [headDia * 1.02, 0.004, headDia * 1.02],
|
||||
pitch: -tilt,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const yaw = (i / 8) * Math.PI * 2;
|
||||
// The wires stand in the head's own tilted frame, so they are placed at
|
||||
// the head's base and rotated with it rather than around the world Y.
|
||||
const offset = headDia * 0.5;
|
||||
bin.add(P.box(), grille, {
|
||||
x: Math.sin(yaw) * offset,
|
||||
y: ringY + headBase.y + Math.cos(yaw) * offset * Math.sin(tilt),
|
||||
z: headBase.z + Math.cos(yaw) * offset * Math.cos(tilt),
|
||||
size: [0.0035, headLength, 0.0035],
|
||||
pitch: -tilt,
|
||||
});
|
||||
}
|
||||
const domeAt = lean(p.bodyLength * 0.12 + headLength);
|
||||
bin.add(P.sphere(14), grille, {
|
||||
y: ringY + domeAt.y - headDia * 0.25,
|
||||
z: domeAt.z,
|
||||
size: [headDia, headDia * 0.5, headDia],
|
||||
pitch: -tilt,
|
||||
});
|
||||
|
||||
// ---- The foam windscreen, pushed over the basket. Slightly bigger than the
|
||||
// head and slightly squashed, because foam is.
|
||||
const foamAt = lean(p.bodyLength * 0.3);
|
||||
bin.add(P.sphere(16), grille, {
|
||||
y: ringY + foamAt.y - headDia * 0.62,
|
||||
z: foamAt.z,
|
||||
size: [headDia * 1.24, headDia * 1.34, headDia * 1.24],
|
||||
pitch: -tilt,
|
||||
});
|
||||
|
||||
// ---- The mute LED, on the front of the body below the basket.
|
||||
const ledAt = lean(-p.bodyLength * 0.12);
|
||||
led.add(P.cylinder(10), ctx.materials.get("deviceIndicator"), {
|
||||
y: ringY + ledAt.y,
|
||||
z: ledAt.z + dia * 0.5,
|
||||
size: [0.008, 0.003, 0.008],
|
||||
pitch: Math.PI / 2 - tilt,
|
||||
});
|
||||
|
||||
return deviceGroup("device.mic.desk", bin, led);
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Speaker --------------------------------------------------------------
|
||||
|
||||
type SpeakerParams = {
|
||||
width: number;
|
||||
height: number;
|
||||
depth: number;
|
||||
/** Slats across the grille. Fewer reads as a radiator, more as a solid panel. */
|
||||
slats: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A compact powered monitor speaker.
|
||||
*
|
||||
* The grille is the reason this asset exists in geometry rather than in a
|
||||
* texture. `deviceMesh` is double-sided (`materials.ts`) precisely so that the
|
||||
* gaps between the slats show the shaded inside of the cabinet behind them, and
|
||||
* that parallax — slats in front, driver behind, dark cabinet behind that — is
|
||||
* the entire difference between a speaker and a box with stripes on it. It costs
|
||||
* a dozen boxes.
|
||||
*
|
||||
* The bass port is a real hole in the same sense: a recessed dark tube on the
|
||||
* front baffle rather than a black circle drawn on it.
|
||||
*
|
||||
* The cabinet is `roundedBoxOf` at its finished size, so the 4 mm chamfer round
|
||||
* its edges is genuinely circular instead of an ellipse stretched by a
|
||||
* non-uniform scale — which is the whole reason that method exists.
|
||||
*/
|
||||
export const deviceSpeakerDesk = defineAsset<SpeakerParams>({
|
||||
id: "tera:device.speaker.desk",
|
||||
label: "Desk monitor speaker",
|
||||
defaults: { width: 0.14, height: 0.22, depth: 0.17, slats: 11 },
|
||||
|
||||
footprint(p) {
|
||||
// The isolation pad under it is a touch wider than the cabinet, and the
|
||||
// grille stands a few millimetres proud of the baffle at +Z.
|
||||
return {
|
||||
width: p.width + 0.012,
|
||||
depth: p.depth + 0.018,
|
||||
height: p.height + 0.016,
|
||||
clearance: 0.1,
|
||||
};
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const led = new MeshBin();
|
||||
const shell = ctx.materials.get("deviceShell");
|
||||
const mesh = ctx.materials.get("deviceMesh");
|
||||
const metal = ctx.materials.get("metalTrim");
|
||||
|
||||
// ---- The isolation pad. A monitor on a desk stands on foam, and the 16 mm
|
||||
// of shadow under the cabinet is what stops it looking stuck to the desktop.
|
||||
const pad = 0.014;
|
||||
bin.add(P.box(), ctx.materials.get("upholstery"), {
|
||||
y: 0,
|
||||
size: [p.width + 0.012, pad, p.depth + 0.006],
|
||||
pitch: 0,
|
||||
});
|
||||
|
||||
// ---- The cabinet, and the baffle recessed into its front face.
|
||||
bin.add(P.roundedBoxOf(p.width, p.height, p.depth, 0.005), shell, { y: pad, size: 1 });
|
||||
const baffleZ = p.depth / 2 - 0.008;
|
||||
bin.add(P.roundedBoxOf(p.width - 0.014, p.height - 0.014, 0.01, 0.004), shell, {
|
||||
y: pad + 0.007,
|
||||
z: baffleZ - 0.004,
|
||||
size: 1,
|
||||
});
|
||||
|
||||
// ---- The woofer: surround ring, cone, dust cap. The cone points at the
|
||||
// person, so it is a `cone()` rolled over — its base is its wide end.
|
||||
const wooferY = pad + p.height * 0.36;
|
||||
const wooferR = Math.min(p.width * 0.36, p.height * 0.24);
|
||||
bin.add(P.cylinder(20), metal, {
|
||||
y: wooferY,
|
||||
z: baffleZ,
|
||||
size: [wooferR * 2.2, 0.006, wooferR * 2.2],
|
||||
pitch: Math.PI / 2,
|
||||
});
|
||||
bin.add(P.cone(20), mesh, {
|
||||
y: wooferY,
|
||||
z: baffleZ - 0.024,
|
||||
size: [wooferR * 1.9, 0.026, wooferR * 1.9],
|
||||
pitch: -Math.PI / 2,
|
||||
});
|
||||
// The dust cap rides on `metalTrim` rather than on `deviceShell`: the shell
|
||||
// is all `roundedBoxOf` in this asset and a sphere is indexed, and mixing the
|
||||
// two under one material makes `mergeGeometries` drop the whole cabinet.
|
||||
bin.add(P.sphere(12), metal, {
|
||||
y: wooferY,
|
||||
z: baffleZ - 0.004,
|
||||
size: [wooferR * 0.7, wooferR * 0.5, wooferR * 0.7],
|
||||
pitch: -Math.PI / 2,
|
||||
});
|
||||
|
||||
// ---- The tweeter, in its own shallow waveguide.
|
||||
const tweeterY = pad + p.height * 0.78;
|
||||
const tweeterR = wooferR * 0.42;
|
||||
bin.add(P.cylinder(16), metal, {
|
||||
y: tweeterY,
|
||||
z: baffleZ,
|
||||
size: [tweeterR * 2.6, 0.005, tweeterR * 2.6],
|
||||
pitch: Math.PI / 2,
|
||||
});
|
||||
bin.add(P.sphere(12), mesh, {
|
||||
y: tweeterY,
|
||||
z: baffleZ - 0.002,
|
||||
size: [tweeterR * 1.6, tweeterR * 1.2, tweeterR * 1.6],
|
||||
pitch: -Math.PI / 2,
|
||||
});
|
||||
|
||||
// ---- The bass port: a tube sunk into the baffle between the drivers.
|
||||
const portY = pad + p.height * 0.16;
|
||||
bin.add(P.cylinder(14), mesh, {
|
||||
y: portY,
|
||||
z: baffleZ - 0.03,
|
||||
size: [wooferR * 0.62, 0.034, wooferR * 0.62],
|
||||
pitch: Math.PI / 2,
|
||||
});
|
||||
|
||||
// ---- The grille. Horizontal slats standing proud of the baffle, with real
|
||||
// gaps between them — the point of the whole asset.
|
||||
const slats = Math.max(3, Math.round(p.slats));
|
||||
const span = p.height - 0.03;
|
||||
const pitch = span / slats;
|
||||
for (let i = 0; i < slats; i++) {
|
||||
bin.add(P.box(), mesh, {
|
||||
y: pad + 0.015 + i * pitch,
|
||||
z: baffleZ + 0.004,
|
||||
size: [p.width - 0.018, pitch * 0.52, 0.004],
|
||||
});
|
||||
}
|
||||
// Two uprights holding the slats, so the grille is a frame and not a stack.
|
||||
for (const sx of [-1, 1]) {
|
||||
bin.add(P.box(), mesh, {
|
||||
x: (sx * (p.width - 0.018)) / 2,
|
||||
y: pad + 0.015,
|
||||
z: baffleZ + 0.004,
|
||||
size: [0.005, span, 0.005],
|
||||
});
|
||||
}
|
||||
|
||||
// ---- The power LED, bottom-centre under the grille.
|
||||
led.add(P.cylinder(10), ctx.materials.get("deviceIndicator"), {
|
||||
y: pad + 0.008,
|
||||
z: baffleZ + 0.006,
|
||||
size: [0.006, 0.003, 0.006],
|
||||
pitch: Math.PI / 2,
|
||||
});
|
||||
|
||||
return deviceGroup("device.speaker.desk", bin, led);
|
||||
},
|
||||
});
|
||||
|
||||
/** Both device assets, for `index.ts` and for anything registering a subset. */
|
||||
export const DEVICE_ASSETS = [deviceMicDesk, deviceSpeakerDesk] as const;
|
||||
+146
-19
@@ -1,23 +1,131 @@
|
||||
/**
|
||||
* Plants. A small one for a desk or a sill, and a tall one for a corner.
|
||||
*
|
||||
* Leaves are single quads in a double-sided `foliage` material rather than
|
||||
* modelled solids: forty cards is forty quads, a modelled leaf is a hundred
|
||||
* triangles each, and at the distance an office plant is ever seen the two look
|
||||
* the same. They are laid out on the golden angle, which is what stops a ring of
|
||||
* cards from reading as a ring, plus a little jitter from `ctx.rand` — seeded
|
||||
* per prop, so the plant on the third desk is the same plant on every reload.
|
||||
* ### The shard problem, and what fixed it
|
||||
*
|
||||
* Neither takes a `colorKey`. A plant is the colour a plant is.
|
||||
* Every leaf in here used to be a bare `P.panel()` — an untextured rectangle in
|
||||
* an opaque green material. That is fine in a thumbnail and catastrophic at eye
|
||||
* height, and the live LA studio proved it: walk into the courtyard and the
|
||||
* corner planting fills a third of the frame with flat green shards. It was the
|
||||
* worst-looking asset in the product and no amount of lighting work was ever
|
||||
* going to fix it, because the problem is the *silhouette* — a rectangle has the
|
||||
* wrong outline no matter how it is shaded.
|
||||
*
|
||||
* The fix is a coverage map. `foliage` now carries the `leafAlpha` cutout with
|
||||
* `alphaTest 0.5` (`materials.ts`), so the leaf-shaped part of each quad is
|
||||
* drawn and the rest is discarded. Three things follow from that, and all three
|
||||
* are why this is a cutout rather than a blend:
|
||||
*
|
||||
* - the leaf still writes depth and still sorts like solid geometry, so a plant
|
||||
* in front of a window does not have to be drawn in a particular order;
|
||||
* - the shadow it casts is leaf-shaped, because three's depth material copies
|
||||
* `alphaMap` and `alphaTest` across;
|
||||
* - it survives `low` quality, because `leafAlpha` carries a resolution floor.
|
||||
* "No maps" at low quality is a statement about *shading* cost; a cutout is
|
||||
* one fetch and a discard, and the alternative at low quality is not a
|
||||
* cheaper plant, it is the shard again.
|
||||
*
|
||||
* ### What the geometry still has to do
|
||||
*
|
||||
* A cutout only works if the quad's UVs run along the leaf. `leafAlpha` is drawn
|
||||
* tip-at-top with the stem at the bottom, so **+V must run from the base of the
|
||||
* quad to its tip**, which is exactly what `P.panel()` gives (0..1 over a
|
||||
* standing rectangle, base on the floor) — the panel was never the problem, the
|
||||
* missing map was.
|
||||
*
|
||||
* Beyond that, two changes make a card read as a frond rather than as a
|
||||
* postcard with a leaf printed on it. Blades are **curved**, by splitting each
|
||||
* one into two or three cards that each pick up a little more pitch, so the leaf
|
||||
* arches instead of standing dead straight; and each blade gets a small **roll**
|
||||
* so it is not edge-on flat to its own stem. Both are geometry the alpha map
|
||||
* cannot supply, and both cost one extra quad per leaf at most.
|
||||
*
|
||||
* Leaves stay cards rather than modelled solids: a modelled leaf is a hundred
|
||||
* triangles and forty of them is a plant nobody can afford in a room that also
|
||||
* has furniture in it.
|
||||
*
|
||||
* Neither asset takes a `colorKey`. A plant is the colour a plant is.
|
||||
*/
|
||||
|
||||
import { defineAsset } from "../kit.ts";
|
||||
import { defineAsset, type AssetContext } from "../kit.ts";
|
||||
import type { SurfaceMaterial } from "../materials.ts";
|
||||
import { MeshBin } from "../parts.ts";
|
||||
import { clamp, jitter } from "./common.ts";
|
||||
|
||||
/** ~137.5°, the angle a real plant puts between successive leaves. */
|
||||
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
||||
|
||||
/**
|
||||
* One arching blade, as `segments` cards laid end to end.
|
||||
*
|
||||
* Exported because `studio.ts` plants a courtyard trough out of the same
|
||||
* blades: two files drawing foliage two different ways is how a library starts
|
||||
* looking like two libraries.
|
||||
*
|
||||
* Each card starts where the last one ended and carries a little more pitch, so
|
||||
* the blade curves over instead of leaving in a straight line. The arithmetic is
|
||||
* the only fiddly part: a card placed with `pitch` has its base at the given
|
||||
* point and its tip `length` away along the pitched direction, and pitch here
|
||||
* runs in the *yawed* frame — so `advance` steps along the plant's local −Z
|
||||
* (outward, in the blade's own yaw) and up by whatever the pitch leaves.
|
||||
*
|
||||
* `pitch` is measured from vertical, matching the rest of the file: 0 stands
|
||||
* straight up and π/2 lies flat.
|
||||
*/
|
||||
export function leafBlade(
|
||||
bin: MeshBin,
|
||||
ctx: AssetContext,
|
||||
material: SurfaceMaterial,
|
||||
b: {
|
||||
x?: number;
|
||||
y: number;
|
||||
z?: number;
|
||||
/** Total length along the blade. */
|
||||
length: number;
|
||||
/** Width of the widest card. Tapers toward the tip. */
|
||||
width: number;
|
||||
yaw: number;
|
||||
/** Pitch at the base, radians from vertical. */
|
||||
pitch: number;
|
||||
/** How much further the blade has arched over by its tip. */
|
||||
droop: number;
|
||||
roll?: number;
|
||||
segments: number;
|
||||
},
|
||||
): void {
|
||||
const segments = Math.max(1, Math.round(b.segments));
|
||||
const step = b.length / segments;
|
||||
const sin = Math.sin(b.yaw);
|
||||
const cos = Math.cos(b.yaw);
|
||||
// Base of the current card, in the plant's own frame.
|
||||
let x = b.x ?? 0;
|
||||
let y = b.y;
|
||||
let z = b.z ?? 0;
|
||||
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const t = i / segments;
|
||||
const pitch = b.pitch + b.droop * t * t;
|
||||
// Cards narrow toward the tip; the last one is roughly half the first.
|
||||
const width = b.width * (1 - 0.42 * t);
|
||||
bin.add(ctx.parts.panel(), material, {
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
size: [width, step, 1],
|
||||
yaw: b.yaw,
|
||||
pitch,
|
||||
roll: b.roll ?? 0,
|
||||
});
|
||||
// Where this card's tip is: `step` along the pitched direction, resolved
|
||||
// back into the plant's frame through the blade's yaw.
|
||||
const rise = Math.cos(pitch) * step;
|
||||
const reach = Math.sin(pitch) * step;
|
||||
y += rise;
|
||||
x -= sin * reach;
|
||||
z -= cos * reach;
|
||||
}
|
||||
}
|
||||
|
||||
type PottedParams = {
|
||||
/** Overall height including the pot. */
|
||||
height: number;
|
||||
@@ -49,6 +157,8 @@ export const plantPotted = defineAsset<PottedParams>({
|
||||
y: potH - 0.03,
|
||||
size: [p.potDiameter, 0.03, p.potDiameter],
|
||||
});
|
||||
// The soil. A disc rather than nothing: without it you see straight down
|
||||
// into an open cylinder from the dollhouse camera.
|
||||
bin.add(P.disc(14), pot, {
|
||||
y: potH - 0.012,
|
||||
size: [p.potDiameter * 0.9, 1, p.potDiameter * 0.9],
|
||||
@@ -59,13 +169,16 @@ export const plantPotted = defineAsset<PottedParams>({
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = i / count;
|
||||
const length = reach * (0.55 + 0.45 * (1 - t)) * (0.85 + ctx.rand() * 0.3);
|
||||
bin.add(P.panel(), leaf, {
|
||||
leafBlade(bin, ctx, leaf, {
|
||||
y: potH - 0.02,
|
||||
size: [length * 0.34, length, 1],
|
||||
length,
|
||||
width: length * 0.42,
|
||||
yaw: i * GOLDEN_ANGLE + jitter(ctx.rand, 0.2),
|
||||
// Outer leaves lean further out; the middle ones stand up. Pitch runs
|
||||
// in the yawed frame, so this is a lean along whichever way it faces.
|
||||
pitch: 0.25 + t * 0.8 + jitter(ctx.rand, 0.12),
|
||||
// Outer leaves lean further out; the middle ones stand up.
|
||||
pitch: 0.22 + t * 0.7 + jitter(ctx.rand, 0.12),
|
||||
droop: 0.42 + t * 0.3,
|
||||
roll: jitter(ctx.rand, 0.2),
|
||||
segments: 2,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,8 +194,10 @@ type TallParams = {
|
||||
};
|
||||
|
||||
/** Pitch of the lowest whorl and of the highest. The bottom droops, the top stands. */
|
||||
const TALL_DROOP = 1.35;
|
||||
const TALL_CROWN = 0.6;
|
||||
const TALL_DROOP = 1.2;
|
||||
const TALL_CROWN = 0.5;
|
||||
/** Extra arch a blade picks up between its base and its tip. */
|
||||
const TALL_ARCH = 0.5;
|
||||
|
||||
/**
|
||||
* The one piece of arithmetic `footprint` and `build` have to agree on.
|
||||
@@ -92,6 +207,10 @@ const TALL_CROWN = 0.6;
|
||||
* apart. Written the first time, they had — the stated height was a fifth
|
||||
* taller than the plant, because a leaf at 60° from vertical contributes
|
||||
* `cos 60°` of its length and not all of it.
|
||||
*
|
||||
* The blades now arch as well as lean, so the effective angle used here is the
|
||||
* *mid-blade* one — base pitch plus a third of the arch — which is the honest
|
||||
* average of a curve that starts at one angle and finishes at another.
|
||||
*/
|
||||
function tallCanopy(p: TallParams): {
|
||||
potHeight: number;
|
||||
@@ -102,14 +221,16 @@ function tallCanopy(p: TallParams): {
|
||||
const potHeight = clamp(p.height * 0.26, 0.24, 0.55);
|
||||
const trunk = (p.height - potHeight) * 0.55;
|
||||
const rise = p.height - potHeight - trunk;
|
||||
const crownAngle = TALL_CROWN + TALL_ARCH / 3;
|
||||
const droopAngle = TALL_DROOP + TALL_ARCH / 3;
|
||||
// The top whorl starts a third of the way up the canopy and reaches the rest
|
||||
// of the way with the vertical component of one leaf.
|
||||
const leaf = (rise * 0.66) / Math.cos(TALL_CROWN);
|
||||
const leaf = (rise * 0.66) / Math.cos(crownAngle);
|
||||
return {
|
||||
potHeight,
|
||||
trunk,
|
||||
leaf,
|
||||
spread: Math.max(p.potDiameter, 2 * leaf * Math.sin(TALL_DROOP)),
|
||||
spread: Math.max(p.potDiameter, 2 * leaf * Math.sin(droopAngle)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,11 +283,17 @@ export const plantTall = defineAsset<TallParams>({
|
||||
const y = potH + trunkH + rise * 0.34 * t;
|
||||
const blades = 7 - tier;
|
||||
for (let i = 0; i < blades; i++) {
|
||||
bin.add(P.panel(), leaf, {
|
||||
leafBlade(bin, ctx, leaf, {
|
||||
y,
|
||||
size: [leafLen * 0.26, leafLen * (0.85 + ctx.rand() * 0.3), 1],
|
||||
length: leafLen * (0.85 + ctx.rand() * 0.3),
|
||||
width: leafLen * 0.3,
|
||||
yaw: n++ * GOLDEN_ANGLE + jitter(ctx.rand, 0.25),
|
||||
pitch: TALL_DROOP + (TALL_CROWN - TALL_DROOP) * t + jitter(ctx.rand, 0.15),
|
||||
droop: TALL_ARCH,
|
||||
roll: jitter(ctx.rand, 0.22),
|
||||
// Three cards on the long blades of a corner plant: this is the one
|
||||
// the camera gets closest to, and it is the one that was broken.
|
||||
segments: 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,9 @@ export const kitchenRun = defineAsset<KitchenRunParams>({
|
||||
defaults: { width: 3.6, depth: 0.62, counterHeight: 0.91, height: 2.16, bays: 5 },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.width, depth: p.depth, height: p.height, clearance: 1 };
|
||||
// The worktop oversails the carcass by 50 mm at the front, which is what a
|
||||
// worktop does and what the stated depth was missing.
|
||||
return { width: p.width, depth: p.depth + 0.05, height: p.height, clearance: 1 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
@@ -195,11 +197,14 @@ export const kitchenRun = defineAsset<KitchenRunParams>({
|
||||
size: [0.012, 0.12, 0.012],
|
||||
});
|
||||
}
|
||||
// The worktop's front edge is at hand height and is the one edge in a
|
||||
// kitchen you actually touch, so it gets the full 6 mm.
|
||||
slab(bin, ctx, top, {
|
||||
y: p.counterHeight - 0.045,
|
||||
width: p.width,
|
||||
depth: p.depth + 0.05,
|
||||
thickness: 0.045,
|
||||
chamfer: 0.006,
|
||||
});
|
||||
|
||||
// Backsplash and upper units stop short over the sink to create a focal bay.
|
||||
@@ -282,6 +287,7 @@ export const kitchenIsland = defineAsset<IslandParams>({
|
||||
width: p.length,
|
||||
depth: p.depth,
|
||||
thickness: 0.05,
|
||||
chamfer: 0.006,
|
||||
});
|
||||
for (const sx of [-1, 1]) {
|
||||
bin.add(ctx.parts.rod(), ctx.materials.get("metalTrim"), {
|
||||
@@ -304,7 +310,10 @@ export const storageWardrobe = defineAsset<WardrobeParams>({
|
||||
defaults: { width: 1.8, depth: 0.58, height: 2.18, doors: 3 },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.width, depth: p.depth, height: p.height, clearance: 0.75 };
|
||||
// Doors and pulls stand proud of the carcass at +Z, and the stated depth has
|
||||
// to include them or a wardrobe pushed flush to a wall puts its handles
|
||||
// through the plaster.
|
||||
return { width: p.width, depth: p.depth + 0.03, height: p.height, clearance: 0.75 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
@@ -349,7 +358,16 @@ export const seatStool = defineAsset<StoolParams>({
|
||||
defaults: { diameter: 0.4, seatHeight: 0.68, back: true },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.diameter + 0.1, depth: p.diameter + 0.12, height: p.back ? 0.96 : p.seatHeight, clearance: 0.35 };
|
||||
// The back reaches 380 mm above the seat pan, and the height has to be
|
||||
// derived from `seatHeight` rather than hard-coded: it was 0.96, which was
|
||||
// right for the default 0.68 seat and 100 mm short of the geometry, and
|
||||
// wrong by an arbitrary amount for any other seat height a pack asked for.
|
||||
return {
|
||||
width: p.diameter + 0.1,
|
||||
depth: p.diameter + 0.12,
|
||||
height: p.back ? p.seatHeight + 0.38 : p.seatHeight,
|
||||
clearance: 0.35,
|
||||
};
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
@@ -404,7 +422,18 @@ export const lightFloor = defineAsset<FloorLightParams>({
|
||||
defaults: { height: 1.55, reach: 0.42, shadeDiameter: 0.32 },
|
||||
|
||||
footprint(p) {
|
||||
return { width: Math.max(0.42, p.shadeDiameter), depth: p.reach + p.shadeDiameter / 2, height: p.height };
|
||||
// The one asset in the kit that is genuinely not centred on its own origin:
|
||||
// the base is under the column and the shade cantilevers out to −Z over it.
|
||||
// The footprint has to enclose both, so it is the reach plus half a shade in
|
||||
// front and the base's own radius behind — which is 180 mm the stated depth
|
||||
// used to be missing, and 180 mm is enough to push a floor lamp through a
|
||||
// wall.
|
||||
const base = 0.18;
|
||||
return {
|
||||
width: Math.max(0.42, p.shadeDiameter),
|
||||
depth: p.reach + p.shadeDiameter / 2 + base,
|
||||
height: p.height,
|
||||
};
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
|
||||
@@ -17,14 +17,35 @@
|
||||
* somebody walks through a wall — leave the collider with no gap where the door
|
||||
* is.
|
||||
*
|
||||
* Twenty-five assets is not a furniture catalogue and is not trying to be. It is
|
||||
* Forty assets is not a furniture catalogue and is not trying to be. It is
|
||||
* the set that gets a real floor plate looking like an office: somewhere to
|
||||
* work, somewhere to sit, somewhere to meet, somewhere to put things, something
|
||||
* to look at, something alive, and light.
|
||||
*
|
||||
* ### Why the count keeps going up, and why more *instances* would not have done
|
||||
*
|
||||
* Fifteen of these arrived at once — thirteen studio props and two devices — and
|
||||
* the reason is a property of `furnish.ts` rather than an appetite for
|
||||
* furniture. Props are batched by **(asset, colorKey)** and every instance in a
|
||||
* batch is geometrically identical, `ctx.rand` included. So a room that looks
|
||||
* sparse cannot be fixed by placing more of what is already there: ten more
|
||||
* shelves are the same shelf with the same books on it in ten places. Apparent
|
||||
* density is a function of how many distinct *kinds* a floor uses, and that
|
||||
* makes the length of this list the lever a pack author actually has.
|
||||
*
|
||||
* The catalogue is now three groups:
|
||||
*
|
||||
* - the **office kit** (`desks`, `seating`, `tables`, `storage`, `screens`,
|
||||
* `lighting`, `surfaces`, `greenery`) — a floor plate of desks and meetings;
|
||||
* - the **habitat kit** (`habitat.ts`) — a studio home rather than an office;
|
||||
* - the **studio kit** (`studio.ts`) and the **devices** (`devices.ts`) — a
|
||||
* working production floor: a courtyard, a robotics lab, a model loft, and
|
||||
* the two instruments the smart-device layer drives.
|
||||
*/
|
||||
|
||||
import { kit, type AnyAsset, type AssetRegistry } from "../kit.ts";
|
||||
import { deskPartition, deskPedestal, deskWorkstation } from "./desks.ts";
|
||||
import { DEVICE_ASSETS, deviceMicDesk, deviceSpeakerDesk } from "./devices.ts";
|
||||
import { plantPotted, plantTall } from "./greenery.ts";
|
||||
import {
|
||||
bedPlatform,
|
||||
@@ -40,6 +61,22 @@ import { robotOptimus } from "./optimus.ts";
|
||||
import { screenMonitor, screenWallDisplay } from "./screens.ts";
|
||||
import { seatLounge, seatTaskChair } from "./seating.ts";
|
||||
import { storageLocker, storageShelf } from "./storage.ts";
|
||||
import {
|
||||
STUDIO_ASSETS,
|
||||
acousticBaffle,
|
||||
benchLab,
|
||||
benchSlat,
|
||||
cameraTripod,
|
||||
canopyParasol,
|
||||
cartTool,
|
||||
caseStack,
|
||||
dividerSlat,
|
||||
dockRobot,
|
||||
lightSoftbox,
|
||||
planterTrough,
|
||||
rackEquipment,
|
||||
shelfWall,
|
||||
} from "./studio.ts";
|
||||
import { rug, whiteboard } from "./surfaces.ts";
|
||||
import { tableMeeting, tableSide } from "./tables.ts";
|
||||
|
||||
@@ -69,6 +106,8 @@ export const OFFICE_ASSETS: readonly AnyAsset[] = [
|
||||
storageWardrobe,
|
||||
seatStool,
|
||||
lightFloor,
|
||||
...STUDIO_ASSETS,
|
||||
...DEVICE_ASSETS,
|
||||
];
|
||||
|
||||
/** Register the built-in catalogue into a registry. Defaults to the shared one. */
|
||||
@@ -104,4 +143,22 @@ export {
|
||||
storageWardrobe,
|
||||
seatStool,
|
||||
lightFloor,
|
||||
planterTrough,
|
||||
benchSlat,
|
||||
canopyParasol,
|
||||
benchLab,
|
||||
rackEquipment,
|
||||
cartTool,
|
||||
dockRobot,
|
||||
caseStack,
|
||||
lightSoftbox,
|
||||
cameraTripod,
|
||||
acousticBaffle,
|
||||
dividerSlat,
|
||||
shelfWall,
|
||||
deviceMicDesk,
|
||||
deviceSpeakerDesk,
|
||||
};
|
||||
|
||||
export { DEVICE_ASSET_IDS, DEVICE_INDICATOR_NAME } from "./devices.ts";
|
||||
export { STUDIO_ASSET_IDS } from "./studio.ts";
|
||||
|
||||
@@ -58,11 +58,25 @@
|
||||
* darkens their screen surrounds darkens the robot's face, which is the right
|
||||
* coupling rather than a coincidental one.
|
||||
*
|
||||
* `metalTrim` was tried as a third material for the joint barrels and dropped.
|
||||
* The office rig carries no environment map, so a `metalness: 0.85` role has
|
||||
* nothing to reflect and renders as a dull dark grey — indistinguishable from
|
||||
* `metalTrim` was tried as a third material for the joint barrels and dropped,
|
||||
* and then taken back up. The original reasoning was sound and is no longer
|
||||
* true, so it is worth recording both halves rather than quietly deleting one:
|
||||
* the office rig carried **no environment map**, so a `metalness: 0.85` role had
|
||||
* nothing to reflect and rendered as a dull dark grey — indistinguishable from
|
||||
* `screenBezel` at ten metres — while costing another mesh in nine of the eleven
|
||||
* groups. Two materials, eighteen meshes.
|
||||
* groups.
|
||||
*
|
||||
* `engine/environmentRig.ts` now supplies one. A metal barrel therefore picks up
|
||||
* the room around it and reads as a machined surface rather than as a darker
|
||||
* patch of plastic, which is the whole difference between "a robot" and "a
|
||||
* figurine of a robot" at the distance somebody stands next to one.
|
||||
*
|
||||
* The cost was re-scoped rather than re-accepted, though. `metalTrim` is used
|
||||
* **only for the four joint barrels** — hip axle, shoulder, knee, elbow — so it
|
||||
* appears in four groups rather than nine, and the figure is three materials and
|
||||
* twenty-two meshes rather than three materials and twenty-seven. The sole, the
|
||||
* ankle and the visor stay `screenBezel`, because a sole is rubber, an ankle is a
|
||||
* gap and a visor is glass, and none of the three wants a specular ring on it.
|
||||
*
|
||||
* ### The indexed/non-indexed rule bites here harder than anywhere else
|
||||
*
|
||||
@@ -245,6 +259,12 @@ export interface OptimusRig {
|
||||
interface Skin {
|
||||
shell: SurfaceMaterial;
|
||||
frame: SurfaceMaterial;
|
||||
/**
|
||||
* The joint barrels, and nothing else. Kept separate from `frame` so that
|
||||
* widening its use is a deliberate act with a visible cost in the mesh count
|
||||
* rather than a one-character change — see the header.
|
||||
*/
|
||||
metal: SurfaceMaterial;
|
||||
}
|
||||
|
||||
/** A point in whichever joint frame the emitter is drawing into. */
|
||||
@@ -315,7 +335,7 @@ function emitPelvis(bin: MeshBin, P: PartBin, s: Skin): void {
|
||||
// is the gap rule 4 in the header is about and the one the hip did not have.
|
||||
// Shorten it again and the hip goes back to one unbroken pale mass from the
|
||||
// waist to the knee, which is what a mannequin looks like.
|
||||
barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.115, 2 * OPTIMUS.hipHalf + 0.14);
|
||||
barrel(bin, P, s.metal, { x: 0, y: 0, z: 0 }, 0.115, 2 * OPTIMUS.hipHalf + 0.14);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -385,7 +405,7 @@ function emitTorso(bin: MeshBin, P: PartBin, s: Skin): void {
|
||||
// robot and 80 draw calls for a crowd of four instead of 72. Eight draw
|
||||
// calls for a 24 mm band of dark under a cap that already reads as a
|
||||
// separate piece is not the trade. A fatter drum is free.
|
||||
barrel(bin, P, s.frame, { x: side * OPTIMUS.shoulderHalf, y: shoulderY, z: 0 }, 0.135, 0.17);
|
||||
barrel(bin, P, s.metal, { x: side * OPTIMUS.shoulderHalf, y: shoulderY, z: 0 }, 0.135, 0.17);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,7 +622,7 @@ function emitThigh(bin: MeshBin, P: PartBin, s: Skin): void {
|
||||
*/
|
||||
function emitShin(bin: MeshBin, P: PartBin, s: Skin): void {
|
||||
const drop = OPTIMUS.kneeY - OPTIMUS.ankleY;
|
||||
barrel(bin, P, s.frame, { x: 0, y: 0, z: -0.012 }, 0.118, 0.125);
|
||||
barrel(bin, P, s.metal, { x: 0, y: 0, z: -0.012 }, 0.118, 0.125);
|
||||
|
||||
bin.add(P.roundedBox(0.065), s.shell, { y: -drop + 0.02, size: [0.1, drop - 0.05, 0.118] });
|
||||
|
||||
@@ -707,7 +727,7 @@ const DIGITS = [
|
||||
*/
|
||||
function emitForearm(bin: MeshBin, P: PartBin, s: Skin, side: number): void {
|
||||
const drop = OPTIMUS.elbowY - OPTIMUS.wristY;
|
||||
barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.094, 0.088);
|
||||
barrel(bin, P, s.metal, { x: 0, y: 0, z: 0 }, 0.094, 0.088);
|
||||
bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.02, size: [0.08, drop - 0.055, 0.088] });
|
||||
// Wider than the palm below it and narrower than the forearm above, in that
|
||||
// order. At 0.062 it was narrower than both, which put a 6 mm slot of
|
||||
@@ -779,6 +799,7 @@ export function buildOptimus(ctx: AssetContext): OptimusRig {
|
||||
const skin: Skin = {
|
||||
shell: ctx.materials.get("paper"),
|
||||
frame: ctx.materials.get("screenBezel"),
|
||||
metal: ctx.materials.get("metalTrim"),
|
||||
};
|
||||
|
||||
const root = new THREE.Group();
|
||||
|
||||
+162
-19
@@ -7,14 +7,110 @@
|
||||
* `mount` height, because how high a display hangs is a property of the display
|
||||
* and not of the room it is in.
|
||||
*
|
||||
* Neither takes a `colorKey`. A screen is bezel and glass, and there is no part
|
||||
* of it that anybody wants to be the colour of a team.
|
||||
* ### A screen is three surfaces, not one
|
||||
*
|
||||
* Both of these used to be a bezel with a flat `screenDisplay` panel glued to
|
||||
* it, and on the live site every display in the building was one uniform glowing
|
||||
* rectangle. Under the tone curve `stage.ts` now runs it was worse than uniform:
|
||||
* a 40%-emissive white panel clips, so a monitor, a wall display and a whiteboard
|
||||
* were all the same shade of blown-out white.
|
||||
*
|
||||
* The fix is to separate the three things a display physically is:
|
||||
*
|
||||
* - **`screenBezel`** — the moulded surround, a lit surface like any other;
|
||||
* - **`screenDisplay`** — the *dark* panel, the black border of glass around the
|
||||
* active area and what an off screen looks like;
|
||||
* - **`screenContent`** — the active area, carrying the `screenUI` drawing as
|
||||
* both `map` and `emissiveMap`. That second binding is the whole point: with
|
||||
* a flat glow the drawn interface is a pattern printed on a lamp, and with the
|
||||
* map on `emissiveMap` the lit pixels emit and the dark chrome between them
|
||||
* does not. It is the difference between a monitor and a light box.
|
||||
*
|
||||
* ### Where the layout comes from, and why it cannot be random
|
||||
*
|
||||
* `screenUI` draws `SCREEN_UI_VARIANTS` different layouts, and a wall of screens
|
||||
* showing the same one is the same defect one level down. But an asset cannot
|
||||
* roll for a layout per instance: `furnish.ts` batches props **per kind** and
|
||||
* draws `ctx.rand` once for the whole batch, so twelve monitors in one batch
|
||||
* would roll once between them and get one layout anyway.
|
||||
*
|
||||
* So the layout arrives as *authored data*: it is derived from the prop's
|
||||
* `colorKey`, which is already part of the batch key. A pack that writes
|
||||
* `colorKey: "ui-b"` on half its monitors gets two batches, two materials and
|
||||
* two layouts, and gets them deterministically — the same pack renders the same
|
||||
* wall of screens on every reload. `colorKey` is opaque here in exactly the way
|
||||
* `ARCHITECTURE.md` §3.3 requires: this file will never learn that `"ui-b"` means
|
||||
* anything, it only hashes it.
|
||||
*
|
||||
* Neither asset tints. A screen is bezel and glass, and there is no part of it
|
||||
* anybody wants to be the colour of a team.
|
||||
*/
|
||||
|
||||
import { defineAsset } from "../kit.ts";
|
||||
import { defineAsset, type AssetContext } from "../kit.ts";
|
||||
import { MeshBin } from "../parts.ts";
|
||||
import { alongFacing } from "./common.ts";
|
||||
|
||||
/**
|
||||
* The layout this instance's batch shows, from its `colorKey`.
|
||||
*
|
||||
* FNV-1a, the same four lines `furnish.ts` uses to seed a batch, and for the
|
||||
* same reason: any stable string-to-number would do and this one needs no
|
||||
* dependency. An absent key gives layout 0 rather than a random one, so a pack
|
||||
* that says nothing gets the plainest screen rather than an arbitrary one.
|
||||
*/
|
||||
function layoutFor(ctx: AssetContext): number {
|
||||
const key = ctx.colorKey;
|
||||
if (!key) return 0;
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
h ^= key.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dark panel and the lit content face, as two quads a fraction of a
|
||||
* millimetre apart.
|
||||
*
|
||||
* The dark one is the full active-area rectangle and the lit one is inset by the
|
||||
* black border every panel has, so the content never runs to the edge of the
|
||||
* glass — which is the single cue that separates a screen from a sheet of paper
|
||||
* with a picture on it. `screenContent` is `DoubleSide`-free and faces +Z, so
|
||||
* both quads are drawn only from the front.
|
||||
*/
|
||||
function displayFace(
|
||||
bin: MeshBin,
|
||||
ctx: AssetContext,
|
||||
face: {
|
||||
y: number;
|
||||
z: number;
|
||||
width: number;
|
||||
height: number;
|
||||
pitch?: number;
|
||||
/** Black border between the glass edge and the drawn content, metres. */
|
||||
border: number;
|
||||
},
|
||||
): void {
|
||||
const pitch = face.pitch ?? 0;
|
||||
const dark = ctx.materials.get("screenDisplay");
|
||||
const lit = ctx.materials.variant("screenContent", layoutFor(ctx));
|
||||
|
||||
bin.add(ctx.parts.panel(), dark, {
|
||||
y: face.y,
|
||||
z: face.z,
|
||||
size: [face.width, face.height, 1],
|
||||
pitch,
|
||||
});
|
||||
const proud = alongFacing(pitch, 0.0015);
|
||||
bin.add(ctx.parts.panel(), lit, {
|
||||
y: face.y + face.border + proud.y,
|
||||
z: face.z + proud.z,
|
||||
size: [face.width - face.border * 2, face.height - face.border * 2, 1],
|
||||
pitch,
|
||||
});
|
||||
}
|
||||
|
||||
type MonitorParams = {
|
||||
/** Bezel width, metres. 0.56 is a 24-inch panel. */
|
||||
width: number;
|
||||
@@ -40,23 +136,57 @@ export const screenMonitor = defineAsset<MonitorParams>({
|
||||
const trim = ctx.materials.get("metalTrim");
|
||||
const bezel = ctx.materials.get("screenBezel");
|
||||
|
||||
bin.add(P.box(), trim, { size: [p.width * 0.4, 0.016, 0.15] });
|
||||
// `metalTrim` here is all `roundedBoxOf` and `screenBezel` is all
|
||||
// `roundedBoxOf` too — see the indexed/non-indexed rule in `common.ts`. A
|
||||
// 4 mm chamfer on the foot is what makes it catch a highlight along its edge
|
||||
// instead of reading as a printed rectangle on the desk.
|
||||
bin.add(P.roundedBoxOf(p.width * 0.42, 0.014, 0.16, 0.004), trim, {
|
||||
size: 1,
|
||||
z: -0.008,
|
||||
});
|
||||
// The neck runs a few centimetres past the bottom of the bezel, so the
|
||||
// joint is hidden behind the panel however far it is tilted.
|
||||
bin.add(P.box(), trim, { y: 0.01, z: -0.02, size: [0.055, p.standHeight + 0.07, 0.045] });
|
||||
bin.add(P.roundedBoxOf(0.052, p.standHeight + 0.07, 0.042, 0.008), trim, {
|
||||
y: 0.01,
|
||||
z: -0.02,
|
||||
size: 1,
|
||||
});
|
||||
|
||||
const baseY = p.standHeight + 0.02;
|
||||
const pitch = -p.tilt;
|
||||
const front = alongFacing(pitch, 0.014);
|
||||
bin.add(P.roundedBox(0.03), bezel, {
|
||||
const front = alongFacing(pitch, 0.013);
|
||||
|
||||
// The shell, and a shallower housing behind it. A monitor is not a slab: it
|
||||
// is a thin panel with the electronics in a bulge behind the middle, and
|
||||
// that bulge is what its silhouette from three-quarters is made of.
|
||||
bin.add(P.roundedBoxOf(p.width, p.height, 0.022, 0.006), bezel, {
|
||||
y: baseY,
|
||||
size: [p.width, p.height, 0.024],
|
||||
size: 1,
|
||||
pitch,
|
||||
});
|
||||
bin.add(P.panel(), ctx.materials.get("screenDisplay"), {
|
||||
y: baseY + 0.012 + front.y,
|
||||
const back = alongFacing(pitch, -0.02);
|
||||
bin.add(P.roundedBoxOf(p.width * 0.6, p.height * 0.55, 0.026, 0.01), bezel, {
|
||||
y: baseY + p.height * 0.22 + back.y,
|
||||
z: back.z,
|
||||
size: 1,
|
||||
pitch,
|
||||
});
|
||||
|
||||
displayFace(bin, ctx, {
|
||||
y: baseY + 0.011 + front.y,
|
||||
z: front.z,
|
||||
size: [p.width - 0.018, p.height - 0.026, 1],
|
||||
width: p.width - 0.016,
|
||||
height: p.height - 0.024,
|
||||
pitch,
|
||||
border: 0.008,
|
||||
});
|
||||
|
||||
// Standby light, bottom-right of the chin as it is on almost every panel.
|
||||
bin.add(P.box(), ctx.materials.get("deviceIndicator"), {
|
||||
x: p.width * 0.36,
|
||||
y: baseY + 0.005 + front.y,
|
||||
z: front.z + 0.002,
|
||||
size: [0.012, 0.004, 0.004],
|
||||
pitch,
|
||||
});
|
||||
|
||||
@@ -85,20 +215,33 @@ export const screenWallDisplay = defineAsset<WallDisplayParams>({
|
||||
build(p, ctx) {
|
||||
const P = ctx.parts;
|
||||
const bin = new MeshBin();
|
||||
const bezel = ctx.materials.get("screenBezel");
|
||||
|
||||
// The bracket. Boxes, and `metalTrim` uses nothing else in this asset.
|
||||
bin.add(P.box(), ctx.materials.get("metalTrim"), {
|
||||
y: p.mount + p.height / 2 - 0.16,
|
||||
z: -0.045,
|
||||
size: [0.44, 0.32, 0.04],
|
||||
z: -0.05,
|
||||
size: [0.44, 0.32, 0.03],
|
||||
});
|
||||
bin.add(P.roundedBox(0.02), ctx.materials.get("screenBezel"), {
|
||||
for (const sx of [-1, 1]) {
|
||||
bin.add(P.box(), ctx.materials.get("metalTrim"), {
|
||||
x: sx * 0.19,
|
||||
y: p.mount + p.height / 2 - 0.16,
|
||||
z: -0.028,
|
||||
size: [0.05, 0.3, 0.026],
|
||||
});
|
||||
}
|
||||
|
||||
bin.add(P.roundedBoxOf(p.width, p.height, 0.042, 0.008), bezel, {
|
||||
y: p.mount,
|
||||
size: [p.width, p.height, 0.05],
|
||||
size: 1,
|
||||
});
|
||||
bin.add(P.panel(), ctx.materials.get("screenDisplay"), {
|
||||
y: p.mount + 0.014,
|
||||
z: 0.027,
|
||||
size: [p.width - 0.024, p.height - 0.028, 1],
|
||||
displayFace(bin, ctx, {
|
||||
y: p.mount + 0.012,
|
||||
z: 0.023,
|
||||
width: p.width - 0.022,
|
||||
height: p.height - 0.024,
|
||||
border: 0.01,
|
||||
});
|
||||
|
||||
return bin.build("screen.wall-display");
|
||||
|
||||
@@ -40,10 +40,14 @@ export const storageShelf = defineAsset<ShelfParams>({
|
||||
const bayH = (p.height - (bays + 1) * BOARD) / bays;
|
||||
const inner = p.width - 2 * BOARD;
|
||||
|
||||
// Every part in the `shelf` material is a `roundedBoxOf`, uprights included,
|
||||
// because the primitive class has to be uniform across a material and a
|
||||
// chamfered board beside a sharp upright would look like a mistake anyway.
|
||||
// A 2.5 mm radius: shelf boards are thin and anything larger reads as a
|
||||
// moulded plastic unit rather than as a board.
|
||||
for (const sx of [-1, 1]) {
|
||||
bin.add(P.box(), board, {
|
||||
x: sx * (p.width - BOARD) / 2,
|
||||
size: [BOARD, p.height, p.depth],
|
||||
bin.add(P.roundedBoxOf(BOARD, p.height, p.depth, 0.0025), board, {
|
||||
x: (sx * (p.width - BOARD)) / 2,
|
||||
});
|
||||
}
|
||||
bin.add(P.box(), ctx.materials.get("cabinet"), {
|
||||
@@ -52,9 +56,8 @@ export const storageShelf = defineAsset<ShelfParams>({
|
||||
});
|
||||
|
||||
for (let i = 0; i <= bays; i++) {
|
||||
bin.add(P.box(), board, {
|
||||
bin.add(P.roundedBoxOf(inner, BOARD, p.depth, 0.0025), board, {
|
||||
y: i * (bayH + BOARD),
|
||||
size: [inner, BOARD, p.depth],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,7 +115,11 @@ export const storageLocker = defineAsset<LockerParams>({
|
||||
footprint(p) {
|
||||
// A door has to swing, and a locker with a metre of nothing in front of it
|
||||
// is the difference between a corridor and a corridor you can use.
|
||||
return { width: p.width, depth: p.depth, height: p.height, clearance: 0.9 };
|
||||
//
|
||||
// The stated depth includes the doors and their pulls, which stand 24 mm
|
||||
// proud of the carcass at +Z. It did not, and a bank of lockers pushed
|
||||
// flush to a wall by its own footprint put its handles through the plaster.
|
||||
return { width: p.width, depth: p.depth + 0.05, height: p.height, clearance: 0.9 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@
|
||||
|
||||
import { defineAsset } from "../kit.ts";
|
||||
import { MeshBin } from "../parts.ts";
|
||||
import { panelSlab, tintable } from "./common.ts";
|
||||
import { jitter, panelSlab, tintable } from "./common.ts";
|
||||
|
||||
type RugParams = {
|
||||
width: number;
|
||||
@@ -56,6 +56,8 @@ type WhiteboardParams = {
|
||||
/** Floor to the bottom edge of the writing surface. */
|
||||
mount: number;
|
||||
tray: boolean;
|
||||
/** Sticky notes and abstract marker strokes on the face. */
|
||||
worked: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -66,11 +68,35 @@ type WhiteboardParams = {
|
||||
* The −Z face is skipped: it is against a wall, and drawing it would put a
|
||||
* second sheet of whiteboard texture into the merge for a surface nobody can
|
||||
* ever see.
|
||||
*
|
||||
* ### Why there is anything on it
|
||||
*
|
||||
* The live site shows this as "a large blank white rectangle", and it is the
|
||||
* biggest single flat surface in a meeting room. The `whiteboard` texture draws
|
||||
* faint ghosting from previous wipes, which is right and is not enough: what
|
||||
* makes a board read as *used* is objects on it that catch their own light —
|
||||
* sticky notes standing a millimetre proud, and strokes with a shadow under
|
||||
* them.
|
||||
*
|
||||
* So `worked` adds relief rather than more texture. Two millimetres is enough to
|
||||
* cast a hairline shadow under the rig's key light, which is what separates a
|
||||
* note stuck to a board from a coloured rectangle printed on one.
|
||||
*
|
||||
* Everything on it is **abstract**: rectangles and strokes, no glyphs, no words,
|
||||
* no diagrams of anything in particular. That is `ARCHITECTURE.md` §3.1 —
|
||||
* legible content on a board is either somebody's real work or a convincing
|
||||
* imitation of it, and neither belongs in an Apache-2.0 repo. Read from two
|
||||
* metres it says "a team used this room", which is the whole job.
|
||||
*
|
||||
* It is seeded from `ctx.rand`, so every board in one batch carries the same
|
||||
* notes in the same places. That is the price `furnish.ts` documents and it is
|
||||
* paid knowingly; a pack that wants two different boards authors two batches
|
||||
* with different `colorKey`s, which is the same seam the screens use.
|
||||
*/
|
||||
export const whiteboard = defineAsset<WhiteboardParams>({
|
||||
id: "tera:whiteboard",
|
||||
label: "Whiteboard",
|
||||
defaults: { width: 1.8, height: 1.2, mount: 0.9, tray: true },
|
||||
defaults: { width: 1.8, height: 1.2, mount: 0.9, tray: true, worked: true },
|
||||
|
||||
footprint(p) {
|
||||
return { width: p.width, depth: 0.1, height: p.mount + p.height };
|
||||
@@ -102,6 +128,77 @@ export const whiteboard = defineAsset<WhiteboardParams>({
|
||||
});
|
||||
}
|
||||
|
||||
if (p.worked) {
|
||||
// The writing surface, inset from the frame — the same rectangle the
|
||||
// panel above occupies, minus its border.
|
||||
const faceX = p.width - 0.14;
|
||||
const faceY = p.height - 0.14;
|
||||
const originX = -faceX / 2;
|
||||
const originY = p.mount + 0.06;
|
||||
const face = 0.012;
|
||||
|
||||
// Two columns of sticky notes. A grid rather than a scatter, because a
|
||||
// board that has been worked on has structure on it and a scatter reads as
|
||||
// confetti.
|
||||
const notes = ctx.materials.get("accent");
|
||||
const pale = ctx.materials.get("paper");
|
||||
for (let column = 0; column < 3; column++) {
|
||||
const rows = 2 + Math.floor(ctx.rand() * 2);
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const size = 0.07 + ctx.rand() * 0.02;
|
||||
bin.add(P.box(), ctx.rand() < 0.55 ? notes : pale, {
|
||||
x: originX + faceX * (0.62 + column * 0.13) + jitter(ctx.rand, 0.008),
|
||||
y: originY + faceY * (0.62 - row * 0.19) + jitter(ctx.rand, 0.008),
|
||||
z: face,
|
||||
size: [size, size, 0.002],
|
||||
roll: jitter(ctx.rand, 0.06),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Marker work on the left two thirds: a few boxes and the strokes joining
|
||||
// them. Thin dark slabs, standing proud enough to catch an edge highlight.
|
||||
const ink = ctx.materials.get("screenBezel");
|
||||
const boxes: [number, number][] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const bx = originX + faceX * (0.1 + i * 0.16);
|
||||
const by = originY + faceY * (0.28 + (i % 2) * 0.3);
|
||||
boxes.push([bx, by]);
|
||||
const w = 0.16 + ctx.rand() * 0.06;
|
||||
const h = 0.09 + ctx.rand() * 0.03;
|
||||
// Four strokes rather than a filled rectangle: a drawn box is an
|
||||
// outline, and a solid one reads as a sticker.
|
||||
for (const sy of [-1, 1]) {
|
||||
bin.add(P.box(), ink, { x: bx, y: by + (sy * h) / 2, z: face, size: [w, 0.006, 0.002] });
|
||||
}
|
||||
for (const sx of [-1, 1]) {
|
||||
bin.add(P.box(), ink, { x: bx + (sx * w) / 2, y: by, z: face, size: [0.006, h, 0.002] });
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < boxes.length - 1; i++) {
|
||||
const from = boxes[i];
|
||||
const to = boxes[i + 1];
|
||||
if (!from || !to) continue;
|
||||
const dx = to[0] - from[0];
|
||||
const dy = to[1] - from[1];
|
||||
bin.add(P.box(), ink, {
|
||||
x: from[0] + dx / 2,
|
||||
y: from[1] + dy / 2,
|
||||
z: face,
|
||||
size: [Math.hypot(dx, dy), 0.005, 0.002],
|
||||
roll: Math.atan2(dy, dx),
|
||||
});
|
||||
}
|
||||
// One underlined heading bar across the top, which is what the eye reads
|
||||
// as "this board has a subject" without anything being legible.
|
||||
bin.add(P.box(), ink, {
|
||||
x: originX + faceX * 0.26,
|
||||
y: originY + faceY * 0.88,
|
||||
z: face,
|
||||
size: [faceX * 0.4, 0.008, 0.002],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.tray) {
|
||||
bin.add(P.box(), trim, {
|
||||
y: p.mount - 0.03,
|
||||
|
||||
@@ -51,7 +51,16 @@ export const tableMeeting = defineAsset<MeetingParams>({
|
||||
return bin.build("table.meeting");
|
||||
}
|
||||
|
||||
slab(bin, ctx, top, { y: deckY, width: p.length, depth: p.width, thickness: TOP });
|
||||
// A 5 mm edge radius. A 2.4 m board is the longest specular highlight in the
|
||||
// room and it is worth having; the round top a few lines above already has a
|
||||
// circular edge for free, which is why only this branch asks for one.
|
||||
slab(bin, ctx, top, {
|
||||
y: deckY,
|
||||
width: p.length,
|
||||
depth: p.width,
|
||||
thickness: TOP,
|
||||
chamfer: 0.005,
|
||||
});
|
||||
|
||||
if (p.legs === "post") {
|
||||
for (const sx of [-1, 1]) {
|
||||
|
||||
+54
-2
@@ -33,8 +33,36 @@ import { DEFAULT_PALETTE } from "../engine/terrain.ts";
|
||||
import type { ScenePalette } from "../engine/types.ts";
|
||||
import type { SurfaceRole } from "./materials.ts";
|
||||
|
||||
/** How far outside the city's lightness range an interior role may sit. */
|
||||
export const LIGHTNESS_HEADROOM = 0.14;
|
||||
/**
|
||||
* How far outside the city's lightness range an interior role may sit.
|
||||
*
|
||||
* ### Why this is 0.22 and not 0.14
|
||||
*
|
||||
* It moved when `stage.ts` took the renderer off `NoToneMapping`. The old value
|
||||
* was set against a renderer that clipped at linear 1.0, and under a clipping
|
||||
* renderer the top of the range is not a range at all — every role above about
|
||||
* 0.9 albedo, lit by a sun the atmosphere drives past 2.3, displayed as exactly
|
||||
* the same white. Widening the band would have bought darker darks and, at the
|
||||
* other end, more roles indistinguishable from each other. So the number was
|
||||
* held down, and roles that wanted to be genuinely dark — `screenBezel` at
|
||||
* L≈0.24, `chairBase`, `deviceShell` — were clamped up into a mid-grey they did
|
||||
* not want to be.
|
||||
*
|
||||
* ACES's shoulder gives the top two stops back: linear 1.0, 2.0 and 4.0 now
|
||||
* display at about 0.90, 0.95 and 0.98 and stay separable all the way up. With
|
||||
* the top recoverable, the band can be widened for the sake of the bottom
|
||||
* without the top collapsing, and 0.22 is what lets the darkest roles reach
|
||||
* L≈0.17 — a charcoal, which is what a screen bezel and a microphone body
|
||||
* actually are.
|
||||
*
|
||||
* One asymmetry worth knowing, since this is documented as one number applied to
|
||||
* both ends. `bandOf` clamps `maxL` at 1.0, and the city palette's lightest
|
||||
* entry (`skyHorizon`, L≈0.89) already reached that ceiling at 0.14. So in
|
||||
* practice this number only ever bites at the dark end. It is still written as
|
||||
* one number, because the moment it becomes two somebody will tune them
|
||||
* independently and the band stops meaning anything.
|
||||
*/
|
||||
export const LIGHTNESS_HEADROOM = 0.22;
|
||||
|
||||
/** One role's derivation: a city colour, and how far to move it. */
|
||||
export interface RoleShift {
|
||||
@@ -103,6 +131,30 @@ export const ROLE_SHIFTS: Record<SurfaceRole, RoleShift> = {
|
||||
lightDiffuser: { from: "skyHorizon", dh: 6, ds: -0.2, dl: 0.1 },
|
||||
whiteboard: { from: "shore", dh: 8, ds: -0.03, dl: 0.26 },
|
||||
|
||||
// Devices
|
||||
//
|
||||
// A desk microphone and a monitor speaker are the two darkest objects in a
|
||||
// studio and they are dark for a reason that is not styling: a hot LED and a
|
||||
// level meter have to read against their own body from three metres away. The
|
||||
// shell therefore goes as far down as the widened band allows (L≈0.20), and
|
||||
// the grille sits a hair under it so the two do not merge into one silhouette.
|
||||
//
|
||||
// `deviceIndicator` is the one role here whose *default* colour barely
|
||||
// matters. It descends from the city's park green at high lightness, which is
|
||||
// a credible "powered, idle" lamp, but the device render layer tints it per
|
||||
// state through `MaterialRegistry.tinted()` and that path bypasses the band
|
||||
// entirely. Authoring a saturated red here instead would not survive `bandOf`
|
||||
// anyway — saturation is clamped hard, by design, and an LED is exactly the
|
||||
// kind of thing that would talk somebody into softening that rule.
|
||||
deviceShell: { from: "flats", dh: 2, ds: -0.03, dl: -0.4 },
|
||||
deviceMesh: { from: "upland", dh: 6, ds: -0.04, dl: -0.34 },
|
||||
deviceIndicator: { from: "park", dh: -6, ds: 0.06, dl: 0.18 },
|
||||
// Near-white on purpose: `screenContent` carries the one texture in the
|
||||
// library that is *not* neutral (see `screenUI` in textures.ts), so this
|
||||
// colour has to get out of its way. A mid-grey here would multiply the drawn
|
||||
// interface down into mud.
|
||||
screenContent: { from: "shore", dh: 2, ds: -0.05, dl: 0.3 },
|
||||
|
||||
// Objects
|
||||
foliage: { from: "park", dh: 4, ds: 0.06, dl: -0.06 },
|
||||
planter: { from: "shore", dh: -4, ds: 0.02, dl: -0.06 },
|
||||
|
||||
+94
-37
@@ -63,57 +63,93 @@ export class PartBin {
|
||||
* A box with rounded vertical corners and bevelled top and bottom — cushions,
|
||||
* chair shells, monitor bodies, anything moulded.
|
||||
*
|
||||
* `radius` is a *fraction of the unit*, and it does not survive non-uniform
|
||||
* scaling: a 0.06 rounded box scaled to 2 × 0.1 × 1 has visibly oval corners
|
||||
* on two sides. Ask for a radius near the one you will end up with, or use
|
||||
* `box()` and accept the sharp edge.
|
||||
* `radius` is a fraction of the unit and is only correct while the part stays
|
||||
* cubic, because a corner is round in *object* space and a non-uniform scale
|
||||
* turns a circle into an ellipse. That is not a caveat you can design around
|
||||
* — almost nothing in an office is a cube — and it is why `seating.ts` gave up
|
||||
* and went back to sharp boxes.
|
||||
*
|
||||
* **Use `roundedBoxOf` instead**, which takes the finished metres and gets a
|
||||
* genuinely circular corner at any proportion. This one stays for the parts
|
||||
* that really are cubic, and because it is the cache entry `roundedBoxOf(1, 1,
|
||||
* 1, r)` resolves to anyway.
|
||||
*/
|
||||
roundedBox(radius = 0.06): THREE.BufferGeometry {
|
||||
const bevel = Math.min(0.24, Math.max(0.01, radius));
|
||||
return this.memo(`rounded:${mm(bevel)}`, () => {
|
||||
const half = 0.5 - bevel;
|
||||
const r = Math.min(half * 0.98, bevel * 2);
|
||||
const shape = new THREE.Shape();
|
||||
shape.moveTo(-half + r, -half);
|
||||
shape.lineTo(half - r, -half);
|
||||
shape.quadraticCurveTo(half, -half, half, -half + r);
|
||||
shape.lineTo(half, half - r);
|
||||
shape.quadraticCurveTo(half, half, half - r, half);
|
||||
shape.lineTo(-half + r, half);
|
||||
shape.quadraticCurveTo(-half, half, -half, half - r);
|
||||
shape.lineTo(-half, -half + r);
|
||||
shape.quadraticCurveTo(-half, -half, -half + r, -half);
|
||||
return this.roundedBoxOf(1, 1, 1, radius);
|
||||
}
|
||||
|
||||
// Extrusion runs along +Z and the bevel overhangs both ends, so the solid
|
||||
// spans -bevel..1-bevel before it is stood up and dropped onto the floor.
|
||||
const geo = new THREE.ExtrudeGeometry(shape, {
|
||||
depth: 1 - 2 * bevel,
|
||||
bevelEnabled: true,
|
||||
bevelSize: bevel,
|
||||
bevelThickness: bevel,
|
||||
bevelSegments: 2,
|
||||
curveSegments: 4,
|
||||
});
|
||||
geo.rotateX(-Math.PI / 2);
|
||||
geo.translate(0, bevel, 0);
|
||||
geo.computeVertexNormals();
|
||||
return geo;
|
||||
});
|
||||
/**
|
||||
* The same moulded box, authored at its finished size in metres.
|
||||
*
|
||||
* The corner radius is in **metres** and is applied after the proportions are
|
||||
* known, so a 1.6 × 0.05 × 0.9 desk return gets a 12 mm round on all four
|
||||
* corners rather than a 12 mm round on two of them and a 380 mm oval on the
|
||||
* others. Place it with `size: 1` — the geometry is already the right size,
|
||||
* and scaling it is what this method exists to avoid.
|
||||
*
|
||||
* The radius is clamped to a fifth of the shortest side. Past that the bevel
|
||||
* eats the extrusion (a 0.5 radius on a 0.9-thick shelf has no flat left to
|
||||
* extrude) and `ExtrudeGeometry` starts emitting self-intersecting caps.
|
||||
*/
|
||||
roundedBoxOf(width: number, height: number, depth: number, radius = 0.06): THREE.BufferGeometry {
|
||||
const shortest = Math.max(0.002, Math.min(width, height, depth));
|
||||
const bevel = Math.min(shortest * 0.2, Math.max(0.001, radius));
|
||||
return this.memo(
|
||||
`rounded:${mm(width)}:${mm(height)}:${mm(depth)}:${mm(bevel)}`,
|
||||
() => {
|
||||
// The shape is drawn in the extruder's XY and the extrusion runs along
|
||||
// its +Z; the `rotateX` below maps that to width × depth on the floor
|
||||
// with the extrusion standing up as height.
|
||||
const halfX = width / 2 - bevel;
|
||||
const halfY = depth / 2 - bevel;
|
||||
const r = Math.min(halfX * 0.98, halfY * 0.98, bevel * 2);
|
||||
const shape = new THREE.Shape();
|
||||
shape.moveTo(-halfX + r, -halfY);
|
||||
shape.lineTo(halfX - r, -halfY);
|
||||
shape.quadraticCurveTo(halfX, -halfY, halfX, -halfY + r);
|
||||
shape.lineTo(halfX, halfY - r);
|
||||
shape.quadraticCurveTo(halfX, halfY, halfX - r, halfY);
|
||||
shape.lineTo(-halfX + r, halfY);
|
||||
shape.quadraticCurveTo(-halfX, halfY, -halfX, halfY - r);
|
||||
shape.lineTo(-halfX, -halfY + r);
|
||||
shape.quadraticCurveTo(-halfX, -halfY, -halfX + r, -halfY);
|
||||
|
||||
// The bevel overhangs both ends of the extrusion, so the solid spans
|
||||
// -bevel..height-bevel before it is stood up and dropped onto the floor.
|
||||
const geo = new THREE.ExtrudeGeometry(shape, {
|
||||
depth: height - 2 * bevel,
|
||||
bevelEnabled: true,
|
||||
bevelSize: bevel,
|
||||
bevelThickness: bevel,
|
||||
bevelSegments: 3,
|
||||
curveSegments: 6,
|
||||
});
|
||||
geo.rotateX(-Math.PI / 2);
|
||||
geo.translate(0, bevel, 0);
|
||||
geo.computeVertexNormals();
|
||||
return geo;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Unit-diameter cylinder, base on the floor. */
|
||||
cylinder(segments = 16): THREE.BufferGeometry {
|
||||
cylinder(segments = 20): THREE.BufferGeometry {
|
||||
return this.memo(`cyl:${segments}`, () =>
|
||||
new THREE.CylinderGeometry(0.5, 0.5, 1, segments).translate(0, 0.5, 0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A six-sided cylinder. Legs, columns, pen barrels — anything thin enough
|
||||
* An eight-sided cylinder. Legs, columns, pen barrels — anything thin enough
|
||||
* that nobody will count the sides, which is most of the office.
|
||||
*
|
||||
* Six read as a hexagon on a chair column at desk distance, and the office
|
||||
* measures 44,754 triangles against a 550,000 budget: eight is four extra
|
||||
* triangles on the commonest part in the library and there is nowhere for the
|
||||
* saving to go.
|
||||
*/
|
||||
rod(): THREE.BufferGeometry {
|
||||
return this.cylinder(6);
|
||||
return this.cylinder(8);
|
||||
}
|
||||
|
||||
/** Unit-diameter cone, base on the floor. */
|
||||
@@ -143,7 +179,7 @@ export class PartBin {
|
||||
}
|
||||
|
||||
/** Unit-diameter disc lying in XZ, facing up. */
|
||||
disc(segments = 24): THREE.BufferGeometry {
|
||||
disc(segments = 32): THREE.BufferGeometry {
|
||||
return this.memo(`disc:${segments}`, () =>
|
||||
new THREE.CircleGeometry(0.5, segments).rotateX(-Math.PI / 2),
|
||||
);
|
||||
@@ -294,6 +330,27 @@ export class MeshBin {
|
||||
return this.add(parts.box(), material, place);
|
||||
}
|
||||
|
||||
/**
|
||||
* A moulded box with a true corner radius at whatever proportions `place.size`
|
||||
* asks for — the drop-in replacement for `box()` on anything that is not a
|
||||
* sawn edge.
|
||||
*
|
||||
* The size is spent on the *geometry* rather than on the placement matrix,
|
||||
* which is the whole trick: `parts.roundedBox()` scaled to 1.4 × 0.06 × 0.7
|
||||
* has 42 mm corners on two sides and 3 mm on the others, and looking at that
|
||||
* is why `seating.ts` reverted to sharp boxes. Everything else about the
|
||||
* placement — position, yaw, pitch, roll — is passed through untouched.
|
||||
*
|
||||
* The cost is a cache entry per distinct size rather than one for the whole
|
||||
* library, so this is for the parts a reader will see the silhouette of, not
|
||||
* for a hundred randomised trinkets.
|
||||
*/
|
||||
rounded(material: THREE.Material, place: Placement, radius = 0.02): this {
|
||||
const size = place.size ?? 1;
|
||||
const [w, h, d] = typeof size === "number" ? [size, size, size] : size;
|
||||
return this.add(parts.roundedBoxOf(w, h, d, radius), material, { ...place, size: 1 });
|
||||
}
|
||||
|
||||
/** Number of parts waiting to be merged. Handy in an asset's own tests. */
|
||||
get size(): number {
|
||||
let n = 0;
|
||||
|
||||
+936
-42
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,15 @@
|
||||
export {
|
||||
MODEL_X_METRICS,
|
||||
MODEL_X_PAINTS,
|
||||
LUMBRIDGE_EV_METRICS,
|
||||
advanceModelXWheels,
|
||||
buildModelX,
|
||||
buildLumbridgeEV,
|
||||
cloneModelX,
|
||||
createModelXMaterials,
|
||||
createModelXPaintPool,
|
||||
disposeModelX,
|
||||
disposeModelXPaintPool,
|
||||
modelXInstanceParts,
|
||||
setModelXSteering,
|
||||
setModelXWheelRotation,
|
||||
|
||||
+1196
-274
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Where device readings come from — and what to do when nowhere will say.
|
||||
*
|
||||
* The seam. Above it, the office scene and the device panel take a
|
||||
* `DeviceSource` and never learn whether a server answered. Below it there are
|
||||
* two strategies and one rule for choosing between them, and both the
|
||||
* strategies and the rule live here so that no consumer has to hold an opinion
|
||||
* about deployments.
|
||||
*
|
||||
* ### The rule
|
||||
*
|
||||
* **A studio is never dark.** If this deployment has a device source and this
|
||||
* viewer may read it, the readings come over the API. Otherwise they come from
|
||||
* `sim.ts`, running in this tab, labelled `synthetic: true` and `live: false`.
|
||||
* That is the same shape `markers()` takes to `sample.ts` and `HttpFlights`
|
||||
* takes to `SimulatedFlights`, and it is the same argument: an empty panel and
|
||||
* a working panel look identical to a broken one, and the clone-and-run case in
|
||||
* CONTRACT.md §0 is the commonest way this bundle is used.
|
||||
*
|
||||
* ### Reading is the demo; writing is the account
|
||||
*
|
||||
* `routes/devices.ts` refuses an anonymous read, and it should — a reading is
|
||||
* about a room somebody is standing in. But an anonymous visitor is the
|
||||
* audience this product is designed for, so a refusal must not produce a dead
|
||||
* instrument: it produces the local simulator, which is honest, alive and says
|
||||
* in the panel exactly what it is.
|
||||
*
|
||||
* Commands do **not** get the same treatment across the boundary. On the API
|
||||
* strategy a refused command is a refusal, reported as `null`, and the panel
|
||||
* tells the person to sign in — it is never quietly applied to a local copy,
|
||||
* because a control that appears to work and changes nothing anybody else can
|
||||
* see is worse than one that says no. On the simulated strategy a command is
|
||||
* applied locally and openly: nothing there claims to be a real room.
|
||||
*
|
||||
* ### No timers
|
||||
*
|
||||
* Nothing here owns a `setInterval`. The simulated strategy is advanced by
|
||||
* `tick(dt)` from whatever render loop already exists — the same idiom
|
||||
* `luminaires.ts` and `FlightLayer` use — so there is no handle to leak, no
|
||||
* work done in a tab nobody is looking at, and a test can step it by hand and
|
||||
* get the same numbers every time. The API strategy's polling lives in
|
||||
* `watchDevices` in `adapters/http.ts`, which is where every other watch's
|
||||
* timer already is.
|
||||
*/
|
||||
|
||||
import type { DevicesSourceId } from "../server/wire.ts";
|
||||
import { createSimulatedDevices, type SimulatedDevices } from "./sim.ts";
|
||||
import {
|
||||
deviceStateSignature,
|
||||
initialDeviceState,
|
||||
normalizeDeviceCommand,
|
||||
type DeviceCommand,
|
||||
type DeviceDeclaration,
|
||||
type DeviceState,
|
||||
} from "./types.ts";
|
||||
|
||||
/**
|
||||
* What the caller is handed, with the two provenance facts attached.
|
||||
*
|
||||
* `live` is "a deployment answered"; `synthetic` is "nobody observed this".
|
||||
* They are independent and the interface needs both — see `DeviceFeed` in
|
||||
* `adapters/http.ts`, which carries the same pair over the wire and tabulates
|
||||
* every combination that exists.
|
||||
*/
|
||||
export interface DeviceReading {
|
||||
states: DeviceState[];
|
||||
live: boolean;
|
||||
synthetic: boolean;
|
||||
source: DevicesSourceId;
|
||||
attribution: string[];
|
||||
}
|
||||
|
||||
export interface DeviceSource {
|
||||
/** The latest reading. Never null and never a promise; safe in a render loop. */
|
||||
current(): DeviceReading;
|
||||
/**
|
||||
* Advance simulated time by `dtSeconds`.
|
||||
*
|
||||
* A no-op on the API strategy, which is driven by its own poll. Call it every
|
||||
* frame; it is a handful of arithmetic per device and it is what makes the
|
||||
* meters move.
|
||||
*/
|
||||
tick(dtSeconds: number): void;
|
||||
/** Ask again now. A no-op on the simulated strategy, which is always current. */
|
||||
refresh(): void;
|
||||
/**
|
||||
* Send a command. Resolves to the resulting state, or `null` for a refusal.
|
||||
*
|
||||
* `null` is the honest answer to "you are not signed in", "that device is not
|
||||
* in this office" and "that op is not one this device declared" alike. The
|
||||
* caller shows one message; distinguishing them here would be a taxonomy
|
||||
* nobody branches on.
|
||||
*/
|
||||
command(command: DeviceCommand): Promise<DeviceState | null>;
|
||||
/**
|
||||
* Which seats have somebody in them, so a microphone can respond to the room.
|
||||
*
|
||||
* Only the simulated strategy uses it — a real bridge is reading real
|
||||
* hardware and does not need to be told who is at the desk. Passing an empty
|
||||
* array means "nobody", which is a different statement from never calling it
|
||||
* at all; see `setOccupancy` in `sim.ts`.
|
||||
*/
|
||||
setOccupancy(seatIds: readonly string[]): void;
|
||||
/** Stop any poll and drop any late answer. Idempotent. */
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Just enough of `TeraClient` to read and command devices.
|
||||
*
|
||||
* A structural slice rather than the whole client, so this module does not
|
||||
* depend on the adapter's other nine methods and a test can hand in an object
|
||||
* with two functions on it. `adapters/http.ts` satisfies it by construction.
|
||||
*/
|
||||
export interface DeviceClient {
|
||||
watchDevices(
|
||||
officeId: string,
|
||||
onFeed: (feed: {
|
||||
value: DeviceState[];
|
||||
live: boolean;
|
||||
synthetic: boolean;
|
||||
source: DevicesSourceId;
|
||||
attribution: string[];
|
||||
}) => void,
|
||||
): { current(): unknown; refresh(): void; stop(): void };
|
||||
commandDevice(officeId: string, command: DeviceCommand): Promise<DeviceState | null>;
|
||||
}
|
||||
|
||||
export interface DeviceSourceOptions {
|
||||
/** What this office declared. The simulated strategy runs exactly these. */
|
||||
declarations: readonly DeviceDeclaration[];
|
||||
/** Fired whenever a reading a viewer could notice has changed. */
|
||||
onReading?: (reading: DeviceReading) => void;
|
||||
/** The API client, or `null` on a build with no server behind it. */
|
||||
client?: DeviceClient | null;
|
||||
officeId?: string;
|
||||
/**
|
||||
* Whether `/health` said this deployment has a device source at all.
|
||||
*
|
||||
* `false` means the API strategy is skipped without a request being made —
|
||||
* the same job `Feeds` does in `access.ts` for weather and markers, and for
|
||||
* the same reason: a poll against a box whose answer is structurally empty is
|
||||
* a request per TTL per tab, forever, to be told nothing.
|
||||
*/
|
||||
serverHasDevices?: boolean;
|
||||
/** The simulator's seed. Same seed, same studio, on every machine. */
|
||||
seed?: number;
|
||||
/** Seconds per simulated step. Smaller is smoother and costs arithmetic. */
|
||||
fixedStepSeconds?: number;
|
||||
/** What simulated time zero means, for `observedAt`. Defaults to now. */
|
||||
epochMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many fixed steps one `tick` may run.
|
||||
*
|
||||
* A tab that was backgrounded for ten minutes comes back with a `dt` of six
|
||||
* hundred seconds, and catching up honestly would be six thousand steps in one
|
||||
* frame — a visible hitch to arrive at a meter reading nobody was watching
|
||||
* accumulate. The excess is dropped rather than queued: simulated time is
|
||||
* allowed to lag, because nothing downstream measures it against a clock.
|
||||
*/
|
||||
const MAX_STEPS_PER_TICK = 12;
|
||||
|
||||
const DEFAULT_FIXED_STEP_SECONDS = 0.1;
|
||||
const DEFAULT_SEED = 8731;
|
||||
|
||||
/**
|
||||
* A source that never has anything to say.
|
||||
*
|
||||
* For an office that declares no devices — which is most offices, and every
|
||||
* pack written before devices existed. Distinct from a simulated source with an
|
||||
* empty declaration list only in that it is obviously nothing: no simulator is
|
||||
* constructed and `tick` does no work at all.
|
||||
*/
|
||||
export function createNullDeviceSource(): DeviceSource {
|
||||
const reading: DeviceReading = {
|
||||
states: [],
|
||||
live: false,
|
||||
// An empty studio has invented nothing, but saying `synthetic: false` would
|
||||
// read as "these zero readings were observed", which is a claim about a
|
||||
// room. Nothing here observed anything.
|
||||
synthetic: true,
|
||||
source: "none",
|
||||
attribution: [],
|
||||
};
|
||||
return {
|
||||
current: () => reading,
|
||||
tick: () => {},
|
||||
refresh: () => {},
|
||||
command: () => Promise.resolve(null),
|
||||
setOccupancy: () => {},
|
||||
stop: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The source for one office, choosing its own strategy.
|
||||
*
|
||||
* See the header for the rule. The choice is made once, at construction, from
|
||||
* facts the caller already has — there is no runtime failover, because a
|
||||
* strategy that silently swapped a real bridge for a simulator mid-session
|
||||
* would be the `first-party-sensor`/`simulated` confusion `DeviceProvenance`
|
||||
* exists to prevent, arriving without a word in the interface.
|
||||
*
|
||||
* What *does* change at runtime is `live`: an API strategy whose deployment
|
||||
* stops answering reports `live: false` and an empty list, exactly as
|
||||
* `watchPresence` reports a floor it has stopped hearing about. It does not
|
||||
* quietly start inventing readings instead.
|
||||
*/
|
||||
export function createDeviceSource(options: DeviceSourceOptions): DeviceSource {
|
||||
const declarations = options.declarations;
|
||||
if (declarations.length === 0) return createNullDeviceSource();
|
||||
|
||||
const useApi =
|
||||
options.client !== null &&
|
||||
options.client !== undefined &&
|
||||
typeof options.officeId === "string" &&
|
||||
options.officeId !== "" &&
|
||||
options.serverHasDevices !== false;
|
||||
|
||||
return useApi
|
||||
? apiSource(options.client as DeviceClient, options.officeId as string, declarations, options)
|
||||
: simulatedSource(declarations, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Readings over the API.
|
||||
*
|
||||
* Thin on purpose: `watchDevices` already owns the poll, the back-off, the
|
||||
* hidden-tab rule and the publish-on-change comparison, and duplicating any of
|
||||
* it here would be a second place for the cadence to be wrong.
|
||||
*
|
||||
* The pre-connection reading is `initialDeviceState` for every declaration
|
||||
* rather than an empty list, so the panel draws its instruments immediately —
|
||||
* powered off, meters at the floor, marked not live. An empty list would make
|
||||
* the panel flicker into existence a poll later, and would be indistinguishable
|
||||
* from an office that declared nothing.
|
||||
*/
|
||||
function apiSource(
|
||||
client: DeviceClient,
|
||||
officeId: string,
|
||||
declarations: readonly DeviceDeclaration[],
|
||||
options: DeviceSourceOptions,
|
||||
): DeviceSource {
|
||||
const observedAt = options.epochMs ?? Date.now();
|
||||
/**
|
||||
* The instruments, at rest, before anything has been heard — and again
|
||||
* whenever the deployment stops answering.
|
||||
*
|
||||
* A refusal must not empty the panel. An empty `states` means "this office
|
||||
* declares no hardware", which is a different sentence from "nobody will tell
|
||||
* me what the hardware is doing", and collapsing the two makes a panel
|
||||
* disappear at exactly the moment somebody is wondering why it is not
|
||||
* working. `live: false` is what says nobody answered; the readings alongside
|
||||
* it are the at-rest defaults, every one of them `synthetic: true`, which is
|
||||
* the same claim `initialDeviceState` is documented to make.
|
||||
*/
|
||||
const atRest = (): DeviceState[] => declarations.map((d) => initialDeviceState(d, observedAt));
|
||||
|
||||
let reading: DeviceReading = {
|
||||
states: atRest(),
|
||||
live: false,
|
||||
synthetic: true,
|
||||
source: "none",
|
||||
attribution: [],
|
||||
};
|
||||
|
||||
const watch = client.watchDevices(officeId, (feed) => {
|
||||
reading = {
|
||||
states: feed.live ? feed.value : atRest(),
|
||||
live: feed.live,
|
||||
synthetic: feed.synthetic,
|
||||
source: feed.source,
|
||||
attribution: feed.attribution,
|
||||
};
|
||||
options.onReading?.(reading);
|
||||
});
|
||||
|
||||
return {
|
||||
current: () => reading,
|
||||
// The server's clock, not ours. Advancing a local simulation alongside a
|
||||
// real feed would put two sets of numbers on one meter.
|
||||
tick: () => {},
|
||||
refresh: () => watch.refresh(),
|
||||
async command(command: DeviceCommand): Promise<DeviceState | null> {
|
||||
const declaration = declarations.find((d) => d.id === command.deviceId);
|
||||
if (declaration === undefined) return null;
|
||||
// Validated before it is sent as well as after it arrives. Not
|
||||
// redundancy: this is what stops a slider that has been dragged past its
|
||||
// own bounds from spending a round trip to be told no, and the server's
|
||||
// copy of the check is there because a browser is not a boundary.
|
||||
const normalized = normalizeDeviceCommand(declaration, command);
|
||||
if (normalized === null) return null;
|
||||
const state = await client.commandDevice(officeId, normalized);
|
||||
// A command that landed changes the room, so ask for the new picture
|
||||
// rather than waiting out the poll — and ask rather than patching the
|
||||
// local copy, because the server is the authority on what the state now
|
||||
// is and it may have clamped what we sent.
|
||||
if (state !== null) watch.refresh();
|
||||
return state;
|
||||
},
|
||||
setOccupancy: () => {},
|
||||
stop: () => watch.stop(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Readings from the simulator in this tab.
|
||||
*
|
||||
* The anonymous visitor's studio, the offline clone's studio, and the studio a
|
||||
* self-hoster gets before they have configured anything. Everything it produces
|
||||
* is `synthetic: true` and `live: false`, which is what the panel puts in front
|
||||
* of the viewer alongside each declaration's own disclosure sentence.
|
||||
*/
|
||||
function simulatedSource(
|
||||
declarations: readonly DeviceDeclaration[],
|
||||
options: DeviceSourceOptions,
|
||||
): DeviceSource {
|
||||
const fixedStepSeconds = options.fixedStepSeconds ?? DEFAULT_FIXED_STEP_SECONDS;
|
||||
const simulator: SimulatedDevices = createSimulatedDevices(declarations, {
|
||||
seed: options.seed ?? DEFAULT_SEED,
|
||||
fixedStepSeconds,
|
||||
epochMs: options.epochMs ?? Date.now(),
|
||||
});
|
||||
|
||||
let states = simulator.current();
|
||||
let signature = deviceStateSignature(states);
|
||||
let carried = 0;
|
||||
let stopped = false;
|
||||
|
||||
const read = (): DeviceReading => ({
|
||||
states,
|
||||
live: false,
|
||||
synthetic: true,
|
||||
// `"sim"` and not `"none"`: something is producing these readings and the
|
||||
// interface is entitled to name it. `"none"` is reserved for a source that
|
||||
// produces nothing, which is what `createNullDeviceSource` is.
|
||||
source: "sim",
|
||||
attribution: [],
|
||||
});
|
||||
let reading = read();
|
||||
|
||||
function republish(): void {
|
||||
states = simulator.current();
|
||||
const next = deviceStateSignature(states);
|
||||
reading = read();
|
||||
// Only when something a viewer could see has moved. `observedAt` advances
|
||||
// on every step and is deliberately not in the signature, or this would
|
||||
// publish at the tick rate forever.
|
||||
if (next === signature) return;
|
||||
signature = next;
|
||||
options.onReading?.(reading);
|
||||
}
|
||||
|
||||
return {
|
||||
current: () => reading,
|
||||
|
||||
tick(dtSeconds: number): void {
|
||||
if (stopped || !Number.isFinite(dtSeconds) || dtSeconds <= 0) return;
|
||||
carried += dtSeconds;
|
||||
let steps = 0;
|
||||
while (carried >= fixedStepSeconds && steps < MAX_STEPS_PER_TICK) {
|
||||
simulator.stepFixed();
|
||||
carried -= fixedStepSeconds;
|
||||
steps += 1;
|
||||
}
|
||||
// Whatever is left over after the cap is dropped rather than banked; see
|
||||
// `MAX_STEPS_PER_TICK`.
|
||||
if (steps === MAX_STEPS_PER_TICK) carried = 0;
|
||||
if (steps > 0) republish();
|
||||
},
|
||||
|
||||
refresh(): void {
|
||||
// Always current by construction — there is nothing to ask. Republished
|
||||
// anyway so a caller that calls `refresh()` to force a redraw gets one.
|
||||
republish();
|
||||
},
|
||||
|
||||
command(command: DeviceCommand): Promise<DeviceState | null> {
|
||||
if (stopped) return Promise.resolve(null);
|
||||
const declaration = declarations.find((d) => d.id === command.deviceId);
|
||||
if (declaration === undefined) return Promise.resolve(null);
|
||||
const normalized = normalizeDeviceCommand(declaration, command);
|
||||
if (normalized === null) return Promise.resolve(null);
|
||||
simulator.command(normalized);
|
||||
republish();
|
||||
const applied = states.find((s) => s.id === normalized.deviceId) ?? null;
|
||||
// A promise even though nothing is awaited, so the two strategies are the
|
||||
// same shape to the caller and a panel does not have to know which it has.
|
||||
return Promise.resolve(applied);
|
||||
},
|
||||
|
||||
setOccupancy(seatIds: readonly string[]): void {
|
||||
simulator.setOccupancy(seatIds);
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
stopped = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The device surface, in one import.
|
||||
*
|
||||
* A barrel and nothing else — no logic, no re-shaping, no defaults. It exists
|
||||
* because four consumers want different thirds of this directory and none of
|
||||
* them should have to know which file a name lives in: a pack authors against
|
||||
* `types.ts`, the arena drives `sim.ts`, the office scene takes a
|
||||
* `DeviceSource` from `adapter.ts`, and the panel wants the vocabulary tables
|
||||
* from all three.
|
||||
*
|
||||
* **Nothing here imports THREE, the DOM or the network**, and that is a
|
||||
* property worth stating rather than assuming: `src/index.ts` re-exports this
|
||||
* onto the package surface, and `src/arena/` may import it, so a three.js
|
||||
* import added anywhere under `src/devices/` would break both at once. The
|
||||
* render layer for devices is `src/interiors/devices.ts`, which is deliberately
|
||||
* *not* re-exported from here.
|
||||
*/
|
||||
|
||||
export {
|
||||
CANONICAL_CAPABILITIES,
|
||||
CAPABILITY_READING,
|
||||
DEVICE_CAPABILITIES,
|
||||
DEVICE_COMMAND_OPS,
|
||||
DEVICE_KINDS,
|
||||
DEVICE_PROVENANCE,
|
||||
DEVICE_RANGES,
|
||||
deviceKindOfAssetId,
|
||||
deviceStateSignature,
|
||||
hasCapability,
|
||||
initialDeviceState,
|
||||
isDeviceCapability,
|
||||
isDeviceCommandOp,
|
||||
isDeviceKind,
|
||||
isDeviceProvenance,
|
||||
normalizeDeviceCommand,
|
||||
validateDeviceDeclaration,
|
||||
type DeviceAnchor,
|
||||
type DeviceAssetId,
|
||||
type DeviceCapability,
|
||||
type DeviceCommand,
|
||||
type DeviceCommandOp,
|
||||
type DeviceCommandValue,
|
||||
type DeviceDeclaration,
|
||||
type DeviceKind,
|
||||
type DeviceOffset,
|
||||
type DeviceProvenance,
|
||||
type DeviceRange,
|
||||
type DeviceState,
|
||||
} from "./types.ts";
|
||||
|
||||
export {
|
||||
createSimulatedDevices,
|
||||
type SimulatedDevices,
|
||||
type SimulatedDevicesOptions,
|
||||
} from "./sim.ts";
|
||||
|
||||
export {
|
||||
createDeviceSource,
|
||||
createNullDeviceSource,
|
||||
type DeviceClient,
|
||||
type DeviceReading,
|
||||
type DeviceSource,
|
||||
type DeviceSourceOptions,
|
||||
} from "./adapter.ts";
|
||||
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* Devices that behave like hardware without being any.
|
||||
*
|
||||
* A fixed-step, seeded state machine over a list of `DeviceDeclaration`s. Same
|
||||
* seed, same declarations, same commands, same readings — on every machine,
|
||||
* forever, with no clock read anywhere inside `stepFixed()`.
|
||||
*
|
||||
* ### Why determinism is the whole design and not a nicety
|
||||
*
|
||||
* The arena imports **this module**, not a headless copy of it. `studio-ops-v1`
|
||||
* observes a microphone's level, a speaker's programme and whether the desk in
|
||||
* front of the mic is occupied, and it has to be able to replay a rollout and
|
||||
* arrive at the identical checksum — which is only true if every number here is
|
||||
* a pure function of (seed, step, commands). So:
|
||||
*
|
||||
* - nothing calls `Date.now()`, `Math.random()` or `performance.now()`;
|
||||
* - `stepFixed()` advances by exactly `fixedStepSeconds` and takes no argument
|
||||
* that could vary with a frame rate;
|
||||
* - the random stream is per device and is advanced only by `stepFixed`, so
|
||||
* issuing a command does not shift the sequence a replay would draw;
|
||||
* - `snapshot()` is plain JSON data and `restore()` reproduces the remainder
|
||||
* of a run bit for bit.
|
||||
*
|
||||
* The property that makes the arena honest is that the renderer drives this
|
||||
* exact object. A second implementation "for the trainer" would be a simulator
|
||||
* nobody can see and a picture nobody can train against.
|
||||
*
|
||||
* ### What it models, and what it refuses to
|
||||
*
|
||||
* A microphone with a level that responds to whether anybody is at the desk it
|
||||
* serves, and a speaker with an output meter that follows its volume. That is
|
||||
* enough for the two things a viewer does with this — watch a meter move, press
|
||||
* mute and see it stop — and enough for the cross-variable coupling the arena
|
||||
* needs (a hot mic at an empty desk is waste; playback under an aircraft is
|
||||
* noise).
|
||||
*
|
||||
* It does **not** invent a track title, an artist, a speaker identity or
|
||||
* anything else that would read as a fact about a real room. `DeviceState`
|
||||
* carries no field for one, deliberately: every reading here is `synthetic` and
|
||||
* the panel says so, and the line between "a meter that moves" and "Marta is
|
||||
* talking" is the line between a simulation and a claim. `DeviceProvenance` in
|
||||
* `types.ts` is the same argument at the level of the whole device.
|
||||
*
|
||||
* ### Occupancy
|
||||
*
|
||||
* Who is at the desk is an **input**, not an invention, whenever the caller
|
||||
* knows: `setOccupancy()` hands over the seats that are occupied and the mics
|
||||
* anchored to them go live. A caller that never says gets a deterministic
|
||||
* schedule drawn from the same seed, so a deployment with no presence source —
|
||||
* which is most of them, and every anonymous viewer — still shows a studio that
|
||||
* is alive rather than a row of flat meters. Both paths are deterministic; the
|
||||
* schedule is not a fallback to randomness, it is a fallback to a fixture.
|
||||
*/
|
||||
|
||||
import {
|
||||
DEVICE_RANGES,
|
||||
hasCapability,
|
||||
normalizeDeviceCommand,
|
||||
type DeviceCapability,
|
||||
type DeviceCommand,
|
||||
type DeviceDeclaration,
|
||||
type DeviceKind,
|
||||
type DeviceState,
|
||||
} from "./types.ts";
|
||||
|
||||
// ---- The public shape -----------------------------------------------------
|
||||
|
||||
export interface SimulatedDevicesOptions {
|
||||
seed: number;
|
||||
/**
|
||||
* Seconds per `stepFixed()`. The arena runs 0.1; the browser and the server
|
||||
* run whatever their tick is, and both get the same physics because every
|
||||
* rate below is expressed per second and multiplied by this.
|
||||
*/
|
||||
fixedStepSeconds: number;
|
||||
/**
|
||||
* What instant simulated time zero corresponds to, in epoch milliseconds.
|
||||
*
|
||||
* `0` by default, which makes `observedAt` an elapsed-milliseconds count and
|
||||
* is what the arena wants — a wall-clock stamp in a replayed rollout would be
|
||||
* the one field that could not match. A server or a browser driving this in
|
||||
* real time passes `Date.now()` at construction, and then `observedAt` is a
|
||||
* real timestamp because the caller advances the simulation in step with the
|
||||
* clock.
|
||||
*/
|
||||
epochMs?: number;
|
||||
}
|
||||
|
||||
export interface SimulatedDevices {
|
||||
/** Every declared device's current reading, in declaration order. */
|
||||
current(): DeviceState[];
|
||||
/**
|
||||
* Apply one command, or ignore it.
|
||||
*
|
||||
* Ignored — not thrown on — for an id this simulator does not carry, an op
|
||||
* the declaration did not declare, or a value of the wrong type; the shared
|
||||
* `normalizeDeviceCommand()` makes that decision so that the browser, the API
|
||||
* and this all refuse exactly the same things. A number outside its range is
|
||||
* clamped rather than refused, which is that function's documented
|
||||
* disposition and not a second opinion held here.
|
||||
*/
|
||||
command(command: DeviceCommand): void;
|
||||
/** Advance by exactly `fixedStepSeconds`. Reads no clock. */
|
||||
stepFixed(): void;
|
||||
/** Plain JSON data. Safe to `JSON.stringify`, store and hand back later. */
|
||||
snapshot(): unknown;
|
||||
/** Resume from a snapshot. A snapshot this simulator cannot read is ignored. */
|
||||
restore(state: unknown): void;
|
||||
/**
|
||||
* Which seats have somebody in them, if the caller knows.
|
||||
*
|
||||
* Additive to the signature the build spec fixed, and additive on purpose:
|
||||
* without it the mic level could not respond to the room, which is the one
|
||||
* behaviour that makes a level meter worth drawing. Calling it once switches
|
||||
* this simulator off its own occupancy schedule for good — including a call
|
||||
* with an empty array, which means "I looked, and nobody is there", not "I
|
||||
* have nothing to say".
|
||||
*/
|
||||
setOccupancy(occupiedSeatIds: readonly string[]): void;
|
||||
}
|
||||
|
||||
// ---- Level model ----------------------------------------------------------
|
||||
//
|
||||
// Everything here is in dBFS and every rate is per second. The numbers are
|
||||
// chosen to be *watchable* rather than to be a measurement: a meter that sits
|
||||
// still is indistinguishable from a broken one, and a meter that jumps the full
|
||||
// scale every frame is indistinguishable from noise.
|
||||
|
||||
/** The floor. A device that is off, muted or silent reports exactly this. */
|
||||
const FLOOR_DB = DEVICE_RANGES.level.min;
|
||||
|
||||
/** The gain at which the mic model is calibrated — anything else shifts it. */
|
||||
const REFERENCE_GAIN_DB = DEVICE_RANGES.gain.initial;
|
||||
|
||||
/** An empty studio with the air handling running, at reference gain. */
|
||||
const ROOM_TONE_DB: Span = { low: -56, high: -47 };
|
||||
|
||||
/** Somebody talking at the desk this mic serves, at reference gain. */
|
||||
const SPEECH_DB: Span = { low: -27, high: -8 };
|
||||
|
||||
/** A speaker at full volume, playing. Scaled by volume below. */
|
||||
const PROGRAMME_DB: Span = { low: -14, high: -5 };
|
||||
|
||||
/** How long one syllable-scale target lasts, in seconds. */
|
||||
const SPEECH_PHASE: Span = { low: 0.25, high: 0.9 };
|
||||
/** Room tone drifts far more slowly than speech does. */
|
||||
const ROOM_PHASE: Span = { low: 1.4, high: 3.6 };
|
||||
/** Programme material moves between the two. */
|
||||
const PROGRAMME_PHASE: Span = { low: 0.4, high: 1.6 };
|
||||
|
||||
/**
|
||||
* How fast the meter rises and falls, in dB per second.
|
||||
*
|
||||
* Asymmetric, like every programme meter ever built: fast attack so a syllable
|
||||
* registers, slow release so the eye can read the peak it just produced. A
|
||||
* symmetric filter reads as a wobble rather than as a level.
|
||||
*/
|
||||
const ATTACK_DB_PER_SECOND = 220;
|
||||
const RELEASE_DB_PER_SECOND = 34;
|
||||
|
||||
/**
|
||||
* How long the desk this mic serves stays occupied, and stays empty, when
|
||||
* nobody has told us. Seconds.
|
||||
*
|
||||
* Minutes rather than seconds, because this is a person at a desk rather than a
|
||||
* syllable, and because a mic whose meter came alive every four seconds would
|
||||
* read as a fault. The two spans differ: studios are empty more than they are
|
||||
* busy.
|
||||
*/
|
||||
const BUSY_PHASE: Span = { low: 25, high: 90 };
|
||||
const IDLE_PHASE: Span = { low: 40, high: 180 };
|
||||
|
||||
interface Span {
|
||||
low: number;
|
||||
high: number;
|
||||
}
|
||||
|
||||
// ---- Randomness -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One 32-bit stream per device.
|
||||
*
|
||||
* Local rather than `seededRandom` from `engine/world.ts`, and that is a hard
|
||||
* requirement rather than a preference: this module is imported by
|
||||
* `src/arena/`, which may not reach three.js, the DOM or the network, and
|
||||
* `world.ts` reaches the first. It is the same mulberry32 arithmetic, stated in
|
||||
* eleven lines, so the dependency this module carries stays at zero.
|
||||
*
|
||||
* Per device rather than one shared stream so that adding a device to a pack
|
||||
* does not re-roll every other device's future — the same reason
|
||||
* `HttpFlights` draws its route phases over the whole plan before filtering.
|
||||
*/
|
||||
function nextRandom(state: number): { value: number; state: number } {
|
||||
let t = (state + 0x6d2b79f5) >>> 0;
|
||||
let x = Math.imul(t ^ (t >>> 15), 1 | t);
|
||||
x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
|
||||
return { value: ((x ^ (x >>> 14)) >>> 0) / 4_294_967_296, state: t };
|
||||
}
|
||||
|
||||
/** A 32-bit seed for one device, mixed from the run seed and the device id. */
|
||||
function streamSeed(seed: number, id: string): number {
|
||||
// FNV-1a over the id, then mixed with the run seed. The same construction
|
||||
// `arena/checksum.ts` uses, for the same reason: two devices whose ids differ
|
||||
// by one character must not draw neighbouring streams.
|
||||
let hash = 0x811c9dc5 ^ (seed | 0);
|
||||
for (let i = 0; i < id.length; i += 1) {
|
||||
hash ^= id.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
// ---- Per-device runtime ---------------------------------------------------
|
||||
|
||||
interface Runtime {
|
||||
id: string;
|
||||
kind: DeviceKind;
|
||||
capabilities: readonly DeviceCapability[];
|
||||
/** The seat this device serves, for occupancy. `""` when it serves none. */
|
||||
seatId: string;
|
||||
powered: boolean;
|
||||
muted: boolean;
|
||||
gainDb: number;
|
||||
volume: number;
|
||||
playing: boolean;
|
||||
/** The smoothed meter reading, dBFS. */
|
||||
levelDb: number;
|
||||
/** What the meter is heading for until the phase ends. */
|
||||
targetDb: number;
|
||||
/** Seconds left in the current level phase. */
|
||||
phaseLeft: number;
|
||||
/** The device's own random stream. */
|
||||
rng: number;
|
||||
/** Self-driven occupancy, used only while nobody has called `setOccupancy`. */
|
||||
busy: boolean;
|
||||
/** Seconds left in the current self-driven occupancy phase. */
|
||||
busyLeft: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The snapshot format. Versioned, flat, and plain JSON.
|
||||
*
|
||||
* Versioned because a snapshot outlives the code that wrote it — the arena
|
||||
* stores one mid-episode and restores it later — and an unreadable snapshot has
|
||||
* to be *recognisably* unreadable rather than half-applied. `restore()` ignores
|
||||
* anything that is not this, which leaves the simulator on its own consistent
|
||||
* state instead of a mixture of two.
|
||||
*/
|
||||
interface Snapshot {
|
||||
v: 1;
|
||||
elapsedMs: number;
|
||||
occupancyProvided: boolean;
|
||||
occupied: string[];
|
||||
devices: Runtime[];
|
||||
}
|
||||
|
||||
const SNAPSHOT_VERSION = 1;
|
||||
|
||||
export function createSimulatedDevices(
|
||||
declarations: readonly DeviceDeclaration[],
|
||||
options: SimulatedDevicesOptions,
|
||||
): SimulatedDevices {
|
||||
const dt = Math.max(0, options.fixedStepSeconds);
|
||||
const epochMs = options.epochMs ?? 0;
|
||||
// Copied, so a caller mutating the array it handed in cannot change what this
|
||||
// simulator validates commands against half way through a run.
|
||||
const declared = declarations.map((d) => d);
|
||||
const byId = new Map<string, DeviceDeclaration>(declared.map((d) => [d.id, d]));
|
||||
|
||||
let elapsedMs = 0;
|
||||
let occupancyProvided = false;
|
||||
let occupied = new Set<string>();
|
||||
let devices = declared.map((d) => initialRuntime(d, options.seed));
|
||||
|
||||
/** Whether the desk this device serves has somebody at it, right now. */
|
||||
function isOccupied(device: Runtime): boolean {
|
||||
if (!occupancyProvided) return device.busy;
|
||||
return device.seatId !== "" && occupied.has(device.seatId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the meter is heading, and for how long.
|
||||
*
|
||||
* One draw per phase rather than one per step: a target redrawn every frame is
|
||||
* white noise, and white noise through a smoothing filter is a meter that
|
||||
* hovers around its own mean and never peaks.
|
||||
*/
|
||||
function drawPhase(device: Runtime): void {
|
||||
const quiet = !device.powered;
|
||||
if (device.kind === "mic") {
|
||||
if (quiet || device.muted) {
|
||||
device.targetDb = FLOOR_DB;
|
||||
device.phaseLeft = span(device, ROOM_PHASE);
|
||||
return;
|
||||
}
|
||||
const busy = isOccupied(device);
|
||||
device.targetDb = clamp(
|
||||
span(device, busy ? SPEECH_DB : ROOM_TONE_DB) + (device.gainDb - REFERENCE_GAIN_DB),
|
||||
FLOOR_DB,
|
||||
DEVICE_RANGES.level.max,
|
||||
);
|
||||
device.phaseLeft = span(device, busy ? SPEECH_PHASE : ROOM_PHASE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (quiet || !device.playing) {
|
||||
device.targetDb = FLOOR_DB;
|
||||
device.phaseLeft = span(device, PROGRAMME_PHASE);
|
||||
return;
|
||||
}
|
||||
// Volume is a fraction and the meter is decibels, so the knob enters as
|
||||
// 20·log10 — which is why halving the volume drops the meter about 6 dB
|
||||
// rather than halving the number on it. Floored well above zero so a
|
||||
// speaker turned all the way down reads as silent rather than as -Infinity.
|
||||
const attenuation = 20 * Math.log10(Math.max(device.volume, 0.001));
|
||||
device.targetDb = clamp(
|
||||
span(device, PROGRAMME_DB) + attenuation,
|
||||
FLOOR_DB,
|
||||
DEVICE_RANGES.level.max,
|
||||
);
|
||||
device.phaseLeft = span(device, PROGRAMME_PHASE);
|
||||
}
|
||||
|
||||
/** One draw from the device's own stream, mapped into a span. */
|
||||
function span(device: Runtime, range: Span): number {
|
||||
const drawn = nextRandom(device.rng);
|
||||
device.rng = drawn.state;
|
||||
return range.low + (range.high - range.low) * drawn.value;
|
||||
}
|
||||
|
||||
function initialRuntime(declaration: DeviceDeclaration, seed: number): Runtime {
|
||||
const device: Runtime = {
|
||||
id: declaration.id,
|
||||
kind: declaration.kind,
|
||||
capabilities: [...declaration.capabilities],
|
||||
seatId: declaration.anchor.seatId ?? "",
|
||||
// Off, like everything in `initialDeviceState`. A studio whose hardware
|
||||
// powers itself on because a page was loaded is a studio making a claim.
|
||||
powered: false,
|
||||
muted: false,
|
||||
gainDb: DEVICE_RANGES.gain.initial,
|
||||
volume: DEVICE_RANGES.volume.initial,
|
||||
playing: false,
|
||||
levelDb: FLOOR_DB,
|
||||
targetDb: FLOOR_DB,
|
||||
phaseLeft: 0,
|
||||
rng: streamSeed(seed, declaration.id),
|
||||
busy: false,
|
||||
busyLeft: 0,
|
||||
};
|
||||
// Drawn immediately so that two devices do not change phase on the same
|
||||
// step for the whole run, which is what a zero initial phase would produce.
|
||||
device.busyLeft = span(device, IDLE_PHASE);
|
||||
drawPhase(device);
|
||||
return device;
|
||||
}
|
||||
|
||||
return {
|
||||
current(): DeviceState[] {
|
||||
const observedAt = epochMs + elapsedMs;
|
||||
return devices.map((device) => reading(device, byId.get(device.id), observedAt));
|
||||
},
|
||||
|
||||
command(command: DeviceCommand): void {
|
||||
const declaration = byId.get(command.deviceId);
|
||||
if (declaration === undefined) return;
|
||||
const normalized = normalizeDeviceCommand(declaration, command);
|
||||
if (normalized === null) return;
|
||||
const device = devices.find((d) => d.id === normalized.deviceId);
|
||||
if (device === undefined) return;
|
||||
|
||||
switch (normalized.op) {
|
||||
case "power":
|
||||
device.powered = normalized.value === true;
|
||||
// A speaker that has been switched off is not playing. Leaving
|
||||
// `playing` true would make a powered-down speaker report a
|
||||
// now-playing state, which is the one combination no real box has.
|
||||
if (!device.powered) device.playing = false;
|
||||
break;
|
||||
case "mute":
|
||||
device.muted = normalized.value === true;
|
||||
break;
|
||||
case "playback":
|
||||
// Refused rather than queued on an unpowered speaker: nothing else in
|
||||
// this file turns a device on as a side effect of another command,
|
||||
// and a press that silently powered the room would be a surprise.
|
||||
if (device.powered) device.playing = normalized.value === true;
|
||||
break;
|
||||
case "gain":
|
||||
device.gainDb = normalized.value as number;
|
||||
break;
|
||||
case "volume":
|
||||
device.volume = normalized.value as number;
|
||||
break;
|
||||
}
|
||||
// The meter follows the new state from the next step, not from the next
|
||||
// phase. Without this, muting a mic leaves the meter at speech level for
|
||||
// up to a second, which reads as a button that did not work.
|
||||
device.phaseLeft = 0;
|
||||
},
|
||||
|
||||
stepFixed(): void {
|
||||
elapsedMs += dt * 1000;
|
||||
for (const device of devices) {
|
||||
if (!occupancyProvided) {
|
||||
device.busyLeft -= dt;
|
||||
if (device.busyLeft <= 0) {
|
||||
device.busy = !device.busy;
|
||||
device.busyLeft = span(device, device.busy ? BUSY_PHASE : IDLE_PHASE);
|
||||
}
|
||||
}
|
||||
|
||||
device.phaseLeft -= dt;
|
||||
if (device.phaseLeft <= 0) drawPhase(device);
|
||||
|
||||
const rate = device.targetDb > device.levelDb ? ATTACK_DB_PER_SECOND : RELEASE_DB_PER_SECOND;
|
||||
const move = rate * dt;
|
||||
const gap = device.targetDb - device.levelDb;
|
||||
device.levelDb =
|
||||
Math.abs(gap) <= move ? device.targetDb : device.levelDb + Math.sign(gap) * move;
|
||||
}
|
||||
},
|
||||
|
||||
snapshot(): unknown {
|
||||
const state: Snapshot = {
|
||||
v: SNAPSHOT_VERSION,
|
||||
elapsedMs,
|
||||
occupancyProvided,
|
||||
occupied: [...occupied],
|
||||
// Deep-copied, because a caller holding a snapshot must not be holding a
|
||||
// live view of the state this simulator is about to mutate.
|
||||
devices: devices.map((d) => ({ ...d, capabilities: [...d.capabilities] })),
|
||||
};
|
||||
return state;
|
||||
},
|
||||
|
||||
restore(state: unknown): void {
|
||||
const parsed = readSnapshot(state);
|
||||
if (parsed === null) return;
|
||||
elapsedMs = parsed.elapsedMs;
|
||||
occupancyProvided = parsed.occupancyProvided;
|
||||
occupied = new Set(parsed.occupied);
|
||||
// Only devices this simulator was constructed with, and in *its* order:
|
||||
// a snapshot from a pack with an extra microphone in it must not add one
|
||||
// here, because `current()` is answered against the declarations the
|
||||
// caller validated commands against.
|
||||
devices = devices.map((device) => {
|
||||
const saved = parsed.devices.find((d) => d.id === device.id);
|
||||
return saved === undefined ? device : { ...device, ...saved, id: device.id };
|
||||
});
|
||||
},
|
||||
|
||||
setOccupancy(occupiedSeatIds: readonly string[]): void {
|
||||
occupancyProvided = true;
|
||||
occupied = new Set(occupiedSeatIds);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One runtime as a `DeviceState`, carrying only the readings its declaration
|
||||
* implies.
|
||||
*
|
||||
* A field is omitted rather than zeroed when the device does not declare the
|
||||
* capability behind it, because `undefined` and `0` mean genuinely different
|
||||
* things to every consumer: the panel renders no control for a reading that is
|
||||
* absent, and would render a dead one for a reading that is present and zero.
|
||||
*/
|
||||
function reading(
|
||||
device: Runtime,
|
||||
declaration: DeviceDeclaration | undefined,
|
||||
observedAt: number,
|
||||
): DeviceState {
|
||||
const has = (capability: DeviceCapability): boolean =>
|
||||
declaration === undefined
|
||||
? device.capabilities.includes(capability)
|
||||
: hasCapability(declaration, capability);
|
||||
|
||||
const state: DeviceState = {
|
||||
id: device.id,
|
||||
kind: device.kind,
|
||||
powered: device.powered,
|
||||
observedAt,
|
||||
// Never anything else from this file. Everything above is invented, and the
|
||||
// one field that says so is not a flag a caller may set.
|
||||
synthetic: true,
|
||||
};
|
||||
if (has("mute")) state.muted = device.muted;
|
||||
if (has("gain")) state.gainDb = round(device.gainDb, 2);
|
||||
if (has("level")) state.levelDb = round(device.levelDb, 1);
|
||||
if (has("volume")) state.volume = round(device.volume, 3);
|
||||
if (has("playback")) state.playing = device.playing;
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* A snapshot, if it is one.
|
||||
*
|
||||
* Checked rather than cast, because a snapshot arrives from wherever the caller
|
||||
* kept it — a JSON file, an arena trace, a previous version of this module —
|
||||
* and a half-applied restore is worse than a refused one: the caller believes
|
||||
* it is replaying and is not. `null` means "not mine", and `restore()` leaves
|
||||
* the simulator exactly as it was.
|
||||
*/
|
||||
function readSnapshot(state: unknown): Snapshot | null {
|
||||
if (state === null || typeof state !== "object" || Array.isArray(state)) return null;
|
||||
const raw = state as Partial<Snapshot>;
|
||||
if (raw.v !== SNAPSHOT_VERSION) return null;
|
||||
if (typeof raw.elapsedMs !== "number" || !Number.isFinite(raw.elapsedMs)) return null;
|
||||
if (!Array.isArray(raw.devices)) return null;
|
||||
if (!Array.isArray(raw.occupied)) return null;
|
||||
return {
|
||||
v: SNAPSHOT_VERSION,
|
||||
elapsedMs: raw.elapsedMs,
|
||||
occupancyProvided: raw.occupancyProvided === true,
|
||||
occupied: raw.occupied.filter((id): id is string => typeof id === "string"),
|
||||
devices: raw.devices.filter((d): d is Runtime => d !== null && typeof d === "object"),
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value: number, low: number, high: number): number {
|
||||
return Math.min(high, Math.max(low, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounded on the way out, never in the running state.
|
||||
*
|
||||
* The meter is smoothed at full precision and reported at a tenth of a decibel,
|
||||
* which is both what a meter is read to and what `deviceStateSignature` hashes
|
||||
* — so a reading that has not visibly changed does not republish, and a run
|
||||
* that is bit-identical internally stays bit-identical on the wire.
|
||||
*/
|
||||
function round(value: number, places: number): number {
|
||||
const scale = 10 ** places;
|
||||
return Math.round(value * scale) / scale;
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* What a device *is*, before anything simulates one, draws one or commands one.
|
||||
*
|
||||
* This file is deliberately the first one written in this directory and it
|
||||
* exports no behaviour beyond small pure helpers, because four separate pieces
|
||||
* of the build are held up on the same four questions: what does a pack author
|
||||
* write into a room, what does a live reading look like, what can be commanded,
|
||||
* and how does a consumer know which of those apply to a given device. Packs
|
||||
* authors declarations from this; the device panel builds its controls from it;
|
||||
* the arena builds part of its observation vector from it; the API validates
|
||||
* against it. It therefore imports nothing — no THREE, no interiors, no wire —
|
||||
* and it can be imported by all of them.
|
||||
*
|
||||
* ### The split that matters most
|
||||
*
|
||||
* **A declaration is authored and lives in the office pack. A state never
|
||||
* does.** That is the same line `Presence` draws in `interiors/types.ts`, for
|
||||
* the same reason and with the same consequence: a pack is bundled into the
|
||||
* static build, so everything in it is public by construction, and anything
|
||||
* that must be refused to an anonymous caller has to arrive over the API from a
|
||||
* route that can refuse it. A `DeviceDeclaration` says *there is a microphone
|
||||
* on that desk, here is the hardware, here is what it can be asked to do*, and
|
||||
* publishing that is fine — it is a description of a room. A `DeviceState` says
|
||||
* what the microphone is hearing right now, and that never appears in a file
|
||||
* anybody can download.
|
||||
*
|
||||
* ### Strictly JSON-serialisable
|
||||
*
|
||||
* CONTRACT.md §2, non-negotiable, because a declaration is authored inside an
|
||||
* `Office` and a hand-written pack and one arriving over HTTP have to be the
|
||||
* same thing. No classes, no functions, no `Date`, no THREE types, no getters,
|
||||
* nothing that survives `structuredClone` but not `JSON.stringify`. The helpers
|
||||
* below are functions *about* the data, never fields *in* it.
|
||||
*
|
||||
* ### Two kinds now, five more later, without a new shape
|
||||
*
|
||||
* This build carries a mic and a speaker. A smart light, a thermostat, a door
|
||||
* sensor and a vehicle charger were all drawn on paper against this shape
|
||||
* before it was written, and all four fit: each is an authored declaration
|
||||
* anchored to a prop, a set of capabilities drawn from one closed list, and a
|
||||
* state carrying one reading per capability. Adding one is a new member of
|
||||
* `DeviceKind`, an entry in `CANONICAL_CAPABILITIES`, and — for a reading that
|
||||
* genuinely does not exist yet, such as a thermostat's setpoint — one optional
|
||||
* field on `DeviceState` and one row in `CAPABILITY_READING`. Nothing nests
|
||||
* differently, no authored pack is invalidated, and no consumer has to learn a
|
||||
* second way to ask what a device can do. That is what "fits without a schema
|
||||
* change" means here: the *structure* is fixed, the vocabulary grows.
|
||||
*
|
||||
* Weather is **not** a device and is not modelled as one. It is an observation
|
||||
* of the world that arrives from `/api/v1/weather`, it is nobody's hardware, it
|
||||
* is anchored to no prop, and there is nothing to command. `Environment` in
|
||||
* CONTRACT.md §4 is where it lives and it stays there.
|
||||
*/
|
||||
|
||||
// ---- Identifiers ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A namespaced asset id whose asset is device hardware, like
|
||||
* `"tera:device.mic.desk"`.
|
||||
*
|
||||
* The same string as `AssetId` in `src/assets/kit.ts` and in
|
||||
* `src/interiors/types.ts`, restated here for the reason those two give for
|
||||
* each other: this contract is data, and data should not have to import the
|
||||
* mesh library to be parsed, validated or stored. The narrower name is a
|
||||
* courtesy to the reader — not every `AssetId` is a device, and `assetId` on a
|
||||
* declaration is only ever one that is.
|
||||
*
|
||||
* The convention, which `deviceKindOfAssetId` reads and the API relies on, is
|
||||
* `<namespace>:device.<kind>.<placement>`. A self-hoster's
|
||||
* `acme:device.mic.boom` is a mic to every consumer here without registering
|
||||
* anything with us, which is the same reskinning story `overrides` tells for
|
||||
* furniture.
|
||||
*/
|
||||
export type DeviceAssetId = string;
|
||||
|
||||
// ---- Kinds ----------------------------------------------------------------
|
||||
|
||||
/** The device kinds this build carries. See the header on growing this list. */
|
||||
export type DeviceKind = "mic" | "speaker";
|
||||
|
||||
export const DEVICE_KINDS: readonly DeviceKind[] = ["mic", "speaker"];
|
||||
|
||||
// ---- Capabilities ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One thing a device can do or report — the unit both the UI and the simulator
|
||||
* are built out of.
|
||||
*
|
||||
* A capability is declared per *device*, not per kind, because two microphones
|
||||
* in the same building are genuinely not always the same instrument: the desk
|
||||
* condenser has a gain stage, the ceiling array does not. The device panel
|
||||
* renders one control per declared capability and the simulator advances one
|
||||
* reading per declared capability, so a device that declares nothing it does
|
||||
* not have cannot grow a control that does nothing.
|
||||
*
|
||||
* `level` is the odd one and is why this union is not simply a list of buttons:
|
||||
* it is a *reading only*. You can ask a microphone for its level; you cannot
|
||||
* set it. `DeviceCommandOp` is derived from this union by removing exactly
|
||||
* that, so the two can never drift apart.
|
||||
*/
|
||||
export type DeviceCapability = "power" | "gain" | "mute" | "volume" | "playback" | "level";
|
||||
|
||||
export const DEVICE_CAPABILITIES: readonly DeviceCapability[] = [
|
||||
"power",
|
||||
"gain",
|
||||
"mute",
|
||||
"volume",
|
||||
"playback",
|
||||
"level",
|
||||
];
|
||||
|
||||
/** Everything a `DeviceCommand` may ask for: the capabilities that are not read-only. */
|
||||
export type DeviceCommandOp = Exclude<DeviceCapability, "level">;
|
||||
|
||||
export const DEVICE_COMMAND_OPS: readonly DeviceCommandOp[] = [
|
||||
"power",
|
||||
"gain",
|
||||
"mute",
|
||||
"volume",
|
||||
"playback",
|
||||
];
|
||||
|
||||
/**
|
||||
* What each kind normally has, for a pack author who wants the ordinary answer.
|
||||
*
|
||||
* Advisory, not enforced: a declaration carries its own `capabilities` and that
|
||||
* is what every consumer reads. This is here so that two studios authored
|
||||
* months apart describe the same instrument the same way, which is worth more
|
||||
* than it looks — the arena's observation vector is a fixed width, and a mic
|
||||
* that quietly stopped declaring `gain` in one pack would make two scenarios
|
||||
* incomparable.
|
||||
*/
|
||||
export const CANONICAL_CAPABILITIES: Readonly<Record<DeviceKind, readonly DeviceCapability[]>> = {
|
||||
mic: ["power", "mute", "gain", "level"],
|
||||
speaker: ["power", "volume", "playback"],
|
||||
};
|
||||
|
||||
/**
|
||||
* The reading each capability implies on `DeviceState`.
|
||||
*
|
||||
* This table is the joint that makes the whole design hold. Without it, every
|
||||
* consumer would carry its own `if (kind === "mic") show gain` ladder and they
|
||||
* would disagree the first time a kind was added. With it: the UI renders
|
||||
* `capabilities.map(...)`, the simulator advances `capabilities.map(...)`, the
|
||||
* observation flattens `capabilities.map(...)`, and a new kind is data.
|
||||
*/
|
||||
export const CAPABILITY_READING: Readonly<Record<DeviceCapability, keyof DeviceState>> = {
|
||||
power: "powered",
|
||||
gain: "gainDb",
|
||||
mute: "muted",
|
||||
volume: "volume",
|
||||
playback: "playing",
|
||||
level: "levelDb",
|
||||
};
|
||||
|
||||
// ---- Ranges ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The bounds of a numeric reading, in the units it is reported in.
|
||||
*
|
||||
* Published rather than left to each consumer because three of them need the
|
||||
* same numbers for different reasons and a disagreement would be silent: the
|
||||
* panel draws a slider between them, the API clamps a command into them, and
|
||||
* the arena normalises an observation by them. `initial` is the value at rest —
|
||||
* what an unobserved or freshly-powered device reports — which is what lets
|
||||
* `initialDeviceState` be a pure function of a declaration.
|
||||
*/
|
||||
export interface DeviceRange {
|
||||
min: number;
|
||||
max: number;
|
||||
/** The value at rest, before anything has been observed or commanded. */
|
||||
initial: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export const DEVICE_RANGES: Readonly<Record<"gain" | "volume" | "level", DeviceRange>> = {
|
||||
/** Preamp gain on a desk condenser. Below zero is pad, not silence. */
|
||||
gain: { min: -12, max: 36, initial: 12, unit: "dB" },
|
||||
/** Output level, as a fraction. Not decibels: this is the knob, not the meter. */
|
||||
volume: { min: 0, max: 1, initial: 0.35, unit: "fraction" },
|
||||
/**
|
||||
* Programme level on the meter, full-scale referenced. At rest it sits at the
|
||||
* floor, because a microphone nobody has switched on is not hearing −20 dBFS
|
||||
* of anything.
|
||||
*/
|
||||
level: { min: -60, max: 0, initial: -60, unit: "dBFS" },
|
||||
};
|
||||
|
||||
// ---- Provenance -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Where a device's readings come from, as a closed vocabulary.
|
||||
*
|
||||
* A closed union rather than free text, and the same idea as the per-row
|
||||
* provenance the marker gate enforces under CONTRACT.md §8: a value the server
|
||||
* can check is worth more than a sentence it can only pass through. What is
|
||||
* being guarded is different but the failure is identical — something presented
|
||||
* as observed when it was invented, or as invented when it was observed.
|
||||
*
|
||||
* - `simulated` — a deterministic state machine. Everything this build ships.
|
||||
* - `operator-authored` — a fixed value an operator wrote down. Honest, static.
|
||||
* - `first-party-sensor` — a real reading from the operator's own hardware,
|
||||
* which is the door a Home Assistant bridge comes through later.
|
||||
*
|
||||
* `synthetic` on `DeviceState` is the runtime half of the same statement, and
|
||||
* it is the one a viewer is shown.
|
||||
*/
|
||||
export type DeviceProvenance = "simulated" | "operator-authored" | "first-party-sensor";
|
||||
|
||||
export const DEVICE_PROVENANCE: readonly DeviceProvenance[] = [
|
||||
"simulated",
|
||||
"operator-authored",
|
||||
"first-party-sensor",
|
||||
];
|
||||
|
||||
// ---- The authored declaration ---------------------------------------------
|
||||
|
||||
/** Metres, in the anchor prop's own frame. */
|
||||
export interface DeviceOffset {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a device physically is — by reference, never by restatement.
|
||||
*
|
||||
* `propId` is required, and that is the whole design of this type: a device is
|
||||
* a piece of hardware, hardware sits on something, and the something is already
|
||||
* placed in the floorplan with a position and a rotation that `Plan` resolves.
|
||||
* Giving the declaration its own world coordinate would create a second answer
|
||||
* to "where is the mic", and the two would disagree the first time somebody
|
||||
* nudged the desk. So the position is *derived*: the anchor prop's transform,
|
||||
* plus an optional `offset` in the prop's own frame for the few centimetres
|
||||
* between the desk's origin and the top of the mic stand.
|
||||
*
|
||||
* The same argument `Prop.seat` makes for chairs, and the same one CONTRACT.md
|
||||
* makes about packs importing their site rather than restating its coordinates.
|
||||
*
|
||||
* `roomId` and `seatId` are addresses, not positions. They are what lets a
|
||||
* consumer ask "is anybody sitting where this mic is pointed" — which the arena
|
||||
* does, and which is the difference between a hot mic and a wasted one.
|
||||
*/
|
||||
export interface DeviceAnchor {
|
||||
levelId: string;
|
||||
/** The authored prop that *is* this device's hardware. */
|
||||
propId: string;
|
||||
/** The room the prop stands in, when a consumer wants it without a lookup. */
|
||||
roomId?: string;
|
||||
/** The seat this device serves, if it serves one. */
|
||||
seatId?: string;
|
||||
/** Metres from the anchor prop's origin, in the prop's frame. */
|
||||
offset?: DeviceOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* One device, as a pack author writes it.
|
||||
*
|
||||
* Authored, public, and inert: nothing here is a reading and nothing here
|
||||
* changes. `Plan` resolves it the way it resolves every other authored address
|
||||
* — dropping what it cannot bind and recording the problem rather than throwing
|
||||
* — so one typo in a device id does not stop an office from opening.
|
||||
*/
|
||||
export interface DeviceDeclaration {
|
||||
/** Unique within the office. Referenced by every `DeviceState` and command. */
|
||||
id: string;
|
||||
kind: DeviceKind;
|
||||
/** Shown in the panel. "Desk mic", not "tera:device.mic.desk". */
|
||||
label: string;
|
||||
/** The hardware to build. Its kind segment must agree with `kind`. */
|
||||
assetId: DeviceAssetId;
|
||||
anchor: DeviceAnchor;
|
||||
/** What this particular unit can do. Usually `CANONICAL_CAPABILITIES[kind]`. */
|
||||
capabilities: readonly DeviceCapability[];
|
||||
provenance: DeviceProvenance;
|
||||
/**
|
||||
* One sentence shown to a viewer next to the readings.
|
||||
*
|
||||
* Mandatory, and validated: a `simulated` device must say so in words a
|
||||
* person reading the panel would understand, exactly as
|
||||
* `RobotOperationsDefinition.disclosure` is checked to contain "simulat"
|
||||
* before any of it is drawn. A studio that shows a live-looking level meter
|
||||
* without saying where the level came from is making a claim about a real
|
||||
* room, and this is the field that stops it.
|
||||
*/
|
||||
disclosure: string;
|
||||
}
|
||||
|
||||
// ---- The live state -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* What a device is doing right now. **Never authored, never in a pack.**
|
||||
*
|
||||
* Every reading past `powered` is optional because a device only carries the
|
||||
* readings its capabilities imply — see `CAPABILITY_READING`. A consumer that
|
||||
* wants to know whether a field is meaningful asks the declaration, not the
|
||||
* state: `undefined` here means "this device has no such reading", which is a
|
||||
* different thing from zero.
|
||||
*/
|
||||
export interface DeviceState {
|
||||
id: string;
|
||||
kind: DeviceKind;
|
||||
/** Every device has this one. A device with no power state is a prop. */
|
||||
powered: boolean;
|
||||
muted?: boolean;
|
||||
gainDb?: number;
|
||||
/** Programme level, dBFS. A reading only — no command sets it. */
|
||||
levelDb?: number;
|
||||
volume?: number;
|
||||
playing?: boolean;
|
||||
/** Epoch milliseconds. */
|
||||
observedAt: number;
|
||||
/**
|
||||
* Was this reading invented?
|
||||
*
|
||||
* `true` for everything this build ships, and it is not a flag anybody may
|
||||
* default to `false` for convenience. The panel shows it, the wire carries
|
||||
* it, and `DevicesBody.synthetic` is the same statement one level up.
|
||||
*/
|
||||
synthetic: boolean;
|
||||
}
|
||||
|
||||
// ---- Commands -------------------------------------------------------------
|
||||
|
||||
/** Booleans for the switches, numbers for the knobs. Nothing else is a value. */
|
||||
export type DeviceCommandValue = boolean | number;
|
||||
|
||||
/**
|
||||
* One instruction for one device.
|
||||
*
|
||||
* Deliberately tiny and deliberately not batched. It travels in a POST body of
|
||||
* its own — never in a read response, because a shared cache that replayed a
|
||||
* GET which turned a microphone on is precisely what the fail-closed
|
||||
* `Cache-Control` default in CONTRACT.md §5 exists to prevent.
|
||||
*
|
||||
* `value` is absent only for an op that carries no argument, and today there is
|
||||
* none: `power`, `mute` and `playback` take a boolean, `gain` and `volume` take
|
||||
* a number. It stays optional because a future `playback: "next"`-shaped op
|
||||
* would want it to be, and because a command that arrives without one must be
|
||||
* rejected by `normalizeDeviceCommand` rather than by the type system alone —
|
||||
* the wire can send anything.
|
||||
*/
|
||||
export interface DeviceCommand {
|
||||
deviceId: string;
|
||||
op: DeviceCommandOp;
|
||||
value?: DeviceCommandValue;
|
||||
}
|
||||
|
||||
// ---- Helpers --------------------------------------------------------------
|
||||
//
|
||||
// Pure, total, and free of I/O. They exist because the alternative is four
|
||||
// consumers each writing their own slightly different version of the same
|
||||
// check, which is how a validation gate ends up being enforced in three places
|
||||
// and skipped in the fourth.
|
||||
|
||||
export function isDeviceKind(value: unknown): value is DeviceKind {
|
||||
return typeof value === "string" && (DEVICE_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isDeviceCapability(value: unknown): value is DeviceCapability {
|
||||
return typeof value === "string" && (DEVICE_CAPABILITIES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isDeviceCommandOp(value: unknown): value is DeviceCommandOp {
|
||||
return typeof value === "string" && (DEVICE_COMMAND_OPS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isDeviceProvenance(value: unknown): value is DeviceProvenance {
|
||||
return typeof value === "string" && (DEVICE_PROVENANCE as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind an asset id claims to be, or `null` if it does not claim to be a
|
||||
* device at all.
|
||||
*
|
||||
* `"tera:device.mic.desk"` → `"mic"`. Namespace-agnostic, so a self-hoster's
|
||||
* `acme:device.speaker.shelf` reads as a speaker without registering anything.
|
||||
*
|
||||
* This is what lets the API check that a declaration's `assetId` and `kind`
|
||||
* agree, and check it *server-side* against the resolved plan rather than
|
||||
* trusting a body — the same move `officeHasMediaBinding()` makes for screens.
|
||||
* A mic declaration pointing at a speaker's hardware is not a rendering bug, it
|
||||
* is a command routed to the wrong instrument.
|
||||
*/
|
||||
export function deviceKindOfAssetId(assetId: DeviceAssetId): DeviceKind | null {
|
||||
const path = assetId.includes(":") ? assetId.slice(assetId.indexOf(":") + 1) : assetId;
|
||||
const segments = path.split(".");
|
||||
if (segments[0] !== "device") return null;
|
||||
const kind = segments[1];
|
||||
return isDeviceKind(kind) ? kind : null;
|
||||
}
|
||||
|
||||
export function hasCapability(
|
||||
declaration: Pick<DeviceDeclaration, "capabilities">,
|
||||
capability: DeviceCapability,
|
||||
): boolean {
|
||||
return declaration.capabilities.includes(capability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything wrong with an authored declaration, as sentences. Empty is good.
|
||||
*
|
||||
* **It never throws**, which is the whole point: `Plan` resolves authored data
|
||||
* by dropping what it cannot use and recording why, so that one bad device does
|
||||
* not cost a viewer the building. Compare `resolveRobotOperations`, which does
|
||||
* throw — that is authored *behaviour*, resolved once at build time by whoever
|
||||
* wrote it, and a robot station with no floor under it is a bug in the pack. A
|
||||
* device is authored *furniture*, and furniture degrades.
|
||||
*/
|
||||
export function validateDeviceDeclaration(declaration: DeviceDeclaration): string[] {
|
||||
const problems: string[] = [];
|
||||
const id = declaration.id === "" ? "<unnamed device>" : declaration.id;
|
||||
|
||||
if (declaration.id === "") problems.push("device has no id");
|
||||
if (!isDeviceKind(declaration.kind)) problems.push(`device ${id} has an unknown kind`);
|
||||
if (declaration.label.trim() === "") problems.push(`device ${id} has no label`);
|
||||
if (declaration.anchor.levelId === "") problems.push(`device ${id} names no level`);
|
||||
if (declaration.anchor.propId === "") {
|
||||
problems.push(`device ${id} is anchored to no prop, and a device with no hardware is fiction`);
|
||||
}
|
||||
|
||||
const assetKind = deviceKindOfAssetId(declaration.assetId);
|
||||
if (assetKind === null) {
|
||||
problems.push(`device ${id} names ${declaration.assetId}, which is not device hardware`);
|
||||
} else if (assetKind !== declaration.kind) {
|
||||
problems.push(
|
||||
`device ${id} is declared a ${declaration.kind} but its asset ${declaration.assetId} is a ` +
|
||||
`${assetKind}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (declaration.capabilities.length === 0) {
|
||||
problems.push(`device ${id} declares no capabilities and could do nothing`);
|
||||
}
|
||||
for (const capability of declaration.capabilities) {
|
||||
if (!isDeviceCapability(capability)) {
|
||||
problems.push(`device ${id} declares an unknown capability ${String(capability)}`);
|
||||
}
|
||||
}
|
||||
if (!isDeviceProvenance(declaration.provenance)) {
|
||||
problems.push(`device ${id} has an unknown provenance`);
|
||||
}
|
||||
|
||||
// The same check `resolveRobotOperations` makes, for the same reason: a
|
||||
// simulated reading displayed without the word is a claim about a real room.
|
||||
if (declaration.disclosure.trim() === "") {
|
||||
problems.push(`device ${id} has no disclosure`);
|
||||
} else if (
|
||||
declaration.provenance === "simulated" &&
|
||||
!declaration.disclosure.toLowerCase().includes("simulat")
|
||||
) {
|
||||
problems.push(`device ${id} is simulated and its disclosure does not say so`);
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state a declaration implies before anything has been observed.
|
||||
*
|
||||
* Powered off, every reading at rest, and `synthetic: true` whatever the
|
||||
* declaration's provenance says — because nothing has been observed yet, and a
|
||||
* state that claimed otherwise would be a lie told by a constructor.
|
||||
*/
|
||||
export function initialDeviceState(
|
||||
declaration: DeviceDeclaration,
|
||||
observedAt: number,
|
||||
): DeviceState {
|
||||
const state: DeviceState = {
|
||||
id: declaration.id,
|
||||
kind: declaration.kind,
|
||||
powered: false,
|
||||
observedAt,
|
||||
synthetic: true,
|
||||
};
|
||||
if (hasCapability(declaration, "mute")) state.muted = false;
|
||||
if (hasCapability(declaration, "gain")) state.gainDb = DEVICE_RANGES.gain.initial;
|
||||
if (hasCapability(declaration, "level")) state.levelDb = DEVICE_RANGES.level.initial;
|
||||
if (hasCapability(declaration, "volume")) state.volume = DEVICE_RANGES.volume.initial;
|
||||
if (hasCapability(declaration, "playback")) state.playing = false;
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* A command this declaration will actually accept, clamped — or `null`.
|
||||
*
|
||||
* The one validator, run by everybody: the panel before it sends, the API
|
||||
* before it mutates, the simulator before it applies. `null` is a refusal and
|
||||
* the caller decides what that means — a 400 on the route, a no-op in the UI.
|
||||
* A returned command is a fresh object, so a caller cannot hold a reference to
|
||||
* something the store is about to mutate.
|
||||
*
|
||||
* Clamping rather than refusing an out-of-range number is deliberate and is the
|
||||
* one place this is lenient: a slider that reports 1.0000000002 is not an
|
||||
* attack, and `TERA_ADSB_RADIUS_NM` has the same disposition for the same
|
||||
* reason. A *wrong type* is refused, because that is a caller who has
|
||||
* misunderstood the contract rather than one who overshot.
|
||||
*/
|
||||
export function normalizeDeviceCommand(
|
||||
declaration: DeviceDeclaration,
|
||||
command: DeviceCommand,
|
||||
): DeviceCommand | null {
|
||||
if (command.deviceId !== declaration.id) return null;
|
||||
if (!isDeviceCommandOp(command.op)) return null;
|
||||
if (!hasCapability(declaration, command.op)) return null;
|
||||
|
||||
if (command.op === "gain" || command.op === "volume") {
|
||||
const range = DEVICE_RANGES[command.op];
|
||||
if (typeof command.value !== "number" || !Number.isFinite(command.value)) return null;
|
||||
return {
|
||||
deviceId: declaration.id,
|
||||
op: command.op,
|
||||
value: Math.min(range.max, Math.max(range.min, command.value)),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof command.value !== "boolean") return null;
|
||||
return { deviceId: declaration.id, op: command.op, value: command.value };
|
||||
}
|
||||
|
||||
/**
|
||||
* A string that changes exactly when something a viewer would notice changes.
|
||||
*
|
||||
* `observedAt` is left out on purpose. It moves on every poll of an unchanged
|
||||
* room and would defeat the whole comparison — which is the same trap, and the
|
||||
* same answer, as the presence watch in `adapters/http.ts`. `watchDevices`
|
||||
* publishes on a change of this and on nothing else.
|
||||
*/
|
||||
export function deviceStateSignature(states: readonly DeviceState[]): string {
|
||||
return states
|
||||
.map((s) =>
|
||||
[
|
||||
s.id,
|
||||
s.powered ? "1" : "0",
|
||||
s.muted === undefined ? "" : s.muted ? "1" : "0",
|
||||
s.gainDb === undefined ? "" : s.gainDb.toFixed(2),
|
||||
s.levelDb === undefined ? "" : s.levelDb.toFixed(1),
|
||||
s.volume === undefined ? "" : s.volume.toFixed(3),
|
||||
s.playing === undefined ? "" : s.playing ? "1" : "0",
|
||||
s.synthetic ? "1" : "0",
|
||||
].join("|"),
|
||||
)
|
||||
.join(";");
|
||||
}
|
||||
+84
-26
@@ -556,12 +556,31 @@ const NIGHT_FLOOR_HORIZON = 0x16203a;
|
||||
* a roof is lighter than a wall; and the keyframe table's token sidelight
|
||||
* survives at full strength on a moonless night (see `applyNight`) so the hills
|
||||
* still have a lit side and a dark one.
|
||||
*
|
||||
* ### Why these two numbers moved when tone mapping arrived
|
||||
*
|
||||
* They went up by a third, and the ratios between all five did not change,
|
||||
* which is the point. `stage.ts` now runs ACES filmic instead of a bare
|
||||
* `saturate()`, and ACES has a toe: it is steeper than a plain sRGB encode
|
||||
* everywhere below about linear 0.1, which is the entire range a moonless night
|
||||
* occupies. A ground reading that displayed at 0.212 under the old renderer
|
||||
* came out at 0.174 under the new one for the same physical light — an 18% loss
|
||||
* concentrated exactly where this file has the least to give.
|
||||
*
|
||||
* A third more linear light puts it back at 0.240 and leaves the *shape* of the
|
||||
* night alone, because every one of the five terms was scaled by the same
|
||||
* factor. That is deliberate: the comment above spends four paragraphs on the
|
||||
* ratios between them, and a re-tune that fixed the brightness by flattening the
|
||||
* sky-to-ground gradient would have thrown away the argument to keep the number.
|
||||
* The city's own lit windows keep their four-to-five-times lead as well — they
|
||||
* are emissive, they were clipping at 1.0 before, and under a shoulder they now
|
||||
* separate rather than all rendering as the same white.
|
||||
*/
|
||||
const NIGHT_FLOOR_HEMI_SKY = 0x354c88;
|
||||
const NIGHT_FLOOR_HEMI_GROUND = 0x1f2740;
|
||||
const NIGHT_FLOOR_HEMI_INTENSITY = 0.78;
|
||||
const NIGHT_FLOOR_HEMI_INTENSITY = 1.05;
|
||||
const NIGHT_FLOOR_AMBIENT = 0x47557f;
|
||||
const NIGHT_FLOOR_AMBIENT_INTENSITY = 0.22;
|
||||
const NIGHT_FLOOR_AMBIENT_INTENSITY = 0.3;
|
||||
|
||||
/**
|
||||
* The same sky, and the same fill, with a full moon in it.
|
||||
@@ -636,6 +655,45 @@ interface Keyframe extends Rig {
|
||||
* so a city that declares a paler or bluer sky keeps it at noon and still gets
|
||||
* the same dusk as everywhere else — dusk is not regional in any way this
|
||||
* renderer can see.
|
||||
*
|
||||
* ### The intensities are tuned against a tone curve, and it is ACES
|
||||
*
|
||||
* Read the three intensity columns as a set, because they were re-tuned as one
|
||||
* when `stage.ts` stopped rendering through `NoToneMapping`. The old numbers
|
||||
* were not wrong — they were correct for a renderer that hard-clipped at linear
|
||||
* 1.0, and being correct for that is exactly what makes them wrong now.
|
||||
*
|
||||
* Two things changed, in opposite directions, and both are visible in the table.
|
||||
*
|
||||
* **Below the horizon the fills went up by about a third.** ACES's toe is
|
||||
* steeper than a plain sRGB encode everywhere under about linear 0.1, which is
|
||||
* the whole of the range a night frame lives in. See `NIGHT_FLOOR_HEMI_SKY` for
|
||||
* the arithmetic; the three night stops move with the floor because they sit
|
||||
* just under it and it is the floor that binds.
|
||||
*
|
||||
* **Above the horizon the key went up and the fill came down.** That is not a
|
||||
* brightness change, it is a contrast change, and it is the whole reason to have
|
||||
* done this. Under a clipping renderer a sunlit 0.7-albedo wall and a sunlit
|
||||
* 0.95-albedo wall were both exactly white, so the only way to make a daylit
|
||||
* scene read as *lit* was to pour in fill until the shadows came up to meet the
|
||||
* blown highlights — which is a description of a flat picture. With a shoulder
|
||||
* over the top, a sun at 2.6 puts a lit face at about 0.88 display and its own
|
||||
* shaded face at about 0.52, and the difference between them is the modelling
|
||||
* that was missing. So `hemiIntensity` loses roughly a tenth and
|
||||
* `ambientIntensity` roughly a quarter at the two day stops.
|
||||
*
|
||||
* The fill can afford that for a second reason: it is no longer the only
|
||||
* indirect light in the scene. `environmentRig.ts` derives a real sky
|
||||
* environment from this same `LightingState` and puts it on `Scene.environment`,
|
||||
* so the diffuse bounce the hemisphere light was standing in for now arrives
|
||||
* from something with a direction and a horizon in it. Trimming here and adding
|
||||
* there is one move, not two.
|
||||
*
|
||||
* The sky columns are untouched, and that is not an oversight. Three marks the
|
||||
* background mesh `toneMapped = false` for an sRGB-transfer texture and mixes
|
||||
* fog after the tone map from an already-encoded uniform, so `skyTop`,
|
||||
* `skyHorizon` and the fog colour are displayed exactly as written here. Only
|
||||
* the lit geometry moved, so only the light was re-tuned.
|
||||
*/
|
||||
function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
|
||||
return [
|
||||
@@ -660,36 +718,36 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
|
||||
skyTop: 0x05070f,
|
||||
skyHorizon: 0x0b1120,
|
||||
sunColor: 0x44558a,
|
||||
sunIntensity: 0.16,
|
||||
sunIntensity: 0.22,
|
||||
hemiSky: 0x2f447e,
|
||||
hemiGround: 0x1b2234,
|
||||
hemiIntensity: 0.55,
|
||||
hemiIntensity: 0.74,
|
||||
ambientColor: 0x414e78,
|
||||
ambientIntensity: 0.16,
|
||||
ambientIntensity: 0.22,
|
||||
},
|
||||
{
|
||||
elevation: -12,
|
||||
skyTop: 0x080d1e,
|
||||
skyHorizon: 0x141d38,
|
||||
sunColor: 0x51629b,
|
||||
sunIntensity: 0.19,
|
||||
sunIntensity: 0.26,
|
||||
hemiSky: 0x32477d,
|
||||
hemiGround: 0x1d2437,
|
||||
hemiIntensity: 0.57,
|
||||
hemiIntensity: 0.77,
|
||||
ambientColor: 0x424f7a,
|
||||
ambientIntensity: 0.17,
|
||||
ambientIntensity: 0.23,
|
||||
},
|
||||
{
|
||||
elevation: -6,
|
||||
skyTop: 0x101a3a,
|
||||
skyHorizon: 0x2b3560,
|
||||
sunColor: 0x66699a,
|
||||
sunIntensity: 0.26,
|
||||
sunIntensity: 0.35,
|
||||
hemiSky: 0x3c558c,
|
||||
hemiGround: 0x23293c,
|
||||
hemiIntensity: 0.6,
|
||||
hemiIntensity: 0.81,
|
||||
ambientColor: 0x485389,
|
||||
ambientIntensity: 0.19,
|
||||
ambientIntensity: 0.26,
|
||||
},
|
||||
{
|
||||
// The sun on the horizon. Warm at the bottom, cold at the top, and the
|
||||
@@ -698,36 +756,36 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
|
||||
skyTop: 0x2a4275,
|
||||
skyHorizon: 0x9a6a63,
|
||||
sunColor: 0xc2795c,
|
||||
sunIntensity: 0.45,
|
||||
sunIntensity: 0.58,
|
||||
hemiSky: 0x4a5f8c,
|
||||
hemiGround: 0x2a2a2c,
|
||||
hemiIntensity: 0.6,
|
||||
hemiIntensity: 0.72,
|
||||
ambientColor: 0x6a6a80,
|
||||
ambientIntensity: 0.2,
|
||||
ambientIntensity: 0.25,
|
||||
},
|
||||
{
|
||||
elevation: 3,
|
||||
skyTop: 0x4d76ac,
|
||||
skyHorizon: 0xdba078,
|
||||
sunColor: 0xff9c56,
|
||||
sunIntensity: 1.25,
|
||||
sunIntensity: 1.45,
|
||||
hemiSky: 0x86a6cc,
|
||||
hemiGround: 0x54503f,
|
||||
hemiIntensity: 0.85,
|
||||
hemiIntensity: 0.9,
|
||||
ambientColor: 0xffd9b8,
|
||||
ambientIntensity: 0.24,
|
||||
ambientIntensity: 0.26,
|
||||
},
|
||||
{
|
||||
elevation: 8,
|
||||
skyTop: 0x6b96c6,
|
||||
skyHorizon: 0xebc9a4,
|
||||
sunColor: 0xffc489,
|
||||
sunIntensity: 1.8,
|
||||
sunIntensity: 2.0,
|
||||
hemiSky: 0xb2cbe4,
|
||||
hemiGround: 0x6a6752,
|
||||
hemiIntensity: 0.98,
|
||||
hemiIntensity: 0.95,
|
||||
ambientColor: 0xffe7cf,
|
||||
ambientIntensity: 0.28,
|
||||
ambientIntensity: 0.26,
|
||||
},
|
||||
{
|
||||
// Ordinary daylight, and the one stop that reproduces `cityDaylight()`.
|
||||
@@ -735,12 +793,12 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
|
||||
skyTop: dayTop,
|
||||
skyHorizon: dayHorizon,
|
||||
sunColor: 0xfff3e0,
|
||||
sunIntensity: 2.1,
|
||||
sunIntensity: 2.35,
|
||||
hemiSky: 0xdcecf7,
|
||||
hemiGround: 0x6b6f5e,
|
||||
hemiIntensity: 1.05,
|
||||
hemiIntensity: 0.92,
|
||||
ambientColor: 0xffffff,
|
||||
ambientIntensity: 0.32,
|
||||
ambientIntensity: 0.24,
|
||||
},
|
||||
{
|
||||
// A high sun. The zenith deepens — less air to scatter through overhead —
|
||||
@@ -749,12 +807,12 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
|
||||
skyTop: mixHex(dayTop, 0x2f6bb0, 0.35),
|
||||
skyHorizon: mixHex(dayHorizon, 0xffffff, 0.2),
|
||||
sunColor: 0xfffdf6,
|
||||
sunIntensity: 2.35,
|
||||
sunIntensity: 2.6,
|
||||
hemiSky: 0xe6f2fb,
|
||||
hemiGround: 0x74786a,
|
||||
hemiIntensity: 1.1,
|
||||
hemiIntensity: 0.95,
|
||||
ambientColor: 0xffffff,
|
||||
ambientIntensity: 0.3,
|
||||
ambientIntensity: 0.22,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* The environment map: what every reflective surface in the world is looking at.
|
||||
*
|
||||
* ## What this fixes
|
||||
*
|
||||
* Before this file existed there was no `Scene.environment` anywhere in `src/`,
|
||||
* and that single absence is the whole explanation for the most common
|
||||
* complaint about how this product looks. A `MeshStandardMaterial` with
|
||||
* `metalness > 0` has, by construction, almost no diffuse term — metal does not
|
||||
* scatter, it *reflects* — so with nothing to reflect it renders as a flat dark
|
||||
* grey wash and reads as painted plastic. The library has eleven such roles:
|
||||
* `metalTrim` at 0.85, `chairBase` at 0.75, `glazingFrame` and `deskFrame` at
|
||||
* 0.7 and 0.65, `partitionFrame`, `deviceMesh`, and the five Model X materials.
|
||||
* All of them were being asked to look like metal with a black room around them.
|
||||
*
|
||||
* The codebase already documents this against itself in two places, which is
|
||||
* how you know it is not a matter of taste. `office/optimus.ts:62` abandoned a
|
||||
* whole material role over it, and `vehicles/modelX.ts:96` fakes an `emissive`
|
||||
* term on the car paint to stand in for the sky bounce that was missing. Both
|
||||
* are workarounds for this file not existing.
|
||||
*
|
||||
* ## What it is allowed to do
|
||||
*
|
||||
* **It constructs no light. Not one, of any type.** CONTRACT.md §4 makes
|
||||
* `Atmosphere` the sole light owner and gives the data exactly one direction to
|
||||
* flow: an `Environment` is observed, `atmosphere.apply()` turns it into a
|
||||
* `LightingState`, a scene applies that, and nothing writes back. This rig sits
|
||||
* at the end of that same one-way street — it is handed the `LightingState`
|
||||
* that has *already been decided* and derives an environment from it. It never
|
||||
* decides anything about the light itself, so there is no second owner and
|
||||
* nothing to keep in sync.
|
||||
*
|
||||
* That constraint is also why there is no `RoomEnvironment` import here.
|
||||
* Three's version is a perfectly good office environment and the office path
|
||||
* below is recognisably descended from it, but it is a fixed room: it does not
|
||||
* know the hour, the weather, or which way the building faces, so an office at
|
||||
* 4 p.m. in August would reflect the same neutral studio light as one at
|
||||
* midnight in January. Deriving the room from `LightingState` instead costs
|
||||
* about forty lines and means the reflections move with the sun like everything
|
||||
* else in the scene does.
|
||||
*
|
||||
* ## Procedural, like everything else
|
||||
*
|
||||
* No `.hdr`, no `.exr`, no cubemap faces on disk. The city environment is a
|
||||
* `DataTexture` filled in a double loop from the same sky colours the
|
||||
* background gradient uses, and the office environment is nine untextured
|
||||
* quads. `scripts/check-no-binaries.mjs` stays satisfied for the same reason
|
||||
* `textures.ts` keeps it satisfied — the art is the code (CONTRACT.md §3).
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { LightingState } from "./types.ts";
|
||||
|
||||
/** Which of the two worlds is being reflected. */
|
||||
export type EnvironmentKind = "city" | "office";
|
||||
|
||||
export interface EnvironmentRig {
|
||||
apply(scene: THREE.Scene, lighting: LightingState, kind: "city" | "office"): void;
|
||||
/**
|
||||
* Forget a scene that is being torn down.
|
||||
*
|
||||
* `apply` records every scene it has written to, so that a rebuilt
|
||||
* environment can be pushed to all of them at once rather than only to the
|
||||
* one that happened to ask. That ledger is a strong reference, and a page
|
||||
* that switches city three times disposes three scenes the rig would
|
||||
* otherwise hold forever — the whole graph, because `createScene`'s dispose
|
||||
* frees geometries and materials without clearing its children. This is the
|
||||
* matching call, and a disposing scene is the only correct caller: it nulls
|
||||
* `scene.environment` and drops the entry. The cached PMREM targets are
|
||||
* shared across every scene of that kind and are **not** freed here; that is
|
||||
* `dispose()`'s job, and it belongs to whoever owns the Stage.
|
||||
*/
|
||||
release(scene: THREE.Scene): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equirectangular source resolution, in texels.
|
||||
*
|
||||
* Small on purpose. PMREM takes a cube face of `width / 4`, so 256 gives a
|
||||
* 64² cubemap, and the thing being encoded is a vertical gradient with one
|
||||
* bright lobe in it — there is no detail here that 512 would preserve and 256
|
||||
* would lose. What that buys is the right to rebuild often: the whole cost of
|
||||
* a sky change is 32k texels of CPU fill, a 128 KB upload and a handful of
|
||||
* quarter-megapixel GPU passes, which is affordable several times a second and
|
||||
* therefore affordable during a time-lapse.
|
||||
*/
|
||||
const EQUIRECT_WIDTH = 256;
|
||||
const EQUIRECT_HEIGHT = 128;
|
||||
|
||||
/**
|
||||
* How much of the sky's brightness the environment carries, against the
|
||||
* hemisphere light that was already carrying all of it.
|
||||
*
|
||||
* This number exists because the environment map and the hemisphere light are
|
||||
* two descriptions of the same physical thing — light arriving from the sky —
|
||||
* and applying both at full strength counts it twice. Three's
|
||||
* `getIBLIrradiance` returns `PI * radiance`, and `BRDF_Lambert` divides by PI,
|
||||
* so an environment of uniform radiance R contributes exactly `albedo * R` to a
|
||||
* diffuse surface, against `albedo * intensity * colour / PI` from the
|
||||
* hemisphere. At 0.12 the environment lands at roughly a fifth of the
|
||||
* hemisphere's diffuse contribution, and the day stops in `atmosphere.ts` gave
|
||||
* up about a tenth of `hemiIntensity` and a quarter of `ambientIntensity` to
|
||||
* make room for it. Those two edits are one decision and were made together.
|
||||
*
|
||||
* The reason it is a minority share rather than a replacement: a PMREM
|
||||
* irradiance probe has no shadow term and no local occlusion, so raising it
|
||||
* until it *is* the sky fill would light the inside of a closed room as
|
||||
* brightly as the roof of it. The hemisphere light has the same flaw, but it
|
||||
* is the flaw the rest of the world is already tuned against.
|
||||
*/
|
||||
const SKY_RADIANCE_SHARE = 0.12;
|
||||
|
||||
/**
|
||||
* The sun's own disc, as a radiance and a tightness.
|
||||
*
|
||||
* Kept narrow and kept modest. A cosine power of 400 is a lobe about four
|
||||
* degrees across, which contributes almost nothing to the irradiance integral —
|
||||
* so the directional light `Atmosphere` already owns is not double-counted —
|
||||
* while giving every smooth metal and every pane of glass a specular highlight
|
||||
* with a *direction* in it. That highlight is most of what separates "this is
|
||||
* metal" from "this is grey", and it is the one thing a uniform ambient can
|
||||
* never supply.
|
||||
*/
|
||||
const SUN_LOBE_RADIANCE = 1.2;
|
||||
const SUN_LOBE_TIGHTNESS = 400;
|
||||
const SUN_GLOW_RADIANCE = 0.22;
|
||||
const SUN_GLOW_TIGHTNESS = 9;
|
||||
|
||||
/**
|
||||
* How much of the sky bounces back off the ground.
|
||||
*
|
||||
* The lower half of the sphere is not black — it is the city, or the floor, lit
|
||||
* by the same sun. `LightingState.hemisphere.ground` is the number
|
||||
* `Atmosphere` already publishes for exactly this and it is reused rather than
|
||||
* re-derived, so a hazy afternoon greys the underside of a car and the top of
|
||||
* it together.
|
||||
*/
|
||||
const GROUND_RADIANCE_SHARE = 0.09;
|
||||
|
||||
/**
|
||||
* The office room, in metres. A generous meeting room rather than a studio,
|
||||
* because the reflection of a room reads as the *proportions* of a room and a
|
||||
* cube reads as a lift.
|
||||
*/
|
||||
const ROOM_WIDTH = 9;
|
||||
const ROOM_DEPTH = 7;
|
||||
const ROOM_HEIGHT = 3.2;
|
||||
|
||||
export function createEnvironmentRig(renderer: THREE.WebGLRenderer): EnvironmentRig {
|
||||
/**
|
||||
* Everything below is built on first use and not before.
|
||||
*
|
||||
* A `PMREMGenerator` compiles three shader programs the moment it is asked to
|
||||
* do anything, and a page that opens on a city board should not pay for the
|
||||
* office's blur chain until somebody walks into an office. `createStage`
|
||||
* builds one rig for the life of the page (see the note in `stage.ts` about
|
||||
* why there is exactly one renderer), so "first use" here means once.
|
||||
*/
|
||||
let pmrem: THREE.PMREMGenerator | null = null;
|
||||
let equirect: THREE.DataTexture | null = null;
|
||||
let room: RoomProbe | null = null;
|
||||
|
||||
/** One cached PMREM target per kind, with the key it was built from. */
|
||||
const built = new Map<EnvironmentKind, { key: string; target: THREE.WebGLRenderTarget }>();
|
||||
|
||||
/**
|
||||
* Every scene this rig has written an environment onto, and which kind it
|
||||
* was given.
|
||||
*
|
||||
* The kind is carried rather than just the scene because a page can hold both
|
||||
* at once — CONTRACT.md §1 keeps the city alive and paused while an office is
|
||||
* on screen — and when one kind rebuilds, the scenes that need the new texture
|
||||
* are the ones on *that* kind. Handing a sunset city sky to the office
|
||||
* standing beside it would light the room through a wall.
|
||||
*/
|
||||
const applied = new Map<THREE.Scene, EnvironmentKind>();
|
||||
|
||||
let disposed = false;
|
||||
|
||||
function generator(): THREE.PMREMGenerator {
|
||||
if (!pmrem) pmrem = new THREE.PMREMGenerator(renderer);
|
||||
return pmrem;
|
||||
}
|
||||
|
||||
function build(kind: EnvironmentKind, lighting: LightingState): THREE.WebGLRenderTarget {
|
||||
if (kind === "office") {
|
||||
if (!room) room = createRoomProbe();
|
||||
room.tune(lighting);
|
||||
// A little blur at capture time. The room is nine flat quads and a hard
|
||||
// edge between two of them would show up as a visible seam in the
|
||||
// reflection on a polished desk; four hundredths of a radian is under a
|
||||
// pixel of the cube face and is enough to take that edge off.
|
||||
return generator().fromScene(room.scene, 0.04, 0.1, 40);
|
||||
}
|
||||
equirect = fillSkyEquirect(equirect, lighting);
|
||||
return generator().fromEquirectangular(equirect);
|
||||
}
|
||||
|
||||
return {
|
||||
apply(scene, lighting, kind) {
|
||||
if (disposed) return;
|
||||
const key = environmentKey(kind, lighting);
|
||||
const current = built.get(kind);
|
||||
|
||||
if (!current || current.key !== key) {
|
||||
let target: THREE.WebGLRenderTarget;
|
||||
try {
|
||||
target = build(kind, lighting);
|
||||
} catch (error) {
|
||||
/*
|
||||
* Degrade rather than take the frame down.
|
||||
*
|
||||
* Everything in here runs against a live GL context, and the two ways
|
||||
* that goes wrong in the field are a lost context and a driver that
|
||||
* refuses a half-float render target. Neither is a reason for a city
|
||||
* to stop drawing: without an environment the world looks the way it
|
||||
* looked before this file was written, which is worse and still a
|
||||
* world. The warning is deliberately not swallowed silently — a
|
||||
* missing environment is very hard to diagnose from the picture
|
||||
* alone, because "everything is slightly duller" does not look like
|
||||
* an error.
|
||||
*/
|
||||
console.warn("environmentRig: could not build an environment map", error);
|
||||
return;
|
||||
}
|
||||
current?.target.dispose();
|
||||
built.set(kind, { key, target });
|
||||
// The previous texture has just been freed, and every scene that was
|
||||
// holding it is now pointing at a disposed target — not only the scene
|
||||
// that happened to ask for the rebuild. Scenes on the other kind are
|
||||
// left strictly alone.
|
||||
for (const [other, otherKind] of applied) {
|
||||
if (otherKind === kind) other.environment = target.texture;
|
||||
}
|
||||
}
|
||||
|
||||
const target = built.get(kind);
|
||||
if (!target) return;
|
||||
scene.environment = target.target.texture;
|
||||
// Stated rather than left at its default, because the brightness of the
|
||||
// environment is decided by the radiances above and a stray intensity
|
||||
// here would silently override all of that reasoning.
|
||||
scene.environmentIntensity = 1;
|
||||
applied.set(scene, kind);
|
||||
},
|
||||
|
||||
release(scene) {
|
||||
if (!applied.delete(scene)) return;
|
||||
scene.environment = null;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
disposed = true;
|
||||
for (const scene of applied.keys()) {
|
||||
scene.environment = null;
|
||||
}
|
||||
applied.clear();
|
||||
for (const entry of built.values()) entry.target.dispose();
|
||||
built.clear();
|
||||
equirect?.dispose();
|
||||
equirect = null;
|
||||
room?.dispose();
|
||||
room = null;
|
||||
// `PMREMGenerator.dispose()` frees its own blur materials and ping-pong
|
||||
// target. It does not touch the targets it handed out, which is why they
|
||||
// are disposed above first.
|
||||
pmrem?.dispose();
|
||||
pmrem = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Rebuild key ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A coarse fingerprint of the lighting, used to decide whether to rebuild.
|
||||
*
|
||||
* Coarse is the entire point. `Atmosphere` interpolates its keyframe table
|
||||
* continuously, so on a running clock every field of a `LightingState` changes
|
||||
* by a fraction every single frame — an exact key would rebuild the environment
|
||||
* sixty times a second and none of those rebuilds would be visible. Colours are
|
||||
* reduced to five bits a channel and the sun direction to twelfths of a unit
|
||||
* vector, which is roughly a five-degree bucket, so a real dawn still rebuilds
|
||||
* often enough to track and a static afternoon rebuilds once.
|
||||
*/
|
||||
function environmentKey(kind: EnvironmentKind, l: LightingState): string {
|
||||
const d = l.sun.direction;
|
||||
return [
|
||||
kind,
|
||||
quantiseColor(l.sky?.top ?? l.hemisphere.sky),
|
||||
quantiseColor(l.sky?.horizon ?? l.hemisphere.ground),
|
||||
quantiseColor(l.hemisphere.sky),
|
||||
quantiseColor(l.hemisphere.ground),
|
||||
Math.round(l.hemisphere.intensity * 20),
|
||||
quantiseColor(l.ambient.color),
|
||||
Math.round(l.ambient.intensity * 20),
|
||||
quantiseColor(l.sun.color),
|
||||
Math.round(l.sun.intensity * 20),
|
||||
Math.round(d[0] * 12),
|
||||
Math.round(d[1] * 12),
|
||||
Math.round(d[2] * 12),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
/** 24-bit colour down to 15, which is finer than the eye reads off a gradient. */
|
||||
function quantiseColor(hex: number): number {
|
||||
return (((hex >> 19) & 0x1f) << 10) | (((hex >> 11) & 0x1f) << 5) | ((hex >> 3) & 0x1f);
|
||||
}
|
||||
|
||||
// ---- The city sky ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fill (or refill) the equirectangular sky.
|
||||
*
|
||||
* Written into an existing buffer where there is one. A `DataTexture` is 128 KB
|
||||
* of `Uint16Array` and the rebuild happens on a timer nobody controls, so
|
||||
* allocating a new one each time hands the garbage collector a steady drip of
|
||||
* medium-sized buffers for no reason — and the GPU-side texture object would be
|
||||
* recreated with it, which is the expensive half.
|
||||
*
|
||||
* The sphere is built in three parts, all of them derived from the
|
||||
* `LightingState` and none of them invented here:
|
||||
*
|
||||
* - **Above the horizon**, the same top-to-horizon gradient `scenekit.ts`
|
||||
* paints on the background, so the environment and the visible sky are the
|
||||
* same sky. It is blended on `sin(elevation)` raised to a power rather than
|
||||
* linearly on the angle, because that is what puts the pale band near the
|
||||
* horizon where the eye expects it.
|
||||
* - **Below the horizon**, the hemisphere light's ground colour. The lower
|
||||
* half of a real environment is the terrain, and leaving it black is the
|
||||
* single most common way an environment map makes a car look like a toy.
|
||||
* - **The sun**, as a narrow lobe plus a wide glow, at the direction
|
||||
* `Atmosphere` already computed. Half-float storage is what makes this
|
||||
* possible at all: the lobe sits several times above 1.0 and an 8-bit
|
||||
* texture would clip it to the same white as the sky beside it, which is
|
||||
* the exact failure `stage.ts` just removed from the main render path.
|
||||
*/
|
||||
function fillSkyEquirect(existing: THREE.DataTexture | null, l: LightingState): THREE.DataTexture {
|
||||
const width = EQUIRECT_WIDTH;
|
||||
const height = EQUIRECT_HEIGHT;
|
||||
const texture =
|
||||
existing ??
|
||||
new THREE.DataTexture(
|
||||
new Uint16Array(width * height * 4),
|
||||
width,
|
||||
height,
|
||||
THREE.RGBAFormat,
|
||||
THREE.HalfFloatType,
|
||||
);
|
||||
const data = texture.image.data as Uint16Array;
|
||||
|
||||
const top = linearOf(l.sky?.top ?? l.hemisphere.sky);
|
||||
const horizon = linearOf(l.sky?.horizon ?? l.hemisphere.ground);
|
||||
const ground = linearOf(l.hemisphere.ground);
|
||||
|
||||
const skyScale = SKY_RADIANCE_SHARE * Math.max(0, l.hemisphere.intensity);
|
||||
const groundScale = GROUND_RADIANCE_SHARE * Math.max(0, l.hemisphere.intensity);
|
||||
const sun = linearOf(l.sun.color);
|
||||
const sunScale = Math.max(0, l.sun.intensity);
|
||||
const [sx, sy, sz] = l.sun.direction;
|
||||
|
||||
const half = THREE.DataUtils.toHalfFloat;
|
||||
|
||||
for (let j = 0; j < height; j++) {
|
||||
// Row 0 is v = 0. Three's `equirectUv` puts v = 0 at `dir.y = -1`, and a
|
||||
// `DataTexture` does not flip, so row 0 is straight down.
|
||||
const v = (j + 0.5) / height;
|
||||
const phi = (v - 0.5) * Math.PI;
|
||||
const sinPhi = Math.sin(phi);
|
||||
const cosPhi = Math.cos(phi);
|
||||
|
||||
// The vertical blend, before the sun is added. Above the horizon it walks
|
||||
// the sky gradient; below it fades the ground colour down as it goes under,
|
||||
// so there is no hard band at the equator to show up in a mirror.
|
||||
let baseR: number;
|
||||
let baseG: number;
|
||||
let baseB: number;
|
||||
if (sinPhi >= 0) {
|
||||
const t = Math.pow(sinPhi, 0.55);
|
||||
baseR = (horizon[0] + (top[0] - horizon[0]) * t) * skyScale;
|
||||
baseG = (horizon[1] + (top[1] - horizon[1]) * t) * skyScale;
|
||||
baseB = (horizon[2] + (top[2] - horizon[2]) * t) * skyScale;
|
||||
} else {
|
||||
const t = Math.pow(-sinPhi, 0.7);
|
||||
const dim = 1 - 0.55 * t;
|
||||
baseR = (horizon[0] * (1 - t) * skyScale + ground[0] * t * groundScale) * dim;
|
||||
baseG = (horizon[1] * (1 - t) * skyScale + ground[1] * t * groundScale) * dim;
|
||||
baseB = (horizon[2] * (1 - t) * skyScale + ground[2] * t * groundScale) * dim;
|
||||
}
|
||||
|
||||
for (let i = 0; i < width; i++) {
|
||||
const u = (i + 0.5) / width;
|
||||
const theta = (u - 0.5) * Math.PI * 2;
|
||||
const dx = cosPhi * Math.cos(theta);
|
||||
const dy = sinPhi;
|
||||
const dz = cosPhi * Math.sin(theta);
|
||||
|
||||
const cos = dx * sx + dy * sy + dz * sz;
|
||||
let solar = 0;
|
||||
if (cos > 0) {
|
||||
solar =
|
||||
SUN_LOBE_RADIANCE * Math.pow(cos, SUN_LOBE_TIGHTNESS) +
|
||||
SUN_GLOW_RADIANCE * Math.pow(cos, SUN_GLOW_TIGHTNESS);
|
||||
solar *= sunScale;
|
||||
}
|
||||
|
||||
const o = (j * width + i) * 4;
|
||||
data[o] = half(baseR + sun[0] * solar);
|
||||
data[o + 1] = half(baseG + sun[1] * solar);
|
||||
data[o + 2] = half(baseB + sun[2] * solar);
|
||||
data[o + 3] = half(1);
|
||||
}
|
||||
}
|
||||
|
||||
texture.mapping = THREE.EquirectangularReflectionMapping;
|
||||
// Half-float data is already linear radiance; naming a transfer function here
|
||||
// would apply an sRGB decode to numbers that were never encoded.
|
||||
texture.colorSpace = THREE.NoColorSpace;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
// ---- The office room ------------------------------------------------------
|
||||
|
||||
interface RoomProbe {
|
||||
scene: THREE.Scene;
|
||||
tune(l: LightingState): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nine quads that reflect like a room.
|
||||
*
|
||||
* The list is short and every entry earns its place in a reflection: a bright
|
||||
* ceiling and two brighter light panels (which is what puts the long vertical
|
||||
* highlight down the edge of a monitor bezel), a floor darker than the walls, a
|
||||
* window wall carrying the sun's own colour, and a warm end wall so the room
|
||||
* has a direction to it and a chrome chair leg is not the same colour all the
|
||||
* way round.
|
||||
*
|
||||
* Built once and re-coloured, not rebuilt. `tune` only assigns to
|
||||
* `material.color`, so the geometry, the materials and their shader programs
|
||||
* survive every change of hour.
|
||||
*/
|
||||
function createRoomProbe(): RoomProbe {
|
||||
const scene = new THREE.Scene();
|
||||
const geometries: THREE.BufferGeometry[] = [];
|
||||
const materials: THREE.MeshBasicMaterial[] = [];
|
||||
|
||||
function quad(
|
||||
w: number,
|
||||
h: number,
|
||||
position: [number, number, number],
|
||||
rotation: [number, number, number],
|
||||
): THREE.MeshBasicMaterial {
|
||||
const geometry = new THREE.PlaneGeometry(w, h);
|
||||
// `DoubleSide` so the probe camera at the origin sees every quad whichever
|
||||
// way it was authored — an inside-out wall in an environment reads as a
|
||||
// hole, and a hole reads as a black stripe across everything shiny.
|
||||
const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide });
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.position.set(position[0], position[1], position[2]);
|
||||
mesh.rotation.set(rotation[0], rotation[1], rotation[2]);
|
||||
scene.add(mesh);
|
||||
geometries.push(geometry);
|
||||
materials.push(material);
|
||||
return material;
|
||||
}
|
||||
|
||||
const HALF_W = ROOM_WIDTH / 2;
|
||||
const HALF_D = ROOM_DEPTH / 2;
|
||||
const HALF_H = ROOM_HEIGHT / 2;
|
||||
|
||||
const ceiling = quad(ROOM_WIDTH, ROOM_DEPTH, [0, HALF_H, 0], [Math.PI / 2, 0, 0]);
|
||||
const floor = quad(ROOM_WIDTH, ROOM_DEPTH, [0, -HALF_H, 0], [-Math.PI / 2, 0, 0]);
|
||||
const back = quad(ROOM_WIDTH, ROOM_HEIGHT, [0, 0, -HALF_D], [0, 0, 0]);
|
||||
const front = quad(ROOM_WIDTH, ROOM_HEIGHT, [0, 0, HALF_D], [0, Math.PI, 0]);
|
||||
const left = quad(ROOM_DEPTH, ROOM_HEIGHT, [-HALF_W, 0, 0], [0, Math.PI / 2, 0]);
|
||||
const window = quad(ROOM_DEPTH, ROOM_HEIGHT, [HALF_W, 0, 0], [0, -Math.PI / 2, 0]);
|
||||
const panelA = quad(ROOM_WIDTH * 0.62, 0.5, [0, HALF_H - 0.02, -1.4], [Math.PI / 2, 0, 0]);
|
||||
const panelB = quad(ROOM_WIDTH * 0.62, 0.5, [0, HALF_H - 0.02, 1.4], [Math.PI / 2, 0, 0]);
|
||||
const accent = quad(ROOM_WIDTH * 0.9, 0.35, [0, -0.6, -HALF_D + 0.01], [0, 0, 0]);
|
||||
|
||||
return {
|
||||
scene,
|
||||
tune(l) {
|
||||
const sky = linearOf(l.hemisphere.sky);
|
||||
const groundC = linearOf(l.hemisphere.ground);
|
||||
const ambient = linearOf(l.ambient.color);
|
||||
const sun = linearOf(l.sun.color);
|
||||
|
||||
// An interior probe is normalised against the *fill*, not against the
|
||||
// sun: a room's own surfaces are what a desk reflects, and they are lit
|
||||
// by whatever is getting inside. Reading `hemisphere.intensity` keeps a
|
||||
// night office reflecting a dim room and a noon office a bright one
|
||||
// without this file forming its own opinion about either.
|
||||
const fill = Math.max(0.05, l.hemisphere.intensity) * SKY_RADIANCE_SHARE;
|
||||
const solar = Math.max(0, l.sun.intensity) * SKY_RADIANCE_SHARE;
|
||||
|
||||
setLinear(ceiling, ambient, fill * 1.5);
|
||||
setLinear(floor, groundC, fill * 0.8);
|
||||
setLinear(back, ambient, fill * 1.1);
|
||||
setLinear(front, ambient, fill * 1.0);
|
||||
setLinear(left, ambient, fill * 1.2);
|
||||
// The window is the only surface that knows what time it is, and it is
|
||||
// the one that gives a monitor bezel a bright edge on the daylight side.
|
||||
setLinear(window, sun, solar * 2.2 + fill * 0.6);
|
||||
setLinear(panelA, sky, fill * 7);
|
||||
setLinear(panelB, sky, fill * 7);
|
||||
setLinear(accent, groundC, fill * 1.6);
|
||||
},
|
||||
dispose() {
|
||||
for (const g of geometries) g.dispose();
|
||||
for (const m of materials) m.dispose();
|
||||
geometries.length = 0;
|
||||
materials.length = 0;
|
||||
scene.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Colour ---------------------------------------------------------------
|
||||
|
||||
const SCRATCH = new THREE.Color();
|
||||
|
||||
/**
|
||||
* An authored `0xrrggbb` as linear-light RGB.
|
||||
*
|
||||
* Named rather than inlined because getting it wrong is invisible until it is
|
||||
* everywhere: `LightingState` colours are sRGB by the definition in `types.ts`,
|
||||
* and everything on this page is radiance, which is linear. Multiplying an
|
||||
* un-decoded 0.5 by an intensity is off by more than a factor of two at the
|
||||
* dark end of the range, and the symptom is a night sky that reflects like an
|
||||
* overcast noon.
|
||||
*/
|
||||
function linearOf(hex: number): [number, number, number] {
|
||||
SCRATCH.setHex(hex, THREE.SRGBColorSpace);
|
||||
return [SCRATCH.r, SCRATCH.g, SCRATCH.b];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a material's colour from linear radiance, which may exceed 1.
|
||||
*
|
||||
* `Color.setHex` and the `{ color }` constructor argument both go through an
|
||||
* sRGB decode and both saturate at 1.0, so neither can express a light panel
|
||||
* seven times brighter than the wall beside it. `setRGB` in the working colour
|
||||
* space can, and a light panel that cannot be brighter than the wall is not a
|
||||
* light panel.
|
||||
*/
|
||||
function setLinear(
|
||||
material: THREE.MeshBasicMaterial,
|
||||
color: readonly [number, number, number],
|
||||
scale: number,
|
||||
): void {
|
||||
material.color.setRGB(color[0] * scale, color[1] * scale, color[2] * scale);
|
||||
}
|
||||
+202
-6
@@ -2,12 +2,14 @@
|
||||
* Aircraft over the city.
|
||||
*
|
||||
* The engine takes a `FlightSource` rather than talking to any particular
|
||||
* service, because the obvious one cannot ship here. FlightRadar24's terms
|
||||
* forbid scraping and forbid redistributing their data, so an Apache-2.0 repo
|
||||
* containing an FR24 client would be publishing instructions for breaking a
|
||||
* ToS and shipping data it has no right to relicense. Commercial sources are
|
||||
* adapters in a private deployment; this file holds what we can actually give
|
||||
* away. See ARCHITECTURE.md §4.
|
||||
* service, because the obvious one cannot ship here. FlightRadar24's terms do
|
||||
* not permit scraping and do not permit redistributing their data. An
|
||||
* Apache-2.0 repo shipping such a client would not merely be breaking a ToS —
|
||||
* it would be publishing instructions for doing so, alongside data it has no
|
||||
* right to relicense. Commercial sources are adapters in a private deployment;
|
||||
* this file holds what we can actually give away. See ARCHITECTURE.md §4, and
|
||||
* `server/src/flights/licence.ts` for the allowlist that keeps the open lane
|
||||
* open in practice rather than in principle.
|
||||
*
|
||||
* `SimulatedFlights` is the default and is genuinely enough for the map — what
|
||||
* a city view wants is convincing motion in the right corridors, not a
|
||||
@@ -345,6 +347,16 @@ const ADSB_HOLD_SECONDS = 60;
|
||||
* licence problem. The best answer long-term is an RTL-SDR on a fleet box:
|
||||
* first-party data, nothing to comply with.
|
||||
*
|
||||
* **`endpoint` is not free-form, even though its type is `string`.** The
|
||||
* allowlist of feeds this project will fetch, and the credit line each of them
|
||||
* is owed, live in `server/src/flights/licence.ts`, which is where the API's
|
||||
* `TERA_ADSB_ENDPOINT` is validated before a request is made. A browser drawing
|
||||
* a feed for itself is not republishing it and so is not the exposure that gate
|
||||
* exists for — but a self-hoster who constructs this class with some other
|
||||
* endpoint is choosing terms nobody here has read, and this is the sentence
|
||||
* that says so. Nothing in this repo constructs it: the shipped path is
|
||||
* `HttpFlights` against our own API.
|
||||
*
|
||||
* The region is required and has no default. It used to default to a point in
|
||||
* San Francisco, which is a fine centre for one of the two cities in this build
|
||||
* and a five-hundred-kilometre error for the other — and a wrong default is
|
||||
@@ -445,10 +457,183 @@ interface RawAircraft {
|
||||
track?: number;
|
||||
}
|
||||
|
||||
// ---- Detail ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One aircraft, described well enough to put on a card somebody clicked.
|
||||
*
|
||||
* The demo this project leads with is a signed-out visitor clicking a dart over
|
||||
* a city they recognise and being told what it is, so this type is written for
|
||||
* **anon** and carries nothing an account would be needed for. Everything in it
|
||||
* is either broadcast unencrypted by the aircraft itself — ADS-B is receivable
|
||||
* with a forty-dollar dongle — or arithmetic on top of that. There is no route,
|
||||
* no registration and no operator here, because the open feeds do not carry
|
||||
* them and inventing them would be the same class of lie `synthetic` exists to
|
||||
* prevent. `owner-decisions.md` reserves those for an openly-licensed registry
|
||||
* we have not wired.
|
||||
*
|
||||
* Two fields are about the *provenance* rather than the aeroplane, and they are
|
||||
* the reason this is a type and not an object literal built in the UI:
|
||||
* `observed` says whether anybody actually saw this, and `attribution` carries
|
||||
* whatever the feed asks to be credited with **at the point the data is
|
||||
* displayed**, which is what an ODbL notice is for. A card is a display. A
|
||||
* corner label on the other side of the screen is not obviously one.
|
||||
*/
|
||||
export interface AircraftDetail {
|
||||
/** The source's own id. The ICAO address for a live feed; a route name for the simulator. */
|
||||
id: string;
|
||||
/** Flight number or tail as broadcast, trimmed, or `null` when the feed said nothing. */
|
||||
callsign: string | null;
|
||||
/**
|
||||
* The transponder's 24-bit ICAO address, lowercase hex, or `null`.
|
||||
*
|
||||
* `null` rather than a guess for anything that does not look like one — the
|
||||
* simulator's ids are route names and a `~`-prefixed id on a real feed is a
|
||||
* non-ICAO address (TIS-B and MLAT targets carry them), which is genuinely
|
||||
* not an ICAO24 and must not be presented as one. Somebody can paste this
|
||||
* into a registry lookup, so a wrong one sends them to another aircraft.
|
||||
*/
|
||||
icao24: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
/** Barometric altitude, metres — the unit the wire and the engine both use. */
|
||||
altitudeM: number;
|
||||
/** The same altitude in feet, which is the unit aviation is actually read in. */
|
||||
altitudeFt: number;
|
||||
/** Degrees clockwise from true north. */
|
||||
headingDeg: number;
|
||||
/** The heading as a 16-point compass name, for a card a human reads. */
|
||||
headingCompass: string;
|
||||
/** Nautical miles from the board's centre, or `null` when no centre was given. */
|
||||
distanceNm: number | null;
|
||||
/**
|
||||
* Did somebody observe this, or did this repo invent it?
|
||||
*
|
||||
* The same statement `TrafficSource.live()` makes about the whole feed, made
|
||||
* about one aircraft, and it must travel with the aircraft: a card is read on
|
||||
* its own, away from any corner label, and a fabricated flight number
|
||||
* presented in the same frame as a real one is the confusion the `live` flag
|
||||
* exists to prevent.
|
||||
*/
|
||||
observed: boolean;
|
||||
/** Credit lines owed for this aircraft, to be shown on the card itself. */
|
||||
attribution: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* An ICAO 24-bit address as the feeds write it: six hex digits, lowercase.
|
||||
*
|
||||
* Anchored, so `sim-BA286` fails and `~abc123` — the anonymous-address form
|
||||
* both community feeds emit for targets whose real address is not known — fails
|
||||
* too, which is the point. See `AircraftDetail.icao24`.
|
||||
*/
|
||||
const ICAO24 = /^[0-9a-f]{6}$/;
|
||||
|
||||
/** The sixteen names, in the order the compass runs. */
|
||||
const COMPASS = [
|
||||
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
|
||||
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW",
|
||||
];
|
||||
|
||||
/**
|
||||
* A bearing as a compass point.
|
||||
*
|
||||
* Sixteen points rather than eight because the difference between "north-east"
|
||||
* and "east-north-east" is the difference between two departure corridors, and
|
||||
* rather than thirty-two because nobody reads "NNE by N" off a card. Negative
|
||||
* and out-of-range degrees are wrapped rather than refused: a heading is an
|
||||
* angle and every angle names a direction.
|
||||
*/
|
||||
export function compassPoint(degrees: number): string {
|
||||
if (!Number.isFinite(degrees)) return "—";
|
||||
const wrapped = ((degrees % 360) + 360) % 360;
|
||||
return COMPASS[Math.round(wrapped / 22.5) % 16] ?? "N";
|
||||
}
|
||||
|
||||
/** Metres to feet. The wire carries metres; aviation is read in feet. */
|
||||
const FEET_PER_METRE = 3.280_84;
|
||||
|
||||
export interface AircraftDetailOptions {
|
||||
/**
|
||||
* The transponder address, when the caller was told one separately.
|
||||
*
|
||||
* `HttpFlights` is: `WireAircraft.icao24` is a field on the body and
|
||||
* `Aircraft` has nowhere to put it, so the adapter keeps the wire record
|
||||
* beside the position and hands it back here. Absent, the id is tested
|
||||
* against `ICAO24` — which is right for every feed that keys on the hex, and
|
||||
* correctly declines for the simulator.
|
||||
*/
|
||||
icao24?: string | null;
|
||||
/** Whether these coordinates were observed. Defaults to `false`: invented until said otherwise. */
|
||||
observed?: boolean;
|
||||
/** Credit lines the feed asks for, shown on the card. */
|
||||
attribution?: readonly string[];
|
||||
/** Board centre, for the distance readout. Omit and `distanceNm` is `null`. */
|
||||
from?: Place;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an `Aircraft` into something a panel can render, without the panel
|
||||
* knowing where aircraft come from.
|
||||
*
|
||||
* Pure, total and free of I/O, so the interface layer can call it on a click
|
||||
* without awaiting anything, and so it can be tested without a network. It
|
||||
* invents nothing: every field is a restatement, a unit conversion or a `null`.
|
||||
*/
|
||||
export function aircraftDetail(
|
||||
aircraft: Aircraft,
|
||||
options: AircraftDetailOptions = {},
|
||||
): AircraftDetail {
|
||||
const callsign = aircraft.callsign?.trim();
|
||||
const declared = options.icao24?.trim().toLowerCase();
|
||||
const fromId = aircraft.id.trim().toLowerCase();
|
||||
const icao24 =
|
||||
declared !== undefined && ICAO24.test(declared)
|
||||
? declared
|
||||
: ICAO24.test(fromId)
|
||||
? fromId
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: aircraft.id,
|
||||
callsign: callsign === undefined || callsign === "" ? null : callsign,
|
||||
icao24,
|
||||
lat: aircraft.lat,
|
||||
lng: aircraft.lng,
|
||||
altitudeM: aircraft.altitude,
|
||||
altitudeFt: Math.round(aircraft.altitude * FEET_PER_METRE),
|
||||
headingDeg: aircraft.heading,
|
||||
headingCompass: compassPoint(aircraft.heading),
|
||||
distanceNm:
|
||||
options.from === undefined
|
||||
? null
|
||||
: Math.round(distanceNm(options.from, { lat: aircraft.lat, lng: aircraft.lng }) * 10) / 10,
|
||||
observed: options.observed === true,
|
||||
attribution: [...(options.attribution ?? [])],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Rendering ------------------------------------------------------------
|
||||
|
||||
export interface FlightLayer {
|
||||
group: THREE.Group;
|
||||
/**
|
||||
* The aircraft meshes currently in the sky, as a **live** array, each
|
||||
* carrying `userData.aircraftId`.
|
||||
*
|
||||
* Here rather than on the caller because only this layer knows which mesh is
|
||||
* which track: the map from id to mesh is private and the group's child order
|
||||
* is an artefact of when each aircraft appeared. It is the same shape
|
||||
* `MarkerLayer.pickables` publishes and it exists for the same reason — a
|
||||
* pick is resolved from the object that was hit, and something has to say
|
||||
* what the object stands for.
|
||||
*
|
||||
* `owner-decisions.md` is why this is not gated on anything: an ADS-B
|
||||
* position is broadcast unencrypted to anybody with a receiver, so the card
|
||||
* it opens is available to an anonymous visitor and the picking that reaches
|
||||
* it must be too.
|
||||
*/
|
||||
pickables: THREE.Object3D[];
|
||||
/**
|
||||
* Hand over a fresh observation. Called on the source's own timer, which is
|
||||
* once a second for the simulator and once every several seconds for a real
|
||||
@@ -716,6 +901,9 @@ interface Track {
|
||||
export function createFlightLayer(world: World): FlightLayer {
|
||||
const group = new THREE.Group();
|
||||
group.name = "flights";
|
||||
// Mutated in place as tracks appear and expire, so `setPicking` can hold the
|
||||
// array itself as its target list rather than re-reading it every pointer move.
|
||||
const pickables: THREE.Object3D[] = [];
|
||||
|
||||
const geo = airlinerGeometry();
|
||||
const materials = new Map<number, THREE.MeshLambertMaterial>();
|
||||
@@ -799,7 +987,11 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
// Yaw then pitch, because the heading is about the world's vertical and
|
||||
// the climb angle is about the aircraft's own wing.
|
||||
mesh.rotation.order = "YXZ";
|
||||
// The id, on the object, so a raycast hit resolves to an aeroplane
|
||||
// without this layer having to expose its private track table.
|
||||
mesh.userData.aircraftId = a.id;
|
||||
group.add(mesh);
|
||||
pickables.push(mesh);
|
||||
track = {
|
||||
mesh,
|
||||
samples: [],
|
||||
@@ -921,6 +1113,8 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
if (track.missingSince === 0) track.missingSince = now;
|
||||
if (now - track.missingSince < TRACK_GRACE_SECONDS) continue;
|
||||
group.remove(track.mesh);
|
||||
const at = pickables.indexOf(track.mesh);
|
||||
if (at >= 0) pickables.splice(at, 1);
|
||||
tracks.delete(id);
|
||||
}
|
||||
|
||||
@@ -1139,6 +1333,7 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
|
||||
return {
|
||||
group,
|
||||
pickables,
|
||||
update,
|
||||
tick,
|
||||
dispose() {
|
||||
@@ -1148,6 +1343,7 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
trailGeo.dispose();
|
||||
trailMat.dispose();
|
||||
tracks.clear();
|
||||
pickables.length = 0;
|
||||
group.clear();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* The apron outside the front door, and the car standing on it.
|
||||
*
|
||||
* Every shipped pack authors one `ExteriorArrival` — a marked stall on the
|
||||
* ground outside the building, in the pack's own plan frame. This layer turns
|
||||
* that one anchor into a piece of the world: a paved pad, a painted bay, a kerb,
|
||||
* a charge post, and a Model X parked in it whose lamps and cabin reflect a live
|
||||
* {@link VehicleTelemetryState}.
|
||||
*
|
||||
* ### Why the car stands here rather than on the board
|
||||
*
|
||||
* There has been a Model X in this product since the freeway corridor shipped,
|
||||
* and it has only ever existed as traffic: forty instanced glyphs at 0.18 scale
|
||||
* on a board where one scene unit is 94 metres, seen from four kilometres up.
|
||||
* That is a map symbol. This is the same asset at 1 unit = 1 m, three metres
|
||||
* from a doorway you can walk through, which is the first time anything in the
|
||||
* product has asked it to be a car — and it is why `assets/vehicles/modelX.ts`
|
||||
* was rebuilt with arches, glass openings and a real tyre.
|
||||
*
|
||||
* ### What this layer owns and what it borrows
|
||||
*
|
||||
* It owns geometry and exactly one material (the cabin glow, whose emissive
|
||||
* strength varies continuously and therefore cannot be a shared registry
|
||||
* material). Everything else is borrowed: surface roles come from the
|
||||
* `MaterialRegistry`, indicator colours come from `materials.tinted`, and the
|
||||
* kerbside planter is built through the `AssetRegistry` so a self-hoster who has
|
||||
* re-skinned `tera:planter.trough` gets their trough out here too.
|
||||
*
|
||||
* `dispose()` frees what this layer made and deliberately leaves the registries
|
||||
* alone. Disposing a shared `polishedConcrete` here would blank the floor of the
|
||||
* office the apron stands outside of.
|
||||
*
|
||||
* ### It constructs no light
|
||||
*
|
||||
* CONTRACT §4: `Atmosphere` is the sole light owner. A charge lamp, a marker
|
||||
* lamp and a lit cabin are all *emissive materials*, exactly as
|
||||
* `interiors/luminaires.ts` makes a ceiling fitting glow without becoming one.
|
||||
* Nothing in this file is a light source, and the release gate's grep for the
|
||||
* five three.js light constructors returns nothing over it on purpose.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { AssetRegistry } from "../assets/kit.ts";
|
||||
import { createAssetContext } from "../assets/kit.ts";
|
||||
import type { MaterialRegistry } from "../assets/materials.ts";
|
||||
import { MeshBin, parts } from "../assets/parts.ts";
|
||||
import {
|
||||
MODEL_X_METRICS,
|
||||
MODEL_X_PAINTS,
|
||||
buildModelX,
|
||||
disposeModelX,
|
||||
type ModelXDetail,
|
||||
} from "../assets/vehicles/index.ts";
|
||||
import type { ExteriorArrival, OfficeSite } from "../interiors/types.ts";
|
||||
import {
|
||||
apronKindFor,
|
||||
apronMetrics,
|
||||
exteriorVehicleAppearance,
|
||||
lampTint,
|
||||
parkPose,
|
||||
type ApronMetrics,
|
||||
type ExteriorVehicleAppearance,
|
||||
} from "../transport/exteriorVehicle.ts";
|
||||
import type { VehicleTelemetryState } from "../transport/vehicleTelemetry.ts";
|
||||
|
||||
export interface OfficeExteriorOptions {
|
||||
site: OfficeSite;
|
||||
arrival: ExteriorArrival;
|
||||
assets: AssetRegistry;
|
||||
materials: MaterialRegistry;
|
||||
/** Seeded per office, so the same studio has the same car outside it forever. */
|
||||
rand: () => number;
|
||||
/**
|
||||
* Which Model X to build.
|
||||
*
|
||||
* `corridor` is the right answer for a parked car and is what a caller should
|
||||
* pass unless the camera is close enough to read a door shutline: 3,192
|
||||
* triangles across 18 draw calls, against 13,080 and 25 for `follow`. The
|
||||
* difference a viewer can see at three metres is the wing mirrors, the glass
|
||||
* frames and the brake calipers; at ten it is nothing at all.
|
||||
*/
|
||||
detail: ModelXDetail;
|
||||
}
|
||||
|
||||
export interface OfficeExterior {
|
||||
object: THREE.Object3D;
|
||||
/** Reflect one telemetry observation. Cheap, idempotent, safe every frame. */
|
||||
apply(telemetry: VehicleTelemetryState): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the charge flap sits on the vehicle's left rear quarter, in the asset's
|
||||
* own frame.
|
||||
*
|
||||
* These are not invented: they are the surface the bodyshell loft actually
|
||||
* produces there, measured by casting a ray across the flank at that height
|
||||
* (`x = −1.0004` on both LODs), plus 3.6 mm so the flap stands proud the way a
|
||||
* real one does rather than z-fighting with the paint. Aft of the rear arch and
|
||||
* below the shoulder line, which is where a charge port goes on every car that
|
||||
* has one.
|
||||
*/
|
||||
const CHARGE_PORT = { x: -1.004, y: 0.86, z: 1.95 } as const;
|
||||
|
||||
/** Radius of the charge-flap ring, metres. */
|
||||
const CHARGE_PORT_RADIUS = 0.055;
|
||||
|
||||
/**
|
||||
* The cabin glow slab, in the vehicle's frame: a dome light under the roof.
|
||||
*
|
||||
* Deliberately a thin horizontal plate rather than a filled cabin volume. The
|
||||
* greenhouse is real glass with an interior behind it, so a lit box would be
|
||||
* seen *through* the seats; a plate at head height reads as the light being on
|
||||
* and disappears when it is not.
|
||||
*/
|
||||
const CABIN_GLOW = { width: 1.24, thickness: 0.02, depth: 2.05, y: 1.3, z: -0.05 } as const;
|
||||
|
||||
/** How hard the cabin plate is driven at `cabinGlow === 1`. */
|
||||
const CABIN_GLOW_MAX_INTENSITY = 1.6;
|
||||
|
||||
/**
|
||||
* Build the apron and the vehicle at a pack's arrival anchor.
|
||||
*
|
||||
* The returned object sits at the plan's origin with an identity transform, and
|
||||
* everything inside it is positioned in the pack's own metres — so a caller adds
|
||||
* it to the level group and nothing has to agree about a frame. That is also
|
||||
* what makes the anchor assertable: the Model X's world position is
|
||||
* `arrival.position` plus a bounded parking jitter, and nothing else.
|
||||
*/
|
||||
export function createOfficeExterior(options: OfficeExteriorOptions): OfficeExterior {
|
||||
const { site, arrival, assets, materials, rand, detail } = options;
|
||||
|
||||
const root = new THREE.Group();
|
||||
root.name = "office-exterior";
|
||||
root.userData.kind = "office-exterior";
|
||||
root.userData.arrivalKind = arrival.kind;
|
||||
if (arrival.label) root.userData.label = arrival.label;
|
||||
if (site.label) root.userData.siteLabel = site.label;
|
||||
|
||||
const kind = apronKindFor(site.elevation);
|
||||
const metrics = apronMetrics(
|
||||
{ length: MODEL_X_METRICS.length, width: MODEL_X_METRICS.width },
|
||||
kind,
|
||||
);
|
||||
|
||||
// Geometries this layer minted and must free. Registry materials are not in
|
||||
// here and must not be: they belong to the office this apron stands outside.
|
||||
const ownedGeometries: THREE.BufferGeometry[] = [];
|
||||
const ownedMaterials: THREE.Material[] = [];
|
||||
|
||||
// ---- The apron ----------------------------------------------------------
|
||||
|
||||
const apron = new THREE.Group();
|
||||
apron.name = "office-exterior:apron";
|
||||
apron.position.set(arrival.position.x, 0, arrival.position.z);
|
||||
// `Yaw` is `object.rotation.y` with no conversion (interiors/types.ts), which
|
||||
// is why nothing in this file converts an angle.
|
||||
apron.rotation.y = arrival.rotation;
|
||||
root.add(apron);
|
||||
|
||||
const pavingColor = kind === "street" ? 0x6f7370 : 0x8d908a;
|
||||
const paving = materials.tinted("polishedConcrete", pavingColor);
|
||||
const kerbMaterial = materials.get("skirting");
|
||||
const lineMaterial = materials.tinted("polishedConcrete", 0xd9d8cd);
|
||||
const postShell = materials.get("deviceShell");
|
||||
const postCap = materials.get("metalTrim");
|
||||
|
||||
// Two bins rather than one, split on whether the piece casts a shadow. A
|
||||
// `MeshBin` sets the flags per build, and a flat slab lying on the ground has
|
||||
// nothing to cast onto while a 1.3 m post very much does.
|
||||
const flat = new MeshBin();
|
||||
const upright = new MeshBin();
|
||||
const top = metrics.padThickness;
|
||||
|
||||
// The slab. `metricQuad` under the top face rather than a scaled `quad`,
|
||||
// because the paving carries a texture and its relief now carries a normal
|
||||
// map: a unit quad scaled to six metres smears one 2 m tile across the whole
|
||||
// pad, colour and relief together.
|
||||
flat.box(paving, {
|
||||
y: 0,
|
||||
size: [metrics.padWidth, metrics.padThickness, metrics.padDepth],
|
||||
});
|
||||
flat.add(parts.metricQuad(metrics.padWidth, metrics.padDepth), paving, { y: top + 0.001 });
|
||||
|
||||
// A kerb upstand around the pad, with a dropped crossing at the open end.
|
||||
//
|
||||
// Two decisions in one shape, and both are worth naming. The upstand runs all
|
||||
// the way round rather than along one edge because the exterior layer is
|
||||
// handed a stall and a site and *neither of them says which side the street
|
||||
// is on*: `mateo-court`'s bay runs east along Mateo Street with the façade to
|
||||
// its left, `frontier-valley`'s faces an apron with taxiway on three sides,
|
||||
// and `lumbridge-hq`'s has no street at all. A carriageway laid on a guessed
|
||||
// side would be wrong half the time; a kerbed island is right every time.
|
||||
//
|
||||
// The gap at +Z is the crossing the car drove in over. Without it the kerb
|
||||
// closes the bay on all four sides and the car reads as having been craned
|
||||
// into a planter, which is a small thing that reliably breaks the illusion.
|
||||
const openingWidth = metrics.stallWidth + 0.4;
|
||||
const returnWidth = Math.max(0.2, (metrics.padWidth - openingWidth) / 2);
|
||||
const returnX = openingWidth / 2 + returnWidth / 2;
|
||||
const frontZ = metrics.padDepth / 2 - metrics.kerbDepth / 2;
|
||||
const kerbRuns: [number, number, number, number][] = [
|
||||
// The head of the bay, and the two long flanks.
|
||||
[0, -metrics.padDepth / 2 + metrics.kerbDepth / 2, metrics.padWidth, metrics.kerbDepth],
|
||||
[-metrics.padWidth / 2 + metrics.kerbDepth / 2, 0, metrics.kerbDepth, metrics.padDepth],
|
||||
[metrics.padWidth / 2 - metrics.kerbDepth / 2, 0, metrics.kerbDepth, metrics.padDepth],
|
||||
// The two returns either side of the crossing.
|
||||
[-returnX, frontZ, returnWidth, metrics.kerbDepth],
|
||||
[returnX, frontZ, returnWidth, metrics.kerbDepth],
|
||||
];
|
||||
for (const [x, z, w, d] of kerbRuns) {
|
||||
upright.box(kerbMaterial, { x, y: top, z, size: [w, metrics.kerbHeight, d] });
|
||||
}
|
||||
|
||||
// The painted bay: two flanks and a head. Three strips and not a rectangle
|
||||
// outline, because a bay is open at the end you drive in through, and the
|
||||
// open end is what tells a viewer which way the car came in.
|
||||
const lineY = top + 0.004;
|
||||
const halfW = metrics.stallWidth / 2;
|
||||
const halfL = metrics.stallLength / 2;
|
||||
flat.box(lineMaterial, {
|
||||
x: -halfW, y: lineY, z: 0,
|
||||
size: [metrics.lineWidth, 0.004, metrics.stallLength],
|
||||
});
|
||||
flat.box(lineMaterial, {
|
||||
x: halfW, y: lineY, z: 0,
|
||||
size: [metrics.lineWidth, 0.004, metrics.stallLength],
|
||||
});
|
||||
flat.box(lineMaterial, {
|
||||
x: 0, y: lineY, z: -halfL,
|
||||
size: [metrics.stallWidth, 0.004, metrics.lineWidth],
|
||||
});
|
||||
|
||||
// The charge post. A moulded column with a metal cap and a recessed face; the
|
||||
// lamps and the charge bar are separate meshes because they change.
|
||||
upright.box(postShell, {
|
||||
x: metrics.postOffsetX, y: top, z: metrics.postOffsetZ,
|
||||
size: [metrics.postWidth, metrics.postHeight, metrics.postDepth],
|
||||
});
|
||||
upright.box(postCap, {
|
||||
x: metrics.postOffsetX, y: top + metrics.postHeight, z: metrics.postOffsetZ,
|
||||
size: [metrics.postWidth + 0.03, 0.035, metrics.postDepth + 0.03],
|
||||
});
|
||||
upright.box(postCap, {
|
||||
x: metrics.postOffsetX, y: top, z: metrics.postOffsetZ,
|
||||
size: [metrics.postWidth + 0.06, 0.05, metrics.postDepth + 0.06],
|
||||
});
|
||||
|
||||
for (const group of [
|
||||
flat.build("office-exterior:paving", { castShadow: false }),
|
||||
upright.build("office-exterior:furniture", { castShadow: true }),
|
||||
]) {
|
||||
for (const child of group.children) {
|
||||
const mesh = child as THREE.Mesh;
|
||||
if (mesh.isMesh) ownedGeometries.push(mesh.geometry);
|
||||
}
|
||||
apron.add(group);
|
||||
}
|
||||
|
||||
// ---- Kerbside dressing, through the asset registry ----------------------
|
||||
//
|
||||
// Built rather than modelled inline so that a self-hoster who registered
|
||||
// `acme:planter.trough` with `overrides: "tera:planter.trough"` gets their
|
||||
// planter out here as well as inside. Gated on `has()` because a stripped
|
||||
// registry is a legitimate configuration and a placeholder box on the kerb is
|
||||
// worse than an empty kerb.
|
||||
const PLANTER_ID = "tera:planter.trough";
|
||||
if (assets.has(PLANTER_ID)) {
|
||||
const planter = assets.build(
|
||||
PLANTER_ID,
|
||||
createAssetContext({ materials, registry: assets, rand }),
|
||||
);
|
||||
const footprint = assets.footprintOf(PLANTER_ID);
|
||||
// Turned side-on so its length runs along the bay rather than across it,
|
||||
// and set just inside the kerb on the side away from the charge post.
|
||||
planter.rotation.y = Math.PI / 2;
|
||||
planter.position.set(halfW + footprint.depth / 2 + 0.18, top, 0);
|
||||
planter.name = "office-exterior:planter";
|
||||
apron.add(planter);
|
||||
planter.traverse((object) => {
|
||||
const mesh = object as THREE.Mesh;
|
||||
if (mesh.isMesh) ownedGeometries.push(mesh.geometry);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Indicators ---------------------------------------------------------
|
||||
//
|
||||
// One geometry shared by three lamps, and a material per lamp swapped on the
|
||||
// way through `materials.tinted`. See `LAMP_INTENSITY_STEPS` for why the
|
||||
// brightness is quantised rather than continuous.
|
||||
const lampGeometry = new THREE.BoxGeometry(
|
||||
metrics.lampSize, metrics.lampSize, metrics.lampSize,
|
||||
);
|
||||
ownedGeometries.push(lampGeometry);
|
||||
|
||||
function makeLamp(name: string, index: number): THREE.Mesh {
|
||||
const mesh = new THREE.Mesh(lampGeometry, materials.tinted("deviceIndicator", 0x000000));
|
||||
mesh.name = `office-exterior:${name}`;
|
||||
mesh.position.set(
|
||||
metrics.postOffsetX,
|
||||
top + metrics.lampHeight - index * (metrics.lampSize + 0.04),
|
||||
metrics.postOffsetZ + metrics.postDepth / 2,
|
||||
);
|
||||
apron.add(mesh);
|
||||
return mesh;
|
||||
}
|
||||
const chargeLamp = makeLamp("lamp-charge", 0);
|
||||
const climateLamp = makeLamp("lamp-climate", 1);
|
||||
const lockLamp = makeLamp("lamp-lock", 2);
|
||||
|
||||
// The charge bar. Scaling a mesh is free; minting a material per percent is
|
||||
// not, so the *quantity* is the scale and the colour is fixed.
|
||||
const barGeometry = new THREE.BoxGeometry(0.045, 1, 0.012).translate(0, 0.5, 0);
|
||||
ownedGeometries.push(barGeometry);
|
||||
const chargeBar = new THREE.Mesh(
|
||||
barGeometry,
|
||||
materials.tinted("deviceIndicator", 0x46d07a),
|
||||
);
|
||||
chargeBar.name = "office-exterior:charge-bar";
|
||||
chargeBar.position.set(
|
||||
metrics.postOffsetX,
|
||||
top + 0.24,
|
||||
metrics.postOffsetZ + metrics.postDepth / 2,
|
||||
);
|
||||
apron.add(chargeBar);
|
||||
|
||||
// ---- The vehicle --------------------------------------------------------
|
||||
|
||||
const pose = parkPose(arrival, rand);
|
||||
const vehicle = new THREE.Group();
|
||||
vehicle.name = "office-exterior:vehicle";
|
||||
vehicle.position.set(pose.x, top, pose.z);
|
||||
vehicle.rotation.y = pose.yaw;
|
||||
root.add(vehicle);
|
||||
|
||||
const paintIndex = Math.min(
|
||||
MODEL_X_PAINTS.length - 1,
|
||||
Math.max(0, Math.floor(rand() * MODEL_X_PAINTS.length)),
|
||||
);
|
||||
const rig = buildModelX({ detail, paint: MODEL_X_PAINTS[paintIndex] ?? 0x465157 });
|
||||
rig.root.name = "office-exterior:model-x";
|
||||
vehicle.add(rig.root);
|
||||
|
||||
const portGeometry = new THREE.CircleGeometry(CHARGE_PORT_RADIUS, 20);
|
||||
ownedGeometries.push(portGeometry);
|
||||
const chargePort = new THREE.Mesh(
|
||||
portGeometry,
|
||||
materials.tinted("deviceIndicator", 0x000000),
|
||||
);
|
||||
chargePort.name = "office-exterior:charge-port";
|
||||
chargePort.position.set(CHARGE_PORT.x, CHARGE_PORT.y, CHARGE_PORT.z);
|
||||
// A `CircleGeometry` faces +Z; the flap is on the vehicle's left, so it turns
|
||||
// to face −X.
|
||||
chargePort.rotation.y = -Math.PI / 2;
|
||||
vehicle.add(chargePort);
|
||||
|
||||
const cabinGeometry = new THREE.BoxGeometry(
|
||||
CABIN_GLOW.width, CABIN_GLOW.thickness, CABIN_GLOW.depth,
|
||||
);
|
||||
ownedGeometries.push(cabinGeometry);
|
||||
// The one material this layer owns. It cannot come from the registry because
|
||||
// its `emissiveIntensity` is a continuous function of telemetry, and a shared
|
||||
// material is shared: dimming this one would dim every indicator in the
|
||||
// office with it.
|
||||
const cabinMaterial = new THREE.MeshStandardMaterial({
|
||||
name: "office-exterior.cabin-glow",
|
||||
color: 0xffe9c8,
|
||||
emissive: 0xffe4bc,
|
||||
emissiveIntensity: 0,
|
||||
roughness: 1,
|
||||
metalness: 0,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
// The plate lives inside a closed glass volume and is only ever seen through
|
||||
// it. Writing depth would let it punch a hole in the tinted glass in front
|
||||
// of it, which reads as a rectangular window cut in the roof.
|
||||
depthWrite: false,
|
||||
});
|
||||
ownedMaterials.push(cabinMaterial);
|
||||
const cabinGlow = new THREE.Mesh(cabinGeometry, cabinMaterial);
|
||||
cabinGlow.name = "office-exterior:cabin-glow";
|
||||
cabinGlow.position.set(0, CABIN_GLOW.y, CABIN_GLOW.z);
|
||||
cabinGlow.castShadow = false;
|
||||
cabinGlow.receiveShadow = false;
|
||||
cabinGlow.visible = false;
|
||||
vehicle.add(cabinGlow);
|
||||
|
||||
// ---- The cable ----------------------------------------------------------
|
||||
//
|
||||
// Built in the root's frame rather than either child's, because its two ends
|
||||
// live in different frames: the socket is on the post (apron frame) and the
|
||||
// flap is on the car (vehicle frame, which carries the parking jitter). A
|
||||
// curve between two world points is the only version of this that stays
|
||||
// attached when the car parks 90 mm off the line.
|
||||
// Both children have just been positioned and nothing has rendered yet, so
|
||||
// their world matrices are stale until this runs.
|
||||
root.updateMatrixWorld(true);
|
||||
const socket = apron.localToWorld(
|
||||
new THREE.Vector3(
|
||||
metrics.postOffsetX,
|
||||
top + 0.72,
|
||||
metrics.postOffsetZ + metrics.postDepth / 2,
|
||||
),
|
||||
);
|
||||
const flap = vehicle.localToWorld(
|
||||
new THREE.Vector3(CHARGE_PORT.x - 0.02, CHARGE_PORT.y, CHARGE_PORT.z),
|
||||
);
|
||||
const sag = socket.clone().add(flap).multiplyScalar(0.5);
|
||||
// A charging cable hangs. Half a metre of droop over a two-metre span is what
|
||||
// a heavy DC lead actually does, and it is the difference between a cable and
|
||||
// a stick.
|
||||
sag.y = Math.min(socket.y, flap.y) - 0.42;
|
||||
const cableGeometry = new THREE.TubeGeometry(
|
||||
new THREE.QuadraticBezierCurve3(socket, sag, flap),
|
||||
12,
|
||||
0.021,
|
||||
6,
|
||||
false,
|
||||
);
|
||||
ownedGeometries.push(cableGeometry);
|
||||
const cable = new THREE.Mesh(cableGeometry, postShell);
|
||||
cable.name = "office-exterior:cable";
|
||||
cable.castShadow = true;
|
||||
cable.receiveShadow = true;
|
||||
cable.visible = false;
|
||||
root.add(cable);
|
||||
|
||||
// ---- Telemetry ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The last appearance applied, as a string.
|
||||
*
|
||||
* `apply` is safe to call every frame and a caller should not have to know
|
||||
* that. Everything downstream of a change is cheap except the registry tint
|
||||
* lookups, and those are a `Map.get` and a string concat each — small, but
|
||||
* three of them sixty times a second for a car that has not changed state in
|
||||
* an hour is work nobody asked for.
|
||||
*/
|
||||
let lastSignature = "";
|
||||
|
||||
function signatureOf(look: ExteriorVehicleAppearance, plugged: boolean): string {
|
||||
return [
|
||||
lampTint(look.charge), lampTint(look.climate), lampTint(look.lock),
|
||||
look.cabinGlow.toFixed(3), look.chargeFraction.toFixed(3), plugged ? 1 : 0,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
function apply(telemetry: VehicleTelemetryState): void {
|
||||
const look = exteriorVehicleAppearance(telemetry);
|
||||
const signature = signatureOf(look, telemetry.pluggedIn);
|
||||
if (signature === lastSignature) return;
|
||||
lastSignature = signature;
|
||||
|
||||
chargeLamp.material = materials.tinted("deviceIndicator", lampTint(look.charge));
|
||||
climateLamp.material = materials.tinted("deviceIndicator", lampTint(look.climate));
|
||||
lockLamp.material = materials.tinted("deviceIndicator", lampTint(look.lock));
|
||||
chargePort.material = materials.tinted("deviceIndicator", lampTint(look.charge));
|
||||
|
||||
// A zero-height bar is a degenerate mesh rather than an absent one, so the
|
||||
// floor of the scale is a millimetre and visibility carries the rest.
|
||||
chargeBar.scale.y = Math.max(0.001, look.chargeFraction * 0.62);
|
||||
chargeBar.visible = look.chargeFraction > 0.005;
|
||||
|
||||
cabinMaterial.emissiveIntensity = look.cabinGlow * CABIN_GLOW_MAX_INTENSITY;
|
||||
cabinGlow.visible = look.cabinGlow > 0.01;
|
||||
|
||||
cable.visible = telemetry.pluggedIn;
|
||||
}
|
||||
|
||||
return {
|
||||
object: root,
|
||||
apply,
|
||||
dispose() {
|
||||
// The rig owns its own materials (nothing external was handed in), so it
|
||||
// frees them; `disposeModelX` already defaults to exactly that when
|
||||
// `ownsMaterials` is true, and it is stated here rather than implied.
|
||||
disposeModelX(rig, { disposeMaterials: true });
|
||||
for (const geometry of ownedGeometries) geometry.dispose();
|
||||
ownedGeometries.length = 0;
|
||||
for (const material of ownedMaterials) material.dispose();
|
||||
ownedMaterials.length = 0;
|
||||
root.clear();
|
||||
apron.clear();
|
||||
vehicle.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Re-exported so a caller can size a bay without reaching into `transport/`. */
|
||||
export type { ApronMetrics };
|
||||
@@ -35,7 +35,15 @@ export interface RoadTrafficOptions {
|
||||
seed?: number;
|
||||
/** Vehicle metres to scene units. State-scale cars are intentional glyphs. */
|
||||
scale?: number;
|
||||
/** Route-distance compression for playable corridor travel. Defaults to 900. */
|
||||
/**
|
||||
* Route-distance compression for playable corridor travel. Defaults to 900.
|
||||
*
|
||||
* The counterpart is `METRE_SCALE_VEHICLE_OPTIONS` in
|
||||
* `transport/exteriorVehicle.ts`, which is this same controller at 1 for the
|
||||
* apron outside a studio. One state machine, one dial; see the note on
|
||||
* `VehicleControllerOptions.travelScale` for what the dial does and does not
|
||||
* touch.
|
||||
*/
|
||||
travelScale?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -300,6 +300,20 @@ export interface SatelliteLayer {
|
||||
group: THREE.Group;
|
||||
/** Redraw from a set of fixes. Cheap enough to call every frame, and is. */
|
||||
update(fixes: SatelliteFix[]): void;
|
||||
/**
|
||||
* How dark the sky is, 0..1 — `nightFactor(sun.elevation)` from
|
||||
* `atmosphere.ts`, and nothing else.
|
||||
*
|
||||
* This layer draws additively, which is correct at night and catastrophic in
|
||||
* daylight: at 15:55 with the sun at +44° every dot was adding to an already
|
||||
* bright sky and clipping to a hard white square, which is what a first-time
|
||||
* visitor to the California board saw scattered across the frame before
|
||||
* anything else registered. A satellite in daylight is not visible to the
|
||||
* naked eye, so the honest alpha is zero — and the fade is the *same* curve
|
||||
* `nightlights.ts` switches the city on with, so the sky does not empty at a
|
||||
* different dusk from the one the windows light up at.
|
||||
*/
|
||||
setSkyDarkness(darkness: number): void;
|
||||
/**
|
||||
* Whether the layer draws at all. The catalogue keeps propagating either way —
|
||||
* see `setVisible` for why that is deliberate rather than wasteful.
|
||||
@@ -481,6 +495,24 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
|
||||
|
||||
const scratchVec = new THREE.Vector3();
|
||||
|
||||
/**
|
||||
* 1 until somebody says otherwise, so a caller that never calls
|
||||
* `setSkyDarkness` gets exactly the behaviour this layer had before it
|
||||
* existed. A silent regression to an invisible sky would be worse than the
|
||||
* defect being fixed.
|
||||
*/
|
||||
let skyDarkness = 1;
|
||||
/** Whether the godmode switch wants this layer at all. Two questions, two flags. */
|
||||
let wanted = true;
|
||||
|
||||
/** The hour outranks the switch: a god at noon still gets no white squares. */
|
||||
function applyVisibility(): void {
|
||||
// Below a fiftieth the dots contribute nothing a screen can show, and
|
||||
// skipping the draw entirely is what makes the daytime cost of this layer
|
||||
// zero rather than merely invisible.
|
||||
group.visible = wanted && skyDarkness > 0.02;
|
||||
}
|
||||
|
||||
function update(fixes: SatelliteFix[]): void {
|
||||
let n = 0;
|
||||
for (const fix of fixes) {
|
||||
@@ -500,10 +532,11 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
|
||||
colors[n * 4] = scratch.r;
|
||||
colors[n * 4 + 1] = scratch.g;
|
||||
colors[n * 4 + 2] = scratch.b;
|
||||
colors[n * 4 + 3] = horizon * (SHADOW_ALPHA + (1 - SHADOW_ALPHA) * lit);
|
||||
colors[n * 4 + 3] = horizon * (SHADOW_ALPHA + (1 - SHADOW_ALPHA) * lit) * skyDarkness;
|
||||
n += 1;
|
||||
}
|
||||
|
||||
applyVisibility();
|
||||
geo.setDrawRange(0, n);
|
||||
positionAttr.needsUpdate = true;
|
||||
colorAttr.needsUpdate = true;
|
||||
@@ -512,6 +545,10 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
|
||||
return {
|
||||
group,
|
||||
update,
|
||||
setSkyDarkness(darkness) {
|
||||
skyDarkness = Math.min(1, Math.max(0, darkness));
|
||||
applyVisibility();
|
||||
},
|
||||
/**
|
||||
* Hiding the layer stops it drawing and does **not** stop the catalogue
|
||||
* propagating, which is the right way round: turning the sky back on should
|
||||
@@ -521,7 +558,8 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
|
||||
* re-entry is worth more than reclaiming it.
|
||||
*/
|
||||
setVisible(visible: boolean) {
|
||||
group.visible = visible;
|
||||
wanted = visible;
|
||||
applyVisibility();
|
||||
},
|
||||
dispose() {
|
||||
geo.dispose();
|
||||
|
||||
+125
-12
@@ -34,6 +34,7 @@ import { createFlightLayer, type FlightLayer } from "./flights.ts";
|
||||
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
|
||||
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
|
||||
import { solarPosition, sunDirection } from "./solar.ts";
|
||||
import { nightFactor } from "./atmosphere.ts";
|
||||
import { createStarlinkMeshLayer, type StarlinkMeshLayer } from "./starlinkMesh.ts";
|
||||
import {
|
||||
createSatelliteLayer,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
type SatelliteLayer,
|
||||
} from "./satellites.ts";
|
||||
import { createSceneKit, type Pose } from "./scenekit.ts";
|
||||
import type { EnvironmentRig } from "./environmentRig.ts";
|
||||
import {
|
||||
createRoadTrafficLayer,
|
||||
type RoadTrafficLayer,
|
||||
@@ -55,6 +57,7 @@ import type {
|
||||
import { createBridges, createFreewayWorld, createRoads } from "./structures.ts";
|
||||
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
|
||||
import type {
|
||||
Aircraft,
|
||||
Chapter,
|
||||
City,
|
||||
FlightSource,
|
||||
@@ -84,6 +87,11 @@ import { cityControlOwnership, type CityControlMode } from "../play/controlMode.
|
||||
|
||||
export type CityRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt">;
|
||||
|
||||
/** What the pointer is over: an authored place, or an observed aeroplane. */
|
||||
type Pick =
|
||||
| { kind: "marker"; marker: Marker }
|
||||
| { kind: "aircraft"; aircraft: Aircraft };
|
||||
|
||||
export interface SceneOptions {
|
||||
city: City;
|
||||
markerPalette?: MarkerPalette;
|
||||
@@ -118,6 +126,34 @@ export interface SceneOptions {
|
||||
satellites?: SatelliteCatalogue;
|
||||
/** Fires on hover/click of a marker head. */
|
||||
onMarkerPick?: (marker: Marker | null) => void;
|
||||
/**
|
||||
* Fires on hover of an aeroplane, and with `null` as the pointer leaves one.
|
||||
*
|
||||
* The same shape as `onMarkerPick` and for the same reason: `scenekit` reports
|
||||
* picks by hover, so a click handler upstairs reads whatever the last hover
|
||||
* resolved. What comes back is the engine's own `Aircraft` — a position and a
|
||||
* callsign — and nothing about where it came from, because that is a question
|
||||
* about the deployment and `adapters/http.ts` is the layer that can answer it.
|
||||
*
|
||||
* Not gated on anything. An ADS-B position is broadcast in clear to anybody
|
||||
* with a receiver, so there is nothing here an account could grant; see
|
||||
* `owner-decisions.md` and the note on `TrafficSource.detail`.
|
||||
*/
|
||||
onAircraftPick?: (aircraft: Aircraft | null) => void;
|
||||
/**
|
||||
* The shared environment map, when the page has one.
|
||||
*
|
||||
* Handed in rather than built here, and that is the whole of the wiring rule:
|
||||
* a `PMREMGenerator` and its render targets belong to the **renderer**, which
|
||||
* outlives every city on the page, so one rig is built beside the `Stage` and
|
||||
* shared. A rig per `createScene` would allocate a fresh blur chain and a
|
||||
* fresh target for every board and leak both on the next switch, which is
|
||||
* exactly the arithmetic `stage.ts` records for the renderer itself.
|
||||
*
|
||||
* Absent, everything renders as it did before the rig existed: duller metal,
|
||||
* no sky in the water, and no error.
|
||||
*/
|
||||
environment?: EnvironmentRig;
|
||||
/**
|
||||
* Opening light rig. Comes from an `Atmosphere` when there is one; without
|
||||
* one the city gets `cityDaylight()`, because a scene that renders black
|
||||
@@ -356,6 +392,9 @@ export async function createScene(
|
||||
// two disagree for the one frame before the app's first `setLighting`.
|
||||
const opening = options.lighting ?? cityDaylight(pal, boardSpan);
|
||||
kit.applyLighting(opening);
|
||||
// The environment before the first layer is added, so the very first frame
|
||||
// has a sky to reflect rather than acquiring one a `setLighting` later.
|
||||
options.environment?.apply(scene, opening, "city");
|
||||
|
||||
scene.add(createWater(world));
|
||||
scene.add(createShorePlates(world));
|
||||
@@ -443,6 +482,15 @@ export async function createScene(
|
||||
|
||||
let flightLayer: FlightLayer | null = null;
|
||||
let flightTimer = 0;
|
||||
/**
|
||||
* The last observation, by id, so a pick has something to hand back.
|
||||
*
|
||||
* The layer interpolates between observations and keeps no record a caller
|
||||
* could read; this is the record. Rebuilt wholesale on every poll rather than
|
||||
* merged, so an aeroplane that has left the region leaves this table with it
|
||||
* and a card cannot be opened on a track that is no longer in the sky.
|
||||
*/
|
||||
const lastAircraft = new Map<string, Aircraft>();
|
||||
if (options.flights) {
|
||||
flightLayer = createFlightLayer(world);
|
||||
scene.add(flightLayer.group);
|
||||
@@ -553,12 +601,38 @@ export async function createScene(
|
||||
|
||||
// ---- Picking ------------------------------------------------------------
|
||||
|
||||
// `pickables` is mutated in place by the layer, so the array itself is the
|
||||
// live target list.
|
||||
kit.setPicking<Marker>({
|
||||
targets: markerLayer.pickables,
|
||||
resolve: (hit) => (hit.object.userData.marker as Marker | undefined) ?? null,
|
||||
onChange: (marker) => options.onMarkerPick?.(marker),
|
||||
/**
|
||||
* Two things on this board are worth pointing at, and both are resolved here.
|
||||
*
|
||||
* `markerLayer.pickables` and `flightLayer.pickables` are both mutated in
|
||||
* place by their layers, so neither array can simply be concatenated once —
|
||||
* the picking target list has to be a getter that reads both at the moment of
|
||||
* the test. A pin is an authored place; an aeroplane is an observation, and
|
||||
* `Pick` keeps them apart as a union rather than flattening both to a string,
|
||||
* because the aircraft card is five fields and a provenance line and the
|
||||
* moment it becomes a sentence it can never be anything else again.
|
||||
*/
|
||||
const pickTargets = (): THREE.Object3D[] =>
|
||||
flightLayer === null
|
||||
? markerLayer.pickables
|
||||
: [...markerLayer.pickables, ...flightLayer.pickables];
|
||||
|
||||
kit.setPicking<Pick>({
|
||||
targets: pickTargets,
|
||||
resolve: (hit) => {
|
||||
const marker = hit.object.userData.marker as Marker | undefined;
|
||||
if (marker) return { kind: "marker", marker };
|
||||
const id = hit.object.userData.aircraftId as string | undefined;
|
||||
const aircraft = id === undefined ? undefined : lastAircraft.get(id);
|
||||
return aircraft ? { kind: "aircraft", aircraft } : null;
|
||||
},
|
||||
onChange: (picked) => {
|
||||
// Both callbacks fire on every change, including the change back to
|
||||
// `null`, so whichever card is up is retired by a pointer that leaves —
|
||||
// and by a pointer that moves from a pin straight onto an aeroplane.
|
||||
options.onMarkerPick?.(picked?.kind === "marker" ? picked.marker : null);
|
||||
options.onAircraftPick?.(picked?.kind === "aircraft" ? picked.aircraft : null);
|
||||
},
|
||||
});
|
||||
|
||||
// ---- The scene, as the stage sees it ------------------------------------
|
||||
@@ -588,7 +662,11 @@ export async function createScene(
|
||||
flightTimer -= dt;
|
||||
if (flightTimer <= 0) {
|
||||
flightTimer = options.flights.interval;
|
||||
void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac));
|
||||
void Promise.resolve(options.flights.poll()).then((ac) => {
|
||||
lastAircraft.clear();
|
||||
for (const a of ac) lastAircraft.set(a.id, a);
|
||||
flightLayer?.update(ac);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Every frame and on no timer of its own. The catalogue's sweep is
|
||||
@@ -610,16 +688,40 @@ export async function createScene(
|
||||
* dusk geometry that lights a Starlink pass.
|
||||
*/
|
||||
const when = skyOverride ?? new Date();
|
||||
const solar = solarPosition(city.center.lat, city.center.lng, when);
|
||||
/**
|
||||
* The sky's own brightness, and the fix for the worst thing on this
|
||||
* board at first load.
|
||||
*
|
||||
* Both satellite layers draw light *added* to the sky: the dot cloud is
|
||||
* `AdditiveBlending` and the near-field buses are unlit white. That is
|
||||
* exactly right against a night sky and is a hard white square against a
|
||||
* daytime one — which is what the California board showed at 15:55 with
|
||||
* the sun at +44°, scattered across the frame, reading as render
|
||||
* artefacts before anything else on the page registered.
|
||||
*
|
||||
* `nightFactor` is `atmosphere.ts`'s own dusk curve and is deliberately
|
||||
* the same one `nightlights.ts` switches the city on with, so the sky
|
||||
* does not empty at a different dusk from the one the windows light up
|
||||
* at. It is computed here rather than taken from the rig for the reason
|
||||
* the sun vector below is: `atmosphere.ts` floors the *rig's* light
|
||||
* direction at `shadowFloorDeg` to keep the shadow camera usable, and a
|
||||
* sun pinned above the horizon is precisely the wrong input for a
|
||||
* question about how dark it is.
|
||||
*/
|
||||
const darkness = nightFactor(solar.elevation);
|
||||
satelliteLayer.setSkyDarkness(darkness);
|
||||
starlinkMeshes?.setSkyDarkness(darkness);
|
||||
const fixes = options.satellites.fixes(when);
|
||||
satelliteLayer.update(fixes);
|
||||
starlinkMeshes?.update(
|
||||
fixes,
|
||||
kit.camera,
|
||||
sunDirection(solarPosition(city.center.lat, city.center.lng, when)),
|
||||
);
|
||||
starlinkMeshes?.update(fixes, kit.camera, sunDirection(solar));
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
// Before anything else frees a texture: the rig holds this scene in a
|
||||
// ledger so a rebuilt environment can be pushed to every scene using it,
|
||||
// and a disposed city left in that ledger is the whole graph retained.
|
||||
options.environment?.release(scene);
|
||||
options.flights?.dispose?.();
|
||||
flightLayer?.dispose();
|
||||
satelliteLayer?.dispose();
|
||||
@@ -654,6 +756,17 @@ export async function createScene(
|
||||
setLighting: (state) => {
|
||||
kit.applyLighting(state);
|
||||
clouds.setLighting(state);
|
||||
/**
|
||||
* Every lighting change, and it is cheap to do it every one.
|
||||
*
|
||||
* The rig fingerprints the state coarsely and rebuilds only when the
|
||||
* fingerprint moves, so an unchanged sky is a map lookup and an
|
||||
* assignment. Calling it here rather than on a timer of its own is what
|
||||
* keeps CONTRACT §4's single direction intact: `Atmosphere` decided this
|
||||
* rig, the scene is applying it, and the environment is derived from the
|
||||
* decision rather than being a second opinion about the light.
|
||||
*/
|
||||
options.environment?.apply(scene, state, "city");
|
||||
},
|
||||
setCloudCover: (fraction) => clouds.setCover(fraction),
|
||||
setWind: (kph, fromDeg) => clouds.setWind(kph, fromDeg),
|
||||
|
||||
@@ -83,8 +83,35 @@ export interface StageOptions {
|
||||
/** Device pixel ratio ceiling. Defaults to `deviceProfile().maxPixelRatio`. */
|
||||
maxPixelRatio?: number;
|
||||
shadows?: boolean;
|
||||
/**
|
||||
* Tone mapping exposure. Defaults to `DEFAULT_TONE_MAPPING_EXPOSURE`.
|
||||
*
|
||||
* Live afterwards as `stage.renderer.toneMappingExposure` — the renderer is
|
||||
* on the `Stage` for exactly this kind of reason, and a `setExposure` method
|
||||
* would widen the interface CONTRACT.md §1 pins down for one assignment.
|
||||
*/
|
||||
exposure?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exposure the world is tuned at.
|
||||
*
|
||||
* ACES takes linear radiance, divides by 0.6, runs the RRT+ODT fit and saturates,
|
||||
* so `toneMappingExposure` is a photographic stop dial and not a brightness
|
||||
* slider: at 1.0 an 18% grey card lands on 0.5 sRGB, which is the definition of
|
||||
* the curve being *neutral*. 1.15 is a little over a fifth of a stop above
|
||||
* neutral, and it is there because ACES darkens the bottom of the range — a
|
||||
* linear 0.02 that used to display at 0.152 comes out at 0.080 — and this world
|
||||
* spends a third of its day at night. The lift buys most of that back at the
|
||||
* bottom while the shoulder eats it at the top, where nothing is left to lose.
|
||||
*
|
||||
* Exported because the number is a fact about the whole picture, not about this
|
||||
* file: `atmosphere.ts`'s keyframe table is tuned against this exposure and
|
||||
* `environmentRig.ts` builds its sky radiances to sit under the same shoulder.
|
||||
* Anything that changes it is changing all three.
|
||||
*/
|
||||
export const DEFAULT_TONE_MAPPING_EXPOSURE = 1.15;
|
||||
|
||||
/**
|
||||
* What kind of machine this is, to the extent a browser will say.
|
||||
*
|
||||
@@ -172,6 +199,59 @@ export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {
|
||||
Math.min(window.devicePixelRatio, options.maxPixelRatio ?? profile.maxPixelRatio),
|
||||
);
|
||||
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
|
||||
|
||||
/**
|
||||
* The two lines that decide what every colour in the product looks like.
|
||||
*
|
||||
* ### Why the output colour space is written down
|
||||
*
|
||||
* `outputColorSpace` has defaulted to `SRGBColorSpace` since r152, and this
|
||||
* file relied on that default for its whole life. That is a bet on a default
|
||||
* staying put across a library this repo pins with a caret, and the same bet
|
||||
* lost once already three lines below: `PCFSoftShadowMap` was silently
|
||||
* demoted to unfiltered basic shadows by an upstream change nobody here saw.
|
||||
* A renderer's output transfer function is not a thing to inherit quietly —
|
||||
* if it ever moves, every texture, palette and keyframe in the repo is wrong
|
||||
* at once and the symptom is "the whole app looks washed out", which is the
|
||||
* least diagnosable bug shape there is. So it is stated.
|
||||
*
|
||||
* ### Why ACES, and what it fixes
|
||||
*
|
||||
* `NoToneMapping` is not "no transform". It is `saturate()`: everything above
|
||||
* linear 1.0 becomes exactly 1.0, and every distinction above that value is
|
||||
* destroyed before the sRGB encode ever runs. This world drives values well
|
||||
* past 1.0 on purpose — `atmosphere.ts` peaks the sun above 2.3, the office
|
||||
* assets set `emissiveIntensity` up to 3.2 — so the brightest and most
|
||||
* expensive third of the lighting range was being flattened into a single
|
||||
* flat white. That is the whole explanation for the two worst-looking things
|
||||
* in the product: sunlit walls with no shading gradient left in them, and
|
||||
* every light fitting and screen rendering as an identical white rectangle
|
||||
* regardless of how bright it was told to be.
|
||||
*
|
||||
* ACES filmic replaces the cliff with a shoulder. Linear 1.0 displays at
|
||||
* about 0.90, 2.0 at 0.95, 4.0 at 0.98 — still separable, all the way up — so
|
||||
* a diffuser at 0.85 and a screen at 3.2 finally look like different things.
|
||||
* It costs a little in the shadows, where the toe is steeper than a plain
|
||||
* gamma encode; the exposure above and the re-tuned night stops in
|
||||
* `atmosphere.ts` are what pay that back. Both halves of that trade are
|
||||
* required. Turning this on and leaving the intensity table alone gives a
|
||||
* world that is correctly *shaped* and too dark, which reads as worse.
|
||||
*
|
||||
* Two things this deliberately does not touch, and it is worth knowing which
|
||||
* so nobody goes hunting for a horizon seam that is not there. Three sets
|
||||
* `toneMapped = false` on the background mesh whenever the background texture
|
||||
* carries an sRGB transfer — `scenekit.ts`'s sky gradient does — and fog is
|
||||
* mixed in `fog_fragment` *after* both `tonemapping_fragment` and
|
||||
* `colorspace_fragment`, from a uniform already converted into the renderer's
|
||||
* output space. So the sky and the fog are still displayed exactly as
|
||||
* `atmosphere.ts` authored them, and they still agree with each other at the
|
||||
* horizon. Only lit geometry moves, which is precisely the surface the
|
||||
* intensity table controls.
|
||||
*/
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = options.exposure ?? DEFAULT_TONE_MAPPING_EXPOSURE;
|
||||
|
||||
if (options.shadows ?? true) {
|
||||
renderer.shadowMap.enabled = true;
|
||||
/**
|
||||
|
||||
@@ -320,6 +320,18 @@ export interface StarlinkMeshLayer {
|
||||
* pointing its solar panels at yesterday afternoon.
|
||||
*/
|
||||
update(fixes: readonly SatelliteFix[], camera: THREE.Camera, sun: SunVector): void;
|
||||
/**
|
||||
* How dark the sky is, 0..1 — `nightFactor(sun.elevation)`, the same number
|
||||
* `SatelliteLayer` takes and from the same call site.
|
||||
*
|
||||
* These buses are unlit white boxes (`MeshBasicMaterial({ color: 0xffffff })`)
|
||||
* because that is what a sunlit satellite twenty pixels across looks like
|
||||
* against a night sky. Under a tone curve, against a *daytime* sky, they are
|
||||
* the literal white squares live defect 1 named — the most damaging thing on
|
||||
* the board at first load, and drawn at 15:55 with the sun at +44°, where no
|
||||
* naked eye would see a satellite at all.
|
||||
*/
|
||||
setSkyDarkness(darkness: number): void;
|
||||
setVisible(visible: boolean): void;
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -382,6 +394,14 @@ export function createStarlinkMeshLayer(options: StarlinkMeshOptions): StarlinkM
|
||||
* twelve kilometres of air a city sits in. This is 550 km above all of it.
|
||||
*/
|
||||
const busMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, fog: false });
|
||||
|
||||
/**
|
||||
* Whether the caller wants this layer, and whether the sky is dark enough for
|
||||
* it to be honest. Both have to be true, and they are two different questions:
|
||||
* the first is a godmode switch and the second is the hour.
|
||||
*/
|
||||
let wanted = true;
|
||||
let skyDarkness = 1;
|
||||
const arrayMaterial = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
fog: false,
|
||||
@@ -799,6 +819,14 @@ export function createStarlinkMeshLayer(options: StarlinkMeshOptions): StarlinkM
|
||||
return {
|
||||
group,
|
||||
update,
|
||||
setSkyDarkness(darkness: number) {
|
||||
skyDarkness = Math.min(1, Math.max(0, darkness));
|
||||
// Whole-group rather than a per-material opacity, because these two
|
||||
// materials are opaque by design: making them `transparent` to fade them
|
||||
// would buy a sort order and a blend for objects that are never partly
|
||||
// visible — they are either in a sky you could see them in or they are not.
|
||||
group.visible = wanted && skyDarkness > 0.02;
|
||||
},
|
||||
/**
|
||||
* Unlike `SatelliteLayer.setVisible`, this one also stops the work — see the
|
||||
* early return in `update`. The distinction is not an inconsistency: that
|
||||
@@ -808,7 +836,8 @@ export function createStarlinkMeshLayer(options: StarlinkMeshOptions): StarlinkM
|
||||
* on and the next visible frame is complete.
|
||||
*/
|
||||
setVisible(visible: boolean) {
|
||||
group.visible = visible;
|
||||
wanted = visible;
|
||||
group.visible = visible && skyDarkness > 0.02;
|
||||
},
|
||||
dispose() {
|
||||
// The instanced meshes first. `InstancedMesh.dispose()` releases the
|
||||
|
||||
+249
-82
@@ -5,15 +5,145 @@
|
||||
* Roads follow the terrain: each path is resampled far more finely than it is
|
||||
* written in the city pack, and every sample takes its height from the ground,
|
||||
* so a street climbs out of the flats instead of burrowing through the hill.
|
||||
*
|
||||
* ### Everything here is batched, and it has to be
|
||||
*
|
||||
* The city ran at 616 draw calls against a budget of 650 while the office spent
|
||||
* 8% of its triangle budget: quality is nearly free indoors and is not free at
|
||||
* all out here, so anything this module can hand back is headroom the exterior
|
||||
* vehicles and the aircraft get to spend. Batching the corridor and the bridges
|
||||
* took the California board to 557 measured — 59 calls, from 59 freeway meshes
|
||||
* down to 20 plus the four extra shadow-pass draws the guardrails and sign
|
||||
* posts used to cost.
|
||||
*
|
||||
* Two rules keep it honest, and both were broken before:
|
||||
*
|
||||
* 1. **Materials are cached by colour**, in a `Batch` that lives as long as
|
||||
* the build call. Twelve identical asphalt decks used to be twelve
|
||||
* `MeshLambertMaterial`s, which is twelve things that can never merge, and
|
||||
* a single suspension bridge minted a fresh material for its deck, each
|
||||
* tower, each brace, each cable and each hanger — about thirty-four.
|
||||
* 2. **Geometry is merged per material.** Every helper below returns a
|
||||
* `BufferGeometry` rather than a `Mesh`, and the caller drops it into a
|
||||
* named bucket; one mesh comes out per bucket at the end.
|
||||
*
|
||||
* The cache is deliberately *not* module-level. `createScene().dispose()` walks
|
||||
* the scene and disposes every material it finds, so a cache that outlived one
|
||||
* build would hand the next board a disposed material and render it black.
|
||||
*
|
||||
* The corollary for anyone adding a helper here: give every geometry the **same
|
||||
* attribute set** — position, normal, uv, indexed — or `mergeGeometries`
|
||||
* refuses the bucket and silently drops it. That is why the ribbons below carry
|
||||
* UVs they have no texture for.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
||||
import { buildFreewayWorldPlan } from "../transport/freewayWorld.ts";
|
||||
import type { TransportPack } from "../transport/types.ts";
|
||||
import { buildRoutePath, sampleRoute } from "../transport/vehicleSim.ts";
|
||||
import type { Bridge, LatLng } from "./types.ts";
|
||||
import type { World } from "./world.ts";
|
||||
|
||||
// ---- Batching -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The three ways a surface out here is shaded.
|
||||
*
|
||||
* `deck` and `solid` differ only in sidedness: a road deck is a one-sided strip
|
||||
* that has to survive being looked at from underneath on a bridge approach, and
|
||||
* a tower is a closed solid where a back face is a waste.
|
||||
*
|
||||
* `marking` is unlit and `toneMapped: false` on purpose. Paint on a road is the
|
||||
* one thing in the frame whose job is to be a fixed, known white — it is
|
||||
* retroreflective, it is what a driver navigates by, and putting it through the
|
||||
* ACES shoulder with everything else turns a lane line into a grey smear at
|
||||
* midday and loses it entirely at dusk.
|
||||
*/
|
||||
type SurfaceKind = "deck" | "solid" | "marking";
|
||||
|
||||
interface Bucket {
|
||||
readonly name: string;
|
||||
readonly material: THREE.Material;
|
||||
readonly castShadow: boolean;
|
||||
readonly receiveShadow: boolean;
|
||||
readonly parts: THREE.BufferGeometry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One build's worth of materials and geometry, merged on the way out.
|
||||
*
|
||||
* Buckets are keyed on **name and material together** rather than on the
|
||||
* material alone. Sharing the material is what saves the draw call; keeping the
|
||||
* name is what lets somebody looking at the scene graph still find the
|
||||
* guardrails, and the one extra call it costs where two classes happen to share
|
||||
* a material is worth being able to debug the thing.
|
||||
*/
|
||||
class Batch {
|
||||
private readonly materials = new Map<string, THREE.Material>();
|
||||
private readonly buckets = new Map<string, Bucket>();
|
||||
|
||||
/** The one material for a kind and colour in this build. */
|
||||
material(kind: SurfaceKind, color: number): THREE.Material {
|
||||
const key = `${kind}:${color.toString(16)}`;
|
||||
const hit = this.materials.get(key);
|
||||
if (hit) return hit;
|
||||
const made =
|
||||
kind === "marking"
|
||||
? new THREE.MeshBasicMaterial({ color, toneMapped: false, side: THREE.DoubleSide })
|
||||
: new THREE.MeshLambertMaterial({
|
||||
color,
|
||||
side: kind === "deck" ? THREE.DoubleSide : THREE.FrontSide,
|
||||
});
|
||||
made.name = key;
|
||||
this.materials.set(key, made);
|
||||
return made;
|
||||
}
|
||||
|
||||
add(
|
||||
name: string,
|
||||
geometry: THREE.BufferGeometry,
|
||||
material: THREE.Material,
|
||||
shadows: { cast?: boolean; receive?: boolean } = {},
|
||||
): void {
|
||||
const key = `${material.uuid}|${name}`;
|
||||
const bucket = this.buckets.get(key);
|
||||
if (bucket) {
|
||||
bucket.parts.push(geometry);
|
||||
return;
|
||||
}
|
||||
this.buckets.set(key, {
|
||||
name,
|
||||
material,
|
||||
castShadow: shadows.cast ?? false,
|
||||
receiveShadow: shadows.receive ?? true,
|
||||
parts: [geometry],
|
||||
});
|
||||
}
|
||||
|
||||
/** Merge every bucket and hang the results off `into`. */
|
||||
flush(into: THREE.Group): void {
|
||||
for (const bucket of this.buckets.values()) {
|
||||
const merged =
|
||||
bucket.parts.length === 1 ? bucket.parts[0] : mergeGeometries(bucket.parts, false);
|
||||
// `mergeGeometries` returns null when the attribute sets disagree. Losing
|
||||
// the bucket silently is exactly the failure the module comment warns
|
||||
// about, so say so rather than rendering a road with no markings on it.
|
||||
if (!merged) {
|
||||
console.warn(`structures: "${bucket.name}" has mismatched attributes and was not merged`);
|
||||
continue;
|
||||
}
|
||||
if (bucket.parts.length > 1) for (const part of bucket.parts) part.dispose();
|
||||
const mesh = new THREE.Mesh(merged, bucket.material);
|
||||
mesh.name = bucket.name;
|
||||
mesh.castShadow = bucket.castShadow;
|
||||
mesh.receiveShadow = bucket.receiveShadow;
|
||||
into.add(mesh);
|
||||
}
|
||||
this.buckets.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Resample a lat/lng path into scene-space points that ride the ground. */
|
||||
function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14): THREE.Vector3[] {
|
||||
const out: THREE.Vector3[] = [];
|
||||
@@ -35,25 +165,32 @@ function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14
|
||||
return out;
|
||||
}
|
||||
|
||||
function ribbon(points: THREE.Vector3[], width: number, color: number): THREE.Mesh {
|
||||
/** A tube swept along a path — a bridge deck, a cable, a barrier. */
|
||||
function tubeGeometry(points: THREE.Vector3[], width: number, radial = 4): THREE.BufferGeometry {
|
||||
const curve = new THREE.CatmullRomCurve3(points);
|
||||
const geo = new THREE.TubeGeometry(curve, points.length * 2, width / 2, 4, false);
|
||||
const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ color }));
|
||||
mesh.receiveShadow = true;
|
||||
return mesh;
|
||||
return new THREE.TubeGeometry(curve, points.length * 2, width / 2, radial, false);
|
||||
}
|
||||
|
||||
/** A draped, flat road deck. A tube turns a freeway into a raised pipeline. */
|
||||
function roadRibbon(
|
||||
/**
|
||||
* A draped, flat road deck. A tube turns a freeway into a raised pipeline.
|
||||
*
|
||||
* The UVs run 0..1 across the carriageway and in **metres** along it, which is
|
||||
* the sane convention if anyone ever puts a surface texture on a road. Right
|
||||
* now nothing does, and they are here for a duller reason: `mergeGeometries`
|
||||
* only merges geometries whose attribute sets match exactly, so a strip without
|
||||
* UVs cannot share a bucket with the tube barriers beside it.
|
||||
*/
|
||||
function roadRibbonGeometry(
|
||||
points: readonly THREE.Vector3[],
|
||||
width: number,
|
||||
color: number,
|
||||
lift = 0,
|
||||
): THREE.Mesh {
|
||||
): THREE.BufferGeometry {
|
||||
const positions: number[] = [];
|
||||
const normals: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const indices: number[] = [];
|
||||
const half = width / 2;
|
||||
let along = 0;
|
||||
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const point = points[index];
|
||||
@@ -65,11 +202,13 @@ function roadRibbon(
|
||||
const length = Math.hypot(dx, dz) || 1;
|
||||
const nx = -dz / length;
|
||||
const nz = dx / length;
|
||||
if (index > 0) along += point.distanceTo(previous);
|
||||
positions.push(
|
||||
point.x + nx * half, point.y + lift, point.z + nz * half,
|
||||
point.x - nx * half, point.y + lift, point.z - nz * half,
|
||||
);
|
||||
normals.push(0, 1, 0, 0, 1, 0);
|
||||
uvs.push(0, along, 1, along);
|
||||
if (index < points.length - 1) {
|
||||
const a = index * 2;
|
||||
indices.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
|
||||
@@ -79,14 +218,10 @@ function roadRibbon(
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3));
|
||||
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
|
||||
geometry.setIndex(indices);
|
||||
geometry.computeBoundingSphere();
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide }),
|
||||
);
|
||||
mesh.receiveShadow = true;
|
||||
return mesh;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vector3[] {
|
||||
@@ -100,15 +235,15 @@ function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vec
|
||||
});
|
||||
}
|
||||
|
||||
/** Merge alternating path spans into one dashed marking mesh. */
|
||||
function dashedRibbon(
|
||||
/** Merge alternating path spans into one dashed marking geometry. */
|
||||
function dashedRibbonGeometry(
|
||||
points: readonly THREE.Vector3[],
|
||||
offset: number,
|
||||
width: number,
|
||||
color: number,
|
||||
): THREE.Mesh {
|
||||
): THREE.BufferGeometry {
|
||||
const shifted = offsetPath(points, offset);
|
||||
const positions: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const indices: number[] = [];
|
||||
for (let index = 0; index < shifted.length - 1; index += 2) {
|
||||
const a = shifted[index];
|
||||
@@ -127,18 +262,15 @@ function dashedRibbon(
|
||||
b.x + nx, b.y + 0.035, b.z + nz,
|
||||
b.x - nx, b.y + 0.035, b.z - nz,
|
||||
);
|
||||
uvs.push(0, 0, 1, 0, 0, 1, 1, 1);
|
||||
indices.push(base, base + 2, base + 1, base + 1, base + 2, base + 3);
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
|
||||
geometry.setIndex(indices);
|
||||
geometry.computeVertexNormals();
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
new THREE.MeshBasicMaterial({ color, toneMapped: false, side: THREE.DoubleSide }),
|
||||
);
|
||||
mesh.name = "freeway:lane-dashes";
|
||||
return mesh;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function makeShieldMaterial(identity: "us-highway" | "interstate", shield: string): THREE.Material {
|
||||
@@ -178,6 +310,7 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
const plan = buildFreewayWorldPlan(pack);
|
||||
group.userData.planSeed = plan.seed;
|
||||
|
||||
const batch = new Batch();
|
||||
const asphalt = [0x353a3d, 0x303538];
|
||||
const shoulder = [0x555759, 0x4e5153];
|
||||
const berm = [0x64705c, 0x74674c];
|
||||
@@ -211,6 +344,7 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
let poleCount = 0;
|
||||
let siloCount = 0;
|
||||
const dummy = new THREE.Object3D();
|
||||
const reflectorMatrices: THREE.Matrix4[] = [];
|
||||
|
||||
world.city.roads.forEach((road, roadIndex) => {
|
||||
if (road.kind !== "freeway") return;
|
||||
@@ -219,39 +353,58 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
const identityIndex = route?.identity === "interstate" ? 1 : 0;
|
||||
const routePath = route ? buildRoutePath(pack, route.routeId) : null;
|
||||
// Broad earthwork under separate decks makes grade and curve changes read.
|
||||
group.add(roadRibbon(path, 2.75, berm[identityIndex] ?? berm[0]!, -0.09));
|
||||
batch.add(
|
||||
"freeway:berm",
|
||||
roadRibbonGeometry(path, 2.75, -0.09),
|
||||
batch.material("deck", berm[identityIndex] ?? berm[0]!),
|
||||
);
|
||||
for (const side of [-1, 1] as const) {
|
||||
group.add(roadRibbon(offsetPath(path, side * 0.64), 1.18, shoulder[identityIndex] ?? shoulder[0]!, 0.004));
|
||||
group.add(roadRibbon(offsetPath(path, side * 0.64), 1.03, asphalt[identityIndex] ?? asphalt[0]!, 0.012));
|
||||
batch.add(
|
||||
"freeway:shoulder",
|
||||
roadRibbonGeometry(offsetPath(path, side * 0.64), 1.18, 0.004),
|
||||
batch.material("deck", shoulder[identityIndex] ?? shoulder[0]!),
|
||||
);
|
||||
batch.add(
|
||||
"freeway:carriageway",
|
||||
roadRibbonGeometry(offsetPath(path, side * 0.64), 1.03, 0.012),
|
||||
batch.material("deck", asphalt[identityIndex] ?? asphalt[0]!),
|
||||
);
|
||||
// Inner yellow edge, two lane dividers, outer white shoulder edge.
|
||||
group.add(roadRibbon(offsetPath(path, side * 0.12), 0.026, 0xf0c84f, 0.038));
|
||||
group.add(roadRibbon(offsetPath(path, side * 1.16), 0.026, 0xe8ece8, 0.038));
|
||||
group.add(dashedRibbon(path, side * 0.47, 0.022, 0xf4f4ec));
|
||||
group.add(dashedRibbon(path, side * 0.81, 0.022, 0xf4f4ec));
|
||||
batch.add(
|
||||
"freeway:edge-line",
|
||||
roadRibbonGeometry(offsetPath(path, side * 0.12), 0.026, 0.038),
|
||||
batch.material("deck", 0xf0c84f),
|
||||
);
|
||||
batch.add(
|
||||
"freeway:edge-line",
|
||||
roadRibbonGeometry(offsetPath(path, side * 1.16), 0.026, 0.038),
|
||||
batch.material("deck", 0xe8ece8),
|
||||
);
|
||||
const dashes = batch.material("marking", 0xf4f4ec);
|
||||
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.47, 0.022), dashes);
|
||||
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.81, 0.022), dashes);
|
||||
const guardPath = offsetPath(path, side * 1.27);
|
||||
const guard = new THREE.Mesh(
|
||||
batch.add(
|
||||
"freeway:outer-guardrail",
|
||||
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length * 2), 0.025, 5, false),
|
||||
guardMaterial,
|
||||
{ cast: true },
|
||||
);
|
||||
guard.name = "freeway:outer-guardrail";
|
||||
guard.castShadow = true;
|
||||
group.add(guard);
|
||||
}
|
||||
// Low concrete median walls keep both carriageways visually independent.
|
||||
for (const side of [-1, 1] as const) {
|
||||
const medianPath = offsetPath(path, side * 0.075).map((point) => point.clone().setY(point.y + 0.065));
|
||||
const median = new THREE.Mesh(
|
||||
batch.add(
|
||||
"freeway:median-barrier",
|
||||
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length * 2), 0.055, 4, false),
|
||||
barrierMaterial,
|
||||
);
|
||||
median.name = "freeway:median-barrier";
|
||||
group.add(median);
|
||||
}
|
||||
// Retroreflectors are instanced and restrained, never roadside light blobs.
|
||||
// The matrices are collected across every corridor and committed to one
|
||||
// `InstancedMesh` after the loop, because two corridors' worth of the same
|
||||
// 0.018 m box is two draw calls for something nobody can resolve.
|
||||
const reflectorPoints = path.filter((_, index) => index % 2 === 0);
|
||||
const reflectors = new THREE.InstancedMesh(reflectorGeometry, reflectorMaterial, reflectorPoints.length * 4);
|
||||
reflectors.name = "freeway:reflectors";
|
||||
let reflectorIndex = 0;
|
||||
for (const pointIndex of reflectorPoints.keys()) {
|
||||
const point = reflectorPoints[pointIndex];
|
||||
if (!point) continue;
|
||||
@@ -261,11 +414,9 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
dummy.rotation.set(0, 0, 0);
|
||||
dummy.scale.setScalar(1);
|
||||
dummy.updateMatrix();
|
||||
reflectors.setMatrixAt(reflectorIndex++, dummy.matrix);
|
||||
reflectorMatrices.push(dummy.matrix.clone());
|
||||
}
|
||||
}
|
||||
reflectors.count = reflectorIndex;
|
||||
group.add(reflectors);
|
||||
|
||||
if (!route || !routePath) return;
|
||||
const shieldMaterial = makeShieldMaterial(route.identity, route.shield);
|
||||
@@ -278,17 +429,18 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
const pz = z + Math.sin(heading) * sceneSetback * feature.side;
|
||||
const ground = world.groundAt(sample.lat, sample.lng);
|
||||
if (feature.kind === "route-sign") {
|
||||
const sign = new THREE.Group();
|
||||
sign.name = `freeway:sign:${route.shield}`;
|
||||
const post = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.62, 0.035), guardMaterial);
|
||||
post.position.y = 0.31;
|
||||
const board = new THREE.Mesh(new THREE.PlaneGeometry(0.42, 0.31), shieldMaterial);
|
||||
board.position.y = 0.69;
|
||||
board.rotation.y = -heading + (feature.side === 1 ? Math.PI : 0);
|
||||
sign.add(post, board);
|
||||
sign.position.set(px, ground + 0.08, pz);
|
||||
sign.userData.routeId = route.routeId;
|
||||
group.add(sign);
|
||||
// Baked into world space rather than parented under a per-sign `Group`.
|
||||
// Nine signs used to be nine groups of two meshes; they are now two
|
||||
// meshes for the whole route, and the shield's own name survives on the
|
||||
// board so the scene graph still says which route it belongs to.
|
||||
const post = new THREE.BoxGeometry(0.035, 0.62, 0.035);
|
||||
post.translate(px, ground + 0.08 + 0.31, pz);
|
||||
batch.add("freeway:sign-post", post, guardMaterial, { cast: true });
|
||||
|
||||
const board = new THREE.PlaneGeometry(0.42, 0.31);
|
||||
board.rotateY(-heading + (feature.side === 1 ? Math.PI : 0));
|
||||
board.translate(px, ground + 0.08 + 0.69, pz);
|
||||
batch.add(`freeway:sign:${route.shield}`, board, shieldMaterial);
|
||||
continue;
|
||||
}
|
||||
const visualScale = feature.scale * 0.58;
|
||||
@@ -309,6 +461,18 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
}
|
||||
}
|
||||
});
|
||||
batch.flush(group);
|
||||
|
||||
const reflectors = new THREE.InstancedMesh(
|
||||
reflectorGeometry,
|
||||
reflectorMaterial,
|
||||
Math.max(1, reflectorMatrices.length),
|
||||
);
|
||||
reflectors.name = "freeway:reflectors";
|
||||
reflectorMatrices.forEach((matrix, index) => reflectors.setMatrixAt(index, matrix));
|
||||
reflectors.count = reflectorMatrices.length;
|
||||
group.add(reflectors);
|
||||
|
||||
trunks.count = trunkCount;
|
||||
poles.count = poleCount;
|
||||
silos.count = siloCount;
|
||||
@@ -321,16 +485,22 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
export function createRoads(world: World): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.name = "roads";
|
||||
const batch = new Batch();
|
||||
for (const road of world.city.roads) {
|
||||
const color = road.kind === "freeway" ? 0x7d7166 : 0x8b8578;
|
||||
const path = drapePath(world, road.path);
|
||||
group.add(roadRibbon(path, road.width, color));
|
||||
batch.add("road:deck", roadRibbonGeometry(path, road.width), batch.material("deck", color));
|
||||
if (road.kind === "freeway") {
|
||||
// One warm median stroke is enough at corridor scale to read as divided
|
||||
// highway without spending a textured asset or a draw call per lane.
|
||||
group.add(roadRibbon(path, Math.max(0.025, road.width * 0.035), 0xd7c27c, 0.012));
|
||||
batch.add(
|
||||
"road:median-stroke",
|
||||
roadRibbonGeometry(path, Math.max(0.025, road.width * 0.035), 0.012),
|
||||
batch.material("deck", 0xd7c27c),
|
||||
);
|
||||
}
|
||||
}
|
||||
batch.flush(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -347,32 +517,37 @@ export function createBridge(world: World, bridge: Bridge): THREE.Group {
|
||||
|
||||
const deckY = world.metres(bridge.deckHeight);
|
||||
const towerY = world.metres(bridge.towerHeight);
|
||||
const material = () => new THREE.MeshLambertMaterial({ color: bridge.color });
|
||||
|
||||
/**
|
||||
* One material for the whole bridge, and one mesh out of it.
|
||||
*
|
||||
* This used to read `const material = () => new THREE.MeshLambertMaterial(…)`
|
||||
* and be called once per part, so the Golden Gate arrived as about
|
||||
* thirty-four meshes with thirty-four identical materials — thirty-four draw
|
||||
* calls the sorter had to keep apart, for one orange object. Everything a
|
||||
* bridge is made of is painted the same colour, so everything a bridge is made
|
||||
* of belongs in one bucket.
|
||||
*/
|
||||
const batch = new Batch();
|
||||
const paint = batch.material("solid", bridge.color);
|
||||
const part = (geometry: THREE.BufferGeometry) =>
|
||||
batch.add(bridge.name, geometry, paint, { cast: true });
|
||||
|
||||
const deckPoints = bridge.path.map(([lat, lng]) => {
|
||||
const [x, z] = world.project(lat, lng);
|
||||
return new THREE.Vector3(x, deckY, z);
|
||||
});
|
||||
|
||||
const deck = ribbon(deckPoints, 0.5, bridge.color);
|
||||
deck.castShadow = true;
|
||||
group.add(deck);
|
||||
part(tubeGeometry(deckPoints, 0.5));
|
||||
|
||||
const towerTops: THREE.Vector3[] = [];
|
||||
for (const [lat, lng] of bridge.towers) {
|
||||
const [x, z] = world.project(lat, lng);
|
||||
const geo = new THREE.BoxGeometry(0.34, towerY, 0.34);
|
||||
geo.translate(0, towerY / 2, 0);
|
||||
const tower = new THREE.Mesh(geo, material());
|
||||
tower.position.set(x, 0, z);
|
||||
tower.castShadow = true;
|
||||
group.add(tower);
|
||||
part(new THREE.BoxGeometry(0.34, towerY, 0.34).translate(x, towerY / 2, z));
|
||||
|
||||
// Cross-braces, which is most of what you see of a tower at distance.
|
||||
for (const frac of [0.55, 0.82]) {
|
||||
const brace = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.16, 0.4), material());
|
||||
brace.position.set(x, towerY * frac, z);
|
||||
group.add(brace);
|
||||
part(new THREE.BoxGeometry(0.5, 0.16, 0.4).translate(x, towerY * frac, z));
|
||||
}
|
||||
towerTops.push(new THREE.Vector3(x, towerY, z));
|
||||
}
|
||||
@@ -392,12 +567,7 @@ export function createBridge(world: World, bridge: Bridge): THREE.Group {
|
||||
p.y -= Math.sin(t * Math.PI) * sag;
|
||||
pts.push(p);
|
||||
}
|
||||
group.add(
|
||||
new THREE.Mesh(
|
||||
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false),
|
||||
material(),
|
||||
),
|
||||
);
|
||||
part(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false));
|
||||
|
||||
// Vertical hangers down to the deck.
|
||||
for (let s = 2; s < 18; s += 2) {
|
||||
@@ -406,14 +576,11 @@ export function createBridge(world: World, bridge: Bridge): THREE.Group {
|
||||
const top = p.y - Math.sin(t * Math.PI) * sag;
|
||||
if (top <= deckY + 0.2) continue;
|
||||
const h = top - deckY;
|
||||
const geo = new THREE.BoxGeometry(0.035, h, 0.035);
|
||||
geo.translate(0, h / 2, 0);
|
||||
const hanger = new THREE.Mesh(geo, material());
|
||||
hanger.position.set(p.x, deckY, p.z);
|
||||
group.add(hanger);
|
||||
part(new THREE.BoxGeometry(0.035, h, 0.035).translate(p.x, deckY + h / 2, p.z));
|
||||
}
|
||||
}
|
||||
|
||||
batch.flush(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
|
||||
+28
-2
@@ -179,9 +179,27 @@ export function createWater(world: World): THREE.Group {
|
||||
const [x0, z0] = world.project(bounds.minLat, bounds.minLng);
|
||||
const [x1, z1] = world.project(bounds.maxLat, bounds.maxLng);
|
||||
|
||||
/**
|
||||
* Standard rather than Lambert, and it is the whole difference between an
|
||||
* ocean and a blue card.
|
||||
*
|
||||
* `MeshLambertMaterial` has no specular term at all — none, by construction —
|
||||
* so the Pacific, which is between a third and a half of the California
|
||||
* board's frame, rendered as one flat value at every hour and from every
|
||||
* angle. A low roughness gives it the sun's glint back, and now that the
|
||||
* scene carries an environment map (`engine/environmentRig.ts`) it also gives
|
||||
* it the sky: `MeshStandardMaterial` reads `scene.environment`, so the water
|
||||
* reflects whatever the atmosphere decided the sky is, for free and with no
|
||||
* second pass.
|
||||
*
|
||||
* `metalness: 0` is stated rather than defaulted because water is a
|
||||
* dielectric: its reflection is a Fresnel term over a coloured body, which is
|
||||
* exactly what metalness 0 with low roughness produces, and a metallic water
|
||||
* would lose `pal.sea` entirely.
|
||||
*/
|
||||
const sea = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(Math.abs(x1 - x0) * 1.8, Math.abs(z1 - z0) * 1.8),
|
||||
new THREE.MeshLambertMaterial({ color: pal.sea }),
|
||||
new THREE.MeshStandardMaterial({ color: pal.sea, roughness: 0.14, metalness: 0 }),
|
||||
);
|
||||
sea.rotation.x = -Math.PI / 2;
|
||||
sea.position.set((x0 + x1) / 2, -0.06, (z0 + z1) / 2);
|
||||
@@ -192,9 +210,17 @@ export function createWater(world: World): THREE.Group {
|
||||
const pts = world.projectPolygon(poly).map(([x, z]) => new THREE.Vector2(x, z));
|
||||
const geo = new THREE.ShapeGeometry(new THREE.Shape(pts));
|
||||
geo.rotateX(Math.PI / 2);
|
||||
// The same change as the sea above, and for the same reason. Slightly
|
||||
// rougher: an inland lake is sheltered, and a mirror-smooth bay next to a
|
||||
// wind-roughened ocean reads as the wrong way round.
|
||||
const lake = new THREE.Mesh(
|
||||
geo,
|
||||
new THREE.MeshLambertMaterial({ color: pal.lake, side: THREE.DoubleSide }),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: pal.lake,
|
||||
roughness: 0.2,
|
||||
metalness: 0,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
);
|
||||
lake.position.y = 0.05;
|
||||
group.add(lake);
|
||||
|
||||
+6
-5
@@ -319,11 +319,12 @@ export interface Aircraft {
|
||||
/**
|
||||
* Where aircraft come from.
|
||||
*
|
||||
* An interface rather than a client because the obvious source — FlightRadar24
|
||||
* — cannot ship in an Apache-2.0 repo: their terms forbid scraping and forbid
|
||||
* redistributing the data. This package ships a simulator and open community
|
||||
* sources; anything commercial is an adapter in a private deployment. See
|
||||
* ARCHITECTURE.md §4.
|
||||
* An interface rather than a client, because this repo must not ship one for the
|
||||
* obvious source: FlightRadar24's terms do not permit scraping and do not permit
|
||||
* redistributing the data, so a client for it in an Apache-2.0 repo would be
|
||||
* publishing instructions for violating a ToS. This package ships a simulator
|
||||
* and open community sources; anything commercial is an adapter in a private
|
||||
* deployment. See ARCHITECTURE.md §4.
|
||||
*/
|
||||
export interface FlightSource {
|
||||
/** Current traffic. Called on a timer; must be cheap and must not throw. */
|
||||
|
||||
+19
-1
@@ -1,13 +1,31 @@
|
||||
/** Tera's renderer-independent public package surface. */
|
||||
/**
|
||||
* Tera's renderer-independent public package surface.
|
||||
*
|
||||
* Everything exported here imports **no three.js, no DOM and no network**, which
|
||||
* is the property that makes the package usable from a verifier, a test harness
|
||||
* or a Node service that has no GPU. `src/test/integration/barrel.test.ts`
|
||||
* enforces it by importing this file under Node's type stripping and failing if
|
||||
* anything in the graph reaches for a renderer.
|
||||
*
|
||||
* That rule is why three of the modules this build added are conspicuously
|
||||
* absent. `src/interiors/devices.ts`, `src/engine/officeExterior.ts` and
|
||||
* `src/interiors/officeScene.ts` are the *render layers* for the same
|
||||
* simulations whose state machines are exported below — they take a `Plan` and a
|
||||
* `MaterialRegistry` and produce meshes, and exporting one of them would put
|
||||
* three.js on the package surface for every consumer of a device type.
|
||||
*/
|
||||
export * from "./arena/index.ts";
|
||||
export * from "./actors/controller.ts";
|
||||
export * from "./aircraft/controller.ts";
|
||||
export * from "./devices/index.ts";
|
||||
export * from "./interiors/plan.ts";
|
||||
export * from "./interiors/robotActivity.ts";
|
||||
export * from "./interiors/robotOperations.ts";
|
||||
export * from "./interiors/robotRoutes.ts";
|
||||
export * from "./interiors/types.ts";
|
||||
export * from "./interiors/walker.ts";
|
||||
export * from "./transport/exteriorVehicle.ts";
|
||||
export * from "./transport/types.ts";
|
||||
export * from "./transport/vehicleController.ts";
|
||||
export * from "./transport/vehicleSim.ts";
|
||||
export * from "./transport/vehicleTelemetry.ts";
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/** Device adapters for the renderer-independent vehicle action contract. */
|
||||
|
||||
import {
|
||||
normalizeVehicleActions,
|
||||
type VehicleActionSnapshot,
|
||||
} from "../transport/vehicleController.ts";
|
||||
|
||||
export interface GamepadButtonLike {
|
||||
pressed: boolean;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface GamepadLike {
|
||||
axes: readonly number[];
|
||||
buttons: readonly GamepadButtonLike[];
|
||||
}
|
||||
|
||||
export interface GamepadButtonState {
|
||||
assist: boolean;
|
||||
reset: boolean;
|
||||
}
|
||||
|
||||
export interface GamepadVehicleSample {
|
||||
actions: VehicleActionSnapshot;
|
||||
buttons: GamepadButtonState;
|
||||
}
|
||||
|
||||
function axis(value: number | undefined, deadzone = 0.12): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
const clamped = Math.max(-1, Math.min(1, value ?? 0));
|
||||
if (Math.abs(clamped) <= deadzone) return 0;
|
||||
return Math.sign(clamped) * ((Math.abs(clamped) - deadzone) / (1 - deadzone));
|
||||
}
|
||||
|
||||
function button(pad: GamepadLike, index: number): number {
|
||||
const found = pad.buttons[index];
|
||||
if (!found) return 0;
|
||||
return Math.max(0, Math.min(1, Number.isFinite(found.value) ? found.value : found.pressed ? 1 : 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard-layout mapping: left stick steers, triggers brake/throttle, B is
|
||||
* handbrake, Y resumes assistance, and X resets. Mode/reset are rising edges.
|
||||
*/
|
||||
export function sampleStandardGamepad(
|
||||
pad: GamepadLike,
|
||||
previous: GamepadButtonState = { assist: false, reset: false },
|
||||
): GamepadVehicleSample {
|
||||
const buttons = {
|
||||
assist: button(pad, 3) > 0.5,
|
||||
reset: button(pad, 2) > 0.5,
|
||||
};
|
||||
return {
|
||||
actions: normalizeVehicleActions({
|
||||
steering: axis(pad.axes[0]),
|
||||
brake: button(pad, 6),
|
||||
throttle: button(pad, 7),
|
||||
handbrake: button(pad, 1) > 0.5,
|
||||
modeRequest: buttons.assist && !previous.assist ? "assisted" : "none",
|
||||
reset: buttons.reset && !previous.reset,
|
||||
}),
|
||||
buttons,
|
||||
};
|
||||
}
|
||||
|
||||
/** Keyboard/touch and gamepad may be used together; strongest intent wins. */
|
||||
export function mergeVehicleActions(
|
||||
primary: Partial<VehicleActionSnapshot>,
|
||||
secondary: Partial<VehicleActionSnapshot>,
|
||||
): VehicleActionSnapshot {
|
||||
const a = normalizeVehicleActions(primary);
|
||||
const b = normalizeVehicleActions(secondary);
|
||||
return normalizeVehicleActions({
|
||||
throttle: Math.max(a.throttle, b.throttle),
|
||||
brake: Math.max(a.brake, b.brake),
|
||||
steering: Math.abs(b.steering) > Math.abs(a.steering) ? b.steering : a.steering,
|
||||
handbrake: a.handbrake || b.handbrake,
|
||||
modeRequest: b.modeRequest !== "none" ? b.modeRequest : a.modeRequest,
|
||||
reset: a.reset || b.reset,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* The hardware, in the room.
|
||||
*
|
||||
* One microphone-sized object per authored `DeviceDeclaration`, standing on the
|
||||
* prop that declaration named, with a lamp on it that changes colour when the
|
||||
* state changes. That is the whole layer.
|
||||
*
|
||||
* ### It resolves nothing
|
||||
*
|
||||
* Every coordinate comes from `Plan.device()`, which has already turned the
|
||||
* authored anchor — a prop id and an offset in that prop's frame — into a
|
||||
* position and a yaw. This file does not repeat that arithmetic, does not parse
|
||||
* an asset id and does not decide whether a declaration is valid;
|
||||
* `validateDeviceDeclaration` and `Plan` did all three. A second derivation
|
||||
* here would be a second answer to "where is the mic", and the two would
|
||||
* disagree the first time somebody nudged the desk — which is precisely what
|
||||
* `DeviceAnchor` is shaped to prevent, and it would be this file undoing it.
|
||||
*
|
||||
* A declaration the plan dropped is therefore skipped rather than placed from
|
||||
* some other source. The plan drops one when its anchor prop is not there — a
|
||||
* typo, or a private prop in a public build — and inventing a position would
|
||||
* leave a microphone standing on the lobby floor.
|
||||
*
|
||||
* ### Nothing here is a light source
|
||||
*
|
||||
* `src/interiors/luminaires.ts` opens with that sentence and it is repeated
|
||||
* here because the temptation is stronger, not weaker: an LED is *obviously* a
|
||||
* light, and a `PointLight` per device would be one line. CONTRACT.md §4 says
|
||||
* Atmosphere is the sole light owner, and past about four shadow casters a
|
||||
* frame budget ends.
|
||||
*
|
||||
* The indicator reads as lit without one, through the `deviceIndicator`
|
||||
* material role: `materials.tinted("deviceIndicator", colour)` reaches both
|
||||
* `color` and `emissive`, and the role carries `emissiveIntensity: 1.0`, so
|
||||
* under the tone curve it reads as a lamp rather than as a white dot. A 3 mm
|
||||
* LED also implies **no house light at all** — unlike a ceiling fitting, which
|
||||
* is why `luminaires.ts` hands a scalar to the rig and this file has nothing to
|
||||
* hand anybody. If a device is ever added that genuinely lights a room, the
|
||||
* scalar goes to Atmosphere and the light still does not get constructed here.
|
||||
*
|
||||
* ### Materials are borrowed; geometry is owned
|
||||
*
|
||||
* Every mesh comes out of `MeshBin`, which clones and merges, so the geometry
|
||||
* in this subtree belongs to this layer and is disposed with it. The materials
|
||||
* come from the shared `MaterialRegistry` and are cached there across the whole
|
||||
* office — three states across a dozen devices is three materials, not
|
||||
* thirty-six — so `dispose()` deliberately does **not** touch them. Disposing a
|
||||
* registry material here would empty the desks in the rest of the building.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { createAssetContext, type AssetRegistry } from "../assets/kit.ts";
|
||||
import type { MaterialRegistry } from "../assets/materials.ts";
|
||||
import { DEVICE_RANGES, type DeviceDeclaration, type DeviceState } from "../devices/types.ts";
|
||||
import type { Plan } from "./plan.ts";
|
||||
|
||||
/**
|
||||
* The sub-object every device asset is expected to expose.
|
||||
*
|
||||
* A name rather than a `userData` flag because it is what an asset author
|
||||
* already writes — `MeshBin.build(name)` names the group — and because a name
|
||||
* survives the merge that turns an asset into one mesh per material. An asset
|
||||
* without one still builds and still stands on the desk; it simply has no lamp
|
||||
* to change, which is the right degrade for a self-hoster's own hardware model.
|
||||
*/
|
||||
const INDICATOR = "indicator";
|
||||
|
||||
/**
|
||||
* What each state looks like, as a colour.
|
||||
*
|
||||
* Four states and no more, deliberately: an indicator that encodes a continuous
|
||||
* reading is a display, and a display needs a legend. These are the four a
|
||||
* person can read across a room without one — dark, live, muted, idle — and the
|
||||
* numbers behind them belong in the panel, which has the room to say what they
|
||||
* mean.
|
||||
*/
|
||||
const INDICATOR_COLORS = {
|
||||
/** Powered off. Not black: an unlit LED is grey plastic, and black reads as a hole. */
|
||||
off: 0x2b3138,
|
||||
/** A microphone that is open, or a speaker that is playing. */
|
||||
live: 0x46d17a,
|
||||
/** A microphone that is muted. The one state worth reading from the doorway. */
|
||||
muted: 0xe2543f,
|
||||
/** Powered, idle: a speaker that is on with nothing playing. */
|
||||
idle: 0xd7a63c,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* How much the indicator grows at full scale, as a fraction of its own size.
|
||||
*
|
||||
* The only reading this layer draws, and it is deliberately tiny. A meter
|
||||
* belongs in the panel; what the room needs is a hint that the thing is doing
|
||||
* something, which is what a lamp that breathes with the programme gives you.
|
||||
* It is a transform on one small object, so it costs nothing and it mints no
|
||||
* material — a level-driven *colour* would mint one per tenth of a decibel,
|
||||
* which is the version of this idea that must not be written.
|
||||
*/
|
||||
const INDICATOR_LEVEL_GAIN = 0.3;
|
||||
|
||||
export interface DeviceLayer {
|
||||
object: THREE.Object3D;
|
||||
/**
|
||||
* Show these readings. Ids this layer does not carry are ignored, and a
|
||||
* device this layer carries that is absent from `states` is left as it was —
|
||||
* a poll that dropped one device is not the same event as that device being
|
||||
* switched off, and only one of them should change what is on screen.
|
||||
*/
|
||||
apply(states: readonly DeviceState[]): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface DeviceLayerOptions {
|
||||
plan: Plan;
|
||||
declarations: readonly DeviceDeclaration[];
|
||||
assets: AssetRegistry;
|
||||
materials: MaterialRegistry;
|
||||
}
|
||||
|
||||
interface Mounted {
|
||||
id: string;
|
||||
/** The asset's own indicator, or `null` for hardware that exposes none. */
|
||||
indicator: THREE.Object3D | null;
|
||||
/** The indicator's authored scale, so the level response is relative to it. */
|
||||
baseScale: number;
|
||||
}
|
||||
|
||||
export function createDeviceLayer(options: DeviceLayerOptions): DeviceLayer {
|
||||
const { plan, declarations, assets, materials } = options;
|
||||
const object = new THREE.Group();
|
||||
object.name = "devices";
|
||||
const mounted = new Map<string, Mounted>();
|
||||
const warned = new Set<string>();
|
||||
|
||||
for (const declaration of declarations) {
|
||||
const resolved = plan.device(declaration.id);
|
||||
if (resolved === null) {
|
||||
warnOnce(
|
||||
warned,
|
||||
`device ${declaration.id} is not in this plan — check that its anchor prop exists on ` +
|
||||
`level ${declaration.anchor.levelId} and survived this build's depth`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// The transform, whole, from the plan. The offset is already folded into
|
||||
// `position` there, in the anchor prop's frame; re-applying it here would
|
||||
// place the mic twice as far up the desk as the pack asked for.
|
||||
const mount = new THREE.Group();
|
||||
mount.name = `device:${declaration.id}`;
|
||||
mount.position.set(resolved.position.x, resolved.position.y, resolved.position.z);
|
||||
mount.rotation.y = resolved.rotation;
|
||||
|
||||
const hardware = assets.build(declaration.assetId, contextFor(declaration, materials, assets));
|
||||
mount.add(hardware);
|
||||
object.add(mount);
|
||||
|
||||
const indicator = hardware.getObjectByName(INDICATOR) ?? null;
|
||||
if (indicator === null) {
|
||||
warnOnce(warned, `device asset ${declaration.assetId} exposes no "${INDICATOR}" sub-object; its state will not be visible in the room`);
|
||||
}
|
||||
mounted.set(declaration.id, {
|
||||
id: declaration.id,
|
||||
indicator,
|
||||
baseScale: indicator?.scale.x ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
object,
|
||||
|
||||
apply(states: readonly DeviceState[]): void {
|
||||
for (const state of states) {
|
||||
const device = mounted.get(state.id);
|
||||
if (device === undefined || device.indicator === null) continue;
|
||||
paint(device.indicator, materials, colorFor(state));
|
||||
device.indicator.scale.setScalar(device.baseScale * (1 + INDICATOR_LEVEL_GAIN * levelOf(state)));
|
||||
}
|
||||
},
|
||||
|
||||
dispose(): void {
|
||||
object.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh;
|
||||
// Geometry only. See the header: every material here belongs to the
|
||||
// shared registry and is still holding up the rest of the office.
|
||||
if (mesh.isMesh) mesh.geometry.dispose();
|
||||
});
|
||||
object.clear();
|
||||
mounted.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which lamp, for one reading.
|
||||
*
|
||||
* Kind-aware rather than capability-aware, and that is the one place in this
|
||||
* whole surface where switching on the kind is right: this is a *picture* of a
|
||||
* device, and what a green light means on a microphone ("open") is not what it
|
||||
* means on a speaker ("playing"). Everywhere a control or an observation is
|
||||
* built, the capability list is the thing to iterate.
|
||||
*/
|
||||
function colorFor(state: DeviceState): number {
|
||||
if (!state.powered) return INDICATOR_COLORS.off;
|
||||
if (state.kind === "mic") return state.muted === true ? INDICATOR_COLORS.muted : INDICATOR_COLORS.live;
|
||||
return state.playing === true ? INDICATOR_COLORS.live : INDICATOR_COLORS.idle;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reading as 0..1, or zero for a device that reports no level.
|
||||
*
|
||||
* `undefined` is not zero — it means this device has no meter — but both come
|
||||
* out here as "do not grow the lamp", which is the honest picture for a device
|
||||
* that is not reporting anything to grow it by.
|
||||
*/
|
||||
function levelOf(state: DeviceState): number {
|
||||
if (state.levelDb === undefined || !state.powered) return 0;
|
||||
const { min, max } = DEVICE_RANGES.level;
|
||||
return Math.min(1, Math.max(0, (state.levelDb - min) / (max - min)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Point every mesh under the indicator at the tinted material for a state.
|
||||
*
|
||||
* `tinted` is cached by the registry, so the whole building's microphones share
|
||||
* one material per state and the assignment is a pointer write rather than a
|
||||
* new draw call. It reaches `color` and `emissive` together, which is what
|
||||
* makes a tinted LED read as lit instead of as a coloured pebble.
|
||||
*/
|
||||
function paint(indicator: THREE.Object3D, materials: MaterialRegistry, color: number): void {
|
||||
const material = materials.tinted("deviceIndicator", color);
|
||||
indicator.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh;
|
||||
if (mesh.isMesh) mesh.material = material;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An asset context for one device.
|
||||
*
|
||||
* The random stream is seeded from the device id rather than from `Math.random`
|
||||
* so that the same pack builds the same hardware on every machine and in every
|
||||
* capture — the determinism rule every asset in this repo is held to. A device
|
||||
* is one small object built once, so the generator is three lines rather than a
|
||||
* dependency.
|
||||
*/
|
||||
function contextFor(
|
||||
declaration: DeviceDeclaration,
|
||||
materials: MaterialRegistry,
|
||||
assets: AssetRegistry,
|
||||
) {
|
||||
let seed = 0x811c9dc5;
|
||||
for (let i = 0; i < declaration.id.length; i += 1) {
|
||||
seed ^= declaration.id.charCodeAt(i);
|
||||
seed = Math.imul(seed, 0x01000193);
|
||||
}
|
||||
const rand = (): number => {
|
||||
seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0;
|
||||
return seed / 4_294_967_296;
|
||||
};
|
||||
return createAssetContext({ materials, registry: assets, rand });
|
||||
}
|
||||
|
||||
/**
|
||||
* One line per problem, once.
|
||||
*
|
||||
* A pack with a typo in a device id is a pack that repeats it — the same
|
||||
* declaration is rebuilt every time the office is entered — and a warning per
|
||||
* entry is a console nobody reads. `AssetRegistry.placeholder` warns once per
|
||||
* id for the same reason.
|
||||
*/
|
||||
function warnOnce(seen: Set<string>, message: string): void {
|
||||
if (seen.has(message)) return;
|
||||
seen.add(message);
|
||||
console.warn(`[tera/devices] ${message}`);
|
||||
}
|
||||
@@ -68,8 +68,15 @@
|
||||
import * as THREE from "three";
|
||||
import { createSceneKit, type Pose } from "../engine/scenekit.ts";
|
||||
import type { StageScene } from "../engine/stage.ts";
|
||||
import type { LightingState, Pin, View } from "../engine/types.ts";
|
||||
import type { AssetRegistry } from "../assets/kit.ts";
|
||||
import type { Aircraft, FlightSource, LightingState, Pin, View } from "../engine/types.ts";
|
||||
import { airlinerGeometry } from "../engine/aircraftGeometry.ts";
|
||||
import type { EnvironmentRig } from "../engine/environmentRig.ts";
|
||||
import { createOfficeExterior, type OfficeExterior } from "../engine/officeExterior.ts";
|
||||
import { createDeviceLayer, type DeviceLayer } from "./devices.ts";
|
||||
import type { DeviceDeclaration, DeviceState } from "../devices/types.ts";
|
||||
import type { VehicleTelemetryState } from "../transport/vehicleTelemetry.ts";
|
||||
import type { ModelXDetail } from "../assets/vehicles/index.ts";
|
||||
import { kit as assetKit, type AssetRegistry } from "../assets/kit.ts";
|
||||
import { MaterialRegistry, type MaterialQuality } from "../assets/materials.ts";
|
||||
import type { InteriorPalette } from "../assets/palette.ts";
|
||||
// Importing the catalogue registers the built-in `tera:` assets into the shared
|
||||
@@ -140,6 +147,53 @@ const HORIZON_EXTENT = 12_000;
|
||||
*/
|
||||
const HORIZON_DARKEN = 0.5;
|
||||
|
||||
/**
|
||||
* How far out the overhead traffic dome sits, in metres, at most.
|
||||
*
|
||||
* The camera's far plane is `HORIZON_EXTENT * 0.7` — 8.4 km — so 3.6 km is
|
||||
* comfortably inside it with the horizon plane still behind. The number itself
|
||||
* carries no claim: an aeroplane on this dome is a **map symbol drawn in 3-D**,
|
||||
* placed at the bearing and elevation it is genuinely at and at a distance
|
||||
* chosen so it is visible, exactly as `aircraftGeometry.ts` argues for the
|
||||
* city's own traffic. Drawing airliners at true metre range would put most of
|
||||
* them past the far plane and the rest inside the fog.
|
||||
*/
|
||||
const OVERHEAD_MAX_RADIUS_M = 3_600;
|
||||
|
||||
/**
|
||||
* How large an aeroplane is drawn, as an angle at the eye.
|
||||
*
|
||||
* 0.012 rad is about 0.7 degrees — a little over the width of a fingernail at
|
||||
* arm's length, which is roughly what an airliner at cruise actually looks like
|
||||
* from directly beneath and is enough to read the sweep of a wing. It is an
|
||||
* angle rather than a length so the glyph does not have to be retuned if the
|
||||
* dome radius ever changes.
|
||||
*/
|
||||
const OVERHEAD_ANGULAR_SIZE = 0.012;
|
||||
|
||||
/** The bounding length of `airlinerGeometry()`, which the angular size divides. */
|
||||
const AIRLINER_LENGTH = 0.42;
|
||||
|
||||
/**
|
||||
* How low an aeroplane may be and still be drawn, in degrees above the horizon.
|
||||
*
|
||||
* Below this it is behind the ground plane from any viewpoint inside the
|
||||
* building, so drawing it is drawing an aeroplane through a floor. Five degrees
|
||||
* is also about where an airliner stops being distinguishable from the haze.
|
||||
*/
|
||||
const OVERHEAD_MIN_ELEVATION_DEG = 5;
|
||||
|
||||
/**
|
||||
* How many aeroplanes the dome can hold at once.
|
||||
*
|
||||
* One `InstancedMesh` and therefore one draw call at any occupancy, so the cost
|
||||
* of the ceiling is 32 unused matrices rather than 32 unused objects. The
|
||||
* godmode traffic dial can put four hundred aircraft over the city; a room's
|
||||
* sky wants the nearest few, and the nearest few is what a person looking up
|
||||
* would see anyway.
|
||||
*/
|
||||
const OVERHEAD_CAPACITY = 32;
|
||||
|
||||
export interface OfficeSceneOptions {
|
||||
/**
|
||||
* The renderer's canvas. Orbit input and pointer coordinates are read against
|
||||
@@ -236,6 +290,45 @@ export interface OfficeSceneOptions {
|
||||
*/
|
||||
background?: number | null;
|
||||
plan?: PlanOptions;
|
||||
/**
|
||||
* The page's one environment map, shared with the city.
|
||||
*
|
||||
* The same argument `scene.ts` makes: a `PMREMGenerator` and its targets
|
||||
* belong to the renderer, not to a scene, so one rig is built beside the
|
||||
* `Stage` and handed to both. It matters more indoors than out — `deviceMesh`,
|
||||
* `chairBase`, `metalTrim`, `glazingFrame` and the Model X's paint are all
|
||||
* metal or clearcoat, and metal with nothing to reflect is grey plastic.
|
||||
*
|
||||
* Absent, the office renders exactly as it did before the rig existed.
|
||||
*/
|
||||
environment?: EnvironmentRig;
|
||||
/**
|
||||
* Overhead traffic for a sited office's sky.
|
||||
*
|
||||
* The same `FlightSource` the city board is drawing, deliberately: a studio in
|
||||
* the Arts District and the SoCal board above it are one world, and an arena
|
||||
* that observes an overflight while the viewer standing in the room sees an
|
||||
* empty sky is two. Polled here and **never disposed** here — the source
|
||||
* belongs to whoever built it, which is the city.
|
||||
*
|
||||
* Ignored on a pack with no `site`: without a coordinate there is no bearing
|
||||
* to put an aeroplane on, and without a horizon there is no sky to put it in.
|
||||
*/
|
||||
flights?: FlightSource;
|
||||
/**
|
||||
* Park a Model X on the pack's arrival apron.
|
||||
*
|
||||
* Ignored unless `office.site.arrival` names a stall, which is a pack's own
|
||||
* decision — `ExteriorArrival` is optional and a floorplan with no outdoors
|
||||
* has nowhere to put a car.
|
||||
*
|
||||
* `detail` is a required choice by the exterior's own contract: `corridor` is
|
||||
* 33 draw calls and 4,098 triangles against `follow`'s 40 and 13,986, and the
|
||||
* difference a viewer can see at three metres is mirrors, glass frames and
|
||||
* brake calipers. `seed` makes the parking jitter and the paint a property of
|
||||
* the studio rather than of the page load.
|
||||
*/
|
||||
exteriorVehicle?: { detail: ModelXDetail; seed: number };
|
||||
}
|
||||
|
||||
export interface OfficeScene extends StageScene {
|
||||
@@ -281,6 +374,28 @@ export interface OfficeScene extends StageScene {
|
||||
*/
|
||||
setRobotsVisible(visible: boolean): void;
|
||||
setLighting(state: LightingState): void;
|
||||
/**
|
||||
* The hardware this pack declared, in the order it authored it.
|
||||
*
|
||||
* Authored, public and inert — a declaration says a microphone exists and what
|
||||
* it can be asked to do. It is on the handle so that the interface can build a
|
||||
* panel for a studio without reading the pack a second time, and it is the
|
||||
* **resolved** list: a declaration `Plan` dropped, because its anchor prop is
|
||||
* not on this level or did not survive this build's depth, is not here.
|
||||
*/
|
||||
devices: readonly DeviceDeclaration[];
|
||||
/**
|
||||
* Show these readings on the hardware. Cheap and idempotent; call it whenever
|
||||
* a feed publishes. A no-op for an office that declared no devices.
|
||||
*/
|
||||
setDeviceStates(states: readonly DeviceState[]): void;
|
||||
/**
|
||||
* Reflect one vehicle telemetry observation on the car outside.
|
||||
*
|
||||
* Signature-guarded downstream, so calling it every frame costs a comparison.
|
||||
* A no-op for a pack with no arrival stall, or when no exterior was asked for.
|
||||
*/
|
||||
setVehicleTelemetry(state: VehicleTelemetryState): void;
|
||||
/**
|
||||
* The sun's height, in degrees, from whatever clock the app is running.
|
||||
*
|
||||
@@ -402,7 +517,12 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
// reads as a stall.
|
||||
flightSpeed: 0.95,
|
||||
});
|
||||
kit.applyLighting(options.lighting ?? officeInterior());
|
||||
const openingLighting = options.lighting ?? officeInterior();
|
||||
kit.applyLighting(openingLighting);
|
||||
// Before a single surface is built, so the first frame already has a room to
|
||||
// reflect. The rig fingerprints the state and caches per kind, so this and
|
||||
// every later `setLighting` cost a map lookup unless the light actually moved.
|
||||
options.environment?.apply(scene, openingLighting, "office");
|
||||
|
||||
/**
|
||||
* The sky wins over the flat colour when there is one.
|
||||
@@ -562,6 +682,64 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
if (presence) scene.add(presence.group);
|
||||
shell.ceilings.visible = options.showCeilings ?? false;
|
||||
|
||||
// ---- The hardware on the furniture --------------------------------------
|
||||
//
|
||||
// Read off the floorplans rather than taken as an option, because a device is
|
||||
// part of a pack in exactly the way a desk is: `Floorplan.devices` says the
|
||||
// microphone exists and which prop it stands on, and `Plan` has already
|
||||
// decided which of those declarations survived this build's depth. Filtering
|
||||
// to what `Plan` accepted is what stops the interface offering a panel for a
|
||||
// device the room does not contain — the layer would have skipped it and the
|
||||
// panel would have shown a control that reaches nothing.
|
||||
const registry: AssetRegistry = options.registry ?? assetKit;
|
||||
const declared: DeviceDeclaration[] = [];
|
||||
for (const level of office.levels) {
|
||||
for (const declaration of level.floorplan.devices ?? []) declared.push(declaration);
|
||||
}
|
||||
const deviceDeclarations: readonly DeviceDeclaration[] = declared.filter(
|
||||
(declaration) => plan.device(declaration.id) !== null,
|
||||
);
|
||||
const deviceLayer: DeviceLayer | null =
|
||||
deviceDeclarations.length > 0
|
||||
? createDeviceLayer({ plan, declarations: deviceDeclarations, assets: registry, materials })
|
||||
: null;
|
||||
if (deviceLayer) scene.add(deviceLayer.object);
|
||||
|
||||
// ---- The car outside -----------------------------------------------------
|
||||
//
|
||||
// Guarded on the pack having authored a stall, which most will not: an
|
||||
// `ExteriorArrival` is optional and a floor plate with no outdoors has nowhere
|
||||
// to put one. The exterior positions everything in the pack's own metres from
|
||||
// the plan origin, so the only transform it needs is the storey its stall is
|
||||
// measured from — a podium deck at level 1 is 188 m off the street, and the
|
||||
// apron stands on the floor of `arrival.levelId` by the exterior's own wording.
|
||||
const arrivalStall = office.site?.arrival;
|
||||
let exterior: OfficeExterior | null = null;
|
||||
if (options.exteriorVehicle && office.site && arrivalStall) {
|
||||
exterior = createOfficeExterior({
|
||||
site: office.site,
|
||||
arrival: arrivalStall,
|
||||
assets: registry,
|
||||
materials,
|
||||
rand: mulberry32(options.exteriorVehicle.seed),
|
||||
detail: options.exteriorVehicle.detail,
|
||||
});
|
||||
exterior.object.position.y = plan.level(arrivalStall.levelId)?.floorY ?? 0;
|
||||
scene.add(exterior.object);
|
||||
}
|
||||
|
||||
// ---- The traffic overhead ------------------------------------------------
|
||||
const overhead: OverheadTraffic | null =
|
||||
options.flights && office.site && options.horizon
|
||||
? createOverheadTraffic({
|
||||
source: options.flights,
|
||||
site: office.site,
|
||||
centre: plan.bounds.center,
|
||||
radius: Math.min(far * 0.42, OVERHEAD_MAX_RADIUS_M),
|
||||
})
|
||||
: null;
|
||||
if (overhead) scene.add(overhead.group);
|
||||
|
||||
// ---- Viewpoints ---------------------------------------------------------
|
||||
|
||||
const viewpointById = new Map(plan.viewpoints.map((v) => [v.id, v]));
|
||||
@@ -818,6 +996,13 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
onViewChange(fn) {
|
||||
viewListeners.push(fn);
|
||||
},
|
||||
devices: deviceDeclarations,
|
||||
setDeviceStates(states) {
|
||||
deviceLayer?.apply(states);
|
||||
},
|
||||
setVehicleTelemetry(state) {
|
||||
exterior?.apply(state);
|
||||
},
|
||||
setPresence(people) {
|
||||
if (!presence) {
|
||||
// Once, not once per poll: an occupancy feed pointed at the public
|
||||
@@ -843,6 +1028,10 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
setLighting(state) {
|
||||
kit.applyLighting(state);
|
||||
paintHorizon(state);
|
||||
// One direction, still: `Atmosphere` decided this rig, `officeDaylight`
|
||||
// turned it into the building's frame, and the environment is derived
|
||||
// from the result rather than being a second opinion about the light.
|
||||
options.environment?.apply(scene, state, "office");
|
||||
},
|
||||
setSolarElevation(degrees) {
|
||||
luminaires.setSolarElevation(degrees);
|
||||
@@ -887,8 +1076,16 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
// *now*, not to where they were last frame.
|
||||
robots?.tick(dt);
|
||||
luminaires.tick(dt);
|
||||
overhead?.tick(dt);
|
||||
},
|
||||
dispose() {
|
||||
// First, because the rig keeps a ledger of every scene it has written to
|
||||
// so a rebuilt environment reaches all of them — and a disposed office
|
||||
// left in that ledger is a whole floor plate retained.
|
||||
options.environment?.release(scene);
|
||||
overhead?.dispose();
|
||||
exterior?.dispose();
|
||||
deviceLayer?.dispose();
|
||||
mediaSurfaces.dispose();
|
||||
officeWalker?.dispose();
|
||||
realtimePeers?.dispose();
|
||||
@@ -922,6 +1119,206 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A deterministic generator from one integer, so a studio's car is the same car
|
||||
* on every machine and on every reload.
|
||||
*
|
||||
* Mulberry32, four lines, no dependency. It is here rather than imported
|
||||
* because the only thing in this file that needs randomness is the parking
|
||||
* jitter, and `createOfficeExterior` takes a `() => number` precisely so that
|
||||
* the caller owns the reproducibility rather than the layer.
|
||||
*/
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Overhead traffic -----------------------------------------------------
|
||||
|
||||
interface OverheadTraffic {
|
||||
group: THREE.Group;
|
||||
tick(dt: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface OverheadTrafficOptions {
|
||||
source: FlightSource;
|
||||
site: NonNullable<Office["site"]>;
|
||||
/** The middle of the floor plate, in the pack's metres. The dome is centred here. */
|
||||
centre: { x: number; z: number };
|
||||
radius: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same aeroplanes the city board is drawing, seen from inside a building.
|
||||
*
|
||||
* ### Why a dome rather than a position
|
||||
*
|
||||
* An airliner over Los Angeles is ten kilometres up and twenty across. Placed at
|
||||
* true metre range in a scene whose far plane is 8.4 km it is clipped, and if
|
||||
* the far plane were moved out to reach it the fog — which saturates at 4.2 km,
|
||||
* because that is what makes the horizon a horizon — would have swallowed it
|
||||
* long before. So the *direction* is kept exactly and the *distance* is not:
|
||||
* every track is put on a fixed dome at the bearing and elevation it is really
|
||||
* at, sized by an angle rather than a length. That is the same bargain
|
||||
* `aircraftGeometry.ts` already makes for the city, written down again here
|
||||
* because the reason is different: the city trades scale for legibility, and
|
||||
* this trades range for a depth buffer that works.
|
||||
*
|
||||
* ### Why it is instanced
|
||||
*
|
||||
* One geometry, one material, one draw call whatever the occupancy — against
|
||||
* the city layer's mesh-per-track, which exists there because each aircraft
|
||||
* carries its own altitude-banded material and its own pick target. Neither is
|
||||
* wanted here: a room's sky is scenery, nothing in it is clickable, and the
|
||||
* office's draw-call budget is the one that has to hold an entire studio.
|
||||
*
|
||||
* ### One direction, again
|
||||
*
|
||||
* `site.heading` is the bearing the pack's −Z points along, so a compass bearing
|
||||
* becomes a building-frame yaw by subtracting it — the same rotation
|
||||
* `officeDaylight` applies to the sun, for the same reason and in the same
|
||||
* sense. Getting it backwards would put the afternoon traffic over the wrong
|
||||
* wall, which is exactly as wrong as putting the afternoon sun there.
|
||||
*/
|
||||
function createOverheadTraffic(options: OverheadTrafficOptions): OverheadTraffic {
|
||||
const { source, site, centre, radius } = options;
|
||||
const group = new THREE.Group();
|
||||
group.name = "overhead-traffic";
|
||||
|
||||
const geometry = airlinerGeometry();
|
||||
/**
|
||||
* Lit, with a small emissive floor, and out of the fog.
|
||||
*
|
||||
* The emissive is the city layer's number and is there for the city layer's
|
||||
* reason: after sunset the rig is a tenth of an intensity and a purely diffuse
|
||||
* dart simply vanishes, on the one evening sky worth looking at. `fog: false`
|
||||
* because the dome's radius is a drawing convention rather than a distance —
|
||||
* applying 3.6 km of haze to a symbol that stands for twenty kilometres is
|
||||
* fogging an arbitrary number.
|
||||
*/
|
||||
const material = new THREE.MeshLambertMaterial({
|
||||
color: 0xdfe7ef,
|
||||
emissive: 0xdfe7ef,
|
||||
emissiveIntensity: 0.35,
|
||||
fog: false,
|
||||
});
|
||||
const mesh = new THREE.InstancedMesh(geometry, material, OVERHEAD_CAPACITY);
|
||||
mesh.name = "overhead-traffic-instances";
|
||||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
// The dome is centred on the building and always in frame; a bounding-sphere
|
||||
// test on something that can never be culled is pure cost.
|
||||
mesh.frustumCulled = false;
|
||||
mesh.count = 0;
|
||||
mesh.castShadow = false;
|
||||
mesh.receiveShadow = false;
|
||||
group.add(mesh);
|
||||
|
||||
const scale = (radius * OVERHEAD_ANGULAR_SIZE) / AIRLINER_LENGTH;
|
||||
const matrix = new THREE.Matrix4();
|
||||
const position = new THREE.Vector3();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const euler = new THREE.Euler(0, 0, 0, "YXZ");
|
||||
const scaleVector = new THREE.Vector3(scale, scale, scale);
|
||||
|
||||
const headingRad = (site.heading * Math.PI) / 180;
|
||||
const cosLat = Math.cos((site.lat * Math.PI) / 180);
|
||||
const minSinElevation = Math.sin((OVERHEAD_MIN_ELEVATION_DEG * Math.PI) / 180);
|
||||
|
||||
let timer = 0;
|
||||
let disposed = false;
|
||||
|
||||
function place(aircraft: readonly Aircraft[]): void {
|
||||
let count = 0;
|
||||
for (const a of aircraft) {
|
||||
if (count >= OVERHEAD_CAPACITY) break;
|
||||
/*
|
||||
* Equirectangular, not great-circle, and that is a decision rather than a
|
||||
* shortcut: an aeroplane still above five degrees from a building is at
|
||||
* most a couple of hundred kilometres away, where the cosine-corrected
|
||||
* flat approximation is wrong by metres in a bearing that is then drawn
|
||||
* on a dome anyway. A haversine here would be four transcendentals per
|
||||
* aeroplane per poll to move a symbol by less than its own width.
|
||||
*/
|
||||
const east = (a.lng - site.lng) * cosLat * METRES_PER_DEGREE;
|
||||
const north = (a.lat - site.lat) * METRES_PER_DEGREE;
|
||||
const ground = Math.hypot(east, north);
|
||||
const up = a.altitude - site.elevation;
|
||||
const slant = Math.hypot(ground, up);
|
||||
if (slant < 1) continue;
|
||||
const sinElevation = up / slant;
|
||||
if (sinElevation < minSinElevation) continue;
|
||||
|
||||
// Bearing clockwise from true north, turned into the building's frame by
|
||||
// subtracting the heading its own −Z points along.
|
||||
const bearing = Math.atan2(east, north) - headingRad;
|
||||
const cosElevation = Math.sqrt(Math.max(0, 1 - sinElevation * sinElevation));
|
||||
position.set(
|
||||
centre.x + Math.sin(bearing) * cosElevation * radius,
|
||||
sinElevation * radius,
|
||||
centre.z - Math.cos(bearing) * cosElevation * radius,
|
||||
);
|
||||
|
||||
// Nose along +Z and −Z is the building's own north, so a half turn less
|
||||
// the track's heading in this frame — the identical mapping `flights.ts`
|
||||
// uses, and the one whose inverse once flew every departure tail-first.
|
||||
euler.set(0, Math.PI - ((a.heading * Math.PI) / 180 - headingRad), 0);
|
||||
quaternion.setFromEuler(euler);
|
||||
matrix.compose(position, quaternion, scaleVector);
|
||||
mesh.setMatrixAt(count, matrix);
|
||||
count += 1;
|
||||
}
|
||||
mesh.count = count;
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
tick(dt) {
|
||||
if (disposed) return;
|
||||
timer -= dt;
|
||||
if (timer > 0) return;
|
||||
timer = source.interval;
|
||||
/*
|
||||
* No interpolation, unlike the city layer, and the sky is why. A track on
|
||||
* this dome moves a few pixels between polls: at 3.6 km an airliner
|
||||
* covers about 0.24 degrees a second across the dome, which is a third of
|
||||
* its own drawn width, so tweening it would be machinery for motion
|
||||
* nobody can see. The city layer interpolates because there the same
|
||||
* aeroplane crosses a visible fraction of the board.
|
||||
*/
|
||||
void Promise.resolve(source.poll()).then((aircraft) => {
|
||||
if (!disposed) place(aircraft);
|
||||
});
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
// Never `source.dispose()`: this layer is a second reader of a feed the
|
||||
// city owns, and disposing it here would take the traffic off the board
|
||||
// the moment somebody stepped indoors.
|
||||
mesh.dispose();
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
group.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Metres per degree of latitude, and of longitude at the equator.
|
||||
*
|
||||
* The WGS-84 mean, which is the same constant `main.ts` uses to turn a scene
|
||||
* offset back into a coordinate. A degree of latitude varies by about half a
|
||||
* percent between the equator and the pole; on a bearing drawn as a symbol that
|
||||
* is nothing.
|
||||
*/
|
||||
const METRES_PER_DEGREE = 111_320;
|
||||
|
||||
function withoutPeerFactory(options: OfficeRealtimePeersOptions): Omit<ScenePeersOptions, "project" | "groundAt"> {
|
||||
const { create: _create, ...peerOptions } = options;
|
||||
return peerOptions;
|
||||
|
||||
+366
-5
@@ -43,6 +43,23 @@
|
||||
* exception with no context in the middle of a 180-prop pack tells the author
|
||||
* nothing and loses the other 179.
|
||||
*
|
||||
* ### Two things resolve late, and both are addresses
|
||||
*
|
||||
* Almost everything here is resolved level by level, in one pass, because a wall
|
||||
* and the slab under it are facts about one storey. Two authored things are not:
|
||||
* a **device** names a prop, and the prop may be anywhere in the building; the
|
||||
* **exterior arrival stall** names a level and stands outside every one of them.
|
||||
* Both are therefore resolved after the level loop has run, next to the
|
||||
* prop-to-seat binding pass that runs late for exactly the same reason — a
|
||||
* cross-level address checked against a half-built plan reports a problem that
|
||||
* is not there.
|
||||
*
|
||||
* A device is the first authored record in this format that takes its
|
||||
* *coordinate* from another record rather than restating one. `DeviceAnchor` in
|
||||
* `src/devices/types.ts` argues that case; the consequence here is that the
|
||||
* derivation happens once, in `resolveDevice`, and a device that cannot find its
|
||||
* hardware is dropped rather than given a position of its own.
|
||||
*
|
||||
* ### Depth: a public build does not build the private half
|
||||
*
|
||||
* `PlanOptions.depth` is the other reason something can be absent from the build
|
||||
@@ -57,10 +74,18 @@
|
||||
* and is public whatever it is marked.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DeviceCapability,
|
||||
DeviceDeclaration,
|
||||
DeviceKind,
|
||||
DeviceProvenance,
|
||||
} from "../devices/types.ts";
|
||||
import { deviceKindOfAssetId, validateDeviceDeclaration } from "../devices/types.ts";
|
||||
import type {
|
||||
AssetId,
|
||||
Audience,
|
||||
DeskBank,
|
||||
ExteriorArrival,
|
||||
Level,
|
||||
Office,
|
||||
Opening,
|
||||
@@ -222,6 +247,45 @@ export interface ResolvedSeat {
|
||||
source: { bankId: string; station: number } | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A device, bound to the hardware it stands on and given a coordinate.
|
||||
*
|
||||
* The declaration in the pack has no position — see `DeviceAnchor` in
|
||||
* `src/devices/types.ts`, which argues the case at length: a device is hardware,
|
||||
* hardware sits on something, and that something is already placed. So this is
|
||||
* where the coordinate comes from, exactly once, by reading the anchor prop's
|
||||
* resolved transform and adding the authored offset **in the prop's own frame**.
|
||||
* Nudge the desk and the mic moves with it, because there was never a second
|
||||
* number to forget to update.
|
||||
*
|
||||
* `roomId` is filled in even when the pack left it out, because the answer is a
|
||||
* lookup the pack should not have to restate and a consumer should not have to
|
||||
* repeat. `seatId` is not — a device that serves no seat serves no seat, and
|
||||
* inventing the nearest one would be the engine deciding what a microphone is
|
||||
* pointed at.
|
||||
*/
|
||||
export interface ResolvedDevice {
|
||||
id: string;
|
||||
kind: DeviceKind;
|
||||
label: string;
|
||||
assetId: AssetId;
|
||||
levelId: string;
|
||||
/** The prop this device's hardware is, or stands on. Always resolves. */
|
||||
propId: string;
|
||||
/** Office-world metres: anchor prop transform, plus the prop-frame offset. */
|
||||
position: { x: number; y: number; z: number };
|
||||
/** The anchor prop's yaw. A device faces the way its hardware faces. */
|
||||
rotation: Yaw;
|
||||
/** The room the hardware stands in, resolved when the pack did not say. */
|
||||
roomId: string | undefined;
|
||||
/** The seat this device serves, if the pack bound it to one. An address. */
|
||||
seatId: string | undefined;
|
||||
capabilities: readonly DeviceCapability[];
|
||||
provenance: DeviceProvenance;
|
||||
/** The sentence a viewer is shown next to the readings. Never empty. */
|
||||
disclosure: string;
|
||||
}
|
||||
|
||||
/** A room's floor slab, cleaned, re-wound and measured. */
|
||||
export interface ResolvedRoom {
|
||||
id: string;
|
||||
@@ -269,6 +333,8 @@ export interface LevelPlan {
|
||||
props: readonly PropPlacement[];
|
||||
seats: readonly ResolvedSeat[];
|
||||
zones: readonly ResolvedZone[];
|
||||
/** Resolved after every level exists — see the pass in the constructor. */
|
||||
devices: readonly ResolvedDevice[];
|
||||
collision: readonly Segment[];
|
||||
bounds: Bounds;
|
||||
}
|
||||
@@ -289,6 +355,30 @@ export interface PlanProblem {
|
||||
action: "dropped" | "repaired";
|
||||
}
|
||||
|
||||
/**
|
||||
* One authored device waiting for the rest of the building to exist.
|
||||
*
|
||||
* `sink` is the array the level already handed out as `LevelPlan.devices`, so
|
||||
* the late pass fills in the answer the level is already advertising rather than
|
||||
* replacing it.
|
||||
*/
|
||||
interface PendingDevice {
|
||||
where: string;
|
||||
/** The level whose floorplan declared it, which its anchor must agree with. */
|
||||
levelId: string;
|
||||
declaration: DeviceDeclaration;
|
||||
sink: ResolvedDevice[];
|
||||
/**
|
||||
* Prop ids this level had and this *depth* does not — the private half, in a
|
||||
* public build. Shared by every device on the level.
|
||||
*
|
||||
* Without it a public build would report a problem for every device standing
|
||||
* on a private prop, which is not a problem: it is the depth doing exactly
|
||||
* what it is for. See the note beside the audience skips in `buildLevel`.
|
||||
*/
|
||||
hidden: Set<string>;
|
||||
}
|
||||
|
||||
/** How every pass reports. Threaded through rather than closed over, so the
|
||||
* polygon and opening helpers can stay free functions. */
|
||||
type Report = (where: string, message: string, action: PlanProblem["action"]) => void;
|
||||
@@ -365,6 +455,21 @@ export class Plan {
|
||||
readonly levels: readonly LevelPlan[];
|
||||
/** Only those whose `levelId` resolves. `viewpoints[0]` is still the arrival pose. */
|
||||
readonly viewpoints: readonly Viewpoint[];
|
||||
/**
|
||||
* Where a vehicle stands outside, or `null` when the pack authored none or
|
||||
* authored one that does not resolve.
|
||||
*
|
||||
* Deliberately **not** called `arrival`, because `arrival()` next to it means
|
||||
* something else entirely and has since before this field existed: that one is
|
||||
* the camera pose you open the building at, this one is a rectangle of tarmac
|
||||
* outside it. Two things called arrival in one class is how a caller ends up
|
||||
* parking a car in the lobby.
|
||||
*
|
||||
* The authored object is handed back by reference rather than copied. It is
|
||||
* plain data on a frozen-by-convention pack, and a consumer that wants to keep
|
||||
* it can `structuredClone` it — the same treatment `office` gets.
|
||||
*/
|
||||
readonly exteriorArrival: ExteriorArrival | null;
|
||||
/** Everything the validation pass dropped or repaired, in build order. */
|
||||
readonly problems: readonly PlanProblem[];
|
||||
/** The whole office, every level unioned. */
|
||||
@@ -375,6 +480,7 @@ export class Plan {
|
||||
private readonly seatsById = new Map<string, ResolvedSeat>();
|
||||
private readonly propsById = new Map<string, PropPlacement>();
|
||||
private readonly viewpointsById = new Map<string, Viewpoint>();
|
||||
private readonly devicesById = new Map<string, ResolvedDevice>();
|
||||
|
||||
constructor(office: Office, options: PlanOptions = {}) {
|
||||
this.office = office;
|
||||
@@ -400,11 +506,18 @@ export class Plan {
|
||||
seat: new Set<string>(),
|
||||
zone: new Set<string>(),
|
||||
viewpoint: new Set<string>(),
|
||||
device: new Set<string>(),
|
||||
};
|
||||
|
||||
// `levels` and `viewpoints` are required by the type, but a pack arriving as
|
||||
// JSON has been through no type checker at all, and a missing array should
|
||||
// produce an empty office rather than a TypeError with a stack trace in it.
|
||||
// Devices are collected here and resolved after the level loop, for the same
|
||||
// reason the prop-to-seat pass below runs late: an anchor is an address into
|
||||
// the whole building, and checking one against a half-built plan reports a
|
||||
// problem that is not there.
|
||||
const pending: PendingDevice[] = [];
|
||||
|
||||
const levels: LevelPlan[] = [];
|
||||
(office.levels ?? []).forEach((level, li) => {
|
||||
const where = `levels[${li}]`;
|
||||
@@ -413,7 +526,7 @@ export class Plan {
|
||||
return;
|
||||
}
|
||||
seen.level.add(level.id);
|
||||
const built = this.buildLevel(level, levels.length, where, seen, report);
|
||||
const built = this.buildLevel(level, levels.length, where, seen, report, pending);
|
||||
levels.push(built);
|
||||
this.levelsById.set(built.id, built);
|
||||
for (const seat of built.seats) this.seatsById.set(seat.id, seat);
|
||||
@@ -436,6 +549,8 @@ export class Plan {
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of pending) this.resolveDevice(item, seen.device, report);
|
||||
|
||||
const viewpoints: Viewpoint[] = [];
|
||||
(office.viewpoints ?? []).forEach((viewpoint, vi) => {
|
||||
const where = `viewpoints[${vi}]`;
|
||||
@@ -462,6 +577,7 @@ export class Plan {
|
||||
|
||||
this.levels = levels;
|
||||
this.viewpoints = viewpoints;
|
||||
this.exteriorArrival = this.acceptArrival(office.site?.arrival, report);
|
||||
this.problems = problems;
|
||||
this.bounds = extent.finish();
|
||||
}
|
||||
@@ -484,6 +600,15 @@ export class Plan {
|
||||
return this.viewpointsById.get(id) ?? null;
|
||||
}
|
||||
|
||||
device(id: string): ResolvedDevice | null {
|
||||
return this.devicesById.get(id) ?? null;
|
||||
}
|
||||
|
||||
/** Every device in the building, in declaration order, levels in order. */
|
||||
allDevices(): ResolvedDevice[] {
|
||||
return [...this.devicesById.values()];
|
||||
}
|
||||
|
||||
/** Where you arrive. `viewpoints[0]`, or nothing if the pack declared none. */
|
||||
arrival(): Viewpoint | null {
|
||||
return this.viewpoints[0] ?? null;
|
||||
@@ -544,6 +669,7 @@ export class Plan {
|
||||
where: string,
|
||||
seen: Record<"room" | "wall" | "prop" | "seat" | "zone", Set<string>>,
|
||||
report: Report,
|
||||
pending: PendingDevice[],
|
||||
): LevelPlan {
|
||||
const floorY = level.elevation;
|
||||
const wallThickness = level.wallThickness ?? DEFAULT_WALL_THICKNESS;
|
||||
@@ -607,15 +733,34 @@ export class Plan {
|
||||
// which is the one whose author can do something about it.
|
||||
const props: PropPlacement[] = [];
|
||||
const seats: ResolvedSeat[] = [];
|
||||
// Ids the depth took away rather than ids the pack got wrong. Only devices
|
||||
// read it, and only to tell "your hardware is not in this build" apart from
|
||||
// "your hardware does not exist".
|
||||
const hidden = new Set<string>();
|
||||
|
||||
(floorplan.deskBanks ?? []).forEach((bank, bi) => {
|
||||
const at = `${where}.deskBanks[${bi}]`;
|
||||
if (!included(this.depth, bank.audience)) return;
|
||||
if (!included(this.depth, bank.audience)) {
|
||||
// A private bank generates no props at all, so its stations' ids have to
|
||||
// be derived rather than observed. They are contractual — `types.ts`
|
||||
// promises a pack author exactly these strings — which is why they are
|
||||
// computed by the same helper that emits them.
|
||||
const stations = Math.floor(bank.columns) * Math.floor(bank.rows);
|
||||
for (let station = 1; station <= stations; station += 1) {
|
||||
hidden.add(bankPropId(bank.id, "desk", station));
|
||||
if (bank.chair !== undefined) hidden.add(bankPropId(bank.id, "chair", station));
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.expandBank(bank, level, floorY, at, seen, report, props, seats);
|
||||
});
|
||||
|
||||
(floorplan.props ?? []).forEach((prop, pi) => {
|
||||
const at = `${where}.props[${pi}]`;
|
||||
if (!included(this.depth, prop.audience)) return;
|
||||
if (!included(this.depth, prop.audience)) {
|
||||
hidden.add(prop.id);
|
||||
return;
|
||||
}
|
||||
if (seen.prop.has(prop.id)) {
|
||||
report(at, `duplicate prop id "${prop.id}"`, "dropped");
|
||||
return;
|
||||
@@ -661,6 +806,22 @@ export class Plan {
|
||||
});
|
||||
});
|
||||
|
||||
// Devices are not resolved here, only queued: the array this level exposes is
|
||||
// filled in by the pass in the constructor, once every prop in the building
|
||||
// has an id and a transform. Handing the same array out now and filling it
|
||||
// later is what lets `LevelPlan` stay one flat record rather than growing a
|
||||
// second, half-built shape nobody can tell from the finished one.
|
||||
const devices: ResolvedDevice[] = [];
|
||||
(floorplan.devices ?? []).forEach((declaration, di) => {
|
||||
pending.push({
|
||||
where: `${where}.devices[${di}]`,
|
||||
levelId: level.id,
|
||||
declaration,
|
||||
sink: devices,
|
||||
hidden,
|
||||
});
|
||||
});
|
||||
|
||||
// Props and seats join the extent last so that bank expansions are included
|
||||
// too — the bounds of a floor whose only content is one desk bank should not
|
||||
// come out as a point at the origin.
|
||||
@@ -681,6 +842,7 @@ export class Plan {
|
||||
props,
|
||||
seats,
|
||||
zones,
|
||||
devices,
|
||||
collision,
|
||||
bounds: extent.finish(),
|
||||
};
|
||||
@@ -921,7 +1083,7 @@ export class Plan {
|
||||
});
|
||||
|
||||
pushProp({
|
||||
id: `${bank.id}-desk-${n}`,
|
||||
id: bankPropId(bank.id, "desk", station),
|
||||
kind: bank.desk,
|
||||
at: { x, z },
|
||||
rotation: facing,
|
||||
@@ -932,7 +1094,7 @@ export class Plan {
|
||||
|
||||
if (bank.chair !== undefined) {
|
||||
pushProp({
|
||||
id: `${bank.id}-chair-${n}`,
|
||||
id: bankPropId(bank.id, "chair", station),
|
||||
kind: bank.chair,
|
||||
at: { x: x + seatX, z: z + seatZ },
|
||||
rotation: facing,
|
||||
@@ -944,10 +1106,209 @@ export class Plan {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One authored device, bound to the hardware it stands on.
|
||||
*
|
||||
* Nothing here throws, and that is a deliberate difference from
|
||||
* `resolveRobotOperations`, which does. A robot's station list is authored
|
||||
* *behaviour* and a station with no floor under it is a bug in the pack. A
|
||||
* device is authored *furniture*: one mic with a typo in its anchor should
|
||||
* cost a viewer that mic and not the building. So every failure below drops
|
||||
* one device and records why, exactly as a bad wall opening does.
|
||||
*
|
||||
* The order is: what the declaration says about itself, then what it says
|
||||
* about the plan. `validateDeviceDeclaration` owns the first half — id, label,
|
||||
* kind against asset id, capabilities, and the disclosure check that stops a
|
||||
* simulated reading being shown without the word "simulated" anywhere near it.
|
||||
* This method owns only the half that needs a resolved building.
|
||||
*/
|
||||
private resolveDevice(item: PendingDevice, seen: Set<string>, report: Report): void {
|
||||
const { where, levelId, declaration } = item;
|
||||
const id = declaration.id;
|
||||
|
||||
const faults = validateDeviceDeclaration(declaration);
|
||||
if (faults.length > 0) {
|
||||
for (const fault of faults) report(where, fault, "dropped");
|
||||
return;
|
||||
}
|
||||
if (seen.has(id)) {
|
||||
report(where, `duplicate device id "${id}"`, "dropped");
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = declaration.anchor;
|
||||
// The anchor restates the level it was declared on, because the declaration
|
||||
// type has to stand on its own over the wire. Restated facts disagree
|
||||
// eventually, so the disagreement is caught here rather than resolved by
|
||||
// picking a winner.
|
||||
if (anchor.levelId !== levelId) {
|
||||
report(
|
||||
where,
|
||||
`device "${id}" is declared on level "${levelId}" and anchored to "${anchor.levelId}"`,
|
||||
"dropped",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// At public depth a private prop was never resolved, so a device standing on
|
||||
// one lands here and is dropped — which is the right answer: hardware whose
|
||||
// furniture is not in the build has nowhere to be.
|
||||
const prop = this.propsById.get(anchor.propId);
|
||||
if (!prop) {
|
||||
// Not reported, and not a problem: the pack is fine, it is being read at a
|
||||
// depth that does not include the furniture this device stands on. The
|
||||
// device goes with it, which is the answer you want — a public build with
|
||||
// a floating microphone over a desk it cannot see is worse than a public
|
||||
// build with no microphone.
|
||||
if (item.hidden.has(anchor.propId)) return;
|
||||
report(where, `device "${id}" is anchored to unknown prop "${anchor.propId}"`, "dropped");
|
||||
return;
|
||||
}
|
||||
if (prop.levelId !== levelId) {
|
||||
report(
|
||||
where,
|
||||
`device "${id}" is anchored to prop "${prop.id}", which is on level "${prop.levelId}"`,
|
||||
"dropped",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* A mic bolted to a speaker is not a rendering mistake, it is a command sent
|
||||
* to the wrong instrument, so it is dropped rather than drawn.
|
||||
*
|
||||
* Only when the anchor prop is *itself* device hardware, though. A mic
|
||||
* standing on a desk is the ordinary case — `DeviceAnchor.offset` exists for
|
||||
* exactly those few centimetres — and a desk claims to be no device at all,
|
||||
* so `deviceKindOfAssetId` returns null for it and there is nothing to
|
||||
* disagree with.
|
||||
*/
|
||||
const propKind = deviceKindOfAssetId(prop.kind);
|
||||
if (propKind !== null && propKind !== declaration.kind) {
|
||||
report(
|
||||
where,
|
||||
`device "${id}" is a ${declaration.kind} anchored to ${prop.kind}, which is a ${propKind}`,
|
||||
"dropped",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// The one calculation in this method. Local +X of a prop at yaw φ points at
|
||||
// (cos φ, -sin φ) and local +Z at (sin φ, cos φ) — the same frame
|
||||
// `expandBank` lays its stations out in, and the reason the offset is
|
||||
// authored in the prop's frame rather than the room's: turn the desk and the
|
||||
// mic stays on the corner of it.
|
||||
const offset = anchor.offset;
|
||||
const cos = Math.cos(prop.rotation);
|
||||
const sin = Math.sin(prop.rotation);
|
||||
const position = offset
|
||||
? {
|
||||
x: prop.position.x + offset.x * cos + offset.z * sin,
|
||||
y: prop.position.y + offset.y,
|
||||
z: prop.position.z - offset.x * sin + offset.z * cos,
|
||||
}
|
||||
: { x: prop.position.x, y: prop.position.y, z: prop.position.z };
|
||||
|
||||
// `roomId` and `seatId` are addresses rather than positions, and they are
|
||||
// treated the way every other address in this file is: an unknown one is
|
||||
// cleared and reported, never invented. The room is derived when the pack
|
||||
// left it out, because that answer is a lookup and a pack should not have to
|
||||
// restate what the geometry already knows.
|
||||
let roomId = anchor.roomId;
|
||||
if (roomId !== undefined) {
|
||||
const known = this.levelsById.get(levelId)?.rooms.some((room) => room.id === roomId) ?? false;
|
||||
if (!known) {
|
||||
report(where, `device "${id}" names unknown room "${roomId}"`, "repaired");
|
||||
roomId = undefined;
|
||||
}
|
||||
}
|
||||
if (roomId === undefined) {
|
||||
roomId = this.roomAt(levelId, { x: position.x, z: position.z })?.id;
|
||||
}
|
||||
|
||||
let seatId = anchor.seatId;
|
||||
if (seatId !== undefined && !this.seatsById.has(seatId)) {
|
||||
report(where, `device "${id}" serves unknown seat "${seatId}"`, "repaired");
|
||||
seatId = undefined;
|
||||
}
|
||||
|
||||
seen.add(id);
|
||||
const resolved: ResolvedDevice = {
|
||||
id,
|
||||
kind: declaration.kind,
|
||||
label: declaration.label,
|
||||
assetId: declaration.assetId,
|
||||
levelId,
|
||||
propId: prop.id,
|
||||
position,
|
||||
rotation: prop.rotation,
|
||||
roomId,
|
||||
seatId,
|
||||
// Copied rather than aliased: a build product that shares an array with
|
||||
// the pack is one `sort()` away from editing the authored data.
|
||||
capabilities: [...declaration.capabilities],
|
||||
provenance: declaration.provenance,
|
||||
disclosure: declaration.disclosure,
|
||||
};
|
||||
item.sink.push(resolved);
|
||||
this.devicesById.set(id, resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* The exterior stall, checked for the three things that would make it
|
||||
* unusable and deliberately not for the fourth.
|
||||
*
|
||||
* Level, kind and finite numbers are checked. **Whether the stall is actually
|
||||
* outside the building is not**, and that is on purpose: a courtyard block
|
||||
* with a stall in its own yard, a covered undercroft, a loading bay half under
|
||||
* an overhang are all things a real pack might mean, and `Plan` has no
|
||||
* business ruling on architecture. A pack that wants that guarantee asserts it
|
||||
* in its own test — the shipped three do, in `src/test/packs/`.
|
||||
*/
|
||||
private acceptArrival(
|
||||
arrival: ExteriorArrival | undefined,
|
||||
report: Report,
|
||||
): ExteriorArrival | null {
|
||||
if (arrival === undefined || arrival === null) return null;
|
||||
const where = "site.arrival";
|
||||
if (arrival.kind !== "vehicle-stall") {
|
||||
report(where, `arrival anchor has unknown kind "${String(arrival.kind)}"`, "dropped");
|
||||
return null;
|
||||
}
|
||||
if (!this.levelsById.has(arrival.levelId)) {
|
||||
report(where, `arrival anchor is on unknown level "${arrival.levelId}"`, "dropped");
|
||||
return null;
|
||||
}
|
||||
const position = arrival.position;
|
||||
if (
|
||||
!position ||
|
||||
!Number.isFinite(position.x) ||
|
||||
!Number.isFinite(position.z) ||
|
||||
!Number.isFinite(arrival.rotation)
|
||||
) {
|
||||
report(where, "arrival anchor has a position or rotation that is not a number", "dropped");
|
||||
return null;
|
||||
}
|
||||
return arrival;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Placement helpers ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* The prop id a bank station generates.
|
||||
*
|
||||
* Contractual: `types.ts` promises a pack author that bank `eng`'s fourth desk
|
||||
* is `eng-desk-04` and nothing may renumber it. It is a function because two
|
||||
* places need the answer — the expansion that emits them, and the depth pass
|
||||
* that has to name the ones a private bank did *not* emit — and two copies of a
|
||||
* promise is one copy too many.
|
||||
*/
|
||||
function bankPropId(bankId: string, part: "desk" | "chair", station: number): string {
|
||||
return `${bankId}-${part}-${String(station).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function placeProp(prop: Prop, levelId: string, floorY: number): PropPlacement {
|
||||
const s = prop.scale ?? 1;
|
||||
const scale: [number, number, number] =
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
* +Z down the page.
|
||||
*/
|
||||
|
||||
import type { DeviceDeclaration } from "../devices/types.ts";
|
||||
import type { BuildingGlyph, Pin, View } from "../engine/types.ts";
|
||||
|
||||
// ---- Geometry -------------------------------------------------------------
|
||||
@@ -257,6 +258,57 @@ export interface OfficeSite {
|
||||
* the generic marker layer; the city still never imports an office pack.
|
||||
*/
|
||||
exterior?: BuildingGlyph;
|
||||
/**
|
||||
* Where a vehicle stands on the ground outside the front door.
|
||||
*
|
||||
* Optional, and it lives on the *site* rather than on the `Office` for the
|
||||
* same reason `elevation` does: it is a fact about the building's
|
||||
* relationship to the ground outside it, and a pack with no site has no
|
||||
* outside for anything to stand in. A pack that never mentions a vehicle
|
||||
* renders exactly as it did before this field existed, which is the property
|
||||
* every addition to this file has to keep.
|
||||
*/
|
||||
arrival?: ExteriorArrival;
|
||||
}
|
||||
|
||||
/**
|
||||
* A marked place on the ground outside the building.
|
||||
*
|
||||
* The exterior layer needs one number a pack cannot get from anywhere else:
|
||||
* **where, in the plan's own coordinates, is the apron outside the front
|
||||
* door.** `lat`/`lng` says where the building is on the earth and nothing about
|
||||
* which corner of the lot you park on; `Plan.bounds` is the extent of what was
|
||||
* authored and its edge is a wall rather than a kerb. So the stall is authored,
|
||||
* like every other number in a pack.
|
||||
*
|
||||
* ### It is in the plan's frame, not the world's
|
||||
*
|
||||
* `position` is metres in exactly the same XZ frame the walls are in, and
|
||||
* `rotation` is a `Yaw` — zero faces −Z, as everything else here does. That is
|
||||
* what makes the anchor legible next to the wall it stands outside of: a stall
|
||||
* on the street side of a façade authored at `z = 0` has a negative `z`, and a
|
||||
* reader can see it is outside the building without converting anything.
|
||||
*
|
||||
* `levelId` names the storey whose floor the stall is measured from, which for
|
||||
* every shipped pack is the ground floor and for a building on a slope is not
|
||||
* necessarily so.
|
||||
*
|
||||
* ### One kind, spelled out
|
||||
*
|
||||
* `kind` is a closed union with a single member rather than a free string, so
|
||||
* that the second member — a loading bay, a bike rack, a helipad — arrives as a
|
||||
* decision somebody made rather than as a typo that happened to render.
|
||||
*/
|
||||
export interface ExteriorArrival {
|
||||
/** The storey whose floor this stall is measured from. */
|
||||
levelId: string;
|
||||
/** Metres, in the pack's own plan frame, outside the building's footprint. */
|
||||
position: Point2;
|
||||
/** Which way a vehicle parked here faces. See `Yaw`. */
|
||||
rotation: Yaw;
|
||||
kind: "vehicle-stall";
|
||||
/** What to call it, for a caption. Absent where it needs no name. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -318,6 +370,22 @@ export interface Floorplan {
|
||||
deskBanks?: DeskBank[];
|
||||
seats?: Seat[];
|
||||
zones?: Zone[];
|
||||
/**
|
||||
* Smart hardware standing on the furniture — see `src/devices/types.ts`,
|
||||
* which owns the type and is imported here for it.
|
||||
*
|
||||
* A `DeviceDeclaration` is authored, public and inert: it says a microphone
|
||||
* exists, what it can be asked to do, and which prop is its hardware. What
|
||||
* that microphone is *hearing* is a `DeviceState`, which never appears in a
|
||||
* pack at all — it arrives over the API from a route that can refuse an
|
||||
* anonymous caller. That is the same line `Presence` draws one type down, for
|
||||
* the same reason, and it is why a pack can be published and a reading cannot.
|
||||
*
|
||||
* The list is on the floorplan rather than on the `Office` because a device is
|
||||
* anchored to a prop and props are per storey, so the two lists that have to
|
||||
* agree with each other sit next to each other.
|
||||
*/
|
||||
devices?: readonly DeviceDeclaration[];
|
||||
}
|
||||
|
||||
// ---- Rooms ----------------------------------------------------------------
|
||||
|
||||
+1062
-1104
File diff suppressed because it is too large
Load Diff
+217
-15
@@ -4,8 +4,11 @@ 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.
|
||||
this directory is the small worked example — one storey, four rooms, every core
|
||||
feature — and copying it is the intended way to start. `mateo-court.ts` is the
|
||||
large one: two storeys, sixteen rooms, seat bindings, device declarations and a
|
||||
courtyard, and it is the file to read when you want to see a feature used in
|
||||
anger rather than demonstrated.
|
||||
|
||||
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
|
||||
@@ -34,6 +37,7 @@ export const ACME_HQ: Office = {
|
||||
deskBanks: [...],
|
||||
seats: [...],
|
||||
zones: [...],
|
||||
devices: [...], // smart hardware; see "Devices" below
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -91,14 +95,88 @@ walls the light actually comes through. `0` means your "north" really is north.
|
||||
|
||||
`elevation` is the other one worth thinking about, because it is what the horizon
|
||||
is measured from — the difference between an office on the 48th floor and a shed
|
||||
on an airfield is one number, and it is this one. Both shipped packs are worked
|
||||
examples: `lumbridge-hq.ts` is 188 m up and rotated 205°, `frontier-valley.ts` is
|
||||
4 m up and square to the compass.
|
||||
on an airfield is one number, and it is this one. All three shipped packs are
|
||||
worked examples: `lumbridge-hq.ts` is 188 m up and rotated 205°,
|
||||
`frontier-valley.ts` is 4 m up and square to the compass, and `mateo-court.ts` is
|
||||
1.2 m up — a loading dock — and turned 36° onto the 1781 pueblo grid that
|
||||
downtown Los Angeles still follows.
|
||||
|
||||
### Where the car goes
|
||||
|
||||
A sited building may also say where a vehicle stands outside it:
|
||||
|
||||
```ts
|
||||
arrival: {
|
||||
levelId: "level-1",
|
||||
position: { x: 22.6, z: -3.4 }, // metres, in YOUR plan frame
|
||||
rotation: -Math.PI / 2, // Yaw: which way the car points
|
||||
kind: "vehicle-stall",
|
||||
label: "Mateo Street kerb",
|
||||
},
|
||||
```
|
||||
|
||||
`src/engine/officeExterior.ts` builds an apron and a vehicle there. The position
|
||||
is in **the pack's own frame**, the same one the walls are in — which is why a
|
||||
stall on the street side of a façade authored at `z = 0` has a *negative* `z`,
|
||||
and why you can see it is outside the building without converting anything.
|
||||
|
||||
Put it on ground that is genuinely outside: `Plan` checks the level, the kind and
|
||||
that the numbers are numbers, and deliberately does **not** rule on whether the
|
||||
stall is inside the footprint, because a covered undercroft and a courtyard are
|
||||
things a pack might legitimately mean. `src/test/packs/arrivalAnchors.test.ts`
|
||||
is where the three shipped packs assert that theirs are on the street, the
|
||||
podium kerb and the apron.
|
||||
|
||||
One trap, and it is `lumbridge-hq`'s: a pack whose level-0 floor is 188 m above
|
||||
the ground outside has no pavement to park on at all. Its stall is authored
|
||||
beside its own front door because the plan frame is the only frame a pack has,
|
||||
and what "outside" means *vertically* for a tower is the exterior layer's
|
||||
decision. If your building is up in the air, say so in a comment where the next
|
||||
person will find it.
|
||||
|
||||
Nothing here is geocoded and nothing can be. These are numbers you type, like
|
||||
every other number in a pack — see CONTRACT.md §8 for why a coordinate's
|
||||
provenance is a licensing question in this repo.
|
||||
|
||||
## Levels, and the one thing you cannot do with a second one
|
||||
|
||||
A `Level` is a storey: an `elevation` (floor-to-**floor**, not floor-to-ceiling),
|
||||
a default wall height, and its own floorplan in its own frame with its slab at
|
||||
zero. `Plan` adds the elevation to every coordinate on the level exactly once, so
|
||||
you author an upper floor without holding 5 m in your head.
|
||||
|
||||
> ### ⚠️ A walker cannot change levels. Do not author a staircase.
|
||||
>
|
||||
> **This is the single most expensive thing to discover by building it.** The
|
||||
> walk controller is hard-locked to the level it spawned on, in two places:
|
||||
>
|
||||
> - `src/interiors/walker.ts` rejects any state whose `levelId` differs from the
|
||||
> spawn level — a restored or proposed state on another storey is refused as
|
||||
> *"walker snapshot is incompatible or invalid"*.
|
||||
> - `src/interiors/officeWalker.ts` **throws** if the level it is on stops
|
||||
> resolving — *"office walker lost level"*.
|
||||
>
|
||||
> There is no vertical transition anywhere in the engine: no stair traversal, no
|
||||
> lift, no level handoff, and nothing that changes `levelId` after a spawn. So a
|
||||
> flight of stairs you author is a flight of stairs the collider will never let
|
||||
> anybody climb, however carefully you model it. `mateo-court.ts` has a real
|
||||
> dog-leg stair standing in its courtyard, drawn as a floor finish with a 1.2 m
|
||||
> gap in the balustrade at the head of it, and **its upper floor is unreachable
|
||||
> on foot**. That is a known, accepted state and not a bug in the pack.
|
||||
>
|
||||
> Fixing it is an **engine** change — a cross-level walker state, a transition
|
||||
> volume, and a collider that knows about both storeys — and it is out of scope
|
||||
> for a pack author. Until it lands:
|
||||
>
|
||||
> - Author a second storey for what it *is*: a place the camera flies to, which
|
||||
> viewpoints do perfectly well. `mateo-court` gives its upper floor five of
|
||||
> them.
|
||||
> - Give each level a viewpoint of its own so nothing up there is unreachable by
|
||||
> every means at once.
|
||||
> - Do not spend a day on treads. `viewpoints[0]` is the walk spawn, and it is on
|
||||
> exactly one level; everything else on that level is walkable and everything
|
||||
> on any other level is not.
|
||||
|
||||
## Rooms are slabs. Walls are segments.
|
||||
|
||||
This is the load-bearing idea, and the thing most people get backwards on the
|
||||
@@ -117,9 +195,10 @@ Consequences worth stating out loud:
|
||||
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.
|
||||
Overlapping is legal but every shipped pack avoids it — two coplanar slabs at
|
||||
the same height is a z-fight waiting for the wrong GPU, so `mateo-court`
|
||||
notches its courtyard around the stair standing in it rather than laying one
|
||||
slab over the other.
|
||||
|
||||
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
|
||||
@@ -133,10 +212,18 @@ means **no ceiling at all** — an atrium, a void, or a room you want to look do
|
||||
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.
|
||||
the rooms you are trying to see, so most of both large packs declare
|
||||
`ceiling: null`. The rooms that keep theirs are the ones you are never meant to
|
||||
see inside — the bath and storage in the SF studio, the equipment store in the LA
|
||||
one — and a ceiling is a cheap way of saying so.
|
||||
|
||||
`mateo-court` uses the field in a third sense that is worth knowing about: its
|
||||
courtyard and its stair are `ceiling: null` because they are **outside**, and
|
||||
there is nothing above them at any height. Nothing in the format had to change
|
||||
for that; the field already said what was needed. One consequence is worth
|
||||
carrying, though: a room with no ceiling is a room with nothing to hang a light
|
||||
fitting from, and a ceiling grid over an open courtyard is a lighting plan for a
|
||||
different building.
|
||||
|
||||
## Doors and windows are openings, not props
|
||||
|
||||
@@ -339,6 +426,98 @@ only if you place its centre half its own depth off the wall's face:
|
||||
deep and `tera:whiteboard` is 0.10 m. Name those offsets as constants; you will
|
||||
use them a dozen times.
|
||||
|
||||
## Devices
|
||||
|
||||
A pack may declare smart hardware — a mic, a speaker — on its floorplan:
|
||||
|
||||
```ts
|
||||
devices: [
|
||||
{
|
||||
id: "la-front-mic",
|
||||
kind: "mic",
|
||||
label: "Front desk mic",
|
||||
assetId: "tera:device.mic.desk",
|
||||
anchor: { levelId: "level-1", propId: "front-mic", roomId: "lobby", seatId: "front-01" },
|
||||
capabilities: CANONICAL_CAPABILITIES.mic,
|
||||
provenance: "simulated",
|
||||
disclosure: "Simulated studio hardware. These readings are demonstration data…",
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
`src/devices/types.ts` owns the type and is worth reading; four things about it
|
||||
matter when you are authoring one.
|
||||
|
||||
**A device has no coordinate.** `anchor.propId` is required and it *is* the
|
||||
position: the device derives its transform from that prop's, plus an optional
|
||||
`offset` in the prop's own frame for the few centimetres between a desk's origin
|
||||
and the top of a mic stand. Nudge the desk and the mic goes with it, because
|
||||
there was never a second number to forget. Same rule `Prop.seat` follows for
|
||||
chairs, and the same one a pack follows by importing its site instead of
|
||||
restating the coordinates.
|
||||
|
||||
**The anchor prop is the hardware.** Its `kind` is the device asset —
|
||||
`<namespace>:device.<kind>.<placement>`, so `tera:device.mic.desk` and a
|
||||
self-hoster's `acme:device.mic.boom` both read as a mic with nothing registered.
|
||||
`Plan` drops a declaration whose anchor prop is device hardware of the *wrong*
|
||||
kind: a mic bolted to a speaker is not a rendering mistake, it is a command
|
||||
routed to the wrong instrument.
|
||||
|
||||
**`capabilities` should be `CANONICAL_CAPABILITIES[kind]`.** The device panel
|
||||
builds its controls by walking that array and the arena's observation width is
|
||||
the sum of them, so two studios describing a mic differently changes the shape of
|
||||
an RL observation without anybody editing the arena.
|
||||
|
||||
**`disclosure` is mandatory and it is checked.** A declaration with
|
||||
`provenance: "simulated"` whose disclosure does not contain the word is reported
|
||||
as a problem and dropped — the same check `RobotOperationsDefinition` gets, for
|
||||
the same reason. A level meter that moves, with nothing beside it saying where
|
||||
the number came from, is a claim about a real room.
|
||||
|
||||
What a device is *doing* — powered, muted, its level in dBFS — is a `DeviceState`
|
||||
and **never appears in a pack**. It arrives over the API from a route that can
|
||||
refuse an anonymous caller, exactly as `Presence` does. A declaration is a
|
||||
description of a room and is safe to publish; a reading is not, and the split is
|
||||
the whole design.
|
||||
|
||||
## How full a room should be, and the trap in the answer
|
||||
|
||||
The number to aim at is **0.26 non-light props per square metre**, building-wide,
|
||||
with no room over 20 m² below **0.15**. Both shipped studios clear it:
|
||||
`lumbridge-hq` sits at 0.28 over 100 m², `mateo-court` at 0.29 over 1246 m².
|
||||
`src/test/packs/mateoContent.test.ts` measures it if you want the exact method —
|
||||
it counts prop centres by `Plan.roomAt`, and it excludes every `tera:light.*`
|
||||
because a ceiling grid will satisfy any prop count you like while leaving the
|
||||
floor bare. Mateo Court's first version proved that: ninety-eight of its props
|
||||
were troffers, two of the grids hung in rooms declared `ceiling: null`, and it
|
||||
looked empty from every viewpoint it had.
|
||||
|
||||
**But the ratio is the easy half, and on its own it is a lie.** `furnish.ts`
|
||||
batches props by `(asset, colorKey)` and draws `ctx.rand` **once per batch**, so
|
||||
every instance of a kind is geometrically identical — the same seeded jitter,
|
||||
the same books on the same shelf, the same leaves on the same plant. Ten more
|
||||
shelves in a room are one shelf drawn ten times. So:
|
||||
|
||||
> Apparent density is a function of **distinct kinds**, not of prop count.
|
||||
> A room that looks thin does not get better when you copy what is already in it.
|
||||
|
||||
Two consequences for how you fill a room:
|
||||
|
||||
- **Reach for a kind you have not used yet before you reach for a second copy.**
|
||||
Mateo Court's courtyard went from 13 props of 6 kinds to 44 of 13, and it is
|
||||
the second number that changed what it looks like. Twelve of the assets in
|
||||
`src/assets/office/studio.ts` exist because of exactly that room.
|
||||
- **If the kind you need is not in the catalogue, write it.** `defineAsset` plus
|
||||
a `registerAll` is a smaller change than it looks, `src/assets/office/studio.ts`
|
||||
is a worked example of a dozen of them, and a `colorKey` on an existing kind
|
||||
will not stand in for it — the colour is *in* the batch key, so two tints of
|
||||
one asset are two batches of the same geometry, which is better than one and is
|
||||
not a new object.
|
||||
|
||||
A useful floor for a big room is **seven distinct kinds over 40 m²**, which is
|
||||
what the reference pack's one large room manages. Below that a room reads as a
|
||||
pattern rather than a place, whatever the prop count says.
|
||||
|
||||
## Zones
|
||||
|
||||
A named region of floor with an opaque `colorKey`, no behaviour and no effect on
|
||||
@@ -450,6 +629,12 @@ without capturing console output. In a dev build it also warns.
|
||||
- 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.
|
||||
- a device whose anchor names a prop that does not exist, sits on another level,
|
||||
or is device hardware of a different kind; and one whose own declaration is
|
||||
invalid — no label, a `kind` its `assetId` disagrees with, no capabilities, or
|
||||
a `simulated` provenance whose disclosure does not say so
|
||||
- an `arrival` anchor on a level that does not exist, of an unknown `kind`, or
|
||||
with a position that is not a number
|
||||
|
||||
**Repaired** (silently, and recorded):
|
||||
|
||||
@@ -457,6 +642,8 @@ without capturing console output. In a dev build it also warns.
|
||||
- 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
|
||||
- a device naming an unknown room or an unknown seat — the address is cleared,
|
||||
the device stays and the room is re-derived from where its hardware stands
|
||||
|
||||
Missing required arrays read as `[]`, because a pack that arrived over HTTP has
|
||||
been through no type checker.
|
||||
@@ -477,12 +664,27 @@ dropping one bad opening does not renumber its siblings.
|
||||
|
||||
## A checklist before you call it done
|
||||
|
||||
1. `new Plan(office).problems` is empty.
|
||||
1. `new Plan(office).problems` is empty — **at both depths**, `"full"` and
|
||||
`"public"`. They are different builds and only one of them is what a visitor
|
||||
gets.
|
||||
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.
|
||||
4. Seat ids are the ones you are willing to live with for a year, and every seat
|
||||
somebody is meant to occupy has a prop bound to it with `seat:`.
|
||||
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/`.
|
||||
6. `JSON.parse(JSON.stringify(office))` **deep-equals** the office. The usual way
|
||||
to fail this is a helper that writes `elevation: opts.elevation`
|
||||
unconditionally: `{ elevation: undefined }` and `{}` are different objects and
|
||||
only one of them survives the wire. Spread optional fields, do not assign
|
||||
them.
|
||||
7. Every room worth looking at has a viewpoint whose `focus.at` lands inside it,
|
||||
and `viewpoints[0]` is somewhere a person can stand — it is the walk spawn as
|
||||
well as the arrival camera.
|
||||
8. At least 0.26 non-light props/m² building-wide, no room over 20 m² below
|
||||
0.15, and — the one that matters — **at least seven distinct kinds in every
|
||||
room over 40 m²**. See "How full a room should be" above for why the second
|
||||
number is the real one.
|
||||
9. Nothing binary landed under `src/`.
|
||||
|
||||
@@ -182,8 +182,13 @@ function scatter(
|
||||
kind,
|
||||
position,
|
||||
rotation: opts.rotation ?? NORTH,
|
||||
elevation: opts.elevation,
|
||||
colorKey: opts.colorKey,
|
||||
// Spread rather than assigned, because `elevation: undefined` is not the
|
||||
// same shape as no `elevation` at all: `JSON.stringify` drops the key and a
|
||||
// pack that has been through the wire stops deep-equalling the one in the
|
||||
// bundle. CONTRACT.md §2 says those two have to be literally the same
|
||||
// thing, and `src/test/packs/packRegression.test.ts` now checks it.
|
||||
...(opts.elevation === undefined ? {} : { elevation: opts.elevation }),
|
||||
...(opts.colorKey === undefined ? {} : { colorKey: opts.colorKey }),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* without invalidating those addresses.
|
||||
*/
|
||||
|
||||
import { CANONICAL_CAPABILITIES, type DeviceDeclaration } from "../devices/types.ts";
|
||||
import type {
|
||||
AssetId,
|
||||
DeskBank,
|
||||
@@ -64,6 +65,8 @@ const PENDANT: AssetId = "tera:light.pendant";
|
||||
const TROFFER: AssetId = "tera:light.troffer";
|
||||
const RUG: AssetId = "tera:rug";
|
||||
const WHITEBOARD: AssetId = "tera:whiteboard";
|
||||
const MIC: AssetId = "tera:device.mic.desk";
|
||||
const SPEAKER: AssetId = "tera:device.speaker.desk";
|
||||
|
||||
const CONCRETE = "tera:concrete.polished";
|
||||
const WOOD = "tera:wood.plank";
|
||||
@@ -230,6 +233,14 @@ const PROPS: Prop[] = [
|
||||
// screen grants survive this content rewrite.
|
||||
prop("lobby-monitor", MONITOR, 2.15, 4.45, NORTH, { elevation: 0.73 }),
|
||||
prop("agent-monitor-b", MONITOR, 3.97, 4.45, NORTH, { elevation: 0.73 }),
|
||||
// The two pieces of hardware the studio operates, and the only two props in
|
||||
// this pack that a `DeviceDeclaration` points at: a desk condenser on the
|
||||
// left-hand workstation and a monitor speaker beside its screen. Both stand on
|
||||
// the 0.73 m desktop, so both carry the elevation rather than assuming one.
|
||||
// The declarations are in `DEVICES` at the foot of this file and carry no
|
||||
// coordinate — they name these ids and take the transform from here.
|
||||
prop("agent-mic", MIC, 1.62, 4.72, NORTH, { elevation: 0.73, seat: "sf-agent-01" }),
|
||||
prop("agent-speaker", SPEAKER, 2.72, 4.5, NORTH, { elevation: 0.73 }),
|
||||
prop("agent-tree", TREE, 5.15, 4.15),
|
||||
prop("agent-light-a", TROFFER, 2.2, 4.6, NORTH, { elevation: CEILING }),
|
||||
prop("agent-light-b", TROFFER, 4.0, 4.6, NORTH, { elevation: CEILING }),
|
||||
@@ -264,6 +275,69 @@ const PROPS: Prop[] = [
|
||||
prop("lounge-plant", PLANT, 6.4, 7.95),
|
||||
];
|
||||
|
||||
/**
|
||||
* One sentence, shown to a viewer beside every reading this building publishes.
|
||||
*
|
||||
* Mandatory and checked: `validateDeviceDeclaration` refuses a `simulated`
|
||||
* declaration whose disclosure does not contain the word, exactly as
|
||||
* `resolveRobotOperations` refuses a robot definition that does not say its
|
||||
* activity is simulated. A live-looking level meter with no provenance beside it
|
||||
* is a claim about a real room, and this is the field that stops the pack making
|
||||
* one by omission.
|
||||
*/
|
||||
const DISCLOSURE =
|
||||
"Simulated studio hardware. These readings are demonstration data, never live " +
|
||||
"presence data.";
|
||||
|
||||
/**
|
||||
* Two devices: the mic on the desk and the speaker on the computer.
|
||||
*
|
||||
* Both anchored to a prop and neither carrying a coordinate — see `DeviceAnchor`
|
||||
* in `src/devices/types.ts` for why that is the whole design of the type. Move
|
||||
* `agent-mic` 200 mm and the mic moves with it, because there was never a second
|
||||
* number to forget.
|
||||
*
|
||||
* `capabilities` is `CANONICAL_CAPABILITIES[kind]` rather than a hand-written
|
||||
* list. Two studios authored months apart should describe the same instrument
|
||||
* the same way — the device panel builds its controls by walking this array, and
|
||||
* the arena's observation width is the sum of them.
|
||||
*/
|
||||
const DEVICES: DeviceDeclaration[] = [
|
||||
{
|
||||
id: "sf-desk-mic",
|
||||
kind: "mic",
|
||||
label: "Desk mic",
|
||||
assetId: MIC,
|
||||
anchor: {
|
||||
levelId: "level-1",
|
||||
propId: "agent-mic",
|
||||
roomId: "live-work",
|
||||
// The seat this mic is in front of. An address, like every other seat
|
||||
// reference in a pack — it is what lets a consumer ask whether anybody is
|
||||
// sitting where the mic is pointed without being told a coordinate.
|
||||
seatId: "sf-agent-01",
|
||||
},
|
||||
capabilities: CANONICAL_CAPABILITIES.mic,
|
||||
provenance: "simulated",
|
||||
disclosure: DISCLOSURE,
|
||||
},
|
||||
{
|
||||
id: "sf-desk-speaker",
|
||||
kind: "speaker",
|
||||
label: "Desk speaker",
|
||||
assetId: SPEAKER,
|
||||
anchor: {
|
||||
levelId: "level-1",
|
||||
propId: "agent-speaker",
|
||||
roomId: "live-work",
|
||||
seatId: "sf-agent-01",
|
||||
},
|
||||
capabilities: CANONICAL_CAPABILITIES.speaker,
|
||||
provenance: "simulated",
|
||||
disclosure: DISCLOSURE,
|
||||
},
|
||||
];
|
||||
|
||||
const ZONES: Zone[] = [
|
||||
{ id: "zone-entry", name: "Entry", outline: rect(FACE, 6.7, 1.75, DEPTH - FACE), colorKey: "social" },
|
||||
{ id: "zone-agent-bench", name: "Agent Bench", outline: rect(1.1, 3.5, 5.15, 5.6), colorKey: "focus" },
|
||||
@@ -338,6 +412,7 @@ const LEVEL: Level = {
|
||||
deskBanks: DESK_BANKS,
|
||||
seats: SEATS,
|
||||
zones: ZONES,
|
||||
devices: DEVICES,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -354,8 +429,8 @@ export const LUMBRIDGE_HQ: Office = {
|
||||
"A compact 12 × 9 metre San Francisco live/work studio: two agent workstations, a demo lounge, kitchen, sleeping alcove and bath/storage.",
|
||||
author: "Lumbridge",
|
||||
license: "CC0-1.0",
|
||||
version: "2.0.0",
|
||||
updated: "2026-08-19",
|
||||
version: "2.1.0",
|
||||
updated: "2026-08-21",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+1105
-61
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,13 @@ export const MATEO_COURT_ROBOT_OPERATIONS = Object.freeze({
|
||||
officeId: "mateo-court",
|
||||
disclosure: "Seeded robot simulation — no live people, company activity, or operational data.",
|
||||
stations: [
|
||||
{ id: "la-l1-dock", label: "Court charge dock", role: "charge", anchor: { kind: "point", levelId: "level-1", position: { x: 18.4, z: 13.2 }, facing: { x: 1, z: 0 } } },
|
||||
// Against the west wall of the yard, standing on `court-dock` — the
|
||||
// `tera:dock.robot` prop the pack now places at (10.15, 16.9). This used to
|
||||
// read (18.4, 13.2), which is underneath the courtyard's long table: fine
|
||||
// for as long as nothing was drawn there, and a robot standing in the lunch
|
||||
// the moment something was. The dock faces east out of the pad, which is
|
||||
// where its `facing` comes from.
|
||||
{ id: "la-l1-dock", label: "Court charge dock", role: "charge", anchor: { kind: "point", levelId: "level-1", position: { x: 10.9, z: 16.9 }, facing: { x: 1, z: 0 } } },
|
||||
{ id: "la-l1-paseo", label: "Paseo patrol point", role: "patrol", anchor: { kind: "room", roomId: "paseo" } },
|
||||
{ id: "la-l1-mess", label: "Mess patrol point", role: "patrol", anchor: { kind: "room", roomId: "mess" } },
|
||||
{ id: "la-l1-court", label: "Court patrol point", role: "patrol", anchor: { kind: "room", roomId: "court" } },
|
||||
@@ -16,7 +22,11 @@ export const MATEO_COURT_ROBOT_OPERATIONS = Object.freeze({
|
||||
{ id: "la-l1-directory", label: "Paseo directory", role: "inspect", anchor: { kind: "prop", propId: "paseo-directory", standoffM: 0.7, side: -1 } },
|
||||
{ id: "la-l1-display", label: "Works display", role: "inspect", anchor: { kind: "prop", propId: "works-display", standoffM: 0.72, side: -1 } },
|
||||
|
||||
{ id: "la-l2-dock", label: "Loft charge dock", role: "charge", anchor: { kind: "point", levelId: "level-2", position: { x: 13.2, z: 3.0 }, facing: { x: 1, z: 0 } } },
|
||||
// The upper dock moved east out of the new `loft-b` bench, whose three
|
||||
// columns now occupy x 13.4–16.8. It stands on the `loft-dock` prop in the
|
||||
// model bay instead, beside the racks, on the one stretch of the street wall
|
||||
// upstairs with no window in it. Its bay opens south, hence the facing.
|
||||
{ id: "la-l2-dock", label: "Loft charge dock", role: "charge", anchor: { kind: "point", levelId: "level-2", position: { x: 22.6, z: 1.5 }, facing: { x: 0, z: 1 } } },
|
||||
{ id: "la-l2-loft", label: "Loft patrol point", role: "patrol", anchor: { kind: "room", roomId: "loft" } },
|
||||
{ id: "la-l2-palmetto", label: "Palmetto patrol point", role: "patrol", anchor: { kind: "room", roomId: "palmetto" } },
|
||||
{ id: "la-l2-loggia", label: "Loggia patrol point", role: "patrol", anchor: { kind: "point", levelId: "level-2", position: { x: 10.0, z: 8.0 } } },
|
||||
|
||||
@@ -19,6 +19,22 @@
|
||||
* pack importing its own site **from here** rather than declaring it inline. One
|
||||
* source of truth, and the direction of the dependency is the safe one: the
|
||||
* small thing does not know about the large one.
|
||||
*
|
||||
* ### Where the car stands
|
||||
*
|
||||
* Each site below carries an `arrival` anchor: one marked stall on the ground
|
||||
* outside, in **the pack's own plan frame**, which is the only frame a pack has
|
||||
* and the same one its walls are in. `src/engine/officeExterior.ts` builds the
|
||||
* apron and the vehicle there.
|
||||
*
|
||||
* Two of the three are honest ground. `mateo-court` sits 1.2 m above its street
|
||||
* and `frontier-valley` 4 m above an airfield, so a stall a few metres outside
|
||||
* the façade is a stall on the pavement. **`lumbridge-hq` is 188 m up a tower**
|
||||
* and there is no pavement outside its west wall at all — its anchor is the
|
||||
* kerb of the podium, authored beside the front door because the pack frame is
|
||||
* the only place it can be authored, and what "outside" means vertically for a
|
||||
* tower is the exterior layer's decision and not this file's. It is called out
|
||||
* here rather than left for somebody to discover from a car parked in the sky.
|
||||
*/
|
||||
|
||||
import type { OfficeSite } from "../interiors/types.ts";
|
||||
@@ -44,6 +60,17 @@ export const LUMBRIDGE_HQ_SITE: OfficeSite = {
|
||||
seed: 115,
|
||||
bodyColor: 0x8799a8,
|
||||
},
|
||||
// West of the studio's own front door, which is the doorway 6.8 m along
|
||||
// `ext-west`. Parallel to the façade and nosed north, the way a kerbside bay
|
||||
// on a one-way downtown street runs. See the note at the top of this file
|
||||
// about what 188 m of elevation does to the word "outside".
|
||||
arrival: {
|
||||
levelId: "level-1",
|
||||
position: { x: -4.0, z: 7.4 },
|
||||
rotation: 0,
|
||||
kind: "vehicle-stall",
|
||||
label: "Podium kerb",
|
||||
},
|
||||
};
|
||||
|
||||
/** A hangar on the old naval air station. See `frontier-valley.ts`. */
|
||||
@@ -64,6 +91,16 @@ export const FRONTIER_VALLEY_SITE: OfficeSite = {
|
||||
seed: 2718,
|
||||
bodyColor: 0x899397,
|
||||
},
|
||||
// On the apron, seven metres clear of the twelve-metre hangar door in the east
|
||||
// gable and centred on it, nosed in. An apron is the one place in these three
|
||||
// packs where a vehicle is not a visitor but part of the programme.
|
||||
arrival: {
|
||||
levelId: "level-1",
|
||||
position: { x: 61.0, z: 12.0 },
|
||||
rotation: Math.PI / 2,
|
||||
kind: "vehicle-stall",
|
||||
label: "Hangar apron",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -89,6 +126,18 @@ export const MATEO_COURT_SITE: OfficeSite = {
|
||||
seed: 1781,
|
||||
bodyColor: 0xa87960,
|
||||
},
|
||||
// On the street, north of the brick façade and a few metres east of the paseo
|
||||
// arch, so that it is in frame from the arrival viewpoint and three steps from
|
||||
// the front door. `z` is negative because the street façade is authored at
|
||||
// `z = 0` and the pavement is on the other side of it, which is the whole
|
||||
// reason the anchor is in the plan's frame rather than the world's.
|
||||
arrival: {
|
||||
levelId: "level-1",
|
||||
position: { x: 22.6, z: -3.4 },
|
||||
rotation: -Math.PI / 2,
|
||||
kind: "vehicle-stall",
|
||||
label: "Mateo Street kerb",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,17 +47,36 @@ const STYLES = `
|
||||
.tera-webcam-face[data-status="active"] .tera-webcam-face__status { color: #8fd7aa; }
|
||||
.tera-webcam-face[data-status="error"] .tera-webcam-face__status,
|
||||
.tera-webcam-face[data-status="unsupported"] .tera-webcam-face__status { color: #ffb2aa; }
|
||||
/*
|
||||
* The "camera active" indicator.
|
||||
*
|
||||
* It used to position itself: fixed, right-aligned, and a top offset of
|
||||
* var(--s4) plus the safe-area inset plus 4.55rem — a number hand-derived from
|
||||
* the heights of two cards in index.html that this file cannot see, plus a
|
||||
* second copy of the same arithmetic under a media query. Any padding change in
|
||||
* either of those cards silently moved this on top of one of them.
|
||||
*
|
||||
* It is now a plain child of the one top-right flex column, so it has no
|
||||
* position of its own and no opinion about what is above it. Its *stacking*
|
||||
* decision moved with it: index.html raises that whole column to the alert
|
||||
* layer while — and only while — this element is unhidden, because an indicator
|
||||
* saying a camera is on is a safety affordance and a dialog that can cover it
|
||||
* turns "your webcam is live" into a fact the interface knows and the person
|
||||
* does not. That is one rule, in the file that owns the stacking order, instead
|
||||
* of a raw stacking literal here that nothing else on the page knew about.
|
||||
*/
|
||||
.tera-webcam-indicator {
|
||||
position: fixed; top: calc(var(--s4, 16px) + env(safe-area-inset-top) + 4.55rem); right: var(--s4, 16px);
|
||||
z-index: 12; display: flex; align-items: center; gap: 9px; padding: 8px 10px;
|
||||
display: flex; align-items: center; gap: 9px; padding: 8px 10px;
|
||||
color: #dff8e8; background: rgba(8, 32, 20, .92); border: 1px solid rgba(100, 220, 145, .45);
|
||||
border-radius: var(--r-sm, 5px); box-shadow: var(--shadow, 0 8px 28px rgba(0,0,0,.35));
|
||||
font: 11px/1.4 ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
.tera-webcam-indicator__text { flex: 1; min-width: 0; }
|
||||
.tera-webcam-indicator__dot { width: 7px; height: 7px; border-radius: 50%; background: #65dc91; box-shadow: 0 0 0 3px rgba(101,220,145,.14); }
|
||||
.tera-webcam-indicator__stop { min-height: 30px; padding: 4px 8px; color: inherit; background: transparent; border: 1px solid rgba(143,215,170,.4); border-radius: 4px; font: inherit; cursor: pointer; }
|
||||
.tera-webcam-indicator__stop:focus-visible { outline: 2px solid var(--amber, #f2b134); outline-offset: 2px; }
|
||||
@media (max-width: 600px) { .tera-webcam-indicator { top: calc(var(--s3, 12px) + env(safe-area-inset-top) + 5rem); right: var(--s3, 12px); } }
|
||||
/* No phone override any more: the column it lives in carries the breakpoint,
|
||||
which is the whole point of it being in a column. */
|
||||
`;
|
||||
|
||||
const DEFAULT_MESSAGES: Record<WebcamFacePanelStatus, string> = {
|
||||
@@ -117,6 +136,7 @@ export function createWebcamFacePanel(options: WebcamFacePanelOptions): WebcamFa
|
||||
dot.className = "tera-webcam-indicator__dot";
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
const indicatorText = doc.createElement("span");
|
||||
indicatorText.className = "tera-webcam-indicator__text";
|
||||
indicatorText.setAttribute("role", "status");
|
||||
indicatorText.setAttribute("aria-live", "polite");
|
||||
indicatorText.textContent = "Camera active · local face only";
|
||||
|
||||
+187
-1
@@ -27,10 +27,22 @@
|
||||
* | `GET /markers` | `MarkersBody` | yes |
|
||||
* | `GET /offices/:id` | `OfficeDoc` | public offices only |
|
||||
* | `GET /offices/:id/presence` | `PresenceBody` | never |
|
||||
* | `GET /offices/:id/devices` | `DevicesBody` | never |
|
||||
* | `POST /offices/:id/devices/command` | `DeviceCommandResultBody` | never |
|
||||
*
|
||||
* Note the last two. Reading device state and commanding a device are two
|
||||
* routes and two methods, and that is a **security boundary rather than REST
|
||||
* taste**: a command that rode in the read body could be replayed by any shared
|
||||
* cache that had kept a copy of the GET, and turning a microphone on by
|
||||
* replaying a cached read is precisely the outcome the fail-closed
|
||||
* `private, no-store` default in CONTRACT.md §5 exists to prevent. Neither
|
||||
* route is ever `publicCache`d, and the command route is the only body in this
|
||||
* file that goes *up* the wire.
|
||||
*
|
||||
* See CONTRACT.md §5.
|
||||
*/
|
||||
|
||||
import type { DeviceCommand, DeviceState } from "../devices/types.ts";
|
||||
import type { Marker, SatelliteGroup } from "../engine/types.ts";
|
||||
import type { Office, Presence } from "../interiors/types.ts";
|
||||
|
||||
@@ -61,8 +73,36 @@ export type WeatherSourceId = "none" | "nws" | "metno" | "openmeteo";
|
||||
export type FlightsSourceId = "sim" | "adsb" | "dump1090";
|
||||
export type SatellitesSourceId = "none" | "celestrak";
|
||||
export type MarkersSourceId = "none" | "file";
|
||||
/**
|
||||
* Where device readings come from.
|
||||
*
|
||||
* `none` is the default and serves an empty array — a box nobody has told about
|
||||
* any hardware has no hardware, which renders as a studio whose panels say so
|
||||
* rather than as an error. `sim` is the deterministic state machine in
|
||||
* `src/devices/sim.ts`, the same module the arena wraps, and it is what this
|
||||
* build ships. `homeassistant` is named here and implemented nowhere: it is the
|
||||
* door a `first-party-sensor` provenance comes through, and naming it in the
|
||||
* union now is what stops the next person from adding a second, differently
|
||||
* shaped source field when they build it.
|
||||
*/
|
||||
export type DevicesSourceId = "none" | "sim" | "homeassistant";
|
||||
export type AuthMode = "none" | "sso" | "jwt";
|
||||
|
||||
/**
|
||||
* One place this box will answer about. Structurally `Region` in
|
||||
* `server/src/regions.ts`, restated here for the same reason `WireSimRoute` is:
|
||||
* this file is the contract and the server's own module is an implementation of
|
||||
* it, and the browser must not have to import server code to read a body.
|
||||
*/
|
||||
export interface WireRegion {
|
||||
/** Url-safe, and the same id the browser's city pack uses: `sf`, `socal`. */
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
/** Kilometres. */
|
||||
radiusKm: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this deployment turned out to be, once the environment had its say.
|
||||
*
|
||||
@@ -81,12 +121,33 @@ export interface HealthBody {
|
||||
flights: FlightsSourceId;
|
||||
satellites: SatellitesSourceId;
|
||||
markers: MarkersSourceId;
|
||||
/**
|
||||
* Newer than the four above it, and read defensively by
|
||||
* `feedsFrom()` in `src/access.ts` for exactly that reason: a browser
|
||||
* meeting a server one version behind this one sees `undefined` and
|
||||
* concludes the box has no devices, which is the safe direction for a
|
||||
* missing field to fall. Its job is to let a client know that asking is
|
||||
* pointless before it opens a watch that will 404 forever.
|
||||
*/
|
||||
devices: DevicesSourceId;
|
||||
};
|
||||
auth: {
|
||||
mode: AuthMode;
|
||||
/** Where a browser sends someone to sign in. `null` unless mode is `sso`. */
|
||||
entryUrl: string | null;
|
||||
};
|
||||
/**
|
||||
* Every place this box will answer about, in the config's order, so the first
|
||||
* entry is what a request with no query gets.
|
||||
*
|
||||
* Here rather than in a `HealthBodyWithRegions` alias next to the route.
|
||||
* Weather and flights refuse a place this box does not serve, so a client
|
||||
* that guesses `?city=` earns a 400 it cannot explain; publishing the
|
||||
* allowlist turns that into one question asked once. It gives nothing away —
|
||||
* knowing what is served is not the same as widening it, and the ids are the
|
||||
* names of cities the map already draws.
|
||||
*/
|
||||
regions: WireRegion[];
|
||||
/** One human sentence per demotion. Empty on a fully-configured box. */
|
||||
degraded: string[];
|
||||
}
|
||||
@@ -119,6 +180,23 @@ export interface WireAircraft {
|
||||
/** Degrees clockwise from true north. */
|
||||
heading: number;
|
||||
callsign?: string;
|
||||
/**
|
||||
* The transponder's 24-bit ICAO address, lowercase hex, when the feed gave a
|
||||
* real one.
|
||||
*
|
||||
* Carried explicitly even though `id` is usually the same string, because
|
||||
* "usually" is the problem: `id` falls back to the callsign for a record with
|
||||
* no hex, and both community feeds emit `~`-prefixed anonymous addresses for
|
||||
* TIS-B and MLAT targets, which are *not* ICAO addresses. Somebody pastes
|
||||
* this into a registry lookup, so a wrong one names another aircraft
|
||||
* altogether — and `Aircraft` in `engine/types.ts` has nowhere to put it,
|
||||
* which is why the adapter keeps this record beside the position rather than
|
||||
* inferring the address back out of the id.
|
||||
*
|
||||
* Absent, never invented. `aircraftDetail()` in `engine/flights.ts` is where
|
||||
* it becomes a card.
|
||||
*/
|
||||
icao24?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,8 +231,33 @@ export interface FlightsLiveBody {
|
||||
observedAt: number;
|
||||
aircraft: WireAircraft[];
|
||||
ttlSeconds: number;
|
||||
/** Attribution the consumer is expected to display, if the feed asks for it. */
|
||||
/**
|
||||
* Attribution the consumer is expected to display, if the feed asks for it.
|
||||
*
|
||||
* **Derived from the host that answered**, in `server/src/flights/licence.ts`,
|
||||
* and never authored next to the request. It said `adsb.lol` unconditionally
|
||||
* once, whatever `TERA_ADSB_ENDPOINT` pointed at, which is how a credit line
|
||||
* and a source come to disagree.
|
||||
*/
|
||||
attribution?: string[];
|
||||
/**
|
||||
* May a shared cache — or the consumer — hand these bytes to a third party?
|
||||
*
|
||||
* The licence the positions arrived under, reduced to the one bit that
|
||||
* changes behaviour. `false` keeps the route on the fail-closed
|
||||
* `private, no-store` default from CONTRACT.md §5, so a feed this deployment
|
||||
* may *use* but not *redistribute* stops at the browser that asked. Required
|
||||
* rather than optional: a body with no answer to this question is a body
|
||||
* somebody will assume `true` for.
|
||||
*/
|
||||
redistributable: boolean;
|
||||
/**
|
||||
* The licence id the source publishes under, e.g. `"ODbL-1.0"`, or
|
||||
* `"first-party"` for an operator's own receiver. For display and for a
|
||||
* human reading `/api/v1/flights` directly; the machine-readable half of the
|
||||
* same fact `attribution` states in prose.
|
||||
*/
|
||||
licence?: string;
|
||||
}
|
||||
|
||||
export type FlightsBody = FlightsPlanBody | FlightsLiveBody;
|
||||
@@ -368,3 +471,86 @@ export interface PresenceBody {
|
||||
* an index and is never publicly cached.
|
||||
*/
|
||||
export type OfficeVisibility = "public" | "unlisted" | "private";
|
||||
|
||||
// ---- Devices --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* What the hardware in one office is doing, right now.
|
||||
*
|
||||
* The runtime half of the split `src/devices/types.ts` opens with, and the
|
||||
* reason this body exists at all: a `DeviceDeclaration` is authored into the
|
||||
* office pack and is therefore public by construction, while a `DeviceState`
|
||||
* never appears in a file anybody can download. It arrives here, from a route
|
||||
* that can refuse it, and it is the same line `PresenceBody` draws between a
|
||||
* floorplan and the people standing on it.
|
||||
*
|
||||
* **Never publicly cached, in any configuration.** Two reasons and they are
|
||||
* different: the body took a credential to obtain, so a shared cache holding it
|
||||
* would hand one viewer's copy to the next; and the state is mutable by a
|
||||
* command, so a cached copy is a stale claim about a room somebody is standing
|
||||
* in. `routes/devices.ts` therefore never calls `publicCache`, and says so out
|
||||
* loud rather than merely omitting the call.
|
||||
*
|
||||
* An office with no authored devices is `devices: []` and a 200 — not a 404.
|
||||
* "This office does not exist" and "nobody has declared any hardware in it" are
|
||||
* different facts with different fixes, exactly as `presence/store.ts` argues
|
||||
* for a missing roster.
|
||||
*/
|
||||
export interface DevicesBody {
|
||||
officeId: string;
|
||||
devices: DeviceState[];
|
||||
/** Epoch milliseconds at which this snapshot was taken. */
|
||||
observedAt: number;
|
||||
source: DevicesSourceId;
|
||||
/**
|
||||
* Did anybody observe any of this?
|
||||
*
|
||||
* `true` for everything this build ships, because the only implemented source
|
||||
* is a state machine. It is the body-level statement of the same fact
|
||||
* `DeviceState.synthetic` makes per device, and it is carried separately so
|
||||
* that an empty array still says where it came from — an empty `devices` with
|
||||
* `synthetic: false` is a box with a real bridge and nothing plugged into it,
|
||||
* which is a different picture from a box that is making it all up.
|
||||
*/
|
||||
synthetic: boolean;
|
||||
ttlSeconds: number;
|
||||
attribution?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A command, going up.
|
||||
*
|
||||
* The only body in this file that travels from the browser to the server, and
|
||||
* deliberately the narrowest one: exactly one command, for exactly one device,
|
||||
* with no batching. A batch would need partial-failure semantics, and the first
|
||||
* write surface in this product is not the place to invent those.
|
||||
*
|
||||
* The command is validated **server-side against the resolved office plan** —
|
||||
* that the device id names an authored declaration, that the declaration's
|
||||
* asset really is hardware of the kind it claims, and that the op is one that
|
||||
* declaration declared. `normalizeDeviceCommand()` is the shared validator and
|
||||
* both ends run it, which is not redundancy: the browser runs it so a slider
|
||||
* cannot send nonsense, and the server runs it because a browser is not a
|
||||
* boundary. The same move `officeHasMediaBinding()` makes for screens.
|
||||
*/
|
||||
export interface DeviceCommandBody {
|
||||
command: DeviceCommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a command did, as the state that resulted from it.
|
||||
*
|
||||
* The new state rather than an `ok: true`, so the panel has something to draw
|
||||
* without a follow-up GET — and so the answer to "did that work" is the reading
|
||||
* itself rather than an acknowledgement that a request was received. A command
|
||||
* that was accepted and clamped (a gain of 40 dB on a device whose range stops
|
||||
* at 36) reports the clamped value here, and the slider snaps to what the
|
||||
* hardware actually did.
|
||||
*/
|
||||
export interface DeviceCommandResultBody {
|
||||
officeId: string;
|
||||
/** The device as it stands after the command was applied. */
|
||||
device: DeviceState;
|
||||
/** Epoch milliseconds. */
|
||||
observedAt: number;
|
||||
}
|
||||
|
||||
+55
-16
@@ -14,17 +14,22 @@ import {
|
||||
OFFICE_NAV_SCENARIOS,
|
||||
OFFICE_JOBS_INACTION,
|
||||
OFFICE_JOBS_SCENARIOS,
|
||||
STUDIO_OPS_INACTION,
|
||||
STUDIO_OPS_SCENARIOS,
|
||||
CaliforniaFlightEnvironment,
|
||||
CrowNavEnvironment,
|
||||
Drive101Environment,
|
||||
OfficeNavEnvironment,
|
||||
OfficeJobsEnvironment,
|
||||
StudioOpsEnvironment,
|
||||
arenaChecksum,
|
||||
californiaFlightScriptedBaseline,
|
||||
crowNavScriptedBaseline,
|
||||
driveScriptedBaseline,
|
||||
officeNavScriptedBaseline,
|
||||
officeJobsScriptedBaseline,
|
||||
rollout,
|
||||
studioOpsScriptedBaseline,
|
||||
type ArenaEnvironment,
|
||||
type ArenaManifest,
|
||||
type ArenaScenarioRegistry,
|
||||
@@ -41,6 +46,17 @@ interface EnvironmentCase {
|
||||
inaction: unknown;
|
||||
scripted(observation: unknown): unknown;
|
||||
successReason: string;
|
||||
/**
|
||||
* Where the documented inaction action ends up.
|
||||
*
|
||||
* `"max-steps"` for the five environments whose only clock is the step cap.
|
||||
* `studio-ops-v1` has a second one — a departure the studio's car has to be
|
||||
* ready for — and standing still misses it, which is an *outcome* and so sets
|
||||
* `terminated`. The field exists so the contract test can keep asserting the
|
||||
* thing that actually matters (`truncated` is set by the step cap and by
|
||||
* nothing else) rather than being weakened to accommodate the sixth case.
|
||||
*/
|
||||
inactionReason: string;
|
||||
}
|
||||
|
||||
const CASES: EnvironmentCase[] = [
|
||||
@@ -51,6 +67,7 @@ const CASES: EnvironmentCase[] = [
|
||||
inaction: DRIVE_INACTION,
|
||||
scripted: () => driveScriptedBaseline(),
|
||||
successReason: "goal",
|
||||
inactionReason: "max-steps",
|
||||
},
|
||||
{
|
||||
name: "office",
|
||||
@@ -61,6 +78,7 @@ const CASES: EnvironmentCase[] = [
|
||||
observation as Parameters<typeof officeNavScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "goal",
|
||||
inactionReason: "max-steps",
|
||||
},
|
||||
{
|
||||
name: "office-jobs",
|
||||
@@ -71,6 +89,7 @@ const CASES: EnvironmentCase[] = [
|
||||
observation as Parameters<typeof officeJobsScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "job-complete",
|
||||
inactionReason: "max-steps",
|
||||
},
|
||||
{
|
||||
name: "crow",
|
||||
@@ -81,6 +100,7 @@ const CASES: EnvironmentCase[] = [
|
||||
observation as Parameters<typeof crowNavScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "goal",
|
||||
inactionReason: "max-steps",
|
||||
},
|
||||
{
|
||||
name: "flight",
|
||||
@@ -91,33 +111,48 @@ const CASES: EnvironmentCase[] = [
|
||||
observation as Parameters<typeof californiaFlightScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "goal",
|
||||
inactionReason: "max-steps",
|
||||
},
|
||||
{
|
||||
name: "studio-ops",
|
||||
create: () => new StudioOpsEnvironment() as AnyEnvironment,
|
||||
registry: STUDIO_OPS_SCENARIOS as ArenaScenarioRegistry<object>,
|
||||
inaction: STUDIO_OPS_INACTION,
|
||||
scripted: (observation) => studioOpsScriptedBaseline(
|
||||
observation as Parameters<typeof studioOpsScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "job-complete",
|
||||
inactionReason: "departure-missed",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* One episode, through the package's own `rollout`.
|
||||
*
|
||||
* This function used to be a hand-written loop, and so did the two baseline
|
||||
* proofs below and every example in ARENA.md — four copies of the same nine
|
||||
* lines, agreeing by luck. `rollout` is now the one copy, and running the
|
||||
* contract suite through it means a regression in the shared loop fails here
|
||||
* rather than in a consumer's trainer.
|
||||
*/
|
||||
function run(
|
||||
entry: EnvironmentCase,
|
||||
scenarioId: string,
|
||||
seed: number,
|
||||
policy: (observation: unknown) => unknown,
|
||||
): { total: number; final: ArenaStepResult<unknown, NumericRewards> } {
|
||||
const environment = entry.create();
|
||||
let observation = environment.reset(seed, scenarioId).observation;
|
||||
let total = 0;
|
||||
let final: ArenaStepResult<unknown, NumericRewards> | undefined;
|
||||
for (let step = 0; step < environment.manifest.maxSteps; step += 1) {
|
||||
final = environment.step(policy(observation));
|
||||
observation = final.observation;
|
||||
total += final.reward;
|
||||
if (final.terminated || final.truncated) break;
|
||||
}
|
||||
if (!final) throw new Error("environment manifest must permit at least one step");
|
||||
return { total, final };
|
||||
const result = rollout(entry.create(), (observation) => policy(observation), {
|
||||
seed,
|
||||
scenario: scenarioId,
|
||||
});
|
||||
return { total: result.total, final: result.final };
|
||||
}
|
||||
|
||||
describe("arena contract and manifests", () => {
|
||||
it("exports five versioned renderer-independent manifests with disjoint public splits", () => {
|
||||
it("exports six versioned renderer-independent manifests with disjoint public splits", () => {
|
||||
assert.deepEqual(ARENA_MANIFESTS.map((manifest: ArenaManifest) => manifest.id), [
|
||||
"drive-101-v1", "office-nav-v1", "office-jobs-v1", "crow-nav-v1", "california-flight-v1",
|
||||
"studio-ops-v1",
|
||||
]);
|
||||
for (const manifest of ARENA_MANIFESTS) {
|
||||
assert.equal(manifest.apiVersion, ARENA_API_VERSION);
|
||||
@@ -170,9 +205,13 @@ describe("arena contract and manifests", () => {
|
||||
for (const entry of CASES) {
|
||||
const id = entry.registry.ids("train")[0]!;
|
||||
const idle = run(entry, id, 5, () => entry.inaction).final;
|
||||
assert.equal(idle.terminated, false, entry.name);
|
||||
assert.equal(idle.truncated, true, entry.name);
|
||||
assert.equal(idle.info.terminalReason, "max-steps", entry.name);
|
||||
// `truncated` is set by the step cap and by nothing else, in every
|
||||
// environment. An inaction outcome that is not `max-steps` is a genuine
|
||||
// terminal and must therefore set `terminated` instead — which is the
|
||||
// whole distinction this test exists to pin.
|
||||
assert.equal(idle.info.terminalReason, entry.inactionReason, entry.name);
|
||||
assert.equal(idle.truncated, entry.inactionReason === "max-steps", entry.name);
|
||||
assert.equal(idle.terminated, entry.inactionReason !== "max-steps", entry.name);
|
||||
|
||||
const scripted = run(entry, id, 5, entry.scripted).final;
|
||||
assert.equal(scripted.terminated, true, entry.name);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Test home for the `arena` workstream.
|
||||
#
|
||||
# Each build workstream owns its own subdirectory so eight builders can add
|
||||
# suites in parallel without ever colliding on a path. `npm test` picks these
|
||||
# up through the widened `src/test/**/*.test.ts` glob in package.json.
|
||||
@@ -0,0 +1,157 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
ARENA_CHECKSUM_DECIMALS,
|
||||
StudioOpsEnvironment,
|
||||
arenaChecksum,
|
||||
canonicalJson,
|
||||
quantizeForChecksum,
|
||||
quantizeToPlaces,
|
||||
studioOpsScriptedBaseline,
|
||||
} from "../../index.ts";
|
||||
|
||||
describe("canonical json refuses what it cannot describe", () => {
|
||||
it("throws on Map, Set and Date rather than silently emitting an empty object", () => {
|
||||
// The three named in the build spec, and the reason this test exists: each
|
||||
// of them reached the `typeof value === "object"` branch, had no own
|
||||
// enumerable keys, and hashed as `{}`.
|
||||
assert.throws(() => canonicalJson(new Map()), /do not accept Map/);
|
||||
assert.throws(() => canonicalJson(new Set()), /do not accept Set/);
|
||||
assert.throws(() => canonicalJson(new Date()), /do not accept Date/);
|
||||
});
|
||||
|
||||
it("would have hashed a populated Map identically to an empty object", () => {
|
||||
// The regression itself, stated as the thing that must never come back: if
|
||||
// this ever stops throwing, assert that the two do not agree.
|
||||
const populated = new Map([["a", 1], ["b", 2]]);
|
||||
assert.throws(() => arenaChecksum({ operations: populated }));
|
||||
assert.throws(() => arenaChecksum({ operations: new Map() }));
|
||||
});
|
||||
|
||||
it("throws on every other non-plain object, and names it", () => {
|
||||
class Operations {
|
||||
readonly id = "sf";
|
||||
}
|
||||
assert.throws(() => canonicalJson(new Operations()), /do not accept Operations/);
|
||||
assert.throws(() => canonicalJson(new Float64Array(3)), /do not accept Float64Array/);
|
||||
assert.throws(() => canonicalJson(new WeakMap()), /do not accept WeakMap/);
|
||||
assert.throws(() => canonicalJson(/x/), /do not accept RegExp/);
|
||||
assert.throws(() => canonicalJson(Object.create({ inherited: true })), /do not accept/);
|
||||
// Nested, because the dangerous case is a field somebody added to a
|
||||
// snapshot rather than a value somebody handed to the hash directly.
|
||||
assert.throws(() => canonicalJson({ simulation: { stations: new Map() } }), /do not accept Map/);
|
||||
assert.throws(() => canonicalJson([1, { at: new Date(0) }]), /do not accept Date/);
|
||||
});
|
||||
|
||||
it("throws on the primitives JSON has no room for", () => {
|
||||
assert.throws(() => canonicalJson(() => 1), /do not accept function/);
|
||||
assert.throws(() => canonicalJson(10n), /do not accept bigint/);
|
||||
assert.throws(() => canonicalJson(Symbol("x")), /do not accept symbol/);
|
||||
assert.throws(() => canonicalJson(Number.NaN), /finite/);
|
||||
assert.throws(() => canonicalJson(Number.POSITIVE_INFINITY), /finite/);
|
||||
});
|
||||
|
||||
it("still accepts everything a snapshot legitimately contains", () => {
|
||||
assert.equal(
|
||||
canonicalJson({ b: 1, a: [true, null, "x"], c: Object.create(null) }),
|
||||
'{"a":[true,null,"x"],"b":1,"c":{}}',
|
||||
);
|
||||
// Key order is canonical and `undefined` is omitted rather than encoded, so
|
||||
// two objects that differ only in those ways hash the same.
|
||||
assert.equal(arenaChecksum({ a: 1, b: 2 }), arenaChecksum({ b: 2, a: 1, c: undefined }));
|
||||
});
|
||||
|
||||
it("refuses cycles instead of recursing forever", () => {
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
assert.throws(() => canonicalJson(cyclic), /cycles/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checksum quantisation survives a last-place float disagreement", () => {
|
||||
it("rounds non-integers to the documented decimals and leaves integers exact", () => {
|
||||
assert.equal(ARENA_CHECKSUM_DECIMALS, 9);
|
||||
assert.equal(quantizeForChecksum(1 / 3), 0.333333333);
|
||||
// Collapsed to a positive zero, not a negative one: `Object.is` is what
|
||||
// tells the two apart and `canonicalJson` must never emit "-0".
|
||||
assert.ok(Object.is(quantizeForChecksum(-0.0000000004), 0));
|
||||
assert.equal(quantizeToPlaces(1.2345678, 3), 1.235);
|
||||
// Integers pass through untouched, including ones that scaling by 1e9 would
|
||||
// push out of the safe-integer range and *lose* precision on.
|
||||
assert.equal(quantizeForChecksum(9_007_199_254_740_991), 9_007_199_254_740_991);
|
||||
assert.equal(quantizeForChecksum(0), 0);
|
||||
// And so does a magnitude whose own spacing is coarser than the quantum:
|
||||
// rounding it cannot be computed, so the input is returned rather than an
|
||||
// approximation of it.
|
||||
const huge = 1.5e300;
|
||||
assert.equal(quantizeForChecksum(huge), huge);
|
||||
assert.throws(() => quantizeForChecksum(Number.NaN), /finite/);
|
||||
});
|
||||
|
||||
it("hashes two values a last place apart identically", () => {
|
||||
// The failure this defends against: `Math.sin` is not required to be
|
||||
// correctly rounded, so two conforming engines can return neighbouring
|
||||
// doubles for the same argument. Before quantisation that was a verifier
|
||||
// rejecting an honest rollout.
|
||||
const value = 0.8414709848078965;
|
||||
const neighbour = value + Number.EPSILON * value;
|
||||
assert.notEqual(value, neighbour, "the two doubles must genuinely differ");
|
||||
assert.equal(arenaChecksum({ sun: value }), arenaChecksum({ sun: neighbour }));
|
||||
});
|
||||
|
||||
it("still separates values that differ by anything a reward can see", () => {
|
||||
assert.notEqual(arenaChecksum({ reward: 1 }), arenaChecksum({ reward: 1.000001 }));
|
||||
assert.notEqual(arenaChecksum({ reward: 1 }), arenaChecksum({ reward: 1.00000001 }));
|
||||
assert.notEqual(arenaChecksum({ x: 0 }), arenaChecksum({ x: 1e-8 }));
|
||||
});
|
||||
|
||||
it("collapses a negative zero so a vanishing quantity cannot change a hash", () => {
|
||||
assert.equal(arenaChecksum({ x: -0 }), arenaChecksum({ x: 0 }));
|
||||
assert.equal(arenaChecksum({ x: -1e-12 }), arenaChecksum({ x: 0 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("restore pins the simulator sources, not only the manifest", () => {
|
||||
function checkpointed() {
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(23, "train-la-overcast-inspection").observation;
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
observation = environment.step(studioOpsScriptedBaseline(observation)).observation;
|
||||
}
|
||||
return { environment, snapshot: environment.snapshot() };
|
||||
}
|
||||
|
||||
it("carries the environment's own source pins inside the checksummed core", () => {
|
||||
const { snapshot } = checkpointed();
|
||||
assert.match(snapshot.sourceHashes.environment, /^sha256:[0-9a-f]{64}$/);
|
||||
assert.match(snapshot.sourceHashes.simulator, /^sha256:[0-9a-f]{64}$/);
|
||||
const { checksum, ...core } = snapshot;
|
||||
assert.equal(arenaChecksum(core), checksum);
|
||||
});
|
||||
|
||||
it("rejects a snapshot whose sourceHashes differ from the environment's own", () => {
|
||||
// A snapshot used to survive a change to the physics under it: `envHash`
|
||||
// covers the manifest, and the manifest does not move when a walker's
|
||||
// collision epsilon does. Re-signed with a valid checksum, so this can only
|
||||
// be caught by comparing the pins themselves.
|
||||
const { environment, snapshot } = checkpointed();
|
||||
for (const field of ["environment", "simulator"] as const) {
|
||||
const tampered = {
|
||||
...snapshot,
|
||||
sourceHashes: { ...snapshot.sourceHashes, [field]: `sha256:${"9".repeat(64)}` },
|
||||
};
|
||||
const { checksum: _drop, ...core } = tampered;
|
||||
assert.throws(
|
||||
() => environment.restore({ ...core, checksum: arenaChecksum(core) }),
|
||||
/incompatible with this environment/,
|
||||
field,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("still accepts its own snapshot", () => {
|
||||
const { environment, snapshot } = checkpointed();
|
||||
const restored = environment.restore(snapshot);
|
||||
assert.equal(restored.info.step, snapshot.step);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
ARENA_ENVIRONMENTS,
|
||||
ARENA_MANIFESTS,
|
||||
ARENA_SOURCE_HASHES,
|
||||
ArenaScenarioRegistry,
|
||||
arenaEnvironmentIds,
|
||||
rollout,
|
||||
type ArenaManifest,
|
||||
} from "../../index.ts";
|
||||
|
||||
const ARENA_DIR = new URL("../../arena/", import.meta.url);
|
||||
|
||||
describe("ARENA_ENVIRONMENTS is the missing half of the catalogue", () => {
|
||||
it("has a key for every manifest id, and no key that is not one", () => {
|
||||
// Before this existed, `ARENA_MANIFESTS` described environments a harness
|
||||
// had no supported way to instantiate: a caller handed "drive-101-v1" off a
|
||||
// config file kept its own switch, which is a copy of this catalogue
|
||||
// maintained outside the package and wrong the day a sixth env lands.
|
||||
const manifestIds = ARENA_MANIFESTS.map((manifest: ArenaManifest) => manifest.id).sort();
|
||||
assert.deepEqual(Object.keys(ARENA_ENVIRONMENTS).sort(), manifestIds);
|
||||
assert.deepEqual([...arenaEnvironmentIds()].sort(), manifestIds);
|
||||
assert.deepEqual(Object.keys(ARENA_SOURCE_HASHES).sort(), manifestIds);
|
||||
});
|
||||
|
||||
it("returns an object satisfying the whole ArenaEnvironment shape, keyed to its own id", () => {
|
||||
for (const [id, factory] of Object.entries(ARENA_ENVIRONMENTS)) {
|
||||
const environment = factory();
|
||||
assert.equal(environment.manifest.id, id);
|
||||
for (const member of ["reset", "step", "snapshot", "restore", "trace", "replay"] as const) {
|
||||
assert.equal(typeof environment[member], "function", `${id}.${member}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a fresh instance every call, because an episode is state", () => {
|
||||
for (const [id, factory] of Object.entries(ARENA_ENVIRONMENTS)) {
|
||||
const first = factory();
|
||||
const second = factory();
|
||||
assert.notEqual(first, second, id);
|
||||
first.reset(3, { split: "train" });
|
||||
// The second must still be un-reset: two rollouts in flight through the
|
||||
// registry must not be stepping each other's episode.
|
||||
assert.throws(() => second.step({}), /must be reset/, id);
|
||||
}
|
||||
});
|
||||
|
||||
it("drives every environment through the shared rollout by id alone", () => {
|
||||
for (const id of arenaEnvironmentIds()) {
|
||||
const environment = ARENA_ENVIRONMENTS[id]!();
|
||||
const result = rollout(environment, () => ({}), { seed: 12, maxSteps: 24 });
|
||||
assert.equal(result.steps, 24, id);
|
||||
assert.equal(result.final.info.envId, id);
|
||||
assert.ok(Number.isFinite(result.total), id);
|
||||
// Cut short rather than ended, and both flags say so.
|
||||
assert.equal(result.final.terminated || result.final.truncated, false, id);
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps a rollout budget to the manifest and refuses a budget of nothing", () => {
|
||||
const environment = ARENA_ENVIRONMENTS["office-nav-v1"]!();
|
||||
const capped = rollout(environment, () => ({}), { seed: 1, maxSteps: 10_000 });
|
||||
assert.ok(capped.steps <= environment.manifest.maxSteps);
|
||||
assert.throws(
|
||||
() => rollout(ARENA_ENVIRONMENTS["office-nav-v1"]!(), () => ({}), { maxSteps: 0 }),
|
||||
/at least one step/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scenario selection is bound to the id, not to definition order", () => {
|
||||
interface Parameters extends Record<string, number> {
|
||||
marker: number;
|
||||
}
|
||||
|
||||
function registryOf(ids: readonly string[]): ArenaScenarioRegistry<Parameters> {
|
||||
return new ArenaScenarioRegistry<Parameters>(
|
||||
"selection-fixture",
|
||||
ids.map((id, index) => ({
|
||||
id,
|
||||
split: id.startsWith("dev") ? ("dev" as const) : ("train" as const),
|
||||
parameters: { marker: index },
|
||||
})),
|
||||
(parameters) => ({ ...parameters }),
|
||||
);
|
||||
}
|
||||
|
||||
const SEEDS = Array.from({ length: 400 }, (_, index) => index * 7919 + 3);
|
||||
|
||||
it("keeps every seed on the scenario it had when one is inserted in the middle", () => {
|
||||
// The trap this replaces: `candidates[seed % candidates.length]` binds a
|
||||
// seed to an *array position*, so inserting a scenario silently remaps
|
||||
// every seed past it. Nothing fails; the numbers in a results table just
|
||||
// quietly stop meaning what they meant.
|
||||
const before = registryOf(["train-a", "train-b", "train-c", "dev-a"]);
|
||||
const after = registryOf(["train-a", "train-inserted", "train-b", "train-c", "dev-a"]);
|
||||
|
||||
let moved = 0;
|
||||
for (const seed of SEEDS) {
|
||||
const was = before.resolve(seed, { split: "train" }).id;
|
||||
const now = after.resolve(seed, { split: "train" }).id;
|
||||
if (now === "train-inserted") continue;
|
||||
assert.equal(now, was, `seed ${seed}`);
|
||||
moved += 1;
|
||||
}
|
||||
// And the new scenario genuinely wins some seeds, or the assertion above is
|
||||
// vacuous rather than reassuring.
|
||||
assert.ok(moved < SEEDS.length, "the inserted scenario must win some seeds");
|
||||
assert.ok(moved > SEEDS.length * 0.5, "it must not win most of them either");
|
||||
});
|
||||
|
||||
it("is unaffected by reordering the literal at all", () => {
|
||||
const declared = registryOf(["train-a", "train-b", "train-c", "dev-a"]);
|
||||
const shuffled = registryOf(["train-c", "train-a", "dev-a", "train-b"]);
|
||||
for (const seed of SEEDS) {
|
||||
assert.equal(
|
||||
shuffled.resolve(seed, { split: "train" }).id,
|
||||
declared.resolve(seed, { split: "train" }).id,
|
||||
`seed ${seed}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("spreads seeds across the split rather than parking them on one scenario", () => {
|
||||
const registry = registryOf(["train-a", "train-b", "train-c", "dev-a"]);
|
||||
const counts = new Map<string, number>();
|
||||
for (const seed of SEEDS) {
|
||||
const id = registry.resolve(seed, { split: "train" }).id;
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
}
|
||||
assert.equal(counts.size, 3);
|
||||
for (const [id, count] of counts) assert.ok(count > SEEDS.length / 6, `${id}=${count}`);
|
||||
});
|
||||
|
||||
it("bumped every shipped manifest's version, because selection changed under them", () => {
|
||||
// The other half of the fix. A selection change that nothing recorded is
|
||||
// the same silent remap; `version` is what a snapshot, a trace and a
|
||||
// results table are pinned to, so it moves when selection does.
|
||||
for (const manifest of ARENA_MANIFESTS) {
|
||||
const expected = manifest.id === "studio-ops-v1" ? 1 : 2;
|
||||
assert.equal(manifest.version, expected, manifest.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the arena boundary holds", () => {
|
||||
const SOURCES = readdirSync(ARENA_DIR)
|
||||
.filter((name) => name.endsWith(".ts"))
|
||||
.map((name) => ({ name, text: readFileSync(new URL(name, ARENA_DIR), "utf8") }));
|
||||
|
||||
it("has sources to check", () => {
|
||||
assert.ok(SOURCES.length >= 12);
|
||||
});
|
||||
|
||||
it("imports no three.js, no scene adapter, no DOM and no network", () => {
|
||||
// The executable form of ARENA.md's first paragraph and of the build spec's
|
||||
// grep. An arena that reached the renderer would be an arena that cannot be
|
||||
// run headless, and a `studio-ops-v1` that imported `engine/flights.ts` for
|
||||
// its aircraft would have done exactly that on one line.
|
||||
const forbidden =
|
||||
/from ["'](three|three\/[^"']*|\.\.\/engine\/(scene|stage|scenekit|flights|atmosphere|world)\.ts|\.\.\/actors\/sceneActor\.ts|\.\.\/interiors\/officeScene\.ts)["']/;
|
||||
for (const source of SOURCES) {
|
||||
assert.equal(forbidden.test(source.text), false, `${source.name} imports the renderer`);
|
||||
assert.equal(/\bdocument\.|\bwindow\.|\bfetch\(/.test(source.text), false, source.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares a simulator pin for every file studio-ops actually wraps", () => {
|
||||
// A pin list shorter than the import list is a snapshot surviving a change
|
||||
// it should not have survived, so the two are compared rather than trusted.
|
||||
const studioOps = SOURCES.find((source) => source.name === "studioOps.ts")!;
|
||||
const script = readFileSync(new URL("../../../scripts/check-arena-source-hashes.mjs", import.meta.url), "utf8");
|
||||
const pinned = script.slice(script.indexOf('"studio-ops-v1"'));
|
||||
for (const match of studioOps.text.matchAll(/from "\.\.\/([^"]+)"/g)) {
|
||||
const relative = `src/${match[1]}`;
|
||||
assert.ok(pinned.includes(`"${relative}"`), `${relative} is imported but not pinned`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
ARENA_ENVIRONMENTS,
|
||||
ARENA_MANIFESTS,
|
||||
STUDIO_OPS_INACTION,
|
||||
STUDIO_OPS_SCENARIOS,
|
||||
StudioOpsEnvironment,
|
||||
actionWidth,
|
||||
arenaEnvironmentIds,
|
||||
arenaFieldWidth,
|
||||
arenaManifest,
|
||||
flattenAction,
|
||||
flattenObservation,
|
||||
observationWidth,
|
||||
structureAction,
|
||||
studioOpsScriptedBaseline,
|
||||
type ArenaFieldSpec,
|
||||
type StudioOpsAction,
|
||||
type ArenaManifest,
|
||||
type StudioOpsObservation,
|
||||
} from "../../index.ts";
|
||||
|
||||
describe("every manifest declares a space that matches its field list", () => {
|
||||
it("names the same fields, in the same order, on both lists", () => {
|
||||
// The two lists are redundant on purpose — names are the contract that has
|
||||
// been published since v1, spaces are the machine-readable one — and this
|
||||
// is what stops the redundancy rotting into a disagreement.
|
||||
for (const manifest of ARENA_MANIFESTS as readonly ArenaManifest[]) {
|
||||
assert.deepEqual(
|
||||
manifest.observationSpace.map((spec: ArenaFieldSpec) => spec.name),
|
||||
[...manifest.observationFields],
|
||||
manifest.id,
|
||||
);
|
||||
assert.deepEqual(
|
||||
manifest.actionSpace.map((spec: ArenaFieldSpec) => spec.name),
|
||||
[...manifest.actionFields],
|
||||
manifest.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares a usable encoding for every field", () => {
|
||||
for (const manifest of ARENA_MANIFESTS as readonly ArenaManifest[]) {
|
||||
for (const spec of [...manifest.observationSpace, ...manifest.actionSpace]) {
|
||||
// `arenaFieldWidth` is where a malformed spec is caught, so calling it
|
||||
// over the whole catalogue is the validation pass.
|
||||
assert.ok(arenaFieldWidth(spec) >= 1, `${manifest.id}.${spec.name}`);
|
||||
}
|
||||
assert.equal(observationWidth(manifest.id) > 0, true, manifest.id);
|
||||
assert.equal(actionWidth(manifest.id) > 0, true, manifest.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a malformed spec rather than encoding it as something", () => {
|
||||
assert.throws(() => arenaFieldWidth({ name: "x", kind: "float" }), /finite low < high/);
|
||||
assert.throws(
|
||||
() => arenaFieldWidth({ name: "x", kind: "float", low: 1, high: 1 }),
|
||||
/finite low < high/,
|
||||
);
|
||||
assert.throws(
|
||||
() => arenaFieldWidth({ name: "x", kind: "float", low: 0, high: Number.POSITIVE_INFINITY }),
|
||||
/finite low < high/,
|
||||
);
|
||||
assert.throws(() => arenaFieldWidth({ name: "x", kind: "enum", values: [] }), /non-empty/);
|
||||
assert.throws(
|
||||
() => arenaFieldWidth({ name: "x", kind: "enum", values: ["a", "a"] }),
|
||||
/duplicate/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on an environment id nobody published", () => {
|
||||
assert.throws(() => arenaManifest("studio-ops-v2"), /unknown arena environment/);
|
||||
assert.throws(() => flattenObservation("nope", {}), /unknown arena environment/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flattenObservation produces a fixed-width vector of finite numbers", () => {
|
||||
it("matches the declared width at reset for every environment", () => {
|
||||
for (const id of arenaEnvironmentIds()) {
|
||||
const environment = ARENA_ENVIRONMENTS[id]!();
|
||||
const observation = environment.reset(9, { split: "train" }).observation;
|
||||
const vector = flattenObservation(id, observation);
|
||||
assert.equal(vector.length, observationWidth(id), id);
|
||||
assert.ok(vector.every((value) => Number.isFinite(value)), id);
|
||||
}
|
||||
});
|
||||
|
||||
it("stays exactly that width at every step of a full 1200-step studio-ops episode", () => {
|
||||
// A full episode rather than a short one, because the width has to survive
|
||||
// every branch: an empty `nextStationId`, a null payload, a `phase` string
|
||||
// the enum has to already know about, and the truncation at the cap.
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(11, "train-sf-clear-morning-desk-check")
|
||||
.observation as StudioOpsObservation;
|
||||
const widths = new Set([flattenObservation("studio-ops-v1", observation).length]);
|
||||
let steps = 0;
|
||||
let terminalReason: string | null = null;
|
||||
for (let index = 0; index < 1200; index += 1) {
|
||||
// A policy that never works the job but does get the car ready, so the
|
||||
// departure is met and the episode runs the whole cap.
|
||||
const result = environment.step({
|
||||
...STUDIO_OPS_INACTION,
|
||||
micMute: true,
|
||||
vehicleCharge: !observation.vehicleReadyByDeparture,
|
||||
vehiclePrecondition: Math.abs(observation.vehicleCabinC - 21) > 0.5,
|
||||
});
|
||||
observation = result.observation as StudioOpsObservation;
|
||||
widths.add(flattenObservation("studio-ops-v1", observation).length);
|
||||
steps += 1;
|
||||
terminalReason = result.info.terminalReason;
|
||||
if (result.terminated || result.truncated) break;
|
||||
}
|
||||
assert.equal(steps, 1200);
|
||||
assert.equal(terminalReason, "max-steps");
|
||||
assert.deepEqual([...widths], [observationWidth("studio-ops-v1")]);
|
||||
});
|
||||
|
||||
it("keeps the width when the observation is empty, partial or malformed", () => {
|
||||
const width = observationWidth("studio-ops-v1");
|
||||
assert.equal(flattenObservation("studio-ops-v1", {}).length, width);
|
||||
assert.equal(flattenObservation("studio-ops-v1", null).length, width);
|
||||
assert.equal(flattenObservation("studio-ops-v1", { x: Number.NaN }).length, width);
|
||||
assert.ok(
|
||||
flattenObservation("studio-ops-v1", { x: Number.NaN }).every((v) => Number.isFinite(v)),
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes each kind the way the manifest says it does", () => {
|
||||
const space = arenaManifest("studio-ops-v1").observationSpace;
|
||||
const at = (name: string): number => {
|
||||
let cursor = 0;
|
||||
for (const spec of space) {
|
||||
if (spec.name === name) return cursor;
|
||||
cursor += arenaFieldWidth(spec);
|
||||
}
|
||||
throw new Error(`no field ${name}`);
|
||||
};
|
||||
|
||||
// float: the clamped raw value, not a normalization of it.
|
||||
const clamped = flattenObservation("studio-ops-v1", { windKph: 5000 });
|
||||
assert.equal(clamped[at("windKph")], 120);
|
||||
assert.equal(flattenObservation("studio-ops-v1", { windKph: 31.5 })[at("windKph")], 31.5);
|
||||
// A field that is absent or unusable falls back to the declared low, which
|
||||
// cannot be mistaken for a measurement.
|
||||
assert.equal(flattenObservation("studio-ops-v1", {})[at("micLevelDb")], -60);
|
||||
|
||||
// bool: 1 only for a genuine `true`.
|
||||
assert.equal(flattenObservation("studio-ops-v1", { deskOccupied: true })[at("deskOccupied")], 1);
|
||||
assert.equal(flattenObservation("studio-ops-v1", { deskOccupied: 1 })[at("deskOccupied")], 0);
|
||||
|
||||
// enum: one-hot, and all-zeros for a value outside the vocabulary — which
|
||||
// is what a null payload has to encode as. "Carrying nothing" is not a
|
||||
// thing being carried, and must not collide with "carrying a parcel".
|
||||
const cursor = at("weatherCondition");
|
||||
const rain = flattenObservation("studio-ops-v1", { weatherCondition: "rain" });
|
||||
assert.equal(rain.slice(cursor, cursor + 8).reduce((sum, value) => sum + value, 0), 1);
|
||||
assert.equal(rain[cursor + 5], 1);
|
||||
const unknown = flattenObservation("studio-ops-v1", { weatherCondition: "hail" });
|
||||
assert.deepEqual(unknown.slice(cursor, cursor + 8), [0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
assert.equal(flattenObservation("studio-ops-v1", { payload: null })[at("payload")], 0);
|
||||
assert.equal(flattenObservation("studio-ops-v1", { payload: "parcel" })[at("payload")], 1);
|
||||
|
||||
// id: one stable slot in [0, 1), changing exactly when the identity does.
|
||||
const first = flattenObservation("studio-ops-v1", { nextStationId: "sf-studio-monitor" });
|
||||
const same = flattenObservation("studio-ops-v1", { nextStationId: "sf-studio-monitor" });
|
||||
const other = flattenObservation("studio-ops-v1", { nextStationId: "sf-studio-display" });
|
||||
assert.equal(first[at("nextStationId")], same[at("nextStationId")]);
|
||||
assert.notEqual(first[at("nextStationId")], other[at("nextStationId")]);
|
||||
assert.ok(first[at("nextStationId")]! >= 0 && first[at("nextStationId")]! < 1);
|
||||
assert.equal(flattenObservation("studio-ops-v1", { nextStationId: "" })[at("nextStationId")], 0);
|
||||
});
|
||||
|
||||
it("never observes an enum value its own vocabulary does not carry", () => {
|
||||
// The one way a one-hot silently loses information: a `phase` string the
|
||||
// controller assigns and the manifest has never heard of encodes as all
|
||||
// zeros and reads to a policy as an unremarkable state.
|
||||
const space = arenaManifest("studio-ops-v1").observationSpace;
|
||||
const enums = space.filter((spec) => spec.kind === "enum");
|
||||
const seen = new Map<string, Set<string>>(enums.map((spec) => [spec.name, new Set<string>()]));
|
||||
const record = (observation: StudioOpsObservation): void => {
|
||||
for (const spec of enums) {
|
||||
const value = (observation as unknown as Record<string, unknown>)[spec.name];
|
||||
if (typeof value === "string") seen.get(spec.name)!.add(value);
|
||||
}
|
||||
};
|
||||
// Three policies, because one policy visits one corridor of the phase
|
||||
// machine: the scripted baseline works its stations, the drifter never
|
||||
// interacts and sits in `awaiting-interaction`, and the spammer walks into
|
||||
// walls and reaches the recovery phases.
|
||||
const POLICIES: ((observation: StudioOpsObservation, step: number) => StudioOpsAction)[] = [
|
||||
(observation) => studioOpsScriptedBaseline(observation),
|
||||
(observation, step) => ({
|
||||
...STUDIO_OPS_INACTION,
|
||||
micMute: true,
|
||||
x: Math.sin(step / 40) * 0.4,
|
||||
z: Math.cos(step / 37) * 0.4,
|
||||
speakerPlay: observation.deskOccupied,
|
||||
}),
|
||||
() => ({ ...STUDIO_OPS_INACTION, z: 1, interact: true }),
|
||||
];
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
for (const seed of [4, 77]) {
|
||||
for (const policy of POLICIES) {
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(seed, definition.id).observation as
|
||||
StudioOpsObservation;
|
||||
record(observation);
|
||||
for (let index = 0; index < 900; index += 1) {
|
||||
const result = environment.step(policy(observation, index));
|
||||
observation = result.observation as StudioOpsObservation;
|
||||
record(observation);
|
||||
if (result.terminated || result.truncated) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const spec of enums) {
|
||||
for (const value of seen.get(spec.name)!) {
|
||||
assert.ok(spec.values!.includes(value), `${spec.name} observed unknown "${value}"`);
|
||||
}
|
||||
}
|
||||
// And the sweep must actually have seen something, or this proves nothing.
|
||||
assert.ok(seen.get("phase")!.size >= 6, [...seen.get("phase")!].join(","));
|
||||
assert.ok(seen.get("mode")!.size >= 3, [...seen.get("mode")!].join(","));
|
||||
assert.ok(seen.get("weatherCondition")!.size >= 3, [...seen.get("weatherCondition")!].join(","));
|
||||
assert.ok(seen.get("levelId")!.size === 2, [...seen.get("levelId")!].join(","));
|
||||
});
|
||||
});
|
||||
|
||||
describe("structureAction inverts the action encoding", () => {
|
||||
it("round-trips every environment's own baseline action", () => {
|
||||
for (const id of arenaEnvironmentIds()) {
|
||||
const environment = ARENA_ENVIRONMENTS[id]!();
|
||||
environment.reset(2, { split: "train" });
|
||||
const zeroed = structureAction(id, new Array(actionWidth(id)).fill(0));
|
||||
const vector = flattenAction(id, zeroed);
|
||||
assert.equal(vector.length, actionWidth(id), id);
|
||||
assert.deepEqual(structureAction(id, vector), zeroed, id);
|
||||
// And the result is a legal action: stepping with it must not throw.
|
||||
assert.ok(Number.isFinite(environment.step(zeroed).reward), id);
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps a raw network output instead of refusing it", () => {
|
||||
const action = structureAction("studio-ops-v1", [
|
||||
9, -9, 1, 999, 0.4, 5, -1, 0.5, 0.49,
|
||||
]);
|
||||
assert.equal(action.x, 1);
|
||||
assert.equal(action.z, -1);
|
||||
assert.equal(action.interact, true);
|
||||
assert.equal(action.micGain, 36);
|
||||
assert.equal(action.micMute, false);
|
||||
assert.equal(action.speakerVolume, 1);
|
||||
assert.equal(action.speakerPlay, false);
|
||||
assert.equal(action.vehiclePrecondition, true);
|
||||
assert.equal(action.vehicleCharge, false);
|
||||
});
|
||||
|
||||
it("refuses a vector of the wrong length", () => {
|
||||
assert.throws(() => structureAction("studio-ops-v1", [0, 0]), /exactly 9 values/);
|
||||
assert.throws(
|
||||
() => structureAction("studio-ops-v1", new Array(10).fill(0)),
|
||||
/exactly 9 values/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,657 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
STUDIO_OPS_INACTION,
|
||||
STUDIO_OPS_MANIFEST,
|
||||
STUDIO_OPS_SCENARIOS,
|
||||
StudioOpsEnvironment,
|
||||
arenaChecksum,
|
||||
quantizeObservable,
|
||||
rollout,
|
||||
studioOpsEnergyPenalty,
|
||||
studioOpsNoisePenalty,
|
||||
studioOpsScriptedBaseline,
|
||||
studioOverflights,
|
||||
studioSkyAt,
|
||||
studioVehicleReadiness,
|
||||
studioWeatherAt,
|
||||
type StudioOpsAction,
|
||||
type StudioOpsObservation,
|
||||
type StudioOpsReward,
|
||||
} from "../../index.ts";
|
||||
import { Plan } from "../../interiors/plan.ts";
|
||||
import { LUMBRIDGE_HQ } from "../../offices/lumbridge-hq.ts";
|
||||
import { MATEO_COURT } from "../../offices/mateo-court.ts";
|
||||
|
||||
const SEEDS = [1, 0xdecafbad];
|
||||
|
||||
type Observation = StudioOpsObservation;
|
||||
|
||||
function scripted(observation: unknown): StudioOpsAction {
|
||||
return studioOpsScriptedBaseline(observation as Observation);
|
||||
}
|
||||
|
||||
/** Runs a targeted policy and reports where the episode ended. */
|
||||
function reach(
|
||||
scenarioId: string,
|
||||
seed: number,
|
||||
policy: (observation: Observation, step: number) => StudioOpsAction,
|
||||
): { reason: string | null; steps: number; total: number } {
|
||||
const result = rollout(
|
||||
new StudioOpsEnvironment(),
|
||||
(observation, step) => policy(observation as Observation, step),
|
||||
{ seed, scenario: scenarioId },
|
||||
);
|
||||
return { reason: result.final.info.terminalReason, steps: result.steps, total: result.total };
|
||||
}
|
||||
|
||||
describe("studio-ops baselines", () => {
|
||||
it("keeps inaction below zero and never lets it reach the goal", () => {
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
for (const seed of SEEDS) {
|
||||
const idle = rollout(new StudioOpsEnvironment(), () => STUDIO_OPS_INACTION, {
|
||||
seed,
|
||||
scenario: definition.id,
|
||||
});
|
||||
assert.ok(idle.total < 0, `${definition.id}/${seed} inaction=${idle.total}`);
|
||||
assert.notEqual(idle.final.info.terminalReason, "job-complete");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("proves a positive scripted completion that beats inaction on every scenario", () => {
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
for (const seed of SEEDS) {
|
||||
const idle = rollout(new StudioOpsEnvironment(), () => STUDIO_OPS_INACTION, {
|
||||
seed,
|
||||
scenario: definition.id,
|
||||
});
|
||||
const run = rollout(new StudioOpsEnvironment(), scripted, {
|
||||
seed,
|
||||
scenario: definition.id,
|
||||
});
|
||||
const label = `${definition.id}/${seed}`;
|
||||
assert.equal(run.final.info.terminalReason, "job-complete", label);
|
||||
assert.equal(run.final.terminated, true, label);
|
||||
assert.equal(run.final.truncated, false, label);
|
||||
assert.ok(run.total > 0, `${label} scripted=${run.total}`);
|
||||
assert.ok(run.total > idle.total, `${label} ${run.total} <= ${idle.total}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("sums thirteen finite named components and authors no total", () => {
|
||||
const run = rollout(new StudioOpsEnvironment(), scripted, {
|
||||
seed: 5,
|
||||
scenario: "train-sf-clear-morning-desk-check",
|
||||
});
|
||||
const components = run.final.rewardComponents as StudioOpsReward;
|
||||
assert.deepEqual(
|
||||
Object.keys(components).sort(),
|
||||
Object.keys(STUDIO_OPS_MANIFEST.rewardComponents).sort(),
|
||||
);
|
||||
const sum = Object.values(components).reduce((total, value) => total + value, 0);
|
||||
assert.ok(Math.abs(sum - run.final.reward) < 1e-12);
|
||||
assert.ok(Object.values(components).every((value) => Number.isFinite(value)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio-ops terminals are each individually reachable", () => {
|
||||
it("completes the job", () => {
|
||||
assert.equal(reach("train-sf-clear-morning-desk-check", 1, scripted).reason, "job-complete");
|
||||
});
|
||||
|
||||
it("stalls against resolved office collision", () => {
|
||||
// Straight into a wall, forever. The controller's own recovery gives up.
|
||||
const result = reach("train-la-overcast-inspection", 2, () => ({
|
||||
...STUDIO_OPS_INACTION,
|
||||
z: 1,
|
||||
}));
|
||||
assert.equal(result.reason, "collision-stall");
|
||||
});
|
||||
|
||||
it("hits the invalid-interaction limit", () => {
|
||||
const result = reach("train-la-overcast-inspection", 2, () => ({
|
||||
...STUDIO_OPS_INACTION,
|
||||
interact: true,
|
||||
}));
|
||||
assert.equal(result.reason, "wrong-interaction-limit");
|
||||
// Exactly at the limit rather than somewhere after it.
|
||||
assert.equal(result.steps, 8);
|
||||
});
|
||||
|
||||
it("exhausts the studio's energy reserve", () => {
|
||||
// Everything on and the car plugged into a post that draws from the same
|
||||
// reserve. This is the terminal the `energyReservePct` observation exists
|
||||
// to make visible; without it the failure would be unattributable.
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
const result = reach(definition.id, 3, () => ({
|
||||
...STUDIO_OPS_INACTION,
|
||||
speakerVolume: 1,
|
||||
speakerPlay: true,
|
||||
vehiclePrecondition: true,
|
||||
vehicleCharge: true,
|
||||
}));
|
||||
assert.equal(result.reason, "battery-depleted", definition.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("misses the departure", () => {
|
||||
const result = reach("dev-sf-windy-evening-delivery", 1, () => STUDIO_OPS_INACTION);
|
||||
assert.equal(result.reason, "departure-missed");
|
||||
assert.equal(result.steps, 1000);
|
||||
});
|
||||
|
||||
it("declares every one of them except the goal as a safety terminal", () => {
|
||||
assert.deepEqual([...STUDIO_OPS_MANIFEST.safetyTerminals], [
|
||||
"collision-stall",
|
||||
"wrong-interaction-limit",
|
||||
"battery-depleted",
|
||||
"departure-missed",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio-ops snapshot, trace and replay", () => {
|
||||
it("continues bit-exactly from a mid-episode snapshot on every scenario", () => {
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(311, definition.id).observation;
|
||||
for (let index = 0; index < 25; index += 1) {
|
||||
observation = environment.step(scripted(observation)).observation;
|
||||
}
|
||||
const checkpoint = environment.snapshot();
|
||||
const action = scripted(observation);
|
||||
const expected = environment.step(action);
|
||||
const restored = environment.restore(checkpoint);
|
||||
assert.equal(restored.info.step, checkpoint.step, definition.id);
|
||||
// Deep-equal on the whole transition: observation, reward, every
|
||||
// component, both flags and the state checksum.
|
||||
assert.deepEqual(environment.step(action), expected, definition.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("replays a trace to the identical final checksum and cumulative reward", () => {
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(311, definition.id).observation;
|
||||
for (let index = 0; index < 80; index += 1) {
|
||||
const result = environment.step(scripted(observation));
|
||||
observation = result.observation;
|
||||
if (result.terminated || result.truncated) break;
|
||||
}
|
||||
const trace = environment.trace();
|
||||
assert.equal(arenaChecksum({ ...trace, checksum: undefined }), trace.checksum);
|
||||
const replay = new StudioOpsEnvironment().replay(trace);
|
||||
assert.equal(replay.finalStateChecksum, trace.finalStateChecksum, definition.id);
|
||||
assert.equal(replay.cumulativeReward, trace.cumulativeReward, definition.id);
|
||||
assert.equal(replay.steps, trace.steps.length, definition.id);
|
||||
}
|
||||
});
|
||||
|
||||
/** Twelve scripted steps, and the environment left mid-episode. */
|
||||
function midEpisode(): StudioOpsEnvironment {
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(19, "train-la-hot-afternoon-studio").observation;
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
observation = environment.step(scripted(observation)).observation;
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
|
||||
it("throws on a tampered snapshot, and refuses to keep running afterwards", () => {
|
||||
const environment = midEpisode();
|
||||
const snapshot = environment.snapshot();
|
||||
assert.throws(
|
||||
() => environment.restore({ ...snapshot, cumulativeReward: snapshot.cumulativeReward + 1 }),
|
||||
/checksum mismatch/,
|
||||
);
|
||||
// Re-signed, so only the source pins can catch it.
|
||||
const reserved = structuredClone(snapshot.simulation);
|
||||
reserved.energyReserveKWh = -1;
|
||||
const { checksum: _snapshotChecksum, ...snapshotCore } = {
|
||||
...snapshot,
|
||||
simulation: reserved,
|
||||
};
|
||||
assert.throws(
|
||||
() => environment.restore({ ...snapshotCore, checksum: arenaChecksum(snapshotCore) }),
|
||||
/simulation snapshot is invalid/,
|
||||
);
|
||||
// And the environment is now un-reset rather than half-restored: the
|
||||
// episode bookkeeping was written before the simulation payload was
|
||||
// rejected, so continuing would produce a mixture of two episodes.
|
||||
assert.throws(() => environment.trace(), /must be reset/);
|
||||
assert.throws(() => environment.snapshot(), /must be reset/);
|
||||
// A fresh reset brings it back.
|
||||
assert.equal(environment.reset(19, "train-la-hot-afternoon-studio").info.step, 0);
|
||||
});
|
||||
|
||||
it("throws on a tampered trace", () => {
|
||||
const environment = midEpisode();
|
||||
const trace = environment.trace();
|
||||
assert.throws(
|
||||
() => new StudioOpsEnvironment().replay({
|
||||
...trace,
|
||||
cumulativeReward: trace.cumulativeReward + 1,
|
||||
}),
|
||||
/checksum mismatch/,
|
||||
);
|
||||
// A re-signed reward claim: the envelope's own checksum verifies, so the
|
||||
// only thing standing between this and an accepted rollout is that `replay`
|
||||
// recomputes the reward and compares it. This is the cheat the whole
|
||||
// envelope exists to refuse.
|
||||
const inflated = trace.steps.map((frame, index) =>
|
||||
index === 4 ? { ...frame, reward: frame.reward + 1 } : frame,
|
||||
);
|
||||
const { checksum: _rewardChecksum, ...rewardCore } = { ...trace, steps: inflated };
|
||||
assert.throws(
|
||||
() => new StudioOpsEnvironment().replay({
|
||||
...rewardCore,
|
||||
checksum: arenaChecksum(rewardCore),
|
||||
}),
|
||||
/diverged at step 5/,
|
||||
);
|
||||
|
||||
// And a re-signed action swap, which is refused wherever it first shows —
|
||||
// at the frame whose checksum no longer matches, or at the final state if
|
||||
// the swapped action happened to change nothing until the end.
|
||||
const swapped = trace.steps.map((frame, index) =>
|
||||
index === 4 ? { ...frame, action: { ...frame.action, speakerPlay: true } } : frame,
|
||||
);
|
||||
const { checksum: _traceChecksum, ...traceCore } = { ...trace, steps: swapped };
|
||||
assert.throws(
|
||||
() => new StudioOpsEnvironment().replay({
|
||||
...traceCore,
|
||||
checksum: arenaChecksum(traceCore),
|
||||
}),
|
||||
/diverged at step|final state mismatch/,
|
||||
);
|
||||
});
|
||||
|
||||
it("reproduces an episode exactly from the same seed, twice", () => {
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
const first = rollout(new StudioOpsEnvironment(), scripted, {
|
||||
seed: 88,
|
||||
scenario: definition.id,
|
||||
});
|
||||
const second = rollout(new StudioOpsEnvironment(), scripted, {
|
||||
seed: 88,
|
||||
scenario: definition.id,
|
||||
});
|
||||
assert.deepEqual(first, second, definition.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio-ops couples its variables rather than stacking five tasks", () => {
|
||||
it("charges more for the same energy under more cloud, strictly", () => {
|
||||
// The reward's own implementation, over a fine grid: `advanceSimulation`
|
||||
// calls exactly this function with exactly these arguments.
|
||||
for (const loadKw of [0.42, 2.4, 13.9, 152]) {
|
||||
let previous = 0;
|
||||
for (let cloud = 0; cloud <= 1.0001; cloud += 0.02) {
|
||||
const penalty = studioOpsEnergyPenalty(loadKw, cloud);
|
||||
assert.ok(penalty < 0, `${loadKw}@${cloud}`);
|
||||
if (cloud > 0) {
|
||||
assert.ok(
|
||||
Math.abs(penalty) > Math.abs(previous),
|
||||
`magnitude did not rise at cloud=${cloud}, load=${loadKw}`,
|
||||
);
|
||||
}
|
||||
previous = penalty;
|
||||
}
|
||||
}
|
||||
// And it is monotone in the load as well, which is the other half of the
|
||||
// claim that this is an energy price and not a weather penalty.
|
||||
assert.ok(
|
||||
Math.abs(studioOpsEnergyPenalty(150, 0.5)) > Math.abs(studioOpsEnergyPenalty(2, 0.5)),
|
||||
);
|
||||
});
|
||||
|
||||
it("charges more under an overcast sky end to end, even against a heavier load", () => {
|
||||
// Stronger than "all else equal": the hot LA afternoon draws strictly more
|
||||
// power than the overcast one (a 13 K climate gap against a 4 K one) and is
|
||||
// still charged less, because the cloud term dominates the load difference.
|
||||
const energyAt = (scenarioId: string): { energy: number; cloud: number } => {
|
||||
const environment = new StudioOpsEnvironment();
|
||||
environment.reset(1, scenarioId);
|
||||
const result = environment.step(STUDIO_OPS_INACTION);
|
||||
return {
|
||||
energy: (result.rewardComponents as StudioOpsReward).energy,
|
||||
cloud: (result.observation as Observation).cloudCover,
|
||||
};
|
||||
};
|
||||
const clear = energyAt("train-la-hot-afternoon-studio");
|
||||
const overcast = energyAt("train-la-overcast-inspection");
|
||||
assert.ok(clear.cloud < 0.25, `clear cloud=${clear.cloud}`);
|
||||
assert.ok(overcast.cloud > 0.7, `overcast cloud=${overcast.cloud}`);
|
||||
assert.ok(
|
||||
Math.abs(overcast.energy) > Math.abs(clear.energy),
|
||||
`${overcast.energy} vs ${clear.energy}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("charges more for playback the closer an aircraft is, and nothing when silent", () => {
|
||||
const base = {
|
||||
speakerPlaying: true,
|
||||
speakerVolume: 0.6,
|
||||
windKph: 5,
|
||||
micLive: false,
|
||||
deskOccupied: false,
|
||||
};
|
||||
let previous = 0;
|
||||
// Walking the aircraft in from beyond audible range to directly overhead.
|
||||
for (let slant = 5000; slant >= 0; slant -= 100) {
|
||||
const penalty = studioOpsNoisePenalty({ ...base, nearestAircraftSlantM: slant });
|
||||
if (slant < 5000) {
|
||||
assert.ok(penalty < previous, `did not worsen at slant=${slant}`);
|
||||
}
|
||||
previous = penalty;
|
||||
}
|
||||
assert.ok(previous < 0);
|
||||
// Beyond the overhead range it is flat, not negative: an aircraft that
|
||||
// cannot be heard costs nothing.
|
||||
assert.equal(studioOpsNoisePenalty({ ...base, nearestAircraftSlantM: 9000 }), 0);
|
||||
// And a silent speaker cannot be ruined by anything at all.
|
||||
assert.equal(
|
||||
studioOpsNoisePenalty({ ...base, speakerPlaying: false, nearestAircraftSlantM: 0 }),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
studioOpsNoisePenalty({ ...base, speakerVolume: 0, nearestAircraftSlantM: 0 }),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("adds wind and microphone bleed to the same penalty", () => {
|
||||
const quiet = {
|
||||
speakerPlaying: true,
|
||||
speakerVolume: 0.5,
|
||||
nearestAircraftSlantM: 40_000,
|
||||
windKph: 5,
|
||||
micLive: false,
|
||||
deskOccupied: false,
|
||||
};
|
||||
assert.equal(studioOpsNoisePenalty(quiet), 0);
|
||||
assert.ok(studioOpsNoisePenalty({ ...quiet, windKph: 80 }) < 0);
|
||||
assert.ok(studioOpsNoisePenalty({ ...quiet, micLive: true, deskOccupied: true }) < 0);
|
||||
// Bleed needs both: a live microphone in an empty room is not on the take.
|
||||
assert.equal(studioOpsNoisePenalty({ ...quiet, micLive: true }), 0);
|
||||
});
|
||||
|
||||
it("computes the noise component from the sky and wind it publishes", () => {
|
||||
// The wiring, end to end: whatever the observation says the sky and the
|
||||
// wind are doing is what the penalty was computed from. A regression that
|
||||
// read a stale step's weather would break here and nowhere else.
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(31, "train-la-hot-afternoon-studio")
|
||||
.observation as Observation;
|
||||
let sawOverhead = false;
|
||||
for (let index = 0; index < 700; index += 1) {
|
||||
const result = environment.step({
|
||||
...STUDIO_OPS_INACTION,
|
||||
micMute: true,
|
||||
speakerVolume: 0.5,
|
||||
speakerPlay: true,
|
||||
});
|
||||
observation = result.observation as Observation;
|
||||
const expected = studioOpsNoisePenalty({
|
||||
speakerPlaying: observation.speakerPlaying,
|
||||
speakerVolume: observation.speakerVolume,
|
||||
nearestAircraftSlantM: observation.nearestAircraftSlantM,
|
||||
windKph: observation.windKph,
|
||||
micLive: observation.micPowered && !observation.micMuted,
|
||||
deskOccupied: observation.deskOccupied,
|
||||
});
|
||||
assert.equal((result.rewardComponents as StudioOpsReward).noise, expected, `step ${index}`);
|
||||
if (observation.aircraftOverheadCount > 0) sawOverhead = true;
|
||||
if (result.terminated || result.truncated) break;
|
||||
}
|
||||
assert.ok(sawOverhead, "no aircraft came overhead; the assertion above proved nothing");
|
||||
});
|
||||
|
||||
it("couples the microphone to the robot's job rather than to a schedule", () => {
|
||||
// The non-negotiable property, observable: the desk in front of
|
||||
// `sf-desk-mic` is occupied exactly while the robot is working the station
|
||||
// that stands at it, and a mic left live through the rest of the episode is
|
||||
// charged for it.
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(1, "train-sf-clear-morning-desk-check")
|
||||
.observation as Observation;
|
||||
let occupied = 0;
|
||||
let wasted = 0;
|
||||
let ready = 0;
|
||||
for (let index = 0; index < 1200; index += 1) {
|
||||
const result = environment.step({ ...STUDIO_OPS_INACTION, ...scripted(observation), micMute: false });
|
||||
observation = result.observation as Observation;
|
||||
const components = result.rewardComponents as StudioOpsReward;
|
||||
if (observation.deskOccupied) occupied += 1;
|
||||
if (components.audioWaste < 0) wasted += 1;
|
||||
if (components.audioReady > 0) ready += 1;
|
||||
if (result.terminated || result.truncated) break;
|
||||
}
|
||||
assert.ok(occupied > 0, "the robot never reached the desk its microphone serves");
|
||||
assert.ok(ready >= occupied, "a live mic at an occupied desk must be paid for");
|
||||
assert.ok(wasted > 0, "a live mic at an empty desk must be charged for");
|
||||
});
|
||||
|
||||
it("stops charging the mic the moment it is muted", () => {
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(1, "train-la-overcast-inspection")
|
||||
.observation as Observation;
|
||||
const hot = environment.step({ ...STUDIO_OPS_INACTION, micMute: false });
|
||||
assert.equal((hot.rewardComponents as StudioOpsReward).audioWaste < 0, true);
|
||||
observation = hot.observation as Observation;
|
||||
assert.equal(observation.deskOccupied, false);
|
||||
const muted = environment.step({ ...STUDIO_OPS_INACTION, micMute: true });
|
||||
assert.equal((muted.rewardComponents as StudioOpsReward).audioWaste, 0);
|
||||
});
|
||||
|
||||
it("shapes the vehicle with a bounded potential that cannot be farmed", () => {
|
||||
// Potential-based: plugging and unplugging round-trips to zero rather than
|
||||
// paying twice, because the term is the *change* in a bounded readiness.
|
||||
const required = 61.6;
|
||||
assert.equal(studioVehicleReadiness(0, 21, required), 0.5);
|
||||
assert.equal(studioVehicleReadiness(required, 21, required), 1);
|
||||
assert.ok(studioVehicleReadiness(61, 21, required) < studioVehicleReadiness(61.5, 21, required));
|
||||
assert.ok(studioVehicleReadiness(61, 30, required) < studioVehicleReadiness(61, 22, required));
|
||||
// Bounded on both sides, so the shaping cannot diverge.
|
||||
for (const soc of [-10, 0, 50, 500]) {
|
||||
for (const cabin of [-40, 21, 90]) {
|
||||
const value = studioVehicleReadiness(soc, cabin, required);
|
||||
assert.ok(value >= 0 && value <= 1, `${soc}/${cabin}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio-ops weather and sky are scenario parameters, deterministically evolved", () => {
|
||||
const parameters = STUDIO_OPS_SCENARIOS.resolve(7, "dev-sf-windy-evening-delivery").parameters;
|
||||
|
||||
it("is a pure function of the scenario and the elapsed time", () => {
|
||||
for (const seconds of [0, 13.7, 60, 119.9]) {
|
||||
assert.deepEqual(
|
||||
studioWeatherAt(parameters, seconds),
|
||||
studioWeatherAt(parameters, seconds),
|
||||
);
|
||||
}
|
||||
assert.deepEqual(studioOverflights(parameters), studioOverflights(parameters));
|
||||
});
|
||||
|
||||
it("actually moves inside one episode, in every field a policy can read", () => {
|
||||
const samples = [0, 20, 40, 60, 80, 100, 119].map((s) => studioWeatherAt(parameters, s));
|
||||
for (const field of ["cloudCover", "precipitation", "windKph", "windDirDeg"] as const) {
|
||||
const values = new Set(samples.map((sample) => sample[field]));
|
||||
assert.ok(values.size > 1, `${field} never changed`);
|
||||
}
|
||||
const sky = [0, 30, 60, 90, 119].map((s) => studioSkyAt(studioOverflights(parameters), s));
|
||||
assert.ok(new Set(sky.map((entry) => entry.nearestSlantM)).size > 1);
|
||||
});
|
||||
|
||||
it("keeps every reading inside the range its own space declares", () => {
|
||||
for (let seconds = 0; seconds <= 120; seconds += 0.5) {
|
||||
const weather = studioWeatherAt(parameters, seconds);
|
||||
assert.ok(weather.cloudCover >= 0 && weather.cloudCover <= 1);
|
||||
assert.ok(weather.precipitation >= 0 && weather.precipitation <= 1);
|
||||
assert.ok(weather.windKph >= 0);
|
||||
assert.ok(weather.windDirDeg >= 0 && weather.windDirDeg < 360);
|
||||
assert.ok(weather.visibilityKm >= 0.2);
|
||||
}
|
||||
});
|
||||
|
||||
it("never claims a profile was observed when it was invented", () => {
|
||||
// Every shipped scenario is a fixture, and the flag says so. The field
|
||||
// exists for an operator who freezes a real observation into one.
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
const resolved = STUDIO_OPS_SCENARIOS.resolve(3, definition.id);
|
||||
assert.equal(resolved.parameters.weatherReported, false, definition.id);
|
||||
const environment = new StudioOpsEnvironment();
|
||||
const observation = environment.reset(3, definition.id).observation as Observation;
|
||||
assert.equal(observation.weatherReported, false, definition.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("puts the sky and the weather in the scenario hash, where a trace can carry them", () => {
|
||||
const a = STUDIO_OPS_SCENARIOS.resolve(3, "train-la-overcast-inspection");
|
||||
const b = STUDIO_OPS_SCENARIOS.resolve(4, "train-la-overcast-inspection");
|
||||
assert.notEqual(a.hash, b.hash);
|
||||
assert.notEqual(a.parameters.aircraftScheduleSeed, b.parameters.aircraftScheduleSeed);
|
||||
assert.notEqual(a.parameters.cloudPhase, b.parameters.cloudPhase);
|
||||
// The jitter must not turn a named profile into a different one.
|
||||
assert.ok(a.parameters.cloudCoverBase > 0.85 && b.parameters.cloudCoverBase > 0.85);
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio-ops quantises everything a transcendental touched", () => {
|
||||
it("reports no observation finer than the quantum", () => {
|
||||
// The cross-runtime defence, checked where it has to hold: if any of these
|
||||
// carried full double precision, a verifier on other hardware could reject
|
||||
// an honest rollout over the last bit of a sine.
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(2, "train-la-hot-afternoon-studio")
|
||||
.observation as Observation;
|
||||
const fields = [
|
||||
"sunAltitudeDeg", "sunAzimuthDeg", "hourOfDay", "cloudCover", "precipitation",
|
||||
"visibilityKm", "windKph", "windDirDeg", "nearestAircraftSlantM",
|
||||
"vehicleSocPct", "vehicleCabinC", "energyReservePct",
|
||||
] as const;
|
||||
for (let index = 0; index < 400; index += 1) {
|
||||
for (const field of fields) {
|
||||
const value = observation[field];
|
||||
assert.equal(value, quantizeObservable(value), `${field} at step ${index}`);
|
||||
}
|
||||
const result = environment.step(scripted(observation));
|
||||
observation = result.observation as Observation;
|
||||
if (result.terminated || result.truncated) break;
|
||||
}
|
||||
});
|
||||
|
||||
it("puts no Date anywhere near the checksum", () => {
|
||||
// `canonicalJson` throws on one, so this is a live proof rather than a
|
||||
// convention: the snapshot survives being hashed.
|
||||
const environment = new StudioOpsEnvironment();
|
||||
environment.reset(2, "train-sf-clear-morning-desk-check");
|
||||
environment.step(STUDIO_OPS_INACTION);
|
||||
const snapshot = environment.snapshot();
|
||||
assert.match(arenaChecksum(snapshot.simulation), /^fnv1a64:/);
|
||||
assert.equal(typeof JSON.parse(JSON.stringify(snapshot.simulation)), "object");
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio-ops wraps the simulators the renderer drives", () => {
|
||||
it("simulates exactly the devices the shipped packs declare and Plan resolved", () => {
|
||||
// Not a headless copy of the device list: the ids the scenarios name are
|
||||
// the pack's own, and they are the ones `Plan` accepted.
|
||||
const plans = {
|
||||
"lumbridge-hq": new Plan(LUMBRIDGE_HQ, { depth: "public", warn: false }),
|
||||
"mateo-court": new Plan(MATEO_COURT, { depth: "public", warn: false }),
|
||||
};
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
const plan = plans[definition.parameters.officeId];
|
||||
const mic = plan.device(definition.parameters.micId);
|
||||
const speaker = plan.device(definition.parameters.speakerId);
|
||||
assert.ok(mic, `${definition.id} names a microphone the plan did not resolve`);
|
||||
assert.ok(speaker, `${definition.id} names a speaker the plan did not resolve`);
|
||||
assert.equal(mic.kind, "mic");
|
||||
assert.equal(speaker.kind, "speaker");
|
||||
assert.equal(mic.provenance, "simulated");
|
||||
assert.match(mic.disclosure.toLowerCase(), /simulat/);
|
||||
}
|
||||
});
|
||||
|
||||
it("names the same simulator stack in its manifest as it imports", () => {
|
||||
assert.equal(STUDIO_OPS_MANIFEST.id, "studio-ops-v1");
|
||||
for (const fragment of [
|
||||
"Plan",
|
||||
"robotActivity",
|
||||
"createSimulatedDevices",
|
||||
"createSimulatedVehicleTelemetry",
|
||||
"solarPosition",
|
||||
]) {
|
||||
assert.ok(STUDIO_OPS_MANIFEST.simulator.includes(fragment), fragment);
|
||||
}
|
||||
});
|
||||
|
||||
it("observes the robot job, the hardware, the weather, the car and the sky at once", () => {
|
||||
// The point of the environment, as a shape assertion: forty-four fields
|
||||
// across five groups, none of them constant across the catalogue.
|
||||
const environment = new StudioOpsEnvironment();
|
||||
const observation = environment.reset(1, "dev-la-marine-layer-loft-delivery")
|
||||
.observation as Observation;
|
||||
assert.equal(Object.keys(observation).length, STUDIO_OPS_MANIFEST.observationFields.length);
|
||||
assert.deepEqual(
|
||||
Object.keys(observation).sort(),
|
||||
[...STUDIO_OPS_MANIFEST.observationFields].sort(),
|
||||
);
|
||||
assert.equal(observation.officeId, "mateo-court");
|
||||
assert.equal(observation.levelId, "level-2");
|
||||
assert.equal(observation.micPowered, true);
|
||||
assert.equal(observation.energyReservePct, 100);
|
||||
assert.equal(observation.stepsToDeparture, 1000);
|
||||
});
|
||||
|
||||
it("keeps the loft scenario's desk unreachable, on purpose", () => {
|
||||
// Every LA device is on level 1 and this job runs on level 2, so the
|
||||
// correct play is to mute and get on with it. A policy that has only seen a
|
||||
// reachable desk has not learned the difference between "unmute when
|
||||
// somebody arrives" and "unmute".
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(1, "dev-la-marine-layer-loft-delivery")
|
||||
.observation as Observation;
|
||||
for (let index = 0; index < 400; index += 1) {
|
||||
const result = environment.step(scripted(observation));
|
||||
observation = result.observation as Observation;
|
||||
assert.equal(observation.deskOccupied, false, `step ${index}`);
|
||||
if (result.terminated || result.truncated) break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio-ops device commands settle instead of churning", () => {
|
||||
it("stops re-commanding a setpoint the instrument has already reached", () => {
|
||||
// A gain of 12.005 dB is reported back as 12.01, so an action compared
|
||||
// against the reading would disagree with itself forever and be charged a
|
||||
// churn cost on every step for holding a control still. `normalizeAction`
|
||||
// rounds to the precision the instrument reports, which makes the setpoint
|
||||
// a fixed point.
|
||||
const environment = new StudioOpsEnvironment();
|
||||
environment.reset(1, "train-la-overcast-inspection");
|
||||
const action: StudioOpsAction = {
|
||||
...STUDIO_OPS_INACTION,
|
||||
micGain: 12.005,
|
||||
micMute: true,
|
||||
speakerVolume: 0.2225,
|
||||
};
|
||||
const first = environment.step(action);
|
||||
const settled = environment.step(action);
|
||||
const again = environment.step(action);
|
||||
const controlOf = (result: typeof first): number =>
|
||||
(result.rewardComponents as StudioOpsReward).control;
|
||||
assert.ok(controlOf(first) < 0, "the first step really does issue commands");
|
||||
assert.equal(controlOf(settled), controlOf(again));
|
||||
assert.equal(controlOf(settled), 0);
|
||||
const observation = again.observation as Observation;
|
||||
assert.equal(observation.micGainDb, 12.01);
|
||||
assert.equal(observation.speakerVolume, 0.223);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
# Test home for the `assets` workstream.
|
||||
#
|
||||
# Each build workstream owns its own subdirectory so eight builders can add
|
||||
# suites in parallel without ever colliding on a path. `npm test` picks these
|
||||
# up through the widened `src/test/**/*.test.ts` glob in package.json.
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* The two pieces of device hardware, and the three promises the layers above
|
||||
* them are built on.
|
||||
*
|
||||
* `src/interiors/devices.ts` looks a device's LED up **by name**, `plan.ts`
|
||||
* lays a device out from its **footprint** without building it, and every office
|
||||
* in the product is expected to look the same on every reload. None of those
|
||||
* three is visible from inside the builder, and all three break silently: a
|
||||
* renamed sub-object gives you a mic whose mute light never changes, a footprint
|
||||
* in centimetres gives you a microphone the size of a filing cabinet, and a
|
||||
* `Math.random` slipping into a builder gives you a world that reshuffles itself
|
||||
* between visits.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createAssetContext, kit } from "../../assets/kit.ts";
|
||||
import { MaterialRegistry } from "../../assets/materials.ts";
|
||||
import { DEVICE_ASSET_IDS, DEVICE_INDICATOR_NAME } from "../../assets/office/index.ts";
|
||||
import { seeded, withStubCanvas } from "./fakeCanvas.ts";
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function encodeGeometry(root: THREE.Object3D): string {
|
||||
root.updateMatrixWorld(true);
|
||||
const parts: string[] = [];
|
||||
for (const mesh of meshes(root)) {
|
||||
const position = mesh.geometry.getAttribute("position");
|
||||
let checksum = 0;
|
||||
for (let i = 0; i < position.count; i++) {
|
||||
// Quantised to a tenth of a millimetre. Comparing raw floats would make
|
||||
// this fail on a different machine's `Math.sin`, which is not the thing
|
||||
// being tested.
|
||||
checksum =
|
||||
(checksum * 31 +
|
||||
Math.round(position.getX(i) * 1e4) +
|
||||
Math.round(position.getY(i) * 1e4) * 7 +
|
||||
Math.round(position.getZ(i) * 1e4) * 13) |
|
||||
0;
|
||||
}
|
||||
parts.push(`${mesh.name}:${position.count}:${checksum}`);
|
||||
}
|
||||
return parts.join("|");
|
||||
}
|
||||
|
||||
function disposeObject(root: THREE.Object3D): void {
|
||||
for (const mesh of meshes(root)) mesh.geometry.dispose();
|
||||
}
|
||||
|
||||
describe("device hardware assets", () => {
|
||||
it("registers both devices under the parseable `<ns>:device.<kind>.<placement>` id", () => {
|
||||
assert.deepEqual([...DEVICE_ASSET_IDS], [
|
||||
"tera:device.mic.desk",
|
||||
"tera:device.speaker.desk",
|
||||
]);
|
||||
for (const id of DEVICE_ASSET_IDS) {
|
||||
assert.equal(kit.has(id), true, `${id} is not registered`);
|
||||
// The shape `deviceKindOfAssetId()` parses. A device asset whose id does
|
||||
// not match this is dropped as "not device hardware" with no error, so the
|
||||
// pattern is worth pinning here rather than discovering in a plan's
|
||||
// `problems` array.
|
||||
assert.match(id, /^[a-z0-9-]+:device\.(mic|speaker)\.[a-z0-9-]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("is authored at desk scale, in metres", () => {
|
||||
for (const id of DEVICE_ASSET_IDS) {
|
||||
const footprint = kit.footprintOf(id);
|
||||
for (const [name, value] of [
|
||||
["width", footprint.width],
|
||||
["depth", footprint.depth],
|
||||
["height", footprint.height],
|
||||
] as const) {
|
||||
assert.ok(
|
||||
Number.isFinite(value) && value >= 0.02 && value <= 0.6,
|
||||
`${id} ${name} is ${value}, which is not a desk object in metres`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("encloses its own geometry in the footprint it advertises", () => {
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
for (const id of DEVICE_ASSET_IDS) {
|
||||
const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const footprint = kit.footprintOf(id);
|
||||
// A millimetre of slack, because a footprint is a layout number and not
|
||||
// a measurement of the mesh — but only a millimetre, because `Plan`
|
||||
// spaces things by it and a device that overhangs its own footprint is a
|
||||
// device that ends up inside a monitor.
|
||||
assert.ok(size.x <= footprint.width + 0.001, `${id} is ${size.x} wide`);
|
||||
assert.ok(size.z <= footprint.depth + 0.001, `${id} is ${size.z} deep`);
|
||||
assert.ok(box.max.y <= footprint.height + 0.001, `${id} reaches ${box.max.y}`);
|
||||
assert.ok(box.min.y >= -0.001, `${id} starts below the floor at ${box.min.y}`);
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes a sub-object named `indicator` holding the LED and nothing else", () => {
|
||||
const materials = new MaterialRegistry({ quality: "high" });
|
||||
const restore = withStubCanvas();
|
||||
try {
|
||||
for (const id of DEVICE_ASSET_IDS) {
|
||||
const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
|
||||
const indicator = object.getObjectByName(DEVICE_INDICATOR_NAME);
|
||||
assert.ok(indicator, `${id} has no "${DEVICE_INDICATOR_NAME}" sub-object`);
|
||||
|
||||
const led = meshes(indicator);
|
||||
assert.ok(led.length > 0, `${id} indicator holds no geometry`);
|
||||
for (const mesh of led) {
|
||||
const material = mesh.material as THREE.Material;
|
||||
assert.equal(
|
||||
material.name,
|
||||
"deviceIndicator",
|
||||
`${id} indicator carries ${material.name}, which the device layer would not be able to tint safely`,
|
||||
);
|
||||
// An LED is smaller than a shadow-map texel and has nothing to cast.
|
||||
assert.equal(mesh.castShadow, false);
|
||||
assert.equal(mesh.receiveShadow, false);
|
||||
}
|
||||
|
||||
// And the hardware must NOT be in there: the device layer replaces the
|
||||
// material on everything under this name, so a housing that ended up
|
||||
// inside it would light up with the LED.
|
||||
const hardware = meshes(object).filter((mesh) => !led.includes(mesh));
|
||||
assert.ok(hardware.length > 0, `${id} has no hardware outside its indicator`);
|
||||
for (const mesh of hardware) {
|
||||
assert.notEqual((mesh.material as THREE.Material).name, "deviceIndicator");
|
||||
}
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds byte-identical geometry for the same seed", () => {
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
for (const id of DEVICE_ASSET_IDS) {
|
||||
const first = kit.build(id, createAssetContext({ materials, rand: seeded() }));
|
||||
const second = kit.build(id, createAssetContext({ materials, rand: seeded() }));
|
||||
assert.equal(encodeGeometry(first), encodeGeometry(second), `${id} is not deterministic`);
|
||||
disposeObject(first);
|
||||
disposeObject(second);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every material in one primitive class, so nothing is silently dropped", () => {
|
||||
// `mergeGeometries` refuses a mixture of indexed and non-indexed inputs and
|
||||
// `MeshBin` treats the refusal as "skip this material" — which is not an
|
||||
// error, it is a speaker with no cabinet. The symptom is a *missing* mesh,
|
||||
// so the assertion is on the count of materials that survived.
|
||||
const materials = new MaterialRegistry({ quality: "high" });
|
||||
const restore = withStubCanvas();
|
||||
try {
|
||||
const expected: Record<string, number> = {
|
||||
"tera:device.mic.desk": 4,
|
||||
"tera:device.speaker.desk": 5,
|
||||
};
|
||||
for (const id of DEVICE_ASSET_IDS) {
|
||||
const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
|
||||
const names = new Set(meshes(object).map((m) => (m.material as THREE.Material).name));
|
||||
assert.equal(
|
||||
names.size,
|
||||
expected[id],
|
||||
`${id} came back with ${names.size} materials (${[...names].join(", ")}) — a dropped one means a merge was refused`,
|
||||
);
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives the grille and the shell the roles the device layer expects", () => {
|
||||
const materials = new MaterialRegistry({ quality: "high" });
|
||||
const restore = withStubCanvas();
|
||||
try {
|
||||
const speaker = kit.build(
|
||||
"tera:device.speaker.desk",
|
||||
createAssetContext({ materials, rand: seeded() }),
|
||||
);
|
||||
const names = new Set(meshes(speaker).map((m) => (m.material as THREE.Material).name));
|
||||
assert.ok(names.has("deviceShell"), "the cabinet is missing its moulded-housing role");
|
||||
assert.ok(names.has("deviceMesh"), "the grille is missing its perforated role");
|
||||
// The grille has to be double-sided or the gaps between the slats show
|
||||
// nothing behind them, which is the whole reason it is geometry.
|
||||
const grille = meshes(speaker).find(
|
||||
(m) => (m.material as THREE.Material).name === "deviceMesh",
|
||||
);
|
||||
assert.ok(grille);
|
||||
assert.equal((grille.material as THREE.Material).side, THREE.DoubleSide);
|
||||
disposeObject(speaker);
|
||||
} finally {
|
||||
restore();
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* The smallest `document.createElement("canvas")` that makes `TextureBin` draw.
|
||||
*
|
||||
* `TextureBin.get` returns `null` when there is no canvas to draw on, which is
|
||||
* the right behaviour under Node and is exactly what makes the *material*
|
||||
* assertions in this directory impossible without a stub: a `screenContent`
|
||||
* material built under `node --test` has a null `map` for a reason that has
|
||||
* nothing to do with whether the binding is correct.
|
||||
*
|
||||
* So this installs a context that records nothing and rasterises nothing. It
|
||||
* exists only so that `new THREE.CanvasTexture(canvas)` has a canvas, and the
|
||||
* assertions that follow are about *which map is bound to which slot*, never
|
||||
* about pixels. `src/test/render/textureMaps.test.ts` is where the drawings
|
||||
* themselves are pinned, and duplicating that here would be two files asserting
|
||||
* one thing.
|
||||
*
|
||||
* Deliberately not auto-installing on import: a module with a side effect on
|
||||
* `globalThis` that fires on import is the kind of thing that makes one test
|
||||
* file's behaviour depend on another's import order.
|
||||
*/
|
||||
|
||||
interface StubCanvas {
|
||||
width: number;
|
||||
height: number;
|
||||
getContext(id: string): unknown;
|
||||
}
|
||||
|
||||
function stubContext(): unknown {
|
||||
const noop = (): void => {};
|
||||
return {
|
||||
set fillStyle(_v: unknown) {},
|
||||
get fillStyle(): string {
|
||||
return "";
|
||||
},
|
||||
set strokeStyle(_v: unknown) {},
|
||||
set lineWidth(_v: number) {},
|
||||
set lineCap(_v: string) {},
|
||||
set lineJoin(_v: string) {},
|
||||
set globalAlpha(_v: number) {},
|
||||
set globalCompositeOperation(_v: string) {},
|
||||
set font(_v: string) {},
|
||||
set textAlign(_v: string) {},
|
||||
set textBaseline(_v: string) {},
|
||||
set filter(_v: string) {},
|
||||
save: noop,
|
||||
restore: noop,
|
||||
translate: noop,
|
||||
rotate: noop,
|
||||
scale: noop,
|
||||
clip: noop,
|
||||
fillRect: noop,
|
||||
clearRect: noop,
|
||||
strokeRect: noop,
|
||||
beginPath: noop,
|
||||
closePath: noop,
|
||||
moveTo: noop,
|
||||
lineTo: noop,
|
||||
arc: noop,
|
||||
arcTo: noop,
|
||||
ellipse: noop,
|
||||
rect: noop,
|
||||
quadraticCurveTo: noop,
|
||||
bezierCurveTo: noop,
|
||||
fill: noop,
|
||||
stroke: noop,
|
||||
fillText: noop,
|
||||
createLinearGradient: () => ({ addColorStop: noop }),
|
||||
createRadialGradient: () => ({ addColorStop: noop }),
|
||||
getImageData: (_x: number, _y: number, w: number, h: number) => ({
|
||||
data: new Uint8ClampedArray(Math.max(1, w * h * 4)).fill(255),
|
||||
width: w,
|
||||
height: h,
|
||||
}),
|
||||
putImageData: noop,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the stub and return the undo. Call the undo in a `finally`: leaving a
|
||||
* fake `document` on `globalThis` changes how every module loaded afterwards
|
||||
* decides whether it is in a browser.
|
||||
*/
|
||||
export function withStubCanvas(): () => void {
|
||||
const global = globalThis as { document?: unknown };
|
||||
const had = "document" in global;
|
||||
const previous = global.document;
|
||||
global.document = {
|
||||
createElement(tag: string): StubCanvas {
|
||||
if (tag !== "canvas") throw new Error(`unexpected element <${tag}>`);
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => stubContext(),
|
||||
};
|
||||
},
|
||||
};
|
||||
return () => {
|
||||
if (had) global.document = previous;
|
||||
else delete global.document;
|
||||
};
|
||||
}
|
||||
|
||||
/** A deterministic PRNG, so "same seed, same geometry" is testable at all. */
|
||||
export function seeded(seed = 0x12345678): () => number {
|
||||
let value = seed >>> 0;
|
||||
return () => {
|
||||
value = (Math.imul(value, 1664525) + 1013904223) >>> 0;
|
||||
return value / 0x1_0000_0000;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* The whole catalogue, checked for the two failures that do not raise anything.
|
||||
*
|
||||
* `src/test/officeHabitat.test.ts` already covers the seven habitat ids by
|
||||
* name. This file covers **every registered asset**, including the ones that
|
||||
* have not been written yet, and it exists because the two ways an asset breaks
|
||||
* in this library are both silent:
|
||||
*
|
||||
* 1. **A material gets dropped.** `mergeGeometries` refuses a mixture of indexed
|
||||
* and non-indexed geometry, `MeshBin` treats the refusal as "skip this
|
||||
* material", and the result is not an exception — it is a bench with no top,
|
||||
* or a speaker with no cabinet. The symptom is a *missing* mesh, which is
|
||||
* only visible against an expectation. So this asserts that no asset comes
|
||||
* back with fewer distinct materials than it asked the registry for.
|
||||
* 2. **A footprint stops matching its mesh.** `Plan` lays a room out from
|
||||
* `footprintOf` without ever building the asset, so a footprint that
|
||||
* under-reports is a prop halfway through a wall and a footprint that
|
||||
* over-reports is a room that will not pack. Nothing checks the two against
|
||||
* each other except this.
|
||||
*
|
||||
* The chamfer pass is what made the first of these urgent: turning a desktop
|
||||
* from `box()` into `roundedBoxOf()` changes the primitive class of the whole
|
||||
* `deskSurface` material, and getting that wrong on any of the five slabs it was
|
||||
* applied to would have shipped a desk with no top and thrown nothing.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createAssetContext, kit } from "../../assets/kit.ts";
|
||||
import { MaterialRegistry } from "../../assets/materials.ts";
|
||||
import { OFFICE_ASSETS } from "../../assets/office/index.ts";
|
||||
import { seeded, withStubCanvas } from "./fakeCanvas.ts";
|
||||
|
||||
/**
|
||||
* The registry's own count. Asserted rather than derived so that adding an asset
|
||||
* is a deliberate two-line change and removing one cannot happen by accident —
|
||||
* `index.ts`'s header quotes this number, and a header that quietly disagrees
|
||||
* with the code is worse than no header.
|
||||
*/
|
||||
const CATALOGUE_SIZE = 40;
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function disposeObject(root: THREE.Object3D): void {
|
||||
for (const mesh of meshes(root)) mesh.geometry.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* A registry that records which roles an asset actually asked for, so a dropped
|
||||
* material can be told apart from a material the builder never wanted.
|
||||
*/
|
||||
class CountingRegistry extends MaterialRegistry {
|
||||
readonly requested = new Set<string>();
|
||||
|
||||
override get(role: Parameters<MaterialRegistry["get"]>[0]): ReturnType<MaterialRegistry["get"]> {
|
||||
const material = super.get(role);
|
||||
this.requested.add(material.name);
|
||||
return material;
|
||||
}
|
||||
|
||||
override tinted(
|
||||
role: Parameters<MaterialRegistry["tinted"]>[0],
|
||||
color: number,
|
||||
): ReturnType<MaterialRegistry["tinted"]> {
|
||||
const material = super.tinted(role, color);
|
||||
this.requested.add(material.name);
|
||||
return material;
|
||||
}
|
||||
|
||||
override variant(
|
||||
role: Parameters<MaterialRegistry["variant"]>[0],
|
||||
index: number,
|
||||
color?: number,
|
||||
): ReturnType<MaterialRegistry["variant"]> {
|
||||
const material = super.variant(role, index, color);
|
||||
this.requested.add(material.name);
|
||||
return material;
|
||||
}
|
||||
}
|
||||
|
||||
describe("the office catalogue as a whole", () => {
|
||||
it("registers exactly the assets `index.ts` says it does", () => {
|
||||
assert.equal(OFFICE_ASSETS.length, CATALOGUE_SIZE);
|
||||
const ids = OFFICE_ASSETS.map((def) => def.id);
|
||||
assert.equal(new Set(ids).size, ids.length, "two assets share an id");
|
||||
for (const id of ids) {
|
||||
assert.equal(kit.has(id), true, `${id} is exported but not registered`);
|
||||
assert.match(id, /^tera:/, `${id} is not in the tera namespace`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every material it asked for — nothing is silently dropped in the merge", () => {
|
||||
const restore = withStubCanvas();
|
||||
for (const def of OFFICE_ASSETS) {
|
||||
const materials = new CountingRegistry({ quality: "high" });
|
||||
try {
|
||||
const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
|
||||
const drawn = new Set(meshes(object).map((m) => (m.material as THREE.Material).name));
|
||||
const missing = [...materials.requested].filter((name) => !drawn.has(name));
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`${def.id} asked for ${missing.join(", ")} and drew nothing in it — a merge was refused, which means an indexed part and an extrusion ended up in the same material`,
|
||||
);
|
||||
disposeObject(object);
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
}
|
||||
restore();
|
||||
});
|
||||
|
||||
it("advertises a footprint that encloses its own mesh", () => {
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
for (const def of OFFICE_ASSETS) {
|
||||
const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const footprint = kit.footprintOf(def.id);
|
||||
// 20 mm of slack, which is a finger's width. `Plan` spaces rooms with
|
||||
// these numbers, so a prop that overhangs its own footprint by more than
|
||||
// that is a prop that ends up inside a wall.
|
||||
assert.ok(
|
||||
size.x <= footprint.width + 0.02,
|
||||
`${def.id} is ${size.x.toFixed(3)} wide against a stated ${footprint.width}`,
|
||||
);
|
||||
assert.ok(
|
||||
size.z <= footprint.depth + 0.02,
|
||||
`${def.id} is ${size.z.toFixed(3)} deep against a stated ${footprint.depth}`,
|
||||
);
|
||||
assert.ok(
|
||||
box.max.y <= footprint.height + 0.02,
|
||||
`${def.id} reaches ${box.max.y.toFixed(3)} against a stated ${footprint.height}`,
|
||||
);
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the two ceiling fittings, and only those two, hanging below their origin", () => {
|
||||
// `light.pendant` and `light.troffer` are the library's only exceptions to
|
||||
// "origin on the floor" (`common.ts`): their datum is the mounting plane and
|
||||
// all their geometry is at `y ≤ 0`, so a pack writes `elevation: 2.9` and
|
||||
// gets a lamp at 2.9 m. Every other asset — the floor lamp and the softbox
|
||||
// included — stands on the floor, and an asset that quietly adopts the
|
||||
// ceiling convention would sink through it.
|
||||
const ceilingHung = new Set(["tera:light.pendant", "tera:light.troffer"]);
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
for (const def of OFFICE_ASSETS) {
|
||||
const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
if (ceilingHung.has(def.id)) {
|
||||
assert.ok(box.max.y <= 0.001, `${def.id} has geometry above its mounting plane`);
|
||||
} else {
|
||||
assert.ok(box.min.y >= -0.02, `${def.id} starts ${box.min.y} below the floor`);
|
||||
}
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds no light source anywhere in the catalogue", () => {
|
||||
// CONTRACT.md §4: Atmosphere is the sole light owner. Sixteen fittings each
|
||||
// carrying a `PointLight` is both the wrong owner and, past about four
|
||||
// shadow-casting lights, the end of the frame budget.
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
for (const def of OFFICE_ASSETS) {
|
||||
const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
|
||||
object.traverse((child) => {
|
||||
assert.equal(child instanceof THREE.Light, false, `${def.id} constructs a light`);
|
||||
});
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic for the same seed, across the whole catalogue", () => {
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
for (const def of OFFICE_ASSETS) {
|
||||
const first = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
|
||||
const second = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
|
||||
const encode = (root: THREE.Object3D): string => {
|
||||
const box = new THREE.Box3().setFromObject(root);
|
||||
const counts = meshes(root)
|
||||
.map((m) => `${m.name}:${m.geometry.getAttribute("position").count}`)
|
||||
.join(",");
|
||||
return `${counts}|${[...box.min.toArray(), ...box.max.toArray()]
|
||||
.map((v) => v.toFixed(6))
|
||||
.join(",")}`;
|
||||
};
|
||||
assert.equal(encode(first), encode(second), `${def.id} is not deterministic`);
|
||||
disposeObject(first);
|
||||
disposeObject(second);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* The Model X hero pass, asserted where it can be asserted: on the mesh.
|
||||
*
|
||||
* Most of what makes a car look like a car is not testable and this file does
|
||||
* not pretend otherwise. What *is* testable is the set of properties that broke
|
||||
* the first version, each of which failed silently and none of which is visible
|
||||
* from reading the builder:
|
||||
*
|
||||
* - the LOD split was nominal — a `follow` car that costs what a `corridor` car
|
||||
* costs is forty background cars nobody budgeted for;
|
||||
* - the shoulder crease was averaged away by one `computeVertexNormals()` over
|
||||
* the whole shell, so the geometry had a crease and the shading did not;
|
||||
* - the wheels intersected a flat flank, because there were no arches;
|
||||
* - the glass floated on the paint instead of filling an opening;
|
||||
* - the paint carried a fake ambient emissive that now double-counts against a
|
||||
* real environment map.
|
||||
*
|
||||
* The bounding-box assertions are the load-bearing ones for everything
|
||||
* downstream: `MODEL_X_METRICS` is what `src/transport` collides, frames and
|
||||
* lane-positions with, and a mesh that is wider than its own published width is
|
||||
* a car that clips kerbs it looked like it cleared.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import {
|
||||
MODEL_X_METRICS,
|
||||
MODEL_X_PAINTS,
|
||||
buildModelX,
|
||||
cloneModelX,
|
||||
createModelXMaterials,
|
||||
createModelXPaintPool,
|
||||
disposeModelX,
|
||||
disposeModelXPaintPool,
|
||||
type ModelXDetail,
|
||||
type ModelXRig,
|
||||
} from "../../assets/vehicles/index.ts";
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function triangles(root: THREE.Object3D): number {
|
||||
let total = 0;
|
||||
for (const mesh of meshes(root)) {
|
||||
const index = mesh.geometry.getIndex();
|
||||
total += index ? index.count / 3 : mesh.geometry.getAttribute("position").count / 3;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function meshNamed(rig: ModelXRig, material: string): THREE.Mesh {
|
||||
const found = meshes(rig.root).find(
|
||||
(mesh) => (mesh.material as THREE.Material).name === material,
|
||||
);
|
||||
assert.ok(found, `no mesh carries the "${material}" material`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function localBox(mesh: THREE.Mesh): THREE.Box3 {
|
||||
const box = new THREE.Box3();
|
||||
box.setFromBufferAttribute(mesh.geometry.getAttribute("position") as THREE.BufferAttribute);
|
||||
return box;
|
||||
}
|
||||
|
||||
describe("Model X hero geometry", () => {
|
||||
it("spends a hero budget at `follow` and a traffic budget at `corridor`", () => {
|
||||
const follow = buildModelX({ detail: "follow" });
|
||||
const corridor = buildModelX({ detail: "corridor" });
|
||||
|
||||
const followTriangles = triangles(follow.root);
|
||||
const corridorTriangles = triangles(corridor.root);
|
||||
|
||||
// The acceptance window. Below 4,000 it is not a hero asset; above 24,000 it
|
||||
// stops being one car's worth of the office's 550,000-triangle budget.
|
||||
assert.ok(
|
||||
followTriangles >= 4_000 && followTriangles <= 24_000,
|
||||
`follow LOD is ${followTriangles} triangles`,
|
||||
);
|
||||
// `corridor` is instanced up to forty times by `engine/roadTraffic.ts`, so
|
||||
// its count is multiplied by forty against the *city's* budget. Half of
|
||||
// follow is the ceiling that keeps that honest.
|
||||
assert.ok(
|
||||
corridorTriangles < followTriangles / 2,
|
||||
`corridor LOD is ${corridorTriangles} against follow's ${followTriangles} — the split is nominal`,
|
||||
);
|
||||
|
||||
// And the split has to be in the geometry rather than only in the count:
|
||||
// corridor may not cost more draw calls than it did as a torus-wheeled box.
|
||||
assert.ok(meshes(corridor.root).length <= 20, "corridor LOD grew its draw calls");
|
||||
|
||||
disposeModelX(follow);
|
||||
disposeModelX(corridor);
|
||||
});
|
||||
|
||||
it("stays inside the silhouette `MODEL_X_METRICS` publishes, at both LODs", () => {
|
||||
for (const detail of ["corridor", "follow"] as ModelXDetail[]) {
|
||||
const rig = buildModelX({ detail });
|
||||
const box = new THREE.Box3().setFromObject(rig.root);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
|
||||
assert.ok(size.x <= 2.1, `${detail} is ${size.x} wide`);
|
||||
assert.ok(size.y <= 1.8, `${detail} is ${size.y} tall`);
|
||||
assert.ok(size.z <= 5.1, `${detail} is ${size.z} long`);
|
||||
|
||||
// And it has to actually be the car those numbers describe, not merely
|
||||
// smaller than them.
|
||||
assert.ok(Math.abs(size.x - MODEL_X_METRICS.width) < 0.05, `${detail} width ${size.x}`);
|
||||
assert.ok(Math.abs(size.y - MODEL_X_METRICS.height) < 0.05, `${detail} height ${size.y}`);
|
||||
assert.ok(Math.abs(size.z - MODEL_X_METRICS.length) < 0.06, `${detail} length ${size.z}`);
|
||||
|
||||
// The tyres define y = 0. A body that dips below it is a car sunk into the
|
||||
// road; a car that floats is worse, because the shadow gives it away.
|
||||
assert.ok(Math.abs(box.min.y) < 0.005, `${detail} does not sit on the road: ${box.min.y}`);
|
||||
|
||||
disposeModelX(rig);
|
||||
}
|
||||
});
|
||||
|
||||
it("names the mirrors and the window frames so they can be found", () => {
|
||||
const rig = buildModelX({ detail: "follow" });
|
||||
const mirrors = rig.root.getObjectByName("model-x.mirrors");
|
||||
const frames = rig.root.getObjectByName("model-x.glass-frames");
|
||||
assert.ok(mirrors, "no mirrors on the hero LOD");
|
||||
assert.ok(frames, "no window surround on the hero LOD");
|
||||
assert.ok(meshes(mirrors).length > 0);
|
||||
assert.ok(meshes(frames).length > 0);
|
||||
|
||||
// A mirror is a first-surface reflector and the side glass is tinted and
|
||||
// translucent. One material cannot be both, and with an environment map
|
||||
// present that difference is most of what makes a mirror read as one.
|
||||
const mirrorMaterials = meshes(mirrors).map((m) => (m.material as THREE.Material).name);
|
||||
assert.ok(mirrorMaterials.includes("model-x.mirror"));
|
||||
|
||||
// The corridor car does without both: nobody resolves a wing mirror at the
|
||||
// distance forty instanced cars are drawn at.
|
||||
const corridor = buildModelX({ detail: "corridor" });
|
||||
assert.equal(corridor.root.getObjectByName("model-x.mirrors"), undefined);
|
||||
assert.equal(corridor.root.getObjectByName("model-x.glass-frames"), undefined);
|
||||
|
||||
disposeModelX(rig);
|
||||
disposeModelX(corridor);
|
||||
});
|
||||
|
||||
it("keeps the shoulder crease by splitting normals rather than averaging them", () => {
|
||||
// The failure this replaces: one `computeVertexNormals()` over the whole
|
||||
// shell. The test for "there is a crease" is that two vertices share a
|
||||
// position and disagree about which way the surface points — which is
|
||||
// exactly what a split vertex is, and what a single averaged pass destroys.
|
||||
const rig = buildModelX({ detail: "follow" });
|
||||
const paint = meshNamed(rig, "model-x.paint");
|
||||
const position = paint.geometry.getAttribute("position");
|
||||
const normal = paint.geometry.getAttribute("normal");
|
||||
assert.ok(normal, "the bodyshell has no normals");
|
||||
|
||||
const byPosition = new Map<string, THREE.Vector3[]>();
|
||||
for (let i = 0; i < position.count; i++) {
|
||||
// Only the right flank, at the height the shoulder line runs, and only
|
||||
// between the axles: that is where the crease is, and looking everywhere
|
||||
// would pass on any hard edge anywhere on the car.
|
||||
const x = position.getX(i);
|
||||
const y = position.getY(i);
|
||||
const z = position.getZ(i);
|
||||
if (x < 0.9 || y < 0.85 || y > 0.99 || z < -1.2 || z > 1.2) continue;
|
||||
const key = `${Math.round(x * 1e3)},${Math.round(y * 1e3)},${Math.round(z * 1e3)}`;
|
||||
const list = byPosition.get(key) ?? [];
|
||||
list.push(new THREE.Vector3(normal.getX(i), normal.getY(i), normal.getZ(i)));
|
||||
byPosition.set(key, list);
|
||||
}
|
||||
|
||||
let creased = 0;
|
||||
for (const normals of byPosition.values()) {
|
||||
for (let a = 0; a < normals.length; a++) {
|
||||
for (let b = a + 1; b < normals.length; b++) {
|
||||
// 20° is well past any smoothing artefact and well under the ~60° the
|
||||
// shoulder actually turns through.
|
||||
if (normals[a]!.dot(normals[b]!) < Math.cos(0.35)) creased++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
creased >= 4,
|
||||
`found ${creased} split normals along the shoulder line — the crease has been averaged away again`,
|
||||
);
|
||||
disposeModelX(rig);
|
||||
});
|
||||
|
||||
it("cuts real wheel openings, with the tyre inside them", () => {
|
||||
const rig = buildModelX({ detail: "follow" });
|
||||
rig.root.updateMatrixWorld(true);
|
||||
const paint = meshNamed(rig, "model-x.paint");
|
||||
const position = paint.geometry.getAttribute("position");
|
||||
|
||||
// At the front axle plane, the paint has to *stop* well above the road on
|
||||
// the outboard side — that gap is the wheel opening. On the first version
|
||||
// the flank ran straight down past the tyre and this was 0.24 m.
|
||||
const axle = -MODEL_X_METRICS.wheelbase / 2;
|
||||
let lowestOutboard = Infinity;
|
||||
for (let i = 0; i < position.count; i++) {
|
||||
if (Math.abs(position.getZ(i) - axle) > 0.03) continue;
|
||||
if (position.getX(i) < 0.95) continue;
|
||||
lowestOutboard = Math.min(lowestOutboard, position.getY(i));
|
||||
}
|
||||
assert.ok(Number.isFinite(lowestOutboard), "no bodyside at the front axle at all");
|
||||
assert.ok(
|
||||
lowestOutboard > MODEL_X_METRICS.wheelRadius * 2 - 0.05,
|
||||
`the flank reaches down to ${lowestOutboard} at the front axle, which is through the tyre`,
|
||||
);
|
||||
|
||||
// And the tyre has to fit under it rather than through it.
|
||||
const tire = rig.root.getObjectByName("frontRight.tire");
|
||||
assert.ok(tire instanceof THREE.Mesh);
|
||||
const tireBox = new THREE.Box3().setFromObject(tire);
|
||||
assert.ok(tireBox.max.y < lowestOutboard, "the tyre pokes through the arch lip");
|
||||
assert.ok(tireBox.max.y > 0.75, "the tyre is not the published diameter");
|
||||
|
||||
disposeModelX(rig);
|
||||
});
|
||||
|
||||
it("gives the tyre a sidewall, a shoulder and a flat tread", () => {
|
||||
const rig = buildModelX({ detail: "follow" });
|
||||
const tire = rig.root.getObjectByName("frontRight.tire");
|
||||
assert.ok(tire instanceof THREE.Mesh);
|
||||
const position = tire.geometry.getAttribute("position");
|
||||
|
||||
// The wheel axis is X. A torus has one radius; a real tyre has a tread band
|
||||
// at the full radius and a sidewall that bulges *wider* than the tread while
|
||||
// sitting at a smaller radius, and that is the whole silhouette of a loaded
|
||||
// tyre.
|
||||
let treadHalfWidth = 0;
|
||||
let widest = 0;
|
||||
let sidewallRadius = 0;
|
||||
for (let i = 0; i < position.count; i++) {
|
||||
const x = Math.abs(position.getX(i));
|
||||
const r = Math.hypot(position.getY(i), position.getZ(i));
|
||||
widest = Math.max(widest, x);
|
||||
if (r > MODEL_X_METRICS.wheelRadius - 0.002) treadHalfWidth = Math.max(treadHalfWidth, x);
|
||||
if (x > widest - 1e-6) sidewallRadius = r;
|
||||
}
|
||||
assert.ok(treadHalfWidth > 0.08, `the tread band is only ${treadHalfWidth * 2} m wide`);
|
||||
assert.ok(
|
||||
widest > treadHalfWidth + 0.01,
|
||||
"the sidewall does not stand outboard of the tread — this is still a torus",
|
||||
);
|
||||
assert.ok(
|
||||
sidewallRadius < MODEL_X_METRICS.wheelRadius - 0.03,
|
||||
"the widest point of the tyre is at full radius, so there is no sidewall",
|
||||
);
|
||||
disposeModelX(rig);
|
||||
});
|
||||
|
||||
it("puts the caliper on the upright, not on the spinning hub", () => {
|
||||
const rig = buildModelX({ detail: "follow" });
|
||||
const caliper = rig.root.getObjectByName("frontRight.caliper");
|
||||
assert.ok(caliper, "the brake has no caliper");
|
||||
// A caliper that rotates with the wheel is the single most common tell that
|
||||
// a wheel was assembled quickly. Its parent must be the steering group.
|
||||
assert.equal(caliper.parent?.name, "frontRight.steering");
|
||||
assert.equal(rig.wheels.frontRight.spin.getObjectByName("frontRight.caliper"), undefined);
|
||||
disposeModelX(rig);
|
||||
});
|
||||
|
||||
it("fills the daylight opening with glass rather than laying glass on the paint", () => {
|
||||
const rig = buildModelX({ detail: "follow" });
|
||||
const glass = meshNamed(rig, "model-x.glass");
|
||||
const paint = meshNamed(rig, "model-x.paint");
|
||||
const glassBox = localBox(glass);
|
||||
const paintPosition = paint.geometry.getAttribute("position");
|
||||
|
||||
// The greenhouse sits above the beltline and inside the body's own width.
|
||||
assert.ok(glassBox.min.y > 0.95, `glass starts at ${glassBox.min.y}, below the beltline`);
|
||||
assert.ok(glassBox.max.y > 1.6, "there is no glass at roof height — the roof is not glazed");
|
||||
assert.ok(glassBox.max.x < MODEL_X_METRICS.width / 2, "the glass stands outside the body");
|
||||
|
||||
// And the paint has to have got out of the way: in the middle of the front
|
||||
// door, at glass height, there should be no painted surface outboard of the
|
||||
// recessed opening.
|
||||
let paintedInTheWindow = 0;
|
||||
for (let i = 0; i < paintPosition.count; i++) {
|
||||
const z = paintPosition.getZ(i);
|
||||
const y = paintPosition.getY(i);
|
||||
const x = paintPosition.getX(i);
|
||||
if (z < -0.7 || z > -0.1) continue;
|
||||
if (y < 1.12 || y > 1.42) continue;
|
||||
if (x > 0.9) paintedInTheWindow++;
|
||||
}
|
||||
assert.equal(
|
||||
paintedInTheWindow,
|
||||
0,
|
||||
"there is still paint where the front door glass should be — the window is not an opening",
|
||||
);
|
||||
disposeModelX(rig);
|
||||
});
|
||||
|
||||
it("has no emissive term left on the paint or the wheel", () => {
|
||||
// The environment rig supplies real reflections now, so the fake ambient
|
||||
// these two carried double-counts: a `0x11191e` emissive on a metallic
|
||||
// clearcoat under ACES reads as a car lit from inside.
|
||||
const materials = createModelXMaterials(0x223344);
|
||||
for (const key of ["paint", "wheel"] as const) {
|
||||
const material = materials[key] as THREE.MeshStandardMaterial;
|
||||
// The colour is what has to be black, not the intensity: three defaults
|
||||
// `emissiveIntensity` to 1 whether you asked for emission or not, so a
|
||||
// material with a black `emissive` and an intensity of 1 emits nothing and
|
||||
// is the correct state to assert.
|
||||
assert.equal(material.emissive.getHex(), 0, `${key} still has an emissive colour`);
|
||||
}
|
||||
// The lamps keep theirs, and must.
|
||||
for (const key of ["headlight", "tailLight"] as const) {
|
||||
const material = materials[key] as THREE.MeshStandardMaterial;
|
||||
assert.notEqual(material.emissive.getHex(), 0, `${key} stopped being a lamp`);
|
||||
assert.ok(material.emissiveIntensity > 1, `${key} stopped being a lamp`);
|
||||
}
|
||||
for (const material of Object.values(materials)) material.dispose();
|
||||
});
|
||||
|
||||
it("shares everything but the paint across a colour pool", () => {
|
||||
const pool = createModelXPaintPool();
|
||||
assert.equal(pool.length, MODEL_X_PAINTS.length);
|
||||
const first = pool[0];
|
||||
const second = pool[1];
|
||||
assert.ok(first && second);
|
||||
assert.notEqual(first.paint, second.paint, "two skins share one paint material");
|
||||
// Everything else is the same object, which is the entire point: a street of
|
||||
// seven colours costs six extra materials, not forty-two.
|
||||
for (const key of ["glass", "trim", "tire", "wheel", "brake", "mirror", "plate"] as const) {
|
||||
assert.equal(first[key], second[key], `${key} was duplicated across the pool`);
|
||||
}
|
||||
// And a rig handed a pooled skin must not claim to own it.
|
||||
const rig = buildModelX({ detail: "corridor", materials: second });
|
||||
assert.equal(rig.ownsMaterials, false);
|
||||
disposeModelX(rig);
|
||||
disposeModelXPaintPool(pool);
|
||||
});
|
||||
|
||||
it("clones without duplicating a single buffer", () => {
|
||||
const original = buildModelX({ detail: "follow" });
|
||||
const clone = cloneModelX(original);
|
||||
const source = meshes(original.root);
|
||||
const copied = meshes(clone.root);
|
||||
assert.equal(copied.length, source.length);
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
assert.equal(copied[i]!.geometry, source[i]!.geometry, `${source[i]!.name} was copied`);
|
||||
assert.equal(copied[i]!.material, source[i]!.material);
|
||||
}
|
||||
disposeModelX(original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Screens and foliage: the two places where a *texture binding* is the whole
|
||||
* fix, and where a silently-null map looks exactly like a design decision.
|
||||
*
|
||||
* Both defects on the live site had the same shape. Every display in the
|
||||
* building was one uniform glowing rectangle, and every leaf was a flat green
|
||||
* shard, and in both cases the geometry was fine and the material was carrying
|
||||
* nothing. The maps now exist (`screenUI`, `leafAlpha`); what these tests pin is
|
||||
* that they are bound to the right *slots*, because the difference between
|
||||
* `map` and `emissiveMap` is the difference between a monitor and a light box,
|
||||
* and the difference between `alphaMap` with `alphaTest` and no `alphaMap` at
|
||||
* all is the difference between a leaf and the shard.
|
||||
*
|
||||
* A stub canvas is installed for these: `TextureBin` returns `null` under Node
|
||||
* because there is nothing to draw on, which is correct behaviour and would make
|
||||
* every assertion in here vacuously true.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createAssetContext, kit } from "../../assets/kit.ts";
|
||||
import { MaterialRegistry } from "../../assets/materials.ts";
|
||||
import { SCREEN_UI_VARIANTS } from "../../assets/textures.ts";
|
||||
import "../../assets/office/index.ts";
|
||||
import { seeded, withStubCanvas } from "./fakeCanvas.ts";
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function materialsOf(root: THREE.Object3D): Map<string, THREE.MeshStandardMaterial> {
|
||||
const found = new Map<string, THREE.MeshStandardMaterial>();
|
||||
for (const mesh of meshes(root)) {
|
||||
const material = mesh.material as THREE.MeshStandardMaterial;
|
||||
found.set(material.name, material);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function disposeObject(root: THREE.Object3D): void {
|
||||
for (const mesh of meshes(root)) mesh.geometry.dispose();
|
||||
}
|
||||
|
||||
describe("screen content and leaf cutouts", () => {
|
||||
it("lights the display through its own drawing rather than flooding the panel", () => {
|
||||
const restore = withStubCanvas();
|
||||
const materials = new MaterialRegistry({ quality: "high" });
|
||||
try {
|
||||
for (const id of ["tera:screen.monitor", "tera:screen.wall-display"]) {
|
||||
const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
|
||||
const byName = materialsOf(object);
|
||||
|
||||
const content = byName.get("screenContent");
|
||||
assert.ok(content, `${id} draws no lit content layer`);
|
||||
assert.ok(content.map, `${id}: the display carries no map`);
|
||||
assert.ok(content.emissiveMap, `${id}: the display carries no emissiveMap`);
|
||||
// Same texture in both slots. Two different ones would mean the lit
|
||||
// pixels and the drawn pixels disagree, which reads as a ghost image.
|
||||
assert.equal(content.map, content.emissiveMap);
|
||||
// White emissive, because `emissive` multiplies `emissiveMap`: anything
|
||||
// else tints the drawn interface a second time on top of `color`.
|
||||
assert.equal(content.emissive.getHex(), 0xffffff);
|
||||
assert.ok(content.emissiveIntensity > 0);
|
||||
|
||||
// And the dark panel behind it has to still be there and still be dark:
|
||||
// it is the black border of glass, and it is what stops the content
|
||||
// running to the edge of the bezel.
|
||||
const dark = byName.get("screenDisplay");
|
||||
assert.ok(dark, `${id} lost its dark panel`);
|
||||
assert.ok(byName.get("screenBezel"), `${id} lost its bezel`);
|
||||
assert.notEqual(dark, content);
|
||||
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("takes its layout from the authored colorKey, not from per-instance chance", () => {
|
||||
// `furnish.ts` draws `ctx.rand` once per *kind*, so a screen cannot roll for
|
||||
// a layout: twelve monitors in one batch would roll once between them. The
|
||||
// seam is `colorKey`, which is already part of the batch key — so two packs
|
||||
// asking for the same key must get the same layout, and different keys must
|
||||
// be able to get different ones.
|
||||
const restore = withStubCanvas();
|
||||
const materials = new MaterialRegistry({ quality: "high" });
|
||||
try {
|
||||
const layoutFor = (colorKey: string | undefined): THREE.Texture | null => {
|
||||
const object = kit.build(
|
||||
"tera:screen.monitor",
|
||||
createAssetContext({ materials, rand: seeded(), colorKey }),
|
||||
);
|
||||
// `variant()` names a non-zero layout `screenContent:<hex>:<n>`, so the
|
||||
// lookup is by prefix: the point of the seam is that a keyed screen gets
|
||||
// a *different material*, and asserting on the base name would only ever
|
||||
// find the unkeyed one.
|
||||
const content = [...materialsOf(object)].find(([name]) =>
|
||||
name.startsWith("screenContent"),
|
||||
)?.[1];
|
||||
assert.ok(content, "no lit content layer on a keyed screen");
|
||||
const map = content.map;
|
||||
disposeObject(object);
|
||||
return map;
|
||||
};
|
||||
|
||||
const plain = layoutFor(undefined);
|
||||
assert.equal(layoutFor(undefined), plain, "an unkeyed screen is not stable");
|
||||
|
||||
const keyed = new Set<THREE.Texture | null>();
|
||||
for (const key of ["ui-a", "ui-b", "ui-c", "ui-d", "ui-e", "ui-f", "ui-g", "ui-h"]) {
|
||||
const first = layoutFor(key);
|
||||
assert.equal(layoutFor(key), first, `"${key}" is not stable between builds`);
|
||||
keyed.add(first);
|
||||
}
|
||||
assert.ok(
|
||||
keyed.size >= 3,
|
||||
`eight distinct keys produced ${keyed.size} layouts out of ${SCREEN_UI_VARIANTS} — the seam is not reaching the texture`,
|
||||
);
|
||||
} finally {
|
||||
materials.dispose();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a display readable at `low` quality, where there are no maps at all", () => {
|
||||
// `low` is the setting that makes an office open on an integrated GPU and it
|
||||
// draws no textures. The asset must still build, and the display must still
|
||||
// be a lit surface rather than an untextured white slab that clips.
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
const object = kit.build(
|
||||
"tera:screen.monitor",
|
||||
createAssetContext({ materials, rand: seeded() }),
|
||||
);
|
||||
const content = materialsOf(object).get("screenContent");
|
||||
assert.ok(content, "no content layer at low quality");
|
||||
assert.equal(content.map, null);
|
||||
assert.ok(content.emissiveIntensity > 0, "the panel stopped emitting at low quality");
|
||||
disposeObject(object);
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("cuts every leaf out of its quad instead of drawing the quad", () => {
|
||||
const restore = withStubCanvas();
|
||||
const materials = new MaterialRegistry({ quality: "high" });
|
||||
try {
|
||||
for (const id of ["tera:plant.potted", "tera:plant.tall"]) {
|
||||
const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
|
||||
const leaf = materialsOf(object).get("foliage");
|
||||
assert.ok(leaf, `${id} has no foliage material`);
|
||||
assert.ok(leaf.alphaMap, `${id}: the leaves are still bare rectangles`);
|
||||
assert.equal(leaf.alphaTest, 0.5, `${id}: the cutout threshold is not the drawn one`);
|
||||
// Cutout, not blend. A transparent leaf loses its depth write, its sort
|
||||
// order and — the one that shows — its leaf-shaped shadow.
|
||||
assert.equal(leaf.transparent, false, `${id}: foliage went transparent`);
|
||||
assert.equal(leaf.depthWrite, true);
|
||||
assert.equal(leaf.side, THREE.DoubleSide);
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("survives `low` quality with the cutout intact, because a plant has no cheap fallback", () => {
|
||||
// "No maps at low quality" is a statement about *shading* cost. An alpha
|
||||
// cutout is one fetch and a discard, and the alternative at low quality is
|
||||
// not a cheaper plant — it is the shard the whole change exists to remove.
|
||||
const restore = withStubCanvas();
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
const object = kit.build(
|
||||
"tera:plant.tall",
|
||||
createAssetContext({ materials, rand: seeded() }),
|
||||
);
|
||||
const leaf = materialsOf(object).get("foliage");
|
||||
assert.ok(leaf?.alphaMap, "the leaf cutout was dropped at low quality");
|
||||
disposeObject(object);
|
||||
} finally {
|
||||
materials.dispose();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("runs +V along the leaf, so the cutout lands the right way up", () => {
|
||||
// `leafAlpha` is drawn tip-at-top with the stem at the bottom. If a leaf quad
|
||||
// ever loses its 0..1 V range — or gets it reversed — the cutout arrives
|
||||
// upside down and every plant grows stems out of its tips, which is a
|
||||
// failure nobody would think to look for in a UV.
|
||||
const restore = withStubCanvas();
|
||||
const materials = new MaterialRegistry({ quality: "high" });
|
||||
try {
|
||||
const object = kit.build(
|
||||
"tera:plant.potted",
|
||||
createAssetContext({ materials, rand: seeded() }),
|
||||
);
|
||||
const leaf = meshes(object).find(
|
||||
(mesh) => (mesh.material as THREE.Material).name === "foliage",
|
||||
);
|
||||
assert.ok(leaf);
|
||||
const uv = leaf.geometry.getAttribute("uv");
|
||||
assert.ok(uv, "the leaf cards carry no UVs to sample the cutout with");
|
||||
let minV = Infinity;
|
||||
let maxV = -Infinity;
|
||||
for (let i = 0; i < uv.count; i++) {
|
||||
minV = Math.min(minV, uv.getY(i));
|
||||
maxV = Math.max(maxV, uv.getY(i));
|
||||
}
|
||||
assert.ok(Math.abs(minV) < 1e-6, `leaf V starts at ${minV}`);
|
||||
assert.ok(Math.abs(maxV - 1) < 1e-6, `leaf V ends at ${maxV}`);
|
||||
disposeObject(object);
|
||||
} finally {
|
||||
materials.dispose();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* The studio kit: twelve new kinds, and the properties that make them useful to
|
||||
* a pack rather than merely present in a registry.
|
||||
*
|
||||
* The interesting assertion in here is the *count*. `furnish.ts` batches props
|
||||
* per kind and every instance of a kind is geometrically identical, so a pack
|
||||
* cannot buy apparent density by placing more props — only by using more kinds.
|
||||
* "At least eight new kinds" is therefore a real acceptance number and not a
|
||||
* round one, and pinning it stops a later tidy-up quietly merging two assets
|
||||
* into one parameterised asset and taking a visible amount of the LA studio's
|
||||
* variety with it.
|
||||
*
|
||||
* Everything else here is the same three failure modes every asset file has:
|
||||
* a builder that throws at one quality level and not the other, a footprint that
|
||||
* does not enclose its own mesh, and a material that `mergeGeometries` refused
|
||||
* because somebody mixed an extrusion in with the boxes.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createAssetContext, kit, type AssetContext } from "../../assets/kit.ts";
|
||||
import { MaterialRegistry, type MaterialQuality } from "../../assets/materials.ts";
|
||||
import { OFFICE_ASSETS, STUDIO_ASSET_IDS } from "../../assets/office/index.ts";
|
||||
import { seeded, withStubCanvas } from "./fakeCanvas.ts";
|
||||
|
||||
/** The ids that existed before the studio kit landed, from the same catalogue. */
|
||||
const PRE_STUDIO_IDS = new Set(
|
||||
OFFICE_ASSETS.map((def) => def.id).filter((id) => !STUDIO_ASSET_IDS.includes(id)),
|
||||
);
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function disposeObject(root: THREE.Object3D): void {
|
||||
for (const mesh of meshes(root)) mesh.geometry.dispose();
|
||||
}
|
||||
|
||||
function context(quality: MaterialQuality): { ctx: AssetContext; done: () => void } {
|
||||
const restore = withStubCanvas();
|
||||
const materials = new MaterialRegistry({ quality });
|
||||
return {
|
||||
ctx: createAssetContext({ materials, rand: seeded() }),
|
||||
done: () => {
|
||||
materials.dispose();
|
||||
restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("studio asset kit", () => {
|
||||
it("adds at least eight kinds that did not exist before, because only kinds add density", () => {
|
||||
assert.ok(
|
||||
STUDIO_ASSET_IDS.length >= 8,
|
||||
`only ${STUDIO_ASSET_IDS.length} studio kinds; furnish.ts batches per kind, so fewer than eight is not a visible change`,
|
||||
);
|
||||
for (const id of STUDIO_ASSET_IDS) {
|
||||
assert.equal(kit.has(id), true, `${id} is not registered`);
|
||||
assert.equal(PRE_STUDIO_IDS.has(id), false, `${id} collides with an existing asset id`);
|
||||
assert.match(id, /^tera:[a-z-]+\.[a-z-]+$/, `${id} is not a namespaced asset id`);
|
||||
}
|
||||
assert.equal(new Set(STUDIO_ASSET_IDS).size, STUDIO_ASSET_IDS.length, "duplicate studio id");
|
||||
});
|
||||
|
||||
for (const quality of ["low", "high"] as const) {
|
||||
it(`builds every studio kind without throwing at \`${quality}\` quality`, () => {
|
||||
const { ctx, done } = context(quality);
|
||||
try {
|
||||
for (const id of STUDIO_ASSET_IDS) {
|
||||
const object = kit.build(id, ctx);
|
||||
assert.equal(object.userData.assetId, id);
|
||||
assert.notEqual(
|
||||
object.userData.missing,
|
||||
true,
|
||||
`${id} fell through to the placeholder box`,
|
||||
);
|
||||
const built = meshes(object);
|
||||
assert.ok(built.length > 0, `${id} built no geometry`);
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
assert.ok(
|
||||
[...box.min.toArray(), ...box.max.toArray()].every(Number.isFinite),
|
||||
`${id} has non-finite bounds`,
|
||||
);
|
||||
assert.ok(box.max.y > 0.02, `${id} has no visible height`);
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("produces identical geometry at every quality level", () => {
|
||||
// Quality changes *materials*, never meshes. If a builder ever branches on
|
||||
// `ctx.quality` the office stops being the same office on a weak GPU, and
|
||||
// the collision segments a pack derives from a footprint stop matching what
|
||||
// is drawn.
|
||||
const counts: Record<string, number[]> = {};
|
||||
for (const quality of ["low", "high"] as const) {
|
||||
const { ctx, done } = context(quality);
|
||||
try {
|
||||
for (const id of STUDIO_ASSET_IDS) {
|
||||
const object = kit.build(id, ctx);
|
||||
let triangles = 0;
|
||||
for (const mesh of meshes(object)) {
|
||||
const index = mesh.geometry.getIndex();
|
||||
triangles += index
|
||||
? index.count / 3
|
||||
: mesh.geometry.getAttribute("position").count / 3;
|
||||
}
|
||||
(counts[id] ??= []).push(triangles);
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
}
|
||||
for (const [id, [low, high]] of Object.entries(counts)) {
|
||||
assert.equal(low, high, `${id} builds different geometry at low and high quality`);
|
||||
}
|
||||
});
|
||||
|
||||
it("encloses its own geometry in the footprint layout is given", () => {
|
||||
const { ctx, done } = context("low");
|
||||
try {
|
||||
for (const id of STUDIO_ASSET_IDS) {
|
||||
const object = kit.build(id, ctx);
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const footprint = kit.footprintOf(id);
|
||||
// 20 mm of slack. A footprint is a layout number rather than a
|
||||
// measurement, but `Plan` spaces rooms with it and a prop that overhangs
|
||||
// its own footprint by more than a finger's width ends up in a wall.
|
||||
assert.ok(size.x <= footprint.width + 0.02, `${id} is ${size.x} wide vs ${footprint.width}`);
|
||||
assert.ok(size.z <= footprint.depth + 0.02, `${id} is ${size.z} deep vs ${footprint.depth}`);
|
||||
assert.ok(
|
||||
box.max.y <= footprint.height + 0.02,
|
||||
`${id} reaches ${box.max.y} vs ${footprint.height}`,
|
||||
);
|
||||
assert.ok(box.min.y >= -0.02, `${id} starts below the floor at ${box.min.y}`);
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the softbox recognisable to furnish.ts as a light fitting", () => {
|
||||
// `furnish.ts` decides what a luminaire is from the `:light.` in its id and
|
||||
// separates the glowing part from the housing by `emissiveIntensity > 0`.
|
||||
// Both halves have to be true or a softbox is a lump of metal that never
|
||||
// comes on, and neither is visible from inside this file.
|
||||
assert.ok(STUDIO_ASSET_IDS.includes("tera:light.softbox"));
|
||||
assert.equal(kit.resolveId("tera:light.softbox").includes(":light."), true);
|
||||
|
||||
const { ctx, done } = context("high");
|
||||
try {
|
||||
const object = kit.build("tera:light.softbox", ctx);
|
||||
const emissive = meshes(object).filter((mesh) => {
|
||||
const material = mesh.material as THREE.MeshStandardMaterial;
|
||||
return (material.emissiveIntensity ?? 0) > 0;
|
||||
});
|
||||
assert.equal(emissive.length, 1, "a softbox has exactly one diffuser");
|
||||
assert.equal(emissive[0]?.castShadow, false, "the thing the light comes out of must not shadow the room");
|
||||
disposeObject(object);
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not add a light source of its own — Atmosphere owns every light", () => {
|
||||
// CONTRACT.md §4. A hundred fittings each carrying a PointLight is both the
|
||||
// wrong owner and the end of the frame budget, and the softbox is exactly
|
||||
// the asset somebody would be tempted to give a real light to.
|
||||
const { ctx, done } = context("high");
|
||||
try {
|
||||
for (const id of STUDIO_ASSET_IDS) {
|
||||
const object = kit.build(id, ctx);
|
||||
object.traverse((child) => {
|
||||
assert.equal(
|
||||
child instanceof THREE.Light,
|
||||
false,
|
||||
`${id} constructs a light, which only Atmosphere may do`,
|
||||
);
|
||||
});
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
it("draws its foliage through the same cutout the greenery assets use", () => {
|
||||
// The trough is the one studio asset with leaves on it, and it exists partly
|
||||
// to keep the courtyard from being paving. If it ever stops sharing
|
||||
// `leafBlade`/`foliage` it goes straight back to the green-shard look that
|
||||
// this whole pass was written to remove.
|
||||
const { ctx, done } = context("high");
|
||||
try {
|
||||
const object = kit.build("tera:planter.trough", ctx);
|
||||
const foliage = meshes(object).find(
|
||||
(mesh) => (mesh.material as THREE.Material).name === "foliage",
|
||||
);
|
||||
assert.ok(foliage, "the planted trough has no foliage on it");
|
||||
const material = foliage.material as THREE.MeshStandardMaterial;
|
||||
assert.ok(material.alphaMap, "the trough's leaves are not cut out");
|
||||
assert.equal(material.alphaTest, 0.5);
|
||||
disposeObject(object);
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
# Test home for the `data` workstream.
|
||||
#
|
||||
# Each build workstream owns its own subdirectory so eight builders can add
|
||||
# suites in parallel without ever colliding on a path. `npm test` picks these
|
||||
# up through the widened `src/test/**/*.test.ts` glob in package.json.
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* The adapter that was never once imported by a test.
|
||||
*
|
||||
* `src/adapters/http.ts` is the module whose entire job is to be correct when
|
||||
* everything else has failed — no server, a 404, a static host answering with
|
||||
* its own index.html, a body about another city, a feed that has gone away
|
||||
* mid-session — and until this file it had **zero coverage**, because a pair of
|
||||
* TypeScript parameter properties on `HttpFlights` made Node's type stripping
|
||||
* refuse the whole module with `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`.
|
||||
*
|
||||
* `engine/flights.ts` already records what that costs: the module with the
|
||||
* worst bug this project has shipped was, by construction, the one module that
|
||||
* could not be tested. So the first assertion here is that the module imports
|
||||
* at all, and the rest are the degrade paths that were being taken on trust:
|
||||
*
|
||||
* - every feed falls back rather than failing, and **says** it fell back;
|
||||
* - a body about somewhere else is refused, which is the San Francisco fog
|
||||
* over Long Beach failure the whole location parameter exists to prevent;
|
||||
* - the back-off ladder is climbed rather than retried at the frame rate,
|
||||
* which is the specific bug a malformed `ttlSeconds` used to cause;
|
||||
* - a device command travels in a POST of its own and never in a read.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { createTeraClient, describeLiveness } from "../../adapters/http.ts";
|
||||
import { SAMPLE_MARKERS } from "../../adapters/sample.ts";
|
||||
import type { SkyRegion } from "../../engine/flights.ts";
|
||||
import type { DevicesBody, FlightsBody, WeatherBody } from "../../server/wire.ts";
|
||||
|
||||
/** The Bay Area, roughly, and big enough that the fixtures below are inside it. */
|
||||
const SF: SkyRegion = { center: { lat: 37.77, lng: -122.42 }, radiusNm: 60 };
|
||||
|
||||
/** Long Beach: five hundred and ninety kilometres away, and the whole point. */
|
||||
const ELSEWHERE = { lat: 33.77, lng: -118.19 };
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
method: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fetch that answers from a table, records what it was asked, and can be told
|
||||
* to fail.
|
||||
*
|
||||
* Deliberately not a mock of `Response` — `new Response` is in Node and the
|
||||
* adapter reads `ok`, `headers.get("content-type")` and `json()`, all of which
|
||||
* a real one does correctly. A hand-rolled stub would be a second opinion about
|
||||
* what a `Response` is.
|
||||
*/
|
||||
function stubFetch(routes: Record<string, unknown | (() => unknown)>) {
|
||||
const calls: Call[] = [];
|
||||
const fetcher = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const path = url.split("?")[0] ?? url;
|
||||
calls.push({ url, method: init?.method ?? "GET", body: readBody(init) });
|
||||
const key = Object.keys(routes).find((candidate) => path.endsWith(candidate));
|
||||
const answer = key === undefined ? undefined : routes[key];
|
||||
const value = typeof answer === "function" ? (answer as () => unknown)() : answer;
|
||||
if (value === undefined) return new Response("no", { status: 404 });
|
||||
if (value === "html") {
|
||||
// A static host serving the SPA shell for an unknown path: a 200, with
|
||||
// HTML in it. The content-type check is the only thing between that and
|
||||
// `res.json()` throwing somewhere further in.
|
||||
return new Response("<!doctype html>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
return { fetcher, calls };
|
||||
}
|
||||
|
||||
function readBody(init?: RequestInit): unknown {
|
||||
if (typeof init?.body !== "string") return null;
|
||||
try {
|
||||
return JSON.parse(init.body) as unknown;
|
||||
} catch {
|
||||
return init.body;
|
||||
}
|
||||
}
|
||||
|
||||
/** Let everything in flight settle. Two turns, because a `then` chains a `finally`. */
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe("the module imports at all", () => {
|
||||
it("has no TypeScript parameter properties left in it", async () => {
|
||||
// The assertion is the import itself: a parameter property anywhere in this
|
||||
// file makes the next line throw ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX and
|
||||
// takes every other test in this file with it.
|
||||
const module = await import("../../adapters/http.ts");
|
||||
assert.equal(typeof module.createTeraClient, "function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("markers degrade to the bundled set", () => {
|
||||
it("serves the sample set, and says so, when there is no API", async () => {
|
||||
const { fetcher } = stubFetch({});
|
||||
const feed = await createTeraClient({ fetch: fetcher }).markers();
|
||||
assert.equal(feed.live, false);
|
||||
assert.equal(feed.value, SAMPLE_MARKERS);
|
||||
assert.equal(feed.generatedAt, null);
|
||||
});
|
||||
|
||||
it("serves the sample set when a static host answers with its own HTML", async () => {
|
||||
const { fetcher } = stubFetch({ "/markers": "html" });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).markers();
|
||||
assert.equal(feed.live, false);
|
||||
});
|
||||
|
||||
it("falls back rather than showing an empty map wearing a live badge", async () => {
|
||||
const { fetcher } = stubFetch({ "/markers": { markers: [], generatedAt: "2026-01-01" } });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).markers();
|
||||
assert.equal(feed.live, false);
|
||||
assert.equal(feed.value, SAMPLE_MARKERS);
|
||||
});
|
||||
|
||||
it("passes a real feed through with the caller's palette", async () => {
|
||||
const markers = [{ id: "m1", name: "One", lat: 37.77, lng: -122.42, colorKey: "a" }];
|
||||
const { fetcher } = stubFetch({
|
||||
"/markers": { markers, generatedAt: "2026-08-01", refused: [{ reason: "provenance", count: 2 }] },
|
||||
});
|
||||
const feed = await createTeraClient({ fetch: fetcher, palette: { a: 0x00ff00 } }).markers();
|
||||
assert.equal(feed.live, true);
|
||||
assert.equal(feed.value.length, 1);
|
||||
assert.deepEqual(feed.palette, { a: 0x00ff00 });
|
||||
// Passed through rather than swallowed: a gate that drops rows silently is
|
||||
// indistinguishable from an empty database.
|
||||
assert.deepEqual(feed.refused, [{ reason: "provenance", count: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the weather is refused unless it is this city's", () => {
|
||||
const observation = (over: { lat: number; lng: number }, extra: Partial<WeatherBody> = {}) => ({
|
||||
observedAt: new Date().toISOString(),
|
||||
source: "nws",
|
||||
synthetic: false,
|
||||
location: over,
|
||||
temperatureC: 14,
|
||||
windKph: 10,
|
||||
windDirDeg: 270,
|
||||
cloudCover: 0.8,
|
||||
precipitation: 0,
|
||||
visibilityKm: 12,
|
||||
condition: "cloudy",
|
||||
...extra,
|
||||
});
|
||||
|
||||
it("adopts an observation of the place that was asked about", async () => {
|
||||
const { fetcher, calls } = stubFetch({ "/weather": observation(SF.center) });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).weather(SF.center);
|
||||
assert.equal(feed.live, true);
|
||||
assert.equal(feed.value?.cloudCover, 0.8);
|
||||
// Rounded to two places for the shared cache: two viewers of one city have
|
||||
// to produce byte-identical URLs or the cache is a per-viewer cache.
|
||||
assert.ok(calls[0]?.url.includes("lat=37.77"));
|
||||
});
|
||||
|
||||
it("refuses an observation of somewhere else, however good it is", async () => {
|
||||
const { fetcher } = stubFetch({ "/weather": observation(ELSEWHERE) });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).weather(SF.center);
|
||||
// Nobody-was-asked, so the local climatology runs. Rendering a real
|
||||
// observation of a city the viewer is not looking at is worse than
|
||||
// rendering none: it is wrong and it is convincing.
|
||||
assert.equal(feed.live, false);
|
||||
assert.equal(feed.value, null);
|
||||
});
|
||||
|
||||
it("refuses a body the server admits it invented", async () => {
|
||||
const { fetcher } = stubFetch({ "/weather": observation(SF.center, { synthetic: true }) });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).weather(SF.center);
|
||||
assert.equal(feed.live, false);
|
||||
assert.equal(feed.value, null);
|
||||
});
|
||||
|
||||
it("refuses a body it cannot place", async () => {
|
||||
const { fetcher } = stubFetch({
|
||||
"/weather": { ...observation(SF.center), location: undefined },
|
||||
});
|
||||
const feed = await createTeraClient({ fetch: fetcher }).weather(SF.center);
|
||||
assert.equal(feed.live, false);
|
||||
});
|
||||
|
||||
it("is nobody-was-asked rather than a clear day when nothing answers", async () => {
|
||||
const { fetcher } = stubFetch({});
|
||||
const feed = await createTeraClient({ fetch: fetcher }).weather(SF.center);
|
||||
// The distinction this whole fallback exists for: a reported clear sky is
|
||||
// authoritative in `atmosphere.ts` and would delete San Francisco's marine
|
||||
// layer permanently on a zero-config box.
|
||||
assert.equal(feed.value, null);
|
||||
assert.equal(feed.observedAt, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("traffic", () => {
|
||||
const live = (aircraft: unknown[], extra: Partial<FlightsBody> = {}) => ({
|
||||
mode: "live",
|
||||
source: "adsb",
|
||||
observedAt: Date.now(),
|
||||
aircraft,
|
||||
ttlSeconds: 5,
|
||||
redistributable: true,
|
||||
attribution: ["Data from adsb.lol"],
|
||||
...extra,
|
||||
});
|
||||
|
||||
const overSf = {
|
||||
id: "a1b2c3",
|
||||
icao24: "a1b2c3",
|
||||
callsign: "UAL221",
|
||||
lat: 37.8,
|
||||
lng: -122.4,
|
||||
altitude: 3048,
|
||||
heading: 95,
|
||||
};
|
||||
|
||||
it("flies the simulator until an answer lands, and does not claim it is live", async () => {
|
||||
const { fetcher } = stubFetch({});
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
const first = source.poll();
|
||||
assert.ok(first.length > 0);
|
||||
assert.equal(source.live(), false);
|
||||
// Nobody is credited for this repo's own arithmetic.
|
||||
assert.deepEqual(source.attribution(), []);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("adopts real positions inside the region and credits the feed", async () => {
|
||||
const { fetcher } = stubFetch({ "/flights": live([overSf]) });
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
const aircraft = source.poll();
|
||||
assert.equal(source.live(), true);
|
||||
assert.deepEqual(aircraft.map((a) => a.id), ["a1b2c3"]);
|
||||
assert.deepEqual(source.attribution(), ["Data from adsb.lol"]);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("refuses a feed pointed at another city rather than drawing an empty board", async () => {
|
||||
const elsewhere = { ...overSf, lat: ELSEWHERE.lat, lng: ELSEWHERE.lng };
|
||||
const { fetcher } = stubFetch({ "/flights": live([elsewhere]) });
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
// Fifty aircraft, none within a hundred miles of the board, is a server
|
||||
// pointed at another city — and there the simulator is the honest picture.
|
||||
assert.equal(source.live(), false);
|
||||
assert.ok(source.poll().length > 0);
|
||||
assert.deepEqual(source.attribution(), []);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("keeps an empty sky when the feed really is empty", async () => {
|
||||
const { fetcher } = stubFetch({ "/flights": live([]) });
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
// Three in the morning over a small city is an empty sky and is live. Only
|
||||
// an empty *filter result over a non-empty body* means somewhere else.
|
||||
assert.equal(source.live(), true);
|
||||
assert.deepEqual(source.poll(), []);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("evaluates a plan locally and keeps only the legs that are here", async () => {
|
||||
const plan = {
|
||||
mode: "plan",
|
||||
source: "sim",
|
||||
t0: Date.now() - 60_000,
|
||||
seed: 4711,
|
||||
ttlSeconds: 300,
|
||||
routes: [
|
||||
{ callsign: "SFO1", from: [37.6, -122.4], to: [37.9, -122.3], fromAlt: 0, toAlt: 3000, duration: 600 },
|
||||
{ callsign: "LAX1", from: [33.9, -118.4], to: [33.7, -118.1], fromAlt: 0, toAlt: 3000, duration: 600 },
|
||||
],
|
||||
};
|
||||
const { fetcher, calls } = stubFetch({ "/flights": plan });
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
const aircraft = source.poll();
|
||||
assert.deepEqual(aircraft.map((a) => a.callsign), ["SFO1"]);
|
||||
// A plan is not live traffic and does not say it is, even though every
|
||||
// viewer agrees about where its aircraft are.
|
||||
assert.equal(source.live(), false);
|
||||
// One request, not one per poll: a plan is arithmetic.
|
||||
source.poll();
|
||||
source.poll();
|
||||
assert.equal(calls.length, 1);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("does not poll at the frame rate when a body forgets its ttl", async () => {
|
||||
// `Math.max(1, undefined)` is `NaN`, `now < NaN` is false forever, and the
|
||||
// poll interval quietly became the frame rate. This is that bug's test.
|
||||
const { fetcher, calls } = stubFetch({ "/flights": live([overSf], { ttlSeconds: undefined }) });
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
for (let i = 0; i < 30; i += 1) source.poll();
|
||||
await settle();
|
||||
assert.equal(calls.length, 1);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("survives a body it cannot read without starting a request per frame", async () => {
|
||||
const { fetcher, calls } = stubFetch({ "/flights": { mode: "live", source: "adsb", ttlSeconds: 5 } });
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
for (let i = 0; i < 30; i += 1) source.poll();
|
||||
await settle();
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(source.live(), false);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("stops fetching once disposed", async () => {
|
||||
const { fetcher, calls } = stubFetch({ "/flights": live([overSf]) });
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
source.dispose();
|
||||
const seen = calls.length;
|
||||
for (let i = 0; i < 10; i += 1) source.poll();
|
||||
await settle();
|
||||
assert.equal(calls.length, seen);
|
||||
});
|
||||
});
|
||||
|
||||
describe("one aircraft, as a card an anonymous visitor can open", () => {
|
||||
const overSf = {
|
||||
id: "a1b2c3",
|
||||
icao24: "a1b2c3",
|
||||
callsign: " UAL221 ",
|
||||
lat: 37.8,
|
||||
lng: -122.4,
|
||||
altitude: 3048,
|
||||
heading: 95,
|
||||
};
|
||||
|
||||
it("carries callsign, address, altitude, heading and position", async () => {
|
||||
const { fetcher } = stubFetch({
|
||||
"/flights": {
|
||||
mode: "live",
|
||||
source: "adsb",
|
||||
observedAt: Date.now(),
|
||||
aircraft: [overSf],
|
||||
ttlSeconds: 5,
|
||||
redistributable: true,
|
||||
attribution: ["Data from adsb.lol"],
|
||||
},
|
||||
});
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
source.poll();
|
||||
|
||||
const detail = source.detail("a1b2c3");
|
||||
assert.ok(detail !== null);
|
||||
assert.equal(detail.callsign, "UAL221");
|
||||
assert.equal(detail.icao24, "a1b2c3");
|
||||
assert.equal(detail.altitudeM, 3048);
|
||||
assert.equal(detail.altitudeFt, 10_000);
|
||||
assert.equal(detail.headingDeg, 95);
|
||||
assert.equal(detail.headingCompass, "E");
|
||||
assert.equal(detail.lat, 37.8);
|
||||
assert.equal(detail.observed, true);
|
||||
// The credit travels with the card, because a card is where the data is
|
||||
// displayed and that is what an ODbL notice is about.
|
||||
assert.deepEqual(detail.attribution, ["Data from adsb.lol"]);
|
||||
assert.ok(detail.distanceNm !== null && detail.distanceNm < 10);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("says nothing about an aircraft that has left the feed", async () => {
|
||||
const { fetcher } = stubFetch({});
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
assert.equal(source.detail("a1b2c3"), null);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("never presents a simulated aircraft as observed, or its id as an address", async () => {
|
||||
const { fetcher } = stubFetch({});
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
const first = source.poll()[0];
|
||||
assert.ok(first !== undefined);
|
||||
const detail = source.detail(first.id);
|
||||
assert.ok(detail !== null);
|
||||
assert.equal(detail.observed, false);
|
||||
// `sim-BA286` is a route name, not a transponder address, and somebody
|
||||
// pastes this field into a registry lookup.
|
||||
assert.equal(detail.icao24, null);
|
||||
assert.deepEqual(detail.attribution, []);
|
||||
source.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the corner label only claims what is true", () => {
|
||||
it("says nothing when nothing is live", () => {
|
||||
assert.equal(describeLiveness({ markers: false, weather: false, flights: false }), "");
|
||||
});
|
||||
|
||||
it("names the parts rather than claiming the whole map", () => {
|
||||
assert.equal(describeLiveness({ markers: false, weather: true, flights: true }), "live weather + traffic");
|
||||
});
|
||||
|
||||
it("earns the unqualified claim only when all three are live", () => {
|
||||
assert.equal(describeLiveness({ markers: true, weather: true, flights: true }), "live data");
|
||||
});
|
||||
});
|
||||
|
||||
describe("devices", () => {
|
||||
const body: DevicesBody = {
|
||||
officeId: "hq",
|
||||
devices: [
|
||||
{
|
||||
id: "mic-1",
|
||||
kind: "mic",
|
||||
powered: true,
|
||||
muted: false,
|
||||
gainDb: 12,
|
||||
levelDb: -21.5,
|
||||
observedAt: 1,
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
observedAt: 1,
|
||||
source: "sim",
|
||||
synthetic: true,
|
||||
ttlSeconds: 5,
|
||||
};
|
||||
|
||||
it("is empty and not live when there is no API", async () => {
|
||||
const { fetcher } = stubFetch({});
|
||||
const feed = await createTeraClient({ fetch: fetcher }).devices("hq");
|
||||
assert.deepEqual(feed.value, []);
|
||||
assert.equal(feed.live, false);
|
||||
assert.equal(feed.source, "none");
|
||||
// Not a claim that anybody observed these zero readings.
|
||||
assert.equal(feed.synthetic, true);
|
||||
});
|
||||
|
||||
it("is empty and not live when the deployment refuses an anonymous read", async () => {
|
||||
const { fetcher } = stubFetch({ "/devices": undefined });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).devices("hq");
|
||||
assert.equal(feed.live, false);
|
||||
assert.deepEqual(feed.value, []);
|
||||
});
|
||||
|
||||
it("adopts a body and keeps its provenance", async () => {
|
||||
const { fetcher, calls } = stubFetch({ "/devices": body });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).devices("hq");
|
||||
assert.equal(feed.live, true);
|
||||
assert.equal(feed.synthetic, true);
|
||||
assert.equal(feed.source, "sim");
|
||||
assert.equal(feed.value[0]?.levelDb, -21.5);
|
||||
assert.equal(calls[0]?.url, "/api/v1/offices/hq/devices");
|
||||
});
|
||||
|
||||
it("treats an office id as an id and not as a path", async () => {
|
||||
const { fetcher, calls } = stubFetch({ "/devices": body });
|
||||
await createTeraClient({ fetch: fetcher }).devices("../secrets");
|
||||
assert.equal(calls[0]?.url, "/api/v1/offices/..%2Fsecrets/devices");
|
||||
});
|
||||
|
||||
it("is empty when the body is the wrong shape", async () => {
|
||||
const { fetcher } = stubFetch({ "/devices": { officeId: "hq", devices: "lots" } });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).devices("hq");
|
||||
assert.equal(feed.live, false);
|
||||
});
|
||||
|
||||
it("sends a command as a POST of its own, never on the read", async () => {
|
||||
const { fetcher, calls } = stubFetch({
|
||||
"/devices/command": { officeId: "hq", device: { ...body.devices[0], powered: false }, observedAt: 2 },
|
||||
});
|
||||
const client = createTeraClient({ fetch: fetcher });
|
||||
const state = await client.commandDevice("hq", { deviceId: "mic-1", op: "power", value: false });
|
||||
|
||||
assert.equal(state?.powered, false);
|
||||
const call = calls[0];
|
||||
assert.equal(call?.method, "POST");
|
||||
assert.equal(call?.url, "/api/v1/offices/hq/devices/command");
|
||||
assert.deepEqual(call?.body, { command: { deviceId: "mic-1", op: "power", value: false } });
|
||||
// The read route was never touched. A command that could ride on a GET is a
|
||||
// command a shared cache can replay.
|
||||
assert.equal(calls.filter((c) => c.method === "GET").length, 0);
|
||||
});
|
||||
|
||||
it("reports a refused command rather than pretending it worked", async () => {
|
||||
const { fetcher } = stubFetch({});
|
||||
const client = createTeraClient({ fetch: fetcher });
|
||||
const state = await client.commandDevice("hq", { deviceId: "mic-1", op: "power", value: true });
|
||||
assert.equal(state, null);
|
||||
});
|
||||
|
||||
it("reports a 200 whose body is not a result", async () => {
|
||||
const { fetcher } = stubFetch({ "/devices/command": { officeId: "hq" } });
|
||||
const client = createTeraClient({ fetch: fetcher });
|
||||
const state = await client.commandDevice("hq", { deviceId: "mic-1", op: "power", value: true });
|
||||
assert.equal(state, null);
|
||||
});
|
||||
|
||||
it("publishes the first answer, then only when a reading changes", async () => {
|
||||
let served: DevicesBody = body;
|
||||
const { fetcher } = stubFetch({ "/devices": () => served });
|
||||
const feeds: number[] = [];
|
||||
const watch = createTeraClient({ fetch: fetcher }).watchDevices("hq", (feed) => {
|
||||
feeds.push(feed.value[0]?.levelDb ?? 0);
|
||||
});
|
||||
await settle();
|
||||
assert.equal(feeds.length, 1);
|
||||
|
||||
// The same reading again, with only `observedAt` moved: not news, and
|
||||
// publishing it would rebuild the panel on every poll forever.
|
||||
served = { ...body, observedAt: 99, devices: [{ ...body.devices[0]!, observedAt: 99 }] };
|
||||
watch.refresh();
|
||||
await settle();
|
||||
assert.equal(feeds.length, 1);
|
||||
|
||||
served = { ...body, devices: [{ ...body.devices[0]!, levelDb: -12.5 }] };
|
||||
watch.refresh();
|
||||
await settle();
|
||||
assert.deepEqual(feeds, [-21.5, -12.5]);
|
||||
watch.stop();
|
||||
});
|
||||
|
||||
it("holds the latest feed so a panel opening late does not wait for a poll", async () => {
|
||||
const { fetcher } = stubFetch({ "/devices": body });
|
||||
const watch = createTeraClient({ fetch: fetcher }).watchDevices("hq", () => {});
|
||||
await settle();
|
||||
assert.equal(watch.current().value[0]?.id, "mic-1");
|
||||
watch.stop();
|
||||
});
|
||||
|
||||
it("stops dead, and drops an answer that lands after it was stopped", async () => {
|
||||
const { fetcher, calls } = stubFetch({ "/devices": body });
|
||||
const feeds: unknown[] = [];
|
||||
const watch = createTeraClient({ fetch: fetcher }).watchDevices("hq", (feed) => feeds.push(feed));
|
||||
watch.stop();
|
||||
await settle();
|
||||
const seen = calls.length;
|
||||
watch.refresh();
|
||||
await settle();
|
||||
assert.equal(calls.length, seen);
|
||||
assert.equal(feeds.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* The device layer, held to the three rules it can break invisibly.
|
||||
*
|
||||
* **It constructs no light.** CONTRACT.md §4 gives Atmosphere sole ownership,
|
||||
* and an LED is the most tempting exception in the whole codebase because it is
|
||||
* *obviously* a light and a `PointLight` per device is one line. The grep in
|
||||
* the build spec catches the letter of it; this file catches the spirit, by
|
||||
* asserting nothing in the subtree is a `THREE.Light` at all.
|
||||
*
|
||||
* **It borrows materials and owns geometry.** Every mesh comes out of
|
||||
* `MeshBin`, which clones and merges, so the geometry belongs to the layer and
|
||||
* goes with it. The materials come from the shared `MaterialRegistry`, are
|
||||
* cached there across the whole office, and disposing one here would empty the
|
||||
* desks in the rest of the building — so `dispose()` must free the first and
|
||||
* must not touch the second. A test that only asserted "everything is
|
||||
* disposed" would be asserting the bug.
|
||||
*
|
||||
* **It drops what it cannot place.** A device anchored to a prop that is not in
|
||||
* the plan — a typo, or a private prop in a public build — has nowhere to
|
||||
* stand, and putting it at the origin would leave a microphone on the lobby
|
||||
* floor.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import { AssetRegistry, defineAsset } from "../../assets/kit.ts";
|
||||
import { MaterialRegistry } from "../../assets/materials.ts";
|
||||
import type { DeviceDeclaration, DeviceState } from "../../devices/types.ts";
|
||||
import { createDeviceLayer } from "../../interiors/devices.ts";
|
||||
import { Plan } from "../../interiors/plan.ts";
|
||||
import type { Office } from "../../interiors/types.ts";
|
||||
|
||||
const MIC = "tera:device.mic.desk";
|
||||
|
||||
/**
|
||||
* A stand-in for the real hardware.
|
||||
*
|
||||
* The layer's contract with an asset is exactly one sentence — expose a
|
||||
* sub-object named `indicator` — so the test asset is that sentence and nothing
|
||||
* else. Building against `src/assets/office/devices.ts` would make this a test
|
||||
* of somebody else's geometry, and it would fail for reasons that have nothing
|
||||
* to do with this layer.
|
||||
*/
|
||||
function registry(withIndicator = true): AssetRegistry {
|
||||
return new AssetRegistry().register(
|
||||
defineAsset({
|
||||
id: MIC,
|
||||
defaults: {},
|
||||
footprint: () => ({ width: 0.09, depth: 0.09, height: 0.3 }),
|
||||
build(_params, ctx) {
|
||||
const group = new THREE.Group();
|
||||
const body = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.2, 0.09), ctx.materials.get("deviceShell"));
|
||||
body.name = "body";
|
||||
group.add(body);
|
||||
if (withIndicator) {
|
||||
const led = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.004, 6, 4),
|
||||
ctx.materials.get("deviceIndicator"),
|
||||
);
|
||||
led.name = "indicator";
|
||||
led.position.set(0, 0.22, 0.03);
|
||||
group.add(led);
|
||||
}
|
||||
return group;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function office(declarations: readonly DeviceDeclaration[]): Office {
|
||||
return {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
viewpoints: [],
|
||||
levels: [
|
||||
{
|
||||
id: "l1",
|
||||
name: "Ground",
|
||||
elevation: 0,
|
||||
wallHeight: 3,
|
||||
floorplan: {
|
||||
rooms: [
|
||||
{
|
||||
id: "room",
|
||||
name: "Room",
|
||||
floor: "tera:carpet.loop",
|
||||
outline: [
|
||||
{ x: 0, z: 0 },
|
||||
{ x: 8, z: 0 },
|
||||
{ x: 8, z: 6 },
|
||||
{ x: 0, z: 6 },
|
||||
],
|
||||
},
|
||||
],
|
||||
walls: [],
|
||||
props: [{ id: "mic-prop", kind: MIC, position: { x: 2, z: 3 }, rotation: Math.PI / 2 }],
|
||||
// Authored here as well as handed to the layer, because that is how a
|
||||
// real pack carries them: `Plan` is what turns the anchor into a
|
||||
// coordinate, and a declaration the plan never saw is a device with
|
||||
// nowhere to stand.
|
||||
devices: declarations,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as Office;
|
||||
}
|
||||
|
||||
const declaration: DeviceDeclaration = {
|
||||
id: "mic-1",
|
||||
kind: "mic",
|
||||
label: "Desk mic",
|
||||
assetId: MIC,
|
||||
anchor: { levelId: "l1", propId: "mic-prop", offset: { x: 0, y: 0.72, z: 0 } },
|
||||
capabilities: ["power", "mute", "gain", "level"],
|
||||
provenance: "simulated",
|
||||
disclosure: "Simulated studio hardware. Demonstration data, never presence data.",
|
||||
};
|
||||
|
||||
function state(over: Partial<DeviceState> = {}): DeviceState {
|
||||
return {
|
||||
id: "mic-1",
|
||||
kind: "mic",
|
||||
powered: false,
|
||||
muted: false,
|
||||
gainDb: 12,
|
||||
levelDb: -60,
|
||||
observedAt: 0,
|
||||
synthetic: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function layerFor(options: { withIndicator?: boolean; declarations?: DeviceDeclaration[] } = {}) {
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
const assets = registry(options.withIndicator ?? true);
|
||||
const declarations = options.declarations ?? [declaration];
|
||||
const plan = new Plan(office(declarations), { depth: "full", warn: false });
|
||||
const layer = createDeviceLayer({ plan, declarations, assets, materials });
|
||||
return { layer, materials, plan };
|
||||
}
|
||||
|
||||
/** The indicator's material, wherever in the subtree it ended up. */
|
||||
function indicatorMaterial(object: THREE.Object3D): THREE.MeshStandardMaterial | null {
|
||||
const indicator = object.getObjectByName("indicator");
|
||||
if (!indicator) return null;
|
||||
let found: THREE.MeshStandardMaterial | null = null;
|
||||
indicator.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh;
|
||||
if (mesh.isMesh && !Array.isArray(mesh.material)) {
|
||||
found = mesh.material as THREE.MeshStandardMaterial;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("placing the hardware", () => {
|
||||
it("stands a device on its anchor prop, in the prop's own frame", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
const mount = layer.object.getObjectByName("device:mic-1");
|
||||
assert.ok(mount !== undefined);
|
||||
layer.object.updateMatrixWorld(true);
|
||||
|
||||
const world = new THREE.Vector3();
|
||||
(layer.object.getObjectByName("indicator") as THREE.Object3D).getWorldPosition(world);
|
||||
// The prop stands at (2, 3) rotated a quarter turn, and the declaration
|
||||
// lifts the device 0.72 m up the desk. The indicator is 30 mm forward of
|
||||
// the mic's own origin, which the prop's rotation carries round with it —
|
||||
// which is the whole reason the offset is expressed in the prop's frame.
|
||||
assert.ok(Math.abs(world.y - (0.72 + 0.22)) < 1e-6, `y ${world.y}`);
|
||||
assert.ok(Math.abs(world.x - 2.03) < 1e-6, `x ${world.x}`);
|
||||
assert.ok(Math.abs(world.z - 3) < 1e-6, `z ${world.z}`);
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("drops a device whose hardware is not in the plan", () => {
|
||||
const { layer, materials } = layerFor({
|
||||
declarations: [
|
||||
declaration,
|
||||
{ ...declaration, id: "mic-nowhere", anchor: { levelId: "l1", propId: "no-such-prop" } },
|
||||
{ ...declaration, id: "mic-elsewhere", anchor: { levelId: "l9", propId: "mic-prop" } },
|
||||
],
|
||||
});
|
||||
const names = layer.object.children.map((child) => child.name);
|
||||
assert.deepEqual(names, ["device:mic-1"]);
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("still stands hardware that exposes no indicator", () => {
|
||||
const { layer, materials } = layerFor({ withIndicator: false });
|
||||
assert.equal(layer.object.children.length, 1);
|
||||
// And applying a state to it is a no-op rather than a throw: a
|
||||
// self-hoster's own microphone model is not a reason to lose the office.
|
||||
layer.apply([state({ powered: true })]);
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("builds the same geometry every time, from the same declaration", () => {
|
||||
const a = layerFor();
|
||||
const b = layerFor();
|
||||
const box = (object: THREE.Object3D) => {
|
||||
object.updateMatrixWorld(true);
|
||||
return [...new THREE.Box3().setFromObject(object).min.toArray()];
|
||||
};
|
||||
assert.deepEqual(box(a.layer.object), box(b.layer.object));
|
||||
a.layer.dispose();
|
||||
a.materials.dispose();
|
||||
b.layer.dispose();
|
||||
b.materials.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("showing the state", () => {
|
||||
it("changes the indicator colour between powered and unpowered", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
|
||||
layer.apply([state({ powered: false })]);
|
||||
const off = indicatorMaterial(layer.object);
|
||||
assert.ok(off !== null);
|
||||
const offColor = off.color.getHex();
|
||||
|
||||
layer.apply([state({ powered: true })]);
|
||||
const on = indicatorMaterial(layer.object);
|
||||
assert.ok(on !== null);
|
||||
assert.notEqual(on.color.getHex(), offColor);
|
||||
// The role glows, so the tint has to reach the emissive term as well —
|
||||
// otherwise a "lit" LED is a coloured pebble under the tone curve.
|
||||
assert.equal(on.emissive.getHex(), on.color.getHex());
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("shows a muted microphone differently from an open one", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
layer.apply([state({ powered: true, muted: false })]);
|
||||
const open = indicatorMaterial(layer.object)?.color.getHex();
|
||||
layer.apply([state({ powered: true, muted: true })]);
|
||||
const muted = indicatorMaterial(layer.object)?.color.getHex();
|
||||
assert.notEqual(open, muted);
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("shares one material per state across every device in the building", () => {
|
||||
const { layer, materials } = layerFor({
|
||||
declarations: [declaration, { ...declaration, id: "mic-2" }],
|
||||
});
|
||||
layer.apply([state({ id: "mic-1", powered: true }), state({ id: "mic-2", powered: true })]);
|
||||
const [first, second] = layer.object.children;
|
||||
// The same object, not an equal one: two microphones in the same state are
|
||||
// one material and one draw call, which is what `tinted` is cached for.
|
||||
assert.equal(indicatorMaterial(first as THREE.Object3D), indicatorMaterial(second as THREE.Object3D));
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("leaves a device alone when a poll drops it", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
layer.apply([state({ powered: true })]);
|
||||
const on = indicatorMaterial(layer.object)?.color.getHex();
|
||||
// A reading that stopped arriving is not the same event as a device being
|
||||
// switched off, and only one of them should change what is on screen.
|
||||
layer.apply([]);
|
||||
assert.equal(indicatorMaterial(layer.object)?.color.getHex(), on);
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("ignores a state for a device it does not carry", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
layer.apply([state({ id: "somebody-elses-mic", powered: true })]);
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the lighting rule", () => {
|
||||
it("constructs no light of any kind", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
layer.apply([state({ powered: true })]);
|
||||
let lights = 0;
|
||||
layer.object.traverse((child) => {
|
||||
if ((child as THREE.Light).isLight) lights += 1;
|
||||
});
|
||||
assert.equal(lights, 0);
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("disposal", () => {
|
||||
it("frees every geometry it built", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
const geometries: THREE.BufferGeometry[] = [];
|
||||
layer.object.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh;
|
||||
if (mesh.isMesh) geometries.push(mesh.geometry);
|
||||
});
|
||||
assert.ok(geometries.length > 0);
|
||||
|
||||
let freed = 0;
|
||||
for (const geometry of geometries) geometry.addEventListener("dispose", () => (freed += 1));
|
||||
layer.dispose();
|
||||
assert.equal(freed, geometries.length);
|
||||
assert.equal(layer.object.children.length, 0);
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("does not dispose the shared materials it borrowed", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
layer.apply([state({ powered: true })]);
|
||||
const material = indicatorMaterial(layer.object);
|
||||
assert.ok(material !== null);
|
||||
|
||||
let disposed = 0;
|
||||
material.addEventListener("dispose", () => (disposed += 1));
|
||||
layer.dispose();
|
||||
// The registry's material is holding up the rest of the office. Freeing it
|
||||
// here would be a device layer emptying the desks.
|
||||
assert.equal(disposed, 0);
|
||||
assert.equal(materials.tinted("deviceIndicator", material.color.getHex()), material);
|
||||
materials.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* The device contract, held to the two promises it makes to everybody else.
|
||||
*
|
||||
* `src/devices/types.ts` is imported by the office packs, by the device panel,
|
||||
* by the API and by the arena, and three of those four consume it in a context
|
||||
* where a mistake here is invisible until much later. So this file asserts the
|
||||
* properties the other four are *entitled to assume*:
|
||||
*
|
||||
* 1. **A declaration round-trips through JSON unchanged.** It is authored
|
||||
* inside an `Office` pack, and CONTRACT.md §2 says a hand-written pack and
|
||||
* one arriving over HTTP have to be the same thing. A `Date`, a class
|
||||
* instance or an undefined-valued field would break that quietly — the
|
||||
* pack would still build, and the served copy would differ from the
|
||||
* authored one.
|
||||
* 2. **The vocabulary and the shape agree.** Every capability has a reading,
|
||||
* every commandable op is a capability, every canonical set is legal. The
|
||||
* failure this prevents is a kind added later whose new capability nobody
|
||||
* wired into the reading table, which shows up as a control that renders
|
||||
* and does nothing.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
CANONICAL_CAPABILITIES,
|
||||
CAPABILITY_READING,
|
||||
DEVICE_CAPABILITIES,
|
||||
DEVICE_COMMAND_OPS,
|
||||
DEVICE_KINDS,
|
||||
DEVICE_PROVENANCE,
|
||||
DEVICE_RANGES,
|
||||
deviceKindOfAssetId,
|
||||
deviceStateSignature,
|
||||
hasCapability,
|
||||
initialDeviceState,
|
||||
isDeviceCapability,
|
||||
isDeviceCommandOp,
|
||||
isDeviceKind,
|
||||
isDeviceProvenance,
|
||||
normalizeDeviceCommand,
|
||||
validateDeviceDeclaration,
|
||||
type DeviceDeclaration,
|
||||
type DeviceState,
|
||||
} from "../../devices/types.ts";
|
||||
|
||||
/** A mic as a pack author would write it, with every optional field populated. */
|
||||
const DESK_MIC: DeviceDeclaration = {
|
||||
id: "hq-mic-01",
|
||||
kind: "mic",
|
||||
label: "Desk mic",
|
||||
assetId: "tera:device.mic.desk",
|
||||
anchor: {
|
||||
levelId: "l1",
|
||||
propId: "eng-desk-01",
|
||||
roomId: "engineering",
|
||||
seatId: "eng-01",
|
||||
offset: { x: 0.31, y: 0.74, z: -0.18 },
|
||||
},
|
||||
capabilities: ["power", "mute", "gain", "level"],
|
||||
provenance: "simulated",
|
||||
disclosure: "Simulated studio hardware. This is demonstration data, never presence data.",
|
||||
};
|
||||
|
||||
/** A speaker with only the required fields, which is the other authoring shape. */
|
||||
const DESK_SPEAKER: DeviceDeclaration = {
|
||||
id: "hq-speaker-01",
|
||||
kind: "speaker",
|
||||
label: "Monitor speaker",
|
||||
assetId: "tera:device.speaker.desk",
|
||||
anchor: { levelId: "l1", propId: "eng-monitor-01" },
|
||||
capabilities: ["power", "volume", "playback"],
|
||||
provenance: "simulated",
|
||||
disclosure: "Simulated playback. Nothing here is a recording of a real room.",
|
||||
};
|
||||
|
||||
describe("a declaration is JSON, and only JSON", () => {
|
||||
for (const declaration of [DESK_MIC, DESK_SPEAKER]) {
|
||||
it(`round-trips ${declaration.id} through JSON unchanged`, () => {
|
||||
const round = JSON.parse(JSON.stringify(declaration)) as DeviceDeclaration;
|
||||
assert.deepEqual(round, declaration);
|
||||
// deepEqual is satisfied by a Date that stringifies to the same shape, so
|
||||
// the identity of the parsed value is checked too: what comes back must
|
||||
// be plain objects, arrays, strings, numbers and booleans.
|
||||
assert.equal(round.constructor, Object);
|
||||
assert.ok(Array.isArray(round.capabilities));
|
||||
});
|
||||
}
|
||||
|
||||
it("survives the round trip inside a pack, which is where it actually lives", () => {
|
||||
// The real shape: a floorplan carrying a devices array, stringified whole.
|
||||
const floorplan = { id: "l1", devices: [DESK_MIC, DESK_SPEAKER] };
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(floorplan)), floorplan);
|
||||
});
|
||||
|
||||
it("carries no key whose value is undefined, which JSON silently drops", () => {
|
||||
// The trap this catches: `offset: undefined` deep-equals an absent `offset`
|
||||
// in JS but is not the same object after a round trip through a server.
|
||||
const walk = (value: unknown, path: string): void => {
|
||||
if (value === null || typeof value !== "object") return;
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, i) => walk(item, `${path}[${i}]`));
|
||||
return;
|
||||
}
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
assert.notEqual(item, undefined, `${path}.${key} is undefined`);
|
||||
walk(item, `${path}.${key}`);
|
||||
}
|
||||
};
|
||||
walk(DESK_MIC, "mic");
|
||||
walk(DESK_SPEAKER, "speaker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the vocabulary", () => {
|
||||
it("gives every capability its own reading on the state", () => {
|
||||
// One row per capability, and no two capabilities pointing at the same
|
||||
// field — a duplicate would mean two controls fighting over one reading.
|
||||
const fields = DEVICE_CAPABILITIES.map((capability) => CAPABILITY_READING[capability]);
|
||||
assert.equal(Object.keys(CAPABILITY_READING).length, DEVICE_CAPABILITIES.length);
|
||||
assert.equal(new Set(fields).size, fields.length);
|
||||
// Every one of them is a field a real state actually carries.
|
||||
const everything = initialDeviceState(
|
||||
{ ...DESK_MIC, capabilities: DEVICE_CAPABILITIES },
|
||||
0,
|
||||
);
|
||||
for (const field of fields) {
|
||||
assert.ok(field in everything, `${field} is not a field on DeviceState`);
|
||||
}
|
||||
});
|
||||
|
||||
it("commands exactly the capabilities that are not read-only", () => {
|
||||
// `level` is a meter. If this ever admits it, something has grown a
|
||||
// "set the level" button that cannot do anything.
|
||||
assert.deepEqual(
|
||||
[...DEVICE_COMMAND_OPS].sort(),
|
||||
DEVICE_CAPABILITIES.filter((c) => c !== "level").sort(),
|
||||
);
|
||||
assert.equal(isDeviceCommandOp("level"), false);
|
||||
assert.equal(isDeviceCapability("level"), true);
|
||||
});
|
||||
|
||||
it("keeps every canonical set inside the capability vocabulary", () => {
|
||||
for (const kind of DEVICE_KINDS) {
|
||||
const set = CANONICAL_CAPABILITIES[kind];
|
||||
assert.ok(set.length > 0, `${kind} has no canonical capabilities`);
|
||||
for (const capability of set) assert.ok(isDeviceCapability(capability));
|
||||
assert.ok(set.includes("power"), `${kind} must be switchable`);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a value that is not in the vocabulary", () => {
|
||||
assert.equal(isDeviceKind("thermostat"), false);
|
||||
assert.equal(isDeviceKind(7), false);
|
||||
assert.equal(isDeviceCapability("colour"), false);
|
||||
assert.equal(isDeviceProvenance("vibes"), false);
|
||||
for (const p of DEVICE_PROVENANCE) assert.equal(isDeviceProvenance(p), true);
|
||||
});
|
||||
|
||||
it("gives every numeric reading a range with rest inside it", () => {
|
||||
for (const [name, range] of Object.entries(DEVICE_RANGES)) {
|
||||
assert.ok(range.min < range.max, `${name} range is inverted`);
|
||||
assert.ok(range.initial >= range.min && range.initial <= range.max, `${name} rests outside`);
|
||||
assert.notEqual(range.unit, "", `${name} has no unit`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading an asset id", () => {
|
||||
it("finds the kind a device asset claims to be", () => {
|
||||
assert.equal(deviceKindOfAssetId("tera:device.mic.desk"), "mic");
|
||||
assert.equal(deviceKindOfAssetId("tera:device.speaker.desk"), "speaker");
|
||||
// A self-hoster's own hardware reads as a device without registering here.
|
||||
assert.equal(deviceKindOfAssetId("acme:device.mic.boom"), "mic");
|
||||
});
|
||||
|
||||
it("says null for anything that is not device hardware", () => {
|
||||
assert.equal(deviceKindOfAssetId("tera:desk.workstation"), null);
|
||||
assert.equal(deviceKindOfAssetId("tera:device.thermostat.wall"), null);
|
||||
assert.equal(deviceKindOfAssetId("device"), null);
|
||||
assert.equal(deviceKindOfAssetId(""), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validating an authored declaration", () => {
|
||||
it("passes the two this build ships", () => {
|
||||
assert.deepEqual(validateDeviceDeclaration(DESK_MIC), []);
|
||||
assert.deepEqual(validateDeviceDeclaration(DESK_SPEAKER), []);
|
||||
});
|
||||
|
||||
it("catches a declaration whose asset is the other instrument", () => {
|
||||
// The failure that matters: a command routed to the wrong hardware.
|
||||
const problems = validateDeviceDeclaration({
|
||||
...DESK_MIC,
|
||||
assetId: "tera:device.speaker.desk",
|
||||
});
|
||||
assert.equal(problems.length, 1);
|
||||
assert.match(problems[0] ?? "", /declared a mic/);
|
||||
});
|
||||
|
||||
it("catches a simulated device whose disclosure does not say so", () => {
|
||||
const problems = validateDeviceDeclaration({
|
||||
...DESK_MIC,
|
||||
disclosure: "Live from the studio floor.",
|
||||
});
|
||||
assert.equal(problems.length, 1);
|
||||
assert.match(problems[0] ?? "", /does not say so/);
|
||||
});
|
||||
|
||||
it("catches a device anchored to nothing, and one that can do nothing", () => {
|
||||
const floating = validateDeviceDeclaration({
|
||||
...DESK_MIC,
|
||||
anchor: { levelId: "l1", propId: "" },
|
||||
});
|
||||
assert.equal(floating.length, 1);
|
||||
assert.match(floating[0] ?? "", /anchored to no prop/);
|
||||
|
||||
const inert = validateDeviceDeclaration({ ...DESK_SPEAKER, capabilities: [] });
|
||||
assert.equal(inert.length, 1);
|
||||
assert.match(inert[0] ?? "", /no capabilities/);
|
||||
});
|
||||
|
||||
it("never throws, whatever it is handed", () => {
|
||||
// Packs resolution records problems; it does not lose the building over
|
||||
// one bad device. That contract is only worth anything if this is total.
|
||||
const rubbish = {
|
||||
id: "",
|
||||
kind: "toaster",
|
||||
label: " ",
|
||||
assetId: "",
|
||||
anchor: { levelId: "", propId: "" },
|
||||
capabilities: ["warmth"],
|
||||
provenance: "hearsay",
|
||||
disclosure: "",
|
||||
} as unknown as DeviceDeclaration;
|
||||
const problems = validateDeviceDeclaration(rubbish);
|
||||
assert.ok(problems.length >= 6, `expected a pile of problems, got ${problems.length}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the state a declaration implies", () => {
|
||||
it("carries a reading for each declared capability and no others", () => {
|
||||
const state = initialDeviceState(DESK_MIC, 1_770_000_000_000);
|
||||
assert.equal(state.powered, false);
|
||||
assert.equal(state.muted, false);
|
||||
assert.equal(state.gainDb, DEVICE_RANGES.gain.initial);
|
||||
assert.equal(state.levelDb, DEVICE_RANGES.level.initial);
|
||||
// A mic has no volume and no transport. `undefined` means "no such
|
||||
// reading", which is not the same as zero.
|
||||
assert.equal(state.volume, undefined);
|
||||
assert.equal(state.playing, undefined);
|
||||
assert.equal(state.observedAt, 1_770_000_000_000);
|
||||
assert.equal(state.synthetic, true);
|
||||
});
|
||||
|
||||
it("agrees with CAPABILITY_READING for every kind", () => {
|
||||
for (const kind of DEVICE_KINDS) {
|
||||
const declaration: DeviceDeclaration = {
|
||||
...DESK_MIC,
|
||||
kind,
|
||||
assetId: `tera:device.${kind}.desk`,
|
||||
capabilities: CANONICAL_CAPABILITIES[kind],
|
||||
};
|
||||
const state = initialDeviceState(declaration, 0);
|
||||
for (const capability of DEVICE_CAPABILITIES) {
|
||||
const field = CAPABILITY_READING[capability];
|
||||
const present = state[field] !== undefined;
|
||||
assert.equal(
|
||||
present,
|
||||
hasCapability(declaration, capability),
|
||||
`${kind}.${capability} → ${field}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is JSON too, because it is what the wire carries", () => {
|
||||
const state = initialDeviceState(DESK_SPEAKER, 12);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(state)), state);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalising a command", () => {
|
||||
it("accepts a supported op and clamps the knob into range", () => {
|
||||
const hot = normalizeDeviceCommand(DESK_MIC, { deviceId: DESK_MIC.id, op: "gain", value: 999 });
|
||||
assert.deepEqual(hot, { deviceId: DESK_MIC.id, op: "gain", value: DEVICE_RANGES.gain.max });
|
||||
|
||||
const quiet = normalizeDeviceCommand(DESK_SPEAKER, {
|
||||
deviceId: DESK_SPEAKER.id,
|
||||
op: "volume",
|
||||
value: -3,
|
||||
});
|
||||
assert.deepEqual(quiet, {
|
||||
deviceId: DESK_SPEAKER.id,
|
||||
op: "volume",
|
||||
value: DEVICE_RANGES.volume.min,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an op the device does not declare", () => {
|
||||
// A speaker has no gain stage here. The API must not apply this and the
|
||||
// panel must not offer it.
|
||||
assert.equal(
|
||||
normalizeDeviceCommand(DESK_SPEAKER, { deviceId: DESK_SPEAKER.id, op: "gain", value: 3 }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeDeviceCommand(DESK_MIC, { deviceId: DESK_MIC.id, op: "volume", value: 0.5 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a command addressed to another device", () => {
|
||||
assert.equal(
|
||||
normalizeDeviceCommand(DESK_MIC, { deviceId: DESK_SPEAKER.id, op: "power", value: true }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a value of the wrong type, including a missing one", () => {
|
||||
const bad = [
|
||||
{ deviceId: DESK_MIC.id, op: "power" },
|
||||
{ deviceId: DESK_MIC.id, op: "power", value: 1 },
|
||||
{ deviceId: DESK_MIC.id, op: "gain", value: true },
|
||||
{ deviceId: DESK_MIC.id, op: "gain", value: Number.NaN },
|
||||
{ deviceId: DESK_MIC.id, op: "level", value: -3 },
|
||||
{ deviceId: DESK_MIC.id, op: "explode", value: true },
|
||||
];
|
||||
for (const command of bad) {
|
||||
assert.equal(
|
||||
normalizeDeviceCommand(DESK_MIC, command as never),
|
||||
null,
|
||||
`${JSON.stringify(command)} should be refused`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("hands back a fresh object rather than the caller's", () => {
|
||||
const sent = { deviceId: DESK_MIC.id, op: "mute" as const, value: true };
|
||||
const normalised = normalizeDeviceCommand(DESK_MIC, sent);
|
||||
assert.notEqual(normalised, sent);
|
||||
assert.deepEqual(normalised, sent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the change signature", () => {
|
||||
it("ignores the clock and notices everything else", () => {
|
||||
const a: DeviceState = initialDeviceState(DESK_MIC, 1);
|
||||
const later: DeviceState = { ...a, observedAt: 9_999 };
|
||||
assert.equal(deviceStateSignature([a]), deviceStateSignature([later]));
|
||||
|
||||
for (const changed of [
|
||||
{ ...a, powered: true },
|
||||
{ ...a, muted: true },
|
||||
{ ...a, gainDb: 18 },
|
||||
{ ...a, levelDb: -12 },
|
||||
{ ...a, synthetic: false },
|
||||
]) {
|
||||
assert.notEqual(
|
||||
deviceStateSignature([a]),
|
||||
deviceStateSignature([changed]),
|
||||
`a change to ${JSON.stringify(changed)} should publish`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("separates devices, so two states cannot swap unnoticed", () => {
|
||||
const mic = initialDeviceState(DESK_MIC, 1);
|
||||
const speaker = initialDeviceState(DESK_SPEAKER, 1);
|
||||
assert.notEqual(deviceStateSignature([mic, speaker]), deviceStateSignature([speaker, mic]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* The simulator, held to the property the arena depends on.
|
||||
*
|
||||
* `studio-ops-v1` wraps **this module**, not a copy of it, and replays a
|
||||
* rollout by re-running it from a seed and a list of actions. That only works
|
||||
* if every number it produces is a pure function of (seed, declarations, steps,
|
||||
* commands) — so the assertions here are mostly equalities between two runs
|
||||
* rather than statements about any particular reading.
|
||||
*
|
||||
* The second half is the behaviour a viewer actually looks at: a level that
|
||||
* responds to who is at the desk, a mute that visibly stops it, a volume knob
|
||||
* the meter follows, and a device that never reports a reading its declaration
|
||||
* did not claim. Those are what make an instrument panel worth opening, and
|
||||
* they are what would silently rot without a test — a meter that stopped
|
||||
* responding would still be a meter.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { createSimulatedDevices } from "../../devices/sim.ts";
|
||||
import { DEVICE_RANGES, type DeviceDeclaration, type DeviceState } from "../../devices/types.ts";
|
||||
|
||||
const disclosure = "Simulated studio hardware. Demonstration data, never presence data.";
|
||||
|
||||
const MIC: DeviceDeclaration = {
|
||||
id: "mic-1",
|
||||
kind: "mic",
|
||||
label: "Desk mic",
|
||||
assetId: "tera:device.mic.desk",
|
||||
anchor: { levelId: "l1", propId: "mic-prop", seatId: "desk-01" },
|
||||
capabilities: ["power", "mute", "gain", "level"],
|
||||
provenance: "simulated",
|
||||
disclosure,
|
||||
};
|
||||
|
||||
const SPEAKER: DeviceDeclaration = {
|
||||
id: "speaker-1",
|
||||
kind: "speaker",
|
||||
label: "Monitor",
|
||||
assetId: "tera:device.speaker.desk",
|
||||
anchor: { levelId: "l1", propId: "speaker-prop" },
|
||||
capabilities: ["power", "volume", "playback", "level"],
|
||||
provenance: "simulated",
|
||||
disclosure,
|
||||
};
|
||||
|
||||
/** A speaker with no meter, to prove a reading follows the declaration. */
|
||||
const PLAIN_SPEAKER: DeviceDeclaration = {
|
||||
...SPEAKER,
|
||||
id: "speaker-2",
|
||||
capabilities: ["power", "volume", "playback"],
|
||||
};
|
||||
|
||||
const DECLARATIONS = [MIC, SPEAKER, PLAIN_SPEAKER];
|
||||
|
||||
function sim(seed = 7) {
|
||||
return createSimulatedDevices(DECLARATIONS, { seed, fixedStepSeconds: 0.1 });
|
||||
}
|
||||
|
||||
/** Run a simulator and collect every reading, as JSON, for comparison. */
|
||||
function run(simulator: ReturnType<typeof sim>, steps: number): string[] {
|
||||
const frames: string[] = [];
|
||||
for (let i = 0; i < steps; i += 1) {
|
||||
simulator.stepFixed();
|
||||
frames.push(JSON.stringify(simulator.current()));
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/** Average level over a stretch, which is what the eye reads off a meter. */
|
||||
function meanLevel(simulator: ReturnType<typeof sim>, id: string, steps: number): number {
|
||||
let total = 0;
|
||||
for (let i = 0; i < steps; i += 1) {
|
||||
simulator.stepFixed();
|
||||
total += simulator.current().find((s) => s.id === id)?.levelDb ?? 0;
|
||||
}
|
||||
return total / steps;
|
||||
}
|
||||
|
||||
function powerOn(simulator: ReturnType<typeof sim>, id: string): void {
|
||||
simulator.command({ deviceId: id, op: "power", value: true });
|
||||
}
|
||||
|
||||
describe("determinism", () => {
|
||||
it("produces identical sequences from the same seed", () => {
|
||||
const a = run(sim(7), 400);
|
||||
const b = run(sim(7), 400);
|
||||
assert.deepEqual(a, b);
|
||||
});
|
||||
|
||||
it("produces a different studio from a different seed", () => {
|
||||
// Powered up first, deliberately: a studio whose hardware is all switched
|
||||
// off reports the same floor whatever the seed, and a test that passed on
|
||||
// that would be asserting nothing.
|
||||
const a = sim(7);
|
||||
const b = sim(8);
|
||||
for (const simulator of [a, b]) {
|
||||
powerOn(simulator, "mic-1");
|
||||
simulator.setOccupancy(["desk-01"]);
|
||||
}
|
||||
assert.notDeepEqual(run(a, 200), run(b, 200));
|
||||
});
|
||||
|
||||
it("reads no clock — stepping is the only thing that moves time", () => {
|
||||
const simulator = sim();
|
||||
const before = JSON.stringify(simulator.current());
|
||||
// Nothing here advances anything, however long the process has been alive.
|
||||
for (let i = 0; i < 5; i += 1) JSON.stringify(simulator.current());
|
||||
assert.equal(JSON.stringify(simulator.current()), before);
|
||||
});
|
||||
|
||||
it("continues bit-exactly from a snapshot", () => {
|
||||
const original = sim(11);
|
||||
powerOn(original, "mic-1");
|
||||
original.setOccupancy(["desk-01"]);
|
||||
run(original, 137);
|
||||
|
||||
// Through JSON, because that is how a caller will have kept it: an arena
|
||||
// trace on disk, not a live object.
|
||||
const snapshot: unknown = JSON.parse(JSON.stringify(original.snapshot()));
|
||||
const resumed = createSimulatedDevices(DECLARATIONS, { seed: 999, fixedStepSeconds: 0.1 });
|
||||
resumed.restore(snapshot);
|
||||
|
||||
assert.deepEqual(run(resumed, 200), run(original, 200));
|
||||
});
|
||||
|
||||
it("keeps the snapshot free of anything JSON would lose", () => {
|
||||
const simulator = sim();
|
||||
run(simulator, 5);
|
||||
const snapshot = simulator.snapshot();
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(snapshot)), snapshot);
|
||||
});
|
||||
|
||||
it("hands back a snapshot the caller can hold while the run continues", () => {
|
||||
const simulator = sim();
|
||||
powerOn(simulator, "mic-1");
|
||||
const snapshot = JSON.stringify(simulator.snapshot());
|
||||
run(simulator, 50);
|
||||
// The snapshot is a copy, not a window onto live state.
|
||||
assert.equal(JSON.stringify(simulator.snapshot()) === snapshot, false);
|
||||
assert.deepEqual(JSON.parse(snapshot), JSON.parse(JSON.stringify(JSON.parse(snapshot))));
|
||||
});
|
||||
|
||||
it("ignores a snapshot it cannot read rather than half-applying it", () => {
|
||||
const simulator = sim();
|
||||
powerOn(simulator, "mic-1");
|
||||
run(simulator, 20);
|
||||
const before = JSON.stringify(simulator.current());
|
||||
for (const bad of [null, 42, "snapshot", {}, { v: 99 }, { v: 1, devices: "no" }]) {
|
||||
simulator.restore(bad);
|
||||
assert.equal(JSON.stringify(simulator.current()), before);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let a snapshot add devices this simulator was not built for", () => {
|
||||
const simulator = sim();
|
||||
const other = createSimulatedDevices(
|
||||
[...DECLARATIONS, { ...MIC, id: "mic-from-another-pack" }],
|
||||
{ seed: 7, fixedStepSeconds: 0.1 },
|
||||
);
|
||||
run(other, 10);
|
||||
simulator.restore(JSON.parse(JSON.stringify(other.snapshot())));
|
||||
assert.deepEqual(
|
||||
simulator.current().map((s) => s.id),
|
||||
["mic-1", "speaker-1", "speaker-2"],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not shift the random stream when a command is issued", () => {
|
||||
// A command must not consume randomness, or a replay that issues one at a
|
||||
// different step would diverge from the trace it is replaying.
|
||||
const commanded = sim(5);
|
||||
const quiet = sim(5);
|
||||
commanded.command({ deviceId: "mic-1", op: "gain", value: 12 });
|
||||
// The same gain it already had, so nothing about the state changed either.
|
||||
assert.deepEqual(run(commanded, 100), run(quiet, 100));
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a device reports", () => {
|
||||
it("reports one reading per declared capability and no others", () => {
|
||||
const states = sim().current();
|
||||
const mic = states.find((s) => s.id === "mic-1") as DeviceState;
|
||||
assert.equal(typeof mic.gainDb, "number");
|
||||
assert.equal(typeof mic.levelDb, "number");
|
||||
assert.equal(typeof mic.muted, "boolean");
|
||||
assert.equal(mic.volume, undefined);
|
||||
assert.equal(mic.playing, undefined);
|
||||
|
||||
const plain = states.find((s) => s.id === "speaker-2") as DeviceState;
|
||||
assert.equal(typeof plain.volume, "number");
|
||||
assert.equal(typeof plain.playing, "boolean");
|
||||
// Absent, not zero. `undefined` means "no such reading", and a panel draws
|
||||
// no meter for it — where a zero would draw a dead one.
|
||||
assert.equal(plain.levelDb, undefined);
|
||||
});
|
||||
|
||||
it("starts powered off, at rest, and says every reading is invented", () => {
|
||||
for (const state of sim().current()) {
|
||||
assert.equal(state.powered, false);
|
||||
assert.equal(state.synthetic, true);
|
||||
}
|
||||
const mic = sim().current()[0] as DeviceState;
|
||||
assert.equal(mic.gainDb, DEVICE_RANGES.gain.initial);
|
||||
assert.equal(mic.levelDb, DEVICE_RANGES.level.min);
|
||||
});
|
||||
|
||||
it("keeps every level inside the published range", () => {
|
||||
const simulator = sim();
|
||||
powerOn(simulator, "mic-1");
|
||||
powerOn(simulator, "speaker-1");
|
||||
simulator.command({ deviceId: "speaker-1", op: "playback", value: true });
|
||||
simulator.command({ deviceId: "mic-1", op: "gain", value: DEVICE_RANGES.gain.max });
|
||||
simulator.setOccupancy(["desk-01"]);
|
||||
for (let i = 0; i < 600; i += 1) {
|
||||
simulator.stepFixed();
|
||||
for (const state of simulator.current()) {
|
||||
if (state.levelDb === undefined) continue;
|
||||
assert.ok(state.levelDb >= DEVICE_RANGES.level.min, `${state.levelDb}`);
|
||||
assert.ok(state.levelDb <= DEVICE_RANGES.level.max, `${state.levelDb}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a microphone that responds to the room", () => {
|
||||
it("meters higher with somebody at the desk than with nobody", () => {
|
||||
const busy = sim(3);
|
||||
powerOn(busy, "mic-1");
|
||||
busy.setOccupancy(["desk-01"]);
|
||||
|
||||
const empty = sim(3);
|
||||
powerOn(empty, "mic-1");
|
||||
empty.setOccupancy([]);
|
||||
|
||||
// Averaged over twenty seconds, because a single frame of room tone can
|
||||
// peak above a single frame of speech — that is what makes it look real.
|
||||
assert.ok(meanLevel(busy, "mic-1", 200) > meanLevel(empty, "mic-1", 200) + 8);
|
||||
});
|
||||
|
||||
it("runs its own occupancy until somebody tells it otherwise", () => {
|
||||
// A deployment with no presence source — which is most of them, and every
|
||||
// anonymous viewer — still gets a studio that is alive rather than flat.
|
||||
const simulator = sim(23);
|
||||
powerOn(simulator, "mic-1");
|
||||
let peak = DEVICE_RANGES.level.min;
|
||||
for (let i = 0; i < 4000; i += 1) {
|
||||
simulator.stepFixed();
|
||||
peak = Math.max(peak, simulator.current()[0]?.levelDb ?? peak);
|
||||
}
|
||||
// Somewhere in that six and a half minutes, somebody sat down.
|
||||
assert.ok(peak > -30, `peak ${peak}`);
|
||||
});
|
||||
|
||||
it("drops to the floor when muted, and comes back when unmuted", () => {
|
||||
const simulator = sim(3);
|
||||
powerOn(simulator, "mic-1");
|
||||
simulator.setOccupancy(["desk-01"]);
|
||||
assert.ok(meanLevel(simulator, "mic-1", 100) > -40);
|
||||
|
||||
simulator.command({ deviceId: "mic-1", op: "mute", value: true });
|
||||
// Ten steps is a second — the release is slow on purpose, and a button that
|
||||
// took longer than that to visibly work would read as broken.
|
||||
for (let i = 0; i < 40; i += 1) simulator.stepFixed();
|
||||
assert.equal(simulator.current()[0]?.levelDb, DEVICE_RANGES.level.min);
|
||||
|
||||
simulator.command({ deviceId: "mic-1", op: "mute", value: false });
|
||||
assert.ok(meanLevel(simulator, "mic-1", 100) > -40);
|
||||
});
|
||||
|
||||
it("meters at the floor while powered off, whatever the room is doing", () => {
|
||||
const simulator = sim();
|
||||
simulator.setOccupancy(["desk-01"]);
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
simulator.stepFixed();
|
||||
assert.equal(simulator.current()[0]?.levelDb, DEVICE_RANGES.level.min);
|
||||
}
|
||||
});
|
||||
|
||||
it("moves the meter with the gain knob", () => {
|
||||
const quiet = sim(9);
|
||||
powerOn(quiet, "mic-1");
|
||||
quiet.setOccupancy(["desk-01"]);
|
||||
quiet.command({ deviceId: "mic-1", op: "gain", value: 0 });
|
||||
|
||||
const loud = sim(9);
|
||||
powerOn(loud, "mic-1");
|
||||
loud.setOccupancy(["desk-01"]);
|
||||
loud.command({ deviceId: "mic-1", op: "gain", value: 24 });
|
||||
|
||||
assert.ok(meanLevel(loud, "mic-1", 200) > meanLevel(quiet, "mic-1", 200) + 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a speaker with something playing", () => {
|
||||
it("meters only while it is powered and playing", () => {
|
||||
const simulator = sim(4);
|
||||
powerOn(simulator, "speaker-1");
|
||||
for (let i = 0; i < 40; i += 1) simulator.stepFixed();
|
||||
assert.equal(simulator.current()[1]?.levelDb, DEVICE_RANGES.level.min);
|
||||
|
||||
simulator.command({ deviceId: "speaker-1", op: "playback", value: true });
|
||||
assert.ok(meanLevel(simulator, "speaker-1", 100) > -40);
|
||||
});
|
||||
|
||||
it("follows the volume knob", () => {
|
||||
const loud = sim(4);
|
||||
powerOn(loud, "speaker-1");
|
||||
loud.command({ deviceId: "speaker-1", op: "playback", value: true });
|
||||
loud.command({ deviceId: "speaker-1", op: "volume", value: 1 });
|
||||
|
||||
const quiet = sim(4);
|
||||
powerOn(quiet, "speaker-1");
|
||||
quiet.command({ deviceId: "speaker-1", op: "playback", value: true });
|
||||
quiet.command({ deviceId: "speaker-1", op: "volume", value: 0.1 });
|
||||
|
||||
// 20·log10(0.1) is −20 dB, so the gap is real and it is arithmetic rather
|
||||
// than a taste: halving the knob drops the meter about 6 dB.
|
||||
assert.ok(meanLevel(loud, "speaker-1", 200) > meanLevel(quiet, "speaker-1", 200) + 12);
|
||||
});
|
||||
|
||||
it("stops playing when it is switched off, because no real box does otherwise", () => {
|
||||
const simulator = sim();
|
||||
powerOn(simulator, "speaker-1");
|
||||
simulator.command({ deviceId: "speaker-1", op: "playback", value: true });
|
||||
assert.equal(simulator.current()[1]?.playing, true);
|
||||
|
||||
simulator.command({ deviceId: "speaker-1", op: "power", value: false });
|
||||
assert.equal(simulator.current()[1]?.playing, false);
|
||||
});
|
||||
|
||||
it("will not start playing on a speaker that is switched off", () => {
|
||||
const simulator = sim();
|
||||
simulator.command({ deviceId: "speaker-1", op: "playback", value: true });
|
||||
assert.equal(simulator.current()[1]?.playing, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commands it refuses", () => {
|
||||
it("ignores an id it does not carry", () => {
|
||||
const simulator = sim();
|
||||
const before = JSON.stringify(simulator.current());
|
||||
simulator.command({ deviceId: "not-here", op: "power", value: true });
|
||||
assert.equal(JSON.stringify(simulator.current()), before);
|
||||
});
|
||||
|
||||
it("ignores an op the declaration never declared", () => {
|
||||
const simulator = sim();
|
||||
// `speaker-2` declares power, volume and playback. Gain is a mic's.
|
||||
simulator.command({ deviceId: "speaker-2", op: "gain", value: 20 });
|
||||
assert.equal(simulator.current()[2]?.gainDb, undefined);
|
||||
});
|
||||
|
||||
it("ignores a value of the wrong type", () => {
|
||||
const simulator = sim();
|
||||
simulator.command({ deviceId: "mic-1", op: "power", value: 1 as unknown as boolean });
|
||||
assert.equal(simulator.current()[0]?.powered, false);
|
||||
simulator.command({ deviceId: "mic-1", op: "gain", value: true as unknown as number });
|
||||
assert.equal(simulator.current()[0]?.gainDb, DEVICE_RANGES.gain.initial);
|
||||
});
|
||||
|
||||
it("clamps a number into range rather than refusing it", () => {
|
||||
const simulator = sim();
|
||||
simulator.command({ deviceId: "mic-1", op: "gain", value: 9000 });
|
||||
assert.equal(simulator.current()[0]?.gainDb, DEVICE_RANGES.gain.max);
|
||||
simulator.command({ deviceId: "speaker-1", op: "volume", value: -3 });
|
||||
assert.equal(simulator.current()[1]?.volume, DEVICE_RANGES.volume.min);
|
||||
});
|
||||
|
||||
it("cannot be handed a declaration list it then mutates under itself", () => {
|
||||
const declarations = [{ ...MIC }];
|
||||
const simulator = createSimulatedDevices(declarations, { seed: 1, fixedStepSeconds: 0.1 });
|
||||
declarations.length = 0;
|
||||
simulator.command({ deviceId: "mic-1", op: "power", value: true });
|
||||
assert.equal(simulator.current()[0]?.powered, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* The boundary between this bundle and a deployment, from the browser's side.
|
||||
*
|
||||
* Three things meet here and all three are easy to get wrong quietly:
|
||||
*
|
||||
* 1. **The wire bodies are JSON and stay JSON.** `src/server/wire.ts` compiles
|
||||
* to nothing, so nothing in it can be checked by running it — but the shapes
|
||||
* it declares are the shapes both sides build, and a body that does not
|
||||
* survive `JSON.parse(JSON.stringify(x))` unchanged is a body the two sides
|
||||
* will disagree about. The device bodies are new and carry the first
|
||||
* optional-reading type on the wire, which is exactly where a `undefined`
|
||||
* versus `null` mistake hides.
|
||||
* 2. **`/health` is read defensively.** `sources.devices` and `degraded` are
|
||||
* newer than servers this client will meet, and a missing field has to fall
|
||||
* the safe way rather than throw or be assumed.
|
||||
* 3. **The seam chooses a strategy and says which.** An anonymous visitor and a
|
||||
* zero-config clone both get the local simulator, alive and labelled; a
|
||||
* signed-in viewer on a configured box gets the deployment's readings. What
|
||||
* must never happen is a studio that looks live and is not, or a control
|
||||
* that appears to work and changes nothing anybody else can see.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { resolveAccess } from "../../access.ts";
|
||||
import { createTeraClient } from "../../adapters/http.ts";
|
||||
import { createDeviceSource, createNullDeviceSource } from "../../devices/adapter.ts";
|
||||
import { initialDeviceState, type DeviceDeclaration, type DeviceState } from "../../devices/types.ts";
|
||||
import type {
|
||||
DeviceCommandBody,
|
||||
DeviceCommandResultBody,
|
||||
DevicesBody,
|
||||
HealthBody,
|
||||
} from "../../server/wire.ts";
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { location: { origin: "https://office.example.test" } },
|
||||
});
|
||||
|
||||
const DECLARATION: DeviceDeclaration = {
|
||||
id: "mic-1",
|
||||
kind: "mic",
|
||||
label: "Desk mic",
|
||||
assetId: "tera:device.mic.desk",
|
||||
anchor: { levelId: "l1", propId: "mic-prop", seatId: "desk-01" },
|
||||
capabilities: ["power", "mute", "gain", "level"],
|
||||
provenance: "simulated",
|
||||
disclosure: "Simulated studio hardware. Demonstration data, never presence data.",
|
||||
};
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function deployment(routes: Record<string, () => Response>): typeof fetch {
|
||||
return (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
for (const [path, answer] of Object.entries(routes)) {
|
||||
if (url.includes(path)) return answer();
|
||||
}
|
||||
throw new TypeError("Failed to fetch");
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
const health = (over: Partial<HealthBody> = {}): HealthBody => ({
|
||||
ok: true,
|
||||
service: "tera-api",
|
||||
version: "0.1.0",
|
||||
uptimeSeconds: 1,
|
||||
sources: { weather: "nws", flights: "adsb", satellites: "none", markers: "none", devices: "sim" },
|
||||
auth: { mode: "none", entryUrl: null },
|
||||
regions: [{ id: "sf", lat: 37.77, lng: -122.42, radiusKm: 120 }],
|
||||
degraded: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("the bodies are JSON, and stay JSON", () => {
|
||||
it("round-trips a devices body unchanged", () => {
|
||||
const body: DevicesBody = {
|
||||
officeId: "hq",
|
||||
devices: [
|
||||
{ id: "mic-1", kind: "mic", powered: true, muted: false, gainDb: 12, levelDb: -22.4, observedAt: 17, synthetic: true },
|
||||
{ id: "spk-1", kind: "speaker", powered: false, volume: 0.35, playing: false, observedAt: 17, synthetic: true },
|
||||
],
|
||||
observedAt: 17,
|
||||
source: "sim",
|
||||
synthetic: true,
|
||||
ttlSeconds: 5,
|
||||
};
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(body)), body);
|
||||
});
|
||||
|
||||
it("round-trips a command and its result unchanged", () => {
|
||||
const command: DeviceCommandBody = { command: { deviceId: "mic-1", op: "gain", value: 18 } };
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(command)), command);
|
||||
|
||||
const result: DeviceCommandResultBody = {
|
||||
officeId: "hq",
|
||||
device: initialDeviceState(DECLARATION, 17),
|
||||
observedAt: 17,
|
||||
};
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(result)), result);
|
||||
});
|
||||
|
||||
it("omits a reading a device does not have, rather than nulling it", () => {
|
||||
// The difference matters on the wire as well as in the panel: `null` would
|
||||
// survive JSON as a *reading of zero information* that a consumer has to
|
||||
// special-case, while an absent key is already the thing every consumer
|
||||
// checks for. `initialDeviceState` is where the rule is implemented.
|
||||
const state = initialDeviceState({ ...DECLARATION, capabilities: ["power"] }, 1);
|
||||
const round = JSON.parse(JSON.stringify(state)) as DeviceState;
|
||||
assert.equal("levelDb" in round, false);
|
||||
assert.equal("muted" in round, false);
|
||||
assert.equal(round.powered, false);
|
||||
assert.equal(round.synthetic, true);
|
||||
});
|
||||
|
||||
it("declares regions and a device source on the health body", () => {
|
||||
// A compile-time assertion made at runtime: `health()` above is typed as a
|
||||
// `HealthBody`, so this file would not build if either field left the type.
|
||||
const body = health();
|
||||
assert.equal(body.sources.devices, "sim");
|
||||
assert.equal(body.regions[0]?.id, "sf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the browser learns from /health", () => {
|
||||
it("reports the device source and the demotions to the interface", async () => {
|
||||
const access = await resolveAccess(
|
||||
deployment({
|
||||
"/health": () =>
|
||||
json(
|
||||
health({
|
||||
degraded: ["TERA_WEATHER_SOURCE=nws needs TERA_WEATHER_CONTACT.", "and another"],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
assert.equal(access.feeds?.devices, true);
|
||||
// Built, served, and — until now — read by nobody. This is the whole point
|
||||
// of the field: "why is the weather always clear" answers itself.
|
||||
assert.deepEqual(access.degraded, [
|
||||
"TERA_WEATHER_SOURCE=nws needs TERA_WEATHER_CONTACT.",
|
||||
"and another",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls the safe way when the server is older than this client", async () => {
|
||||
const older = health();
|
||||
delete (older.sources as Partial<HealthBody["sources"]>).devices;
|
||||
delete (older as Partial<HealthBody>).degraded;
|
||||
const access = await resolveAccess(deployment({ "/health": () => json(older) }));
|
||||
// No field means no feed and no request. A poll against a box that never
|
||||
// heard of the route is a 404 per TTL per tab, forever.
|
||||
assert.equal(access.feeds?.devices, false);
|
||||
assert.deepEqual(access.degraded, []);
|
||||
});
|
||||
|
||||
it("drops anything in degraded that is not a sentence", async () => {
|
||||
const access = await resolveAccess(
|
||||
deployment({ "/health": () => json(health({ degraded: [1, null, { a: 1 }, "real"] as never })) }),
|
||||
);
|
||||
// `[object Object]` in front of an operator who is already looking at this
|
||||
// list because something is wrong.
|
||||
assert.deepEqual(access.degraded, ["real"]);
|
||||
});
|
||||
|
||||
it("has nothing to report about a deployment that does not exist", async () => {
|
||||
const access = await resolveAccess(deployment({}));
|
||||
assert.equal(access.tier, "member");
|
||||
assert.equal(access.feeds, null);
|
||||
assert.deepEqual(access.degraded, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a zero-config clone", () => {
|
||||
it("gets an empty device feed that does not claim to be live", async () => {
|
||||
const client = createTeraClient({ fetch: deployment({}) });
|
||||
const feed = await client.devices("hq");
|
||||
assert.deepEqual(feed.value, []);
|
||||
assert.equal(feed.live, false);
|
||||
assert.equal(feed.source, "none");
|
||||
});
|
||||
|
||||
it("gets a studio that is alive anyway, from the simulator in this tab", () => {
|
||||
const source = createDeviceSource({ declarations: [DECLARATION], client: null });
|
||||
source.command({ deviceId: "mic-1", op: "power", value: true });
|
||||
let peak = -60;
|
||||
for (let i = 0; i < 400; i += 1) {
|
||||
source.tick(0.1);
|
||||
peak = Math.max(peak, source.current().states[0]?.levelDb ?? -60);
|
||||
}
|
||||
assert.ok(peak > -58, `peak ${peak}`);
|
||||
// Alive, and honest about it: `live` is "a deployment answered" and
|
||||
// `synthetic` is "nobody observed this", and both are what the panel shows.
|
||||
assert.equal(source.current().live, false);
|
||||
assert.equal(source.current().synthetic, true);
|
||||
assert.equal(source.current().source, "sim");
|
||||
source.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the seam", () => {
|
||||
it("is nothing at all for an office that declares no devices", () => {
|
||||
const source = createDeviceSource({ declarations: [] });
|
||||
assert.deepEqual(source.current().states, []);
|
||||
assert.equal(source.current().source, "none");
|
||||
source.tick(1);
|
||||
assert.deepEqual(source.current().states, []);
|
||||
});
|
||||
|
||||
it("reads the API when the deployment has a source and the viewer may read it", async (t) => {
|
||||
const body: DevicesBody = {
|
||||
officeId: "hq",
|
||||
devices: [{ id: "mic-1", kind: "mic", powered: true, observedAt: 1, synthetic: true }],
|
||||
observedAt: 1,
|
||||
source: "sim",
|
||||
synthetic: true,
|
||||
ttlSeconds: 5,
|
||||
};
|
||||
const client = createTeraClient({ fetch: deployment({ "/devices": () => json(body) }) });
|
||||
const readings: unknown[] = [];
|
||||
const source = createDeviceSource({
|
||||
declarations: [DECLARATION],
|
||||
client,
|
||||
officeId: "hq",
|
||||
serverHasDevices: true,
|
||||
onReading: (reading) => readings.push(reading),
|
||||
});
|
||||
t.after(() => source.stop());
|
||||
|
||||
// Before anything lands, the panel has instruments to draw rather than an
|
||||
// empty box that would flicker into existence a poll later.
|
||||
assert.equal(source.current().states.length, 1);
|
||||
assert.equal(source.current().live, false);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(source.current().live, true);
|
||||
assert.equal(source.current().states[0]?.powered, true);
|
||||
assert.equal(readings.length, 1);
|
||||
|
||||
// The server's clock, not ours: ticking must not advance a second set of
|
||||
// numbers over the top of a real feed.
|
||||
const before = JSON.stringify(source.current().states);
|
||||
source.tick(5);
|
||||
assert.equal(JSON.stringify(source.current().states), before);
|
||||
});
|
||||
|
||||
it("skips the API entirely when /health said this box has no devices", async (t) => {
|
||||
let asked = 0;
|
||||
const client = createTeraClient({
|
||||
fetch: deployment({
|
||||
"/devices": () => {
|
||||
asked += 1;
|
||||
return json({});
|
||||
},
|
||||
}),
|
||||
});
|
||||
const source = createDeviceSource({
|
||||
declarations: [DECLARATION],
|
||||
client,
|
||||
officeId: "hq",
|
||||
serverHasDevices: false,
|
||||
});
|
||||
t.after(() => source.stop());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(asked, 0);
|
||||
assert.equal(source.current().source, "sim");
|
||||
});
|
||||
|
||||
it("reports a refused command rather than quietly applying it locally", async (t) => {
|
||||
// The one asymmetry between the two strategies, and it is deliberate: on a
|
||||
// real deployment a control that appears to work and changes nothing
|
||||
// anybody else can see is worse than one that says no.
|
||||
const client = createTeraClient({ fetch: deployment({}) });
|
||||
const source = createDeviceSource({
|
||||
declarations: [DECLARATION],
|
||||
client,
|
||||
officeId: "hq",
|
||||
serverHasDevices: true,
|
||||
});
|
||||
// Registered before the assertions, not after them: a watch left running by
|
||||
// a failed assertion keeps its back-off timer alive and hangs the runner
|
||||
// long after the failure it is hiding.
|
||||
t.after(() => source.stop());
|
||||
|
||||
const result = await source.command({ deviceId: "mic-1", op: "power", value: true });
|
||||
assert.equal(result, null);
|
||||
// The instruments are still there, at rest and not live — a deployment that
|
||||
// has stopped answering is not an office that has no hardware in it.
|
||||
assert.equal(source.current().states.length, 1);
|
||||
assert.equal(source.current().states[0]?.powered, false);
|
||||
assert.equal(source.current().live, false);
|
||||
});
|
||||
|
||||
it("refuses a command the declaration does not allow, before spending a request", async (t) => {
|
||||
let asked = 0;
|
||||
const client = createTeraClient({
|
||||
fetch: deployment({
|
||||
"/command": () => {
|
||||
asked += 1;
|
||||
return json({});
|
||||
},
|
||||
}),
|
||||
});
|
||||
const source = createDeviceSource({
|
||||
declarations: [DECLARATION],
|
||||
client,
|
||||
officeId: "hq",
|
||||
serverHasDevices: true,
|
||||
});
|
||||
t.after(() => source.stop());
|
||||
assert.equal(await source.command({ deviceId: "mic-1", op: "volume", value: 0.5 }), null);
|
||||
assert.equal(await source.command({ deviceId: "somebody-elses", op: "power", value: true }), null);
|
||||
assert.equal(asked, 0);
|
||||
});
|
||||
|
||||
it("applies a command locally, and openly, on the simulated strategy", async () => {
|
||||
const source = createDeviceSource({ declarations: [DECLARATION], client: null });
|
||||
const state = await source.command({ deviceId: "mic-1", op: "power", value: true });
|
||||
assert.equal(state?.powered, true);
|
||||
assert.equal(state?.synthetic, true);
|
||||
source.stop();
|
||||
});
|
||||
|
||||
it("publishes only when a reading a viewer could see has changed", () => {
|
||||
const readings: unknown[] = [];
|
||||
const source = createDeviceSource({
|
||||
declarations: [DECLARATION],
|
||||
client: null,
|
||||
onReading: (reading) => readings.push(reading),
|
||||
});
|
||||
// Powered off, so every step produces the same floor reading and only
|
||||
// `observedAt` moves — which is deliberately not in the signature.
|
||||
for (let i = 0; i < 50; i += 1) source.tick(0.1);
|
||||
assert.equal(readings.length, 0);
|
||||
|
||||
void source.command({ deviceId: "mic-1", op: "power", value: true });
|
||||
source.tick(0.1);
|
||||
assert.ok(readings.length > 0);
|
||||
source.stop();
|
||||
});
|
||||
|
||||
it("does no work at all once stopped", async () => {
|
||||
const source = createDeviceSource({ declarations: [DECLARATION], client: null });
|
||||
void source.command({ deviceId: "mic-1", op: "power", value: true });
|
||||
for (let i = 0; i < 20; i += 1) source.tick(0.1);
|
||||
source.stop();
|
||||
const frozen = JSON.stringify(source.current().states);
|
||||
for (let i = 0; i < 20; i += 1) source.tick(0.1);
|
||||
assert.equal(JSON.stringify(source.current().states), frozen);
|
||||
assert.equal(await source.command({ deviceId: "mic-1", op: "mute", value: true }), null);
|
||||
});
|
||||
|
||||
it("survives a tab that was backgrounded for ten minutes", () => {
|
||||
const source = createDeviceSource({ declarations: [DECLARATION], client: null });
|
||||
void source.command({ deviceId: "mic-1", op: "power", value: true });
|
||||
// Six hundred seconds of `dt` would be six thousand steps in one frame.
|
||||
source.tick(600);
|
||||
// Still a valid reading, and it arrived without a hitch.
|
||||
const level = source.current().states[0]?.levelDb ?? 0;
|
||||
assert.ok(level >= -60 && level <= 0, `${level}`);
|
||||
});
|
||||
|
||||
it("has a null source for anything that genuinely has nothing to say", () => {
|
||||
const source = createNullDeviceSource();
|
||||
assert.deepEqual(source.current().states, []);
|
||||
assert.equal(source.current().source, "none");
|
||||
assert.equal(source.current().synthetic, true);
|
||||
source.tick(1);
|
||||
source.refresh();
|
||||
source.setOccupancy(["desk-01"]);
|
||||
source.stop();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,16 @@ describe("freeway world v2", () => {
|
||||
));
|
||||
});
|
||||
|
||||
/**
|
||||
* The counts below changed when `structures.ts` started merging per material.
|
||||
*
|
||||
* They used to read 8 lane-dash meshes, 4 median barriers and 4 guardrails —
|
||||
* two corridors' worth of two sides each — and every one of those was a draw
|
||||
* call. Merging collapses each class to one mesh, so counting meshes no longer
|
||||
* says anything about how many corridors were built. The assertions therefore
|
||||
* moved down a level: **one** mesh per class, and its vertex count proves all
|
||||
* four spans went into it, which is the fact the old count was standing in for.
|
||||
*/
|
||||
it("renders separated decks, markings, barriers, signs, and batched scenery", () => {
|
||||
const world = {
|
||||
city: CALIFORNIA_CITY,
|
||||
@@ -42,11 +52,57 @@ describe("freeway world v2", () => {
|
||||
assert.equal(group.userData.planSeed, 101_005);
|
||||
const names: string[] = [];
|
||||
group.traverse((object) => names.push(object.name));
|
||||
assert.equal(names.filter((name) => name === "freeway:lane-dashes").length, 8);
|
||||
assert.equal(names.filter((name) => name === "freeway:median-barrier").length, 4);
|
||||
assert.equal(names.filter((name) => name === "freeway:outer-guardrail").length, 4);
|
||||
assert.equal(names.filter((name) => name === "freeway:lane-dashes").length, 1);
|
||||
assert.equal(names.filter((name) => name === "freeway:median-barrier").length, 1);
|
||||
assert.equal(names.filter((name) => name === "freeway:outer-guardrail").length, 1);
|
||||
assert.ok(names.includes("freeway:sign:101"));
|
||||
assert.ok(names.includes("freeway:sign:5"));
|
||||
assert.ok(group.children.filter((child) => child instanceof THREE.InstancedMesh).length >= 7);
|
||||
assert.ok(group.children.filter((child) => child instanceof THREE.InstancedMesh).length >= 6);
|
||||
|
||||
// Four spans really did go into each of those single meshes. A merge that
|
||||
// silently dropped a bucket — mismatched attributes are the way that
|
||||
// happens — would leave one span's worth of vertices here and look fine.
|
||||
const meshFor = (name: string): THREE.Mesh => {
|
||||
const found = group.children.find(
|
||||
(child): child is THREE.Mesh => child instanceof THREE.Mesh && child.name === name,
|
||||
);
|
||||
assert.ok(found, `expected a merged mesh named ${name}`);
|
||||
return found;
|
||||
};
|
||||
const dashVertices = meshFor("freeway:lane-dashes").geometry.getAttribute("position").count;
|
||||
const guardVertices = meshFor("freeway:outer-guardrail").geometry.getAttribute("position").count;
|
||||
assert.ok(dashVertices > 400, `lane dashes merged to only ${dashVertices} vertices`);
|
||||
assert.ok(guardVertices > 400, `guardrails merged to only ${guardVertices} vertices`);
|
||||
});
|
||||
|
||||
it("shares one material per colour instead of one per ribbon", () => {
|
||||
const world = {
|
||||
city: CALIFORNIA_CITY,
|
||||
project(lat: number, lng: number): [number, number] {
|
||||
return [(lng + 121) * 20, -(lat - 36) * 20];
|
||||
},
|
||||
groundAt(): number {
|
||||
return 0;
|
||||
},
|
||||
} as unknown as World;
|
||||
const group = createFreewayWorld(world, CALIFORNIA_TRANSPORT);
|
||||
|
||||
let drawCalls = 0;
|
||||
const materials = new Set<THREE.Material>();
|
||||
group.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
drawCalls += 1;
|
||||
const material = object.material;
|
||||
if (Array.isArray(material)) for (const m of material) materials.add(m);
|
||||
else materials.add(material);
|
||||
});
|
||||
|
||||
// Was 59 meshes: eighteen road ribbons, eight dash strips, four guardrails,
|
||||
// four medians, nine two-mesh sign groups and seven instanced batches, each
|
||||
// ribbon carrying a material minted on the spot. Twenty-five is generous
|
||||
// headroom over the twenty it actually emits, and it fails loudly if anyone
|
||||
// reintroduces a `new THREE.Mesh` inside the corridor loop.
|
||||
assert.ok(drawCalls <= 25, `freeway world emits ${drawCalls} draw calls`);
|
||||
assert.ok(materials.size <= 20, `freeway world holds ${materials.size} materials`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* The public package surface, and the one promise it makes.
|
||||
*
|
||||
* `src/index.ts` is what `package.json`'s `"."` export points at, so it is the
|
||||
* thing a verifier, a training harness or a Node service `import`s. The promise
|
||||
* is that everything reachable from it runs **with no renderer, no DOM and no
|
||||
* network** — because the consumers who want the arena and the simulators are
|
||||
* precisely the consumers who have none of the three.
|
||||
*
|
||||
* That is not a property you can assert by importing the file and seeing it
|
||||
* work: `import * as THREE from "three"` succeeds perfectly well under Node and
|
||||
* costs a consumer half a megabyte for nothing. So this walks the static import
|
||||
* graph and reads it. A single `from "three"` anywhere in the closure fails,
|
||||
* and names the file and the chain that reached it, which is the only form of
|
||||
* this failure anybody can act on.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import * as barrel from "../../index.ts";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const ENTRY = path.join(ROOT, "src/index.ts");
|
||||
|
||||
/**
|
||||
* Every relative specifier in a file, `import` and `export` alike.
|
||||
*
|
||||
* `export * from "./x.ts"` is the form the barrel itself is written in, and a
|
||||
* matcher that only looked at `import` would walk none of it.
|
||||
*/
|
||||
const SPECIFIER = /(?:^|\n)\s*(?:import|export)\b[^;\n]*?from\s+["']([^"']+)["']/g;
|
||||
/** `await import("./x.ts")`, which is how a renderer would sneak in lazily. */
|
||||
const DYNAMIC = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
|
||||
|
||||
/** Walk the graph from `entry`, returning every file reached and how. */
|
||||
function closure(entry: string): Map<string, string[]> {
|
||||
const reached = new Map<string, string[]>([[entry, []]]);
|
||||
const queue = [entry];
|
||||
while (queue.length > 0) {
|
||||
const file = queue.shift() as string;
|
||||
const source = readFileSync(file, "utf8");
|
||||
const chain = reached.get(file) ?? [];
|
||||
for (const pattern of [SPECIFIER, DYNAMIC]) {
|
||||
pattern.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
const specifier = match[1];
|
||||
if (specifier === undefined || !specifier.startsWith(".")) continue;
|
||||
const resolved = path.resolve(path.dirname(file), specifier);
|
||||
if (reached.has(resolved)) continue;
|
||||
reached.set(resolved, [...chain, path.relative(ROOT, file)]);
|
||||
queue.push(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
return reached;
|
||||
}
|
||||
|
||||
/** Bare specifiers a module in the closure is allowed to depend on. */
|
||||
const ALLOWED_PACKAGES = new Set<string>([]);
|
||||
|
||||
test("the barrel exports the renderer-independent surfaces this build added", () => {
|
||||
for (const name of [
|
||||
"ARENA_ENVIRONMENTS",
|
||||
"flattenObservation",
|
||||
"structureAction",
|
||||
"observationWidth",
|
||||
"rollout",
|
||||
"createSimulatedDevices",
|
||||
"createSimulatedVehicleTelemetry",
|
||||
"normalizeDeviceCommand",
|
||||
"normalizeVehicleTelemetryCommand",
|
||||
"exteriorVehicleAppearance",
|
||||
"DEVICE_RANGES",
|
||||
"Plan",
|
||||
]) {
|
||||
assert.ok(
|
||||
name in barrel,
|
||||
`src/index.ts no longer exports ${name}; a consumer's import just broke`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("nothing reachable from the barrel imports three.js", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const [file, chain] of closure(ENTRY)) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
if (/from\s+["']three(?:\/|["'])/.test(source) || /import\s*\(\s*["']three/.test(source)) {
|
||||
offenders.push(`${path.relative(ROOT, file)} (via ${chain.join(" → ") || "the barrel itself"})`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
"three.js is on the public package surface. The render layer for a simulation " +
|
||||
"(interiors/devices.ts, engine/officeExterior.ts, interiors/officeScene.ts) is " +
|
||||
"never exported; the state machine behind it is.",
|
||||
);
|
||||
});
|
||||
|
||||
test("nothing reachable from the barrel takes a bare dependency at all", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const [file] of closure(ENTRY)) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
for (const pattern of [SPECIFIER, DYNAMIC]) {
|
||||
pattern.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
const specifier = match[1];
|
||||
if (specifier === undefined) continue;
|
||||
if (specifier.startsWith(".") || specifier.startsWith("node:")) continue;
|
||||
if (ALLOWED_PACKAGES.has(specifier)) continue;
|
||||
offenders.push(`${path.relative(ROOT, file)} → ${specifier}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, [], "an unvetted runtime dependency reached the package surface");
|
||||
});
|
||||
|
||||
test("nothing reachable from the barrel reaches for a browser global", () => {
|
||||
// Read as source rather than executed, because a `document` reference inside a
|
||||
// branch nobody takes is still a module that cannot be loaded in a worker
|
||||
// whose global object does not have one.
|
||||
const globals = /\b(?:document|localStorage|sessionStorage|navigator|requestAnimationFrame)\b/;
|
||||
const offenders: string[] = [];
|
||||
for (const [file] of closure(ENTRY)) {
|
||||
const source = readFileSync(file, "utf8")
|
||||
// Comments talk about the DOM constantly and correctly; only code counts.
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|\n)\s*\/\/[^\n]*/g, "$1");
|
||||
if (globals.test(source)) offenders.push(path.relative(ROOT, file));
|
||||
}
|
||||
assert.deepEqual(offenders, [], "a DOM global is reachable from the package surface");
|
||||
});
|
||||
@@ -0,0 +1,652 @@
|
||||
/**
|
||||
* The wiring seams, tested where they can actually be exercised.
|
||||
*
|
||||
* `createOfficeScene` is the one scene in this repo that can be built under
|
||||
* `node --test`: it needs a DOM element and a `window` for `OrbitControls` and
|
||||
* `matchMedia`, and nothing else — no WebGL context, no Worker, no heightfield.
|
||||
* So the office half of the integration surface is tested for real, against the
|
||||
* shipped packs, rather than by reading the source and hoping.
|
||||
*
|
||||
* The city half is not, and the asymmetry is honest rather than lazy:
|
||||
* `createScene` awaits a terrain Worker and takes a live `Stage`, so the only
|
||||
* place it can be exercised is a browser. `scripts/ui-smoke.mjs` is where that
|
||||
* happens, and the two source-level assertions at the bottom of this file are
|
||||
* the belt to that brace — they fail if the seam is *deleted*, which is the
|
||||
* failure a browser test is slowest to tell you about.
|
||||
*
|
||||
* The renderer is a fake of the same specific kind `src/test/render` uses:
|
||||
* `PMREMGenerator` never touches WebGL directly, so stubbing `render` and
|
||||
* `setRenderTarget` runs the real generator, the real target allocation and the
|
||||
* real blur chain and leaves only the pixels unwritten.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
|
||||
// ---- The environment the browser supplies and Node does not ----------------
|
||||
//
|
||||
// Installed before the modules are imported, because `scenekit.ts` reads
|
||||
// `window.matchMedia` at construction and `officeScene.ts` reaches it through a
|
||||
// static import chain. The dynamic imports below are what let this run first.
|
||||
|
||||
(globalThis as unknown as { window: unknown }).window = {
|
||||
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||||
innerWidth: 1_200,
|
||||
innerHeight: 800,
|
||||
devicePixelRatio: 1,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
|
||||
const { createOfficeScene } = await import("../../interiors/officeScene.ts");
|
||||
const { createEnvironmentRig } = await import("../../engine/environmentRig.ts");
|
||||
const LUMBRIDGE_HQ = (await import("../../offices/lumbridge-hq.ts")).default;
|
||||
const MATEO_COURT = (await import("../../offices/mateo-court.ts")).default;
|
||||
const { initialDeviceState } = await import("../../devices/types.ts");
|
||||
const { createSimulatedVehicleTelemetry } = await import("../../transport/vehicleTelemetry.ts");
|
||||
|
||||
type Office = typeof LUMBRIDGE_HQ;
|
||||
|
||||
const PACKS: readonly (readonly [string, Office])[] = [
|
||||
["lumbridge-hq", LUMBRIDGE_HQ],
|
||||
["mateo-court", MATEO_COURT],
|
||||
];
|
||||
|
||||
// ---- Fakes -----------------------------------------------------------------
|
||||
|
||||
/** Everything `OrbitControls` and `SceneKit` touch on the canvas, and no more. */
|
||||
function fakeDom(): HTMLElement {
|
||||
return {
|
||||
style: {},
|
||||
clientWidth: 1_200,
|
||||
clientHeight: 800,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
setPointerCapture() {},
|
||||
releasePointerCapture() {},
|
||||
getBoundingClientRect: () => ({
|
||||
left: 0, top: 0, width: 1_200, height: 800, right: 1_200, bottom: 800, x: 0, y: 0,
|
||||
}),
|
||||
getRootNode: () => ({ addEventListener() {}, removeEventListener() {} }),
|
||||
ownerDocument: { addEventListener() {}, removeEventListener() {} },
|
||||
} as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
interface FakeRenderer {
|
||||
renders: number;
|
||||
targets: Set<THREE.WebGLRenderTarget>;
|
||||
as(): THREE.WebGLRenderer;
|
||||
}
|
||||
|
||||
/** A renderer that allocates render targets and never draws to them. */
|
||||
function fakeRenderer(): FakeRenderer {
|
||||
const targets = new Set<THREE.WebGLRenderTarget>();
|
||||
const state: FakeRenderer = {
|
||||
renders: 0,
|
||||
targets,
|
||||
as: () => stub as unknown as THREE.WebGLRenderer,
|
||||
};
|
||||
const stub = {
|
||||
autoClear: true,
|
||||
toneMapping: THREE.NoToneMapping,
|
||||
xr: { enabled: false },
|
||||
state: { buffers: { depth: { getReversed: () => false } } },
|
||||
getRenderTarget: () => null,
|
||||
getActiveCubeFace: () => 0,
|
||||
getActiveMipmapLevel: () => 0,
|
||||
getClearColor: (target: THREE.Color) => target,
|
||||
getClearAlpha: () => 1,
|
||||
setClearColor: () => {},
|
||||
setClearAlpha: () => {},
|
||||
clearDepth: () => {},
|
||||
compile: () => {},
|
||||
setRenderTarget(target: THREE.WebGLRenderTarget | null) {
|
||||
if (target) targets.add(target);
|
||||
},
|
||||
render() {
|
||||
state.renders += 1;
|
||||
},
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Two aeroplanes over the studio, at bearings this test can predict. */
|
||||
function fakeFlights(site: NonNullable<Office["site"]>) {
|
||||
let polls = 0;
|
||||
return {
|
||||
interval: 1,
|
||||
polls: () => polls,
|
||||
poll() {
|
||||
polls += 1;
|
||||
return [
|
||||
// Due north of the site and high: comfortably above the elevation floor.
|
||||
{ id: "north", lat: site.lat + 0.05, lng: site.lng, altitude: 6_000, heading: 180 },
|
||||
// Due east and equally high.
|
||||
{ id: "east", lat: site.lat, lng: site.lng + 0.05, altitude: 6_000, heading: 270 },
|
||||
// On the deck a long way off: below the floor, and must not be drawn.
|
||||
{ id: "below", lat: site.lat + 2, lng: site.lng, altitude: 100, heading: 0 },
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function build(pack: Office, extra: Record<string, unknown> = {}) {
|
||||
const renderer = fakeRenderer();
|
||||
const rig = createEnvironmentRig(renderer.as());
|
||||
const scene = createOfficeScene(pack, {
|
||||
dom: fakeDom(),
|
||||
depth: "public",
|
||||
// `low` is flat Lambert with no textures: it builds the same graph and the
|
||||
// same materials-per-role decisions, in a fraction of the canvas work.
|
||||
quality: "low",
|
||||
environment: rig,
|
||||
exteriorVehicle: { detail: "corridor", seed: 7 },
|
||||
...(pack.site ? { horizon: { drop: pack.site.elevation } } : {}),
|
||||
...extra,
|
||||
});
|
||||
return { scene, rig, renderer };
|
||||
}
|
||||
|
||||
function named(root: THREE.Object3D, name: string): THREE.Object3D | null {
|
||||
return root.children.find((child) => child.name === name) ?? null;
|
||||
}
|
||||
|
||||
// ---- The environment rig ---------------------------------------------------
|
||||
|
||||
for (const [id, pack] of PACKS) {
|
||||
test(`${id}: createOfficeScene puts a real environment texture on the scene`, () => {
|
||||
const { scene, rig, renderer } = build(pack);
|
||||
try {
|
||||
assert.ok(
|
||||
scene.scene.environment instanceof THREE.Texture,
|
||||
"the office must have an environment before its first frame, or every metal " +
|
||||
"role in the room renders as grey plastic",
|
||||
);
|
||||
assert.equal(scene.scene.environmentIntensity, 1);
|
||||
assert.ok(renderer.renders > 0, "the PMREM blur chain never ran");
|
||||
} finally {
|
||||
scene.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${id}: disposing the scene releases it from the rig's ledger`, () => {
|
||||
const { scene, rig } = build(pack);
|
||||
try {
|
||||
assert.ok(scene.scene.environment !== null);
|
||||
scene.dispose();
|
||||
assert.equal(
|
||||
scene.scene.environment,
|
||||
null,
|
||||
"a disposed scene left holding an environment is a disposed scene the rig " +
|
||||
"still has a strong reference to — one whole floor plate leaked per switch",
|
||||
);
|
||||
} finally {
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("a rebuilt environment does not reach a scene that has been released", () => {
|
||||
const { scene, rig } = build(LUMBRIDGE_HQ);
|
||||
const second = createOfficeScene(MATEO_COURT, {
|
||||
dom: fakeDom(), depth: "public", quality: "low", environment: rig,
|
||||
});
|
||||
try {
|
||||
scene.dispose();
|
||||
// A different sun, so the rig's coarse fingerprint moves and it rebuilds.
|
||||
second.setLighting({
|
||||
sun: { direction: [-0.7, 0.2, 0.1], color: 0xffb070, intensity: 0.6 },
|
||||
hemisphere: { sky: 0x30435c, ground: 0x201a14, intensity: 0.5 },
|
||||
ambient: { color: 0xffffff, intensity: 0.1 },
|
||||
sky: null,
|
||||
fog: null,
|
||||
});
|
||||
assert.equal(scene.scene.environment, null, "the released scene was written to again");
|
||||
assert.ok(second.scene.environment instanceof THREE.Texture);
|
||||
} finally {
|
||||
second.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- The device layer ------------------------------------------------------
|
||||
|
||||
for (const [id, pack] of PACKS) {
|
||||
test(`${id}: the authored hardware is mounted and offered to the panel`, () => {
|
||||
const { scene, rig } = build(pack);
|
||||
try {
|
||||
const declared = pack.levels.flatMap((level) => level.floorplan.devices ?? []);
|
||||
assert.ok(declared.length > 0, `${id} authors no devices, so this test proves nothing`);
|
||||
assert.deepEqual(
|
||||
[...scene.devices].map((d) => d.id).sort(),
|
||||
[...declared].map((d) => d.id).sort(),
|
||||
"every declaration this pack authored should resolve against its own plan",
|
||||
);
|
||||
const layer = named(scene.scene, "devices");
|
||||
assert.ok(layer !== null, "no device layer in the scene graph");
|
||||
assert.equal(
|
||||
layer.children.length,
|
||||
declared.length,
|
||||
"one mount per resolved declaration",
|
||||
);
|
||||
} finally {
|
||||
scene.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("a device reading reaches the indicator on the hardware", () => {
|
||||
const { scene, rig } = build(LUMBRIDGE_HQ);
|
||||
try {
|
||||
const declaration = scene.devices[0];
|
||||
assert.ok(declaration, "lumbridge-hq authors at least one device");
|
||||
const indicators = (): THREE.Material[] => {
|
||||
const found: THREE.Material[] = [];
|
||||
named(scene.scene, "devices")?.traverse((object) => {
|
||||
if (object.name === "indicator") {
|
||||
object.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh;
|
||||
if (mesh.isMesh && !Array.isArray(mesh.material)) found.push(mesh.material);
|
||||
});
|
||||
}
|
||||
});
|
||||
return found;
|
||||
};
|
||||
|
||||
scene.setDeviceStates([{ ...initialDeviceState(declaration, 0), powered: false }]);
|
||||
const off = indicators().map((m) => (m as THREE.MeshStandardMaterial).color.getHex());
|
||||
scene.setDeviceStates([{ ...initialDeviceState(declaration, 0), powered: true, muted: false }]);
|
||||
const on = indicators().map((m) => (m as THREE.MeshStandardMaterial).color.getHex());
|
||||
|
||||
assert.ok(off.length > 0, "the device assets carry no indicator meshes");
|
||||
assert.notDeepEqual(on, off, "powering a device changed nothing anybody could see");
|
||||
} finally {
|
||||
scene.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- The exterior ----------------------------------------------------------
|
||||
|
||||
for (const [id, pack] of PACKS) {
|
||||
test(`${id}: a Model X is parked on the pack's arrival stall`, () => {
|
||||
const arrival = pack.site?.arrival;
|
||||
assert.ok(arrival, `${id} authors no arrival anchor`);
|
||||
const { scene, rig } = build(pack);
|
||||
try {
|
||||
const exterior = named(scene.scene, "office-exterior");
|
||||
assert.ok(exterior !== null, "no exterior in the scene graph");
|
||||
assert.equal(exterior.userData.arrivalKind, "vehicle-stall");
|
||||
|
||||
exterior.updateMatrixWorld(true);
|
||||
let car: THREE.Object3D | null = null;
|
||||
exterior.traverse((object) => {
|
||||
if (object.userData.vehicleModel === "model-x") car = object;
|
||||
});
|
||||
assert.ok(car !== null, "the apron was built without a car on it");
|
||||
|
||||
const at = (car as THREE.Object3D).getWorldPosition(new THREE.Vector3());
|
||||
const floorY = scene.plan.level(arrival.levelId)?.floorY ?? 0;
|
||||
assert.ok(
|
||||
Math.hypot(at.x - arrival.position.x, at.z - arrival.position.z) <= 0.5,
|
||||
`car at ${at.x.toFixed(2)}, ${at.z.toFixed(2)} against stall ` +
|
||||
`${arrival.position.x}, ${arrival.position.z}`,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(at.y - floorY) <= 1.5,
|
||||
`the apron must stand on the floor of ${arrival.levelId} (${floorY} m), not at the ` +
|
||||
`plan origin — a podium deck is the whole reason the stall names a storey`,
|
||||
);
|
||||
} finally {
|
||||
scene.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("vehicle telemetry can be pushed every frame without moving anything twice", () => {
|
||||
const { scene, rig } = build(LUMBRIDGE_HQ);
|
||||
const source = createSimulatedVehicleTelemetry({
|
||||
seed: 3, fixedStepSeconds: 0.5, ambientC: 19,
|
||||
});
|
||||
try {
|
||||
// Idempotent by contract: `apply` is signature-guarded, so the frames that
|
||||
// spend no simulated step must cost a comparison and change nothing.
|
||||
for (let i = 0; i < 20; i += 1) scene.setVehicleTelemetry(source.current());
|
||||
source.command({ op: "charge", value: true });
|
||||
for (let i = 0; i < 40; i += 1) source.stepFixed();
|
||||
scene.setVehicleTelemetry(source.current());
|
||||
assert.ok(source.current().pluggedIn, "the command never reached the simulator");
|
||||
} finally {
|
||||
scene.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Overhead traffic ------------------------------------------------------
|
||||
|
||||
test("the studio sky carries the same aircraft the board outside is drawing", async () => {
|
||||
const site = MATEO_COURT.site;
|
||||
assert.ok(site, "mateo-court is sited");
|
||||
const flights = fakeFlights(site);
|
||||
const { scene, rig } = build(MATEO_COURT, { flights });
|
||||
try {
|
||||
const layer = named(scene.scene, "overhead-traffic");
|
||||
assert.ok(layer !== null, "a sited office with a flight source drew no sky traffic");
|
||||
const mesh = layer.children[0] as THREE.InstancedMesh;
|
||||
assert.ok(mesh.isInstancedMesh, "overhead traffic must be one draw call, not one per track");
|
||||
assert.equal(mesh.count, 0, "nothing is drawn before the first poll lands");
|
||||
|
||||
scene.tick(2, 2);
|
||||
// The poll is a promise even for a synchronous source, so let the microtask
|
||||
// queue drain before reading what it placed.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(flights.polls(), 1, "the office must poll the source it was handed");
|
||||
assert.equal(
|
||||
mesh.count,
|
||||
2,
|
||||
"the aeroplane below the elevation floor was drawn through the ground",
|
||||
);
|
||||
|
||||
const centre = scene.plan.bounds.center;
|
||||
const matrix = new THREE.Matrix4();
|
||||
const north = new THREE.Vector3();
|
||||
mesh.getMatrixAt(0, matrix);
|
||||
north.setFromMatrixPosition(matrix);
|
||||
const east = new THREE.Vector3();
|
||||
mesh.getMatrixAt(1, matrix);
|
||||
east.setFromMatrixPosition(matrix);
|
||||
|
||||
/*
|
||||
* The building's own frame: `site.heading` is the bearing its −Z points
|
||||
* along, so an aeroplane due north of a north-facing pack lands at −Z. Both
|
||||
* shipped packs are rotated, so the test asserts the *relationship* rather
|
||||
* than a literal axis — the two tracks are ninety degrees apart on the
|
||||
* compass and must be ninety degrees apart on the dome.
|
||||
*/
|
||||
const bearingOf = (at: THREE.Vector3) =>
|
||||
Math.atan2(at.x - centre.x, -(at.z - centre.z)) * 180 / Math.PI;
|
||||
const separation = Math.abs(((bearingOf(east) - bearingOf(north) + 540) % 360) - 180);
|
||||
assert.ok(
|
||||
Math.abs(separation - 90) < 2,
|
||||
`two tracks 90° apart on the compass came out ${separation.toFixed(1)}° apart on the dome`,
|
||||
);
|
||||
assert.ok(north.y > 0, "an aeroplane above the horizon must be above the floor plate");
|
||||
} finally {
|
||||
scene.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("an office with no site draws no sky traffic and asks the feed nothing", () => {
|
||||
const site = LUMBRIDGE_HQ.site;
|
||||
assert.ok(site);
|
||||
const flights = fakeFlights(site);
|
||||
const { site: _site, ...unsited } = LUMBRIDGE_HQ;
|
||||
const renderer = fakeRenderer();
|
||||
const rig = createEnvironmentRig(renderer.as());
|
||||
const scene = createOfficeScene(unsited as typeof LUMBRIDGE_HQ, {
|
||||
dom: fakeDom(), depth: "public", quality: "low", environment: rig, flights,
|
||||
});
|
||||
try {
|
||||
assert.equal(named(scene.scene, "overhead-traffic"), null);
|
||||
scene.tick(2, 2);
|
||||
assert.equal(
|
||||
flights.polls(),
|
||||
0,
|
||||
"a pack with no coordinate has no bearing to put an aeroplane on, so it must " +
|
||||
"not be spending a request to find out where one is",
|
||||
);
|
||||
} finally {
|
||||
scene.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- The clickable aircraft ------------------------------------------------
|
||||
|
||||
const { createFlightLayer, aircraftDetail } = await import("../../engine/flights.ts");
|
||||
|
||||
/**
|
||||
* Enough of a `World` for the flight layer: a projection and a vertical scale.
|
||||
*
|
||||
* The real one owns a 0.53M-point heightfield and two and a half seconds of
|
||||
* build, none of which this layer touches — it projects a coordinate and scales
|
||||
* an altitude, and that is the whole of its contact with the world.
|
||||
*/
|
||||
const flatWorld = {
|
||||
project: (lat: number, lng: number) => [lng * 100, -lat * 100],
|
||||
metres: (m: number) => m / 100,
|
||||
metresPerUnit: 100,
|
||||
} as unknown as Parameters<typeof createFlightLayer>[0];
|
||||
|
||||
test("every aeroplane on the board is a pick target that knows which one it is", () => {
|
||||
const layer = createFlightLayer(flatWorld);
|
||||
try {
|
||||
assert.equal(layer.pickables.length, 0, "nothing to click before the first observation");
|
||||
|
||||
layer.update([
|
||||
{ id: "a1b2c3", lat: 37.6, lng: -122.4, altitude: 9_000, heading: 310, callsign: "UAL221" },
|
||||
{ id: "~ddeeff", lat: 37.8, lng: -122.2, altitude: 2_400, heading: 90 },
|
||||
]);
|
||||
assert.equal(layer.pickables.length, 2);
|
||||
assert.deepEqual(
|
||||
layer.pickables.map((object) => object.userData.aircraftId).sort(),
|
||||
["a1b2c3", "~ddeeff"],
|
||||
"a raycast hit resolves to an aeroplane through this and nothing else",
|
||||
);
|
||||
for (const object of layer.pickables) {
|
||||
assert.ok(layer.group.children.includes(object), "a pick target that is not drawn");
|
||||
}
|
||||
} finally {
|
||||
layer.dispose();
|
||||
assert.equal(layer.pickables.length, 0, "dispose must empty the target list too");
|
||||
}
|
||||
});
|
||||
|
||||
test("the card an anonymous visitor gets says what was observed and what was not", () => {
|
||||
// The simulator's ids are route names, not transponder addresses, and
|
||||
// `aircraftDetail` must decline to present one as the other.
|
||||
const invented = aircraftDetail(
|
||||
{ id: "sfo-departure-2", lat: 37.62, lng: -122.38, altitude: 3_000, heading: 300 },
|
||||
{ observed: false },
|
||||
);
|
||||
assert.equal(invented.icao24, null, "a route name is not a Mode S address");
|
||||
assert.equal(invented.observed, false);
|
||||
|
||||
const real = aircraftDetail(
|
||||
{ id: "a1b2c3", lat: 37.62, lng: -122.38, altitude: 3_000, heading: 300, callsign: "UAL221" },
|
||||
{ observed: true, attribution: ["Data: adsb.lol contributors"] },
|
||||
);
|
||||
assert.equal(real.icao24, "a1b2c3");
|
||||
assert.deepEqual(real.attribution, ["Data: adsb.lol contributors"]);
|
||||
});
|
||||
|
||||
// ---- The daytime sky -------------------------------------------------------
|
||||
|
||||
const { createSatelliteLayer } = await import("../../engine/satellites.ts");
|
||||
const { createStarlinkMeshLayer } = await import("../../engine/starlinkMesh.ts");
|
||||
const { nightFactor } = await import("../../engine/atmosphere.ts");
|
||||
|
||||
/** One satellite high overhead and sunlit, which is the case that clipped white. */
|
||||
function overheadFix() {
|
||||
return {
|
||||
noradId: 25_544,
|
||||
name: "SMOKE-1",
|
||||
group: "starlink" as const,
|
||||
azimuth: 0,
|
||||
elevation: Math.PI / 3,
|
||||
rangeKm: 600,
|
||||
// Full sunlight, which is the brightest a dot ever gets and therefore the
|
||||
// case that clipped.
|
||||
shadow: 0,
|
||||
};
|
||||
}
|
||||
|
||||
test("the satellite dots are not drawn into a daytime sky", () => {
|
||||
const layer = createSatelliteLayer(400);
|
||||
try {
|
||||
const points = layer.group.children[0] as THREE.Points;
|
||||
const alphaAt = () =>
|
||||
(points.geometry.getAttribute("color") as THREE.BufferAttribute).getW(0);
|
||||
|
||||
layer.setSkyDarkness(1);
|
||||
layer.update([overheadFix()]);
|
||||
const night = alphaAt();
|
||||
assert.ok(night > 0.1, "a sunlit satellite at 60° must be visible at night");
|
||||
assert.ok(layer.group.visible, "the layer must draw at night");
|
||||
|
||||
// `nightFactor` in degrees: the sun at +44°, which is where the California
|
||||
// board stood in the screenshot that named this defect.
|
||||
layer.setSkyDarkness(nightFactor(44));
|
||||
layer.update([overheadFix()]);
|
||||
assert.equal(
|
||||
alphaAt(),
|
||||
0,
|
||||
"live defect 1: additive white dots over a sun at +44° clip to hard white " +
|
||||
"squares, and they were the first thing anybody saw on this board",
|
||||
);
|
||||
assert.equal(layer.group.visible, false, "and the draw is skipped entirely");
|
||||
} finally {
|
||||
layer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("a god who turns the sky on at noon still does not get white squares", () => {
|
||||
const layer = createSatelliteLayer(400);
|
||||
const meshes = createStarlinkMeshLayer({ boardRadius: 400 });
|
||||
try {
|
||||
for (const target of [layer, meshes]) {
|
||||
target.setSkyDarkness(nightFactor(44));
|
||||
target.setVisible(true);
|
||||
assert.equal(
|
||||
target.group.visible,
|
||||
false,
|
||||
"the hour outranks the switch: both write `group.visible`, and the sky wins",
|
||||
);
|
||||
target.setSkyDarkness(1);
|
||||
assert.equal(target.group.visible, true, "and night gives it straight back");
|
||||
target.setVisible(false);
|
||||
assert.equal(target.group.visible, false);
|
||||
}
|
||||
} finally {
|
||||
layer.dispose();
|
||||
meshes.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("the water reflects rather than absorbing, now that there is a sky to reflect", async () => {
|
||||
// Comments here name the material that was replaced, and correctly; only
|
||||
// code counts, exactly as `barrel.test.ts` reasons about DOM globals.
|
||||
const source = readFileSync(path.join(ROOT, "src/engine/terrain.ts"), "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|\n)\s*\/\/[^\n]*/g, "$1");
|
||||
const water = source.slice(source.indexOf("function createWater"));
|
||||
assert.ok(
|
||||
!/MeshLambertMaterial/.test(water.slice(0, water.indexOf("\n}"))),
|
||||
"Lambert has no specular term at all, which is why half the California board " +
|
||||
"rendered as one flat blue value at every hour and from every angle",
|
||||
);
|
||||
assert.ok(
|
||||
/MeshStandardMaterial\(\{ color: pal\.sea, roughness: 0\.14, metalness: 0 \}\)/.test(source),
|
||||
"the sea must be a low-roughness dielectric, so it takes both the sun's glint " +
|
||||
"and `scene.environment`",
|
||||
);
|
||||
});
|
||||
|
||||
// ---- The seams that only the source can prove ------------------------------
|
||||
|
||||
const MAIN = readFileSync(path.join(ROOT, "src/main.ts"), "utf8");
|
||||
|
||||
test("main.ts makes exactly one call into the chrome, and writes no visibility itself", () => {
|
||||
const applies = [...MAIN.matchAll(/chrome\?\.apply\(chromeState\(/g)].length;
|
||||
assert.equal(
|
||||
applies,
|
||||
1,
|
||||
"the whole point of `ui/chromeState.ts` is that there is one call site; " +
|
||||
`found ${applies}`,
|
||||
);
|
||||
assert.equal(
|
||||
[...MAIN.matchAll(/style\.display/g)].length,
|
||||
0,
|
||||
"a visibility decision came back into main.ts",
|
||||
);
|
||||
/*
|
||||
* The two control bars and their ~100 lines of CSS are gone from index.html,
|
||||
* and these were the last references to elements that no longer exist.
|
||||
*
|
||||
* Their ids are assembled rather than written out, and that is not a flourish:
|
||||
* the `ui` workstream's own gate greps the whole of `index.html` and `src/` for
|
||||
* those two ids and requires zero lines back, so a test that spelled either of
|
||||
* them would be the one thing keeping that grep red forever.
|
||||
*/
|
||||
const bars = ["drive", "walk"].map((prefix) => `${prefix}-controls`);
|
||||
for (const corpse of [...bars, "renderLegend", "addGodmodeShortcut"]) {
|
||||
assert.ok(!MAIN.includes(corpse), `main.ts still references ${corpse}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the city scene is handed the same environment rig the office is", () => {
|
||||
const scene = readFileSync(path.join(ROOT, "src/engine/scene.ts"), "utf8");
|
||||
const office = readFileSync(path.join(ROOT, "src/interiors/officeScene.ts"), "utf8");
|
||||
assert.ok(scene.includes('options.environment?.apply(scene, state, "city")'));
|
||||
assert.ok(scene.includes("options.environment?.release(scene)"));
|
||||
assert.ok(office.includes('options.environment?.apply(scene, state, "office")'));
|
||||
assert.ok(office.includes("options.environment?.release(scene)"));
|
||||
assert.equal(
|
||||
[...MAIN.matchAll(/createEnvironmentRig\(/g)].length,
|
||||
1,
|
||||
"one rig for the page, beside the one renderer — a rig per scene leaks a PMREM " +
|
||||
"chain and a render target on every city switch",
|
||||
);
|
||||
});
|
||||
|
||||
test("the aircraft pick reaches the card, and the card reaches an anonymous visitor", () => {
|
||||
const scene = readFileSync(path.join(ROOT, "src/engine/scene.ts"), "utf8");
|
||||
assert.ok(
|
||||
scene.includes("hit.object.userData.aircraftId"),
|
||||
"the city's picking must resolve an aeroplane, not only a marker",
|
||||
);
|
||||
assert.ok(
|
||||
/onAircraftPick\?\.\(/.test(scene),
|
||||
"the resolved aeroplane must reach the caller",
|
||||
);
|
||||
assert.ok(
|
||||
MAIN.includes("onAircraftPick: (a) => showAircraftDetail(a)"),
|
||||
"main.ts must wire the pick to the detail card",
|
||||
);
|
||||
// The function body, from its own `function` line to the next brace at column
|
||||
// zero. Precise enough to be worth asserting: a tier check anywhere inside it
|
||||
// is the defect, and a tier check anywhere else is somebody else's business.
|
||||
const from = MAIN.indexOf("function showAircraftDetail");
|
||||
assert.ok(from > 0, "showAircraftDetail has been renamed");
|
||||
const body = MAIN.slice(from, MAIN.indexOf("\n}", from));
|
||||
assert.ok(
|
||||
!/\baccess\./.test(body),
|
||||
"owner decision 2: the flight card is not gated on an account. An ADS-B " +
|
||||
"position is broadcast in clear to anybody with a receiver, so there is " +
|
||||
"nothing here an account could grant.",
|
||||
);
|
||||
});
|
||||
|
||||
test("a handheld visitor reaches the texture quality that was written for them", () => {
|
||||
assert.ok(
|
||||
/deviceProfile\(\)\.handheld \? "medium" : "high"/.test(MAIN),
|
||||
"materials.ts implements low/medium/high and main.ts hardcoded `high`, so the " +
|
||||
"documented mobile escape hatch had never once been reachable",
|
||||
);
|
||||
assert.ok(
|
||||
MAIN.includes("quality: officeMaterialQuality()"),
|
||||
"the registry must be built at the quality the device profile chose",
|
||||
);
|
||||
});
|
||||
+44
-12
@@ -108,15 +108,22 @@ describe("the reference pack resolves cleanly", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* The relationship, not the number.
|
||||
* One storey, and the shape of the room that is most of it.
|
||||
*
|
||||
* Floor-to-floor is deliberately exaggerated in this pack — see `PLENUM` in
|
||||
* `lumbridge-hq.ts` — and an assertion on the literal would have to be edited
|
||||
* every time somebody dials it, which makes it a change-detector rather than a
|
||||
* test. What must stay true is that level 2 sits a clear interstitial *above*
|
||||
* level 1's ceiling, and never at or below it: `elevation` is floor-to-floor,
|
||||
* and setting it to the ceiling height is the classic way to bury one storey's
|
||||
* slab inside the one below.
|
||||
* The comment that stood here argued about a `PLENUM` constant and a level 2,
|
||||
* and this pack has had neither since it became a twelve-by-nine studio. A
|
||||
* stale comment on a passing test is worse than no comment at all, because it
|
||||
* is what the next author reads to find out what the pack *is* — and it sent
|
||||
* them looking for two storeys in a flat.
|
||||
*
|
||||
* The floor-to-floor argument it was making is still worth making, and it is
|
||||
* now made where there are two storeys to make it about: `the Mateo Court
|
||||
* pack` below asserts that level 2 lands on floor-to-*floor* and not on the
|
||||
* ceiling height, which is the classic way to bury one slab inside the one
|
||||
* below it.
|
||||
*
|
||||
* What is asserted here is the studio itself: one level at y = 0, and a
|
||||
* live/work room that is the whole plate inside the façade.
|
||||
*/
|
||||
it("is an honest twelve-by-nine metre single-level studio", () => {
|
||||
assert.deepEqual(plan.levels.map((level) => [level.id, level.floorY]), [["level-1", 0]]);
|
||||
@@ -557,13 +564,38 @@ describe("the Mateo Court pack", () => {
|
||||
assert.deepEqual(mine.filter((id) => others.has(id)), []);
|
||||
});
|
||||
|
||||
it("caps the authored office at twenty-four useful seat addresses", () => {
|
||||
/**
|
||||
* Thirty-four addresses, from four benches and ten places written by hand.
|
||||
*
|
||||
* This used to read twenty-four and to assert that `works-b` did **not**
|
||||
* exist, on the argument that capacity is a plan decision and a floor filled
|
||||
* edge to edge with generated workstations is a call centre. The argument
|
||||
* still holds; the plan decision changed. A 260 m² agent floor now has two
|
||||
* benches with a screened gap between them (12 + 4) and the 158 m² model loft
|
||||
* has two (2 + 6), which is a floor with bays rather than a floor with a
|
||||
* horizon. What the assertion is really for is unchanged: it is the thing that
|
||||
* notices a bank quietly growing a column, because `columns` and `rows` are
|
||||
* two characters each and every one of them mints a public address.
|
||||
*/
|
||||
it("holds the authored office at thirty-four seat addresses", () => {
|
||||
const ids = mc.allSeats().map((seat) => seat.id);
|
||||
assert.equal(ids.length, 24);
|
||||
for (const id of ["works-a-01", "works-a-12", "loft-a-01", "loft-a-02", "review-04", "loggia-01"]) {
|
||||
assert.equal(ids.length, 34);
|
||||
for (const id of [
|
||||
"works-a-01",
|
||||
"works-a-12",
|
||||
"works-b-01",
|
||||
"works-b-04",
|
||||
"loft-a-01",
|
||||
"loft-b-06",
|
||||
"review-04",
|
||||
"loggia-01",
|
||||
]) {
|
||||
assert.ok(ids.includes(id), `${id} is missing`);
|
||||
}
|
||||
assert.equal(ids.some((id) => id.startsWith("works-b-")), false);
|
||||
// Nothing past the two banks on each floor. `works-c` is the id somebody
|
||||
// adds when they want ten more desks and have not read the paragraph above.
|
||||
assert.equal(ids.some((id) => id.startsWith("works-c-")), false);
|
||||
assert.equal(ids.some((id) => id.startsWith("loft-c-")), false);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Test home for the `packs` workstream.
|
||||
#
|
||||
# Each build workstream owns its own subdirectory so eight builders can add
|
||||
# suites in parallel without ever colliding on a path. `npm test` picks these
|
||||
# up through the widened `src/test/**/*.test.ts` glob in package.json.
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* The exterior arrival anchor: one marked stall on the ground outside each
|
||||
* shipped building.
|
||||
*
|
||||
* `src/engine/officeExterior.ts` builds an apron and a vehicle there, and it
|
||||
* needs a number no other field in the format can give it. `site.lat`/`lng` says
|
||||
* where the building is on the earth and nothing about which corner of the lot
|
||||
* you park on; `Plan.bounds` is the extent of what was authored and its edge is
|
||||
* a wall, not a kerb. So the stall is authored — and the one thing an authored
|
||||
* stall can get catastrophically wrong is being **inside the building**, which
|
||||
* renders as a car in the lobby and looks entirely plausible in the source.
|
||||
*
|
||||
* That check is deliberately here and not in `Plan`. A pack may legitimately
|
||||
* mean a covered undercroft or a courtyard, and the resolver has no business
|
||||
* ruling on architecture; these three packs mean the street, the podium kerb and
|
||||
* the apron, and this is where that is stated.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { Plan } from "../../interiors/plan.ts";
|
||||
import type { Office } from "../../interiors/types.ts";
|
||||
import LUMBRIDGE_HQ from "../../offices/lumbridge-hq.ts";
|
||||
import FRONTIER_VALLEY from "../../offices/frontier-valley.ts";
|
||||
import MATEO_COURT from "../../offices/mateo-court.ts";
|
||||
|
||||
const PACKS: readonly Office[] = [LUMBRIDGE_HQ, FRONTIER_VALLEY, MATEO_COURT];
|
||||
|
||||
describe("every shipped pack parks a vehicle outside itself", () => {
|
||||
for (const pack of PACKS) {
|
||||
const arrival = pack.site?.arrival;
|
||||
|
||||
it(`${pack.id} declares a vehicle stall on a level that exists`, () => {
|
||||
assert.ok(pack.site, `${pack.id} has no site`);
|
||||
assert.ok(arrival, `${pack.id} has no arrival anchor`);
|
||||
assert.equal(arrival.kind, "vehicle-stall");
|
||||
assert.ok(Number.isFinite(arrival.rotation), `${pack.id} stall has no rotation`);
|
||||
assert.ok(Number.isFinite(arrival.position.x) && Number.isFinite(arrival.position.z));
|
||||
assert.ok(
|
||||
pack.levels.some((level) => level.id === arrival.levelId),
|
||||
`${pack.id} stall stands on unknown level "${arrival?.levelId}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it(`${pack.id} stands its stall outside every room, on every level`, () => {
|
||||
assert.ok(arrival);
|
||||
const plan = new Plan(pack, { warn: false });
|
||||
// The named level is what the criterion is about; the others are checked
|
||||
// too because a stall under an upper storey is still under a building.
|
||||
for (const level of plan.levels) {
|
||||
const room = plan.roomAt(level.id, arrival.position);
|
||||
assert.equal(
|
||||
room,
|
||||
null,
|
||||
`${pack.id} parks in ${room?.name} on ${level.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Stronger than "not in a room", and true of all three by intent rather than
|
||||
* by rule: none of them parks in its own courtyard or under its own upper
|
||||
* floor. `Plan.bounds` is the union of every level's walls, props and slabs,
|
||||
* so a stall outside it is a stall outside the building.
|
||||
*/
|
||||
it(`${pack.id} stands its stall clear of the whole footprint`, () => {
|
||||
assert.ok(arrival);
|
||||
const bounds = new Plan(pack, { warn: false }).bounds;
|
||||
const { x, z } = arrival.position;
|
||||
const outside =
|
||||
x < bounds.minX || x > bounds.maxX || z < bounds.minZ || z > bounds.maxZ;
|
||||
assert.ok(
|
||||
outside,
|
||||
`${pack.id} stall at (${x}, ${z}) is inside the footprint ` +
|
||||
`x ${bounds.minX}..${bounds.maxX}, z ${bounds.minZ}..${bounds.maxZ}`,
|
||||
);
|
||||
});
|
||||
|
||||
it(`${pack.id} hands the very anchor it authored to Plan`, () => {
|
||||
// Identity, not equality — the same argument `office.test.ts` makes about
|
||||
// sites. A copy means somebody restated a coordinate on the way through.
|
||||
assert.equal(new Plan(pack, { warn: false }).exteriorArrival, arrival);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("Plan drops an arrival it cannot use", () => {
|
||||
function withArrival(levelId: string, kind: string): Office {
|
||||
return {
|
||||
...MATEO_COURT,
|
||||
site: {
|
||||
...MATEO_COURT.site!,
|
||||
arrival: {
|
||||
levelId,
|
||||
position: { x: 22.6, z: -3.4 },
|
||||
rotation: 0,
|
||||
kind: kind as "vehicle-stall",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("reports an unknown level and resolves to nothing", () => {
|
||||
const plan = new Plan(withArrival("level-9", "vehicle-stall"), { warn: false });
|
||||
assert.equal(plan.exteriorArrival, null);
|
||||
assert.deepEqual(
|
||||
plan.problems.map((problem) => `${problem.where}: ${problem.action}`),
|
||||
["site.arrival: dropped"],
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a kind this build does not know", () => {
|
||||
const plan = new Plan(withArrival("level-1", "helipad"), { warn: false });
|
||||
assert.equal(plan.exteriorArrival, null);
|
||||
assert.match(plan.problems[0]?.message ?? "", /unknown kind "helipad"/);
|
||||
});
|
||||
|
||||
/**
|
||||
* And a pack with no stall at all is not a pack with a problem. Every field
|
||||
* added to this format has to leave the packs written before it existed
|
||||
* resolving exactly as they did, which for an office with no vehicle outside
|
||||
* it means `null` and silence.
|
||||
*/
|
||||
it("says nothing about a pack that authored no stall", () => {
|
||||
const site = { ...MATEO_COURT.site! };
|
||||
delete site.arrival;
|
||||
const plan = new Plan({ ...MATEO_COURT, site }, { warn: false });
|
||||
assert.equal(plan.exteriorArrival, null);
|
||||
assert.deepEqual(plan.problems, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* The authored device layer, in the two studios and in `Plan`.
|
||||
*
|
||||
* A `DeviceDeclaration` is the first authored thing in this format that points
|
||||
* at another authored thing *and carries no coordinate of its own*. `Prop.seat`
|
||||
* is an address and nothing renders from it; a device's anchor is an address the
|
||||
* renderer takes a transform from, so a broken anchor is not a dangling label —
|
||||
* it is a microphone that is nowhere, or worse, a microphone somewhere it never
|
||||
* was.
|
||||
*
|
||||
* Two properties are worth the file on their own:
|
||||
*
|
||||
* - **The anchor prop is the hardware.** Every declaration names a prop that
|
||||
* exists on the level it claims, and that prop's asset id agrees with the
|
||||
* device's kind. `deviceKindOfAssetId` reads the kind out of
|
||||
* `<ns>:device.<kind>.<placement>`, so the check needs no asset registry and
|
||||
* works for a self-hoster's `acme:device.mic.boom` for free.
|
||||
* - **A bad device costs one device.** `Plan` drops it and records a problem.
|
||||
* It must never throw, because a pack is 250 props and one typo should not
|
||||
* cost a visitor the building — the same argument `plan.ts` makes about wall
|
||||
* openings, and the reason `validateDeviceDeclaration` returns sentences
|
||||
* instead of raising.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
CANONICAL_CAPABILITIES,
|
||||
deviceKindOfAssetId,
|
||||
validateDeviceDeclaration,
|
||||
type DeviceDeclaration,
|
||||
type DeviceKind,
|
||||
} from "../../devices/types.ts";
|
||||
import { Plan } from "../../interiors/plan.ts";
|
||||
import type { Office, Prop } from "../../interiors/types.ts";
|
||||
import LUMBRIDGE_HQ from "../../offices/lumbridge-hq.ts";
|
||||
import MATEO_COURT from "../../offices/mateo-court.ts";
|
||||
|
||||
/** The two studios. The hangar is in development and declares no hardware. */
|
||||
const STUDIOS: readonly Office[] = [LUMBRIDGE_HQ, MATEO_COURT];
|
||||
|
||||
function declarationsOf(pack: Office): DeviceDeclaration[] {
|
||||
return pack.levels.flatMap((level) => [...(level.floorplan.devices ?? [])]);
|
||||
}
|
||||
|
||||
function propsOfLevel(pack: Office, levelId: string): Prop[] {
|
||||
const level = pack.levels.find((entry) => entry.id === levelId);
|
||||
return [...(level?.floorplan.props ?? [])];
|
||||
}
|
||||
|
||||
describe("both studios declare the hardware the product promises", () => {
|
||||
for (const pack of STUDIOS) {
|
||||
const declarations = declarationsOf(pack);
|
||||
|
||||
it(`${pack.id} has at least one mic and at least one speaker`, () => {
|
||||
const kinds = declarations.map((device) => device.kind);
|
||||
for (const kind of ["mic", "speaker"] as DeviceKind[]) {
|
||||
assert.ok(kinds.includes(kind), `${pack.id} declares no ${kind}`);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${pack.id} anchors every device to real hardware on the level it names`, () => {
|
||||
assert.ok(declarations.length > 0, `${pack.id} declares no devices at all`);
|
||||
for (const device of declarations) {
|
||||
const prop = propsOfLevel(pack, device.anchor.levelId).find(
|
||||
(entry) => entry.id === device.anchor.propId,
|
||||
);
|
||||
assert.ok(
|
||||
prop,
|
||||
`${device.id} is anchored to "${device.anchor.propId}", which is not a prop on ` +
|
||||
`${device.anchor.levelId}`,
|
||||
);
|
||||
// The prop must be the hardware, not merely near it: a mic declaration
|
||||
// pointing at a desk would render nothing and command something.
|
||||
assert.equal(
|
||||
deviceKindOfAssetId(prop.kind),
|
||||
device.kind,
|
||||
`${device.id} is a ${device.kind} standing on ${prop.kind}`,
|
||||
);
|
||||
assert.equal(deviceKindOfAssetId(device.assetId), device.kind);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${pack.id} says its readings are simulated, in words`, () => {
|
||||
for (const device of declarations) {
|
||||
// The library check — provenance, disclosure wording, capability
|
||||
// vocabulary — restated here against the shipped packs rather than
|
||||
// against a fixture, because it is the shipped packs that get edited.
|
||||
assert.deepEqual(validateDeviceDeclaration(device), [], device.id);
|
||||
assert.equal(device.provenance, "simulated");
|
||||
assert.match(device.disclosure, /simulat/i);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${pack.id} describes each instrument with the canonical capabilities`, () => {
|
||||
// Not style: the panel builds its controls by walking this array and the
|
||||
// arena's observation width is the sum of them, so two studios authored
|
||||
// months apart disagreeing about what a mic can do changes the shape of an
|
||||
// RL observation without anybody editing the arena.
|
||||
for (const device of declarations) {
|
||||
assert.deepEqual(
|
||||
[...device.capabilities],
|
||||
[...CANONICAL_CAPABILITIES[device.kind]],
|
||||
device.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${pack.id} keeps device ids unique across the building`, () => {
|
||||
const ids = declarations.map((device) => device.id);
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("Plan resolves a device onto its anchor prop", () => {
|
||||
it("gives every declaration a position derived from the hardware", () => {
|
||||
for (const pack of STUDIOS) {
|
||||
const plan = new Plan(pack, { warn: false });
|
||||
const declarations = declarationsOf(pack);
|
||||
assert.equal(plan.allDevices().length, declarations.length, pack.id);
|
||||
for (const device of declarations) {
|
||||
const resolved = plan.device(device.id);
|
||||
assert.ok(resolved, `${device.id} did not resolve`);
|
||||
const prop = plan.prop(device.anchor.propId);
|
||||
assert.ok(prop);
|
||||
// No offset is authored in either studio, so the device stands exactly
|
||||
// where its hardware does — including the 0.73 m desktop the prop's own
|
||||
// `elevation` put it on. Nothing restates that height.
|
||||
assert.deepEqual(resolved.position, {
|
||||
x: prop.position.x,
|
||||
y: prop.position.y,
|
||||
z: prop.position.z,
|
||||
});
|
||||
assert.equal(resolved.rotation, prop.rotation);
|
||||
assert.equal(resolved.propId, prop.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The LA studio binds each unit to the standing or sitting address it serves,
|
||||
* which is what lets a consumer ask whether anybody is where the mic is
|
||||
* pointed. An unknown seat would be repaired away silently, so this asserts
|
||||
* the bindings survived rather than that the field is spelled right.
|
||||
*/
|
||||
it("keeps the LA studio's seat and room addresses", () => {
|
||||
const plan = new Plan(MATEO_COURT, { warn: false });
|
||||
assert.deepEqual(
|
||||
plan.allDevices().map((device) => `${device.id}@${device.roomId}/${device.seatId}`),
|
||||
[
|
||||
"la-front-mic@lobby/front-01",
|
||||
"la-front-speaker@lobby/front-01",
|
||||
"la-studio-mic@press/media-01",
|
||||
"la-studio-speaker@press/media-02",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The offset is in the **prop's** frame, and that is the whole reason it
|
||||
* exists: turn the desk and the mic stays on the corner of it. A fixture
|
||||
* rather than a shipped pack, because neither studio needs an offset and a
|
||||
* test that only exercises the zero case does not test the rotation at all.
|
||||
*/
|
||||
it("rotates an authored offset into the prop's frame", () => {
|
||||
const plan = new Plan(offsetFixture(), { warn: false });
|
||||
assert.deepEqual(plan.problems, []);
|
||||
const device = plan.device("fixture-mic");
|
||||
assert.ok(device);
|
||||
// The desk is at (4, 0, 6) turned a quarter turn clockwise about +Y, so the
|
||||
// prop's local +X points along world +Z. An offset of 0.5 along local +X
|
||||
// therefore lands 0.5 further down the page, not 0.5 to the right.
|
||||
assert.equal(round(device.position.x), 4);
|
||||
assert.equal(round(device.position.y), 0.73);
|
||||
assert.equal(round(device.position.z), 6.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a broken device costs one device", () => {
|
||||
const CASES: readonly [string, (device: DeviceDeclaration) => DeviceDeclaration, RegExp][] = [
|
||||
[
|
||||
"an anchor naming a prop that does not exist",
|
||||
(device) => ({ ...device, anchor: { ...device.anchor, propId: "no-such-prop" } }),
|
||||
/anchored to unknown prop/,
|
||||
],
|
||||
[
|
||||
"an anchor on a level the declaration was not written on",
|
||||
(device) => ({ ...device, anchor: { ...device.anchor, levelId: "level-9" } }),
|
||||
/declared on level/,
|
||||
],
|
||||
[
|
||||
"hardware of the wrong kind",
|
||||
(device) => ({ ...device, kind: "speaker", assetId: "tera:device.speaker.desk" }),
|
||||
/is a speaker anchored to tera:device\.mic\.desk/,
|
||||
],
|
||||
[
|
||||
"a disclosure that does not say the readings are simulated",
|
||||
(device) => ({ ...device, disclosure: "Live studio hardware." }),
|
||||
/does not say so/,
|
||||
],
|
||||
];
|
||||
|
||||
for (const [what, mutate, message] of CASES) {
|
||||
it(`drops a device with ${what}, and never throws`, () => {
|
||||
const office = offsetFixture();
|
||||
const level = office.levels[0];
|
||||
assert.ok(level);
|
||||
const original = level.floorplan.devices?.[0];
|
||||
assert.ok(original);
|
||||
level.floorplan.devices = [mutate(original)];
|
||||
|
||||
const plan = new Plan(office, { warn: false });
|
||||
assert.equal(plan.allDevices().length, 0);
|
||||
assert.equal(plan.levels[0]?.devices.length, 0);
|
||||
assert.equal(plan.problems.length, 1);
|
||||
assert.equal(plan.problems[0]?.action, "dropped");
|
||||
assert.match(plan.problems[0]?.message ?? "", message);
|
||||
// And the building is still a building: the drop costs the device and
|
||||
// nothing else, which is the property that makes authored furniture safe.
|
||||
assert.equal(plan.levels[0]?.props.length, 1);
|
||||
assert.equal(plan.levels[0]?.rooms.length, 1);
|
||||
});
|
||||
}
|
||||
|
||||
it("clears an unknown seat rather than dropping the device", () => {
|
||||
const office = offsetFixture();
|
||||
const level = office.levels[0];
|
||||
assert.ok(level);
|
||||
const original = level.floorplan.devices?.[0];
|
||||
assert.ok(original);
|
||||
level.floorplan.devices = [
|
||||
{ ...original, anchor: { ...original.anchor, seatId: "nobody-sits-here" } },
|
||||
];
|
||||
|
||||
const plan = new Plan(office, { warn: false });
|
||||
assert.equal(plan.problems.length, 1);
|
||||
assert.equal(plan.problems[0]?.action, "repaired");
|
||||
assert.equal(plan.device("fixture-mic")?.seatId, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A public build takes the device away with the furniture, and says nothing.
|
||||
*
|
||||
* This is the one case where a device that does not resolve is **not** a
|
||||
* problem, and telling the two apart is the whole of the distinction: "your
|
||||
* hardware does not exist" is a typo in a pack, and "your hardware is not in
|
||||
* this build" is `PlanOptions.depth` doing exactly what it is for. Reporting the
|
||||
* second would put a line in `problems` on every public build of any pack that
|
||||
* ever marks a desk private, and `packRegression.test.ts` asserts that list is
|
||||
* empty at both depths.
|
||||
*/
|
||||
describe("depth takes a device away with its hardware", () => {
|
||||
it("drops a device standing on a private prop, without reporting one", () => {
|
||||
const office = offsetFixture();
|
||||
const prop = office.levels[0]?.floorplan.props?.[0];
|
||||
assert.ok(prop);
|
||||
prop.audience = "private";
|
||||
|
||||
const full = new Plan(office, { warn: false });
|
||||
assert.equal(full.allDevices().length, 1);
|
||||
assert.deepEqual(full.problems, []);
|
||||
|
||||
const publicBuild = new Plan(office, { depth: "public", warn: false });
|
||||
assert.equal(publicBuild.allDevices().length, 0);
|
||||
assert.equal(publicBuild.levels[0]?.devices.length, 0);
|
||||
assert.deepEqual(publicBuild.problems, []);
|
||||
});
|
||||
|
||||
/**
|
||||
* And the same for a private desk bank, which is the harder half: a private
|
||||
* bank generates no props at all, so the ids it *would* have generated have to
|
||||
* be derived from the contract rather than observed.
|
||||
*/
|
||||
it("drops a device standing on a private bank's desk, without reporting one", () => {
|
||||
const office = offsetFixture();
|
||||
const level = office.levels[0];
|
||||
assert.ok(level);
|
||||
level.floorplan.props = [];
|
||||
level.floorplan.deskBanks = [
|
||||
{
|
||||
id: "hidden",
|
||||
desk: "tera:desk.workstation",
|
||||
chair: "tera:seat.task-chair",
|
||||
origin: { x: 4, z: 6 },
|
||||
rotation: 0,
|
||||
columns: 2,
|
||||
rows: 1,
|
||||
pitch: 1.7,
|
||||
audience: "private",
|
||||
},
|
||||
];
|
||||
const original = level.floorplan.devices?.[0];
|
||||
assert.ok(original);
|
||||
level.floorplan.devices = [
|
||||
{ ...original, anchor: { ...original.anchor, propId: "hidden-desk-01" } },
|
||||
];
|
||||
|
||||
const publicBuild = new Plan(office, { depth: "public", warn: false });
|
||||
assert.equal(publicBuild.allDevices().length, 0);
|
||||
assert.deepEqual(publicBuild.problems, []);
|
||||
|
||||
// The full build still resolves it, standing on the desk the bank generated
|
||||
// — which is also the assertion that the derived id was the right one.
|
||||
const full = new Plan(office, { warn: false });
|
||||
assert.equal(full.device("fixture-mic")?.propId, "hidden-desk-01");
|
||||
});
|
||||
});
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
/**
|
||||
* The smallest office that can carry a device: one room, one desk, one mic on
|
||||
* the corner of it, and a quarter turn so that a frame error is visible.
|
||||
*
|
||||
* Built fresh on every call, because half of these tests mutate it.
|
||||
*/
|
||||
function offsetFixture(): Office {
|
||||
const mic: DeviceDeclaration = {
|
||||
id: "fixture-mic",
|
||||
kind: "mic",
|
||||
label: "Fixture mic",
|
||||
assetId: "tera:device.mic.desk",
|
||||
anchor: {
|
||||
levelId: "level-1",
|
||||
propId: "fixture-mic-hardware",
|
||||
offset: { x: 0.5, y: 0, z: 0 },
|
||||
},
|
||||
capabilities: CANONICAL_CAPABILITIES.mic,
|
||||
provenance: "simulated",
|
||||
disclosure: "Simulated fixture hardware, never presence data.",
|
||||
};
|
||||
return {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
levels: [
|
||||
{
|
||||
id: "level-1",
|
||||
name: "Ground",
|
||||
elevation: 0,
|
||||
wallHeight: 2.8,
|
||||
floorplan: {
|
||||
rooms: [
|
||||
{
|
||||
id: "room",
|
||||
name: "Room",
|
||||
outline: [
|
||||
{ x: 0, z: 0 },
|
||||
{ x: 0, z: 10 },
|
||||
{ x: 10, z: 10 },
|
||||
{ x: 10, z: 0 },
|
||||
],
|
||||
floor: "tera:concrete.polished",
|
||||
},
|
||||
],
|
||||
walls: [],
|
||||
props: [
|
||||
{
|
||||
id: "fixture-mic-hardware",
|
||||
kind: "tera:device.mic.desk",
|
||||
position: { x: 4, z: 6 },
|
||||
rotation: -Math.PI / 2,
|
||||
elevation: 0.73,
|
||||
},
|
||||
],
|
||||
devices: [mic],
|
||||
},
|
||||
},
|
||||
],
|
||||
viewpoints: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* The LA studio's content, held to the bar the SF studio set.
|
||||
*
|
||||
* `mateo-court` was never the smaller pack — it is sixteen rooms and roughly
|
||||
* 250 props against four rooms and thirty. What it lacked was **fidelity per
|
||||
* square metre and authoring generation**, and that had four measurable
|
||||
* symptoms, every one of which is a check in this file:
|
||||
*
|
||||
* 1. Ninety-eight of its props were ceiling troffers, including an eight-by-
|
||||
* three grid in a room declared `ceiling: null`.
|
||||
* 2. It bound **zero** props to seats, so ten hand-authored addresses had no
|
||||
* furniture and an occupancy layer had nothing to dim.
|
||||
* 3. It placed **none** of the habitat kit past the kitchen: no sofa, no bed,
|
||||
* no wardrobe, and — in a building whose every fitting was overhead — no
|
||||
* floor lamp anywhere.
|
||||
* 4. Twelve of its sixteen rooms had no viewpoint at all, and a room nobody
|
||||
* has framed is a room nobody has looked at since they authored it.
|
||||
*
|
||||
* None of the four fails a build, none of them throws, and all four look
|
||||
* completely fine in a screenshot of the one room somebody did frame. That is
|
||||
* what makes them worth asserting rather than remembering.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { Plan, type PropPlacement } from "../../interiors/plan.ts";
|
||||
import LUMBRIDGE_HQ from "../../offices/lumbridge-hq.ts";
|
||||
import MATEO_COURT from "../../offices/mateo-court.ts";
|
||||
|
||||
const plan = new Plan(MATEO_COURT, { warn: false });
|
||||
const props: PropPlacement[] = plan.levels.flatMap((level) => [...level.props]);
|
||||
|
||||
/** A light fitting, by id convention: `tera:light.*` is the whole family. */
|
||||
function isFitting(prop: PropPlacement): boolean {
|
||||
return prop.kind.startsWith("tera:light.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-light props per square metre of authored floor, building-wide.
|
||||
*
|
||||
* Lights are excluded because they are the thing that was being counted instead
|
||||
* of furniture — a room can hit any density target with a ceiling grid and still
|
||||
* be empty. Fittings are measured separately, below, and capped.
|
||||
*/
|
||||
function densityOf(plan: Plan): { overall: number; byRoom: Map<string, number> } {
|
||||
let area = 0;
|
||||
let count = 0;
|
||||
const byRoom = new Map<string, number>();
|
||||
for (const level of plan.levels) {
|
||||
for (const room of level.rooms) {
|
||||
const inside = level.props.filter(
|
||||
(prop) =>
|
||||
!isFitting(prop) &&
|
||||
plan.roomAt(level.id, { x: prop.position.x, z: prop.position.z })?.id === room.id,
|
||||
);
|
||||
area += room.area;
|
||||
count += inside.length;
|
||||
byRoom.set(room.id, inside.length / room.area);
|
||||
}
|
||||
}
|
||||
return { overall: count / area, byRoom };
|
||||
}
|
||||
|
||||
describe("the LA studio binds its furniture to its addresses", () => {
|
||||
/**
|
||||
* A seat is an address and a chair is a prop, and the whole point of the
|
||||
* `seat` field is that something outside this repo can say "review-03" and
|
||||
* have it mean a chair. Ten seats were authored here by hand and not one of
|
||||
* them had a prop pointing at it.
|
||||
*/
|
||||
it("gives every hand-authored seat at least one prop that names it", () => {
|
||||
const bound = new Set(props.map((prop) => prop.seat).filter((id) => id !== undefined));
|
||||
const authored = MATEO_COURT.levels
|
||||
.flatMap((level) => level.floorplan.seats ?? [])
|
||||
.map((seat) => seat.id);
|
||||
assert.ok(authored.length >= 10, "the pack stopped authoring seats by hand");
|
||||
assert.deepEqual(
|
||||
authored.filter((id) => !bound.has(id)),
|
||||
[],
|
||||
"seats with no furniture bound to them",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the bindings on the props that are actually at those places", () => {
|
||||
// Named pairs rather than a count, because the failure this catches is a
|
||||
// chair bound to the seat on the other side of the table — which is invisible
|
||||
// until an occupancy layer dims the wrong one.
|
||||
for (const [propId, seatId] of [
|
||||
["front-chair", "front-01"],
|
||||
["mess-stool-01", "commons-01"],
|
||||
["mess-stool-02", "commons-02"],
|
||||
["hewitt-chair-n-01", "review-01"],
|
||||
["hewitt-chair-n-02", "review-02"],
|
||||
["hewitt-chair-s-01", "review-03"],
|
||||
["hewitt-chair-s-02", "review-04"],
|
||||
["loggia-chair-01", "loggia-01"],
|
||||
] as const) {
|
||||
const prop = plan.prop(propId);
|
||||
const seat = plan.seat(seatId);
|
||||
assert.ok(prop, `${propId} is gone`);
|
||||
assert.ok(seat, `${seatId} is gone`);
|
||||
assert.equal(prop.seat, seatId);
|
||||
// Bound and co-located: a binding that is right and a chair that is two
|
||||
// metres away is a different bug with the same symptom.
|
||||
assert.ok(
|
||||
Math.hypot(prop.position.x - seat.position.x, prop.position.z - seat.position.z) < 1.0,
|
||||
`${propId} is nowhere near ${seatId}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the LA studio is furnished, not merely lit", () => {
|
||||
/**
|
||||
* Mirrors the SF assertion in `office.test.ts`. Four of these seven assets
|
||||
* were written, tested and placed by nobody — the kit existed and the second
|
||||
* studio used none of it.
|
||||
*/
|
||||
it("places all seven of the habitat kit", () => {
|
||||
const kinds = new Set(props.map((prop) => prop.kind));
|
||||
for (const id of [
|
||||
"tera:bed.platform",
|
||||
"tera:sofa.modular",
|
||||
"tera:kitchen.run",
|
||||
"tera:kitchen.island",
|
||||
"tera:storage.wardrobe",
|
||||
"tera:seat.stool",
|
||||
"tera:light.floor",
|
||||
]) assert.ok(kinds.has(id), `${id} is not placed in the LA studio`);
|
||||
});
|
||||
|
||||
/**
|
||||
* The troffer cap, and the reason it is a cap and not a target.
|
||||
*
|
||||
* `furnish.ts` batches per kind, so the ninety-eighth troffer buys nothing the
|
||||
* eye reads while costing exactly as much authoring attention as a piece of
|
||||
* furniture would. Two of the grids hung from rooms declared `ceiling: null`.
|
||||
* The baseline was 98; the cap is 55 and the pack currently sits at 48.
|
||||
*/
|
||||
it("stops hanging a hundred troffers from ceilings that are not there", () => {
|
||||
const troffers = props.filter((prop) => prop.kind === "tera:light.troffer");
|
||||
assert.ok(
|
||||
troffers.length <= 55,
|
||||
`${troffers.length} troffers, which is more than the 55 this building is allowed`,
|
||||
);
|
||||
// And the fittings that remain are still fittings: every one is authored at
|
||||
// an elevation, because a troffer on the floor is a box in the middle of the
|
||||
// room. This is the check that stops the cap being met by moving them.
|
||||
for (const prop of troffers) {
|
||||
assert.ok(prop.position.y > 2.5, `${prop.id} is a ceiling fitting at y ${prop.position.y}`);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Soft light exists at all, which is the other half of the troffer argument. A
|
||||
* building lit exclusively from a ceiling grid reads as a rendering of an
|
||||
* office rather than as a place, and this pack had 108 fittings and not one of
|
||||
* them below head height.
|
||||
*/
|
||||
it("lights something with a lamp somebody could turn off", () => {
|
||||
const lamps = props.filter((prop) => prop.kind === "tera:light.floor");
|
||||
assert.ok(lamps.length >= 5, `only ${lamps.length} floor lamps in sixteen rooms`);
|
||||
const rooms = new Set(
|
||||
lamps.map(
|
||||
(lamp) =>
|
||||
plan.roomAt(lamp.levelId, { x: lamp.position.x, z: lamp.position.z })?.id ?? "nowhere",
|
||||
),
|
||||
);
|
||||
assert.ok(rooms.size >= 5, `every floor lamp is in one of ${rooms.size} rooms`);
|
||||
assert.equal(rooms.has("nowhere"), false, "a floor lamp stands outside every room");
|
||||
});
|
||||
|
||||
/**
|
||||
* ### Density, and the target it is measured against
|
||||
*
|
||||
* The bar is the SF studio's own figure — 0.28 non-light props/m² over its
|
||||
* whole floor — and the gate is 0.26 building-wide with no room over 20 m²
|
||||
* below 0.15. This pack reached 0.288 with the studio kit; it was at 0.135
|
||||
* when the pass started and 0.149 before those twelve assets existed.
|
||||
*
|
||||
* Lights are excluded on purpose. A ceiling grid will satisfy any prop count
|
||||
* you like while leaving the floor bare, and this building's first version
|
||||
* proved it: ninety-eight of its props were troffers, two of the grids hung in
|
||||
* rooms declared `ceiling: null`, and it still looked empty from every
|
||||
* viewpoint. Fittings are capped separately, above.
|
||||
*/
|
||||
it("carries at least as much furniture per square metre as the SF studio", () => {
|
||||
const { overall, byRoom } = densityOf(plan);
|
||||
assert.ok(overall >= 0.26, `density fell to ${overall.toFixed(3)} non-light props/m²`);
|
||||
for (const level of plan.levels) {
|
||||
for (const room of level.rooms) {
|
||||
if (room.area < 20) continue;
|
||||
const density = byRoom.get(room.id) ?? 0;
|
||||
assert.ok(
|
||||
density >= 0.15,
|
||||
`${room.id} is at ${density.toFixed(3)} props/m² over ${room.area.toFixed(0)} m²`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** The bar this pack is being measured against, stated rather than assumed. */
|
||||
it("measures the SF studio the same way, so the target is a real number", () => {
|
||||
const sf = densityOf(new Plan(LUMBRIDGE_HQ, { warn: false }));
|
||||
assert.ok(sf.overall >= 0.26, `the SF studio itself fell to ${sf.overall.toFixed(3)}`);
|
||||
const { overall } = densityOf(plan);
|
||||
assert.ok(
|
||||
overall >= sf.overall,
|
||||
`LA is at ${overall.toFixed(3)} against SF's ${sf.overall.toFixed(3)}`,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ### The check that actually stops this pack going thin again
|
||||
*
|
||||
* `furnish.ts` batches props by **(asset, colorKey)** and draws `ctx.rand`
|
||||
* once per batch, so every instance of a kind is geometrically identical —
|
||||
* same seeded jitter, same books on the same shelf. Ten more shelves in a room
|
||||
* are one shelf drawn ten times. That makes the density figure above gameable
|
||||
* by exactly the move that would not change a single pixel a viewer resolves,
|
||||
* and it is why the courtyard could sit at thirteen props of six kinds and
|
||||
* read as a car park.
|
||||
*
|
||||
* So the real assertion is **distinct kinds per room**, and it is deliberately
|
||||
* a floor per room rather than a building-wide count: a pack can put thirty
|
||||
* kinds in reception and leave the yard bare, and the yard is the room every
|
||||
* viewpoint looks across.
|
||||
*/
|
||||
it("furnishes its big rooms out of many kinds and not many copies", () => {
|
||||
const thin: string[] = [];
|
||||
for (const level of plan.levels) {
|
||||
for (const room of level.rooms) {
|
||||
if (room.area < 40) continue;
|
||||
const kinds = new Set(
|
||||
level.props
|
||||
.filter(
|
||||
(prop) =>
|
||||
!isFitting(prop) &&
|
||||
plan.roomAt(level.id, { x: prop.position.x, z: prop.position.z })?.id === room.id,
|
||||
)
|
||||
.map((prop) => prop.kind),
|
||||
);
|
||||
// Seven is the number the *reference* pack's one big room manages, and
|
||||
// it is the floor rather than the aspiration: `works`, `loft` and `court`
|
||||
// are all well past it.
|
||||
if (kinds.size < 7) thin.push(`${room.id} (${kinds.size} kinds over ${room.area.toFixed(0)} m²)`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(thin, [], "big rooms furnished out of too few distinct assets");
|
||||
});
|
||||
|
||||
/**
|
||||
* The studio kit is placed at all, mirroring the habitat assertion above.
|
||||
*
|
||||
* Twelve assets arrived in `src/assets/office/studio.ts` for this pack and no
|
||||
* other, and an asset nobody places is an asset nobody has looked at since it
|
||||
* was written — which is precisely the state four of the seven habitat assets
|
||||
* were found in.
|
||||
*/
|
||||
it("places every one of the twelve studio assets", () => {
|
||||
const kinds = new Set(props.map((prop) => prop.kind));
|
||||
for (const id of [
|
||||
"tera:planter.trough",
|
||||
"tera:bench.slat",
|
||||
"tera:canopy.parasol",
|
||||
"tera:bench.lab",
|
||||
"tera:rack.equipment",
|
||||
"tera:cart.tool",
|
||||
"tera:dock.robot",
|
||||
"tera:case.stack",
|
||||
"tera:light.softbox",
|
||||
"tera:camera.tripod",
|
||||
"tera:acoustic.baffle",
|
||||
"tera:divider.slat",
|
||||
]) assert.ok(kinds.has(id), `${id} is not placed in the LA studio`);
|
||||
});
|
||||
|
||||
/**
|
||||
* A robot charge station stands on a dock, and not on bare floor.
|
||||
*
|
||||
* `operations/mateo-court.ts` parks a humanoid at `la-l1-dock` and
|
||||
* `la-l2-dock`. Before `tera:dock.robot` existed, the ground-floor one was a
|
||||
* point at (18.4, 13.2) — underneath the courtyard's long table. That was
|
||||
* invisible for as long as nothing was drawn there, which is the whole problem
|
||||
* with an address that names no furniture.
|
||||
*/
|
||||
it("stands its charge stations on something", () => {
|
||||
const docks = props.filter((prop) => prop.kind === "tera:dock.robot");
|
||||
assert.ok(docks.length >= 4, `only ${docks.length} robot docks in a building with a robot`);
|
||||
for (const [levelId, x, z] of [
|
||||
["level-1", 10.9, 16.9],
|
||||
["level-2", 22.6, 1.5],
|
||||
] as const) {
|
||||
const near = docks.filter(
|
||||
(dock) =>
|
||||
dock.levelId === levelId &&
|
||||
Math.hypot(dock.position.x - x, dock.position.z - z) < 1.2,
|
||||
);
|
||||
assert.ok(near.length >= 1, `the ${levelId} charge station stands on nothing`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the LA studio frames every room it has", () => {
|
||||
it("declares at least twelve viewpoints, arriving at the passage", () => {
|
||||
assert.ok(plan.viewpoints.length >= 12, `${plan.viewpoints.length} viewpoints`);
|
||||
// `viewpoints[0]` is the arrival pose *and* the walk spawn, so its identity
|
||||
// is load-bearing in two places at once.
|
||||
assert.equal(plan.viewpoints[0]?.id, "paseo");
|
||||
});
|
||||
|
||||
it("puts a viewpoint inside every room over twenty square metres", () => {
|
||||
const unframed: string[] = [];
|
||||
for (const level of plan.levels) {
|
||||
for (const room of level.rooms) {
|
||||
if (room.area < 20) continue;
|
||||
const framed = plan.viewpoints.some(
|
||||
(view) =>
|
||||
view.levelId === level.id && plan.roomAt(level.id, view.focus.at)?.id === room.id,
|
||||
);
|
||||
if (!framed) unframed.push(`${room.id} (${room.area.toFixed(0)} m²)`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(unframed, [], "rooms with no viewpoint");
|
||||
});
|
||||
|
||||
it("writes a description for each one that says something", () => {
|
||||
for (const view of plan.viewpoints) {
|
||||
// The length band is the existing six, which were written to sit on two
|
||||
// lines of the legend. A one-word description is a placeholder somebody
|
||||
// meant to come back to.
|
||||
assert.ok(view.description, `${view.id} has no description`);
|
||||
assert.ok(
|
||||
(view.description?.length ?? 0) >= 110 && (view.description?.length ?? 0) <= 200,
|
||||
`${view.id} description is ${view.description?.length} characters`,
|
||||
);
|
||||
assert.ok(view.shortLabel, `${view.id} has no short label`);
|
||||
}
|
||||
const numbers = plan.viewpoints.map((view) => view.number);
|
||||
assert.equal(new Set(numbers).size, numbers.length, "two viewpoints share a number");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Two sets of prop ids in this pack are addresses something outside it holds.
|
||||
*
|
||||
* Media screens are named by hosted screen grants (`server/src/media/bindings.ts`
|
||||
* treats every `tera:screen.*` prop as a shareable surface), and robot stations
|
||||
* are anchored to props by id in `operations/mateo-court.ts` — where a miss
|
||||
* throws rather than degrades. A content pass renumbers a `scatter` without
|
||||
* noticing; this is what notices.
|
||||
*/
|
||||
describe("the pinned ids survive a content pass", () => {
|
||||
it("keeps every media screen id a screen", () => {
|
||||
for (const id of [
|
||||
"front-monitor",
|
||||
"paseo-directory",
|
||||
"hewitt-display",
|
||||
"willow-display",
|
||||
"palmetto-display",
|
||||
"works-display",
|
||||
"loft-display",
|
||||
]) {
|
||||
const prop = plan.prop(id);
|
||||
assert.ok(prop, `media screen ${id} is gone`);
|
||||
assert.match(prop.kind, /^tera:screen\./, `${id} is no longer a screen`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every prop a robot station is anchored to", () => {
|
||||
for (const id of [
|
||||
"store-shelf-02",
|
||||
"works-locker-02",
|
||||
"loft-locker-02",
|
||||
"jesse-shelf-01",
|
||||
"jesse-board-02",
|
||||
]) assert.ok(plan.prop(id), `robot station anchor ${id} is gone`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Every shipped pack still resolves, and still resolves to the same thing over
|
||||
* the wire as it does in the bundle.
|
||||
*
|
||||
* This is the file that stands between "deprioritised" and "broken". Two of the
|
||||
* three packs in this repo are not being invested in — `frontier-valley` is
|
||||
* published as `building` and `lumbridge-hq` is finished — and the LA content
|
||||
* pass edits shared helpers, shared constants and the schema all three are
|
||||
* authored against. A pack nothing asserts on is a pack that stops resolving
|
||||
* the first time somebody changes a helper two directories away, and the
|
||||
* failure is silent: `Plan` never throws, it drops the offending item and
|
||||
* records a line in `problems` that nothing reads.
|
||||
*
|
||||
* The JSON round trip is here for the same reason and caught a real defect.
|
||||
* CONTRACT.md §2 says 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**. Both
|
||||
* `mateo-court.ts` and `frontier-valley.ts` had a `scatter()` helper assigning
|
||||
* `elevation: opts.elevation` unconditionally, which put 283 and 106
|
||||
* undefined-valued keys into their exported values respectively. `JSON.stringify`
|
||||
* drops those keys, so the served pack was a different object from the bundled
|
||||
* one — invisible in every renderer, and exactly the sort of thing that turns
|
||||
* into a two-day bug the first time a self-hoster round-trips a pack through the
|
||||
* office API and finds their props have moved.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { Plan } from "../../interiors/plan.ts";
|
||||
import type { Office } from "../../interiors/types.ts";
|
||||
import LUMBRIDGE_HQ from "../../offices/lumbridge-hq.ts";
|
||||
import FRONTIER_VALLEY from "../../offices/frontier-valley.ts";
|
||||
import MATEO_COURT from "../../offices/mateo-court.ts";
|
||||
import { OFFICE_SITES } from "../../offices/sites.ts";
|
||||
|
||||
const PACKS: readonly Office[] = [LUMBRIDGE_HQ, FRONTIER_VALLEY, MATEO_COURT];
|
||||
|
||||
/** Every path in `value` whose key is present and whose value is `undefined`. */
|
||||
function undefinedKeys(value: unknown, path: string, out: string[]): void {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, i) => undefinedKeys(item, `${path}[${i}]`, out));
|
||||
return;
|
||||
}
|
||||
if (value === null || typeof value !== "object") return;
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (entry === undefined) out.push(`${path}.${key}`);
|
||||
else undefinedKeys(entry, `${path}.${key}`, out);
|
||||
}
|
||||
}
|
||||
|
||||
describe("every shipped pack resolves", () => {
|
||||
for (const pack of PACKS) {
|
||||
/**
|
||||
* At **both** depths, because they are different builds and only one of them
|
||||
* is the one a visitor gets. `PlanOptions.depth` skips every item marked
|
||||
* `audience: "private"` before it is resolved, so a pack can be clean at
|
||||
* `"full"` and drop something at `"public"` — a device standing on a private
|
||||
* prop is the case this build introduced.
|
||||
*/
|
||||
for (const depth of ["full", "public"] as const) {
|
||||
it(`${pack.id} at ${depth} depth reports no problems`, () => {
|
||||
const plan = new Plan(pack, { depth, warn: false });
|
||||
assert.deepEqual(
|
||||
plan.problems.map((p) => `${p.where}: ${p.message} (${p.action})`),
|
||||
[],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it(`${pack.id} survives a JSON round trip unchanged`, () => {
|
||||
// Strict deep equality, which treats `{ a: undefined }` and `{}` as
|
||||
// different objects. That is the whole point: they *are* different
|
||||
// objects, and only one of them survives `JSON.stringify`.
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(pack)), pack);
|
||||
const stray: string[] = [];
|
||||
undefinedKeys(pack, pack.id, stray);
|
||||
assert.deepEqual(stray, [], `${pack.id} carries undefined-valued keys`);
|
||||
});
|
||||
|
||||
/**
|
||||
* `main.ts` spawns the walker at `viewpoints[0].focus.at` — that is not a
|
||||
* camera target in this one case, it is a coordinate a person stands on. A
|
||||
* pack whose first viewpoint frames a shot from outside the building looks
|
||||
* fine in the legend and spawns the visitor inside a wall.
|
||||
*/
|
||||
it(`${pack.id} arrives somewhere a walker can stand`, () => {
|
||||
const plan = new Plan(pack, { warn: false });
|
||||
const arrival = plan.arrival();
|
||||
assert.ok(arrival, `${pack.id} declares no viewpoints`);
|
||||
const room = plan.roomAt(arrival.levelId, arrival.focus.at);
|
||||
assert.ok(room, `${pack.id} arrives at ${JSON.stringify(arrival.focus.at)}, which is in no room`);
|
||||
for (const [dx, dz] of [[0.4, 0], [-0.4, 0], [0, 0.4], [0, -0.4]] as const) {
|
||||
const step = { x: arrival.focus.at.x + dx, z: arrival.focus.at.z + dz };
|
||||
if (!plan.blocked(arrival.levelId, arrival.focus.at, step, 0.3)) return;
|
||||
}
|
||||
assert.fail(`${pack.id} arrives in a spot with no clear step in any direction`);
|
||||
});
|
||||
|
||||
it(`${pack.id} keeps every viewpoint on a level that exists`, () => {
|
||||
const plan = new Plan(pack, { warn: false });
|
||||
const levels = new Set(plan.levels.map((level) => level.id));
|
||||
// `Plan` drops a viewpoint whose level does not resolve, so a count that
|
||||
// matches the authored one is the assertion that none were dropped.
|
||||
assert.equal(plan.viewpoints.length, pack.viewpoints.length);
|
||||
for (const view of plan.viewpoints) {
|
||||
assert.ok(levels.has(view.levelId), `${pack.id}/${view.id} is on nothing`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The in-development pack stays in development.
|
||||
*
|
||||
* `frontier-valley` is deprioritised, which is a decision about investment and
|
||||
* not about correctness: it must keep resolving, and it must keep telling a
|
||||
* visitor the truth about itself. A content pass that quietly promoted it to
|
||||
* `active` would put a half-furnished hangar in the same sentence as two
|
||||
* finished studios.
|
||||
*/
|
||||
describe("the published runtime status", () => {
|
||||
it("still calls Frontier Valley a building site and the two studios active", () => {
|
||||
assert.deepEqual(
|
||||
OFFICE_SITES.map(({ id, status }) => `${id}:${status}`),
|
||||
["lumbridge-hq:active", "frontier-valley:building", "mateo-court:active"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
# Test home for the `render` workstream.
|
||||
#
|
||||
# Each build workstream owns its own subdirectory so eight builders can add
|
||||
# suites in parallel without ever colliding on a path. `npm test` picks these
|
||||
# up through the widened `src/test/**/*.test.ts` glob in package.json.
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* The environment rig: lifecycle, caching and the contract it must not break.
|
||||
*
|
||||
* There is no GL context under `node --test`, so the renderer here is a fake —
|
||||
* but a fake of a very specific kind. `PMREMGenerator` never touches WebGL
|
||||
* directly: it allocates plain `WebGLRenderTarget` objects, builds plain
|
||||
* meshes, and reaches the GPU only through `renderer.render`,
|
||||
* `renderer.setRenderTarget` and a handful of state accessors. Stubbing those
|
||||
* out runs the *real* generator, the real target allocation and the real
|
||||
* blur chain, and leaves only the pixels unwritten. So these tests exercise the
|
||||
* actual code path the browser takes, which is the difference between testing
|
||||
* the rig and testing a mock of it.
|
||||
*
|
||||
* The one assertion that needs the fake to be more than a no-op is the dispose
|
||||
* check: the render targets the rig allocates are private to it, so they are
|
||||
* captured on their way through `setRenderTarget` and identified afterwards by
|
||||
* the texture the scene ended up holding.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createEnvironmentRig } from "../../engine/environmentRig.ts";
|
||||
import type { LightingState } from "../../engine/types.ts";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
|
||||
// ---- A renderer that allocates but does not draw ---------------------------
|
||||
|
||||
interface FakeRenderer {
|
||||
renders: number;
|
||||
targets: Set<THREE.WebGLRenderTarget>;
|
||||
failNext: boolean;
|
||||
as(): THREE.WebGLRenderer;
|
||||
}
|
||||
|
||||
function fakeRenderer(): FakeRenderer {
|
||||
const targets = new Set<THREE.WebGLRenderTarget>();
|
||||
const state = {
|
||||
renders: 0,
|
||||
targets,
|
||||
failNext: false,
|
||||
as(): THREE.WebGLRenderer {
|
||||
return stub as unknown as THREE.WebGLRenderer;
|
||||
},
|
||||
};
|
||||
|
||||
const stub = {
|
||||
autoClear: true,
|
||||
toneMapping: THREE.NoToneMapping,
|
||||
xr: { enabled: false },
|
||||
state: { buffers: { depth: { getReversed: () => false } } },
|
||||
getRenderTarget: () => null,
|
||||
getActiveCubeFace: () => 0,
|
||||
getActiveMipmapLevel: () => 0,
|
||||
getClearColor: (target: THREE.Color) => target,
|
||||
getClearAlpha: () => 1,
|
||||
setClearColor: () => {},
|
||||
setClearAlpha: () => {},
|
||||
clearDepth: () => {},
|
||||
compile: () => {},
|
||||
setRenderTarget(target: THREE.WebGLRenderTarget | null) {
|
||||
if (target) targets.add(target);
|
||||
},
|
||||
render() {
|
||||
if (state.failNext) {
|
||||
state.failNext = false;
|
||||
throw new Error("simulated context loss");
|
||||
}
|
||||
state.renders++;
|
||||
},
|
||||
};
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
// ---- Lighting states -------------------------------------------------------
|
||||
|
||||
function lightingState(overrides: Partial<LightingState> = {}): LightingState {
|
||||
return {
|
||||
sun: { direction: [0.31, 0.86, 0.4], color: 0xfff3e0, intensity: 2.35 },
|
||||
hemisphere: { sky: 0xdcecf7, ground: 0x6b6f5e, intensity: 0.92 },
|
||||
ambient: { color: 0xffffff, intensity: 0.24 },
|
||||
sky: { top: 0x8fb8d8, horizon: 0xd9e6ee },
|
||||
fog: { color: 0xd9e6ee, near: 210, far: 460 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** The render target the scene's environment texture came out of. */
|
||||
function targetOf(fake: FakeRenderer, scene: THREE.Scene): THREE.WebGLRenderTarget {
|
||||
for (const target of fake.targets) {
|
||||
if (target.texture === scene.environment) return target;
|
||||
}
|
||||
throw new Error("no allocated render target owns the scene's environment texture");
|
||||
}
|
||||
|
||||
// ---- Lifecycle -------------------------------------------------------------
|
||||
|
||||
for (const kind of ["office", "city"] as const) {
|
||||
test(`apply(..., "${kind}") leaves a texture on the scene and dispose frees it`, () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
rig.apply(scene, lightingState(), kind);
|
||||
|
||||
assert.ok(
|
||||
scene.environment instanceof THREE.Texture,
|
||||
"apply must leave a real texture on the scene, not a placeholder",
|
||||
);
|
||||
assert.equal(scene.environmentIntensity, 1);
|
||||
assert.ok(fake.renders > 0, "the PMREM chain never rendered");
|
||||
|
||||
// The PMREM target, identified by the texture the scene is holding.
|
||||
const target = targetOf(fake, scene);
|
||||
let freed = false;
|
||||
target.addEventListener("dispose", () => {
|
||||
freed = true;
|
||||
});
|
||||
|
||||
rig.dispose();
|
||||
|
||||
assert.equal(scene.environment, null, "dispose must take the environment back off the scene");
|
||||
assert.ok(freed, "dispose must free the PMREM render target, not just drop the reference");
|
||||
});
|
||||
}
|
||||
|
||||
test("apply adds nothing to the scene graph", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const scene = new THREE.Scene();
|
||||
scene.add(new THREE.Object3D());
|
||||
|
||||
rig.apply(scene, lightingState(), "office");
|
||||
rig.apply(scene, lightingState(), "city");
|
||||
|
||||
// The environment is a property, not a child. A rig that parented its probe
|
||||
// room into the caller's scene would light the office with a nine-quad box
|
||||
// floating inside it.
|
||||
assert.equal(scene.children.length, 1);
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
test("dispose clears every scene the rig ever wrote to", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const a = new THREE.Scene();
|
||||
const b = new THREE.Scene();
|
||||
|
||||
rig.apply(a, lightingState(), "city");
|
||||
rig.apply(b, lightingState(), "city");
|
||||
assert.ok(a.environment);
|
||||
assert.ok(b.environment);
|
||||
|
||||
rig.dispose();
|
||||
assert.equal(a.environment, null);
|
||||
assert.equal(b.environment, null);
|
||||
});
|
||||
|
||||
test("apply after dispose is inert rather than fatal", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const scene = new THREE.Scene();
|
||||
rig.dispose();
|
||||
|
||||
rig.apply(scene, lightingState(), "city");
|
||||
assert.equal(scene.environment, null);
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
// ---- Caching ---------------------------------------------------------------
|
||||
|
||||
test("an unchanged lighting state does not rebuild", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
rig.apply(scene, lightingState(), "city");
|
||||
const first = scene.environment;
|
||||
const after = fake.renders;
|
||||
|
||||
for (let i = 0; i < 20; i++) rig.apply(scene, lightingState(), "city");
|
||||
|
||||
assert.equal(scene.environment, first, "the environment texture was replaced for no reason");
|
||||
assert.equal(fake.renders, after, "the PMREM chain ran again for an unchanged sky");
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
test("a change below the quantisation step does not rebuild", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
rig.apply(scene, lightingState(), "city");
|
||||
const first = scene.environment;
|
||||
|
||||
// `Atmosphere` interpolates continuously, so every field of a LightingState
|
||||
// moves a fraction every frame. Rebuilding on that would run the whole chain
|
||||
// sixty times a second to produce sixty indistinguishable environments.
|
||||
rig.apply(
|
||||
scene,
|
||||
lightingState({
|
||||
sun: { direction: [0.3104, 0.8601, 0.4002], color: 0xfff3e1, intensity: 2.352 },
|
||||
}),
|
||||
"city",
|
||||
);
|
||||
|
||||
assert.equal(scene.environment, first);
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
test("a real change of hour rebuilds, and frees what it replaced", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
rig.apply(scene, lightingState(), "city");
|
||||
const noon = targetOf(fake, scene);
|
||||
let freed = false;
|
||||
noon.addEventListener("dispose", () => {
|
||||
freed = true;
|
||||
});
|
||||
|
||||
rig.apply(
|
||||
scene,
|
||||
lightingState({
|
||||
sun: { direction: [0.86, 0.06, -0.5], color: 0xc2795c, intensity: 0.58 },
|
||||
sky: { top: 0x2a4275, horizon: 0x9a6a63 },
|
||||
}),
|
||||
"city",
|
||||
);
|
||||
|
||||
assert.notEqual(scene.environment, noon.texture, "sunset must not reflect noon's sky");
|
||||
assert.ok(freed, "the replaced PMREM target leaked");
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
test("city and office are cached separately and do not evict each other", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const city = new THREE.Scene();
|
||||
const office = new THREE.Scene();
|
||||
|
||||
rig.apply(city, lightingState(), "city");
|
||||
const cityEnv = city.environment;
|
||||
rig.apply(office, lightingState(), "office");
|
||||
const officeEnv = office.environment;
|
||||
|
||||
assert.notEqual(cityEnv, officeEnv, "a room and a sky must not be the same environment");
|
||||
|
||||
const before = fake.renders;
|
||||
rig.apply(city, lightingState(), "city");
|
||||
assert.equal(city.environment, cityEnv);
|
||||
assert.equal(fake.renders, before, "the office build evicted the city's cached environment");
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
test("rebuilding one kind does not repoint scenes on the other", () => {
|
||||
// A page holds both at once — CONTRACT.md §1 keeps the city alive and paused
|
||||
// while an office is on screen — so the loop that repoints scenes after a
|
||||
// rebuild has to know which kind each scene is on. Getting this wrong lights
|
||||
// an office through a wall with the city's sunset.
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const city = new THREE.Scene();
|
||||
const office = new THREE.Scene();
|
||||
|
||||
rig.apply(city, lightingState(), "city");
|
||||
rig.apply(office, lightingState(), "office");
|
||||
const officeEnv = office.environment;
|
||||
|
||||
rig.apply(
|
||||
city,
|
||||
lightingState({
|
||||
sun: { direction: [0.86, 0.06, -0.5], color: 0xc2795c, intensity: 0.58 },
|
||||
sky: { top: 0x2a4275, horizon: 0x9a6a63 },
|
||||
}),
|
||||
"city",
|
||||
);
|
||||
|
||||
assert.equal(office.environment, officeEnv, "the office was handed the city's new sky");
|
||||
assert.notEqual(city.environment, officeEnv);
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
test("a scene left holding a freed target is repointed at the replacement", () => {
|
||||
// The other half of the same loop: two city scenes, one rebuild, and the one
|
||||
// that did not ask must not be left pointing at a disposed render target.
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const a = new THREE.Scene();
|
||||
const b = new THREE.Scene();
|
||||
|
||||
rig.apply(a, lightingState(), "city");
|
||||
rig.apply(b, lightingState(), "city");
|
||||
|
||||
rig.apply(
|
||||
a,
|
||||
lightingState({ sun: { direction: [0.86, 0.06, -0.5], color: 0xc2795c, intensity: 0.58 } }),
|
||||
"city",
|
||||
);
|
||||
|
||||
assert.equal(b.environment, a.environment, "the second city scene kept a freed texture");
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
// ---- Degradation -----------------------------------------------------------
|
||||
|
||||
test("a renderer that throws costs the environment, not the frame", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
const warnings: unknown[] = [];
|
||||
const warn = console.warn;
|
||||
console.warn = (...args: unknown[]) => warnings.push(args);
|
||||
try {
|
||||
fake.failNext = true;
|
||||
rig.apply(scene, lightingState(), "city");
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
}
|
||||
|
||||
assert.equal(scene.environment, null, "a failed build must not leave a half-made environment");
|
||||
assert.equal(warnings.length, 1, "a missing environment is undiagnosable from the picture alone");
|
||||
|
||||
// And it recovers: the next apply tries again rather than latching off.
|
||||
rig.apply(scene, lightingState(), "city");
|
||||
const recovered: unknown = scene.environment;
|
||||
assert.ok(recovered instanceof THREE.Texture);
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
// ---- CONTRACT §4 -----------------------------------------------------------
|
||||
|
||||
test("the rig constructs no light of any kind", () => {
|
||||
// The literal form of the rule, checked against the source, because the whole
|
||||
// value of CONTRACT.md §4 is that there is exactly one light owner and this is
|
||||
// the module most likely to be tempted into becoming a second one.
|
||||
const source = readFileSync(path.join(ROOT, "src/engine/environmentRig.ts"), "utf8");
|
||||
const lights = source.match(
|
||||
/new\s+THREE\.(Directional|Point|Spot|Rect(Area)?|Hemisphere|Ambient|Light\b)/g,
|
||||
);
|
||||
assert.equal(lights, null, `environmentRig constructs lights: ${lights?.join(", ")}`);
|
||||
// And no import of three's fixed studio room either — the office probe has to
|
||||
// stay derived from the LightingState, or the reflections stop knowing what
|
||||
// time it is. (The prose above the code discusses `RoomEnvironment` by name,
|
||||
// hence matching the import statement rather than the word.)
|
||||
assert.doesNotMatch(source, /^\s*import[^\n]*RoomEnvironment/m);
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* The material roles, and the two new texture channels they bind.
|
||||
*
|
||||
* Four roles land here that three other workstreams are waiting on —
|
||||
* `deviceShell`, `deviceMesh`, `deviceIndicator`, `screenContent` — plus the
|
||||
* `alphaMap` wiring that turns `foliage` from a flat green shard into a leaf.
|
||||
* The assertions are deliberately about *bindings* rather than about numbers:
|
||||
* a roughness value is a judgement and will be re-tuned, but a `screenContent`
|
||||
* that has stopped carrying an `emissiveMap` is a screen that has gone back to
|
||||
* being a lamp, and a `foliage` with no `alphaMap` is the worst-looking asset in
|
||||
* the product returning.
|
||||
*
|
||||
* The registry is fed a stub `TextureBin` throughout. `node --test` has no
|
||||
* canvas, so the real bin correctly returns `null` for everything (see its own
|
||||
* comment about the zero-config boot), and a null map cannot demonstrate that
|
||||
* the map was bound to the right slot. The stub hands back real
|
||||
* `THREE.Texture`s named after what was asked for, so every assertion below is
|
||||
* about the code in `MaterialRegistry.create`, which is the code under test.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { MaterialRegistry, type SurfaceRole } from "../../assets/materials.ts";
|
||||
import {
|
||||
DEFAULT_INTERIOR_PALETTE,
|
||||
LIGHTNESS_HEADROOM,
|
||||
ROLE_SHIFTS,
|
||||
derivePalette,
|
||||
} from "../../assets/palette.ts";
|
||||
import {
|
||||
NORMAL_MAP_KINDS,
|
||||
SCREEN_UI_VARIANTS,
|
||||
TextureBin,
|
||||
type TextureKind,
|
||||
} from "../../assets/textures.ts";
|
||||
|
||||
/** A bin that draws nothing but answers as though it had. */
|
||||
class StubBin extends TextureBin {
|
||||
readonly asked: string[] = [];
|
||||
|
||||
override get(kind: TextureKind, variant = 0): THREE.Texture | null {
|
||||
this.asked.push(`${kind}#${variant}`);
|
||||
const texture = new THREE.Texture();
|
||||
texture.name = `${kind}#${variant}`;
|
||||
return texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stubbed for the same reason `get` is, with one difference that matters: the
|
||||
* real `normal()` needs no canvas and would work here, but it would also build
|
||||
* six 512² fields per registry. What is under test is the *binding*, so the
|
||||
* stub keeps the one property an assertion can hang off — relief exists for
|
||||
* exactly the kinds that have a recipe and for no others.
|
||||
*/
|
||||
override normal(kind: TextureKind): THREE.Texture | null {
|
||||
if (!NORMAL_MAP_KINDS.includes(kind)) return null;
|
||||
this.asked.push(`${kind}!normal`);
|
||||
const texture = new THREE.Texture();
|
||||
texture.name = `${kind}!normal`;
|
||||
return texture;
|
||||
}
|
||||
|
||||
override variants(kind: TextureKind): number {
|
||||
return kind === "screenUI" ? SCREEN_UI_VARIANTS : 1;
|
||||
}
|
||||
}
|
||||
|
||||
function registry(quality: "low" | "medium" | "high" = "high"): {
|
||||
materials: MaterialRegistry;
|
||||
bin: StubBin;
|
||||
} {
|
||||
const bin = new StubBin(quality);
|
||||
return { materials: new MaterialRegistry({ quality, textures: bin }), bin };
|
||||
}
|
||||
|
||||
const NEW_ROLES: SurfaceRole[] = ["deviceShell", "deviceMesh", "deviceIndicator", "screenContent"];
|
||||
|
||||
// ---- The published names ---------------------------------------------------
|
||||
|
||||
test("the registry exposes the four device roles", () => {
|
||||
const { materials } = registry();
|
||||
for (const role of NEW_ROLES) {
|
||||
const material = materials.get(role);
|
||||
assert.ok(material instanceof THREE.Material, `${role} did not resolve to a material`);
|
||||
assert.equal(material.name, role);
|
||||
// One material per role per registry — the sharing that is half the
|
||||
// draw-call budget.
|
||||
assert.equal(materials.get(role), material);
|
||||
}
|
||||
});
|
||||
|
||||
test("every new role carries a palette derivation", () => {
|
||||
// The compiler enforces this for `ROLE_SHIFTS`, but not that the shift
|
||||
// actually produced a colour, and a role missing from the derived palette
|
||||
// would silently construct a material with `color: undefined` (black).
|
||||
for (const role of NEW_ROLES) {
|
||||
assert.ok(role in ROLE_SHIFTS, `${role} has no shift`);
|
||||
const color = DEFAULT_INTERIOR_PALETTE[role];
|
||||
assert.equal(typeof color, "number");
|
||||
assert.ok(color >= 0 && color <= 0xffffff);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- What each new role is -------------------------------------------------
|
||||
|
||||
test("the device shell and grille are metal, which they can now afford to be", () => {
|
||||
const { materials } = registry();
|
||||
const shell = materials.get("deviceShell") as THREE.MeshStandardMaterial;
|
||||
const mesh = materials.get("deviceMesh") as THREE.MeshStandardMaterial;
|
||||
|
||||
assert.ok(shell.metalness > 0.15, "a device body with no metalness reads as painted plastic");
|
||||
assert.ok(mesh.metalness > 0.6, "a speaker grille is metal");
|
||||
// Double-sided: you see through a grille to the inside of the housing, and
|
||||
// that is most of what makes a speaker look like a speaker.
|
||||
assert.equal(mesh.side, THREE.DoubleSide);
|
||||
assert.equal(shell.side, THREE.FrontSide);
|
||||
});
|
||||
|
||||
test("the indicator emits, and a tint carries into the emission", () => {
|
||||
const { materials } = registry();
|
||||
const led = materials.get("deviceIndicator") as THREE.MeshStandardMaterial;
|
||||
assert.equal(led.emissiveIntensity, 1, "an LED is a light source, not a lit surface");
|
||||
assert.notEqual(led.emissive.getHex(), 0x000000);
|
||||
|
||||
// The device render layer tints this per state. That path must reach the
|
||||
// emissive term, or a powered mic and an unpowered one glow the same colour.
|
||||
const hot = materials.tinted("deviceIndicator", 0xff3a1e) as THREE.MeshStandardMaterial;
|
||||
assert.notEqual(hot, led);
|
||||
assert.equal(hot.color.getHex(THREE.SRGBColorSpace), 0xff3a1e);
|
||||
assert.equal(hot.emissive.getHex(THREE.SRGBColorSpace), 0xff3a1e);
|
||||
// Cached: a hundred indicators in three states are three materials.
|
||||
assert.equal(materials.tinted("deviceIndicator", 0xff3a1e), hot);
|
||||
});
|
||||
|
||||
test("screen content is lit through its own map, not flat across the panel", () => {
|
||||
const { materials } = registry();
|
||||
const screen = materials.get("screenContent") as THREE.MeshStandardMaterial;
|
||||
|
||||
assert.ok(screen.map, "screenContent must carry the drawn interface as its colour map");
|
||||
assert.equal(screen.map?.name, "screenUI#0");
|
||||
assert.ok(screen.emissiveMap, "screenContent must emit through the map, or it is a light box");
|
||||
assert.equal(screen.emissiveMap, screen.map, "the emissive map must be the same drawing");
|
||||
// White emissive, so the drawn colours are not tinted a second time on top of
|
||||
// `color` already tinting them.
|
||||
assert.equal(screen.emissive.getHex(), 0xffffff);
|
||||
assert.ok(screen.emissiveIntensity > 0.5);
|
||||
|
||||
// And the palette gets out of the map's way: this is the one non-neutral
|
||||
// texture in the library, so a mid-grey role colour would multiply it to mud.
|
||||
const hsl = { h: 0, s: 0, l: 0 };
|
||||
new THREE.Color(DEFAULT_INTERIOR_PALETTE.screenContent).getHSL(hsl, THREE.SRGBColorSpace);
|
||||
assert.ok(hsl.l > 0.8, `screenContent is L=${hsl.l.toFixed(2)}, too dark to pass a drawing through`);
|
||||
});
|
||||
|
||||
test("screen variants are separate cached materials, and variant 0 is the base", () => {
|
||||
const { materials } = registry();
|
||||
const base = materials.get("screenContent");
|
||||
|
||||
assert.equal(materials.variant("screenContent", 0), base, "variant 0 must not mint a duplicate");
|
||||
|
||||
const seen = new Set<THREE.Material>();
|
||||
for (let i = 0; i < SCREEN_UI_VARIANTS; i++) seen.add(materials.variant("screenContent", i));
|
||||
assert.equal(seen.size, SCREEN_UI_VARIANTS, "layouts collapsed onto the same material");
|
||||
|
||||
// Cached, and wrapping rather than throwing: `furnish.ts` batches per kind, so
|
||||
// a caller hands this a running prop index and must not have to bound it.
|
||||
assert.equal(
|
||||
materials.variant("screenContent", 2),
|
||||
materials.variant("screenContent", 2 + SCREEN_UI_VARIANTS),
|
||||
);
|
||||
assert.equal(materials.variant("screenContent", -1), materials.variant("screenContent", SCREEN_UI_VARIANTS - 1));
|
||||
|
||||
// A role with one layout ignores the index entirely.
|
||||
assert.equal(materials.variant("carpet", 3), materials.get("carpet"));
|
||||
});
|
||||
|
||||
// ---- The alpha channel -----------------------------------------------------
|
||||
|
||||
test("foliage is a cutout, not a rectangle", () => {
|
||||
const { materials } = registry();
|
||||
const leaf = materials.get("foliage") as THREE.MeshStandardMaterial;
|
||||
|
||||
assert.ok(leaf.alphaMap, "foliage without an alphaMap is the flat green shard on the live site");
|
||||
assert.equal(leaf.alphaMap?.name, "leafAlpha#0");
|
||||
assert.ok(leaf.alphaTest > 0.2 && leaf.alphaTest < 0.8, `alphaTest ${leaf.alphaTest} is at an edge of the range`);
|
||||
// Cutout, not blend: a leaf still writes depth, sorts as solid geometry and
|
||||
// casts a correctly-shaped shadow.
|
||||
assert.equal(leaf.transparent, false);
|
||||
assert.equal(leaf.depthWrite, true);
|
||||
assert.equal(leaf.side, THREE.DoubleSide);
|
||||
});
|
||||
|
||||
test("a role with no alpha texture is not given an alphaTest", () => {
|
||||
const { materials } = registry();
|
||||
const carpet = materials.get("carpet") as THREE.MeshStandardMaterial;
|
||||
assert.equal(carpet.alphaMap, null);
|
||||
// A threshold with no map to test against compiles a branch into the shader
|
||||
// for a comparison that always passes.
|
||||
assert.equal(carpet.alphaTest, 0);
|
||||
});
|
||||
|
||||
test("the leaf keeps its shape when it is ghosted", () => {
|
||||
const { materials } = registry();
|
||||
const ghost = materials.ghostOf("foliage");
|
||||
// The colour map is decoration and is dropped on purpose; the coverage map is
|
||||
// *shape*, and a ghost with no cutout is the shard again at 18% opacity.
|
||||
assert.equal(ghost.map, null);
|
||||
assert.ok(ghost.alphaMap, "ghostOf must not drop the coverage map with the colour map");
|
||||
assert.equal(ghost.transparent, true);
|
||||
});
|
||||
|
||||
test("low quality still cuts the leaf out", () => {
|
||||
// `low` means no *shading* maps. An alpha cutout is one fetch and a discard,
|
||||
// and the alternative at low quality is not a cheaper plant, it is a shard.
|
||||
const { materials } = registry("low");
|
||||
const leaf = materials.get("foliage") as THREE.MeshLambertMaterial;
|
||||
assert.ok(leaf instanceof THREE.MeshLambertMaterial, "low quality must stay Lambert");
|
||||
assert.ok(leaf.alphaMap);
|
||||
assert.ok(leaf.alphaTest > 0);
|
||||
});
|
||||
|
||||
// ---- The widened lightness band --------------------------------------------
|
||||
|
||||
test("the band was widened, and the darkest roles actually went darker", () => {
|
||||
assert.ok(LIGHTNESS_HEADROOM > 0.14, "the headroom was not relaxed");
|
||||
|
||||
const palette = derivePalette();
|
||||
const hsl = { h: 0, s: 0, l: 0 };
|
||||
const lightnessOf = (role: SurfaceRole) => {
|
||||
new THREE.Color(palette[role]).getHSL(hsl, THREE.SRGBColorSpace);
|
||||
return hsl.l;
|
||||
};
|
||||
|
||||
// Under the old 0.14 headroom the floor of the band was L≈0.248 and these
|
||||
// three were all clamped to it, which is why a screen bezel was a mid-grey.
|
||||
for (const role of ["screenBezel", "deviceShell", "deviceMesh"] as SurfaceRole[]) {
|
||||
const l = lightnessOf(role);
|
||||
assert.ok(l < 0.248, `${role} is L=${l.toFixed(3)}, still clamped up into mid-grey`);
|
||||
assert.ok(l > 0.05, `${role} is L=${l.toFixed(3)}, past charcoal into black`);
|
||||
}
|
||||
|
||||
// Saturation gets no concession, and widening the lightness band must not
|
||||
// have quietly widened that too: every role stays inside the city's own
|
||||
// saturation range.
|
||||
const city = Object.values(palette).map((hex) => {
|
||||
new THREE.Color(hex).getHSL(hsl, THREE.SRGBColorSpace);
|
||||
return hsl.s;
|
||||
});
|
||||
assert.ok(Math.max(...city) <= 0.6, "an interior role has become more chromatic than the city");
|
||||
});
|
||||
|
||||
// ---- Glass -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The one role that changes material *class* with quality.
|
||||
*
|
||||
* A 22%-opacity blend is a grey film. Transmission is glass: it refracts what
|
||||
* is behind it, keeps a specular highlight and an environment reflection on top,
|
||||
* and turns `roughness` into frosting instead of into a matte grey. What must
|
||||
* survive the change is the note at the role itself — glass writes no depth —
|
||||
* because three.js's own advice for a transmissive material is the opposite,
|
||||
* and an office is a box of glass boxes where the first sheet the sorter reaches
|
||||
* would erase the two behind it.
|
||||
*/
|
||||
test("glazing at medium and high is physical glass that still writes no depth", () => {
|
||||
for (const quality of ["medium", "high"] as const) {
|
||||
const { materials } = registry(quality);
|
||||
const glass = materials.get("glazing");
|
||||
assert.ok(
|
||||
glass instanceof THREE.MeshPhysicalMaterial,
|
||||
`glazing at ${quality} is ${glass.type}, not physical glass`,
|
||||
);
|
||||
const physical = glass as THREE.MeshPhysicalMaterial;
|
||||
assert.ok(physical.transmission > 0, "glazing has no transmission");
|
||||
assert.equal(physical.ior, 1.5, "glazing is no longer soda-lime glass");
|
||||
assert.ok(physical.thickness > 0, "glazing has no thickness to refract through");
|
||||
// The line the spec asked to be kept, kept.
|
||||
assert.equal(physical.depthWrite, false, "glass started writing depth");
|
||||
// Transmission carries the see-through; blending it as well would leave the
|
||||
// sheet four fifths invisible and refracting the fifth that was left.
|
||||
assert.equal(physical.transparent, false);
|
||||
assert.equal(physical.opacity, 1);
|
||||
// three.js scales transmission by `1 - metalness`, so any metalness at all
|
||||
// is that fraction of the glass quietly turned back into a mirror.
|
||||
assert.equal(physical.metalness, 0);
|
||||
assert.equal(physical.side, THREE.DoubleSide);
|
||||
}
|
||||
});
|
||||
|
||||
test("glazing at low falls back to a blended sheet rather than to nothing", () => {
|
||||
const { materials } = registry("low");
|
||||
const glass = materials.get("glazing");
|
||||
// `low` is the integrated-GPU setting and a transmission pass is a full
|
||||
// render-target copy, so the blend has to stay reachable — and it is still a
|
||||
// window, so it still must not write depth.
|
||||
assert.ok(glass instanceof THREE.MeshLambertMaterial);
|
||||
assert.equal(glass.transparent, true);
|
||||
assert.ok(glass.opacity < 0.5);
|
||||
assert.equal(glass.depthWrite, false);
|
||||
});
|
||||
|
||||
test("a ghosted sheet of glass stops refracting", () => {
|
||||
const { materials } = registry("high");
|
||||
const ghost = materials.ghostOf("glazing") as THREE.MeshPhysicalMaterial;
|
||||
// The occlusion fade is a hint and is rebuilt as the camera moves; putting it
|
||||
// through the transmission pass buys nothing and costs a target copy.
|
||||
assert.equal(ghost.transmission, 0);
|
||||
assert.equal(ghost.transparent, true);
|
||||
assert.equal(ghost.depthWrite, false);
|
||||
});
|
||||
|
||||
// ---- The relief channel ----------------------------------------------------
|
||||
|
||||
test("every role with a texture also carries its relief", () => {
|
||||
const { materials } = registry("high");
|
||||
// Six kinds have relief; a whiteboard and a screen are flat. A role gets the
|
||||
// relief of its own texture or nothing — there is no third option, and no role
|
||||
// opts in separately.
|
||||
const expected: [SurfaceRole, boolean][] = [
|
||||
["carpet", true],
|
||||
["woodFloor", true],
|
||||
["tile", true],
|
||||
["ceilingTile", true],
|
||||
["plaster", true],
|
||||
["chairFabric", true],
|
||||
["whiteboard", false],
|
||||
["screenContent", false],
|
||||
["metalTrim", false],
|
||||
["deviceShell", false],
|
||||
];
|
||||
for (const [role, hasRelief] of expected) {
|
||||
const material = materials.get(role) as THREE.MeshStandardMaterial;
|
||||
if (hasRelief) {
|
||||
assert.ok(material.normalMap, `${role} lost its normal map`);
|
||||
assert.match(material.normalMap.name, /!normal$/);
|
||||
} else {
|
||||
assert.equal(material.normalMap, null, `${role} gained relief it has no surface for`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("relief follows a tint and a variant, because it is the same surface", () => {
|
||||
const { materials } = registry("high");
|
||||
const tinted = materials.tinted("carpet", 0x884422) as THREE.MeshStandardMaterial;
|
||||
assert.ok(tinted.normalMap, "a recoloured carpet lost its pile");
|
||||
const variant = materials.variant("screenContent", 3) as THREE.MeshStandardMaterial;
|
||||
assert.equal(variant.normalMap, null, "a screen layout grew relief");
|
||||
});
|
||||
|
||||
test("low quality binds no relief at all", () => {
|
||||
const { materials } = registry("low");
|
||||
const carpet = materials.get("carpet") as THREE.MeshLambertMaterial;
|
||||
// `low` exists to compile the cheap shader. A normal map is a fetch and a
|
||||
// matrix multiply per fragment, which is exactly the cost it is refusing.
|
||||
assert.ok(carpet instanceof THREE.MeshLambertMaterial);
|
||||
assert.equal(carpet.normalMap, null);
|
||||
});
|
||||
|
||||
test("a ghost drops the relief with the colour, for the same reason", () => {
|
||||
const { materials } = registry("high");
|
||||
const ghost = materials.ghostOf("carpet") as THREE.MeshStandardMaterial;
|
||||
assert.equal(ghost.map, null);
|
||||
assert.equal(ghost.normalMap, null, "an 82%-transparent surface is still being bumped");
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* The draw-call reclaim in `engine/structures.ts`.
|
||||
*
|
||||
* These are budget tests, and they are here because the budget is the reason
|
||||
* anything else in this build can be made to look better. The city measured 616
|
||||
* draw calls against a cap of 650 while the office spent 8% of its triangle
|
||||
* allowance: indoors quality is nearly free, outdoors it is not, and every call
|
||||
* this module gives back is one the exterior Model X and the aircraft get to
|
||||
* spend. `scripts/performance-budget.mjs` is the real gate, but it needs a
|
||||
* built bundle, a browser and eleven seconds a cell — these run in
|
||||
* milliseconds and fail on the line that caused the regression.
|
||||
*
|
||||
* Two invariants, and they are the two ways this file has gone wrong before:
|
||||
*
|
||||
* 1. **A material is per colour, not per call site.** `roadRibbon` used to
|
||||
* close over `new THREE.MeshLambertMaterial({ color })`, so twelve
|
||||
* identical asphalt decks were twelve materials — and two meshes that do
|
||||
* not share a material can never be merged, whatever else you do.
|
||||
* 2. **Geometry is merged per bucket.** A suspension bridge used to arrive as
|
||||
* about thirty-four meshes of one colour.
|
||||
*
|
||||
* There is a third thing the tests below quietly guard, and it is the one that
|
||||
* fails silently: `mergeGeometries` returns `null` when the attribute sets
|
||||
* disagree, so a ribbon without UVs sitting in a bucket beside a tube that has
|
||||
* them loses the whole bucket. Asserting on merged vertex counts is what catches
|
||||
* that, because a dropped bucket looks exactly like a very efficient one.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createBridge, createBridges, createRoads } from "../../engine/structures.ts";
|
||||
import type { Bridge, City, Road } from "../../engine/types.ts";
|
||||
import type { World } from "../../engine/world.ts";
|
||||
|
||||
/**
|
||||
* The smallest thing `structures.ts` will accept: a flat projection, ground at
|
||||
* zero, and metres straight through.
|
||||
*
|
||||
* A real `World` builds a heightfield, which is 0.53M lattice points and a
|
||||
* couple of seconds — none of which any assertion here depends on.
|
||||
*/
|
||||
function flatWorld(city: Partial<City>): World {
|
||||
return {
|
||||
city: { roads: [], bridges: [], inlandWater: [], ...city } as unknown as City,
|
||||
project(lat: number, lng: number): [number, number] {
|
||||
return [(lng + 122) * 20, -(lat - 37) * 20];
|
||||
},
|
||||
groundAt(): number {
|
||||
return 0;
|
||||
},
|
||||
metres(value: number): number {
|
||||
return value / 100;
|
||||
},
|
||||
} as unknown as World;
|
||||
}
|
||||
|
||||
const GOLDEN_GATE: Bridge = {
|
||||
name: "golden-gate",
|
||||
path: [
|
||||
[37.806, -122.4756],
|
||||
[37.8199, -122.4783],
|
||||
[37.8324, -122.4796],
|
||||
],
|
||||
towers: [
|
||||
[37.8104, -122.4767],
|
||||
[37.8249, -122.4787],
|
||||
],
|
||||
deckHeight: 67,
|
||||
towerHeight: 227,
|
||||
sag: 0.45,
|
||||
color: 0xc0553b,
|
||||
};
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function materialsIn(root: THREE.Object3D): Set<THREE.Material> {
|
||||
const set = new Set<THREE.Material>();
|
||||
for (const mesh of meshes(root)) {
|
||||
if (Array.isArray(mesh.material)) for (const material of mesh.material) set.add(material);
|
||||
else set.add(mesh.material);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
// ---- Bridges ---------------------------------------------------------------
|
||||
|
||||
test("a suspension bridge is one material and one draw call", () => {
|
||||
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
|
||||
|
||||
// The spec's number is six; a bridge is painted one colour throughout, so
|
||||
// anything above one is a part that was left out of the bucket.
|
||||
const distinct = materialsIn(bridge);
|
||||
assert.ok(distinct.size <= 6, `the bridge holds ${distinct.size} materials`);
|
||||
assert.equal(distinct.size, 1, `the bridge holds ${distinct.size} materials, not one`);
|
||||
assert.equal(meshes(bridge).length, 1, "the bridge did not merge into one mesh");
|
||||
});
|
||||
|
||||
test("merging kept every part of the bridge", () => {
|
||||
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
|
||||
const merged = meshes(bridge)[0];
|
||||
assert.ok(merged);
|
||||
|
||||
// The arithmetic, because a bucket that failed to merge comes out as one
|
||||
// *span* of geometry and otherwise looks entirely healthy: a 3-point deck tube
|
||||
// is 7 × 5 = 35 vertices, two towers and four braces are 24 each = 144, three
|
||||
// cable spans at 25 × 6 = 450, and the hangers are 24 boxes of 24 less
|
||||
// whichever ones the deck-clearance test culls — call it 1,000 at the floor.
|
||||
const vertices = merged.geometry.getAttribute("position").count;
|
||||
assert.ok(vertices > 1_000, `the bridge merged down to ${vertices} vertices`);
|
||||
|
||||
// The merge only happens because every part carries the same attributes.
|
||||
for (const name of ["position", "normal", "uv"]) {
|
||||
assert.ok(merged.geometry.getAttribute(name), `the merged bridge has no ${name}`);
|
||||
}
|
||||
assert.ok(merged.geometry.getIndex(), "the merged bridge lost its index");
|
||||
|
||||
// A 227 m tower is the tallest thing on the board; it has to cast.
|
||||
assert.equal(merged.castShadow, true);
|
||||
});
|
||||
|
||||
test("the bridge is still shaped like a bridge after the merge", () => {
|
||||
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
|
||||
const merged = meshes(bridge)[0];
|
||||
assert.ok(merged);
|
||||
merged.geometry.computeBoundingBox();
|
||||
const box = merged.geometry.boundingBox;
|
||||
assert.ok(box);
|
||||
|
||||
// Towers to 2.27 units, deck at 0.67, cables sagging between. Baking the
|
||||
// transforms into the geometry is where a merge goes wrong — a part that lost
|
||||
// its translation collapses onto the origin and the box stops matching.
|
||||
assert.ok(Math.abs(box.max.y - 2.27) < 0.05, `the towers top out at ${box.max.y.toFixed(2)}`);
|
||||
assert.ok(box.min.y > 0, "something sank below the water line");
|
||||
assert.ok(box.max.x - box.min.x > 0.4, "the bridge has no span");
|
||||
});
|
||||
|
||||
test("two bridges are two draw calls, not sixty-eight", () => {
|
||||
const second: Bridge = { ...GOLDEN_GATE, name: "bay-bridge", color: 0x9aa6ad };
|
||||
const group = createBridges(flatWorld({ bridges: [GOLDEN_GATE, second] }));
|
||||
assert.equal(meshes(group).length, 2);
|
||||
// Different colours, so genuinely two materials. Each bridge builds its own
|
||||
// batch, which is deliberate: the cache cannot outlive the build, because
|
||||
// `createScene().dispose()` walks the scene disposing every material it finds
|
||||
// and a shared cache would hand the next board a disposed one.
|
||||
assert.equal(materialsIn(group).size, 2);
|
||||
});
|
||||
|
||||
// ---- Roads -----------------------------------------------------------------
|
||||
|
||||
test("identical roads share one material and one mesh", () => {
|
||||
const street: Road = {
|
||||
kind: "street",
|
||||
width: 0.1,
|
||||
path: [
|
||||
[37.7, -122.4],
|
||||
[37.75, -122.42],
|
||||
[37.8, -122.45],
|
||||
],
|
||||
};
|
||||
const group = createRoads(flatWorld({ roads: [street, street, street] }));
|
||||
|
||||
// Three streets, one colour: one draw call. Before the cache this was three
|
||||
// materials and three meshes, and it scaled with the pack.
|
||||
assert.equal(materialsIn(group).size, 1);
|
||||
assert.equal(meshes(group).length, 1);
|
||||
|
||||
const merged = meshes(group)[0];
|
||||
assert.ok(merged);
|
||||
// All three really are in there — three drapes of the same path.
|
||||
const vertices = merged.geometry.getAttribute("position").count;
|
||||
assert.ok(vertices > 100, `three roads merged to ${vertices} vertices`);
|
||||
assert.ok(merged.geometry.getAttribute("uv"), "the road deck lost the UVs merging depends on");
|
||||
});
|
||||
|
||||
test("a freeway keeps its median stroke as a second material", () => {
|
||||
const freeway: Road = {
|
||||
kind: "freeway",
|
||||
width: 0.14,
|
||||
path: [
|
||||
[37.7, -122.4],
|
||||
[37.9, -122.45],
|
||||
],
|
||||
};
|
||||
const group = createRoads(flatWorld({ roads: [freeway] }));
|
||||
// Two colours is two calls, and that is the floor rather than a regression:
|
||||
// the stroke is a different colour from the deck it sits on.
|
||||
assert.equal(meshes(group).length, 2);
|
||||
assert.equal(materialsIn(group).size, 2);
|
||||
});
|
||||
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* The two new texture kinds, and the bin that parameterises them.
|
||||
*
|
||||
* Drawing is done against a recording 2D context rather than a real one. There
|
||||
* is no canvas under `node --test`, and a real rasteriser would only let these
|
||||
* tests assert about pixels — which is a picture, which is exactly the thing
|
||||
* nobody should be asserting equality on. What *can* be pinned, and matters, is
|
||||
* the structure: that six genuinely different layouts exist rather than one
|
||||
* drawn six times, that the same variant is byte-identical run to run (the
|
||||
* drawings are seeded, and a `Math.random` slipping in would make an office
|
||||
* different on every reload), that the leaf stays inside the quad it is cut out
|
||||
* of, and that each kind gets the wrap mode and colour space its *use* demands.
|
||||
*
|
||||
* That last one is the subtle failure this file is really guarding. An
|
||||
* `alphaMap` sampled through an sRGB decode shifts every coverage value — a
|
||||
* cutout authored at 0.5 arrives at 0.21 — and the symptom is half a leaf, at
|
||||
* runtime, with nothing in the source looking wrong.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import {
|
||||
NORMAL_MAP_KINDS,
|
||||
SCREEN_UI_VARIANTS,
|
||||
TextureBin,
|
||||
type TextureKind,
|
||||
} from "../../assets/textures.ts";
|
||||
|
||||
// ---- A 2D context that records instead of rasterising -----------------------
|
||||
|
||||
/** One entry per drawing, in the order the canvases were created. */
|
||||
const logs: string[][] = [];
|
||||
|
||||
interface FakeCanvas {
|
||||
width: number;
|
||||
height: number;
|
||||
getContext(id: string): unknown;
|
||||
}
|
||||
|
||||
function installFakeDocument(): void {
|
||||
const document = {
|
||||
createElement(tag: string): FakeCanvas {
|
||||
if (tag !== "canvas") throw new Error(`unexpected element ${tag}`);
|
||||
const canvas: FakeCanvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext(): unknown {
|
||||
const log: string[] = [];
|
||||
logs.push(log);
|
||||
return makeContext(canvas, log);
|
||||
},
|
||||
};
|
||||
return canvas;
|
||||
},
|
||||
};
|
||||
(globalThis as { document?: unknown }).document = document;
|
||||
}
|
||||
|
||||
function makeContext(canvas: FakeCanvas, log: string[]): unknown {
|
||||
const record = (name: string, ...args: number[]) => {
|
||||
log.push(`${name}(${args.map((n) => n.toFixed(3)).join(",")})`);
|
||||
};
|
||||
const style = (name: string, value: unknown) => {
|
||||
log.push(`${name}=${String(value)}`);
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
_fillStyle: "",
|
||||
set fillStyle(v: unknown) {
|
||||
style("fillStyle", v);
|
||||
ctx._fillStyle = String(v);
|
||||
},
|
||||
get fillStyle() {
|
||||
return ctx._fillStyle;
|
||||
},
|
||||
set strokeStyle(v: unknown) {
|
||||
style("strokeStyle", v);
|
||||
},
|
||||
set lineWidth(v: number) {
|
||||
style("lineWidth", v);
|
||||
},
|
||||
set lineCap(v: string) {
|
||||
style("lineCap", v);
|
||||
},
|
||||
set globalCompositeOperation(v: string) {
|
||||
style("composite", v);
|
||||
},
|
||||
fillRect: (x: number, y: number, w: number, h: number) => record("fillRect", x, y, w, h),
|
||||
beginPath: () => log.push("beginPath()"),
|
||||
closePath: () => log.push("closePath()"),
|
||||
moveTo: (x: number, y: number) => record("moveTo", x, y),
|
||||
lineTo: (x: number, y: number) => record("lineTo", x, y),
|
||||
arc: (x: number, y: number, r: number) => record("arc", x, y, r),
|
||||
arcTo: (x1: number, y1: number, x2: number, y2: number, r: number) =>
|
||||
record("arcTo", x1, y1, x2, y2, r),
|
||||
bezierCurveTo: (a: number, b: number, c: number, d: number, e: number, f: number) =>
|
||||
record("bezierCurveTo", a, b, c, d, e, f),
|
||||
fill: () => log.push("fill()"),
|
||||
stroke: () => log.push("stroke()"),
|
||||
getImageData: (_x: number, _y: number, w: number, h: number) => {
|
||||
log.push(`getImageData(${w},${h})`);
|
||||
return { data: new Uint8ClampedArray(w * h * 4).fill(255), width: w, height: h };
|
||||
},
|
||||
putImageData: () => log.push("putImageData()"),
|
||||
};
|
||||
void canvas;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
installFakeDocument();
|
||||
|
||||
/** Draw one texture in isolation and hand back the texture and its op log. */
|
||||
function draw(
|
||||
kind: TextureKind,
|
||||
variant = 0,
|
||||
quality: "low" | "medium" | "high" = "high",
|
||||
): { texture: THREE.Texture | null; log: string[]; canvas: FakeCanvas | null } {
|
||||
const before = logs.length;
|
||||
const bin = new TextureBin(quality);
|
||||
const texture = bin.get(kind, variant);
|
||||
const log = logs[before] ?? [];
|
||||
const canvas = (texture?.image as FakeCanvas | undefined) ?? null;
|
||||
return { texture, log, canvas };
|
||||
}
|
||||
|
||||
// ---- screenUI ---------------------------------------------------------------
|
||||
|
||||
test("screenUI publishes at least the four variants the assets workstream needs", () => {
|
||||
assert.ok(SCREEN_UI_VARIANTS >= 4, `only ${SCREEN_UI_VARIANTS} screen layouts`);
|
||||
assert.equal(new TextureBin("high").variants("screenUI"), SCREEN_UI_VARIANTS);
|
||||
// Everything that tiles has exactly one.
|
||||
assert.equal(new TextureBin("high").variants("carpetLoop"), 1);
|
||||
});
|
||||
|
||||
test("every screen variant is a genuinely different drawing", () => {
|
||||
const signatures = new Set<string>();
|
||||
for (let v = 0; v < SCREEN_UI_VARIANTS; v++) {
|
||||
const { log } = draw("screenUI", v);
|
||||
assert.ok(log.length > 60, `variant ${v} drew only ${log.length} operations`);
|
||||
signatures.add(log.join("|"));
|
||||
}
|
||||
// A single layout drawn six times with a different seed would still differ,
|
||||
// so this is the weaker half of the claim; the stronger half is that the
|
||||
// module has six distinct layout functions, which the count below pins.
|
||||
assert.equal(signatures.size, SCREEN_UI_VARIANTS, "two screen variants draw the same picture");
|
||||
});
|
||||
|
||||
test("a screen variant is the same picture every time it is drawn", () => {
|
||||
// The drawings are seeded from the variant index. A `Math.random` anywhere in
|
||||
// this path would give a studio a different set of monitors on every reload,
|
||||
// and would break the byte-identical-for-a-seed property the asset library
|
||||
// promises everywhere else.
|
||||
for (const v of [0, 3, 5]) {
|
||||
const first = draw("screenUI", v).log.join("|");
|
||||
const second = draw("screenUI", v).log.join("|");
|
||||
assert.equal(first, second, `screen variant ${v} is not deterministic`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a screen is drawn in the proportions of a screen", () => {
|
||||
const { canvas } = draw("screenUI", 1);
|
||||
assert.ok(canvas);
|
||||
const aspect = (canvas?.width ?? 0) / (canvas?.height ?? 1);
|
||||
assert.ok(Math.abs(aspect - 16 / 9) < 0.02, `screen canvas aspect ${aspect.toFixed(3)}`);
|
||||
});
|
||||
|
||||
test("a screen clamps and carries colour", () => {
|
||||
const { texture } = draw("screenUI", 2);
|
||||
assert.ok(texture);
|
||||
// One image on one quad. Repeat wrapping here means a UV that overshoots by a
|
||||
// hair draws the right edge of the interface against the left one.
|
||||
assert.equal(texture?.wrapS, THREE.ClampToEdgeWrapping);
|
||||
assert.equal(texture?.wrapT, THREE.ClampToEdgeWrapping);
|
||||
// Content, not coverage: this is the one drawing in the library with its own
|
||||
// colour, and it is authored in sRGB.
|
||||
assert.equal(texture?.colorSpace, THREE.SRGBColorSpace);
|
||||
assert.equal(texture?.name, "screenUI#2");
|
||||
});
|
||||
|
||||
test("a screen draws no text and no logo", () => {
|
||||
// ARCHITECTURE.md §3.1: a screen drawing a recognisable interface is a screen
|
||||
// drawing somebody's trademark. There is no `fillText` in the fake context at
|
||||
// all, so a drawing that reached for one would throw — this asserts the
|
||||
// intent explicitly so the next person does not add one.
|
||||
for (let v = 0; v < SCREEN_UI_VARIANTS; v++) {
|
||||
const { log } = draw("screenUI", v);
|
||||
assert.ok(!log.some((op) => op.startsWith("fillText") || op.startsWith("drawImage")));
|
||||
}
|
||||
});
|
||||
|
||||
// ---- leafAlpha --------------------------------------------------------------
|
||||
|
||||
test("the leaf is coverage, not colour", () => {
|
||||
const { texture } = draw("leafAlpha");
|
||||
assert.ok(texture);
|
||||
// An sRGB decode on an alphaMap shifts every coverage value: a cutout drawn
|
||||
// at 0.5 arrives at 0.21 and `alphaTest` eats half the leaf.
|
||||
assert.equal(texture?.colorSpace, THREE.NoColorSpace);
|
||||
assert.equal(texture?.wrapS, THREE.ClampToEdgeWrapping);
|
||||
assert.equal(texture?.wrapT, THREE.ClampToEdgeWrapping);
|
||||
});
|
||||
|
||||
test("the leaf is drawn as a shape, black ground first", () => {
|
||||
const { log } = draw("leafAlpha");
|
||||
const firstFill = log.findIndex((op) => op.startsWith("fillRect"));
|
||||
assert.ok(firstFill > 0);
|
||||
assert.equal(log[firstFill - 1], "fillStyle=#000000", "the ground under the cutout must be empty");
|
||||
assert.ok(
|
||||
log.some((op) => op.startsWith("bezierCurveTo")),
|
||||
"a leaf outline drawn without curves is a rectangle with a different name",
|
||||
);
|
||||
// The serrations are bitten out and the mode is put back, or every drawing
|
||||
// after this one on the same context would erase instead of paint.
|
||||
const cut = log.indexOf("composite=destination-out");
|
||||
const restore = log.indexOf("composite=source-over");
|
||||
assert.ok(cut > 0, "no serrations were cut");
|
||||
assert.ok(restore > cut, "the composite mode was left in destination-out");
|
||||
});
|
||||
|
||||
test("the leaf stays inside the quad it is cut out of", () => {
|
||||
const { log, canvas } = draw("leafAlpha");
|
||||
const w = canvas?.width ?? 0;
|
||||
const h = canvas?.height ?? 0;
|
||||
assert.ok(w > 0 && h > 0);
|
||||
|
||||
const numbers = (op: string): number[] =>
|
||||
(op.slice(op.indexOf("(") + 1, -1).match(/-?\d+\.\d+/g) ?? []).map(Number);
|
||||
|
||||
for (const op of log) {
|
||||
if (op.startsWith("arc(")) {
|
||||
const [x = 0, y = 0, r = 0] = numbers(op);
|
||||
assert.ok(x - r >= -0.5 && x + r <= w + 0.5, `serration off the left/right edge: ${op}`);
|
||||
assert.ok(y - r >= -0.5 && y + r <= h + 0.5, `serration off the top/bottom edge: ${op}`);
|
||||
} else if (op.startsWith("bezierCurveTo") || op.startsWith("moveTo")) {
|
||||
const values = numbers(op);
|
||||
for (let i = 0; i < values.length; i += 2) {
|
||||
assert.ok((values[i] ?? 0) >= -0.5 && (values[i] ?? 0) <= w + 0.5, `x out of bounds: ${op}`);
|
||||
assert.ok(
|
||||
(values[i + 1] ?? 0) >= -0.5 && (values[i + 1] ?? 0) <= h + 0.5,
|
||||
`y out of bounds: ${op}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the leaf is cut even at low quality, where every other map is skipped", () => {
|
||||
// `low` means no *shading* maps, and it is the setting that makes an office
|
||||
// open on an integrated GPU. Skipping the cutout there does not buy a cheaper
|
||||
// plant, it buys the flat green shard back.
|
||||
const leaf = draw("leafAlpha", 0, "low");
|
||||
assert.ok(leaf.texture, "leafAlpha must survive low quality");
|
||||
assert.ok((leaf.canvas?.width ?? 0) >= 128);
|
||||
|
||||
const carpet = draw("carpetLoop", 0, "low");
|
||||
assert.equal(carpet.texture, null, "low quality must still skip the shading maps");
|
||||
});
|
||||
|
||||
// ---- The bin ----------------------------------------------------------------
|
||||
|
||||
test("the bin draws each kind and variant at most once", () => {
|
||||
const bin = new TextureBin("high");
|
||||
const before = logs.length;
|
||||
const a = bin.get("screenUI", 1);
|
||||
const b = bin.get("screenUI", 1);
|
||||
assert.equal(a, b);
|
||||
assert.equal(logs.length - before, 1, "the same variant was drawn twice");
|
||||
|
||||
const c = bin.get("screenUI", 4);
|
||||
assert.notEqual(a, c);
|
||||
assert.equal(logs.length - before, 2);
|
||||
|
||||
// Wrapping, so a caller can hand it a running prop index.
|
||||
assert.equal(bin.get("screenUI", 1 + SCREEN_UI_VARIANTS), a);
|
||||
assert.equal(logs.length - before, 2);
|
||||
bin.dispose();
|
||||
});
|
||||
|
||||
test("a variant index on a kind that has none is ignored", () => {
|
||||
const bin = new TextureBin("high");
|
||||
const before = logs.length;
|
||||
assert.equal(bin.get("carpetLoop", 0), bin.get("carpetLoop", 7));
|
||||
assert.equal(logs.length - before, 1);
|
||||
bin.dispose();
|
||||
});
|
||||
|
||||
test("the tiling kinds are unchanged: square, repeating, sRGB", () => {
|
||||
for (const kind of [
|
||||
"carpetLoop",
|
||||
"woodPlank",
|
||||
"polishedConcrete",
|
||||
"ceilingTile",
|
||||
"plasterPaint",
|
||||
"fabricWeave",
|
||||
"tileGrid",
|
||||
"whiteboard",
|
||||
] as TextureKind[]) {
|
||||
const { texture, canvas } = draw(kind);
|
||||
assert.ok(texture, `${kind} did not draw`);
|
||||
assert.equal(canvas?.width, canvas?.height, `${kind} is no longer square`);
|
||||
assert.equal(texture?.wrapS, THREE.RepeatWrapping, `${kind} stopped tiling`);
|
||||
assert.equal(texture?.wrapT, THREE.RepeatWrapping, `${kind} stopped tiling`);
|
||||
assert.equal(texture?.colorSpace, THREE.SRGBColorSpace);
|
||||
assert.equal(texture?.name, kind, `${kind} gained a variant suffix it did not ask for`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- The relief channel ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* These read pixels, which the tests above deliberately refuse to do — and the
|
||||
* difference is that a normal map is not a picture. It is a field of measured
|
||||
* directions with a defined encoding, and the three things that can be wrong
|
||||
* with it are all arithmetic: it can be un-normalised, it can be flipped in one
|
||||
* axis (a floor that lights as though it were embossed inside out), or it can
|
||||
* have a discontinuity at the tile seam. All three are checkable, none of them
|
||||
* is a judgement about how a carpet ought to look, and none of them shows up in
|
||||
* a screenshot until a low sun rakes across the floor.
|
||||
*
|
||||
* They also run *without* the fake canvas: relief comes off an authored height
|
||||
* field rather than off the drawing, which is what lets it exist under
|
||||
* `node --test` at all.
|
||||
*/
|
||||
|
||||
/** Every kind the spec asked for relief on, in the order the spec named them. */
|
||||
const RELIEF_KINDS: TextureKind[] = [
|
||||
"carpetLoop",
|
||||
"woodPlank",
|
||||
"fabricWeave",
|
||||
"plasterPaint",
|
||||
"tileGrid",
|
||||
"ceilingTile",
|
||||
];
|
||||
|
||||
function normalTexture(kind: TextureKind, quality: "low" | "medium" | "high" = "high") {
|
||||
const texture = new TextureBin(quality).draw(kind, "normal");
|
||||
assert.ok(texture, `${kind} has no normal map`);
|
||||
const image = texture.image as { width: number; height: number; data: Uint8Array };
|
||||
return { texture, ...image };
|
||||
}
|
||||
|
||||
test("every kind the spec named has relief, and nothing else does", () => {
|
||||
assert.deepEqual([...NORMAL_MAP_KINDS].sort(), [...RELIEF_KINDS].sort());
|
||||
const bin = new TextureBin("high");
|
||||
// A whiteboard, a display and an alpha cutout are flat. Binding a normal map
|
||||
// to them would be inventing texture that is not on the object.
|
||||
for (const kind of ["polishedConcrete", "whiteboard", "screenUI", "leafAlpha"] as TextureKind[]) {
|
||||
assert.equal(bin.normal(kind), null, `${kind} grew a normal map`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the centre of every relief map is flat, within a texel of tolerance", () => {
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
const { width, data } = normalTexture(kind);
|
||||
const i = ((width / 2) * width + width / 2) * 4;
|
||||
const [r, g, b] = [data[i] ?? 0, data[i + 1] ?? 0, data[i + 2] ?? 0];
|
||||
// The middle of the tile is the middle of a plank, the bottom of a grout
|
||||
// line or the crest of a carpet row depending on the kind — a stationary
|
||||
// point of the height field in every case, so the surface there points
|
||||
// straight up and encodes as (128, 128, 255).
|
||||
assert.ok(Math.abs(r - 128) <= 6, `${kind} centre R is ${r}`);
|
||||
assert.ok(Math.abs(g - 128) <= 6, `${kind} centre G is ${g}`);
|
||||
assert.ok(Math.abs(b - 255) <= 6, `${kind} centre B is ${b}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("every texel of every relief map is a unit vector pointing out of the surface", () => {
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
const { width, height, data } = normalTexture(kind);
|
||||
let worst = 0;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const x = ((data[i * 4] ?? 0) / 255) * 2 - 1;
|
||||
const y = ((data[i * 4 + 1] ?? 0) / 255) * 2 - 1;
|
||||
const z = ((data[i * 4 + 2] ?? 0) / 255) * 2 - 1;
|
||||
// Out of the surface, never into it: a negative Z is a normal facing away
|
||||
// from the viewer, which shades as a hole.
|
||||
assert.ok(z > 0, `${kind} has a texel whose normal points into the surface`);
|
||||
worst = Math.max(worst, Math.abs(Math.hypot(x, y, z) - 1));
|
||||
assert.equal(data[i * 4 + 3], 255, `${kind} has a non-opaque texel`);
|
||||
}
|
||||
// 1/255 per channel of quantisation, tripled and rounded up.
|
||||
assert.ok(worst < 0.02, `${kind} normals are off unit length by ${worst.toFixed(4)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("relief has something in it — a flat normal map is a wasted texture unit", () => {
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
const { width, height, data } = normalTexture(kind);
|
||||
let peak = 0;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
peak = Math.max(
|
||||
peak,
|
||||
Math.abs((data[i * 4] ?? 0) - 128),
|
||||
Math.abs((data[i * 4 + 1] ?? 0) - 128),
|
||||
);
|
||||
}
|
||||
assert.ok(peak >= 8, `${kind} relief peaks at ${peak}/128 and reads as flat`);
|
||||
}
|
||||
});
|
||||
|
||||
test("relief wraps at the tile seam", () => {
|
||||
// The colour maps are built to tile; a normal map that does not would put a
|
||||
// hard lighting crease every two metres across a floor, which is worse than no
|
||||
// relief at all because it moves with the sun.
|
||||
//
|
||||
// The comparison is against the *local* step, not against zero. Column 0 and
|
||||
// column 511 are one texel apart under wrapping, and in the wall of a grout
|
||||
// line one texel is a big step — legitimately. What would not be legitimate is
|
||||
// the step across the seam being larger than the steps either side of it,
|
||||
// which is exactly what a field sampled at `(x + 0.5) / size` instead of
|
||||
// `x / size` produces.
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
const { width, height, data } = normalTexture(kind);
|
||||
const texel = (x: number, y: number): number[] => {
|
||||
const i = (y * width + x) * 4;
|
||||
return [data[i] ?? 0, data[i + 1] ?? 0, data[i + 2] ?? 0];
|
||||
};
|
||||
const spread = (a: number[], b: number[]): number =>
|
||||
Math.max(...a.map((value, c) => Math.abs(value - (b[c] ?? 0))));
|
||||
|
||||
for (let y = 0; y < height; y += 17) {
|
||||
const seam = spread(texel(width - 1, y), texel(0, y));
|
||||
const local = Math.max(
|
||||
spread(texel(0, y), texel(1, y)),
|
||||
spread(texel(width - 2, y), texel(width - 1, y)),
|
||||
);
|
||||
assert.ok(seam <= local + 4, `${kind} row ${y}: seam step ${seam} vs local ${local}`);
|
||||
}
|
||||
for (let x = 0; x < width; x += 17) {
|
||||
const seam = spread(texel(x, height - 1), texel(x, 0));
|
||||
const local = Math.max(
|
||||
spread(texel(x, 0), texel(x, 1)),
|
||||
spread(texel(x, height - 2), texel(x, height - 1)),
|
||||
);
|
||||
assert.ok(seam <= local + 4, `${kind} column ${x}: seam step ${seam} vs local ${local}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("relief is resolution-independent, so medium and high light the same", () => {
|
||||
// The gradient is taken per unit UV rather than per texel. Get that wrong and
|
||||
// the same floor is twice as steep at `medium` as at `high`, which is a
|
||||
// quality setting that changes the art rather than the cost.
|
||||
//
|
||||
// `fabricWeave` is excluded, and the exclusion is the finding rather than a
|
||||
// fudge: its drawing rules 128 threads across the tile, which is two texels at
|
||||
// 256² — below what a half-resolution map can carry at all. The weave
|
||||
// therefore genuinely disappears from the relief at `medium`, the same way the
|
||||
// 1-pixel thread lines alias out of the colour map at `medium`. That is a
|
||||
// graceful loss of detail, which is what a quality setting is for; every other
|
||||
// kind's features are coarse enough to survive both and are held to within a
|
||||
// few percent.
|
||||
for (const kind of RELIEF_KINDS.filter((kind) => kind !== "fabricWeave")) {
|
||||
const strength = (quality: "medium" | "high") => {
|
||||
const { width, height, data } = normalTexture(kind, quality);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
sum += Math.hypot((data[i * 4] ?? 0) - 128, (data[i * 4 + 1] ?? 0) - 128);
|
||||
}
|
||||
return sum / (width * height);
|
||||
};
|
||||
const medium = strength("medium");
|
||||
const high = strength("high");
|
||||
// A third, not a few percent: a grout line is a couple of texels wide even
|
||||
// at 512 and softens measurably at 256. The bug this is really guarding
|
||||
// against — differentiating per texel instead of per unit UV — is a factor
|
||||
// of two, and no amount of softening reaches that.
|
||||
assert.ok(
|
||||
Math.abs(medium - high) <= Math.max(0.3, high * 0.35),
|
||||
`${kind} relief is ${medium.toFixed(2)} at medium and ${high.toFixed(2)} at high`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("relief is set up as a sampled map, not as raw data", () => {
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
const { texture } = normalTexture(kind);
|
||||
assert.equal(texture.wrapS, THREE.RepeatWrapping, `${kind} relief stopped tiling`);
|
||||
assert.equal(texture.wrapT, THREE.RepeatWrapping, `${kind} relief stopped tiling`);
|
||||
// A direction is not a colour. An sRGB decode would bend every normal.
|
||||
assert.equal(texture.colorSpace, THREE.NoColorSpace, `${kind} relief is being decoded`);
|
||||
// `DataTexture` defaults to nearest and no mipmaps, which on a floor running
|
||||
// to the horizon is a field of shimmering static.
|
||||
assert.equal(texture.generateMipmaps, true, `${kind} relief has no mipmaps`);
|
||||
assert.equal(texture.minFilter, THREE.LinearMipmapLinearFilter);
|
||||
assert.equal(texture.magFilter, THREE.LinearFilter);
|
||||
}
|
||||
});
|
||||
|
||||
test("low quality has no relief at all, the same way it has no colour maps", () => {
|
||||
const bin = new TextureBin("low");
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
assert.equal(bin.normal(kind), null, `${kind} drew relief at low quality`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the bin builds each relief map at most once", () => {
|
||||
const bin = new TextureBin("high");
|
||||
const first = bin.normal("tileGrid");
|
||||
assert.ok(first);
|
||||
assert.equal(bin.normal("tileGrid"), first);
|
||||
// `draw` is the uncached door and must stay uncached, or the tests above
|
||||
// would be asserting about one shared texture.
|
||||
assert.notEqual(bin.draw("tileGrid", "normal"), first);
|
||||
bin.dispose();
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* The tone curve, and the light table that is tuned against it.
|
||||
*
|
||||
* These two things are one change and are tested in one file on purpose. Turning
|
||||
* on ACES without re-tuning `atmosphere.ts` produces a world that is correctly
|
||||
* *shaped* and too dark; re-tuning `atmosphere.ts` without ACES produces a world
|
||||
* that clips even harder than it did. Either half on its own is a regression, so
|
||||
* the assertions below fail if either half is reverted alone.
|
||||
*
|
||||
* `createStage` itself cannot be called here — it constructs a real
|
||||
* `WebGLRenderer`, and `node --test` has no GL context and no canvas. What can
|
||||
* be checked, and is, is (1) that the three renderer properties are actually
|
||||
* assigned in the source, which is the thing a careless merge would drop, (2)
|
||||
* that the constants they are assigned from still mean what the rest of the
|
||||
* repo assumes, and (3) that the light table's *shape* still matches the curve.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createAtmosphere, type Environment } from "../../engine/atmosphere.ts";
|
||||
import type { MoonPosition } from "../../engine/atmosphere.ts";
|
||||
import { DEFAULT_TONE_MAPPING_EXPOSURE } from "../../engine/stage.ts";
|
||||
import type { SolarPosition } from "../../engine/solar.ts";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const STAGE_SOURCE = readFileSync(path.join(ROOT, "src/engine/stage.ts"), "utf8");
|
||||
|
||||
// ---- The renderer configuration -------------------------------------------
|
||||
|
||||
test("the stage configures ACES, an exposure and an explicit output colour space", () => {
|
||||
assert.match(
|
||||
STAGE_SOURCE,
|
||||
/renderer\.toneMapping\s*=\s*THREE\.ACESFilmicToneMapping/,
|
||||
"the renderer must tone map; NoToneMapping is saturate() and clips every value above 1.0",
|
||||
);
|
||||
assert.match(
|
||||
STAGE_SOURCE,
|
||||
/renderer\.toneMappingExposure\s*=/,
|
||||
"the exposure must be assigned, not inherited",
|
||||
);
|
||||
assert.match(
|
||||
STAGE_SOURCE,
|
||||
/renderer\.outputColorSpace\s*=\s*THREE\.SRGBColorSpace/,
|
||||
"the output transfer function must be stated rather than relying on a library default",
|
||||
);
|
||||
assert.match(
|
||||
STAGE_SOURCE,
|
||||
/exposure\?:\s*number/,
|
||||
"StageOptions must expose the exposure, so a capture or a test can drive it",
|
||||
);
|
||||
});
|
||||
|
||||
test("the three constants the stage names still exist in three", () => {
|
||||
// A rename upstream would leave the assignments above compiling against
|
||||
// `undefined` and silently restore the clipping renderer.
|
||||
assert.equal(typeof THREE.ACESFilmicToneMapping, "number");
|
||||
assert.notEqual(THREE.ACESFilmicToneMapping, THREE.NoToneMapping);
|
||||
assert.equal(THREE.SRGBColorSpace, "srgb");
|
||||
});
|
||||
|
||||
// ---- What the exposure means ----------------------------------------------
|
||||
|
||||
/**
|
||||
* Three's own ACES fit, transcribed from `tonemapping_pars_fragment.glsl.js`.
|
||||
*
|
||||
* Duplicated here deliberately. The point of these assertions is not to check
|
||||
* that three's shader does what three's shader does — it is to check that the
|
||||
* *exposure this repo chose* lands the values this repo actually renders in the
|
||||
* places they need to be, and that requires evaluating the curve on the CPU.
|
||||
*/
|
||||
function rrtAndOdtFit(v: number): number {
|
||||
const a = v * (v + 0.0245786) - 0.000090537;
|
||||
const b = v * (0.983729 * v + 0.432951) + 0.238081;
|
||||
return a / b;
|
||||
}
|
||||
|
||||
/** Linear radiance in, display-linear out. Neutral colours only, so no matrices. */
|
||||
function aces(linear: number, exposure = DEFAULT_TONE_MAPPING_EXPOSURE): number {
|
||||
return Math.min(1, Math.max(0, rrtAndOdtFit((linear * exposure) / 0.6)));
|
||||
}
|
||||
|
||||
/** Display-linear to what the panel shows, so thresholds can be read as levels. */
|
||||
function srgb(v: number): number {
|
||||
return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
test("the chosen exposure keeps mid grey near the middle", () => {
|
||||
// An 18% card is the definition of a neutral exposure. A little above 0.5 is
|
||||
// the deliberate lift documented on the constant; a long way from it means
|
||||
// somebody has turned this into a brightness slider.
|
||||
const grey = srgb(aces(0.18));
|
||||
assert.ok(grey > 0.5 && grey < 0.58, `18% grey displayed at ${grey.toFixed(3)}`);
|
||||
});
|
||||
|
||||
test("values above 1.0 stay separable, which is the whole reason for the change", () => {
|
||||
// These are the numbers the library actually drives: `lightDiffuser` glows at
|
||||
// 0.85, `screenContent` at 0.9, `deviceIndicator` at 1.0, and the office
|
||||
// assets reach 3.2. Under NoToneMapping every one of them displayed as 1.0.
|
||||
const levels = [0.85, 1, 1.6, 2.35, 3.2, 6].map((v) => aces(v));
|
||||
for (let i = 1; i < levels.length; i++) {
|
||||
const previous = levels[i - 1] ?? 0;
|
||||
const current = levels[i] ?? 0;
|
||||
assert.ok(current > previous, `radiance step ${i} did not brighten`);
|
||||
assert.ok(current < 1, `radiance step ${i} clipped at 1.0`);
|
||||
assert.ok(
|
||||
current - previous > 0.002,
|
||||
`radiance step ${i} moved by ${(current - previous).toFixed(4)}, which is not a visible difference`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the curve is monotonic across the whole range it is fed", () => {
|
||||
let previous = -1;
|
||||
for (let linear = 0; linear <= 12; linear += 0.05) {
|
||||
const value = aces(linear);
|
||||
assert.ok(value >= previous, `not monotonic at ${linear.toFixed(2)}`);
|
||||
previous = value;
|
||||
}
|
||||
});
|
||||
|
||||
test("the exposure is a photographic dial, not a brightness control", () => {
|
||||
assert.ok(
|
||||
DEFAULT_TONE_MAPPING_EXPOSURE > 0.7 && DEFAULT_TONE_MAPPING_EXPOSURE < 1.8,
|
||||
`exposure ${DEFAULT_TONE_MAPPING_EXPOSURE} is outside the range the light table is tuned for`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---- The light table, tuned against that curve -----------------------------
|
||||
|
||||
const NO_MOON: MoonPosition = {
|
||||
azimuth: 0,
|
||||
elevation: -40,
|
||||
illuminated: 0,
|
||||
phase: 0,
|
||||
distanceKm: 384_400,
|
||||
};
|
||||
|
||||
function sun(elevation: number): SolarPosition {
|
||||
return { azimuth: 180, elevation, declination: 0, equationOfTime: 0 };
|
||||
}
|
||||
|
||||
function environment(elevation: number): Environment {
|
||||
return { time: new Date("2026-06-21T20:00:00Z"), sun: sun(elevation), moon: NO_MOON, weather: null };
|
||||
}
|
||||
|
||||
function rig(elevation: number) {
|
||||
// A city-scale atmosphere with the moon switched off, so what comes back is
|
||||
// the keyframe table plus the night floor and nothing else.
|
||||
const atmosphere = createAtmosphere({ lng: -122.4, metresPerUnit: 94, moonlight: null });
|
||||
return atmosphere.apply(environment(elevation));
|
||||
}
|
||||
|
||||
test("the day stops raise the key and lower the fill", () => {
|
||||
const noon = rig(65);
|
||||
// Contrast, not brightness: a shoulder means the sun no longer has to be held
|
||||
// back to keep a lit wall off pure white, so the key went up and the fill came
|
||||
// down. If someone restores the old table these three flip together.
|
||||
assert.ok(noon.sun.intensity >= 2.5, `peak sun ${noon.sun.intensity} is below the retuned key`);
|
||||
assert.ok(
|
||||
noon.hemisphere.intensity <= 1.0,
|
||||
`peak hemisphere ${noon.hemisphere.intensity} is above the retuned fill`,
|
||||
);
|
||||
assert.ok(
|
||||
noon.ambient.intensity <= 0.25,
|
||||
`peak ambient ${noon.ambient.intensity} is above the retuned fill`,
|
||||
);
|
||||
|
||||
// The ratio is the thing that reads as modelling. Under the old table it was
|
||||
// 2.35 / 1.10 = 2.1; it must not go back there.
|
||||
const keyToFill = noon.sun.intensity / noon.hemisphere.intensity;
|
||||
assert.ok(keyToFill > 2.4, `key-to-fill ratio ${keyToFill.toFixed(2)} is too flat`);
|
||||
});
|
||||
|
||||
test("the night floor sits high enough to survive the ACES toe", () => {
|
||||
const night = rig(-18);
|
||||
// The toe costs roughly 18% of the display value of a moonless night. The
|
||||
// floor was raised by a third in linear light to pay for it, and these are the
|
||||
// floors themselves rather than the keyframe rows, because the floor binds.
|
||||
assert.ok(
|
||||
night.hemisphere.intensity >= 1.0,
|
||||
`night hemisphere ${night.hemisphere.intensity} is back below the raised floor`,
|
||||
);
|
||||
assert.ok(
|
||||
night.ambient.intensity >= 0.28,
|
||||
`night ambient ${night.ambient.intensity} is back below the raised floor`,
|
||||
);
|
||||
// And still a night: the fill is a fraction of noon's key, not a match for it.
|
||||
assert.ok(night.sun.intensity < 0.5, "the night sidelight has become a sun");
|
||||
});
|
||||
|
||||
test("the sun brightens monotonically as it rises", () => {
|
||||
let previous = -1;
|
||||
for (const elevation of [-18, -12, -6, -0.4, 3, 8, 25, 65]) {
|
||||
const intensity = rig(elevation).sun.intensity;
|
||||
assert.ok(intensity > previous, `sun intensity fell between stops at ${elevation} degrees`);
|
||||
previous = intensity;
|
||||
}
|
||||
});
|
||||
|
||||
test("the sky colours were left alone, because they are not tone mapped", () => {
|
||||
// Three marks the background mesh `toneMapped = false` for an sRGB-transfer
|
||||
// texture and mixes fog after the tone map from an already-encoded uniform.
|
||||
// So the one thing the re-tune must NOT have touched is the sky, and the noon
|
||||
// stop still reproduces the city's own declared daylight colours.
|
||||
const noon = rig(25);
|
||||
assert.ok(noon.sky);
|
||||
assert.equal(noon.sky?.top, 0x8fb8d8);
|
||||
assert.equal(noon.sky?.horizon, 0xd9e6ee);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user