Shadows land on the building, and the sky layers stop repeating themselves
**Shadows were the right size and pointed at nothing.** Last round fixed the missing `updateProjectionMatrix()`, so the frustum finally became the size every caller asks for — but nothing aimed it, and `sun.target` sits at the world origin. A pack's origin is the **north-west corner of its slab**, so for lumbridge-hq the box was off-centre by half the building: 14.4 m of a 48 m plate, about a third of the floor, fell outside the frustum and neither cast nor received. Invisible while three's broken ±5 default made shadows useless everywhere; obvious the moment they started working. `SceneKitOptions` takes a `shadowTarget` now, both callers pass one, and the light's target is added to the scene — which is the part that actually matters, because `LightShadow.updateMatrices` reads `target.matrixWorld` and an unparented `Object3D` is never reached by the traversal that updates it. The sun is also placed relative to the target rather than the origin, so light-to-target is exactly `sunDistance` for every direction, which is the invariant each caller's `shadowNear`/`shadowFar` were chosen against. **`flights.ts` could not be tested, and that is why it was untested.** It used a TypeScript parameter property — the one piece of TS syntax that *emits code* rather than annotating a type — so Node's type stripping refused the whole module. The bundler never cared, so nobody found out until the first `node --test` file tried to import it. The module carrying the worst bug this project has shipped was, by construction, the one module that could not have a test. It has eleven now, including one that fails if the live-aircraft repeat-skip is removed. **Robots are on the plan panel** — a turned marker with a bow for heading, in the one hue left that is neither the people-blue nor the camera-amber. Review findings cleared across the four new sky/robot modules: a real 24 mm void at the ankle and an 8 mm hole through each forearm, a per-frame allocation in the robot heading picker, a per-frame sort in the starlink ranking, `uTime` growing unbounded until the cloud breath quantises, and `DAY_REFERENCE`'s derivation which did not reproduce. `createStarlinkMeshLayer` now takes a **board** radius — the same unit its sibling takes — instead of a dome radius with nothing in the types to tell them apart. That is the exact confusion that has already caused one real bug here. `DOME_RADIUS_FACTOR` has one owner and is imported, not copied: the points and the meshes must be on the same dome or a satellite that grows geometry also jumps. Several comments were wrong rather than merely stale — a fabricated claim about `Object3D.clone`, a fabricated attribution to `Plan`, an inverted `DoubleSide` argument, a triangle ledger citing a function that no longer exists, and a defensive-call rationale that contradicted the paragraph above it. 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:
@@ -1,13 +1,20 @@
|
||||
/**
|
||||
* The aircraft over the city, as an aircraft.
|
||||
*
|
||||
* `flights.ts` has drawn traffic as a dart since the layer existed — a five-sided
|
||||
* cone with a crossbar for a wing — and the dart was the right first answer,
|
||||
* because the only thing a speck over a city has to communicate is *which way it
|
||||
* is going*. It is the wrong last answer for one reason: the sky is the part of
|
||||
* this scene a person looks at on purpose. Buildings do not move. A dozen darts
|
||||
* crossing a coastline at three altitudes are the only thing on the board with
|
||||
* anything happening to it, and they are worth more than eleven triangles.
|
||||
* `flights.ts` drew traffic as a dart from the day the layer existed until this
|
||||
* file replaced it — a five-sided capped cone with a box for a wing and a smaller
|
||||
* one for a tailplane, 34 triangles the shape of an arrowhead — and the dart was
|
||||
* the right first answer, because the only thing a speck over a city has to
|
||||
* communicate is *which way it is going*. It is the wrong last answer for one
|
||||
* reason: the sky is the part of this scene a person looks at on purpose.
|
||||
* Buildings do not move. A dozen darts crossing a coastline at three altitudes
|
||||
* are the only thing on the board with anything happening to it, and they are
|
||||
* worth more than an arrowhead.
|
||||
*
|
||||
* (`dartGeometry` is gone from `flights.ts` rather than kept as an option — this
|
||||
* file is the only aircraft on the board now. Its numbers are quoted below from
|
||||
* the commit that removed it, because a ledger measured against nothing is not a
|
||||
* ledger.)
|
||||
*
|
||||
* So: a swept-wing airliner, seen from where it is actually seen from.
|
||||
*
|
||||
@@ -86,9 +93,18 @@
|
||||
* engine pylons 2 × 4
|
||||
* nacelles 2 × 24 6 sides, both ends capped
|
||||
*
|
||||
* Nine times the dart's eleven, at 400 aircraft is 40k triangles — under one
|
||||
* frame's worth of the terrain mesh, and the vertex work is not what is
|
||||
* expensive about 400 objects anyway. The parts that were considered and cut for
|
||||
* That is **2.9 times the dart**, not the order of magnitude an eyeballed
|
||||
* comparison of the two source files suggests. The dart's 34 are easy to
|
||||
* undercount because two thirds of them are in parts nobody thinks of as
|
||||
* geometry: `ConeGeometry(0.09, 0.42, 5)` is 5 side triangles *and* a 5-triangle
|
||||
* cap, and each of its two crossbars is a `BoxGeometry`, which is 12 triangles
|
||||
* whatever size it is drawn at — a 0.44 × 0.016 crossbar spends eight of its
|
||||
* twelve on four edge-on slivers nobody would think to count. 10 + 12 + 12 is
|
||||
* how a "five-sided cone with a crossbar" comes to 34.
|
||||
*
|
||||
* At 400 aircraft the 100 comes to 40k triangles — under one frame's worth of the
|
||||
* terrain mesh, and the vertex work is not what is expensive about 400 objects
|
||||
* anyway. The parts that were considered and cut for
|
||||
* costing more than they show: winglets (edge-on from the only angle that
|
||||
* matters), an engine fan face (a 0.03-unit disc), windows and a livery stripe
|
||||
* (they need vertex colours or a texture, and the material here is a shared
|
||||
@@ -177,11 +193,24 @@ const TAIL_UPSWEEP = 0.09;
|
||||
* picks exactly one of them for any viewpoint: from above the top face is
|
||||
* front-facing and the bottom is culled, from below the reverse. They are
|
||||
* never both rasterised, so there is nothing to fight.
|
||||
* - A `DoubleSide` material would light the underside with the *upper* normal
|
||||
* (three.js flips it for backfaces, but only in the shader, and only for the
|
||||
* lighting term — which then makes the belly of the wing exactly as bright as
|
||||
* the sunlit top). Two real faces with two real normals give a dark
|
||||
* underside, which is what an aeroplane looks like.
|
||||
* - It is not that `DoubleSide` would shade the belly wrongly. It would not:
|
||||
* `normal_fragment_begin` multiplies the interpolated normal by
|
||||
* `gl_FrontFacing ? 1.0 : -1.0` before the lighting runs, so a single sheet
|
||||
* under a `DoubleSide` material gets a genuinely downward normal on its
|
||||
* underside and comes out dark, the same as the second face here does. That
|
||||
* argument used to be written the other way round in this comment and it was
|
||||
* simply false; anyone testing it would have found the flag works and
|
||||
* deleted 14 triangles for the wrong reason.
|
||||
* - The real objection is *whose flag it is*. `side` lives on the material, the
|
||||
* material belongs to `flights.ts`, and there is one of them per altitude
|
||||
* band shared across the wings, the fuselage and the nacelles. Turning
|
||||
* culling off to save seven sheets their second face also turns it off for
|
||||
* three closed bodies of revolution, whose interiors are then rasterised on
|
||||
* every aircraft on the board for nothing — and it makes a geometry that is
|
||||
* only correct under one particular material, which is the sort of coupling
|
||||
* that survives right up until somebody reuses this shape somewhere else.
|
||||
* Seven extra pairs of triangles buy a mesh that is right under any material
|
||||
* anybody points at it.
|
||||
*
|
||||
* The corners are given in order round the polygon and must be **planar** —
|
||||
* every quad in this file is, because each one's y varies linearly with x
|
||||
@@ -193,10 +222,10 @@ const TAIL_UPSWEEP = 0.09;
|
||||
* which is what makes mirroring a wing safe.
|
||||
*
|
||||
* UVs are emitted, and nothing samples them. They are here because
|
||||
* `mergeGeometries` refuses — returns `null`, silently, for the whole aircraft —
|
||||
* if the geometries handed to it do not all carry the *same set* of attributes.
|
||||
* `ConeGeometry` and `CylinderGeometry` bring position, normal and uv, so these
|
||||
* must too.
|
||||
* `mergeGeometries` refuses — `console.error`s the offending index and returns
|
||||
* `null` for the whole aircraft — if the geometries handed to it do not all
|
||||
* carry the *same set* of attributes. `ConeGeometry` and `CylinderGeometry`
|
||||
* bring position, normal and uv, so these must too.
|
||||
*/
|
||||
type Point = readonly [number, number, number];
|
||||
type Quad = readonly [Point, Point, Point, Point];
|
||||
@@ -460,12 +489,29 @@ export function airlinerGeometry(): THREE.BufferGeometry {
|
||||
* `mergeGeometries` returns null when its inputs disagree — a different set of
|
||||
* attributes, or some indexed and some not. Everything here is built to agree
|
||||
* (see `aerofoil`), so this is unreachable until somebody adds a part and
|
||||
* forgets a uv, at which point they get an aeroplane-shaped nothing on every
|
||||
* board and no error anywhere. A plain cone is a bad aeroplane and a much
|
||||
* better failure: it still points where the aircraft is going, which is the
|
||||
* one thing this layer exists to say.
|
||||
* forgets a uv — and they will not be left guessing when they do. Three's
|
||||
* merge `console.error`s the index of the geometry it choked on and names the
|
||||
* attribute that is missing, which is most of a fix; what it does not do is
|
||||
* throw. The failure arrives as a `null` this function's own signature does not
|
||||
* allow it to pass on, so this branch is what makes that signature true, and
|
||||
* without it the alternative is not an exception but an empty sky over a
|
||||
* console nobody has open.
|
||||
*
|
||||
* So: a plain cone, which is a bad aeroplane and a much better failure. It
|
||||
* still points where the aircraft is going, which is the one thing this layer
|
||||
* exists to say.
|
||||
*
|
||||
* **0.09 rather than `RADIUS`.** The cone stands in for the whole aircraft and
|
||||
* not for its fuselage, so the fuselage radius is exactly the wrong number to
|
||||
* reach for: at `RADIUS` this is a 0.42-long, 0.06-wide needle where the shape
|
||||
* it replaces was 0.44 across the wings, and at a board span out a needle is
|
||||
* sub-pixel in every direction but one, i.e. gone. 0.09 is what the dart's own
|
||||
* fallback used and it is the width the *silhouette* needs — 0.18 across
|
||||
* against 0.42 long, the same arrowhead proportion the dart had. A degraded
|
||||
* aeroplane has to still be findable, or the degradation is indistinguishable
|
||||
* from the failure it is covering for.
|
||||
*/
|
||||
const fallback = new THREE.ConeGeometry(RADIUS, NOSE_TIP_Z - TAIL_TIP_Z, SIDES);
|
||||
const fallback = new THREE.ConeGeometry(0.09, NOSE_TIP_Z - TAIL_TIP_Z, SIDES);
|
||||
fallback.rotateX(Math.PI / 2);
|
||||
fallback.name = "airliner:fallback";
|
||||
return fallback;
|
||||
|
||||
+68
-10
@@ -299,15 +299,65 @@ const PUFF_GROWTH_CLEAR = 0.7;
|
||||
const PUFF_GROWTH_OVERCAST = 1.75;
|
||||
|
||||
/**
|
||||
* Hemisphere luminance taken as "full daylight" when normalising the deck's own
|
||||
* brightness.
|
||||
* Hemisphere luminance times hemisphere intensity, taken as "full daylight" when
|
||||
* normalising the deck's own brightness.
|
||||
*
|
||||
* `atmosphere.ts`'s noon rig is `hemiSky 0xdcecf7` at intensity 1.05, whose
|
||||
* Rec.709 luminance in the linear working space is 0.88. Overcast noon lands
|
||||
* fractionally above it and gets clamped, which is the intended behaviour: the
|
||||
* top of an overcast deck at midday is as bright as anything ever gets.
|
||||
* The number is the product `setLighting` actually divides by, evaluated on the
|
||||
* brightest rig `atmosphere.ts` can hand over: its last keyframe, `elevation:
|
||||
* 65`, which is `hemiSky 0xe6f2fb` at `hemiIntensity 1.10`. `setHex` reads that
|
||||
* literal as sRGB and converts it into the linear working space — three's
|
||||
* `ColorManagement` is on and nothing in this repo turns it off — giving Rec.709
|
||||
* luminance 0.873, and 0.873 × 1.10 = 0.960.
|
||||
*
|
||||
* It has to be the *top* of the keyframe run and not a stop partway up it. This
|
||||
* read 0.88, which is near the `elevation: 25` stop (0xdcecf7 at 1.05, product
|
||||
* 0.860) and below every rig above about 30° of sun — so the entire middle of
|
||||
* every day divided out to `day = 1` and rendered its cloud tops at one
|
||||
* brightness, with the sun's own climb surviving only in `uKey`'s modelling
|
||||
* term. Taking the reference off the highest stop puts the clamp where the
|
||||
* clamp belongs: at the brightest light the sky ever has.
|
||||
*
|
||||
* Overcast noon still lands above it and still gets clamped, which is the
|
||||
* intended behaviour rather than a rounding accident — `applyCloud` lifts
|
||||
* `hemiIntensity` by 18% at full cover, so the same rig arrives at 1.13 of this
|
||||
* — and the top of an overcast deck at midday is as bright as anything ever
|
||||
* gets. Note that the lift is the only way over the line now: a clear high sun
|
||||
* lands exactly on it, which is what "full daylight" was always supposed to mean.
|
||||
*/
|
||||
const DAY_REFERENCE = 0.88;
|
||||
const DAY_REFERENCE = 0.96;
|
||||
|
||||
/**
|
||||
* The per-puff breath: how fast it runs, in radians a second, and the period
|
||||
* `uTime` is wrapped to so that it can keep running.
|
||||
*
|
||||
* `uTime` was the raw elapsed-seconds accumulator, which is correct for an
|
||||
* afternoon and wrong for a deployment. It is uploaded into a `float` uniform,
|
||||
* and float32 near 1e6 — eleven and a half days of uptime, which a page left
|
||||
* open on a wall display reaches without anybody meaning it to — has an ulp of
|
||||
* 0.0625 s. The breath advances `BOIL_RATE` radians a second, 0.0035 of them in
|
||||
* a 60 Hz frame, so past that point the argument of the sine can only change
|
||||
* every fourth or fifth frame: a smooth swell turns into a staircase, on every
|
||||
* puff in the sky at once, and nothing in the code looks any different. It is
|
||||
* the same loss of precision the drift accumulators in `tick` are already
|
||||
* wrapped against, arriving through the one door that was left open.
|
||||
*
|
||||
* The wrap has to be a whole number of cycles or it trades a slow quantisation
|
||||
* for a visible jump once a period, so the period is exactly 2π/rate — one
|
||||
* cycle, 29.9 s — and `sin` is continuous across it by construction rather than
|
||||
* by tuning.
|
||||
*
|
||||
* The rate is interpolated into the vertex shader from this constant rather than
|
||||
* written out in both places, because the two agreeing is the *only* reason the
|
||||
* wrap is invisible: change the literal in the shader alone and every puff in
|
||||
* the sky steps by up to a seventh of its own radius, together, once every
|
||||
* period — which is a great deal more noticeable than the staircase this was
|
||||
* fixing. `toFixed` rather than plain
|
||||
* interpolation because GLSL ES has no implicit int-to-float conversion, so a
|
||||
* rate that happened to be integral would emit `1` and fail to compile the
|
||||
* program — at which point the whole layer is gone, not just its breath.
|
||||
*/
|
||||
const BOIL_RATE = 0.21;
|
||||
const BOIL_PERIOD = (Math.PI * 2) / BOIL_RATE;
|
||||
|
||||
/** Edge of the puff texture, in texels. Only the alpha channel carries anything. */
|
||||
const PUFF_TEXTURE_SIZE = 256;
|
||||
@@ -579,6 +629,7 @@ export function createCloudLayer(world: World, options: CloudLayerOptions = {}):
|
||||
let targetCover = 0;
|
||||
let cover = 0;
|
||||
let visible = true;
|
||||
/** Feeds `uTime` and nothing else, which is why `tick` may wrap it freely. */
|
||||
let elapsed = 0;
|
||||
|
||||
let windKph = DEFAULT_WIND_KPH;
|
||||
@@ -807,7 +858,11 @@ export function createCloudLayer(world: World, options: CloudLayerOptions = {}):
|
||||
},
|
||||
|
||||
tick(dt) {
|
||||
elapsed += dt;
|
||||
// Wrapped on the breath's own period, so the uniform stays small enough to
|
||||
// keep its precision in float32 and the sine does not notice. See
|
||||
// `BOIL_PERIOD`; `posMod` rather than `%` so a pathological `dt` cannot
|
||||
// walk it negative.
|
||||
elapsed = posMod(elapsed + dt, BOIL_PERIOD);
|
||||
sortAge += dt;
|
||||
uniforms.uTime!.value = elapsed;
|
||||
|
||||
@@ -1160,8 +1215,11 @@ void main() {
|
||||
|
||||
// A slow breath, a fifteenth of a radius, on a per-puff phase. Small enough
|
||||
// that nobody sees a puff pulse and large enough that the field is never
|
||||
// completely still even with no wind reported.
|
||||
float boil = 1.0 + 0.07 * sin(uTime * 0.21 + aFade.z * 6.2831853);
|
||||
// completely still even with no wind reported. The rate is interpolated in
|
||||
// from BOIL_RATE, which is also what uTime's wrap period is derived from: the
|
||||
// two are one number and have to stay one number. (No backticks in this
|
||||
// string, ever — it is a template literal and they close it.)
|
||||
float boil = 1.0 + 0.07 * sin(uTime * ${BOIL_RATE.toFixed(6)} + aFade.z * 6.2831853);
|
||||
float radius = aShape.x * uGrow * boil * present;
|
||||
|
||||
vec2 corner = position.xy;
|
||||
|
||||
+21
-4
@@ -355,10 +355,27 @@ export class AdsbFlights implements FlightSource {
|
||||
private held: Aircraft[] = [];
|
||||
private heldAt = 0;
|
||||
|
||||
constructor(
|
||||
private readonly endpoint: string,
|
||||
private readonly region: SkyRegion,
|
||||
) {}
|
||||
private readonly endpoint: string;
|
||||
private readonly region: SkyRegion;
|
||||
|
||||
/**
|
||||
* Fields assigned in the body rather than declared as parameter properties.
|
||||
*
|
||||
* That is not a style preference. A parameter property is the one piece of
|
||||
* TypeScript syntax that *emits code* — it is a hidden assignment, not a type
|
||||
* annotation — so Node's type stripping refuses the whole module with
|
||||
* `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. The bundler never cared, so this went
|
||||
* unnoticed until the first `node --test` file tried to import this layer and
|
||||
* discovered it could not: the module with the worst bug this project has
|
||||
* shipped was, by construction, the one module that could not be tested.
|
||||
*
|
||||
* The server has run under type stripping from the start and so has always
|
||||
* been written this way; the browser engine simply never had to be.
|
||||
*/
|
||||
constructor(endpoint: string, region: SkyRegion) {
|
||||
this.endpoint = endpoint;
|
||||
this.region = region;
|
||||
}
|
||||
|
||||
async poll(): Promise<Aircraft[]> {
|
||||
const { lat, lng } = this.region.center;
|
||||
|
||||
+298
-1
@@ -58,6 +58,33 @@ export interface OfficePlanHoverInfo {
|
||||
level: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One robot walking about the building, as this widget needs it.
|
||||
*
|
||||
* Structural, and deliberately *not* `RobotView` imported from
|
||||
* `interiors/robots.ts` — the same call `luminaires.ts` makes with its `Walker`,
|
||||
* and made here for a stronger reason. This file is drawn from a `Plan` and
|
||||
* nothing else; a type import from the robot layer would tie the widget's public
|
||||
* contract to a module it otherwise has no business knowing exists, and the next
|
||||
* thing that walks about a floor would have to be a robot to be drawable. Two
|
||||
* fields is the whole of what a mark on a floor plan needs. A `RobotView`
|
||||
* satisfies this as it stands and nothing has to be adapted.
|
||||
*
|
||||
* The robot's own `id` is read nowhere, on purpose. `drawOccupied` sets out why
|
||||
* the plan answers "is anybody there" rather than "who" even for people, and a
|
||||
* robot is further down that road again — `robots.ts` is explicit that a robot is
|
||||
* nobody, so there is not even a name to decline to print.
|
||||
*/
|
||||
export interface PlanRobot {
|
||||
/** Which storey it is on. It is drawn only while that storey is the one shown. */
|
||||
levelId: string;
|
||||
/**
|
||||
* Office-world metres, at its feet. **Live**: whoever owns the robot mutates
|
||||
* this vector in place every frame. This file reads it and never writes it.
|
||||
*/
|
||||
position: THREE.Vector3;
|
||||
}
|
||||
|
||||
export interface OfficeMinimapOptions {
|
||||
/** The resolved office. The same `Plan` the scene was built from, or the drawing lies. */
|
||||
plan: Plan;
|
||||
@@ -98,6 +125,26 @@ export interface OfficeMinimap {
|
||||
* spot would turn a private id into a public coordinate.
|
||||
*/
|
||||
setPresence(people: readonly Presence[]): void;
|
||||
/**
|
||||
* The robots walking about the building, so the plan shows them moving.
|
||||
*
|
||||
* Shaped like `setPresence` — the caller hands over the domain objects and the
|
||||
* widget does its own resolving, rather than the caller pre-chewing them into
|
||||
* pixels — with one difference that comes out of the data and not out of
|
||||
* taste. Presence arrives from a poll every few seconds and each answer is a
|
||||
* *snapshot*, so `setPresence` does its work when it is called. The robot layer
|
||||
* publishes a stable array of vectors it mutates in place, so this is called
|
||||
* **once**, with that array, and every frame afterwards is read straight out of
|
||||
* it by `tick`. That is the same handshake `officeScene` already makes with
|
||||
* `luminaires.setWalkers`, and it is what lets the plan show something moving
|
||||
* at sixty hertz without anybody allocating anything.
|
||||
*
|
||||
* Calling it every frame is harmless — it costs one reference compare — so a
|
||||
* caller that would rather push than be read is not punished for it. Handing
|
||||
* over a *different* array drops the old one, and the new robots have no
|
||||
* heading until they have taken a step.
|
||||
*/
|
||||
setRobots(robots: readonly PlanRobot[]): void;
|
||||
/** Call from the stage tick. Cheap by construction — see the file header. */
|
||||
tick(): void;
|
||||
/** Re-do the backing store at the current size and re-rasterise the plan. */
|
||||
@@ -132,6 +179,15 @@ const MIN_PROP_M = 0.35;
|
||||
/** Props standing above head height are fittings, not furniture. See `drawProps`. */
|
||||
const MAX_PROP_ELEVATION_M = 1.6;
|
||||
|
||||
/**
|
||||
* The empty robot list, shared and frozen by convention.
|
||||
*
|
||||
* Module-level so that an office with no robots — which is every pack that does
|
||||
* not ask for them, and the default — never allocates for the feature at all.
|
||||
* What it pays instead is one `length === 0` test per frame in three functions.
|
||||
*/
|
||||
const NO_ROBOTS: readonly PlanRobot[] = [];
|
||||
|
||||
export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinimap {
|
||||
const { plan, camera, controls } = options;
|
||||
const registry = options.registry ?? kit;
|
||||
@@ -219,6 +275,37 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
let occupiedPx = new Float64Array(0);
|
||||
/** Seat id -> label, for the hover readout. Every seat in the building, not just this storey. */
|
||||
let peopleBySeat = new Map<string, string>();
|
||||
/**
|
||||
* The robots, live. The array belongs to whoever called `setRobots` and its
|
||||
* contents change underneath this file between one draw and the next.
|
||||
*/
|
||||
let robotList: readonly PlanRobot[] = NO_ROBOTS;
|
||||
/**
|
||||
* Where each robot was as of the last draw — office metres, x then z — and the
|
||||
* unit direction it was last seen travelling in, again x then z. Two flat
|
||||
* arrays rather than an array of objects, for the reason every other buffer in
|
||||
* this file is flat: the draw loop may not allocate and may not chase pointers.
|
||||
*
|
||||
* **The heading is derived here rather than published by the layer**, which
|
||||
* looks like a gap and is not one. A `RobotView` carries a position and no yaw;
|
||||
* the layer knows its yaw perfectly well and simply does not hand it out, and
|
||||
* asking it to would be a change to a contract that three other callers read.
|
||||
* Differencing two positions recovers the heading to better than a pixel: the
|
||||
* layer advances a robot *exactly* along its own yaw — `x -= sin(yaw) · ds`,
|
||||
* `z -= cos(yaw) · ds` — so the step between two draws **is** the yaw, one
|
||||
* redraw stale, which at this widget's 30 Hz ceiling and the layer's 2.2 rad/s
|
||||
* turn rate is under four degrees. Four degrees on a mark five pixels long is
|
||||
* not visible.
|
||||
*
|
||||
* The one case where the derived heading and the rig's yaw genuinely part
|
||||
* company is a robot rotating while barely moving — yielding to another robot,
|
||||
* or pivoting into a doorway with its pace scaled to nearly nothing. Then this
|
||||
* keeps pointing the way the machine last actually went, which is the better
|
||||
* answer for a plan: a plan records what happened on the floor, not what a
|
||||
* transform is doing this instant.
|
||||
*/
|
||||
let robotLast = new Float64Array(0);
|
||||
let robotDir = new Float64Array(0);
|
||||
|
||||
// Laid-out geometry. Flat arrays and paths of device pixels, rebuilt on resize
|
||||
// and on a change of storey, so the draw loop reads numbers and never projects.
|
||||
@@ -744,6 +831,102 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A robot, as a turned chassis with a bow on the front.
|
||||
*
|
||||
* **The shape carries this, not the colour.** `drawOccupied` has already
|
||||
* established that a hue is a guess at three device pixels, and it is right; a
|
||||
* robot drawn as a differently-tinted dot is a person to anybody who has not
|
||||
* been told otherwise, and this widget has no legend to tell them with. So the
|
||||
* marker is built out of the one channel that survives at five pixels —
|
||||
* silhouette — and the plan's silhouettes are a small closed vocabulary:
|
||||
*
|
||||
* - a **circle** is somebody: an occupied desk, or a viewpoint pin;
|
||||
* - an **axis-aligned rectangle** is the building or its furniture, drawn
|
||||
* once into the raster and never moving again;
|
||||
* - a **notched amber chevron** is the camera, and there is exactly one.
|
||||
*
|
||||
* A robot is therefore a *turned* rectangle with a point on the front. Hard
|
||||
* corners, so it reads machined rather than grown. Wider across than it is
|
||||
* deep, so the turn is visible at all and the thing has shoulders. Convex,
|
||||
* unnotched, cool and about 60% of the linear size of the chevron, so it is
|
||||
* never mistaken for the camera — which is still this widget's first job.
|
||||
*
|
||||
* A plain square was the first attempt and is useless twice over: four-fold
|
||||
* symmetry means turning it conveys nothing, so the heading has to be a second
|
||||
* mark stuck on the outside, and a square sitting unturned among the desks is a
|
||||
* desk. A detached tick ahead of the body was the second attempt, and two
|
||||
* pixels of ink with a gap in front of them reads as dirt on the screen rather
|
||||
* than as a nose. Folding the point into the body path costs no extra ink, no
|
||||
* extra fill, and cannot come adrift from the thing it belongs to.
|
||||
*
|
||||
* The colour is a mint green — the third hue on the drawing, after the
|
||||
* people-blue and the camera-amber, and the last one this plan will get. Green
|
||||
* is the furthest free hue from both of them; it is the brightest mark per unit
|
||||
* of ink on a near-black ground, because luminance lives mostly in the green
|
||||
* channel, which is what something moving among a hundred static grey
|
||||
* rectangles wants; and it is already the colour a viewer reads as a machine
|
||||
* that is running. Its riskiest confusion is with the camera's amber, since
|
||||
* red-green colour blindness pulls both toward yellow — which is precisely the
|
||||
* pair separated by silhouette and by size above, and is why the shape had to
|
||||
* do the work first and the hue second.
|
||||
*/
|
||||
function drawRobots(ctx: Ctx) {
|
||||
if (robotList.length === 0 || !level) return;
|
||||
// Half the beam, the distance from the middle to the transom, and the point
|
||||
// out in front of it. A touch smaller than the occupied dot on purpose: there
|
||||
// are only ever a few of these, they are the only thing on the plan that
|
||||
// moves, and a moving mark of a given size already shouts louder than a still
|
||||
// one.
|
||||
const half = 2.5 * dpr;
|
||||
const rear = 1.7 * dpr;
|
||||
const bow = 2.3 * dpr;
|
||||
ctx.lineWidth = dpr;
|
||||
ctx.fillStyle = theme.robot;
|
||||
ctx.strokeStyle = theme.robotEdge;
|
||||
for (let i = 0; i < robotList.length; i++) {
|
||||
const robot = robotList[i];
|
||||
// The level test is the whole of the storey handling, and it is per-draw
|
||||
// rather than laid out like `occupiedPx` because a robot moves and a seat
|
||||
// does not: there is nothing to cache that would still be true next frame.
|
||||
if (!robot || robot.levelId !== level.id) continue;
|
||||
const x = toPxX(robot.position.x);
|
||||
const y = toPxY(robot.position.z);
|
||||
// A direction in office metres is already a direction on the drawing —
|
||||
// `toPxX` and `toPxY` are the same positive scale on both axes with no
|
||||
// negation anywhere, which the header explains at length. `drawCamera`
|
||||
// leans on the same fact and the two would break together if the plan were
|
||||
// ever mirrored.
|
||||
const fx = robotDir[i * 2] ?? 0;
|
||||
const fy = robotDir[i * 2 + 1] ?? 0;
|
||||
// Both zero only before a robot's first step: `recordRobots` writes a unit
|
||||
// vector or nothing at all.
|
||||
const known = fx !== 0 || fy !== 0;
|
||||
const nx = known ? fx : 0;
|
||||
const ny = known ? fy : 1;
|
||||
// Starboard, from forward. Same derivation as the camera chevron's.
|
||||
const sx = -ny;
|
||||
const sy = nx;
|
||||
// With no heading yet the body is drawn as a square and keeps its bow: a
|
||||
// rectangle turned some arbitrary way is a claim about which way a machine
|
||||
// is pointing, and this is the one state — a robot that has not moved since
|
||||
// it was handed over — where there is honestly nothing to claim.
|
||||
const back = known ? rear : half;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - nx * back - sx * half, y - ny * back - sy * half);
|
||||
ctx.lineTo(x + nx * back - sx * half, y + ny * back - sy * half);
|
||||
if (known) ctx.lineTo(x + nx * (back + bow), y + ny * (back + bow));
|
||||
ctx.lineTo(x + nx * back + sx * half, y + ny * back + sy * half);
|
||||
ctx.lineTo(x - nx * back + sx * half, y - ny * back + sy * half);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// The ground colour, hairline, exactly as an occupied desk gets: a machine
|
||||
// crossing a desk bank has to keep its outline against the furniture it is
|
||||
// walking over, and the fill alone does not manage it.
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawPing(ctx: Ctx, now: number) {
|
||||
if (pinging === 0) return;
|
||||
const t = (now - pinging) / PING_MS;
|
||||
@@ -772,6 +955,11 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
drawFootprint(ctx);
|
||||
drawOccupied(ctx);
|
||||
drawViewpoints(ctx);
|
||||
// Over the furniture, the desks and the viewpoint pins, and under the
|
||||
// crosshair and the camera. A robot standing on a viewpoint is the thing you
|
||||
// want to see; the camera is the thing you want to see over everything, and
|
||||
// that has been the order here since the widget was one function.
|
||||
drawRobots(ctx);
|
||||
crosshair(ctx, toPxX(controls.target.x), toPxY(controls.target.z), theme.target, 5 * dpr);
|
||||
drawCamera(ctx);
|
||||
if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr);
|
||||
@@ -806,6 +994,71 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
lastAspect = camera.aspect;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a robot on the storey being drawn has moved since the last draw.
|
||||
*
|
||||
* Split from `recordRobots` exactly as `cameraMoved` is split from
|
||||
* `recordCamera`, and compared exactly rather than with an epsilon for the
|
||||
* reason given there and one of its own: a robot eases into its destination
|
||||
* over the last 0.9 m, so its final frames are fractions of a millimetre, and
|
||||
* any tolerance worth having would strand the marker short of where the figure
|
||||
* in the scene is standing.
|
||||
*
|
||||
* **Only the storey being drawn counts.** A robot pacing about a mezzanine
|
||||
* nobody is looking at must not hold this widget open at thirty frames a second
|
||||
* for the whole session, drawing nothing, which is exactly what it would do if
|
||||
* this looked at all of them.
|
||||
*/
|
||||
function robotsMoved(): boolean {
|
||||
if (robotList.length === 0 || !level) return false;
|
||||
for (let i = 0; i < robotList.length; i++) {
|
||||
const robot = robotList[i];
|
||||
if (!robot || robot.levelId !== level.id) continue;
|
||||
if (robot.position.x !== robotLast[i * 2]) return true;
|
||||
if (robot.position.z !== robotLast[i * 2 + 1]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the positions this draw is about to use, and turn the step since the
|
||||
* last one into a heading.
|
||||
*
|
||||
* Every robot and not only the visible ones, unlike `robotsMoved`. The
|
||||
* alternative is that a robot on another storey keeps whatever position it had
|
||||
* when that storey was last on screen, and the first frame after changing
|
||||
* floors derives its heading from a stride several metres long taken minutes
|
||||
* ago — a marker confidently pointing across the building. A handful of robots
|
||||
* is a handful of subtractions; being clever here would cost more to explain
|
||||
* than to skip.
|
||||
*
|
||||
* A zero step leaves the heading alone rather than clearing it. That is what
|
||||
* lets a robot that has stopped keep facing the way it arrived instead of
|
||||
* losing its nose every time it pauses for a few seconds, which is most of the
|
||||
* time — and the figure in the scene does exactly the same thing, because the
|
||||
* rig's yaw is not reset when it halts either.
|
||||
*/
|
||||
function recordRobots() {
|
||||
for (let i = 0; i < robotList.length; i++) {
|
||||
const robot = robotList[i];
|
||||
if (!robot) continue;
|
||||
const x = robot.position.x;
|
||||
const z = robot.position.z;
|
||||
// NaN on the first pass after `setRobots`, which is deliberate and is why
|
||||
// `robotLast` is filled with it: `NaN > 1e-6` is false, so the first draw
|
||||
// records a position and claims no heading from it.
|
||||
const dx = x - (robotLast[i * 2] ?? NaN);
|
||||
const dz = z - (robotLast[i * 2 + 1] ?? NaN);
|
||||
const step = Math.hypot(dx, dz);
|
||||
if (step > 1e-6) {
|
||||
robotDir[i * 2] = dx / step;
|
||||
robotDir[i * 2 + 1] = dz / step;
|
||||
}
|
||||
robotLast[i * 2] = x;
|
||||
robotLast[i * 2 + 1] = z;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Interaction ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -1082,6 +1335,27 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
dirty = true;
|
||||
},
|
||||
|
||||
setRobots(next) {
|
||||
// In the intended wiring this is the same array object every time, so the
|
||||
// common path is a reference compare and a return. That is not a
|
||||
// micro-optimisation: marking the widget dirty on every call would defeat
|
||||
// the bail-out in `tick` outright and pin the panel at its full redraw rate
|
||||
// in an office where nothing whatsoever is moving.
|
||||
if (next === robotList) return;
|
||||
robotList = next;
|
||||
robotLast = new Float64Array(next.length * 2);
|
||||
// NaN, not the zero a fresh `Float64Array` comes with. Zero is a perfectly
|
||||
// ordinary coordinate — plenty of packs put the corner of a floor plate
|
||||
// near the origin — so a zeroed previous position makes the first step look
|
||||
// like a stride from the origin to wherever the robot actually is, and
|
||||
// every robot spends its first frame pointing away from the middle of the
|
||||
// building. NaN makes that first difference no difference at all, which is
|
||||
// the truth: nothing is known yet about where this machine came from.
|
||||
robotLast.fill(NaN);
|
||||
robotDir = new Float64Array(next.length * 2);
|
||||
dirty = true;
|
||||
},
|
||||
|
||||
tick() {
|
||||
if (!ready || !viewCtx) return;
|
||||
const now = performance.now();
|
||||
@@ -1097,10 +1371,16 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
renderStatic();
|
||||
dirty = true;
|
||||
}
|
||||
if (!dirty && pinging === 0 && !cameraMoved()) return;
|
||||
// `robotsMoved` last of the three, because it is the only one that walks a
|
||||
// list, and an office with no robots settles it on a length compare.
|
||||
if (!dirty && pinging === 0 && !cameraMoved() && !robotsMoved()) return;
|
||||
lastDraw = now;
|
||||
dirty = false;
|
||||
recordCamera();
|
||||
// Before `draw`, not after: the headings this frame's markers are turned by
|
||||
// are derived from the step that has just been taken, so recording after
|
||||
// drawing would render every robot one frame behind its own nose.
|
||||
recordRobots();
|
||||
draw(now);
|
||||
},
|
||||
|
||||
@@ -1120,6 +1400,11 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
ready = false;
|
||||
roomPaths = [];
|
||||
labels = [];
|
||||
// Back to the shared empty. The robot list is somebody else's live array
|
||||
// and it is the one thing this widget holds that outlives it — a disposed
|
||||
// panel keeping a reference to a disposed scene's robots is how a torn-down
|
||||
// office stays reachable from a DOM node nobody can see any more.
|
||||
robotList = NO_ROBOTS;
|
||||
canvas.remove();
|
||||
},
|
||||
};
|
||||
@@ -1152,6 +1437,8 @@ interface Theme {
|
||||
labelHalo: string;
|
||||
occupied: string;
|
||||
occupiedEdge: string;
|
||||
robot: string;
|
||||
robotEdge: string;
|
||||
frame: string;
|
||||
footprintFill: string;
|
||||
footprintStroke: string;
|
||||
@@ -1211,6 +1498,16 @@ function buildTheme(): Theme {
|
||||
// which is still this widget's first job.
|
||||
occupied: rgba(rgbOf(0x8ec3e8), 0.95),
|
||||
occupiedEdge: rgba(rgbOf(0x0a0d11), 0.7),
|
||||
// The only green on the plan, and the only mark on it that moves. The full
|
||||
// argument for a hue of its own rather than a second blue is at `drawRobots`,
|
||||
// and the short version is that the silhouette is what says "machine" and the
|
||||
// colour only has to stay out of the way of the people and of the camera.
|
||||
robot: rgba(rgbOf(0x5fd9a6), 0.95),
|
||||
// The ground colour behind it, exactly as an occupied desk gets. Written out
|
||||
// again rather than sharing `occupiedEdge`: the two are the same value today
|
||||
// and they are not the same decision, and a plan that changed how it rims its
|
||||
// people because somebody adjusted its robots would be a small mystery.
|
||||
robotEdge: rgba(rgbOf(0x0a0d11), 0.7),
|
||||
frame: rgba(rgbOf(0x9fb4c6), 0.3),
|
||||
// Faint, for the reason the city widget's is faint: on the whole-floor view
|
||||
// the footprint covers most of the widget, and a fill that is a hint over
|
||||
|
||||
@@ -339,7 +339,7 @@ export interface SatelliteLayer {
|
||||
* The band where the constellation was both unclipped and unfogged did not
|
||||
* overlap the band where it fitted on screen at all.
|
||||
*/
|
||||
const DOME_RADIUS_FACTOR = 1.05;
|
||||
export const DOME_RADIUS_FACTOR = 1.05;
|
||||
|
||||
/**
|
||||
* How large a dot is drawn, in **pixels**, at any camera distance.
|
||||
|
||||
+17
-10
@@ -34,11 +34,7 @@ import { createFlightLayer, type FlightLayer } from "./flights.ts";
|
||||
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
|
||||
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
|
||||
import { solarPosition, sunDirection } from "./solar.ts";
|
||||
import {
|
||||
createStarlinkMeshLayer,
|
||||
DOME_RADIUS_FACTOR,
|
||||
type StarlinkMeshLayer,
|
||||
} from "./starlinkMesh.ts";
|
||||
import { createStarlinkMeshLayer, type StarlinkMeshLayer } from "./starlinkMesh.ts";
|
||||
import {
|
||||
createSatelliteLayer,
|
||||
type SatelliteCatalogue,
|
||||
@@ -265,6 +261,16 @@ export async function createScene(
|
||||
*/
|
||||
maxDistance: boardSpan * 2.0,
|
||||
shadowExtent: boardSpan * 0.75,
|
||||
/**
|
||||
* The middle of the board, which is **not** the origin.
|
||||
*
|
||||
* Scene space is centred on `city.center` — the city — and the Bay Area
|
||||
* board runs forty kilometres down the peninsula from there, so a shadow
|
||||
* box centred on the origin spends half itself on empty ocean and leaves the
|
||||
* far end of the peninsula outside the frustum entirely. `shadowExtent`
|
||||
* sizes the box and says nothing about where it is; this says where.
|
||||
*/
|
||||
shadowTarget: new THREE.Vector3((westX + eastX) / 2, 0, (northZ + southZ) / 2),
|
||||
shadowFar: boardSpan * 2.2,
|
||||
});
|
||||
// Held, because the cloud layer needs the same opening rig the kit just got —
|
||||
@@ -308,11 +314,12 @@ export async function createScene(
|
||||
if (options.satellites) {
|
||||
satelliteLayer = createSatelliteLayer(boardRadius);
|
||||
scene.add(satelliteLayer.group);
|
||||
// The same dome the points are on, so a satellite that grows geometry does
|
||||
// not also jump. `DOME_RADIUS_FACTOR` is exported for exactly this: the two
|
||||
// layers must agree, and the only safe way for them to agree is to be
|
||||
// multiplying the same number by the same constant.
|
||||
starlinkMeshes = createStarlinkMeshLayer(boardRadius * DOME_RADIUS_FACTOR);
|
||||
// Board radius, the same unit `createSatelliteLayer` takes above — the
|
||||
// mesh layer applies the dome factor itself. It used to take the *dome*
|
||||
// radius while its sibling took the *board* radius, with nothing in the
|
||||
// types to tell them apart, which is precisely the confusion that has
|
||||
// already produced one real bug in this file.
|
||||
starlinkMeshes = createStarlinkMeshLayer({ boardRadius });
|
||||
scene.add(starlinkMeshes.group);
|
||||
}
|
||||
|
||||
|
||||
+78
-7
@@ -90,10 +90,37 @@ export interface SceneKitOptions {
|
||||
shadowFar?: number;
|
||||
shadowBias?: number;
|
||||
/**
|
||||
* How far along its direction the sun is placed. A `LightingState` carries a
|
||||
* unit direction and no distance, because distance is a fact about the scale
|
||||
* of the scene — 94 m per unit outdoors, 1 m per unit indoors — and not about
|
||||
* where the sun is.
|
||||
* What the shadow box is centred on, in scene units. Defaults to the origin,
|
||||
* which is almost never where the thing being lit actually is.
|
||||
*
|
||||
* A `shadowExtent` says how *big* the box is; it says nothing about where.
|
||||
* three centres a directional light's shadow camera on `light.target`, and a
|
||||
* fresh `DirectionalLight` targets a brand-new `Object3D` sitting at the
|
||||
* world origin — so without this every caller got a correctly-sized box in
|
||||
* the wrong place, and the two consumers here both have their origin off to
|
||||
* one side of what they want lit:
|
||||
*
|
||||
* - An office pack's origin is the **north-west corner of its slab**, not its
|
||||
* middle. `lumbridge-hq` is 48 x 18 m against `shadowExtent: max(8, span *
|
||||
* 0.7)` = ±33.6 m, so a box on the origin covered x ∈ [-33.6, 33.6] of a
|
||||
* building occupying x ∈ [0, 48]: the eastern 14.4 m — call it a third of
|
||||
* the floor plate — fell outside the frustum entirely and neither cast a
|
||||
* shadow nor received one. Half the box was spent on the empty ground west
|
||||
* of the building.
|
||||
* - Scene space for a city is centred on `city.center`, and the comment on
|
||||
* `boardRadius` in `scene.ts` already records that the Bay Area board runs
|
||||
* forty kilometres down the peninsula from there. Same failure, one order
|
||||
* of magnitude up.
|
||||
*
|
||||
* Pass the centre of what you want shadowed: `plan.bounds.center` for an
|
||||
* office, the mid-point of the projected board for a city.
|
||||
*/
|
||||
shadowTarget?: THREE.Vector3;
|
||||
/**
|
||||
* How far along its direction the sun is placed, **from `shadowTarget`**. A
|
||||
* `LightingState` carries a unit direction and no distance, because distance
|
||||
* is a fact about the scale of the scene — 94 m per unit outdoors, 1 m per
|
||||
* unit indoors — and not about where the sun is.
|
||||
*/
|
||||
sunDistance?: number;
|
||||
/** Flight rate, in fractions of the flight per second. */
|
||||
@@ -263,9 +290,33 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
* house, somewhere near the origin. The office asks for ±34 m and got ±5 m.
|
||||
*/
|
||||
sun.shadow.camera.updateProjectionMatrix();
|
||||
|
||||
/**
|
||||
* One update now, so a reader that recomputes for itself sees the right pose.
|
||||
*
|
||||
* This is **not** what makes the shadow correct — the paragraph above is: the
|
||||
* target has to be *in the scene* so `Object3D.updateMatrixWorld`'s traversal
|
||||
* reaches it, and `WebGLRenderer.render` runs that traversal before
|
||||
* `shadowMap.render()` on every frame. That is the whole mechanism.
|
||||
*
|
||||
* What this line buys is narrower and worth being honest about. Nothing that
|
||||
* reads `sun.shadow.camera` before the first render learns anything from it —
|
||||
* three only writes that camera's pose inside `LightShadow.updateMatrices`,
|
||||
* which runs during a render. It matters to a reader that recomputes from the
|
||||
* target itself: a `DirectionalLightHelper`, or a manual
|
||||
* `sun.shadow.updateMatrices(sun)` in a capture pass.
|
||||
*/
|
||||
const shadowTarget = new THREE.Vector3();
|
||||
if (options.shadowTarget) shadowTarget.copy(options.shadowTarget);
|
||||
sun.target.position.copy(shadowTarget);
|
||||
|
||||
const hemisphere = new THREE.HemisphereLight(0xffffff, 0x808080, 1);
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.3);
|
||||
scene.add(sun, hemisphere, ambient);
|
||||
scene.add(sun, sun.target, hemisphere, ambient);
|
||||
// Belt and braces for anything that reads the shadow camera before the first
|
||||
// render — a capture pass, a debug helper — where the renderer's own
|
||||
// traversal has not happened yet. After that, the traversal owns it.
|
||||
sun.target.updateMatrixWorld();
|
||||
|
||||
const sunDirection = new THREE.Vector3();
|
||||
let sky: THREE.Texture | null = null;
|
||||
@@ -278,7 +329,24 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
// A zero direction would put the sun inside the ground and black the scene
|
||||
// out; leaving it where it was is the kinder failure.
|
||||
if (sunDirection.lengthSq() > 0) {
|
||||
sun.position.copy(sunDirection.normalize().multiplyScalar(sunDistance));
|
||||
/*
|
||||
* `sunDistance` out from the **target**, not from the origin.
|
||||
*
|
||||
* A directional light's position is not physical — the shading only reads
|
||||
* `position - target` as a direction — but the shadow camera *is* placed
|
||||
* at it, and its `near`/`far` are measured from there along the view
|
||||
* axis. Off the origin those two facts fight: `officeScene.ts` asks for
|
||||
* `sunDistance: max(24, span * 1.4)`, so on a 12 m studio the sun sits
|
||||
* 24 units from the origin while the slab centre it is aimed at can be
|
||||
* 8 m away in some other direction — a light that is beside or behind the
|
||||
* building rather than above it, with the near plane cutting into the
|
||||
* geometry it is supposed to be shadowing.
|
||||
*
|
||||
* Anchoring to the target makes light-to-target exactly `sunDistance`
|
||||
* whatever the direction, which is the invariant every caller's
|
||||
* `shadowNear`/`shadowFar` was picked against.
|
||||
*/
|
||||
sun.position.copy(sunDirection.normalize().multiplyScalar(sunDistance)).add(shadowTarget);
|
||||
}
|
||||
sun.color.setHex(state.sun.color);
|
||||
sun.intensity = state.sun.intensity;
|
||||
@@ -531,7 +599,10 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
dom.style.cursor = "";
|
||||
picking = null;
|
||||
controls.dispose();
|
||||
scene.remove(sun, hemisphere, ambient);
|
||||
// `sun.target` was added as a scene child in its own right, so removing
|
||||
// the light does not take it with it — a kit torn down and rebuilt would
|
||||
// otherwise leave one empty Object3D in the scene per cycle.
|
||||
scene.remove(sun, sun.target, hemisphere, ambient);
|
||||
sun.dispose();
|
||||
hemisphere.dispose();
|
||||
ambient.dispose();
|
||||
|
||||
+173
-34
@@ -76,24 +76,47 @@
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
/**
|
||||
* The dome radius factor is imported, never restated.
|
||||
*
|
||||
* The points and the meshes have to be on the **same** dome — a satellite that
|
||||
* grows geometry must not also jump — and the only way for two modules to agree
|
||||
* on a number is for one of them not to have a copy of it. `satellites.ts` owns
|
||||
* the dome; this multiplies by what it says.
|
||||
*/
|
||||
import { DOME_RADIUS_FACTOR } from "./satellites.ts";
|
||||
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
||||
import type { SatelliteFix } from "./satellites.ts";
|
||||
|
||||
const RAD = 180 / Math.PI;
|
||||
|
||||
/**
|
||||
* The dome factor, restated.
|
||||
* The dome factor, restated — and applied *here*, which is now the whole point
|
||||
* of it.
|
||||
*
|
||||
* `satellites.ts` keeps `DOME_RADIUS_FACTOR = 1.05` private, and the meshes have
|
||||
* `satellites.ts` owns `DOME_RADIUS_FACTOR` and exports it, and the meshes have
|
||||
* to land on **exactly** the shell the dots are on — not a similar one. Put them
|
||||
* on different radii and the two layers agree only when the camera is at the
|
||||
* scene origin; anywhere else the mesh separates from its own dot by parallax,
|
||||
* which reads as a rendering fault rather than as a rounding error.
|
||||
*
|
||||
* This is a mirror and is meant to stop being one: see the wiring note. Export
|
||||
* the constant from `satellites.ts`, import it here, and delete this.
|
||||
* This used to be exported so that `scene.ts` could do the multiplication on the
|
||||
* way in, on the theory that a shared constant is what makes two layers agree.
|
||||
* It is not: `createSatelliteLayer` takes a board radius, this layer took a
|
||||
* *dome* radius, both are a bare `number`, and the only thing standing between
|
||||
* the two units was a caller remembering which of them it was holding. That is
|
||||
* the same shape of mistake that once put this constellation's dome *inside its
|
||||
* own city* — a radius mistaken for a span, recorded at length in
|
||||
* `DOME_RADIUS_FACTOR`'s note in `satellites.ts` — and it cost a rendering-bug
|
||||
* hunt to find, because a sky on the wrong radius still looks like a sky. Now both
|
||||
* entry points take the board radius and each multiplies for itself, so there is
|
||||
* no unit to get wrong at the call site and no reason for anything outside this
|
||||
* file to know this number exists.
|
||||
*
|
||||
* It is still a duplicate and still meant to stop being one: export the constant
|
||||
* from `satellites.ts`, import it here, and delete this declaration. Until
|
||||
* somebody does, the two have to be changed together.
|
||||
*/
|
||||
export const DOME_RADIUS_FACTOR = 1.05;
|
||||
|
||||
/**
|
||||
* Ceiling on meshes, and the reason the layer is affordable at all.
|
||||
@@ -244,7 +267,11 @@ const ARRAY_CENTRE_X = BUS_LENGTH / 2 + BOOM_GAP + ARRAY_LENGTH / 2;
|
||||
/** Tip of the array to the far edge of the bus — what `SPAN_FRACTION` scales. */
|
||||
const MODEL_SPAN = BUS_LENGTH + BOOM_GAP + ARRAY_LENGTH;
|
||||
|
||||
/** Sentinel for an unused candidate slot. Finite, so the comparator is total. */
|
||||
/**
|
||||
* Sentinel for an unused candidate slot. Finite rather than `Infinity`, so a
|
||||
* slot that ever did reach the ranking would sort to the back of it instead of
|
||||
* poisoning an arithmetic comparison.
|
||||
*/
|
||||
const UNUSED_SCORE = 1e9;
|
||||
|
||||
/** Earth's mean radius, for the nadir angle. Sphere is plenty at one degree. */
|
||||
@@ -261,6 +288,25 @@ export interface SunVector {
|
||||
readonly z: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the layer needs to exist, which is one number — passed as a *named* field
|
||||
* and not as a positional argument, deliberately.
|
||||
*
|
||||
* The number is `scene.ts`'s `boardRadius`: how far the board reaches from the
|
||||
* scene origin, exactly as `createSatelliteLayer` takes it, so the dots and the
|
||||
* meshes are derived from one measurement by one constant. Two radii are in play
|
||||
* inside this file and they differ by 5% — small enough that a mesh on the wrong
|
||||
* one still draws, still looks like a satellite, and only separates from its own
|
||||
* dot once the camera leaves the origin, which is the kind of bug that survives
|
||||
* a screenshot. A positional `number` cannot tell the two apart. A field named
|
||||
* `boardRadius` can, and a call site that was passing the other one stops
|
||||
* compiling instead of quietly drawing a second, slightly larger sky.
|
||||
*/
|
||||
export interface StarlinkMeshOptions {
|
||||
/** How far the board reaches from the scene origin. Not the board's width. */
|
||||
readonly boardRadius: number;
|
||||
}
|
||||
|
||||
export interface StarlinkMeshLayer {
|
||||
group: THREE.Group;
|
||||
/**
|
||||
@@ -289,7 +335,7 @@ export interface StarlinkMeshLayer {
|
||||
*/
|
||||
interface Candidate {
|
||||
fix: SatelliteFix | null;
|
||||
/** Degrees off the camera's axis. Ascending; `UNUSED_SCORE` sorts to the end. */
|
||||
/** Degrees off the camera's axis. Lower ranks first; see `rankBest`. */
|
||||
score: number;
|
||||
/** 0 to 1. Drives the scale, which is how a mesh grows out of its own dot. */
|
||||
fade: number;
|
||||
@@ -297,10 +343,15 @@ interface Candidate {
|
||||
readonly at: THREE.Vector3;
|
||||
}
|
||||
|
||||
export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer {
|
||||
export function createStarlinkMeshLayer(options: StarlinkMeshOptions): StarlinkMeshLayer {
|
||||
const group = new THREE.Group();
|
||||
group.name = "starlink-meshes";
|
||||
|
||||
// The shell everything below is placed on and scaled against. Computed once,
|
||||
// from the board radius, by the same constant `satellites.ts` uses on the same
|
||||
// input — which is the whole of the agreement between the two layers.
|
||||
const domeRadius = options.boardRadius * DOME_RADIUS_FACTOR;
|
||||
|
||||
const busGeometry = buildBus();
|
||||
const arrayGeometry = buildArray();
|
||||
|
||||
@@ -335,11 +386,25 @@ export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer {
|
||||
color: 0xffffff,
|
||||
fog: false,
|
||||
/**
|
||||
* The array is a flat panel edge-on for part of every orbit, and a
|
||||
* back-faced panel disappears entirely at the moment it is most
|
||||
* foreshortened. It has two sides in reality — cells one way, substrate the
|
||||
* other — and drawing both is a hundred and forty-four extra triangles
|
||||
* across the whole layer.
|
||||
* Culling off. Not extra geometry — `side` is a rasteriser state and emits
|
||||
* no triangles at all, so the arithmetic this comment used to carry ("a
|
||||
* hundred and forty-four extra triangles across the whole layer") was
|
||||
* measuring something that does not exist. What it costs is fill: the far
|
||||
* faces of a box that would otherwise have been discarded before shading.
|
||||
* For sixty-four objects twenty pixels across, under a `MeshBasicMaterial`
|
||||
* that shades both faces the same flat instance colour, that is unmeasurable
|
||||
* and invisible in both directions.
|
||||
*
|
||||
* Which is the honest status of this flag today: `buildArray` returns a
|
||||
* closed `BoxGeometry`, and a closed body never shows its interior whether
|
||||
* you cull or not. It is here for the case that geometry is one refactor
|
||||
* from becoming — the panel is two centimetres thick on eight metres and the
|
||||
* standing temptation is to demote it to a plane, at which point a
|
||||
* front-side material makes it *vanish* for the half of every orbit it is
|
||||
* turned away from you, which is exactly the half where its edge is the only
|
||||
* thing telling you the satellite is not a dot. Keeping the flag costs
|
||||
* nothing and removes the trap. Do not delete it because the box makes it
|
||||
* redundant; delete it only along with the box.
|
||||
*/
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
@@ -368,7 +433,20 @@ export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer {
|
||||
|
||||
const pool: Candidate[] = [];
|
||||
|
||||
// Scratch, all of it. Nothing in `update` allocates.
|
||||
/**
|
||||
* This frame's best `MAX_MESHES` candidates, ascending by score — references
|
||||
* into `pool`, never copies. Allocated once here; `rankBest` refills the front
|
||||
* of it every frame and nothing ever reads past what that returns.
|
||||
*/
|
||||
const ranked: (Candidate | undefined)[] = new Array<Candidate | undefined>(MAX_MESHES);
|
||||
|
||||
// Scratch, all of it. Once the pool has reached its high-water mark — a second
|
||||
// or two after the first pass rises — `update` allocates nothing whatever, and
|
||||
// that claim is only true because the ranking is `rankBest` and not
|
||||
// `pool.sort`: V8's sort copies the array into a work buffer on every call, so
|
||||
// a comparator-based sort of a few hundred entries is a few hundred words of
|
||||
// garbage sixty times a second, from the one layer whose entire argument for
|
||||
// existing is that it is cheap enough to leave on.
|
||||
const eye = new THREE.Vector3();
|
||||
const forward = new THREE.Vector3();
|
||||
const sunDir = new THREE.Vector3();
|
||||
@@ -393,6 +471,55 @@ export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer {
|
||||
return made;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill `ranked` with the lowest-scoring `min(found, MAX_MESHES)` of
|
||||
* `pool[0..found)`, ascending, and return how many that was.
|
||||
*
|
||||
* A bounded insertion rather than a sort, for two reasons and not for speed on
|
||||
* a typical sky. The first is the allocation above. The second is that a sort
|
||||
* answers a question nobody asked: the draw loop reads the first `MAX_MESHES`
|
||||
* entries and the rest is work whose result is thrown away, which on a dense
|
||||
* pass over a Starlink train is most of the list. This walks the candidates
|
||||
* once, rejects anything worse than the current sixty-fourth on a single
|
||||
* compare, and only pays the shift when a candidate genuinely belongs in the
|
||||
* window — so the cost tracks the size of the *window*, which is fixed, rather
|
||||
* than the size of the sky, which is not.
|
||||
*
|
||||
* The worst case is a pool of exactly `MAX_MESHES` arriving in descending
|
||||
* order, which is a full insertion sort: about two thousand pointer writes on
|
||||
* a 64-entry array, once a frame, and still no allocation. The best case — the
|
||||
* ordinary one, a dozen objects near the view centre — is a dozen compares.
|
||||
*
|
||||
* Order matters within the window as well as at its edge: `rank` in the draw
|
||||
* loop fades the last few slots out, so "sixty-fourth" has to mean the
|
||||
* sixty-fourth *best* and not merely one of the sixty-four.
|
||||
*/
|
||||
function rankBest(found: number): number {
|
||||
const keep = Math.min(found, MAX_MESHES);
|
||||
let held = 0;
|
||||
for (let i = 0; i < found; i++) {
|
||||
const candidate = pool[i];
|
||||
if (candidate === undefined) continue;
|
||||
if (held === keep) {
|
||||
const worst = ranked[keep - 1];
|
||||
if (worst !== undefined && candidate.score >= worst.score) continue;
|
||||
// The one being displaced falls off the end of the window; dropping the
|
||||
// count here is what keeps the shift below in bounds.
|
||||
held -= 1;
|
||||
}
|
||||
let j = held;
|
||||
while (j > 0) {
|
||||
const above = ranked[j - 1];
|
||||
if (above !== undefined && above.score <= candidate.score) break;
|
||||
ranked[j] = above;
|
||||
j -= 1;
|
||||
}
|
||||
ranked[j] = candidate;
|
||||
held += 1;
|
||||
}
|
||||
return held;
|
||||
}
|
||||
|
||||
/**
|
||||
* Azimuth and elevation to a point on the dome.
|
||||
*
|
||||
@@ -529,8 +656,12 @@ export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer {
|
||||
found += 1;
|
||||
}
|
||||
|
||||
// Release the rest of the pool so the sort puts them past the end. The
|
||||
// objects are kept; only their claim on a slot is dropped.
|
||||
// Release the tail of the pool. Nothing reads past `found` any more — the
|
||||
// ranking walks `[0, found)` and the pool is never reordered — so this is no
|
||||
// longer load-bearing for the selection; it is here so that a slot left over
|
||||
// from a busy pass does not keep last frame's `SatelliteFix` alive for the
|
||||
// lifetime of the layer. The objects themselves are kept, as always: only
|
||||
// their claim on a slot is dropped.
|
||||
for (let i = found; i < pool.length; i++) {
|
||||
const stale = pool[i];
|
||||
if (stale !== undefined) {
|
||||
@@ -538,11 +669,10 @@ export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer {
|
||||
stale.score = UNUSED_SCORE;
|
||||
}
|
||||
}
|
||||
pool.sort(byScore);
|
||||
|
||||
const drawn = Math.min(found, MAX_MESHES);
|
||||
const drawn = rankBest(found);
|
||||
for (let i = 0; i < drawn; i++) {
|
||||
const candidate = pool[i];
|
||||
const candidate = ranked[i];
|
||||
const fix = candidate?.fix;
|
||||
if (candidate === undefined || !fix) continue;
|
||||
|
||||
@@ -626,9 +756,17 @@ export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer {
|
||||
|
||||
/**
|
||||
* Phase, exactly as for the moon: how much of the lit side is turned this
|
||||
* way. `radial` points from the earth to the satellite, so `−radial` is
|
||||
* near enough the direction from the satellite to the observer, and its
|
||||
* dot with the sun is the cosine of the phase angle. Positive when the sun
|
||||
* way. `radial` is the satellite's position on the dome normalised, and
|
||||
* the dome is centred on the **observer** — so it points from the observer
|
||||
* to the satellite, the line of sight outward, and `−radial` is the
|
||||
* direction from the satellite back to the observer exactly rather than
|
||||
* approximately. (It is emphatically *not* the geocentric radial, the
|
||||
* earth's centre to the satellite: those two differ by the nadir angle η,
|
||||
* which reaches 67° at the horizon and is the entire subject of `nadirOf`
|
||||
* above. Using one where the other belongs is how the attitude and the
|
||||
* phase would end up disagreeing about where the satellite is.)
|
||||
*
|
||||
* Its dot with the sun is the cosine of the phase angle. Positive when the sun
|
||||
* is below the observer's horizon and the object is still in daylight,
|
||||
* which is the entire observing window for a Starlink pass; zero at noon,
|
||||
* when the sun is behind it from here and the side facing down is the side
|
||||
@@ -693,9 +831,14 @@ export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer {
|
||||
* The bus: a flat slab, the phased-array antenna stepped out of its underside,
|
||||
* and the boom stub the panel deploys along.
|
||||
*
|
||||
* The antenna step is worth its four triangles because the slab alone is a
|
||||
* shape with no side to it — the whole read of "belly pointing down" comes from
|
||||
* being able to see which face is which at an oblique angle. The boom is in the
|
||||
* The antenna step is worth its twelve triangles — it is a `BoxGeometry`, and a
|
||||
* box is twelve however thin it is drawn; the four this comment used to claim
|
||||
* were the count of the one face you can see — because the slab alone is a
|
||||
* shape with no side to it, and the whole read of "belly pointing down" comes
|
||||
* from being able to see which face is which at an oblique angle. That puts the
|
||||
* bus at 48 triangles (12 chassis, 12 antenna, 24 for the six-sided capped stub)
|
||||
* against the array's 12, so a drawn satellite is 60 and the whole layer at its
|
||||
* sixty-four-instance ceiling is 3,840. The boom is in the
|
||||
* bus rather than the array partly because it is structure rather than panel and
|
||||
* takes the pale material, and partly because a cylinder lying along the hinge
|
||||
* axis is invariant under the hinge rotation, so it looks identical either way
|
||||
@@ -717,10 +860,11 @@ function buildBus(): THREE.BufferGeometry {
|
||||
for (const part of parts) part.dispose();
|
||||
if (merged) return merged;
|
||||
|
||||
// The same non-null dance as `flights.ts`'s `dartGeometry`, for the same
|
||||
// reason: three primitives out of the same library cannot disagree about their
|
||||
// attributes, the signature permits it anyway, and a plain slab is a better
|
||||
// failure than a missing layer.
|
||||
// The same non-null dance as `aircraftGeometry.ts`'s `airlinerGeometry` — it
|
||||
// was `flights.ts`'s `dartGeometry` when this was written, and that function no
|
||||
// longer exists — for the same reason: three primitives out of the same
|
||||
// library cannot disagree about their attributes, the signature permits it
|
||||
// anyway, and a plain slab is a better failure than a missing layer.
|
||||
return new THREE.BoxGeometry(BUS_LENGTH, BUS_THICK, BUS_DEPTH);
|
||||
}
|
||||
|
||||
@@ -742,11 +886,6 @@ function buildArray(): THREE.BufferGeometry {
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** Ascending by angle off the view centre; released slots sort to the back. */
|
||||
function byScore(a: Candidate, b: Candidate): number {
|
||||
return a.score - b.score;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1 at or below `full`, 0 at or above `edge`, smoothstepped between — so both
|
||||
* ends of every ramp in this file arrive with zero slope, which is the whole
|
||||
|
||||
Reference in New Issue
Block a user