The city points at its own buildings, and the sky stops depending on an API
**Clouds were invisible to everyone who had not wired up NWS.** The layer
took `currentWeather()?.cloudCover ?? 0`, and `currentWeather()` is null on
any deployment without a weather source — which is the default, 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. Their sky was
permanently, silently empty. `atmosphere.ts` already models a sky when
nobody has observed one; it now models cover too, an observed reading
still wins outright, and the clouds are there on a bare clone.
**Both offices are pins on the city, and clicking one walks you in.** Each
pack has carried a real `site` since the sun needed one, and that
coordinate was known to the lighting and to nothing else — a visitor
looking at the board had no way to tell that two of those buildings are
ones they can go inside. The coordinates move to a tiny eagerly-imported
`offices/sites.ts` that the packs import *from*, because a pack is a 25 kB
lazy chunk and the board wants its pins long before anybody opens a door.
A test asserts the pack and the table hold the **same object**, not merely
equal values: a drifted coordinate would put the marker on one building
and the sun on another and both would look entirely plausible.
**Aircraft bank into their turns.** The roll channel existed and was never
written, so every turn was flat. Bank comes from the coordinated-turn
relation against the measured turn rate, damped by a first-order lag so it
settles rather than oscillates, and clamped at 30° like a real limiter.
Six regression tests, because roll is the one channel that feeds itself —
position and heading are recomputed from the last two observations and
wash out a bad value, while a NaN in the roll would persist for the life
of the track.
That fed straight into a real defect: `AdsbFlights` substituted
`heading: 0` for records with no `track` field, which is harmless for a
symmetrical dart and is a **sustained full-scale artefact** once aircraft
bank — a target whose real heading is 200° reported as 0° reads as a 160°
turn and pins the roll at its limiter for as long as it is in the feed.
Those records are dropped now. An aeroplane the feed will not give a
heading for is one this layer cannot draw honestly.
**The office empties out overnight.** A full complement of seated people
at one in the morning, under house lights that came on because the sun is
down, was the least believable thing left in the room once the clock
became real. A live roster always wins — an API that says the building is
empty is telling the truth about the building.
**Robots go somewhere.** They pick real addresses — a seat, a room — and
turn to face the seat when they arrive, rather than stopping at a random
angle. Godmode gets an office section: house lights forced on or off or
following the sun, robots and ceilings toggled, with a readout.
**The bundle is split.** Entry chunk 758 kB to 208 kB, with three.js and
satellite.js in a vendor chunk that survives an app deploy instead of
being re-downloaded on every one. Rollup's 500 kB warning still fires and
should — it now points at three.js, where it is true, instead of at our
code, where it was pointing at three.js all along.
Reviewers caught two false geography claims in the new prose ("both
shipped buildings stand in San Francisco" — one is across the estuary at
Alameda Point) and several miscounted figures. Fixed. In a codebase where
the comments are the design record, those are defects.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -442,3 +442,330 @@ export const SAMPLE_PRESENCE_PALETTE: Record<string, number> = {
|
||||
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<string, { from: number; to: number }> = {
|
||||
"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<string, DeskDay>();
|
||||
|
||||
/**
|
||||
* 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+263
-1
@@ -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.
|
||||
*/
|
||||
|
||||
+213
-3
@@ -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. */
|
||||
|
||||
@@ -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<string, THREE.Vector3>;
|
||||
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);
|
||||
|
||||
+776
-33
@@ -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<ErrandKind, readonly [number, number]> = {
|
||||
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<string, AddressBook>();
|
||||
|
||||
/** 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() {
|
||||
|
||||
+220
-18
@@ -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<Office["site"]>) {
|
||||
* 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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+246
-1
@@ -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)}`);
|
||||
|
||||
+7
-1
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user