1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/transport/exteriorVehicle.ts
T
karti db074e9cf7 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>
2026-08-21 19:44:24 -07:00

384 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The parked car outside the front door, described without a renderer.
*
* `engine/officeExterior.ts` builds the apron and the vehicle in three.js. This
* file is everything about that scene that is *arithmetic* rather than
* geometry: how big a bay has to be for a given car, how far off square a real
* car parks, which lamp is lit for a given telemetry reading, and the controller
* settings that make a metre-scale vehicle behave like a vehicle rather than
* like a map symbol.
*
* ### Why the split is worth a file
*
* Two reasons, and both of them have bitten this repo before.
*
* The first is testability. A rule like "the charge lamp is green while
* charging and cool white once full" is one line of logic and eight lines of
* mesh plumbing, and if they live together the only way to test the line is to
* build a scene. `exteriorVehicleAppearance` is a pure function of a
* `VehicleTelemetryState`, so the mapping a viewer actually reads is asserted
* directly, and `officeExterior.ts` is left with nothing in it but geometry.
*
* The second is arithmetic that should never be restated. The bay is sized
* *from the car* rather than from a pair of authored numbers, so a wider car
* later gets a wider bay for free instead of getting a wider car in a bay it no
* longer fits — the same reference-not-restatement rule `offices/sites.ts`
* follows for coordinates.
*
* Nothing here imports three.js, the DOM, or the network.
*/
import type { VehicleControllerOptions } from "./vehicleController.ts";
import { VEHICLE_WHEEL_RADIUS_M } from "./vehicleSim.ts";
import type { VehicleTelemetryState } from "./vehicleTelemetry.ts";
// ---- Metre scale ----------------------------------------------------------
/**
* What `VehicleController` needs to behave at 1 unit = 1 m.
*
* `travelScale` is the whole point and the controller documents it as exactly
* this dial: the freeway board compresses route progress by 900 so that a
* playable drive crosses California in minutes rather than in a working day,
* and a car on a forecourt does not want any compression at all. The other
* three follow from the first — at travelScale 1 the speeds and offsets in the
* state are real metres in a real room, so a 58 m/s ceiling and a 5.4 m
* guardrail are a motorway's numbers standing in a car park.
*
* There is no forked controller, and there should not be one. A second state
* machine with the same responsibilities and different constants is how two
* subtly different definitions of "assisted" end up shipping in one product.
*
* **This preset resolves no collisions.** `stepNormalized` resolves none today
* and this changes nothing about that; a metre-scale car that has to negotiate
* kerbs, bollards and walkers is a separate project and is explicitly out of
* scope for this one.
*/
export const METRE_SCALE_VEHICLE_OPTIONS = {
travelScale: 1,
/** 50 km/h. A service road and a forecourt, not an interstate. */
maximumSpeedMps: 13.9,
/** Half the width of a two-lane apron road, so the edge is where the kerb is. */
guardrailOffsetM: 3.2,
/** Assistance loafs at a third of the posted limit; nothing here is a corridor. */
assistedCruiseRatio: 0.35,
/** See `VEHICLE_WHEEL_RADIUS_M` — the asset's wheel, not a rounded guess at it. */
wheelRadiusM: VEHICLE_WHEEL_RADIUS_M,
} as const;
/**
* Controller options for a metre-scale drive on `routeId`.
*
* Overrides are applied last so a caller can raise the ceiling for a wider road
* without restating the other four.
*/
export function metreScaleVehicleOptions(
routeId: string,
overrides: Partial<VehicleControllerOptions> = {},
): VehicleControllerOptions {
return { routeId, ...METRE_SCALE_VEHICLE_OPTIONS, ...overrides };
}
// ---- The apron ------------------------------------------------------------
/**
* What kind of ground the building's front door opens onto.
*
* `street` is a kerb, a marked bay and a strip of carriageway: the normal case,
* and what `mateo-court` (1.2 m above Mateo Street) and `frontier-valley` (4 m
* above an airfield apron) both have.
*
* `deck` is the answer to the question `offices/sites.ts` deliberately left
* open. `lumbridge-hq` is authored 188 m up a Transbay tower and its arrival
* anchor is "the kerb of the podium", because the pack frame is the only frame
* a pack has. Drawing a public street there would be a lie about a building
* that has none at that height, so an elevated site gets a podium deck instead:
* the same marked bay and the same charge post, standing on a paved deck with a
* low upstand and no carriageway running off it.
*
* The vertical question that note deferred is settled the same way, and by
* `ExteriorArrival`'s own wording rather than by a new rule: `levelId` names
* "the storey whose floor this stall is measured from", so the apron stands on
* that floor. The tower's car is on the podium at level 1, not on Folsom Street
* 188 m below it.
*/
export type ApronKind = "street" | "deck";
/**
* Above this site elevation there is no street outside the door.
*
* Thirty metres is about ten storeys — comfortably above anything with a kerb
* and comfortably below anything that could be mistaken for one. Both numbers
* either side of it in the shipped packs (4 m and 188 m) are nowhere near it,
* which is the property a threshold like this wants.
*/
export const APRON_STREET_MAX_ELEVATION_M = 30;
export function apronKindFor(siteElevationM: number): ApronKind {
return Number.isFinite(siteElevationM) && siteElevationM > APRON_STREET_MAX_ELEVATION_M
? "deck"
: "street";
}
/** Just enough of a vehicle to size a bay for it. */
export interface VehicleFootprint {
/** Metres along the vehicle's own forward axis. */
length: number;
/** Metres across it, mirrors included. */
width: number;
}
/** Every dimension `officeExterior.ts` needs, in metres, all derived. */
export interface ApronMetrics {
kind: ApronKind;
/** Thickness of the paved slab. The vehicle stands on top of it. */
padThickness: number;
padWidth: number;
padDepth: number;
/** The painted bay the vehicle sits in. */
stallWidth: number;
stallLength: number;
/** Width of a painted line. */
lineWidth: number;
kerbHeight: number;
kerbDepth: number;
/** Charge post, in the bay's own frame: X is the vehicle's left. */
postWidth: number;
postDepth: number;
postHeight: number;
postOffsetX: number;
postOffsetZ: number;
/** How far the status lamps sit up the post. */
lampHeight: number;
lampSize: number;
}
/**
* Size a bay around a vehicle.
*
* The clearances are the ones a real marked bay uses: about 450 mm each side to
* open a door against, and half a metre fore and aft so the painted rectangle
* reads as a bay rather than as a box drawn round a car. Everything else is
* measured off those two numbers, so there is exactly one place to change if
* the vehicle changes.
*/
export function apronMetrics(vehicle: VehicleFootprint, kind: ApronKind): ApronMetrics {
const width = Math.max(1.2, vehicle.width);
const length = Math.max(2.4, vehicle.length);
const stallWidth = width + 0.9;
const stallLength = length + 1.0;
return {
kind,
padThickness: 0.06,
// A shoulder wide enough to walk round the car on, and deep enough that the
// bay is not floating in the middle of nothing at an oblique camera.
padWidth: stallWidth + 1.8,
padDepth: stallLength + (kind === "street" ? 3.4 : 1.6),
stallWidth,
stallLength,
lineWidth: 0.1,
// A street kerb is a full 135 mm step; a podium deck gets a low upstand,
// because nothing is going to drive up onto a deck 188 m in the air.
kerbHeight: kind === "street" ? 0.135 : 0.09,
kerbDepth: 0.3,
postWidth: 0.3,
postDepth: 0.2,
postHeight: 1.28,
// Beside the vehicle's left rear quarter, which is where the charge port
// is, so the cable has a plausible run rather than crossing the car.
postOffsetX: -(stallWidth / 2 + 0.5),
postOffsetZ: stallLength / 2 - 0.9,
lampHeight: 1.02,
lampSize: 0.062,
};
}
// ---- Where the car actually stands ---------------------------------------
/** A pose in the pack's own plan frame. `yaw` is `object.rotation.y`. */
export interface ParkPose {
x: number;
z: number;
yaw: number;
}
/**
* How far off square a parked car is allowed to be.
*
* Nobody parks on the line. A car dead-centre in its bay at exactly the bay's
* angle is the single clearest tell that a scene was generated, and it costs
* one call to `rand` to fix. The bounds are deliberately small enough that the
* result is still unambiguously *in* the bay — a tenth of a metre and a degree
* — and small enough that a test can assert the anchor is honoured to within
* half a metre and two degrees no matter what generator is handed in, including
* `Math.random`.
*/
export const PARK_JITTER = {
/** Metres across the bay. */
lateralM: 0.11,
/** Metres along it. */
longitudinalM: 0.08,
/** Radians. About 1.0°. */
yawRad: 0.018,
} as const;
/**
* Place a vehicle in its bay, slightly imperfectly.
*
* The jitter is applied in the *bay's* frame rather than the plan's, so a bay
* at 90° gets a car nudged along its own length rather than sideways across it.
*/
export function parkPose(
arrival: { position: { x: number; z: number }; rotation: number },
rand: () => number,
): ParkPose {
const lateral = (rand() * 2 - 1) * PARK_JITTER.lateralM;
const longitudinal = (rand() * 2 - 1) * PARK_JITTER.longitudinalM;
const yaw = arrival.rotation + (rand() * 2 - 1) * PARK_JITTER.yawRad;
// Yaw zero faces Z (see `interiors/types.ts`), so forward is (sin, cos)
// and the vehicle's right is (cos, sin). Both are the three.js sense, which
// is why nothing here converts an angle.
const forwardX = -Math.sin(arrival.rotation);
const forwardZ = -Math.cos(arrival.rotation);
const rightX = Math.cos(arrival.rotation);
const rightZ = -Math.sin(arrival.rotation);
return {
x: arrival.position.x + rightX * lateral + forwardX * longitudinal,
z: arrival.position.z + rightZ * lateral + forwardZ * longitudinal,
yaw,
};
}
// ---- Telemetry as something you can see ----------------------------------
/** One lamp, as a colour and how hard it is driven. */
export interface LampReading {
/** 0xRRGGBB. */
color: number;
/** 0 (dark) to 1 (fully lit). */
intensity: number;
}
export interface ExteriorVehicleAppearance {
/** The charge post's lamp and the vehicle's charge-port ring. */
charge: LampReading;
climate: LampReading;
lock: LampReading;
/** Emissive strength of the cabin interior, 0..1. */
cabinGlow: number;
/** How full the post's charge bar reads, 0..1. */
chargeFraction: number;
}
/** Lamp colours. Named because three of them are used twice. */
const LAMP_OFF = 0x2a3236;
const LAMP_CHARGING = 0x46d07a;
const LAMP_FULL = 0x9fd8ff;
const LAMP_COOLING = 0x58c8e8;
const LAMP_HEATING = 0xe0964a;
const LAMP_SETTLED = 0x7fbf8a;
const LAMP_STANDBY = 0xd8a13a;
const LAMP_OPEN = 0xe8eef0;
/**
* The comfort band climate control is trying to hold the cabin inside.
*
* The state carries no setpoint — `VehicleTelemetryState` is the *observation*
* a real API returns and a setpoint is a setting — so the lamp reads the band
* rather than the target. That is also what a person standing next to the car
* can tell: it is cooling, it is heating, or it has got there.
*/
const COMFORT_MIN_C = 18;
const COMFORT_MAX_C = 24;
function clamp01(value: number): number {
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
}
/**
* How many distinct brightnesses a lamp is allowed to have.
*
* This is a *cache* constraint rather than an aesthetic one, and it is the
* reason `lampTint` exists at all. An indicator in this repo is a material —
* `materials.tinted("deviceIndicator", colour)` reaches both `color` and
* `emissive`, so a lamp changes state by changing material, not by changing a
* uniform on a shared one. That registry cache is keyed on the colour, so a
* charge lamp whose brightness tracked the state of charge continuously would
* mint a new material every step and never free one.
*
* Five steps is more than a lamp read from three metres away resolves, and it
* bounds the whole exterior at forty cache entries in the worst case — of which
* a running scene touches about six.
*/
export const LAMP_INTENSITY_STEPS = 5;
/**
* Fold a lamp's intensity into its colour, quantised.
*
* Multiplying each channel is not a physically-motivated dimming curve; it is
* the one that survives the tone mapping applied downstream, because
* `deviceIndicator` drives `emissive` as well as `color` and ACES compresses the
* top of the range rather than clipping it.
*/
export function lampTint(reading: LampReading): number {
const steps = LAMP_INTENSITY_STEPS - 1;
const level = Math.round(clamp01(reading.intensity) * steps) / steps;
const r = Math.round(((reading.color >> 16) & 0xff) * level);
const g = Math.round(((reading.color >> 8) & 0xff) * level);
const b = Math.round((reading.color & 0xff) * level);
return (r << 16) | (g << 8) | b;
}
/**
* Turn one telemetry observation into the handful of scalars the exterior
* renders.
*
* Pure, total, and deliberately the only place the mapping exists: a viewer
* standing on the apron reads the car's state entirely off these five values,
* and a test can assert what they will see without building a scene.
*
* Note what is *not* modelled: a flashing lamp. `apply()` is called on a change
* of state rather than every frame, so anything that animates would need a
* clock in the exterior layer, and a charge lamp that pulses is worth less than
* a charge lamp that is honest about the state of charge — which is what
* `chargeFraction` is for.
*/
export function exteriorVehicleAppearance(
telemetry: VehicleTelemetryState,
): ExteriorVehicleAppearance {
const socPct = clamp01(telemetry.socPct / 100) * 100;
const full = socPct >= 99.5;
const charge: LampReading = telemetry.pluggedIn
? full
? { color: LAMP_FULL, intensity: 1 }
// Ramps up as the pack fills, so a glance at the post tells you roughly
// how far along it is even before you read the bar.
: { color: LAMP_CHARGING, intensity: 0.45 + 0.55 * clamp01(socPct / 100) }
: { color: LAMP_OFF, intensity: 0.12 };
let climate: LampReading = { color: LAMP_OFF, intensity: 0.1 };
if (telemetry.climateOn) {
const cabinC = Number.isFinite(telemetry.cabinC) ? telemetry.cabinC : COMFORT_MIN_C;
if (cabinC > COMFORT_MAX_C) {
climate = { color: LAMP_COOLING, intensity: clamp01(0.55 + (cabinC - COMFORT_MAX_C) / 20) };
} else if (cabinC < COMFORT_MIN_C) {
climate = { color: LAMP_HEATING, intensity: clamp01(0.55 + (COMFORT_MIN_C - cabinC) / 20) };
} else {
climate = { color: LAMP_SETTLED, intensity: 0.5 };
}
}
// An unlocked car has its interior and marker lamps up; a locked one shows a
// dim standby. This is the reading a person actually uses to tell whether a
// car is theirs to open, which is why it gets a lamp of its own rather than
// being folded into the cabin glow.
const lock: LampReading = telemetry.locked
? { color: LAMP_STANDBY, intensity: 0.22 }
: { color: LAMP_OPEN, intensity: 0.85 };
const cabinGlow = telemetry.locked ? (telemetry.climateOn ? 0.35 : 0) : 0.9;
return { charge, climate, lock, cabinGlow, chargeFraction: clamp01(socPct / 100) };
}