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:
@@ -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 ----------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user