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:
+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