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
+78 -7
View File
@@ -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();