/** * 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 { 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) }; }