1
0

The office keeps its lights on, and something walks around under them

**Lights.** A sited office follows the real sun, and the real sun spends
half its time below the horizon — which was producing a technically
correct and completely useless picture: an unlit floor plate at midnight
in a building whose whole premise is that you can see who is at which
desk. `luminaires.ts` brings the diffusers up as the sun goes down and
reports one scalar for how much interior light there is; `withHouseLights`
adds it to the rig. CONTRACT §4's rule that a fitting emits no light is
kept in full — nothing here is a light source, and the rig still has one
owner.

**And they notice you.** A fitting within four metres of somebody walking
underneath brightens and fades back as they leave, which is what an
occupancy-sensed floor actually does at night. They are one `InstancedMesh`
sharing one material, so `emissiveIntensity` cannot vary between them —
`instanceColor` can, but three multiplies it into the diffuse term only, so
six lines of `onBeforeCompile` carry it into the emissive term as well. The
alternative was one mesh per fitting: forty draw calls of ceiling in a
building that spends about twenty on everything.

**Optimus.** A posable Gen-3 humanoid — eleven articulating joints, pale
shells over a dark frame, a black visor — with a walk cycle driven by
*distance travelled* rather than wall-clock, so the feet do not slide when
a robot slows down. Two per floor, derived from the pack's levels, so the
two-storey tower gets four and the hangar gets two without either pack
knowing robots exist. They wander between reachable points using
`Plan.blocked` — the collider the wall split already produces — and they
are deliberately **not** gated on `depth`: the build-time-exclusion rule is
about occupancy, and a robot is nobody.

**Starlinks stop being pixels.** The sixty-four nearest the centre of view
grow real geometry — a flat bus with ONE large solar array, which is the
actual signature and the thing everybody draws symmetrically and wrong —
fading in so there is no pop where a point becomes a mesh. Two draw calls.
The sun for their attitude comes from `solar.ts` and not from the rig,
because `atmosphere.ts` floors the light direction to keep the shadow
camera usable, and a sun ten degrees *down* is exactly the dusk geometry
that makes a pass visible.

**Aircraft** are airliners now — swept wings, nacelles, a fin — instead of
an arrowhead, still one shared geometry facing +Z as `flights.ts` requires.
**Clouds** drift over the board, driven by observed cover, lit by the rig
rather than by themselves.

Four modules were built by subagents and reviewed by another; every one
came back `needs-work` and the reviews were right. Fixed before wiring:

  - The walk cycle's arms were a quarter cycle out of step with its legs —
    the legs are cosine-shaped and the arms were on `sin`, so at the
    instant the left leg reached full forward the left shoulder was at dead
    neutral. Uncanny, and hard to name until it is pointed at.
  - Every Optimus shell used a `roundedBox` radius of 0.12–0.22, which that
    primitive turns into a near-circular cross-section — the figure was
    built out of lozenges, not panels. The rest of the library uses
    0.02–0.09.
  - The cloud material was `transparent` + `DoubleSide` without
    `forceSinglePass`, so three rendered it twice per frame *and* bumped
    `material.version` on each pass — rebuilding the program cache key
    forever, on the one layer that is fill-rate bound.
  - `starlinkMesh.dispose()` freed the geometries but not the
    `InstancedMesh`es, orphaning their instance buffers on every city
    switch.
  - The airliner's tailplane roots sat outside the tail cone and hung in
    free air over most of their chord.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 00:56:49 -07:00
parent 18dadda917
commit af0d4a7d57
13 changed files with 4917 additions and 42 deletions
+68 -3
View File
@@ -31,7 +31,14 @@ import * as THREE from "three";
import { createBlocks, createLandmarks } from "./blocks.ts";
import { createNightLights, type NightLights } from "./nightlights.ts";
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 {
createSatelliteLayer,
type SatelliteCatalogue,
@@ -111,6 +118,16 @@ export interface SceneHandle {
* elevation, so the number has to arrive separately.
*/
setSolarElevation(degrees: number): void;
/**
* How much of the sky has cloud in it, 0..1 — `WeatherObservation.cloudCover`.
*
* Separate from `setLighting` for the same reason `setSolarElevation` is: a
* `LightingState` deliberately carries no cover, so the number has to arrive
* on its own rather than be reverse-engineered out of a rig.
*/
setCloudCover(fraction: number): void;
/** Wind as the observation reports it: km/h, and the bearing it blows *from*. */
setWind(kph: number | null, fromDeg: number | null): void;
/**
* Freeze the satellite sky at an instant, or pass `null` to follow the wall
* clock. Exactly the shape of `main.ts`'s own time override, deliberately.
@@ -250,7 +267,11 @@ export async function createScene(
shadowExtent: boardSpan * 0.75,
shadowFar: boardSpan * 2.2,
});
kit.applyLighting(options.lighting ?? cityDaylight(pal, boardSpan));
// Held, because the cloud layer needs the same opening rig the kit just got —
// and it must be the same object, not a second call to `cityDaylight`, or the
// two disagree for the one frame before the app's first `setLighting`.
const opening = options.lighting ?? cityDaylight(pal, boardSpan);
kit.applyLighting(opening);
scene.add(createWater(world));
scene.add(createShorePlates(world));
@@ -268,6 +289,10 @@ export async function createScene(
const nightLights: NightLights = createNightLights({ world, blocks });
scene.add(nightLights.group);
const clouds: CloudLayer = createCloudLayer(world, { span: boardSpan });
clouds.setLighting(opening);
scene.add(clouds.group);
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
scene.add(markerLayer.group);
@@ -279,9 +304,16 @@ export async function createScene(
}
let satelliteLayer: SatelliteLayer | null = null;
let starlinkMeshes: StarlinkMeshLayer | null = null;
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);
scene.add(starlinkMeshes.group);
}
/**
@@ -357,6 +389,7 @@ export async function createScene(
onExit: () => kit.resetPick(),
tick(dt) {
kit.tick(dt);
clouds.tick(dt);
if (options.flights && flightLayer) {
flightTimer -= dt;
if (flightTimer <= 0) {
@@ -368,13 +401,40 @@ export async function createScene(
// time-budgeted internally — see `SWEEP_BUDGET_MS` — so calling it more
// often makes it walk the catalogue sooner, never makes it cost more.
if (options.satellites && satelliteLayer) {
satelliteLayer.update(options.satellites.fixes(skyOverride ?? new Date()));
/**
* One instant, one sweep, shared.
*
* `fixes()` advances the catalogue's rolling propagation, so calling it
* twice in a frame spends twice the budget for no new information — and
* two different `when`s would put the near-field satellites' sun
* attitude on a different clock from the sky they are in.
*
* The sun comes from `solar.ts` directly rather than from the rig,
* because `atmosphere.ts` floors the light direction at
* `shadowFloorDeg` to keep the shadow camera usable. That floor pins the
* sun above the horizon, and a sun ten degrees *down* is precisely the
* dusk geometry that lights a Starlink pass.
*/
const when = skyOverride ?? new Date();
const fixes = options.satellites.fixes(when);
satelliteLayer.update(fixes);
starlinkMeshes?.update(
fixes,
kit.camera,
sunDirection(solarPosition(city.center.lat, city.center.lng, when)),
);
}
},
dispose() {
options.flights?.dispose?.();
flightLayer?.dispose();
satelliteLayer?.dispose();
starlinkMeshes?.dispose();
// Before the `scene.traverse` sweep below, and required rather than tidy:
// the sweep reaches geometries and materials, and a `ShaderMaterial`'s
// uniform textures are neither — the cloud texture is a canvas this layer
// drew and only it can free.
clouds.dispose();
nightLights.dispose();
markerLayer.dispose();
kit.dispose();
@@ -394,7 +454,12 @@ export async function createScene(
chapters: city.chapters,
stage,
stageScene,
setLighting: (state) => kit.applyLighting(state),
setLighting: (state) => {
kit.applyLighting(state);
clouds.setLighting(state);
},
setCloudCover: (fraction) => clouds.setCover(fraction),
setWind: (kph, fromDeg) => clouds.setWind(kph, fromDeg),
setSolarElevation: (degrees) => nightLights.setSolarElevation(degrees),
setSkyInstant: (when) => {
skyOverride = when;