1
0

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:
2026-08-07 02:50:25 -07:00
parent af0d4a7d57
commit 51979feea0
13 changed files with 1760 additions and 166 deletions
+173 -34
View File
@@ -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