diff --git a/src/adapters/sample.ts b/src/adapters/sample.ts index ccd8b4f..89d57e5 100644 --- a/src/adapters/sample.ts +++ b/src/adapters/sample.ts @@ -442,3 +442,330 @@ export const SAMPLE_PRESENCE_PALETTE: Record = { meeting: 0xc4796f, guest: 0x7fb886, }; + +// ---- The office, over a day ------------------------------------------------ + +/** + * The fabricated roster as it stands at one particular instant. + * + * `SAMPLE_PRESENCE` is a photograph of one good morning, and a photograph was + * the whole truth for as long as the office had no clock. It is not any more. + * The room is lit by the same sun the city is, the house lights come up as that + * sun goes down, and the robots go on walking the floor after dark — so a + * building with all twenty-five of these people still sitting in it at one in + * the morning is now the least believable thing in it. Everything else in the + * scene has learned what time it is; the people had not. + * + * This is deliberately a **pure function of an instant the caller supplies**, + * not a ticking source. The app already has exactly one clock — the wall clock, + * or whatever the godmode scrubber has overridden it with — and a module that + * read `new Date()` for itself would be a second one, correct until the moment + * somebody drags the scrubber to midnight and finds the desks still full. That + * is the bug being fixed here, so it is not one to reintroduce one layer down. + * + * The result is always a **subset of `SAMPLE_PRESENCE`**, which is where the two + * sharpest constraints go away for free: one entry per seat in, one entry per + * seat or fewer out, so the roster can never name more people than there are + * seats, and nobody is ever moved to a seat they do not sit at. `SAMPLE_PRESENCE` + * itself is untouched and still exported — it is what a caller with no clock, + * and every test written before this existed, is entitled to keep getting. + * + * ### No weekend, on purpose + * + * A Sunday is not modelled and the day of the week is never read. This is a + * judgement call and it is the one worth arguing with: a real building is nearly + * empty all weekend, and modelling that would be more honest than not. + * + * It is also, roughly two days in seven, a first impression of an empty floor + * for somebody who has just walked into the office for the first time — and this + * file's own header is already clear that an empty building teaches nobody what + * the office is for. An empty room at one in the morning is read as "it is one + * in the morning". An empty room at eleven on a Sunday, by a visitor who has not + * thought about what day it is, is read as a floor that failed to load. The + * overnight curve alone buys the thing that was actually missing — a building + * that visibly lives on a clock — and it does it at every hour of every day + * instead of costing us two of them. A deployment that wants weekends can filter + * this result by `when.getDay()` in four lines; a demo that has already been + * dismissed cannot be got back. + * + * ### Which clock + * + * `when.getHours()` — the viewer's local time, matching `#clock`, the godmode + * scrubber and every other reading of a `Date` in this app. That is right for + * the flagship case and imperfect for one that exists: both shipped buildings + * stand in San Francisco, but the sun in the windows is computed from the site's + * latitude and longitude while the roster here follows the viewer, so a visitor + * in another time zone gets a floor that fills up at their nine o'clock under a + * sky that belongs to the building's. Fixing that properly needs a real time + * zone on `OfficeSite`, which is a change to the office contract and not to a + * demo adapter. Guessing one from `site.lng` is *not* the fix and was rejected: + * a longitude gives you mean solar time, and it knows nothing about daylight + * saving. The two shipped sites sit at -122.40° and -122.32°, seven kilometres + * apart, which is about 8 h 10 m of solar offset — ten minutes adrift of Pacific + * Standard Time, and seventy minutes adrift of the clock for the two thirds of + * the year that are daylight time. That is wrong in the way that looks right. + * Until there is a real zone, a caller that genuinely knows the building's can + * shift the `Date` it passes. + */ +export function samplePresenceAt(when: Date): Presence[] { + const t = when.getHours() * 60 + when.getMinutes() + when.getSeconds() / 60; + return SAMPLE_PRESENCE.filter((person) => isPresentAt(person, t)); +} + +/** + * Minutes since local midnight, as a literal. Every window in this section is + * written with it so that the source reads as a timetable. + */ +function at(hour: number, minute: number): number { + return hour * 60 + minute; +} + +/** + * The three shifts that are *not* seeded, and the reason the building is never + * empty during working hours. + * + * Everyone else's day is drawn from a hash, and a hash makes no promises: there + * is a seed for which every single person arrives at ten and the floor is bare + * at half past nine. Rather than clamp the answer afterwards — a clamp is a + * decision that changes as the clock moves, which is exactly the popping this + * function is supposed to be free of — three people are given fixed hours and + * the guarantee is read off them. + * + * They overlap in a deliberate chain: the early ops shift is in before anyone + * and out mid-afternoon, reception opens the front desk before it hands over, + * and one engineer works late and locks up. Each one starts before the previous + * one leaves, so their union is **continuous from 06:15 to 22:40**, and the + * floor cannot be empty inside it. Outside it the building genuinely does empty, + * which is the whole point of the change: at two in the morning there is nobody + * here, and the house lights and the robots are what the night shift looks like. + * + * Anchors take **no lunch break**, unlike everybody else. A break in an anchor + * is a break in the guarantee, and three staggered shifts that all happen to + * step out at half past twelve is precisely the accident that would put an empty + * floor on screen at the one hour nobody would think to check. + * + * The keys are seat ids and they must exist in `SAMPLE_PRESENCE`; see the check + * below, which is there because renaming a seat would otherwise dissolve the + * guarantee without a word. + */ +const PRESENCE_ANCHORS: Record = { + "ops-01": { from: at(6, 15), to: at(15, 5) }, + "reception-01": { from: at(7, 10), to: at(18, 50) }, + "eng-11": { from: at(11, 20), to: at(22, 40) }, +}; + +/** + * Two bookings in Alcatraz, and nothing else all day. + * + * A meeting room whose chairs are full at eight in the evening is the same lie + * as a floor whose desks are, one room in — worse, really, because a meeting is + * the one thing in an office that everybody knows has a start and an end. + */ +const PRESENCE_SESSIONS: { from: number; to: number; attendance: number }[] = [ + { from: at(9, 40), to: at(10, 30), attendance: 0.8 }, + { from: at(14, 5), to: at(15, 15), attendance: 0.6 }, +]; + +/** + * Whoever booked the room is in it. + * + * Without this a session can come round and, on this seed or the next one, draw + * nobody — and a lit meeting room with an empty table for fifty minutes reads as + * the seat binding having failed rather than as a meeting that was cancelled. + * The other five chairs are left to the hash, which is what makes the room look + * like a meeting rather than like a roll call. + */ +const PRESENCE_SESSION_CHAIR = "alcatraz-01"; + +/** + * When the visitor is in the building. + * + * Two short windows, because the lobby seat is the one place on this floor where + * a permanent occupant is obviously wrong: somebody who has been waiting in + * reception since dawn is not a guest, they are furniture. + */ +const PRESENCE_VISITS: { from: number; to: number }[] = [ + { from: at(10, 5), to: at(10, 50) }, + { from: at(15, 20), to: at(15, 55) }, +]; + +/** + * Is this person in the building at `t` minutes past local midnight? + * + * Four models, chosen by what the roster already says about the person rather + * than by a second table of seat ids that would drift out of step with the + * first. `colorKey` is opaque to the engine and to `presence.ts` — that is the + * contract and it is not being bent here — but this module *defines* those keys, + * a few lines up in `SAMPLE_PRESENCE_PALETTE`, so it is the one place in the + * repo entitled to know that `"meeting"` means somebody is in a meeting. + */ +function isPresentAt(person: Presence, t: number): boolean { + const anchor = PRESENCE_ANCHORS[person.seatId]; + if (anchor) return t >= anchor.from && t < anchor.to; + + if (person.colorKey === "guest") { + return PRESENCE_VISITS.some((visit) => t >= visit.from && t < visit.to); + } + + if (person.colorKey === "meeting") { + return PRESENCE_SESSIONS.some( + (session, index) => + t >= session.from && + t < session.to && + (person.seatId === PRESENCE_SESSION_CHAIR || + unit(person.seatId, `session-${index}`) < session.attendance), + ); + } + + const day = deskDay(person.seatId); + if (t < day.arrive || t >= day.leave) return false; + // Out at lunch, for those who go. Deliberately checked after the arrive/leave + // pair rather than folded into it, because these are two different facts about + // a person and a single boolean expression covering both is the one that grows + // an off-by-one the first time somebody edits it. + return !(t >= day.lunchFrom && t < day.lunchTo); +} + +/** One desk worker's day, in minutes since local midnight. */ +interface DeskDay { + arrive: number; + leave: number; + lunchFrom: number; + lunchTo: number; +} + +/** + * Memoised because a seat's day never changes. `samplePresenceAt` may be called + * on every clock tick and on every frame of a scrub, and re-hashing six streams + * per person per call to get an answer that is by construction the same answer + * is work nobody asked for. + */ +const deskDays = new Map(); + +/** + * A believable working day for one seat, from the seat id and nothing else. + * + * ### Why a timetable per person and not an occupancy curve + * + * The obvious shape for "the office fills up and empties out" is a curve — a + * fraction of the floor that is in at time `t` — with each person holding a + * fixed threshold and being in whenever the curve is above it. It was tried and + * it is wrong here, for one reason: **the curve is not monotone**. It has to dip + * at lunch, and everybody whose threshold sits near the bottom of that dip + * blinks out and back in as the curve goes down and up again. Every wobble in + * the curve costs a pop for whoever is parked at that level, and the pop lands + * on the people nearest the middle of the distribution — the ones most likely to + * be on screen. A curve with any noise in it at all strobes. + * + * A per-person timetable has no such failure mode. Each person's day is four + * fixed instants, so the in/out predicate crosses at most four times in + * twenty-four hours and each crossing goes one way. Advancing the clock by a + * second can only change the answer for somebody whose boundary falls inside + * that second, and it changes it once. Scrubbing backwards is symmetric for the + * same reason. + * + * Note what this is *instead of* hysteresis. Hysteresis needs memory of the last + * answer, and this function must give the same answer on a fresh reload as it + * gave the frame before — a page that has just booted has no last answer to + * remember. So rather than damping a boundary that moves, the boundary is made + * not to move at all, which buys the same freedom from chatter without any + * state to get out of sync between two callers. Both the scene and the plan view + * are handed this list; if it depended on history they could disagree. + * + * ### The numbers + * + * Arrivals are `08:45` ± 75 min and departures `17:50` ± 95 min, each drawn + * triangular rather than uniform so the floor fills through a busy middle with + * thin tails instead of a flat trickle — which is what a morning actually looks + * like from a desk. Departure is then pulled by 0.6 of how far the arrival was + * from the mean, because the person who is in at half seven is the person who + * leaves at four, and independent draws produce a floor full of people working + * seven-to-four and ten-to-eight at the same time. Those coefficients put + * arrivals in `07:30`–`10:00`, departures in `15:30`–`20:10` at the extremes, + * and the day itself between 7 h and 11 h 10 — the extremes of both draws at + * once, which is rare by construction. + * + * Lunch is `12:20` ± 40 min, taken away from the desk by the roughly two thirds + * who draw under the threshold, and it lasts 24 to 52 minutes. The shortest of + * those is still twenty-four minutes of world time, which is long enough to read + * as somebody having gone to eat rather than as a figure that glitched. + * + * Every window lies inside one calendar day and none of them wraps midnight. + * That is a constraint worth keeping: an interval that wraps needs `from > to` + * handling in `isPresentAt`, and the first person to write one without it gets a + * night owl who is never in rather than always. + * + * Seeded from the **seat** id and not the person id, because the seat is the + * address — it is what a `Presence` binds to and what survives an edit to this + * file. Renaming Tobias Quillon should not change when the person at `eng-01` + * comes in. + */ +function deskDay(seatId: string): DeskDay { + const hit = deskDays.get(seatId); + if (hit) return hit; + + const arrive = at(8, 45) + 75 * triangular(seatId, "arrive"); + const leave = at(17, 50) + 95 * triangular(seatId, "leave") + 0.6 * (arrive - at(8, 45)); + const lunchFrom = at(12, 20) + 40 * triangular(seatId, "lunch"); + const lunchLength = unit(seatId, "lunch-length") < 0.65 ? 38 + 14 * triangular(seatId, "bite") : 0; + + const made: DeskDay = { arrive, leave, lunchFrom, lunchTo: lunchFrom + lunchLength }; + deskDays.set(seatId, made); + return made; +} + +/** + * A stable number in `[0, 1)` from a seat id and a named stream. + * + * FNV-1a, which is here because it is eight lines and has no dependencies, not + * because its statistical properties matter — nothing downstream of this is a + * simulation, and the requirement is only that the same seat gets the same + * answer on every reload, in every browser, forever. `Math.imul` keeps the + * multiply in 32 bits; a plain `*` would go through a double and lose the low + * bits that are the whole output. + * + * The stream name is what makes the draws independent. Hashing the seat id once + * and slicing the bits would be cheaper and would tie a seat's arrival time to + * its lunch hour in a way that eventually shows up as everybody who comes in + * early eating at the same moment. + */ +function unit(seatId: string, stream: string): number { + const text = `${seatId}/${stream}`; + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i += 1) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0) / 0x1_0000_0000; +} + +/** + * A stable number in `(-1, 1)`, peaked at zero: two uniforms added, which is the + * cheapest thing that is not flat. + * + * Uniform arrival times give a floor that fills at a constant rate from half + * seven to ten, and it reads as wrong without being able to say why — there is + * no rush hour in it. One extra hash buys a peak. + */ +function triangular(seatId: string, stream: string): number { + return unit(seatId, `${stream}-a`) + unit(seatId, `${stream}-b`) - 1; +} + +/** + * The anchors have to be anchored to something. + * + * A seat id in `PRESENCE_ANCHORS` that is not in `SAMPLE_PRESENCE` matches + * nobody, and the guarantee above quietly becomes a hope — the failure being an + * empty office at eleven in the morning on some seeds and not others, which is + * about the worst shape a bug can have. Twenty-five names against three ids, at + * import, is not a cost worth measuring, and it turns that into a line in the + * console the first time somebody renames a seat. + */ +for (const seatId of Object.keys(PRESENCE_ANCHORS)) { + if (!SAMPLE_PRESENCE.some((person) => person.seatId === seatId)) { + console.warn( + `[tera/sample] anchor seat "${seatId}" is not in SAMPLE_PRESENCE; ` + + "the sample office can now be empty during working hours", + ); + } +} diff --git a/src/engine/atmosphere.ts b/src/engine/atmosphere.ts index eee71fb..aca0ff4 100644 --- a/src/engine/atmosphere.ts +++ b/src/engine/atmosphere.ts @@ -27,6 +27,14 @@ * that goes flat grey the moment the wifi drops would fail that in the most * visible way possible. * + * There is a second output alongside the rig, `cloudCover`, and it is here for + * the same reason. It answers "how much sky has cloud in it" rather than "what + * does that cloud do to the light" — the number a cloud layer draws from, not a + * light — and it falls back to the same local climatology when nothing was + * observed. See `modelledCloudCover`: a caller that reads + * `weather?.cloudCover ?? 0` instead gets an empty sky on every deployment + * without a weather API, which is the default one. + * * Wiring one to a city, in full: * * ```ts @@ -465,6 +473,15 @@ export interface AtmosphereOptions { export interface Atmosphere { /** The rig this observation implies. Pure; the caller applies the result. */ apply(env: Environment): LightingState; + /** + * How much of the sky has cloud in it, 0..1 — observed if anyone observed it, + * modelled from this place and this instant if nobody did. Pure, like `apply`. + * + * The cause, not the consequence: what a cloud layer needs in order to draw + * the right amount of cloud, as distinct from everything in `LightingState`, + * which is what that cloud does to the light once it is there. + */ + cloudCover(env: Environment): number; } // ---- Constants ------------------------------------------------------------ @@ -868,7 +885,40 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere { }; } - return { apply }; + /** + * The sky's own cover, 0..1, for whatever wants to draw it. + * + * **A method on `Atmosphere` rather than a field of `LightingState` or a + * second return from `apply`.** The rig is a set of consequences — three + * lights, a gradient and a fog — and CONTRACT.md §4's one-way rule survives + * only while causes travel in an `Environment` and consequences travel in a + * `LightingState`. A cover parked on the rig is an invitation for the next + * module along to read a cause back out of a light, which is the shape this + * file exists to prevent. It is not a field of `Environment` either: an + * `Environment` is what was *observed*, `observe()` is handed nothing but a + * place and an instant, and a modelled number sitting in the observation is + * exactly the confusion that `WeatherObservation`'s `null`-means-unreported + * rule is careful about. + * + * Which leaves a method, and the closure is the reason it is a good one: the + * answer needs `lng`, because the marine layer's clock runs on apparent solar + * time, and it needs this city's `marineLayer`, because the fog is a fact + * about one coast. Both are already held here. A free function would have to + * be handed both at every call site, and the call site that matters already + * has an `Atmosphere` in scope. + */ + function cloudCover(env: Environment): number { + // **An observation wins outright, and nothing below is allowed to argue + // with it.** `WeatherObservation.cloudCover` is a measured fraction — never + // `null`, unlike the fields that can go unreported — so the presence of an + // observation at all is the whole test. A *reported* clear sky ends the + // argument here exactly as it does for obscuration in `apply`: the model + // below is what to do when nobody was asked, not a second opinion. + if (env.weather) return clamp(env.weather.cloudCover, 0, 1); + return modelledCloudCover(marineOptions, env, lng); + } + + return { apply, cloudCover }; } // ---- The sun's direction, and the shadow camera --------------------------- @@ -1377,6 +1427,183 @@ function applyObscuration( return fogColor; } +// ---- Modelled cloud cover ------------------------------------------------- + +/** + * The most sky the generic term is ever allowed to cover. + * + * Scattered to broken, never overcast, and the cap is a statement about what + * this module is entitled to claim. It knows one climate in detail — the coast + * `MarineLayerOptions` describes — and for every other city it has a longitude + * and a sun angle. That is enough to say "there is usually some cloud about, + * more of it in the afternoon"; it is not enough to close a stranger's sky over + * a city it has never been told anything about. An overcast is a real event with + * a real cause, and if a deployment wants one rendered it can report one, at + * which point `cloudCover` hands the report straight through. + */ +const SYNOPTIC_MAX_COVER = 0.55; + +/** + * The slow term: `[period in days, amplitude, phase in turns]`. + * + * Weather systems arrive, cover the sky for a day or two and leave, and that is + * the variation a viewer notices across a week. Three cosines of deliberately + * incommensurate period are the cheapest thing that produces it while staying + * *smooth* — every requirement on this number at once. Continuous in time, and + * in every derivative, so a clock that scrubs forward never steps. Deterministic + * from the instant alone, so two people looking at the same city at the same + * moment on different machines see the same sky, with no seed to agree on and + * nothing stored. And non-repeating on any timescale anyone will watch: as + * tenths of a day the three periods are 29, 67 and 151, all prime, so the + * combined pattern closes after 29 × 67 × 151 tenths — 29,339.3 days, a little + * over eighty years. A single period would come back around inside a fortnight + * and be recognised. + * + * The phases are there only so the three do not all start aligned at the Unix + * epoch, which is a real instant the clock can be scrubbed to. + * + * The amplitudes sum to 1, which is what lets `synopticCover` rescale without a + * second constant to keep in step. + */ +const SYNOPTIC_WAVES: readonly (readonly [number, number, number])[] = [ + [2.9, 0.5, 0.13], + [6.7, 0.32, 0.61], + [15.1, 0.18, 0.29], +]; + +/** + * Exponent leaning the slow term back toward a clear sky. + * + * Three cosines summed and rescaled pile up around their own midpoint. Over a + * year of hourly samples the unshaped term runs 0.20 at the tenth percentile, + * 0.50 at the median and 0.80 at the ninetieth — a sky that is half covered + * half the time, which is not weather, it is a permanent haze the eye stops + * seeing after a minute. Raising it moves the middle down and leaves both ends + * alone: the shaped term still reaches its cap on the days all three waves + * agree and still reaches zero, but the same samples now run 0.11 / 0.38 / + * 0.73. Multiplied out through `SYNOPTIC_MAX_COVER` and `cumulusDiurnal`, a + * city with no marine layer spends a year at a median cover of 0.14, a + * ninetieth percentile of 0.30 and a maximum of 0.53 — some cloud up there + * nearly always, a busy sky now and again, and never a lid. + */ +const SYNOPTIC_SHAPE = 1.4; + +/** + * Apparent solar hour the diurnal term peaks at, and how far it falls overnight. + * + * Cumulus over land is built by the ground under it: the surface heats, the + * heat takes time to get into the air above it, and the cloud that results + * peaks well after noon and thins out overnight. Mid-afternoon here rather than + * noon is that lag — and it is the reason this cannot be driven off the sun's + * elevation, which is the obvious idea and is symmetric about noon. Elevation + * alone makes nine in the morning and three in the afternoon the same sky, and + * they are not the same sky. Apparent solar hours are asymmetric about noon and + * are what `DIURNAL` already runs the marine layer's clock on; see `solarHours` + * for why that is also the only clock available offline. + * + * A floor rather than zero because not all cloud is convective. A sky that + * emptied completely every night and refilled every morning would be a stronger + * claim than this module has any way to support, and the marine layer — which + * does exactly the opposite, being thickest before dawn — is the standing proof + * that it would be wrong somewhere. Where it sits is a compromise between that + * and the requirement that a day not be flat: 0.4 leaves the afternoon two and + * a half times the pre-dawn sky at most, which is a shape you can watch arrive + * without it ever emptying. + */ +const CUMULUS_PEAK_SOLAR_HOUR = 15; +const CUMULUS_NIGHT_FLOOR = 0.4; + +/** + * Cloud cover with nobody to ask: 0..1, from the calendar, the clock and the + * coast. + * + * This is the offline path and the offline path is the *default* one. A city + * with no weather API configured — no account, no key, no network, which is the + * case this whole engine is written around — has no observation to draw a sky + * from, and a caller that reads a cover of 0 out of that draws no cloud, ever. + * The fix belongs in this file because this file already models a sky nobody + * observed: it is where the marine layer lives, and the marine layer is the same + * argument already won once for fog. + * + * Two terms, and the split is the point: + * + * - **The coast, when there is one.** `marineStrength` is season × clock × + * wind and is already what the fog is computed from; it is reused rather + * than paraphrased, so the deck a viewer sees and the grey the rig goes are + * the same event and cannot drift apart. Its strength reads directly as a + * cover because that is what it physically is — an advected stratus deck at + * full strength is a covered sky. It carries the season and the burn-off + * clock with it, which is why a June morning here comes out closed in and a + * December one does not. + * - **Everywhere else.** A slow synoptic drift over days, modulated by the + * afternoon build of cumulus over warm ground. Capped well short of + * overcast — see `SYNOPTIC_MAX_COVER` — because unlike the marine layer it + * is not a fact about anywhere in particular. + * + * The two combine by random overlap: two decks placed independently of one + * another leave `(1 - a)(1 - b)` of the sky clear between them. Plain `max` was + * the alternative and swallows the weaker layer whole, so a summer morning in + * San Francisco would render identically whether or not there was anything else + * in the sky that week. + * + * **Nothing here reaches the light rig, deliberately.** `apply` still reads its + * `cloud` from `env.weather` alone and every keyframe, curve and constant above + * is untouched — the rig is tuned and deployed and this is an output, not a new + * input. It would also be wrong twice over: the marine half of this number + * already reaches the rig as `obscuration`, so feeding it back in as `cloud` + * would count the same deck against the sun twice. + */ +function modelledCloudCover( + layer: MarineLayerOptions | null, + env: Environment, + lng: number, +): number { + const marine = layer ? marineStrength(layer, env, lng) : 0; + + // UTC milliseconds, so the phase is an absolute instant rather than anything + // to do with the viewer's timezone — two people in different zones are + // looking at the same sky and must be given the same number for it. + const days = env.time.getTime() / MS_PER_DAY; + const background = + SYNOPTIC_MAX_COVER * + synopticCover(days) * + cumulusDiurnal(solarHours(env.time, lng, env.sun.equationOfTime)); + + return clamp(1 - (1 - marine) * (1 - background), 0, 1); +} + +/** The slow term, 0..1. See `SYNOPTIC_WAVES` and `SYNOPTIC_SHAPE`. */ +function synopticCover(days: number): number { + let sum = 0; + for (const [period, amplitude, phase] of SYNOPTIC_WAVES) { + sum += amplitude * Math.cos(2 * Math.PI * (days / period + phase)); + } + // The amplitudes sum to 1, so `sum` lands in -1..1 and the base in 0..1. + // Clamped even so, and the reason is the exponent: it is fractional, and a + // base a single float error *below* zero raised to a fractional power is + // `NaN` rather than a small number. Neither 0.32 nor 0.18 is exact in binary, + // so "the amplitudes sum to 1" is true of the decimals and not quite of the + // doubles. One `NaN` leaving here is a cloud layer that silently stops + // drawing at one instant on one machine, which is the least debuggable + // failure available to a function this small. + return clamp((sum + 1) / 2, 0, 1) ** SYNOPTIC_SHAPE; +} + +/** + * The afternoon build, as a multiplier on the slow term rather than a term of + * its own: a cloudy week is cloudier in the afternoon, and a clear week is + * still clear at four o'clock. + * + * A cosine rather than a table like `DIURNAL`, because this one has to close the + * loop at midnight and a table has to be trusted to. Periodic by construction + * means there is no midnight seam to get wrong later. + */ +function cumulusDiurnal(hours: number): number { + const turns = mod(hours - CUMULUS_PEAK_SOLAR_HOUR, 24) / 24; + const bump = 0.5 * (1 + Math.cos(2 * Math.PI * turns)); + return CUMULUS_NIGHT_FLOOR + (1 - CUMULUS_NIGHT_FLOOR) * bump; +} + // ---- Time ----------------------------------------------------------------- /** @@ -1603,4 +1830,39 @@ function wrapSigned(x: number, period: number): number { * them; by -7.6° it is 0.572 and #8ea0d3 from the east. The shadows swing * across the city over about half an hour, which is not an artefact — it is * what actually happens, and on the one night a month it happens on. + * + * **`cloudCover`, with no weather at all**, which is the case it exists for. San + * Francisco with `PACIFIC_MARINE_LAYER`, 21 June 2026, midnight to 23:00 PDT: + * + * ``` + * 0.83 0.83 0.83 0.83 0.84 0.85 0.86 0.84 0.82 0.73 0.57 0.46 + * 0.28 0.27 0.25 0.23 0.30 0.43 0.54 0.72 0.76 0.81 0.81 0.82 + * ``` + * + * A June night closed in at over four fifths, burning back to under a quarter + * by mid-afternoon and shut again by nine — which is the marine layer's own + * diurnal curve arriving in the sky as well as in the fog, and is the day that + * city actually has in June. The 08:00 in that row is 0.82; the same hour four + * months later, on 21 October, is 0.12, and solar noon on 21 December 0.04: + * out of season the layer is + * not there, and neither is the cloud — the same October the fog notes above + * are careful about, arriving here for the same reason and out of the same + * curve. Los Angeles, same 24 hours and no marine layer, runs 0.15 down + * to 0.09 before dawn and back to 0.14 through the afternoon — the generic term + * alone, which is a few clouds about and a slight afternoon build, and never + * pretends to be more than that. + * + * Over a full year sampled hourly, San Francisco's modelled cover runs a median + * of 0.27 and a ninetieth percentile of 0.76, and touches 1.00 at the peak of + * the fog season; Los Angeles runs 0.14 and 0.30 against a maximum of 0.53, + * which is `SYNOPTIC_MAX_COVER` very nearly reached. Neither produces a + * non-finite value or leaves 0..1 anywhere in that year, including at 69.65 N, + * where the sun never sets and `solarHours` is doing the only clock there is. + * + * The largest change in one minute anywhere in that year is 0.0065, at San + * Francisco's steepest burn-off. There is nothing in this to step on: a scrubbed + * clock moves the sky the way an advancing one does. + * + * And with a station reporting, the model does not get a vote: the same June + * morning that models 0.82 returns exactly 0.05 when the observation says 0.05. */ diff --git a/src/engine/flights.ts b/src/engine/flights.ts index b8662bb..b07a1aa 100644 --- a/src/engine/flights.ts +++ b/src/engine/flights.ts @@ -386,6 +386,11 @@ export class AdsbFlights implements FlightSource { const body = (await res.json()) as { ac?: RawAircraft[] }; this.held = (body.ac ?? []) .filter((a) => typeof a.lat === "number" && typeof a.lon === "number") + // See the note on `heading` below: a record with no track is dropped + // rather than zeroed, and dropping it here keeps the map's return type + // honest instead of widening `Aircraft.heading` to admit a null that no + // consumer could do anything sensible with. + .filter((a) => typeof a.track === "number") // The endpoint takes a radius and is trusted to honour it, but a // receiver feeding one of these networks hears whatever it hears and // some deployments serve the lot. Anything outside the region projects @@ -398,7 +403,23 @@ export class AdsbFlights implements FlightSource { lng: a.lon as number, // Feed reports feet; the scene works in metres. altitude: typeof a.alt_baro === "number" ? a.alt_baro * 0.3048 : 3000, - heading: typeof a.track === "number" ? a.track : 0, + /** + * A missing track is *skipped*, not zeroed. + * + * ADS-B carries position-only records — surface vehicles, TIS-B and + * MLAT-derived targets — with no `track` field at all, and they pass + * every other filter here. Substituting `0` used to be harmless + * because a wrong heading only pointed a symmetrical dart the wrong + * way. It stopped being harmless when aircraft learned to bank: a + * target whose real heading is 200° reported as 0° looks like a 160° + * turn, which pins the roll at its 30° limit and holds it there for as + * long as the target is in the feed — a sustained, full-scale artefact + * produced entirely by invented data. + * + * Dropped in the filter above. An aeroplane this feed will not say + * the heading of is one this layer cannot draw honestly. + */ + heading: a.track as number, })); this.heldAt = nowSeconds(); return this.held; @@ -557,6 +578,74 @@ const COLOR_BANDS = 12; /** Steepest nose-up or nose-down attitude an aircraft is drawn at, in radians. */ const MAX_PITCH = 0.42; +/** + * Steepest bank an aircraft is drawn at, in radians — thirty degrees. + * + * A transport aircraft in normal operation does not go past this: airline + * procedure and autopilot bank limiters sit at 25–30°, and steeper than that + * is a manoeuvre rather than a turn. + * + * The cap is not really here for the aeroplanes, though — it is here for the + * data. A reported heading that jumps forty degrees because a receiver lost a + * target and reacquired it is, at this end of the wire, indistinguishable from + * a genuine turn, and it arrives with the position barely moved, so the + * teleport test in `update` does not catch it — that test is about distance and + * this is a lie about attitude. Without a ceiling, one such sample knife-edges + * an airliner over the city, and a frame of that is worse than the flat turns + * this whole channel exists to replace. + */ +const MAX_BANK = (30 * Math.PI) / 180; + +/** + * Nominal true airspeed over g, in seconds. The one constant in the bank. + * + * An aircraft in a coordinated turn holds `tan(bank) = ω·V / g`: the + * horizontal component of lift is the only thing turning it, so the bank + * needed for a given rate of turn rises with how fast the aeroplane is going. + * That relation is what `bankAngle` evaluates, with V fixed at 200 m/s + * (389 kt) rather than measured. + * + * **Fixed rather than measured, deliberately.** The speed is derivable from + * the same pair of samples the turn rate comes from — ground distance over + * `span`, which `climbAngle` already computes half of — but both terms then + * carry a 1/span, so their product carries 1/span², and position noise on a + * live feed would swing the bank about on its own. A constant is wrong by a + * factor the eye cannot read; noise is wrong in a way it can. + * + * 200 m/s is a compromise and knowingly one. `syntheticRoutes` flies its legs + * at eight seconds a nautical mile, which is about 450 kt and is cruise; an + * arrival on final is doing a third of that. Sitting between them draws + * terminal-area turns a little flatter than they fly and high-level ones a + * little steeper — the cheap direction to be wrong in, because the steep case + * saturates against `MAX_BANK` and the flat case still reads as a bank. + * + * V/g works out at 20.4 s, which puts a half-standard-rate turn (1.5 °/s, the + * airline norm at altitude) at 28° and reaches the 30° ceiling at 1.62 °/s. + */ +const SPEED_OVER_G = 200 / 9.80665; + +/** + * Time constant for how quickly the bank follows the turn, in seconds. + * + * The roll is not set to the geometric answer, it is eased toward it: each new + * leg closes a fraction `1 - exp(-span / ROLL_SETTLE_SECONDS)` of the gap. A + * first-order lag, and nothing with a second derivative in it, because a + * first-order lag cannot overshoot. The requirement is that a straight leg + * settles to wings-level and *stays* there; a spring-and-damper would sit + * rocking about zero for several seconds after every turn, which is a worse + * artefact than the flat turns it would have been introduced to fix. + * + * Expressed as a time constant rather than as a per-update fraction because + * `span` is not one quantity here: it is a second for the simulator and five + * to fifteen for a live feed — see the repeat check in `update`. A flat "close + * 30% of the gap each time" would be about three seconds of lag on one source + * and forty-five on the other. At 2.5 s a 1 Hz simulator covers 63% of a roll + * in 2.5 s and 90% in 5.8, while a 10 s live refresh takes 98% of it in a + * single step — which is right, because on that source `tick` is already + * spreading the movement across ten seconds of interpolation. + */ +const ROLL_SETTLE_SECONDS = 2.5; + interface TrailSample { position: THREE.Vector3; altitude: number; @@ -574,6 +663,24 @@ interface Track { span: number; /** Climb angle of the current leg, radians, positive nose-up. */ pitch: number; + /** + * Bank at the two ends of the current leg, radians, positive right-wing-down. + * + * Two numbers rather than one because the roll is interpolated across the leg + * exactly as the position, the altitude and the heading are. A single value + * would step once per poll, and a step is *more* conspicuous on the roll + * channel than on the others: yaw and position move continuously either side + * of it so the discontinuity is small, whereas a bank that arrives all at once + * is an aircraft snapping onto its wingtip. On a live feed that would be a + * visible flick every five to fifteen seconds, on every aircraft that is + * turning. + * + * `rollFrom` is simply what `rollTo` was on the previous leg, so the two + * always meet and the interpolation is continuous across a poll even though + * the target it is chasing is not. + */ + rollFrom: number; + rollTo: number; /** Which cached material is on the mesh, so a band change is the only write. */ band: number; /** Interpolated position, reused rather than reallocated every frame. */ @@ -698,6 +805,11 @@ export function createFlightLayer(world: World): FlightLayer { samples: [], span: MIN_SPAN, pitch: 0, + // Wings level: a track with one observation has no pair of headings to + // have turned between, and `tick` reads both of these on its very first + // frame, so neither may start undefined. + rollFrom: 0, + rollTo: 0, band: -1, head: position.clone(), headAltitude: a.altitude, @@ -765,9 +877,21 @@ export function createFlightLayer(world: World): FlightLayer { track.samples.length = 0; track.head.copy(position); track.pitch = 0; + // The attitude is history too. A simulated route that has just looped + // was, one poll ago, banked into whatever its last leg was doing, and + // that leg is now several hundred units away and belongs to a + // different flight — carrying the bank across the wrap would put the + // aircraft on its ear at the start of a dead-straight departure. Both + // ends are cleared so the interpolation has nothing left to run out. + track.rollFrom = 0; + track.rollTo = 0; } else { track.span = span; track.pitch = climbAngle(world, previous, sample); + // Where the last leg's roll finished is where this one's begins, which + // is what makes the bank continuous across a poll boundary. + track.rollFrom = track.rollTo; + track.rollTo = bankAngle(previous, sample, span, track.rollTo); } } @@ -873,6 +997,37 @@ export function createFlightLayer(world: World): FlightLayer { // Negative, because rotating the nose (+z) about +x by a positive angle // pushes it down. track.mesh.rotation.x = -track.pitch; + /** + * Bank, and the sign of it is the entire point of the channel. + * + * `rotation.order` is "YXZ", so z is applied first and therefore turns in + * the *body* frame — about whatever axis the nose has ended up on rather + * than about the world's z. That is what makes this a roll at all instead + * of a lean, and it is why one sign works at every heading. + * + * Which sign: the nose is modelled along +Z and the aircraft's own up is + * +Y — `aircraftGeometry` builds the fin in the x = 0 plane reaching up to + * y = +0.098, so there is no ambiguity about which way is up on this mesh + * and therefore none about which way its wings go. A positive rotation + * about +Z takes +Y to (−sin θ, cos θ, 0), so the up vector tilts toward + * local −X; and local −X is the aircraft's right-hand side, because + * right = nose × up = (+Z) × (+Y) = −X. + * **Positive `rotation.z` drops the right wing.** + * + * A compass heading increasing is a turn to the right — north through east + * — and `headingDelta` is positive for exactly that. The two conventions + * already agree, which is why there is no negation here, unlike on the two + * channels above. + * + * Verified against three.js rather than reasoned about alone, because + * getting this backwards is the failure everybody sees and nobody can + * name: with rotation.y = π (heading 000) and rotation.z = +30°, the + * mesh's world up comes out (0.5, 0.866, 0) — tilted toward +x, and +x is + * east, which is the right hand of a northbound aircraft. At heading 090 + * the same roll tilts it to (0, 0.866, 0.5), toward +z, which is south and + * is the right hand of an eastbound one. + */ + track.mesh.rotation.z = track.rollFrom + (track.rollTo - track.rollFrom) * alpha; const band = bandFor(track.headAltitude); if (band !== track.band) { @@ -1016,6 +1171,62 @@ function climbAngle(world: World, from: TrailSample, to: TrailSample): number { return clamp(Math.atan2(to.altitude - from.altitude, horizontal), -MAX_PITCH, MAX_PITCH); } +/** + * How far to bank for the turn between two observations, in radians. + * + * Three things are happening here and each is load-bearing. + * + * **The rate uses the clamped `span`, not the real gap between the samples.** + * That looks like a bug and is not: `span` is the time this layer is going to + * spend *rendering* the heading change, and the bank has to match the turn the + * viewer is watching rather than the one that happened. A feed that stalled for + * two minutes and came back a hundred and eighty degrees round has its recovery + * drawn by `tick` as a thirty-second turn, and an aircraft pivoting through half + * the compass in thirty seconds with its wings level is precisely the tell this + * function exists to remove. + * + * **`headingDelta` takes the short way round**, so 358° → 002° is +4° over the + * span and not −356°. Without that, a track crossing north would slam to the + * ceiling in the wrong direction for one leg and then unwind — the same wrap + * that `interpolateHeading` was already written for, on a channel where it would + * be far more obvious. + * + * **The lag is applied here rather than in `tick`**, so it advances once per + * observation and in proportion to how much time that observation covered. + * Putting it on the frame instead would make the settling rate depend on the + * frame rate, and a 144 Hz monitor would bank aircraft differently from a 30 Hz + * one. + * + * The `Number.isFinite` guard is not defensive padding. Unlike the yaw, which + * `tick` recomputes from the samples every frame and which therefore repairs + * itself, the roll is *state* — it is fed its own previous value. One + * non-numeric heading from a feed would not cost a frame, it would poison the + * track for as long as it lives, because every later value is computed from this + * one. Returning `current` costs a leg of staleness and nothing else. + */ +function bankAngle(from: TrailSample, to: TrailSample, span: number, current: number): number { + const radiansPerSecond = (headingDelta(from.heading, to.heading) * Math.PI) / 180 / span; + if (!Number.isFinite(radiansPerSecond)) return current; + const target = clamp(Math.atan(radiansPerSecond * SPEED_OVER_G), -MAX_BANK, MAX_BANK); + return current + (target - current) * (1 - Math.exp(-span / ROLL_SETTLE_SECONDS)); +} + +/** + * The signed shortest angle from one compass heading to another, in degrees. + * + * Positive is a turn to the right: clockwise on the compass, north toward east. + * The result is in [−180, 180) — an exact reversal comes out as a left turn, + * arbitrarily, because two headings 180° apart carry no information about which + * way the aircraft went round. + * + * The yaw and the bank both need this and they have to agree about it. If one of + * them took the long way round, a track crossing north would spin one way while + * banking the other. + */ +function headingDelta(from: number, to: number): number { + return ((((to - from) % 360) + 540) % 360) - 180; +} + /** * Blend two compass headings the short way round. * @@ -1024,8 +1235,7 @@ function climbAngle(world: World, from: TrailSample, to: TrailSample): number { * could have. */ function interpolateHeading(from: number, to: number, t: number): number { - const delta = (((to - from) % 360) + 540) % 360 - 180; - return from + delta * t; + return from + headingDelta(from, to) * t; } /** 0 on the deck, 1 at cruise. Curved, because the low end is where the eye is. */ diff --git a/src/interiors/officeScene.ts b/src/interiors/officeScene.ts index 1e2267d..b783139 100644 --- a/src/interiors/officeScene.ts +++ b/src/interiors/officeScene.ts @@ -247,6 +247,16 @@ export interface OfficeScene extends StageScene { /** Scene-space label anchors per presence id, for an HTML overlay. Empty at public depth. */ anchors: Map; setCeilingsVisible(visible: boolean): void; + /** + * Draw the robots, or do not. + * + * Visibility only, deliberately. A hidden robot still walks and still moves + * the vectors the luminaires hold, so the fittings above it still come up — + * which is the useful half of the switch rather than a caveat: it is how you + * watch the ceiling respond without a figure in the way. Gating `tick` would + * freeze the building instead. + */ + setRobotsVisible(visible: boolean): void; setLighting(state: LightingState): void; /** * The sun's height, in degrees, from whatever clock the app is running. @@ -769,6 +779,9 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): setCeilingsVisible(visible) { shell.ceilings.visible = visible; }, + setRobotsVisible(visible) { + if (robots) robots.group.visible = visible; + }, setLighting(state) { kit.applyLighting(state); paintHorizon(state); diff --git a/src/interiors/robots.ts b/src/interiors/robots.ts index 0f5da64..630ef9f 100644 --- a/src/interiors/robots.ts +++ b/src/interiors/robots.ts @@ -26,32 +26,97 @@ * - **One set of geometry, 7.1k triangles, however many robots there are.** * `buildOptimus` runs once and every figure after the first is a * `cloneOptimus`, which shares every buffer and both materials. - * - **About 30 µs per tick for the crowd**, measured over thirty simulated - * minutes on the reference office — roughly 0.2% of a 60 Hz frame. Most of a + * - **About 20 µs per tick for the crowd**, measured over thirty simulated + * minutes on the reference office — roughly 0.1% of a 60 Hz frame. Most of a * tick is `plan.blocked`, which is linear in the level's collision segments * (fifty-four on that floor); a robot spends one or two calls a frame * steering and up to fifteen on the frames where it is boxed in and fanning * out. Nothing here is worth caching. - * - **Picking a destination costs up to 24 `blocked` calls**, but only on the - * frame a robot arrives somewhere, which is every few seconds. A robot that - * finds nowhere to go waits `RETRY_PAUSE` before trying again, so even one - * that has been sealed into a cupboard costs a burst every second and a half - * rather than one every frame. + * + * It used to be 24 µs, and errands made it *cheaper* rather than dearer. + * Choosing a destination with a reason costs one `blocked` call; choosing one + * at random cost up to forty-eight, because every candidate had to be tested + * for standing room and then again for line of sight. Knowing where you are + * going is less work than not. + * - **Picking a destination costs one `blocked` call** in the normal case and + * at most `SHORTLIST` of them, plus one `roomAt` — and only on the frame a + * robot arrives somewhere, which is every few seconds. The fallbacks are the + * old prices: up to two `blocked` calls on each of `PICK_ATTEMPTS` random + * candidates, then a pass over the doors. A robot that finds nowhere to go + * waits `RETRY_PAUSE` before trying again, so even one sealed into a cupboard + * costs a burst every second and a half rather than one every frame. + * - **An address book costs about 2.5 ms per level, once.** 224 places on the + * reference ground floor, each checked for standing room and a run-in, built + * the first time a robot on that level picks a destination and never again. + * It is deliberately not built at construction: a level with no robots on it + * never needs one, and a hitch during the load is a hitch nobody attributes + * correctly. + * + * ### Errands: where a robot goes, and why it is there + * + * The difference between a robot that is working and a robot that is patrolling + * is not the walk. It is the destination and the arrival. + * + * This used to pick a uniformly random reachable point, walk to it, stop at + * whatever angle it happened to be facing, wait between 1.4 and 4.6 seconds, and + * repeat. Every part of that is defensible on its own and the sum of it is a + * security guard: nowhere it goes is a place, nothing it does when it gets there + * is different from anything else it does, and the only thing distinguishing one + * stop from the next is a random number. + * + * So a destination is now an **address** — somewhere `Plan` already has a name + * for — and an address comes with an angle and a reason to linger: + * + * - a **seat**, approached from behind and held at the seat's own `facing`, so + * the robot stands at somebody's desk looking at the desk; + * - a **fixture** — an authored prop, which is to say a whiteboard, a locker, + * a shelf, a meeting chair — stood in front of and looked at; + * - a **room's centre**, which is the one that sends a robot to the kitchen. + * + * `ErrandKind` covers the choosing and `Address` the arriving. Three things + * about it are worth knowing before changing any of it: + * + * - **The kind is drawn before the address**, so the pack cannot decide the + * mix by how many desks it happens to author. See `ERRAND_MIX`. + * - **Candidates are shortlisted and scored, not taken first-fit**, on how + * recently anyone went there, how close it is to another robot or to where + * another robot is heading, and whether it is out of the room this robot is + * already standing in. That is what spreads four robots over a building + * instead of letting them pool. See `scoreAddress`. + * - **The last stretch is walked along the angle the robot will hold**, via a + * waypoint behind the destination, so it arrives lined up rather than + * stopping crooked and pivoting. See `APPROACH_RUNS`. + * + * Measured over ten thirty-minute runs on each reference pack, against the same + * harness running the version this replaced: a robot arriving somewhere with an + * angle to hold now arrives a median of 9° off it rather than stopping wherever, + * and the crowd enters 15 to 18 of the reference building's 26 rooms in half an + * hour rather than 12 to 17 — and all six of the second pack's every single run, + * rather than four to six. + * + * It also stands still more: 42% of the session against 29%. That is the point + * rather than a regression. The old 29% was a robot with nothing to do having + * nothing to do; the new 42% is four robots holding at desks, at whiteboards and + * in doorways, and the number to watch is not that one but the one below it — + * how much of the standing is a robot that has genuinely failed to find anywhere + * to go, which is what the watchdog and `pickDoor` exist to keep near zero. * * ### Navigation: rejection sampling, not a navmesh * + * Underneath the errands, and still the whole of the fallback. + * * Building a navmesh for an office would mean a floor decomposition, a portal * graph, A*, string-pulling and a funnel — several hundred lines, a new build * product to keep in step with `Plan`, and a whole second definition of "where * can you stand" beside the one the wall split already produces. All of that to * decide which way a decorative robot walks round a desk. * - * So: pick a random point on the floor, keep it if `plan.roomAt` says it is - * indoors and `plan.blocked` says the straight line from here to there crosses - * no wall, and walk at it. Give up after `PICK_ATTEMPTS` and wait a beat. Watch - * one robot for a minute and it looks like it is wandering; watch the algorithm - * and it is playing join-the-dots with its own line of sight. Both readings are - * correct and only one of them is visible. + * So: pick a point on the floor, keep it if `plan.roomAt` says it is indoors and + * `plan.blocked` says the straight line from here to there crosses no wall, and + * walk at it. Give up after `PICK_ATTEMPTS` and wait a beat. Watch one robot for + * a minute and it looks like it is wandering; watch the algorithm and it is + * playing join-the-dots with its own line of sight. Both readings are correct + * and only one of them is visible. * * Two refinements on top of that, and both exist because the plain version was * measured and found wanting rather than because they seemed like good ideas. @@ -65,9 +130,12 @@ * through a 0.9 m gap almost never exists and three of four robots spent * nineteen simulated minutes parked in one (`pickDoor`). * - * Together those take the crowd from 82% of the session standing still to under + * Together those took the crowd from 82% of the session standing still to under * 30%, which is the difference between an office with robots in it and an office - * with four statues. + * with four statues. Both still run, and both still matter: an errand needs an + * address it can see, and the two things that produce a robot which cannot see + * one — a small room, and a pack with nothing in it — are exactly what these two + * were built for. * * Two things this deliberately does not know about: * @@ -77,6 +145,12 @@ * feature with a real cost and is not this. If it ever matters, the place to * put it is `Plan`, next to the wall split, so that the walk controller and * the robots get the same answer. + * + * Errands make this more conspicuous rather than less, because a robot now + * walks *up to* furniture on purpose. What keeps that looking right is that + * it stops short of it: `DESK_STANDOFF` and `FIXTURE_STANDOFF` are the two + * places in this file that know a prop takes up room, and both are stated as + * distances rather than looked up, precisely so that this stays true. * - **Stairs.** A robot belongs to one level for its whole life. Levels are * connected by nothing in the office contract, so there is nowhere for it to * go, and a robot that walked off a mezzanine would be a bug rather than a @@ -116,7 +190,9 @@ * * Soaked over ten thirty-minute runs across both reference packs, four robots * each, with a four-second frame thrown in every fifty seconds to imitate a tab - * waking up: no robot left a room and none entered the clearance band. + * waking up: no robot left a room and none entered the clearance band. Re-run + * unchanged after errands arrived, since a destination with a name is still just + * a point as far as everything below here is concerned — same result. * * ### The walk cycle runs on distance, not on time * @@ -137,6 +213,16 @@ * drains out of it, settling the figure from wherever it was without moving a * foot across the floor. Fading the pose out is the only way to stop that does * not slide; running the cycle on to the end of the stride is the way that does. + * + * One consequence, and it is the price of the arrival turn. A robot settling + * onto its seat's facing rotates with `gait` at zero and its feet planted — the + * whole figure swings about its own axis, because there is no shuffle to play + * and faking one out of the walk joints would be a stride taken on the spot, + * which is the exact thing the paragraph above is about. `SETTLE_RATE` makes + * that slow enough to read as deliberate, and `APPROACH_RUNS` makes it small + * enough to mostly not happen. A shuffle would need turn-in-place footwork the + * rig has never had, and it would have to be driven by yaw the way the walk is + * driven by distance, or it would skate for the same reason. */ import * as THREE from "three"; @@ -219,7 +305,14 @@ const THROUGH_DOOR = 0.85; const PICK_ATTEMPTS = 24; /** Seconds to wait after failing to find anywhere to go. */ const RETRY_PAUSE = 1.5; -/** Seconds a robot stands still on arrival, before and after a random spread. */ +/** + * Seconds a robot stands still after arriving somewhere that was **not** an + * errand — a random point on the floor, or the far side of a doorway. An errand + * sets its own dwell from `DWELL`; this is the shrug. + * + * Also the spread on the initial stagger, so four robots do not all set off on + * the same frame. + */ const PAUSE_MIN = 1.4; const PAUSE_MAX = 4.6; @@ -255,6 +348,219 @@ const GAIT_EASE = 0.24; */ const MAX_STEP = 0.1; +// ---- Errands -------------------------------------------------------------- + +/** + * What a robot went somewhere *for*. + * + * It decides exactly two things — how often that kind of place gets picked, and + * how long a robot stands there once it arrives — and those two are most of the + * difference between a crowd that is working and a crowd that is patrolling. + * Nothing about the walk itself branches on it. + * + * - **`desk`** is a seat. `Plan` publishes every one of them with a `facing`, + * which is the whole reason this exists: arriving at a named spot and + * turning to the angle that spot says is the single detail that reads as + * purpose. See `DESK_STANDOFF` for why the robot stops short of the seat + * rather than on it. + * - **`fixture`** is an authored prop — a whiteboard, a locker, a shelf, a + * meeting chair. Identified by what it is *not*: not bound to a seat and not + * generated by a desk bank, so it is something the pack author put there on + * purpose rather than the second half of a workstation. + * - **`room`** is a room's centroid. The only kind with no facing, and the + * only kind that is about the building rather than about the furniture — + * it is what sends a robot to the kitchen or across the commons. + */ +type ErrandKind = "desk" | "fixture" | "room"; + +/** + * How the three kinds are mixed, as relative weights. + * + * **The kind is drawn first and the address second**, and that ordering is the + * point. Drawing uniformly over one flat list of addresses would let the pack + * decide the mix by accident: the reference office resolves 76 seats, roughly + * 150 fixtures and 17 rooms on its ground floor, so a flat draw would send a + * robot to a room's centre about 9% of the time and to a desk about 31% — + * neither of which anybody chose. Picking the kind first fixes the *behaviour* + * and lets the pack decide only which whiteboard. + * + * Weighted toward desks because a desk is the strongest read and because there + * are enough of them that four robots do not visibly repeat. Kinds a level has + * none of are skipped and their weight goes to the others, so a pack with no + * authored props still gets desks and rooms rather than a stalled robot. + */ +const ERRAND_MIX: readonly (readonly [ErrandKind, number])[] = [ + ["desk", 0.5], + ["fixture", 0.2], + ["room", 0.3], +]; + +/** + * Seconds spent standing at each kind, low and high of a uniform spread. + * + * A robot that pauses for the same length of time everywhere reads as a state + * machine no matter how good the destinations are, so the dwell is the errand's + * and not the walk's. The ordering is the story: you stand at a desk because you + * are doing something there, you look at a whiteboard for a moment, and a room's + * centre is somewhere you are passing through. + * + * The upper end matters more than it looks. Four robots with a mean dwell around + * six seconds and trips that take rather longer than that leaves most of them + * walking at any instant, which is the balance that reads as an office; push the + * desk dwell to half a minute and you get four robots standing about. + */ +const DWELL: Record = { + desk: [5, 12], + fixture: [3, 7], + room: [1.5, 4], +}; + +/** Seconds to stand after stepping through a doorway. Short: the point was to leave. */ +const DWELL_DOOR: readonly [number, number] = [0.4, 1.2]; + +/** + * How far behind a seat a robot stops, in metres. + * + * **A robot must never stand on a seat**, and that is a hard rule rather than a + * preference: `presence.ts` puts a person mesh at exactly `seat.position` with + * exactly `seat.facing`, so a robot that treated the seat as its own destination + * would stand inside whoever is sitting there. Seats are addresses for + * occupants; a robot visiting one is a visitor. + * + * The number is sized off the chair rather than picked. `seating.ts` gives + * `tera:seat.task-chair` a 0.64 m footprint — the star base, which is its widest + * part — so 0.32 m from the seat centre to the chair's edge, plus the 0.28 m + * robot radius, is 0.60 m before the two touch. 0.75 leaves 150 mm of air. + * + * That is cosmetic and not a collision guarantee: `plan.blocked` is the wall + * collider and knows nothing about furniture, as the header says. It is the + * difference between a robot standing at somebody's desk and a robot standing + * in their chair, which is visible from every camera angle in the building. + * + * The offset direction falls out of `plan.ts`: a bank puts its seat at the desk + * centre plus `(sin f, cos f) · seatOffset`, so stepping further along that same + * ray is further from the desk — behind the occupant, looking the way they look. + */ +const DESK_STANDOFF = 0.75; + +/** + * How far in front of a fixture a robot stops. + * + * Chosen rather than derived, because deriving it would mean asking the asset + * registry for every prop's footprint, and this file deliberately does not know + * that the registry exists — the same line the header draws around furniture + * collision. 0.9 m is the distance a person stands from a whiteboard, and it is + * far enough that the error on a fixture with a deeper footprint than expected + * is a robot standing a little close rather than a robot standing inside it. + * + * The facing convention is the desk's, taken from `plan.ts` and not invented + * here: a prop's front is its local **+Z**, `(sin r, cos r)`, because that is the + * side a desk bank puts its seat on. A prop authored with a meaningless rotation + * — a rug — gets a meaningless standing spot, which costs a robot a few seconds + * looking at the floor and breaks nothing. + */ +const FIXTURE_STANDOFF = 0.9; + +/** + * How far back along its own facing an errand's run-in starts, longest first. + * + * The last leg of an approach is walked *along* the facing, so the robot arrives + * already lined up instead of stopping at a random angle and then pivoting. The + * first entry is a turn budget: a quarter turn at `TURN_RATE` takes (π/2)/2.2 = + * 0.71 s, which at `CRUISE` is 0.86 m — so 0.9 m is one right-angle's worth of + * turning, and better than that in practice, because a turning robot walks + * slower and therefore turns further per metre. + * + * The second entry is there because the first one alone is not available often + * enough, and this is the measurement that says so. A run-in has to be somewhere + * a robot could stand, and 0.9 m behind the standing spot is 1.65 m behind the + * seat itself — which on the reference ground floor is inside a wall for 27 of + * 72 desks and outside every room for 9 more, because that is what a meeting + * room is. Only 36 desks got a run-in at all. Falling back to 0.45 m takes it to + * 62, and the arrivals it buys are as well aligned as the long ones. + * + * Note what 0.45 does *not* buy, so nobody re-derives it as a bug: `REACHED` is + * 0.22 and `ARRIVE` is 0.35, so a robot that clears a 0.45 m run-in can already + * be inside the arrival radius and stop on the spot without walking a step of + * the final leg. It still helps, because the alignment mostly comes from having + * steered at a point on the destination's own axis rather than from the metre + * after it. Measured against dropping the fallback entirely, it moves the 75th + * percentile of arrival error from 97° to 86°; both against 90° for a robot that + * simply stops where it gets to. + * + * Same shape as `PROBE_RELIEF`, and for the same reason: a value that is right + * when there is room for it and a smaller one that is better than nothing. + */ +const APPROACH_RUNS = [0.9, 0.45]; + +/** + * A prop whose base sits higher than this is not something you walk up to. + * + * `OPTIMUS.shoulderY` rather than a number, because the question this is asking + * is "is this thing in front of the robot or above it". It exists because the + * "authored prop" test catches ceiling lights: the reference ground floor + * authors 81 troffers and 22 pendants, none bound to a seat, and every one of + * them would otherwise be a place to stand and stare upward. + * + * Measured across both reference packs, the split is not close: the highest + * floor-standing fixture base is a wall display at 1.15 m and the lowest light + * is a troffer at 2.30 m, so the cutoff sits in the middle of a metre-wide gap + * and no plausible pack lands on the boundary. + */ +const FIXTURE_MAX_BASE = OPTIMUS.shoulderY; + +/** How many addresses are scored before one is committed to. See `pickErrand`. */ +const SHORTLIST = 6; + +/** + * Seconds before somewhere a robot went is fully interesting again. + * + * Without this the crowd converges: the score is the same every time it is + * asked, so the best desk in the building is the best desk for every robot for + * the whole session. A minute is long enough that a repeat is a coincidence + * rather than a rut, and the floor below keeps a just-visited address merely + * unlikely rather than banned — on a level with three addresses and four robots, + * banning is how you get a robot with nowhere to go. + */ +const REVISIT_COOLDOWN = 60; +const COOL_FLOOR = 0.05; + +/** + * Distance from the nearest other robot at which a destination stops being + * penalised for crowding, and the floor under that penalty. + * + * "Nearest other robot" counts where they *are* and where they are *going*, so + * two robots do not set off for the same whiteboard from opposite ends of the + * floor and discover the problem on arrival. + */ +const SPREAD_FULL = 7; +const SPREAD_FLOOR = 0.15; + +/** + * What a destination in the room the robot is already standing in is worth, + * against one somewhere else. + * + * This is the term that actually spreads the crowd through the building rather + * than round one floor plate, and it is nearly free: every address knows its + * room from the check that admitted it, so the only cost is one `roomAt` for the + * robot itself, once per errand. + */ +const SAME_ROOM = 0.35; + +/** + * Radians per second of yaw while standing still. + * + * Slower than `TURN_RATE` on purpose. The figure has no pivot-in-place + * animation — the gait is driven by distance travelled, so a robot turning + * without moving has its feet planted and swings the whole body — and the + * faster that happens the more it looks like a turntable. At this rate a half + * turn on the spot takes π/(2.2 · 0.55) = 2.6 s, which reads as settling. + * + * It is usually a small turn anyway, because `APPROACH_RUN` has the robot walk + * the last stretch along the angle it is going to hold. + */ +const SETTLE_RATE = TURN_RATE * 0.55; + // ---- Gait ----------------------------------------------------------------- /** Peak hip angle, radians. Everything else about the stride follows from it. */ @@ -518,6 +824,23 @@ interface Robot { target: Point2 | null; /** Seconds left of the current stand-still. Only meaningful with no target. */ wait: number; + /** + * A yaw to turn to while standing still, or nothing to stand as it stopped. + * **Only meaningful with no target**, and cleared once reached, so a robot + * that settles onto its seat's facing and then waits out the rest of its dwell + * is doing no work at all. + */ + settle: number | null; + /** + * The errand's plan for the moment of arrival: the yaw to hold and the seconds + * to hold it for. Chosen when the destination is, spent when it is reached — + * `arriveFacing` becomes `settle` and `arriveDwell` becomes `wait`. + * + * Two fields rather than one small object because a destination is picked + * every few seconds per robot and this file allocates only where it must. + */ + arriveFacing: number | null; + arriveDwell: number; /** 0 standing, 1 walking. Eased, never snapped. See the header. */ gait: number; /** Metres travelled ever. Drives the walk cycle and is never reset. */ @@ -526,9 +849,14 @@ interface Robot { sinceCheck: number; checkAge: number; /** - * An intermediate point to reach before `target`, or nothing. Only ever a - * doorway; see `pickDoor` for why one waypoint is enough and two would be - * pathfinding. + * An intermediate point to reach before `target`, or nothing. + * + * There is at most one, ever, and that is the rule that keeps this from + * becoming a path — see `pickDoor` for why one is enough and two would need a + * graph. It is either a **doorway**, when a robot could see nowhere to go and + * is leaving the room, or an errand's **run-in**, when the last stretch is + * walked along the angle the robot is going to hold on arrival. Both are + * checked as two independent legs, which is the only reason either works. */ waypoint: Point2 | null; /** The opening this robot last walked through, so it does not turn straight round. */ @@ -654,6 +982,340 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL return false; } + // ---- The address book --------------------------------------------------- + + /** + * Somewhere worth going, and what to do on arrival. + * + * Every field except `visitedAt` is decided once, when the level's book is + * built, and never changes — which is what makes an errand cost one `blocked` + * call to commit to instead of four. `at` has already been checked to be + * indoors and clear of every wall, and `approach` has been checked the same way + * *plus* the leg between the two, so by the time a robot is choosing, the only + * open question is whether it can see the thing from where it is standing. + */ + interface Address { + kind: ErrandKind; + /** Where the robot ends up standing. Never on a seat; see `DESK_STANDOFF`. */ + at: Point2; + /** The yaw to hold once there, or nothing to stop on the arrival heading. */ + facing: number | null; + /** + * Where the last leg starts, so the robot walks in already lined up. Absent + * when there is no facing to line up with, or when the run-in does not fit — + * a desk in an alcove with its back 0.5 m from a wall, say, which is still a + * perfectly good place to stand and simply gets approached from wherever. + */ + approach: Point2 | null; + /** Which room `at` is in. Falls out of the check that admitted it; see `SAME_ROOM`. */ + roomId: string; + /** Layer clock when a robot last set out for here. See `REVISIT_COOLDOWN`. */ + visitedAt: number; + } + + /** One level's addresses, split by kind because the kind is drawn first. */ + interface AddressBook { + desk: Address[]; + fixture: Address[]; + room: Address[]; + } + + /** + * Seconds of simulated time since the layer was made, advanced by the same + * clamped `dt` the robots move on — so a tab that was asleep for a minute does + * not come back to a crowd whose cooldowns have all expired at once, for the + * same reason it does not come back to robots three rooms away. + */ + let clock = 0; + + const books = new Map(); + + /** The id of the room a robot could stand at this point in, or nothing. */ + function standable(levelId: string, point: Point2): string | null { + const room = plan.roomAt(levelId, point); + if (!room) return null; + return plan.blocked(levelId, point, point, radius) ? null : room.id; + } + + /** + * One address, if a robot can stand at it. + * + * The rejections here are the whole reason this is done once per level rather + * than per pick: a desk pushed against a wall, a whiteboard in a stairwell, a + * fixture whose front is inside a partition, a centroid outside its own + * L-shaped room. Every one of those is a fact about the pack that never + * changes, and paying for it at 60 Hz would be the expensive way to learn it. + */ + function makeAddress( + level: LevelPlan, + kind: ErrandKind, + at: Point2, + facing: number | null, + ): Address | null { + const roomId = standable(level.id, at); + if (roomId === null) return null; + + let approach: Point2 | null = null; + if (facing !== null) { + for (const run of APPROACH_RUNS) { + // Back along the facing: the robot walks from here to `at` looking the + // way `at` says, so the run-in and the hold are the same direction. + const back: Point2 = { + x: at.x + Math.sin(facing) * run, + z: at.z + Math.cos(facing) * run, + }; + if (standable(level.id, back) === null) continue; + // Measured on both reference packs: this leg has never once been the + // thing that failed, because it is short and colinear with two points + // already known to be clear. It is checked anyway — a pack is allowed to + // put a partition between a desk and the space behind it, and finding + // that out at 60 Hz with a robot walking through it is not the way. + if (plan.blocked(level.id, back, at, radius)) continue; + approach = back; + break; + } + } + // Far enough in the past that everything starts fully interesting, without + // any special case for "never visited" in the scoring. + return { kind, at, facing, approach, roomId, visitedAt: -REVISIT_COOLDOWN }; + } + + /** + * Every place on a level worth walking to, built once and kept. + * + * Cost is a few `roomAt` and `blocked` calls per candidate — up to three of + * each for a facing address — over every seat, every authored prop and every + * room on the level. On the reference ground floor that is 76 seats, 147 + * qualifying props and 17 rooms, and it is paid on the frame the first robot + * on that level picks its first destination and never again. Doing it eagerly + * at construction would move the same work to a worse moment, since a level + * with no robots on it never needs a book at all. + * + * Seats come from `level.seats` rather than `plan.allSeats()` on purpose: a + * robot belongs to one level for its whole life, and the building's seat list + * would offer it addresses on a floor it can never reach. + */ + function bookFor(level: LevelPlan): AddressBook { + const hit = books.get(level.id); + if (hit) return hit; + const made: AddressBook = { desk: [], fixture: [], room: [] }; + + for (const seat of level.seats) { + const spot = makeAddress( + level, + "desk", + { + x: seat.position.x + Math.sin(seat.facing) * DESK_STANDOFF, + z: seat.position.z + Math.cos(seat.facing) * DESK_STANDOFF, + }, + seat.facing, + ); + if (spot) made.desk.push(spot); + } + + for (const prop of level.props) { + // A prop bound to a seat, or generated by a desk bank, is the furniture of + // a workstation — the seat itself is already a better address for it, and + // adding the desk and the chair as well would put three addresses on one + // spot and weight the whole floor toward whichever room has the most desks. + if (prop.seat !== undefined || prop.source !== undefined) continue; + // `position.y` is the base of the prop with the level's elevation already + // in it, so the level's own floor has to come back out before it can be + // compared with a height on the robot. + if (prop.position.y - level.floorY > FIXTURE_MAX_BASE) continue; + const spot = makeAddress( + level, + "fixture", + { + x: prop.position.x + Math.sin(prop.rotation) * FIXTURE_STANDOFF, + z: prop.position.z + Math.cos(prop.rotation) * FIXTURE_STANDOFF, + }, + prop.rotation, + ); + if (spot) made.fixture.push(spot); + } + + for (const room of level.rooms) { + // No facing: there is nothing at a room's centre to look at, and inventing + // one — face the longest wall, face the door — would be a guess dressed up + // as intent. A robot arriving at a centroid stops looking the way it came + // in, which is into the room, which is enough. + // + // `centroid` is the area centroid and a room need not be convex, so this + // can land outside its own outline; `makeAddress` drops those rather than + // falling back to the bounding box centre, which is not more likely to be + // inside. Both reference packs have none. + const spot = makeAddress(level, "room", { x: room.centroid.x, z: room.centroid.z }, null); + if (spot) made.room.push(spot); + } + + books.set(level.id, made); + return made; + } + + // Scratch for the shortlist, reused by every robot on every pick. Same + // discipline as `from`, `to` and `chosen`: nothing here outlives the call that + // fills it, and `pickErrand` is the only thing allowed to read or write it. + const shortlist: (Address | null)[] = new Array(SHORTLIST).fill(null); + const shortlistScore: number[] = new Array(SHORTLIST).fill(0); + + /** + * Which kind of errand this one is, weighted by `ERRAND_MIX` over the kinds + * this level actually has any of. + * + * The empty-kind skip is not defensive coding for its own sake. Nothing in the + * office contract obliges a level to have seats, or props, or more than one + * room — the second reference pack's mezzanine resolves three desks and a + * single room, and a floor of meeting rooms with no authored furniture is an + * ordinary thing to write. A weight table that did not renormalise would spend + * a fifth of its draws on an empty list and fail whole picks for no reason, + * which presents as a robot that thinks for a second and a half. + */ + function drawKind(rand: () => number, book: AddressBook): ErrandKind | null { + let total = 0; + let last: ErrandKind | null = null; + for (const [kind, weight] of ERRAND_MIX) { + if (book[kind].length === 0) continue; + total += weight; + last = kind; + } + if (last === null) return null; + + let roll = rand() * total; + for (const [kind, weight] of ERRAND_MIX) { + if (book[kind].length === 0) continue; + roll -= weight; + if (roll <= 0) return kind; + } + // Floating-point slop only: the loop above subtracts exactly `total`. + return last; + } + + /** + * How much this robot wants this address, as a number in (0, 1]. + * + * Three factors, multiplied, and each one exists to stop a specific way four + * robots stop looking like four people: + * + * - **Cooldown**, so the crowd does not converge on the same few best + * addresses and pace a rut between them for the rest of the session. + * - **Elbow room**, so a robot does not walk across the building to stand + * where another one already is. Other robots' *destinations* count as much + * as their positions, which is the half that stops two robots setting off + * for the same desk and discovering it on arrival. + * - **Somewhere else**, so a robot in the kitchen tends to leave the + * kitchen. This is the term that spreads the crowd through the building + * rather than round one room, and it is the cheapest of the three. + * + * Multiplied rather than summed, because these are qualities a destination can + * lack independently and a sum lets one good factor carry two bad ones — the + * desk you were just at, with another robot already standing at it, would + * still score well for being in the next room. The floors under the first two + * keep the product away from zero, so a level with only bad options still + * produces an ordering rather than a tie. + */ + function scoreAddress(robot: Robot, address: Address, hereRoom: string | null): number { + const cool = Math.max(COOL_FLOOR, Math.min(1, (clock - address.visitedAt) / REVISIT_COOLDOWN)); + + let nearest = Infinity; + for (const other of robots) { + if (other === robot || other.level.id !== robot.level.id) continue; + const here = other.view.position; + nearest = Math.min(nearest, Math.hypot(here.x - address.at.x, here.z - address.at.z)); + const bound = other.target; + if (bound) { + nearest = Math.min(nearest, Math.hypot(bound.x - address.at.x, bound.z - address.at.z)); + } + } + const elbow = + nearest === Infinity ? 1 : Math.max(SPREAD_FLOOR, Math.min(1, nearest / SPREAD_FULL)); + + return cool * elbow * (address.roomId === hereRoom ? SAME_ROOM : 1); + } + + /** + * Pick somewhere with a reason to be there, or fail and let the caller fall + * back to the sampler. + * + * Shortlist, then commit — and the split is what keeps this cheap. Scoring is + * arithmetic over four robots and costs nothing, so `SHORTLIST` candidates are + * drawn and ranked without touching the collider at all; only then is line of + * sight tested, best first, and the first one that can be seen wins. The + * measured cost is one `plan.blocked` call for most picks, because the best + * candidate is usually visible, and at most `SHORTLIST` of them. + * + * That is strictly cheaper than the sampler it replaced, which spent up to two + * `blocked` calls on each of `PICK_ATTEMPTS` candidates and still ended up + * somewhere with no name — and it is most of why the whole tick got faster + * rather than slower. + */ + function pickErrand(robot: Robot): boolean { + const book = bookFor(robot.level); + const here = robot.view.position; + const hereRoom = plan.roomAt(robot.level.id, here)?.id ?? null; + + let filled = 0; + for (let i = 0; i < SHORTLIST; i++) { + const kind = drawKind(robot.rand, book); + if (kind === null) return false; + const pool = book[kind]; + const candidate = pool[Math.floor(robot.rand() * pool.length)]; + if (!candidate) continue; + // Same rule as the sampler's: too close and the robot shuffles rather than + // walks, and the walk is the part anybody sees. + if (Math.hypot(candidate.at.x - here.x, candidate.at.z - here.z) < MIN_TRIP) continue; + + // Insertion sort, best first. Six entries at most, so this is a handful of + // compares and — unlike sorting an array of pairs — no allocation. + const value = scoreAddress(robot, candidate, hereRoom); + let slot = filled; + while (slot > 0 && (shortlistScore[slot - 1] ?? 0) < value) { + shortlist[slot] = shortlist[slot - 1] ?? null; + shortlistScore[slot] = shortlistScore[slot - 1] ?? 0; + slot--; + } + shortlist[slot] = candidate; + shortlistScore[slot] = value; + filled++; + } + + for (let i = 0; i < filled; i++) { + const address = shortlist[i]; + if (!address) continue; + + // The run-in is taken whenever there is one, from wherever the robot is + // standing — including from the far side, where taking it means walking + // past the destination and coming back at it. That looked like the wrong + // trade and the measurement said otherwise. Taking it only from the near + // side, on the sign of a dot product, halved how often it was used at all + // — 128 of 399 picks over half an hour rather than 267 — and left the + // median arrival 99° off the angle it was supposed to hold. Taking it + // always costs 1.3% more walking and brings that median to 9°. + const goal = address.approach ?? address.at; + from.x = here.x; + from.z = here.z; + if (plan.blocked(robot.level.id, from, goal, radius)) continue; + + // Copied rather than aliased. The address is shared by every robot and + // lives for the whole session; `waypoint` and `target` are one robot's and + // are cleared and replaced constantly, and one line that reached for + // `robot.target.x = …` would quietly move the desk for everybody. + robot.waypoint = + address.approach === null ? null : { x: address.approach.x, z: address.approach.z }; + robot.target = { x: address.at.x, z: address.at.z }; + robot.arriveFacing = address.facing; + const [low, high] = DWELL[address.kind]; + robot.arriveDwell = low + robot.rand() * (high - low); + // Heading somewhere with a name, so the last door stops defining this + // robot — the same reasoning as the sampler's, and the reason a long + // circuit can come back through the door it left by. + robot.lastDoor = null; + address.visitedAt = clock; + return true; + } + return false; + } + /** * A doorway to head for when nowhere in the room is worth walking to. * @@ -729,6 +1391,8 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL robot.waypoint = { x: door.center.x, z: door.center.z }; robot.target = { x: beyond.x, z: beyond.z }; + robot.arriveFacing = null; + robot.arriveDwell = DWELL_DOOR[0] + robot.rand() * (DWELL_DOOR[1] - DWELL_DOOR[0]); robot.lastDoor = door.id; return true; } @@ -737,22 +1401,24 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL } /** - * Choose somewhere to walk to, or fail. + * A random reachable point on the floor. The fallback, and no longer the plan. * - * Failure is still a normal outcome, not an error — a robot boxed into a - * corner with no door in sight will wait and try again from wherever it is — - * and nothing is logged, because a robot with nowhere to go looks exactly like - * a robot taking a moment. + * This used to be the whole of destination selection, and everything that read + * as patrolling was here: a point drawn from a room's bounding box is not a + * place, it is a coordinate — so a robot walked to the middle of nowhere, + * stopped at whatever angle it happened to arrive at, waited a fixed-ish beat + * and set off again. `pickErrand` runs first now, and this catches the two + * things it cannot do: a robot in a room with no address it can see, and a + * pack that authors no seats, no props and no usable centroids at all. + * + * It is worth keeping precisely because it asks so little of the pack. An + * office is a `Plan`, and a `Plan` is allowed to be four walls and a door. * * The line-of-sight test is against the segment from here to there, and a * segment includes its endpoints — so this is also the check that the * destination itself has room to stand in, and there is no separate one. - * - * Sets `target` and `waypoint` on the robot rather than returning a point, - * because the doorway case has to set both and a function that returns one of - * them and mutates the other would be the worst of the two. */ - function pickTarget(robot: Robot): boolean { + function pickWander(robot: Robot): boolean { const candidate: Point2 = { x: 0, z: 0 }; const level = robot.level; const sampler = samplerFor(level); @@ -765,12 +1431,43 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL if (plan.blocked(level.id, from, candidate, radius)) continue; robot.target = { x: candidate.x, z: candidate.z }; robot.waypoint = null; + // Nothing there to look at and no reason to linger, so a shrug of a pause + // and off again. + robot.arriveFacing = null; + robot.arriveDwell = PAUSE_MIN + robot.rand() * (PAUSE_MAX - PAUSE_MIN); // Somewhere in the open: this robot is no longer defined by the last door // it used, and forgetting it is what lets a long circuit of the building // come back through the same doorway without a special case. robot.lastDoor = null; return true; } + return false; + } + + /** + * Choose somewhere to walk to, or fail. + * + * Three tiers, in descending order of how much the destination means: + * somewhere with a name and a facing, then anywhere at all on this floor, then + * out through the nearest door. A robot reaches the second only because it can + * see no address from where it is standing, and the third only because it can + * see nothing at all — which is why the order is this way round, and it is a + * happy accident of the shortlist that the tier that means the most is also + * the one that costs the least. + * + * Failure is still a normal outcome, not an error — a robot boxed into a + * corner with no door in sight will wait and try again from wherever it is — + * and nothing is logged, because a robot with nowhere to go looks exactly like + * a robot taking a moment. + * + * Every tier sets `target`, `waypoint`, `arriveFacing` and `arriveDwell` + * rather than returning a destination, because two of the three have to set a + * waypoint as well and a function that returned one field and mutated three + * would be the worst of both. + */ + function pickTarget(robot: Robot): boolean { + if (pickErrand(robot)) return true; + if (pickWander(robot)) return true; return pickDoor(robot); } @@ -800,10 +1497,21 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL return false; } - function beginPause(robot: Robot, seconds: number): void { + /** + * Stop, and stand there for `seconds`. + * + * `settle` is the yaw to turn to while standing, and it is a parameter rather + * than something read off the robot because the two callers want opposite + * things from it. Arriving somewhere passes the errand's facing — that is the + * point of the errand. Giving up — wedged, deadlocked, watchdogged — passes + * `null`, because a robot that failed to get somewhere has no business + * adopting the pose of having got there. + */ + function beginPause(robot: Robot, seconds: number, settle: number | null): void { robot.target = null; robot.waypoint = null; robot.wait = seconds; + robot.settle = settle; robot.sinceCheck = 0; robot.checkAge = 0; } @@ -858,6 +1566,11 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL target: null, // Staggered, so four robots do not all set off on the same frame. wait: rand() * PAUSE_MAX, + // Nothing to settle to and nowhere to have arrived from: a robot's first + // errand overwrites both of these before either is read. + settle: null, + arriveFacing: null, + arriveDwell: PAUSE_MIN, gait: 0, distance: rand() * STRIDE, waypoint: null, @@ -930,6 +1643,30 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL robot.wait = RETRY_PAUSE; } } + + // Still nothing to walk to, so this is a robot standing somewhere on + // purpose: turn it to the angle its errand asked for. Guarded on `target` + // rather than sequenced before the pick because `arriveFacing` has already + // been copied into `settle` by then and a pick that succeeded has replaced + // it with the *next* destination's — turning toward that one from here + // would have the robot aim itself across the building before setting off. + // + // This is the one place the yaw moves without a destination, and the + // reason `SETTLE_RATE` is slower than `TURN_RATE`. + if (robot.target === null && robot.settle !== null) { + let swing = robot.settle - robot.yaw; + swing = Math.atan2(Math.sin(swing), Math.cos(swing)); + const limit = SETTLE_RATE * dt; + if (Math.abs(swing) <= limit) { + // Arrived at the angle. `+= swing` rather than `= settle` keeps the + // yaw continuous — the facing came out of the pack and may be any + // multiple of a turn away from where this robot has wound up to. + robot.yaw += swing; + robot.settle = null; + } else { + robot.yaw += limit * Math.sign(swing); + } + } } // Steer at the waypoint while there is one, and at the destination after @@ -943,7 +1680,9 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL if (remaining < (robot.waypoint ? REACHED : ARRIVE)) { if (robot.waypoint) robot.waypoint = null; - else beginPause(robot, PAUSE_MIN + robot.rand() * (PAUSE_MAX - PAUSE_MIN)); + // Arrived. Both halves of what the errand asked for are spent here and + // nowhere else: how long to stand, and which way to look while doing it. + else beginPause(robot, robot.arriveDwell, robot.arriveFacing); } else { // A figure faces −Z at yaw 0, so the heading that points along (dx, dz) // is the one whose (−sin, −cos) matches it. This is the same convention @@ -1007,7 +1746,7 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL // can no longer reach. Throwing the destination away and standing // still for a moment resolves all three, and is the reason this // cannot vibrate against a wall forever. - beginPause(robot, RETRY_PAUSE); + beginPause(robot, RETRY_PAUSE, null); } else { robot.sinceCheck = 0; robot.checkAge = 0; @@ -1036,6 +1775,10 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL tick(dt) { if (!(dt > 0)) return; const clamped = Math.min(dt, MAX_STEP); + // The clamped step, deliberately: the clock exists to age destinations + // against how much walking has happened, and in a backgrounded tab none + // has. See `clock`. + clock += clamped; for (const robot of robots) step(robot, clamped); }, robots() { diff --git a/src/main.ts b/src/main.ts index 575eb7f..8ca0ac6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -44,10 +44,11 @@ import { import { SAMPLE_MARKERS, SAMPLE_PALETTE, - SAMPLE_PRESENCE, SAMPLE_PRESENCE_PALETTE, + samplePresenceAt, sampleRoutesFor, } from "./adapters/sample.ts"; +import { OFFICE_SITES } from "./offices/sites.ts"; import { authFetch } from "./session.ts"; import { capabilitiesFor, resolveAccess, type Access } from "./access.ts"; import { createMinimap, type Minimap } from "./engine/minimap.ts"; @@ -73,7 +74,7 @@ import type { MaterialRegistry } from "./assets/materials.ts"; // protect. It arrives with the office, in `loadOffice()`, because it is only // ever drawn once you are standing in one. import type { OfficeMinimap } from "./engine/officeMinimap.ts"; -import type { Godmode, GodmodePlace } from "./tools/index.ts"; +import type { Godmode, GodmodeHouseLights, GodmodePlace } from "./tools/index.ts"; import type { PoseEditor } from "./tools/poseEditor.ts"; const CITIES: { id: string; label: string; city: City }[] = [ @@ -147,7 +148,73 @@ let wantedCity = "sf"; let office: OfficeScene | null = null; let inside = false; let markers: Marker[] = SAMPLE_MARKERS; -let palette: MarkerPalette = SAMPLE_PALETTE; + +/** + * The buildings you can walk into, as pins on the city. + * + * This is the one thing that makes Tera and Spaces feel like one product rather + * than two views sharing a bundle. Both packs carry a real `site` — it is what + * puts the sun in the right place — and until now that coordinate was known to + * the lighting and to nothing else. A stranger looking at the board had no way + * to tell that two of those buildings are ones they can go inside. + * + * `OFFICE_SITES` rather than the packs themselves, deliberately: a pack is a + * lazy chunk worth tens of kilobytes and the city wants these the instant the + * board appears, long before anybody opens a door. See `offices/sites.ts`. + * + * `colorKey` is opaque to the engine, as every `Pin.colorKey` is — `SAMPLE_PALETTE` + * resolves it, and giving these their own key is what lets a door look different + * from a company. + */ +const OFFICE_MARKERS: Marker[] = OFFICE_SITES.map((entry) => ({ + id: `office:${entry.id}`, + label: entry.name, + colorKey: "office", + blurb: `${entry.site.label ?? "An office"} — click to walk in`, + lat: entry.site.lat, + lng: entry.site.lng, + // Hand-typed from the street grid, like every other coordinate here. Not a + // placeholder, so it is drawn as a real address. + located: true, +})); + +/** + * Whether a marker is a door rather than a company. + * + * The id prefix is the whole test, and it is deliberately something no marker + * off the wire can collide with: `markers/gate.ts` serves rows from a synced + * database and none of them are namespaced this way. + */ +function officeIdOf(marker: Marker): string | null { + return marker.id.startsWith("office:") ? marker.id.slice("office:".length) : null; +} + +/** + * Whatever the pointer is over, so a click knows what it clicked. + * + * The engine reports picks by hover rather than by click — that is what drives + * the detail card — so the click handler has no argument of its own and reads + * this instead. `null` whenever the pointer is over open ground, which is what + * makes a click on the terrain do nothing. + */ +let hoveredMarker: Marker | null = null; +/** + * The colour a door is drawn in, which no marker feed knows about. + * + * `colorKey` is opaque to the engine and resolved by the consuming app, so the + * palette is this file's business. The office key is merged in **here** rather + * than added to `SAMPLE_PALETTE`, because it is not a sample of anything: a + * deployment that replaces the whole marker feed with its own palette + * (`feed.palette`, further down) must still get doors it can see, and folding + * this into the sample set would lose it the moment real markers arrived. + * + * Amber, to sit with the chapter list and the "Enter the office" button rather + * than with the marker hues — a door is a piece of this application's + * navigation, and it should read as one. + */ +const OFFICE_PALETTE: MarkerPalette = { office: 0xf5b53f }; + +let palette: MarkerPalette = { ...SAMPLE_PALETTE, ...OFFICE_PALETTE }; let liveData = false; /** * What this visitor may do. Resolved once in `boot()`; every gate below reads @@ -290,6 +357,16 @@ let officePlan: OfficeMinimap | null = null; * possible to take it without the caption. */ let presenceIsSample = false; +/** + * The deployment's own roster, or `null` while the sample one stands in for it. + * + * Held separately from what is on screen because the two are refreshed by + * different things: a real roster arrives from the API on its own timer, and the + * sample one is a function of the clock and has to be recomputed whenever the + * clock moves. Collapsing them would mean either re-rendering a live roster on + * every scrub or freezing the sample one. + */ +let livePresence: Presence[] | null = null; /** * The running poll of who is in, or `null` when nobody is standing in the room. * @@ -369,6 +446,28 @@ function cityDoorUrl(cityWanted?: string): string | null { * override, one writer, one type that can carry everything the sun depends on. */ let instantOverride: Date | null = null; +/** + * Where the office's house lights take their level from. Godmode's switch, and + * it survives the office being rebuilt because the panel re-asserts it. + */ +let houseLights: GodmodeHouseLights = "sun"; + +/** + * The sun's height as the *fittings* are told it, which is the real one unless + * somebody has a hand on the switch. + * + * Forcing the level by lying about the elevation rather than opening a second + * path into `luminaires.ts`, and that is the design rather than a shortcut: + * `OfficeScene.setSolarElevation` feeds the fittings and nothing else, while + * the rig outside is computed from the real `env` a line later. So this moves + * the *interior* and leaves the sky, the key light and the fog exactly where the + * clock put them — which is the whole point of a control that lets you look at + * the night office in daylight. ±90° is far outside the 0°–6° ramp in + * `luminaires.ts`, and stays outside it if that band is ever widened. + */ +function houseElevation(actual: number): number { + return houseLights === "sun" ? actual : houseLights === "on" ? -90 : 90; +} /** * A fabricated sky, or `null` for whatever the deployment reports. * @@ -432,8 +531,8 @@ function officeLighting(site: NonNullable) { * ramp so the first frame is already correct rather than a lit room fading * down or a dark one fading up. */ - office?.setSolarElevation(env.sun.elevation); - const house = office?.houseLevel() ?? houseLevelFor(env.sun.elevation); + office?.setSolarElevation(houseElevation(env.sun.elevation)); + const house = office?.houseLevel() ?? houseLevelFor(houseElevation(env.sun.elevation)); return withHouseLights(officeDaylight(state, site), house); } @@ -462,6 +561,9 @@ function updateSun() { if (state) office.setLighting(state); } + // The roster follows the same clock the sun does. Never over a live answer. + if (livePresence === null) applyPresence(); + if (!city || !atmosphere) return; const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather()); city.setLighting(atmosphere.apply(env)); @@ -470,12 +572,20 @@ function updateSun() { * The sky's own cover, which is a different question from what it does to the * light and is why the scene takes it separately. * - * `null` weather is "nobody was asked" — the state `currentWeather` is careful - * to preserve — and for cloud the honest reading of that is a clear sky rather - * than an invented overcast. The modelled marine layer already reaches the rig - * through `observe`; this is the *observed* cover when a station reported one. + * `atmosphere.cloudCover(env)` and **not** `currentWeather()?.cloudCover ?? 0`, + * and the difference is the whole point of the layer existing. `null` weather + * is "nobody was asked", which is not an edge case — it is the *default* + * deployment and the exact configuration this repo is held to: a stranger + * clones it, runs one command, and gets a city with no account and no key. + * Falling back to zero meant that stranger's sky was permanently, silently + * empty, and the cloud layer only ever appeared for somebody who had wired up + * NWS. + * + * `atmosphere` models a sky when nobody has observed one — it already does + * exactly that for the marine layer — and an observed cover still wins + * outright when there is one. See `cloudCover` in `atmosphere.ts`. */ - city.setCloudCover(currentWeather()?.cloudCover ?? 0); + city.setCloudCover(atmosphere.cloudCover(env)); city.setWind(currentWeather()?.windKph ?? null, currentWeather()?.windDirDeg ?? null); // The override itself, not `currentInstant()`. Handing over a resolved date // would peg the sky to whatever second this ran in, and this runs about once a @@ -611,7 +721,19 @@ async function mountCity(id: string) { markerPalette: palette, flights: dial.source, ...(catalogue ? { satellites: catalogue } : {}), - onMarkerPick: (m) => showDetail(m ? `${m.label}${m.blurb ? ` — ${m.blurb}` : ""}` : null), + /** + * A pin is a hover *and* a click, and an office pin is a door. + * + * `onMarkerPick` fires for both — `scenekit`'s picking calls it on hover + * with the marker and on leave with `null` — so this cannot simply open a + * building on every call or the office would fly open the moment the pointer + * crossed a tower. The hover shows the card; the click is a separate + * listener below, which reads whatever the hover last resolved. + */ + onMarkerPick: (m) => { + hoveredMarker = m; + showDetail(m ? `${m.label}${m.blurb ? ` — ${m.blurb}` : ""}` : null); + }, signal: mount.signal, // An abandoned build keeps its worker running for a tick or two after the // abort; its percentages must not land on the card the new city is using. @@ -695,7 +817,11 @@ async function mountCity(id: string) { // not a decoration. LA gets its own weather, not San Francisco's fog. marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null, }); - city.setMarkers(id === "sf" ? markers : []); + // The offices are in the Bay Area, so they ride along with that board's + // markers and are absent from the Southland's — the same rule the sample set + // already follows, for the same reason: a pin for a building six hundred + // kilometres off the board is a pin in the wrong place. + city.setMarkers(id === "sf" ? [...markers, ...OFFICE_MARKERS] : []); city.onChapterChange(() => renderLegend()); /** @@ -745,7 +871,7 @@ async function mountCity(id: string) { }, }); showPlan(); - minimap.setMarkers(id === "sf" ? markers : []); + minimap.setMarkers(id === "sf" ? [...markers, ...OFFICE_MARKERS] : []); // The instruments, for the one visitor in a deployment who has them. The pose // editor holds a `World`, a camera and a controls, so it belongs to the board @@ -1098,8 +1224,8 @@ function watchOccupancy() { const scene = office; if (!scene || scene.depth !== "full") return; presenceWatch = tera.watchPresence(officePack?.id ?? "lumbridge-hq", (body) => { - const people: Presence[] = body?.people ?? SAMPLE_PRESENCE; - presenceIsSample = body === null; + livePresence = body?.people ?? null; + presenceIsSample = livePresence === null; // The scene may have been torn down between a request going out and coming // back — a city switch disposes the office — and writing people into a // disposed layer is a use-after-free with a friendly name. The watch's own @@ -1107,8 +1233,7 @@ function watchOccupancy() { // because the office can also be *replaced* (signing in rebuilds it at full // depth) without the watch having been stopped in between. if (office !== scene) return; - scene.setPresence(people); - officePlan?.setPresence(people); + applyPresence(); renderOfficeBadge(); renderSource(); }); @@ -1117,6 +1242,29 @@ function watchOccupancy() { function stopWatchingOccupancy() { presenceWatch?.stop(); presenceWatch = null; + // Or the next building — or the next entry into this one — opens wearing the + // previous deployment's roster while its own request is still in the air. + livePresence = null; +} + +/** + * Put whoever is in the building on screen. + * + * The live roster if there is one, and otherwise the sample one **as it would be + * at the instant being rendered**. That second half is what stops the office + * showing a full complement of seated people at one in the morning, under house + * lights that came on because the sun is down — which was the least believable + * thing left in the room once the clock became real. + * + * A live answer always wins. An API that says the building is empty is telling + * the truth about the building, and dressing it with invented people would be + * the one lie this whole layer is arranged to avoid. + */ +function applyPresence() { + if (!office || office.depth !== "full") return; + const people = livePresence ?? samplePresenceAt(currentInstant()); + office.setPresence(people); + officePlan?.setPresence(people); } // ---- Chrome --------------------------------------------------------------- @@ -1572,6 +1720,29 @@ async function toggleOffice() { enterButton?.addEventListener("click", () => void toggleOffice()); +/** + * Clicking a building on the city walks into it. + * + * On the canvas rather than on anything the engine owns, because the engine + * reports picks by *hover* — `onMarkerPick` fires as the pointer crosses a pin + * and again with `null` as it leaves — so there is no click event to hang this + * on down there. `hoveredMarker` is whatever that hover last resolved, which is + * exactly what a click on the same pixel means. + * + * Guarded on not already being inside: the city's canvas is the office's canvas + * too, they share one renderer, and a stray click on the floor of a room should + * not re-enter the building you are standing in. + */ +canvas.addEventListener("click", () => { + if (inside) return; + const marker = hoveredMarker; + if (!marker) return; + const id = officeIdOf(marker); + if (id === null) return; + officeId = id; + void building(`Opening ${marker.label}…`, () => enterOffice()); +}); + // ---- Panels, plan and overlays ---------------------------------------------- /** @@ -1840,6 +2011,36 @@ async function mountGodmode() { * over the board that was current when the panel opened would go on driving * a disposed scene after the first city switch. */ + /** + * Read through the module-level `office`, never closed over — the panel is + * mounted once and every `switchOffice` replaces the scene underneath it. + */ + office: { + onHouseLights(mode) { + houseLights = mode; + // `officeLighting` is the only caller of `setSolarElevation` and + // `updateSun` is the only caller of that, so this is the whole apply. + updateSun(); + }, + onRobotsVisible(visible) { + office?.setRobotsVisible(visible); + }, + onCeilingsVisible(visible) { + office?.setCeilingsVisible(visible); + }, + read() { + // Only while a room is actually on the stage. Out in the city the office + // is paused and kept, and a dimmer aimed at a scene nobody is rendering + // is exactly the dead panel this section is arranged to avoid. + if (!inside || !office) return null; + return { + id: officeId, + depth: office.depth, + houseLevel: office.houseLevel(), + robots: office.robots().length, + }; + }, + }, sky: { onExtraTraffic(count) { trafficDial?.setExtra(count); @@ -2129,7 +2330,8 @@ async function boot() { try { const feed = await tera.markers(); markers = feed.value; - palette = feed.palette; + // The caller's palette, plus the door colour it cannot know about. + palette = { ...feed.palette, ...OFFICE_PALETTE }; liveData = feed.live; } catch { // A missing API is the self-host default, not an error. diff --git a/src/offices/frontier-valley.ts b/src/offices/frontier-valley.ts index 6c80587..aa403b4 100644 --- a/src/offices/frontier-valley.ts +++ b/src/offices/frontier-valley.ts @@ -55,6 +55,7 @@ import type { Yaw, Zone, } from "../interiors/types.ts"; +import { FRONTIER_VALLEY_SITE } from "./sites.ts"; // ---- The shed ------------------------------------------------------------- @@ -672,13 +673,7 @@ export const FRONTIER_VALLEY: Office = { * So the high glazing really does take north light and the lagoon really is * to the south. */ - site: { - lat: 37.7756, - lng: -122.3186, - elevation: 4, - heading: 0, - label: "Alameda Point", - }, + site: FRONTIER_VALLEY_SITE, meta: { description: "A startup in a hangar at Alameda Point: one room, fifty-four by thirty, nine metres to the trusses. The second pack, and the one that shows the format describes a shed as well as it describes a corridor.", diff --git a/src/offices/lumbridge-hq.ts b/src/offices/lumbridge-hq.ts index b3b3cb2..0210f19 100644 --- a/src/offices/lumbridge-hq.ts +++ b/src/offices/lumbridge-hq.ts @@ -86,6 +86,7 @@ import type { Yaw, Zone, } from "../interiors/types.ts"; +import { LUMBRIDGE_HQ_SITE } from "./sites.ts"; // ---- The floor plate ------------------------------------------------------ @@ -2172,13 +2173,7 @@ export const LUMBRIDGE_HQ: Office = { * number that decides whether the sun ever actually enters the building, and * pointing the glass at the afternoon is the whole reason the field exists. */ - site: { - lat: 37.7897, - lng: -122.3972, - elevation: 188, - heading: 205, - label: "Transbay, San Francisco", - }, + site: LUMBRIDGE_HQ_SITE, meta: { description: "The reference office: two levels around a double-height commons, twenty-six rooms, a hundred and twenty-six seats. Copy this file, change the numbers, keep the seat ids.", diff --git a/src/offices/sites.ts b/src/offices/sites.ts new file mode 100644 index 0000000..307cd20 --- /dev/null +++ b/src/offices/sites.ts @@ -0,0 +1,59 @@ +/** + * Where the shipped buildings stand, separately from the buildings themselves. + * + * An `Office` pack carries its own `site`, and that is still the authority — a + * pack handed over HTTP by a self-hoster brings its site with it and never + * touches this file. What this file exists for is the one question the *city* + * asks, which a pack cannot answer without being loaded: **where are the + * buildings I could walk into?** + * + * The office packs are deliberately lazy chunks. `lumbridge-hq` alone is 25 kB + * of furniture and floor plan, and the whole point of `loadOffice`'s dynamic + * import is that a visitor who only ever looks at the city never pays for it. + * But the city wants to draw a marker on the two buildings the moment the board + * appears, which is long before anybody has opened a door — so importing a pack + * to read four numbers off it would put the entire catalogue back in the entry + * chunk and undo the split. + * + * Hence a tiny eagerly-imported module holding just the coordinates, and each + * pack importing its own site **from here** rather than declaring it inline. One + * source of truth, and the direction of the dependency is the safe one: the + * small thing does not know about the large one. + */ + +import type { OfficeSite } from "../interiors/types.ts"; + +/** + * High in a Transbay tower. See `lumbridge-hq.ts` for what the four numbers mean + * and why `heading` is the one that decides whether the sun ever gets in. + */ +export const LUMBRIDGE_HQ_SITE: OfficeSite = { + lat: 37.7897, + lng: -122.3972, + elevation: 188, + heading: 205, + label: "Transbay, San Francisco", +}; + +/** A hangar on the old naval air station. See `frontier-valley.ts`. */ +export const FRONTIER_VALLEY_SITE: OfficeSite = { + lat: 37.7756, + lng: -122.3186, + elevation: 4, + heading: 0, + label: "Alameda Point", +}; + +/** + * Every building this build can walk into, for the city to point at. + * + * `id` matches the pack's own `Office.id` and the `OFFICES` table in `main.ts`, + * which is what lets a click on a marker resolve to a door. Keeping the three in + * step is not enforced by the type system; it is enforced by there being exactly + * two of them and by `office.test.ts` asserting that each pack's site is the one + * named here. + */ +export const OFFICE_SITES: { id: string; name: string; site: OfficeSite }[] = [ + { id: "lumbridge-hq", name: "Lumbridge HQ", site: LUMBRIDGE_HQ_SITE }, + { id: "frontier-valley", name: "Frontier Valley", site: FRONTIER_VALLEY_SITE }, +]; diff --git a/src/test/flights.test.ts b/src/test/flights.test.ts index 4a39b3d..dccc515 100644 --- a/src/test/flights.test.ts +++ b/src/test/flights.test.ts @@ -568,3 +568,121 @@ describe("more aircraft than the trail buffer was sized for", () => { ); }); }); + +// ---- Banking --------------------------------------------------------------- + +/** + * Roll is the one channel that feeds itself. + * + * Position, heading, pitch and altitude are all recomputed from the last two + * observations every time, so a bad value washes out on the next poll. The bank + * is a first-order lag on its own previous value — that is what makes it settle + * smoothly instead of stepping — and the price of that is that a `NaN`, or a + * sign error, or a failure to reset, persists for the life of the track rather + * than for one frame. These are the cases where that would bite. + * + * Read through `mesh.rotation.z`, which needs no GL context. + */ +describe("aircraft banking", () => { + /** Fly `headings` in order, one distinct observation per refresh. */ + function fly(f: Fixture, headings: number[]): THREE.Mesh { + let lng = -122.4; + let t = 0; + for (const heading of headings) { + at(t); + // A real step each time, or the repeat-skip correctly ignores the sample + // and the heading never lands. + lng += 0.02; + f.layer.update([{ ...jet("bank", 37.77, lng), heading }]); + t += REFRESH; + } + at(t); + f.layer.tick(); + const mesh = f.meshes()[0]; + assert.ok(mesh, "no aircraft"); + return mesh; + } + + it("stays dead level on a straight leg", () => { + const f = fixture(); + const mesh = fly(f, [90, 90, 90, 90, 90]); + assert.equal(mesh.rotation.z, 0, "a straight leg should have no bank at all"); + }); + + it("banks into a sustained turn, and not past the limiter", () => { + const f = fixture(); + const mesh = fly(f, [90, 105, 120, 135, 150, 165]); + assert.ok(mesh.rotation.z !== 0, "a turning aircraft should be banked"); + // 30° is the stated ceiling; anything past it is a knife-edge airliner. + assert.ok( + Math.abs(mesh.rotation.z) <= (30 * Math.PI) / 180 + 1e-9, + `banked ${((mesh.rotation.z * 180) / Math.PI).toFixed(1)}°, past the limiter`, + ); + }); + + /** + * The sign, which is the half nobody can check by reading. + * + * A left turn and a right turn of the same size must produce equal and + * opposite rolls. That does not prove the absolute direction is right — the + * geometry argument in `flights.ts` does that — but it does catch the whole + * class of errors where the roll is derived from something that is not the + * signed turn, which would break the symmetry. + */ + it("rolls opposite ways for opposite turns", () => { + const right = fly(fixture(), [90, 105, 120, 135]).rotation.z; + const left = fly(fixture(), [90, 75, 60, 45]).rotation.z; + assert.ok(Math.abs(right) > 1e-3, "the right turn produced no bank"); + assert.ok(Math.abs(right + left) < 1e-6, `${right} and ${left} are not mirrored`); + }); + + /** + * The 0/360 wrap, which is where a naive `to - from` produces a 350° turn out + * of a 10° one and rolls the aircraft onto its back. + */ + it("does not flick as a track crosses north", () => { + const f = fixture(); + const mesh = fly(f, [340, 350, 0, 10, 20]); + const degrees = (mesh.rotation.z * 180) / Math.PI; + assert.ok(Number.isFinite(degrees), "the bank went non-finite across the wrap"); + // A steady 10°-per-refresh right turn. If the wrap were mishandled this + // would be pinned at the limiter with the opposite sign. + assert.ok(degrees > 0, `crossing north banked ${degrees.toFixed(1)}°, the wrong way`); + assert.ok(degrees <= 30 + 1e-9, `crossing north banked ${degrees.toFixed(1)}°`); + }); + + /** + * A looping simulator route teleports, and the teleport branch clears the + * samples. It must clear the roll too — a track that starts its next leg still + * banked has no observation pair to wash it out, so it would simply stay that + * way. + */ + it("comes level again when a route wraps", () => { + const f = fixture(); + fly(f, [90, 105, 120, 135]); + // Half a degree of longitude in one refresh: hundreds of units, well past + // the teleport ceiling. + at(REFRESH * 5); + f.layer.update([{ ...jet("bank", 37.77, -121.9), heading: 135 }]); + at(REFRESH * 5); + f.layer.tick(); + const mesh = f.meshes()[0]; + assert.ok(mesh); + assert.equal(mesh.rotation.z, 0, "a wrapped route kept its bank"); + }); + + it("stays finite when a feed reports a nonsense heading", () => { + const f = fixture(); + fly(f, [90, 105, 120]); + at(REFRESH * 4); + f.layer.update([{ ...jet("bank", 37.77, -122.3), heading: Number.NaN }]); + at(REFRESH * 4); + f.layer.tick(); + const mesh = f.meshes()[0]; + assert.ok(mesh); + assert.ok( + Number.isFinite(mesh.rotation.z), + "one bad heading poisoned the roll for the life of the track", + ); + }); +}); diff --git a/src/test/office.test.ts b/src/test/office.test.ts index 8773a23..f5bbc7b 100644 --- a/src/test/office.test.ts +++ b/src/test/office.test.ts @@ -27,6 +27,7 @@ import { describe, it } from "node:test"; import { Plan } from "../interiors/plan.ts"; import LUMBRIDGE_HQ from "../offices/lumbridge-hq.ts"; import FRONTIER_VALLEY from "../offices/frontier-valley.ts"; +import { OFFICE_SITES } from "../offices/sites.ts"; const plan = new Plan(LUMBRIDGE_HQ, { warn: false }); @@ -415,3 +416,31 @@ describe("the sites", () => { } }); }); + +/** + * `offices/sites.ts` and the packs must not drift. + * + * The city draws its doors from `OFFICE_SITES` because a pack is a lazy chunk + * and the board wants the pins before anybody opens one. That means two places + * name the same building, and nothing in the type system ties them together — + * a coordinate edited in the pack and not in the table would put the marker on + * one building and the sun on another, and both would look entirely plausible. + */ +describe("the office site table", () => { + it("lists exactly the packs this build ships", () => { + assert.deepEqual( + OFFICE_SITES.map((e) => e.id).sort(), + [FRONTIER_VALLEY.id, LUMBRIDGE_HQ.id].sort(), + ); + }); + + it("hands each pack the very same site object it publishes", () => { + for (const pack of [LUMBRIDGE_HQ, FRONTIER_VALLEY]) { + const entry = OFFICE_SITES.find((e) => e.id === pack.id); + assert.ok(entry, `${pack.id} is missing from OFFICE_SITES`); + // Identity, not equality: the packs import from the table, so anything + // less than the same reference means somebody has restated a coordinate. + assert.equal(entry.site, pack.site, `${pack.id} has a site of its own`); + } + }); +}); diff --git a/src/tools/godmode.ts b/src/tools/godmode.ts index 602773f..59663e4 100644 --- a/src/tools/godmode.ts +++ b/src/tools/godmode.ts @@ -33,6 +33,13 @@ * camera — a paste-ready `Chapter.focus`, both bodies' az/el, the marine * strength, and the fog the atmosphere actually installed. * + * Two more sections belong to whatever board is up rather than to the panel, and + * are absent when there is nothing to point them at: **sky** (`GodmodeSky`) and + * **office** (`GodmodeOffice`). Neither is a fifth instrument in the sense above + * — they are handles onto one scene's dials, they arrive with it and they mean + * nothing without it, which is why both are optional interfaces rather than + * fields on `GodmodeOptions`. + * * ## It is not shipped to anyone else * * `access.ts` is explicit that `debug` is a *drawing* decision and not a @@ -156,6 +163,103 @@ export interface GodmodeSky { }; } +/** + * Where the office's house lights take their level from. + * + * `"sun"` is the building's own behaviour and the position everything boots in; + * the other two are a hand on the switch, and they stay where they were put + * until somebody moves them — across a walk out to the city and back, and across + * the rebuild a city switch performs. + */ +export type GodmodeHouseLights = "sun" | "on" | "off"; + +/** + * The office, when the stage is showing one. + * + * The same shape and the same argument as `GodmodeSky` — optional as a unit, + * methods that reach the room through the caller's live handle rather than + * through one captured at construction — with the one difference that the sky + * does not have: a city is simply there, and an office is a room you walk into + * and out of. So `read()` may answer `null` at any moment and the section takes + * itself off screen when it does. A ceiling switch pointed at a building nobody + * is looking at is worse than no switch, and greying it out would only put the + * dead panel back in a paler colour. + * + * The panel owns the three switch positions and re-asserts all three the moment + * `read().id` changes. It has to: in this app an office handle does not survive + * a city switch — it is disposed and the next one is built at its own defaults, + * lids off and robots drawn — so a panel whose chips still claimed "ceilings" + * over a freshly built building would be lying about the only thing it does. + * Re-asserting is three idempotent calls on an event that happens when somebody + * changes metro, which is not a budget anybody has to think about. + */ +export interface GodmodeOffice { + /** + * Where the house lights take their level from. + * + * This is the control the section is worth building for. The fittings ramp on + * against the sun's height — fully on at or below 0°, fully off at or above + * +6°, as `LIGHTS_ON_BELOW_DEG` and `LIGHTS_OFF_ABOVE_DEG` in + * `interiors/luminaires.ts` have it — so until now the only way to see the lit + * building was to scrub the clock into the evening, which also moves the sun, + * the sky behind the glazing, the fog and every shadow. You end up comparing + * two pictures that differ in five ways and learning nothing about any of + * them. Forcing the level moves the fittings and the interior term and + * nothing else: `withHouseLights` in `interiors/daylight.ts` lifts the ambient + * and hemisphere terms and returns the rest of the rig untouched, so the sun + * outside is still the clock's and the two pictures differ in the one thing + * that was asked about. + */ + onHouseLights(mode: GodmodeHouseLights): void; + /** + * Draw the robots, or do not — they keep walking either way, and that is the + * useful half rather than a caveat. + * + * A fitting responds to where a robot *is* and not to whether it is drawn: + * the luminaires hold the live array the robot layer mutates in place, and + * hiding a mesh does not move a position. So this is how you watch the ceiling + * follow somebody across an empty floor with nothing else in the frame, which + * is the one view that shows the occupancy response on its own. + */ + onRobotsVisible(visible: boolean): void; + /** + * Put the lids back on. `OfficeScene.setCeilingsVisible`, which has existed + * since the shell did and which nothing in the app has ever called — the + * ceilings are off by default because looking down into the floor plate is the + * entire view, and that is exactly why being able to close it is worth a chip: + * a fitting you cannot see the underside of is a fitting you cannot check. + */ + onCeilingsVisible(visible: boolean): void; + /** + * The office on the stage, or `null` when the stage is showing something else. + * + * Polled on the panel's own refresh rather than pushed, like + * `GodmodeSky.read`, and here for a stronger reason than either of the sky's: + * `houseLevel` is a ramp against the sun and climbs on its own all through + * dusk while nobody touches a control, which is precisely the moment somebody + * has this section open. + */ + read(): { + /** + * Which building. Printed, and watched for the rebuild described on this + * interface — so it must name the *building*, and must change when the room + * on the stage is a different one. + */ + id: string; + /** + * `"public"` is the stranger's building: same shell, same plan, same + * furniture, and no presence layer at all. Worth a word on screen, because + * an empty office is otherwise indistinguishable from an occupancy feed that + * never landed. + */ + depth: "full" | "public"; + /** `OfficeScene.houseLevel()`, 0..1, after the last solar elevation it was given. */ + houseLevel: number; + /** How many robots this pack asked for. Zero is a normal answer, not a fault. */ + robots: number; + } | null; +} + export interface GodmodeOptions { /** * Where to mount. The root positions *itself* — bottom centre, over the map, @@ -172,6 +276,12 @@ export interface GodmodeOptions { onWeatherOverride(w: WeatherObservation | null): void; /** Traffic and satellites, when there is a board to point them at. */ sky?: GodmodeSky; + /** + * House lights, robots and ceilings, when this deployment has an office at + * all. Absent leaves the section unbuilt; present but `read()`ing `null` + * leaves it off screen until somebody walks into the building. + */ + office?: GodmodeOffice; /** Start with the drawer open. Default `false`: the tab, and nothing else. */ open?: boolean; /** @@ -686,6 +796,69 @@ export function createGodmode(options: GodmodeOptions): Godmode { satelliteNote, ); + // ---- Office --------------------------------------------------------------- + + /** + * Built unconditionally and appended only with `options.office`, exactly as + * the sky block above is, and then hidden whenever `read()` says the stage is + * not showing a room. The `hidden` attribute rather than a detach: the section + * is a grid item, `[hidden]` is `display: none` here in a rule that outranks + * the grid, and taking a node out and putting it back would also have to + * remember where in the column order it belonged. + */ + const officeSection = section("office"); + const officeLine = el("div", "gm-stamp"); + const houseLine = el("div", "gm-line"); + const houseChips = el("div", "gm-chips"); + let houseLights: GodmodeHouseLights = "sun"; + const houseButtons: { mode: GodmodeHouseLights; el: HTMLButtonElement }[] = []; + for (const spec of [ + { mode: "sun", label: "sun" }, + { mode: "on", label: "force on" }, + { mode: "off", label: "force off" }, + ] as const) { + const b = button("gm-chip", spec.label, () => { + houseLights = spec.mode; + options.office?.onHouseLights(spec.mode); + refresh(); + }); + houseButtons.push({ mode: spec.mode, el: b }); + houseChips.append(b); + } + const houseNote = el("div", "gm-hint"); + houseNote.textContent = + "the fittings and the interior term only — the sun outside the glazing is still the clock's"; + + const robotLine = el("div", "gm-line"); + const showChips = el("div", "gm-chips"); + let robotsOn = true; + const robotChip = button("gm-chip", "robots", () => { + robotsOn = !robotsOn; + options.office?.onRobotsVisible(robotsOn); + refresh(); + }); + let ceilingsOn = false; + const ceilingChip = button("gm-chip", "ceilings", () => { + ceilingsOn = !ceilingsOn; + options.office?.onCeilingsVisible(ceilingsOn); + refresh(); + }); + showChips.append(robotChip, ceilingChip); + const officeNote = el("div", "gm-hint"); + officeNote.textContent = + "hidden robots keep walking and the fittings above them still come up; " + + "the lids are off by default, which is why putting them back is a control"; + + officeSection.body.append( + officeLine, + houseLine, + labelled("lights", houseChips), + houseNote, + robotLine, + labelled("show", showChips), + officeNote, + ); + // ---- Performance ---------------------------------------------------------- const perfSection = section("performance"); @@ -732,6 +905,7 @@ export function createGodmode(options: GodmodeOptions): Godmode { timeSection.el, weatherSection.el, ...(options.sky ? [skySection.el] : []), + ...(options.office ? [officeSection.el] : []), perfSection.el, overlaySection.el, ); @@ -824,7 +998,16 @@ export function createGodmode(options: GodmodeOptions): Godmode { if (now - lastRefresh < REFRESH_MS) return; lastRefresh = now; - if (open) refreshLive(); + // The office rides the fast path with the counters rather than waiting for + // the next `refresh()`. Everything else on this panel changes because + // somebody moved something or because `main.ts` announced a new instant — + // and `main.ts` announces one once a minute. The house level is neither: it + // ramps against the sun's height, so at dusk it is a number that visibly + // moves, and it is the number this whole section exists to explain. + if (open) { + refreshLive(); + refreshOffice(); + } if (!hud.hidden) refreshHud(); } raf = requestAnimationFrame(frame); @@ -880,6 +1063,7 @@ export function createGodmode(options: GodmodeOptions): Godmode { refreshTime(); refreshWeather(); refreshSky(); + refreshOffice(); refreshLive(); } refreshHud(); @@ -919,6 +1103,67 @@ export function createGodmode(options: GodmodeOptions): Godmode { satelliteChip.setAttribute("aria-pressed", String(satellitesOn && state.satellites !== null)); } + /** + * The building this office handle last described, or `null` for "no room on + * the stage". See `GodmodeOffice` for what a change in it means and why the + * switches are pushed back down when it happens. + */ + let knownOffice: string | null = null; + + function refreshOffice() { + const office = options.office; + if (!office) return; + const state = office.read(); + officeSection.el.hidden = state === null; + if (!state) { + // Walking out to the city forgets the building rather than the switches, + // so walking back in re-asserts them onto whatever handle is there now — + // which may not be the one that was there when they were set. + knownOffice = null; + return; + } + + if (state.id !== knownOffice) { + // Written before the calls, not after. None of the three re-enters this + // panel today, and a debug control that could loop the render loop by + // being wired slightly differently tomorrow is not worth the two lines it + // saves. + knownOffice = state.id; + office.onHouseLights(houseLights); + office.onRobotsVisible(robotsOn); + office.onCeilingsVisible(ceilingsOn); + } + + setText( + officeLine, + `${state.id} · ${state.depth === "full" ? "full" : "public — no presence layer"}`, + ); + setText( + houseLine, + `house ${Math.round(state.houseLevel * 100)}% · ` + + (houseLights === "sun" ? "following the sun" : `forced ${houseLights}`), + ); + for (const entry of houseButtons) { + entry.el.setAttribute("aria-pressed", String(entry.mode === houseLights)); + } + + // Three states again, and the middle one is again the one worth saying out + // loud: a pack that asked for no robots is not a pack whose robots are + // hidden, and the chip that would toggle nothing says so by going dead + // rather than by appearing to work. + if (state.robots === 0) { + setText(robotLine, "robots none — this pack asked for none"); + } else { + setText( + robotLine, + `robots ${state.robots} walking${robotsOn ? "" : ", hidden"}`, + ); + } + robotChip.disabled = state.robots === 0; + robotChip.setAttribute("aria-pressed", String(robotsOn && state.robots > 0)); + ceilingChip.setAttribute("aria-pressed", String(ceilingsOn)); + } + function refreshBanner() { const parts: string[] = []; if (override) parts.push(`time ${fmtStamp(override)}`); diff --git a/src/tools/index.ts b/src/tools/index.ts index 1a477d2..c0e6264 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -32,4 +32,10 @@ */ export { createGodmode } from "./godmode.ts"; -export type { Godmode, GodmodeOptions, GodmodePlace } from "./godmode.ts"; +export type { + Godmode, + GodmodeHouseLights, + GodmodeOffice, + GodmodeOptions, + GodmodePlace, +} from "./godmode.ts"; diff --git a/vite.config.ts b/vite.config.ts index 34c6097..96972e6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -167,6 +167,34 @@ export default defineConfig({ // `office.html` is deliberately not a third entry: it is the same document // as `index.html` with a different head, so making it one would give it its // own copy of the bundle graph. It is emitted after the build instead. - rollupOptions: { input: { index: "index.html", login: "login.html" } }, + rollupOptions: { + input: { index: "index.html", login: "login.html" }, + output: { + /** + * three.js and the SGP4 propagator get a chunk of their own. + * + * Not to make the download smaller — it is the same bytes either way — + * but to stop them being *re-downloaded*. They were inside the entry + * chunk, so every deploy that changed a line of app code invalidated + * three quarters of a megabyte of dependency that had not changed since + * the last release. Split out, a returning visitor pays for the app and + * keeps the vendor chunk it already has. + * + * They are one chunk rather than two because they are always wanted + * together: `engine/satellites.ts` imports both, and it is reached from + * the entry on every board. + * + * Measured: the entry chunk goes from 758 kB to 208 kB and the vendor + * chunk is 550 kB. Rollup's 500 kB warning therefore still fires — and + * it should. It now points at three.js, where it is a true statement + * about a dependency nobody here can shrink, instead of at our own code, + * where it was pointing at three.js all along and reading as if it were + * about us. Raising `chunkSizeWarningLimit` would have hidden both. + */ + manualChunks: { + vendor: ["three", "satellite.js"], + }, + }, + }, }, });