feat: real fire on the boards, the LA office as a twin, and a night sky worth reading
The world stops being a simulation of California and starts being California. **THE PROMOTION GATE WAS THE FIRST COMMIT, BEFORE ANY ORANGE PIXEL EXISTED.** On today's live store the SoCal board contains 22 incidents. Every one has NULL acreage and fifteen are nameless LA County dispatch numbers. Drawn naively that is 22 orange marks over Los Angeles on a day nothing is burning — in a frame that contains no other warm colour, so one glyph would be the most salient object on the board and twenty-two would spend its credibility permanently. `acres >= 10 AND contained < 80 AND type != 'RX' AND last_seen = max(last_seen)` returns 0 on SoCal, exactly 5 on California, 0 on the Bay — same body, same day, three correct answers. The empty board is a deliverable, not a fallback: it says "No active fire on this board — CAL FIRE and WFIGS, just now", states that 21 records were gated and why, lists the largest fires burning OUTSIDE the frame with distances, and counts the hot pixels it is deliberately not drawing. **The privacy leak is structurally impossible rather than carefully avoided.** cloud-1 serves a projection; the four home-relative columns never leave that box. `observations.threat` was the one that nearly got through — it is `(16/distance)^2 x log10(acres) x momentum x containment x wind-alignment`, so with acreage and containment public it inverts to a distance circle around a house and three fires give an intersection. A grep of the built bundle for distance_km, bearing_deg, threat, 7762 and the street name returns nothing. **Deliberately not used, and both would have produced a confident wrong answer:** the store's `air` table retains only the last parameter of each poll, so all 78 rows read "Good" while the live feed reports ozone 101 "Unhealthy for Sensitive Groups" — haze driven off it would clear the sky during a smoke event. And `weather` is written only inside the NWS alerts loop, so a quiet day stores no wind at all. Tera's own per-region NWS wind is already correct and already what the clouds drift on. Satellite detections are drawn as evidence and never as incidents. The permanent industrial heat source 4.7 km from the owner's house is flagged persistent and dropped, asserted by a test that first proves it is present in the fixture. MODIS integer confidence and VIIRS string confidence are branched on `sat`. **The LA office is a twin.** Its entire authored second storey — Model Loft, Model Bay, The Materials Room, 430 lines nobody had ever stood in — is reachable on foot: a walker crosses level-1 to level-2 in 73 fixed steps, floorY 0 to 5, verified against the real pack rather than a synthetic plan. Its two studio devices read real hardware through a field-allowlisted bridge: mute, volume and reachability only. Never level, because there is no passive level upstream and obtaining one would record a room with people in it. Never dB, because upstream is gainPct across four different native scales. The bridge refuses all writes. Fixed at its root: an anonymous visitor was getting permanently at-rest instruments backing off against a 401. The tier moves into `createDeviceSource`, so anon gets the living simulator three file headers already promised. **Item 8 is closed, not fixed, and the correction is the point.** The Bay Area "stutter" was GPU power management — the card sat at 500 MHz of 2725 through every run that reproduced it, 4096/2048/1024/256 shadow maps all render in 1.21-1.31 ms, and two consecutive runs over a byte-identical dist gave 33.4 then 16.7. The allowance is removed and the cell is back to 16.7. Geometry is the gate; frame time is advisory. Item 7 was re-scoped after measuring: 1,069,006 of the Bay Area's 2,265,056 triangles were the second submission of the same buildings into the shadow pass. Mobile now has its own triangle caps and bay-area mobile draws 1,266,096. Also: bridges and the freeway corridor light up at night as emission, not lights — 1,614 deck lamps and 18 tower heads on the Bay in two draw calls. The single change that made US-101 legible was moving its edge lines from the lit material to the unlit one: retroreflective paint, the argument the SFO night frame already makes. California went 21,991 lamps to 4,051, clustered at the 17 town districts, because a rural interurban corridor genuinely is unlit. Tests 1137 -> 1340, server 280. All ten budget cells pass on first attempt with no cap raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* The plume layer, held to the two properties that make it honest and the one
|
||||
* that makes it cheap.
|
||||
*
|
||||
* **Plume count costs no draw calls.** Eight plumes and one plume are the same
|
||||
* single `THREE.Mesh`; the only thing that moves is `instanceCount`. That is the
|
||||
* whole reason a plume is affordable at all, and it is easy to lose to a
|
||||
* well-meaning refactor that gives each fire its own mesh "for clarity".
|
||||
*
|
||||
* **The wind blows the right way.** `fromDeg` is the bearing the wind comes
|
||||
* *from*, so smoke travels toward `fromDeg + 180`, and scene north is −Z with
|
||||
* +X east. Getting that wrong points every plume into the wind, which looks
|
||||
* completely plausible on a still frame and is wrong on every one of them. The
|
||||
* assertion below is on the uniform, because there is no picture that catches it.
|
||||
*
|
||||
* **An empty layer is not visited.** `visible = false` rather than an
|
||||
* instance count of zero, because a mesh the renderer walks is a draw call paid
|
||||
* on a board where nothing is happening — which is most boards, most days.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createFireSmoke, MAX_PLUMES, type SmokePlume } from "../../engine/fireSmoke.ts";
|
||||
import type { LightingState } from "../../engine/types.ts";
|
||||
|
||||
const SPAN = 428;
|
||||
|
||||
function plume(id: string, over: Partial<SmokePlume> = {}): SmokePlume {
|
||||
return { id, x: 0, y: 0, z: 0, length: 12, width: 1.2, density: 0.5, lift: 0, ...over };
|
||||
}
|
||||
|
||||
/** The default layer: full density, so the instance counts are predictable. */
|
||||
function layer() {
|
||||
return createFireSmoke({ span: SPAN, density: 1 });
|
||||
}
|
||||
|
||||
function mesh(root: THREE.Object3D): THREE.Mesh {
|
||||
const found = root.getObjectByName("fire-smoke-puffs");
|
||||
assert.ok(found, "the puff mesh must exist");
|
||||
return found as THREE.Mesh;
|
||||
}
|
||||
|
||||
const NOON: LightingState = {
|
||||
sun: { direction: [0, 1, 0], color: 0xfff2e0, intensity: 2.6 },
|
||||
hemisphere: { sky: 0x8fb6e8, ground: 0x6b6153, intensity: 0.6 },
|
||||
ambient: { color: 0xffffff, intensity: 0.2 },
|
||||
sky: { top: 0x3f7fd0, horizon: 0xcfe0f2 },
|
||||
fog: { color: 0xc9d8e8, near: 400, far: 1600 },
|
||||
};
|
||||
|
||||
describe("the smoke layer", () => {
|
||||
it("is one mesh whatever the plume count", () => {
|
||||
const smoke = layer();
|
||||
const meshes: string[] = [];
|
||||
smoke.group.traverse((object) => {
|
||||
if ((object as THREE.Mesh).isMesh === true) meshes.push(object.name);
|
||||
});
|
||||
assert.deepEqual(meshes, ["fire-smoke-puffs"]);
|
||||
|
||||
smoke.setPlumes([plume("a")]);
|
||||
const one = (mesh(smoke.group).geometry as THREE.InstancedBufferGeometry).instanceCount;
|
||||
smoke.setPlumes([plume("a"), plume("b"), plume("c")]);
|
||||
const three = (mesh(smoke.group).geometry as THREE.InstancedBufferGeometry).instanceCount;
|
||||
|
||||
assert.equal(three, one * 3, "instances scale with plumes");
|
||||
const stillOne: string[] = [];
|
||||
smoke.group.traverse((object) => {
|
||||
if ((object as THREE.Mesh).isMesh === true) stillOne.push(object.name);
|
||||
});
|
||||
assert.deepEqual(stillOne, ["fire-smoke-puffs"], "and objects do not");
|
||||
smoke.dispose();
|
||||
});
|
||||
|
||||
it("is invisible with no plumes rather than empty and visited", () => {
|
||||
const smoke = layer();
|
||||
assert.equal(mesh(smoke.group).visible, false);
|
||||
smoke.setPlumes([plume("a")]);
|
||||
assert.equal(mesh(smoke.group).visible, true);
|
||||
smoke.setPlumes([]);
|
||||
assert.equal(mesh(smoke.group).visible, false);
|
||||
assert.equal(smoke.plumeCount(), 0);
|
||||
smoke.dispose();
|
||||
});
|
||||
|
||||
it("caps at MAX_PLUMES and keeps the ones it was handed first", () => {
|
||||
const smoke = layer();
|
||||
smoke.setPlumes(Array.from({ length: 40 }, (_, i) => plume(`f${i}`)));
|
||||
assert.equal(smoke.plumeCount(), MAX_PLUMES);
|
||||
smoke.dispose();
|
||||
});
|
||||
|
||||
it("respects a smaller cap and a lower density", () => {
|
||||
const smoke = createFireSmoke({ span: SPAN, maxPlumes: 2, puffsPerPlume: 20, density: 1 });
|
||||
smoke.setPlumes([plume("a"), plume("b"), plume("c")]);
|
||||
assert.equal(smoke.plumeCount(), 2);
|
||||
assert.equal((mesh(smoke.group).geometry as THREE.InstancedBufferGeometry).instanceCount, 40);
|
||||
smoke.dispose();
|
||||
});
|
||||
|
||||
it("blows downwind, not upwind", () => {
|
||||
const smoke = layer();
|
||||
const material = mesh(smoke.group).material as THREE.ShaderMaterial;
|
||||
const wind = material.uniforms.uWind?.value as THREE.Vector2;
|
||||
|
||||
// Wind *from* the north travels south, and scene south is +Z.
|
||||
smoke.setWind(20, 0);
|
||||
assert.ok(Math.abs(wind.x) < 1e-6);
|
||||
assert.ok(wind.y > 0.99, `north wind must travel +Z, got ${wind.y}`);
|
||||
|
||||
// Wind *from* the west travels east, and scene east is +X.
|
||||
smoke.setWind(20, 270);
|
||||
assert.ok(wind.x > 0.99, `west wind must travel +X, got ${wind.x}`);
|
||||
assert.ok(Math.abs(wind.y) < 1e-6);
|
||||
smoke.dispose();
|
||||
});
|
||||
|
||||
it("falls back to a light breeze rather than standing still", () => {
|
||||
const smoke = layer();
|
||||
const material = mesh(smoke.group).material as THREE.ShaderMaterial;
|
||||
const wind = material.uniforms.uWind?.value as THREE.Vector2;
|
||||
smoke.setWind(null, null);
|
||||
assert.ok(Math.hypot(wind.x, wind.y) > 0.99, "a null wind must still have a direction");
|
||||
smoke.setWind(Number.NaN, Number.NaN);
|
||||
assert.ok(Number.isFinite(wind.x) && Number.isFinite(wind.y));
|
||||
smoke.dispose();
|
||||
});
|
||||
|
||||
it("takes the fog and the key off a rig it did not compute", () => {
|
||||
const smoke = layer();
|
||||
const material = mesh(smoke.group).material as THREE.ShaderMaterial;
|
||||
smoke.setLighting(NOON);
|
||||
assert.equal(material.uniforms.uFogNear?.value, 400);
|
||||
assert.equal(material.uniforms.uFogFar?.value, 1600);
|
||||
assert.ok((material.uniforms.uKey?.value as number) > 0.5);
|
||||
|
||||
const dusk: LightingState = { ...NOON, sun: { ...NOON.sun, intensity: 0.2 } };
|
||||
smoke.setLighting(dusk);
|
||||
assert.ok((material.uniforms.uKey?.value as number) < 0.2);
|
||||
smoke.dispose();
|
||||
});
|
||||
|
||||
it("constructs no light — CONTRACT.md §4", () => {
|
||||
const smoke = layer();
|
||||
smoke.setPlumes([plume("a")]);
|
||||
smoke.setLighting(NOON);
|
||||
const lights: string[] = [];
|
||||
smoke.group.traverse((object) => {
|
||||
if ((object as THREE.Light).isLight === true) lights.push(object.type);
|
||||
});
|
||||
assert.deepEqual(lights, []);
|
||||
smoke.dispose();
|
||||
});
|
||||
|
||||
it("writes each plume's own shape into its own instances", () => {
|
||||
const smoke = createFireSmoke({ span: SPAN, maxPlumes: 3, puffsPerPlume: 10, density: 1 });
|
||||
smoke.setPlumes([
|
||||
plume("a", { x: 5, y: 1, z: -7, length: 30, width: 2, density: 0.7, lift: 0.9 }),
|
||||
plume("b", { x: -11, y: 2, z: 3, length: 6, width: 0.5, density: 0.3, lift: 0 }),
|
||||
]);
|
||||
const geometry = mesh(smoke.group).geometry as THREE.InstancedBufferGeometry;
|
||||
const origin = geometry.getAttribute("iOrigin");
|
||||
const shape = geometry.getAttribute("iShape");
|
||||
|
||||
assert.equal(origin.getX(0), 5);
|
||||
assert.equal(origin.getZ(9), -7);
|
||||
assert.equal(shape.getX(0), 30);
|
||||
assert.ok(Math.abs(shape.getW(0) - 0.9) < 1e-6);
|
||||
|
||||
assert.equal(origin.getX(10), -11);
|
||||
assert.equal(shape.getX(19), 6);
|
||||
assert.equal(shape.getW(19), 0);
|
||||
smoke.dispose();
|
||||
});
|
||||
|
||||
it("clamps a density and a lift that arrived out of range", () => {
|
||||
const smoke = createFireSmoke({ span: SPAN, maxPlumes: 1, puffsPerPlume: 4, density: 1 });
|
||||
smoke.setPlumes([plume("a", { density: 9, lift: -3 })]);
|
||||
const shape = (mesh(smoke.group).geometry as THREE.InstancedBufferGeometry).getAttribute(
|
||||
"iShape",
|
||||
);
|
||||
assert.equal(shape.getZ(0), 1);
|
||||
assert.equal(shape.getW(0), 0);
|
||||
smoke.dispose();
|
||||
});
|
||||
|
||||
it("stays hidden while it is switched off, however many plumes arrive", () => {
|
||||
const smoke = layer();
|
||||
smoke.setVisible(false);
|
||||
smoke.setPlumes([plume("a"), plume("b")]);
|
||||
assert.equal(mesh(smoke.group).visible, false);
|
||||
smoke.setVisible(true);
|
||||
assert.equal(mesh(smoke.group).visible, true);
|
||||
smoke.dispose();
|
||||
});
|
||||
|
||||
it("advances its own clock and never on a bad delta", () => {
|
||||
const smoke = layer();
|
||||
const material = mesh(smoke.group).material as THREE.ShaderMaterial;
|
||||
smoke.tick(0.5);
|
||||
const after = material.uniforms.uTime?.value as number;
|
||||
assert.ok(after > 0);
|
||||
smoke.tick(Number.NaN);
|
||||
assert.equal(material.uniforms.uTime?.value, after, "a NaN delta must not poison the clock");
|
||||
smoke.dispose();
|
||||
});
|
||||
|
||||
it("casts no shadow and is never frustum culled", () => {
|
||||
const smoke = layer();
|
||||
const puffs = mesh(smoke.group);
|
||||
// The bounding sphere is the unit quad's, so the CPU thinks this object is
|
||||
// two units across; culling on it would cull the plume from almost anywhere.
|
||||
assert.equal(puffs.frustumCulled, false);
|
||||
assert.equal(puffs.castShadow, false);
|
||||
assert.equal(puffs.receiveShadow, false);
|
||||
smoke.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,672 @@
|
||||
/**
|
||||
* The fire layer, held to the four things a screenshot cannot check.
|
||||
*
|
||||
* **It draws nothing on a quiet board.** This is the whole round in one
|
||||
* assertion. The fixture is a real body captured through the whole wire on an
|
||||
* ordinary day: clipped to the SoCal board it contains twenty-two live incident
|
||||
* records, every single one with `acres: null`, fifteen of them nameless LA
|
||||
* County dispatch numbers. The correct picture is an empty one, and "empty"
|
||||
* means `instanceCount === 0` and a mesh the renderer never visits — not a mesh
|
||||
* drawing twenty-two zero-sized glyphs.
|
||||
*
|
||||
* **It constructs no light.** CONTRACT.md §4 gives `Atmosphere` sole ownership
|
||||
* of the rig, and a wildfire is the most tempting exception in the codebase. The
|
||||
* build spec's `grep` catches the letter; this catches the spirit, by walking
|
||||
* the whole subtree and asserting nothing in it is a `THREE.Light`.
|
||||
*
|
||||
* **It reads two confidence scales out of one column.** MODIS publishes an
|
||||
* integer 0–100 and VIIRS publishes `low`/`nominal`/`high` in the same field. A
|
||||
* renderer that maps the raw value to an opacity is wrong for one of the two on
|
||||
* every frame, and wrong in the direction that puts `NaN` in an alpha.
|
||||
*
|
||||
* **Momentum brightens and lengthens a fire that was already drawn, and never
|
||||
* draws one.** Across all 665 observations in the upstream store's life not one
|
||||
* incident has ever recorded two different acreage values, so this code path's
|
||||
* first real execution will be in production during a fire. It is therefore
|
||||
* asserted against a synthetic series here or it is not shipped.
|
||||
*
|
||||
* The world below is a real board projection rather than a tidy 1:1 fake —
|
||||
* California's `latScale: 58`, which puts one scene unit at 1,919 m. A 1:1 fake
|
||||
* would pass while every plume was a thousand times too long.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import {
|
||||
createFireLayer,
|
||||
createMomentumTracker,
|
||||
extentRadiusKm,
|
||||
fireHeat,
|
||||
growthPerHour,
|
||||
hotPixelStyle,
|
||||
markGlow,
|
||||
momentumOf,
|
||||
plumeLengthKm,
|
||||
plumeWidthKm,
|
||||
smokeLoad,
|
||||
type DrawnFireMark,
|
||||
type FireView,
|
||||
} from "../../engine/fires.ts";
|
||||
import { promote, type FirePromotion } from "../../server/fires.ts";
|
||||
import type { FireLayerFactory, FireView as SceneFireView } from "../../engine/scene.ts";
|
||||
import { LIVE_FIRES_BODY } from "../data/firesFixture.ts";
|
||||
import type { World } from "../../engine/world.ts";
|
||||
|
||||
// ---- The boards, exactly as the packs declare them -------------------------
|
||||
|
||||
const CALIFORNIA = { minLat: 32.55, maxLat: 38.05, minLng: -123.05, maxLng: -114.0 };
|
||||
const SOCAL = { minLat: 33.28, maxLat: 34.36, minLng: -118.88, maxLng: -117.22 };
|
||||
|
||||
/** `california.ts`: centre 35.3/-118.55, `latScale: 58` — 1,919 m to the unit. */
|
||||
function board(
|
||||
latScale: number,
|
||||
centre: { lat: number; lng: number },
|
||||
ground: (lat: number, lng: number) => number = () => 0,
|
||||
): World {
|
||||
const lngScale = latScale * Math.cos((centre.lat * Math.PI) / 180);
|
||||
return {
|
||||
project(lat: number, lng: number): [number, number] {
|
||||
return [(lng - centre.lng) * lngScale, -(lat - centre.lat) * latScale];
|
||||
},
|
||||
groundAt: ground,
|
||||
metresPerUnit: 111_320 / latScale,
|
||||
metres(value: number): number {
|
||||
return value / (111_320 / latScale);
|
||||
},
|
||||
} as unknown as World;
|
||||
}
|
||||
|
||||
const californiaWorld = () => board(58, { lat: 35.3, lng: -118.55 });
|
||||
const socalWorld = () => board(285, { lat: 33.82, lng: -118.05 });
|
||||
|
||||
/** The larger projected extent of the pack's bounds — what `scene.ts` passes. */
|
||||
const CALIFORNIA_SPAN = 428;
|
||||
const SOCAL_SPAN = 393;
|
||||
|
||||
const NOW = Date.parse("2026-08-22T22:26:00Z");
|
||||
|
||||
function promoteFor(bounds: typeof CALIFORNIA): FirePromotion {
|
||||
return promote(LIVE_FIRES_BODY, bounds, NOW);
|
||||
}
|
||||
|
||||
/**
|
||||
* `FirePromotion` must flow into the layer with no adapter at all. Asserted at
|
||||
* compile time, which is the only place it can be: if `promote()` ever renames a
|
||||
* field the renderer reads, this line stops the build instead of quietly
|
||||
* drawing an empty board.
|
||||
*/
|
||||
const _assignable: (p: FirePromotion) => FireView = (p) => p;
|
||||
void _assignable;
|
||||
|
||||
/**
|
||||
* And `createFireLayer` must be a `FireLayerFactory` — the one seam `scene.ts`
|
||||
* constructs through.
|
||||
*
|
||||
* A type-only import, so nothing about `scene.ts` is pulled into this test at
|
||||
* runtime, and `engine/fires.ts` still imports nothing from it. That is the
|
||||
* point of the arrangement: the two modules each declare their own copy of the
|
||||
* shape and are checked against each other *here* and at `SceneOptions.fires`,
|
||||
* rather than one of them depending on the other. Without this line the first
|
||||
* time anyone found out the two had drifted would be the moment WS5 wired them.
|
||||
*/
|
||||
const _factory: FireLayerFactory = createFireLayer;
|
||||
void _factory;
|
||||
|
||||
/** And the view `scene.ts` forwards must be one this layer accepts. */
|
||||
const _viewIn: (v: SceneFireView) => FireView = (v) => v;
|
||||
void _viewIn;
|
||||
|
||||
function meshNamed(root: THREE.Object3D, name: string): THREE.Mesh | THREE.Points {
|
||||
const found = root.getObjectByName(name);
|
||||
assert.ok(found, `no object named ${name}`);
|
||||
return found as THREE.Mesh | THREE.Points;
|
||||
}
|
||||
|
||||
function instanceCount(root: THREE.Object3D, name: string): number {
|
||||
const mesh = meshNamed(root, name);
|
||||
const geometry = mesh.geometry as THREE.InstancedBufferGeometry;
|
||||
return geometry.instanceCount;
|
||||
}
|
||||
|
||||
// ---- The quiet board ------------------------------------------------------
|
||||
|
||||
describe("the fire layer on a quiet board", () => {
|
||||
it("draws nothing at all for twenty-two live records with no acreage", () => {
|
||||
const gated = promoteFor(SOCAL);
|
||||
// The fixture is the claim: this is a real day, not a constructed one.
|
||||
assert.equal(gated.drawn.length, 0, "the gate must refuse every SoCal row today");
|
||||
assert.equal(gated.suppressed, 22, "and there must be twenty-two of them to refuse");
|
||||
|
||||
const layer = createFireLayer(socalWorld(), { span: SOCAL_SPAN, reducedMotion: true });
|
||||
layer.setFires(gated);
|
||||
|
||||
assert.equal(layer.markCount(), 0);
|
||||
assert.equal(layer.plumeCount(), 0);
|
||||
assert.equal(instanceCount(layer.group, "fire-marks"), 0);
|
||||
// Invisible, not merely empty. An empty-but-visible mesh is a draw call the
|
||||
// renderer still pays for on a board where nothing is happening.
|
||||
assert.equal(meshNamed(layer.group, "fire-marks").visible, false);
|
||||
assert.equal(meshNamed(layer.group, "fire-smoke-puffs").visible, false);
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it("clears back to empty when the feed goes away", () => {
|
||||
const layer = createFireLayer(californiaWorld(), {
|
||||
span: CALIFORNIA_SPAN,
|
||||
reducedMotion: true,
|
||||
});
|
||||
layer.setFires(promoteFor(CALIFORNIA));
|
||||
assert.ok(layer.markCount() > 0, "California must draw something to begin with");
|
||||
layer.setFires(null);
|
||||
assert.equal(layer.markCount(), 0);
|
||||
assert.equal(layer.detectionCount(), 0);
|
||||
assert.equal(meshNamed(layer.group, "fire-marks").visible, false);
|
||||
assert.equal(meshNamed(layer.group, "fire-hot-pixels").visible, false);
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it("survives a null, an undefined and a body full of nonsense", () => {
|
||||
const layer = createFireLayer(socalWorld(), { span: SOCAL_SPAN, reducedMotion: true });
|
||||
layer.setFires(null);
|
||||
layer.setFires(undefined as unknown as FireView);
|
||||
layer.setFires({
|
||||
drawn: [null, { id: "x" }] as unknown as readonly DrawnFireMark[],
|
||||
detections: [null, { sat: "MODIS" }] as unknown as FireView["detections"],
|
||||
fetchedAt: "not a date",
|
||||
ageMs: null,
|
||||
});
|
||||
// The consumer is a render loop. A throw here is a black page, so the only
|
||||
// acceptable behaviour for a shape we did not build is to draw less.
|
||||
assert.ok(layer.markCount() <= 1);
|
||||
layer.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- The truthful full board ----------------------------------------------
|
||||
|
||||
describe("the fire layer on the California board", () => {
|
||||
it("draws exactly the five fires the gate admitted, worst first", () => {
|
||||
const gated = promoteFor(CALIFORNIA);
|
||||
assert.deepEqual(
|
||||
gated.drawn.map((fire) => fire.name),
|
||||
["Timber Fire", "Alpaugh Fire", "Carrizo Fire", "Amber Fire", "GREEN"],
|
||||
);
|
||||
|
||||
const layer = createFireLayer(californiaWorld(), {
|
||||
span: CALIFORNIA_SPAN,
|
||||
reducedMotion: true,
|
||||
});
|
||||
layer.setFires(gated);
|
||||
|
||||
assert.equal(layer.markCount(), 5);
|
||||
assert.equal(instanceCount(layer.group, "fire-marks"), 5);
|
||||
assert.equal(meshNamed(layer.group, "fire-marks").visible, true);
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it("gives a plume to every tier-2 fire and to no tier-1 fire", () => {
|
||||
const gated = promoteFor(CALIFORNIA);
|
||||
const tier2 = gated.drawn.filter((fire) => fire.tier === 2);
|
||||
assert.deepEqual(
|
||||
tier2.map((fire) => fire.name),
|
||||
["Timber Fire", "Alpaugh Fire", "Carrizo Fire"],
|
||||
"tier is `promote()`'s decision and this layer must not re-derive it",
|
||||
);
|
||||
|
||||
const layer = createFireLayer(californiaWorld(), {
|
||||
span: CALIFORNIA_SPAN,
|
||||
reducedMotion: true,
|
||||
});
|
||||
layer.setFires(gated);
|
||||
assert.equal(layer.plumeCount(), 3);
|
||||
|
||||
for (const fire of gated.drawn) {
|
||||
const state = layer.inspect(fire.id);
|
||||
assert.ok(state, `${fire.name} must be inspectable`);
|
||||
if (fire.tier === 2) assert.ok(state.plumeUnits > 0, `${fire.name} must have a plume`);
|
||||
else assert.equal(state.plumeUnits, 0, `${fire.name} must not have a plume`);
|
||||
}
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it("gives the biggest fire the longest plume, and caps it", () => {
|
||||
const layer = createFireLayer(californiaWorld(), {
|
||||
span: CALIFORNIA_SPAN,
|
||||
reducedMotion: true,
|
||||
});
|
||||
const gated = promoteFor(CALIFORNIA);
|
||||
layer.setFires(gated);
|
||||
|
||||
const timber = layer.inspect(gated.drawn[0]?.id ?? "");
|
||||
const carrizo = layer.inspect(gated.drawn[2]?.id ?? "");
|
||||
assert.ok(timber && carrizo);
|
||||
assert.ok(timber.plumeUnits > carrizo.plumeUnits);
|
||||
|
||||
// 40 km is the hard cap, and California's board is 1,919 m to the unit.
|
||||
assert.ok(timber.plumeUnits <= (40 * 1000) / (111_320 / 58) + 1e-6);
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it("draws the hot pixels the gate handed it, and not one more", () => {
|
||||
const gated = promoteFor(CALIFORNIA);
|
||||
assert.ok(gated.detections.length > 0);
|
||||
assert.ok(gated.persistentDetections > 0, "the fixture must contain known furniture");
|
||||
|
||||
const layer = createFireLayer(californiaWorld(), {
|
||||
span: CALIFORNIA_SPAN,
|
||||
reducedMotion: true,
|
||||
});
|
||||
layer.setFires(gated);
|
||||
assert.equal(layer.detectionCount(), gated.detections.length);
|
||||
assert.equal(meshNamed(layer.group, "fire-hot-pixels").visible, true);
|
||||
layer.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- CONTRACT §4 ----------------------------------------------------------
|
||||
|
||||
describe("the fire layer and the light rig", () => {
|
||||
it("constructs no THREE.Light anywhere in its subtree", () => {
|
||||
const layer = createFireLayer(californiaWorld(), {
|
||||
span: CALIFORNIA_SPAN,
|
||||
reducedMotion: true,
|
||||
});
|
||||
layer.setFires(promoteFor(CALIFORNIA));
|
||||
layer.setSolarElevation(-12);
|
||||
|
||||
const lights: string[] = [];
|
||||
layer.group.traverse((object) => {
|
||||
if ((object as THREE.Light).isLight === true) lights.push(object.type);
|
||||
});
|
||||
assert.deepEqual(lights, [], "Atmosphere is the sole light owner — CONTRACT.md §4");
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it("takes night from the solar elevation seam and nothing else", () => {
|
||||
const layer = createFireLayer(californiaWorld(), {
|
||||
span: CALIFORNIA_SPAN,
|
||||
reducedMotion: true,
|
||||
});
|
||||
const material = (meshNamed(layer.group, "fire-marks") as THREE.Mesh)
|
||||
.material as THREE.ShaderMaterial;
|
||||
|
||||
layer.setSolarElevation(40);
|
||||
assert.equal(material.uniforms.uNight?.value, 0, "noon is not night");
|
||||
layer.setSolarElevation(-12);
|
||||
assert.equal(material.uniforms.uNight?.value, 1, "well after sunset is fully night");
|
||||
layer.setSolarElevation(Number.NaN);
|
||||
assert.equal(material.uniforms.uNight?.value, 1, "a bad number must change nothing");
|
||||
layer.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Hot pixels are evidence ----------------------------------------------
|
||||
|
||||
describe("hot-pixel styling", () => {
|
||||
const base = { lat: 36.2, lon: -121.7, acquiredAt: "2026-08-22T21:00:00Z", frp: 12 };
|
||||
|
||||
it("reads VIIRS confidence off the string scale", () => {
|
||||
const low = hotPixelStyle({ ...base, sat: "VIIRS-NOAA20", confidence: "low", persistent: false });
|
||||
const nominal = hotPixelStyle({
|
||||
...base,
|
||||
sat: "VIIRS-SNPP",
|
||||
confidence: "nominal",
|
||||
persistent: false,
|
||||
});
|
||||
const high = hotPixelStyle({ ...base, sat: "VIIRS-NOAA20", confidence: "high", persistent: false });
|
||||
|
||||
assert.ok(low.opacity < nominal.opacity);
|
||||
assert.ok(nominal.opacity < high.opacity);
|
||||
// The failure this exists to stop: `Number("nominal")` is NaN, and NaN in an
|
||||
// alpha is a hole in the frame rather than a dim dot.
|
||||
for (const style of [low, nominal, high]) {
|
||||
assert.ok(Number.isFinite(style.opacity), "a VIIRS string must never become NaN");
|
||||
}
|
||||
});
|
||||
|
||||
it("reads MODIS confidence off the 0-100 integer scale", () => {
|
||||
const weak = hotPixelStyle({ ...base, sat: "MODIS", confidence: "26", persistent: false });
|
||||
const strong = hotPixelStyle({ ...base, sat: "MODIS", confidence: "100", persistent: false });
|
||||
assert.ok(weak.opacity < strong.opacity);
|
||||
|
||||
// The same literal means opposite things on the two instruments, which is
|
||||
// the entire reason the branch exists: "100" is full confidence on MODIS and
|
||||
// is not a VIIRS value at all.
|
||||
const viirs = hotPixelStyle({ ...base, sat: "VIIRS-SNPP", confidence: "100", persistent: false });
|
||||
assert.notEqual(viirs.opacity, strong.opacity);
|
||||
});
|
||||
|
||||
it("sizes by fire radiative power, so a cluster has structure in it", () => {
|
||||
const faint = hotPixelStyle({ ...base, frp: 1, sat: "MODIS", confidence: "80", persistent: false });
|
||||
const fierce = hotPixelStyle({
|
||||
...base,
|
||||
frp: 112.9,
|
||||
sat: "MODIS",
|
||||
confidence: "80",
|
||||
persistent: false,
|
||||
});
|
||||
assert.ok(fierce.size > faint.size);
|
||||
});
|
||||
|
||||
it("visibly demotes a persistent source rather than dropping it", () => {
|
||||
const fresh = hotPixelStyle({
|
||||
...base,
|
||||
frp: 1.0,
|
||||
sat: "VIIRS-NOAA20",
|
||||
confidence: "nominal",
|
||||
persistent: false,
|
||||
});
|
||||
// The industrial heat source 4.7 km from the upstream operator's house: FRP
|
||||
// ~1.0, "nominal", on every pass on every day the store holds, with no
|
||||
// incident behind it.
|
||||
const furniture = hotPixelStyle({
|
||||
...base,
|
||||
frp: 1.0,
|
||||
sat: "VIIRS-NOAA20",
|
||||
confidence: "nominal",
|
||||
persistent: true,
|
||||
});
|
||||
|
||||
assert.ok(furniture.opacity < fresh.opacity * 0.5, "a flare stack must not read as a fire");
|
||||
assert.ok(furniture.size < fresh.size);
|
||||
assert.notEqual(furniture.color, fresh.color);
|
||||
assert.ok(furniture.opacity > 0, "counted and drawn faintly, never silently dropped");
|
||||
});
|
||||
|
||||
it("survives a missing confidence and a missing FRP", () => {
|
||||
const style = hotPixelStyle({ ...base, frp: null, sat: "MODIS", confidence: null, persistent: false });
|
||||
assert.ok(Number.isFinite(style.opacity) && style.opacity > 0);
|
||||
assert.ok(Number.isFinite(style.size) && style.size > 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Momentum -------------------------------------------------------------
|
||||
|
||||
describe("momentum", () => {
|
||||
it("is zero for a fire that has never restated its acreage", () => {
|
||||
// Which is every fire the upstream store has ever held: across all 665
|
||||
// observations in its life, no incident has recorded two different acreages.
|
||||
const tracker = createMomentumTracker();
|
||||
for (let i = 0; i < 6; i++) tracker.observe("f", NOW + i * 600_000, 7591);
|
||||
assert.equal(tracker.rateFor("f"), 0);
|
||||
assert.equal(momentumOf(tracker.rateFor("f")), 0);
|
||||
});
|
||||
|
||||
it("ignores a repeated observation, so one restatement is not a spike", () => {
|
||||
const tracker = createMomentumTracker();
|
||||
tracker.observe("f", NOW, 100);
|
||||
for (let i = 0; i < 20; i++) tracker.observe("f", NOW, 100);
|
||||
tracker.observe("f", NOW + 3_600_000, 150);
|
||||
assert.equal(tracker.samples("f").length, 2);
|
||||
assert.ok(Math.abs(tracker.rateFor("f") - 0.5) < 1e-9, "50 % in an hour is a rate of 0.5");
|
||||
});
|
||||
|
||||
it("forgets a fire the gate has dropped", () => {
|
||||
const tracker = createMomentumTracker();
|
||||
tracker.observe("a", NOW, 100);
|
||||
tracker.observe("b", NOW, 100);
|
||||
tracker.retain(["a"]);
|
||||
assert.equal(tracker.samples("a").length, 1);
|
||||
assert.equal(tracker.samples("b").length, 0);
|
||||
});
|
||||
|
||||
it("brightens and lengthens without changing the drawn set", () => {
|
||||
/**
|
||||
* Six samples over five hours, four times, each ending at **the same**
|
||||
* acreage and starting lower. So the only thing that differs between the
|
||||
* runs is the growth *rate* — the acreage the plume is sized from is
|
||||
* identical, which is what makes this a test of momentum rather than of
|
||||
* arithmetic on `acres`.
|
||||
*/
|
||||
const finalAcres = 500;
|
||||
const starts = [500, 460, 380, 240];
|
||||
const world = californiaWorld();
|
||||
const lengths: number[] = [];
|
||||
const glows: number[] = [];
|
||||
const drawnSets: string[][] = [];
|
||||
|
||||
for (const start of starts) {
|
||||
const layer = createFireLayer(world, { span: CALIFORNIA_SPAN, reducedMotion: true });
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const acres = start + ((finalAcres - start) * i) / 5;
|
||||
const at = new Date(NOW + i * 3_600_000).toISOString();
|
||||
layer.setFires({
|
||||
drawn: [
|
||||
{
|
||||
id: "synthetic",
|
||||
name: "Synthetic Fire",
|
||||
lat: 36.0,
|
||||
lon: -120.0,
|
||||
acres,
|
||||
pctContained: 20,
|
||||
tier: 2,
|
||||
observedAt: at,
|
||||
},
|
||||
],
|
||||
detections: [],
|
||||
fetchedAt: at,
|
||||
ageMs: 0,
|
||||
});
|
||||
}
|
||||
const state = layer.inspect("synthetic");
|
||||
assert.ok(state, "the synthetic fire must be drawn in every run");
|
||||
lengths.push(state.plumeUnits);
|
||||
glows.push(markGlow(state.heat, state.momentum, 1));
|
||||
drawnSets.push([...Array(layer.markCount()).keys()].map(() => "synthetic"));
|
||||
assert.equal(layer.plumeCount(), 1);
|
||||
layer.dispose();
|
||||
}
|
||||
|
||||
for (let i = 1; i < lengths.length; i++) {
|
||||
assert.ok(
|
||||
(lengths[i] ?? 0) > (lengths[i - 1] ?? 0),
|
||||
`plume length must rise with growth rate (${lengths.join(", ")})`,
|
||||
);
|
||||
assert.ok(
|
||||
(glows[i] ?? 0) > (glows[i - 1] ?? 0),
|
||||
`emissive must rise with growth rate (${glows.join(", ")})`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
drawnSets[i],
|
||||
drawnSets[i - 1],
|
||||
"momentum modifies a fire that already passed the gate — it never promotes one",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("caps: a fire that trebles in an hour is not drawn twice as long", () => {
|
||||
assert.equal(momentumOf(0.35), 1);
|
||||
assert.equal(momentumOf(3.0), 1);
|
||||
assert.equal(momentumOf(-1), 0);
|
||||
assert.equal(momentumOf(Number.NaN), 0);
|
||||
});
|
||||
|
||||
it("refuses a series it cannot read", () => {
|
||||
assert.equal(growthPerHour([]), 0);
|
||||
assert.equal(growthPerHour([{ atMs: NOW, acres: 10 }]), 0);
|
||||
assert.equal(growthPerHour([{ atMs: NOW, acres: 0 }, { atMs: NOW + 3_600_000, acres: 10 }]), 0);
|
||||
assert.equal(growthPerHour([{ atMs: NOW, acres: 10 }, { atMs: NOW, acres: 20 }]), 0);
|
||||
// Shrinking is not negative momentum. An agency revising an estimate down is
|
||||
// not a fire going out, and a plume that got shorter would say it was.
|
||||
assert.equal(growthPerHour([{ atMs: NOW, acres: 20 }, { atMs: NOW + 3_600_000, acres: 10 }]), 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- The arithmetic -------------------------------------------------------
|
||||
|
||||
describe("fire arithmetic", () => {
|
||||
it("turns acreage into the radius of an equal-area disc", () => {
|
||||
// Bug Fire: 93,733 acres is 379 km², an 11 km radius, a disc 22 km across.
|
||||
assert.ok(Math.abs(extentRadiusKm(93_733) - 11.0) < 0.15);
|
||||
assert.ok(Math.abs(extentRadiusKm(10) - 0.113) < 0.005);
|
||||
assert.equal(extentRadiusKm(0), 0);
|
||||
assert.equal(extentRadiusKm(Number.NaN), 0);
|
||||
});
|
||||
|
||||
it("keeps a small fire's plume small", () => {
|
||||
// The design's own warning: "a 268-acre fire with a 40 km plume is a lie
|
||||
// told in a medium that reads as truthful".
|
||||
const carrizo = plumeLengthKm(268.1, 14);
|
||||
assert.ok(carrizo > 2 && carrizo < 8, `Carrizo got ${carrizo} km`);
|
||||
const timber = plumeLengthKm(7591, 14);
|
||||
assert.ok(timber > 20 && timber <= 40, `Timber got ${timber} km`);
|
||||
assert.equal(plumeLengthKm(1e9, 200), 40, "the cap is hard");
|
||||
assert.equal(plumeLengthKm(0, 14), 0);
|
||||
});
|
||||
|
||||
it("lengthens a plume in the wind and widens it with the fire", () => {
|
||||
assert.ok(plumeLengthKm(500, 40) > plumeLengthKm(500, 4));
|
||||
assert.ok(plumeWidthKm(7591) > plumeWidthKm(268));
|
||||
assert.ok(plumeWidthKm(1e9) <= 3.5);
|
||||
});
|
||||
|
||||
it("reads heat off acreage and containment, and never off nothing", () => {
|
||||
assert.ok(fireHeat(7591, 29) > fireHeat(268, 29));
|
||||
assert.ok(fireHeat(500, 0) > fireHeat(500, 70), "a fire being beaten is cooler");
|
||||
// `null` containment means "the agency has not said", which is not zero and
|
||||
// must not read as a fire nobody has touched.
|
||||
assert.equal(fireHeat(500, null), fireHeat(500, 0));
|
||||
for (const acres of [10, 100, 1000, 100_000]) {
|
||||
const heat = fireHeat(acres, 50);
|
||||
assert.ok(heat >= 0 && heat <= 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("mirrors the shader's glow term and rises with both inputs", () => {
|
||||
assert.ok(markGlow(1, 0, 1) > markGlow(0, 0, 1));
|
||||
assert.ok(markGlow(0.5, 1, 1) > markGlow(0.5, 0, 1));
|
||||
assert.ok(markGlow(0.5, 0, 1) > markGlow(0.5, 0, 0), "night is what makes it glow");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- The scalar the office reads ------------------------------------------
|
||||
|
||||
describe("smoke load", () => {
|
||||
const fire: DrawnFireMark = {
|
||||
id: "f",
|
||||
name: "Test",
|
||||
lat: 34.3,
|
||||
lon: -118.0,
|
||||
acres: 5000,
|
||||
pctContained: 10,
|
||||
tier: 2,
|
||||
observedAt: null,
|
||||
};
|
||||
|
||||
it("is zero with nothing burning", () => {
|
||||
assert.equal(smokeLoad([], 34.0, -118.2, 250), 0);
|
||||
});
|
||||
|
||||
it("is zero beyond the reach, however big the fire", () => {
|
||||
assert.equal(smokeLoad([{ ...fire, acres: 1e6 }], 40.0, -118.0, null), 0);
|
||||
});
|
||||
|
||||
it("is higher downwind than upwind of the same fire, at the same distance", () => {
|
||||
// Wind from the north (0°) blows smoke south, so a point south of the fire
|
||||
// is in it and a point the same distance north is not.
|
||||
const south = smokeLoad([fire], fire.lat - 0.3, fire.lon, 0);
|
||||
const north = smokeLoad([fire], fire.lat + 0.3, fire.lon, 0);
|
||||
assert.ok(south > north, `downwind ${south} must beat upwind ${north}`);
|
||||
assert.ok(north > 0, "an upwind floor exists because wind is a ten-minute average");
|
||||
});
|
||||
|
||||
it("falls off with distance and stays inside 0..1", () => {
|
||||
const near = smokeLoad([fire], fire.lat - 0.05, fire.lon, 0);
|
||||
const far = smokeLoad([fire], fire.lat - 0.6, fire.lon, 0);
|
||||
assert.ok(near > far);
|
||||
const many = smokeLoad(
|
||||
Array.from({ length: 12 }, (_, i) => ({ ...fire, id: `f${i}`, acres: 90_000 })),
|
||||
fire.lat - 0.05,
|
||||
fire.lon,
|
||||
0,
|
||||
);
|
||||
assert.ok(many <= 1 && many > 0.5);
|
||||
});
|
||||
|
||||
it("is what the layer reports, wind and all", () => {
|
||||
const layer = createFireLayer(socalWorld(), { span: SOCAL_SPAN, reducedMotion: true });
|
||||
layer.setWind(20, 0);
|
||||
layer.setFires({ drawn: [fire], detections: [], fetchedAt: "2026-08-22T22:20:00Z", ageMs: 0 });
|
||||
assert.ok(layer.smokeLoadAt(fire.lat - 0.3, fire.lon) > 0);
|
||||
layer.setFires(null);
|
||||
assert.equal(layer.smokeLoadAt(fire.lat - 0.3, fire.lon), 0);
|
||||
layer.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Cost -----------------------------------------------------------------
|
||||
|
||||
describe("what the fire layer costs", () => {
|
||||
it("is three objects, whatever is burning", () => {
|
||||
const layer = createFireLayer(californiaWorld(), {
|
||||
span: CALIFORNIA_SPAN,
|
||||
reducedMotion: true,
|
||||
});
|
||||
layer.setFires(promoteFor(CALIFORNIA));
|
||||
|
||||
const drawable: string[] = [];
|
||||
layer.group.traverse((object) => {
|
||||
if ((object as THREE.Mesh).isMesh === true || (object as THREE.Points).isPoints === true) {
|
||||
drawable.push(object.name);
|
||||
}
|
||||
});
|
||||
// One instanced mesh for the marks and their ground extent, one Points for
|
||||
// the hot pixels, one instanced mesh for every plume there will ever be.
|
||||
assert.deepEqual(drawable.sort(), ["fire-hot-pixels", "fire-marks", "fire-smoke-puffs"]);
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it("packs the ground extent into the mark's own geometry", () => {
|
||||
const layer = createFireLayer(californiaWorld(), {
|
||||
span: CALIFORNIA_SPAN,
|
||||
reducedMotion: true,
|
||||
});
|
||||
const geometry = meshNamed(layer.group, "fire-marks").geometry;
|
||||
const index = geometry.getIndex();
|
||||
assert.ok(index);
|
||||
// Six for the ember, two for the disc. Two triangles a fire and no second
|
||||
// draw call is the whole reason the extent can exist at all.
|
||||
assert.equal(index.count / 3, 8);
|
||||
assert.ok(geometry.getAttribute("aPart"), "the per-vertex part selector must be there");
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it("never exceeds its instance capacity, whatever an upstream sends", () => {
|
||||
const layer = createFireLayer(californiaWorld(), {
|
||||
span: CALIFORNIA_SPAN,
|
||||
maxFires: 8,
|
||||
maxDetections: 16,
|
||||
reducedMotion: true,
|
||||
});
|
||||
layer.setFires({
|
||||
drawn: Array.from({ length: 400 }, (_, i) => ({
|
||||
id: `f${i}`,
|
||||
name: null,
|
||||
lat: 35 + (i % 20) * 0.05,
|
||||
lon: -119 + (i % 17) * 0.05,
|
||||
acres: 200,
|
||||
pctContained: null,
|
||||
tier: 2 as const,
|
||||
observedAt: null,
|
||||
})),
|
||||
detections: Array.from({ length: 900 }, (_, i) => ({
|
||||
sat: "MODIS",
|
||||
lat: 35 + (i % 30) * 0.02,
|
||||
lon: -119 + (i % 23) * 0.02,
|
||||
frp: 5,
|
||||
confidence: "60",
|
||||
persistent: false,
|
||||
acquiredAt: "2026-08-22T21:00:00Z",
|
||||
})),
|
||||
fetchedAt: "2026-08-22T22:20:00Z",
|
||||
ageMs: 0,
|
||||
});
|
||||
|
||||
assert.equal(layer.markCount(), 8);
|
||||
assert.equal(layer.detectionCount(), 16);
|
||||
assert.ok(layer.plumeCount() <= 8);
|
||||
layer.dispose();
|
||||
});
|
||||
});
|
||||
@@ -99,3 +99,87 @@ describe("glyph scale", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* The focus-distance clamp — the complete fix the ceiling above was a mitigation
|
||||
* for.
|
||||
*
|
||||
* Every assertion in the suite above is left exactly as it was, and that is the
|
||||
* point of the first test here: the third parameter is optional and omitting it
|
||||
* has to reproduce the previous answer to the bit, not to a rounding. A ceiling
|
||||
* that moved by a hair under this change would be silent visual drift in the one
|
||||
* function whose entire job is to prevent silent visual drift.
|
||||
*/
|
||||
describe("glyph scale, clamped against what the camera is looking at", () => {
|
||||
it("reproduces the two-argument answer exactly when no focus distance is given", () => {
|
||||
for (const fov of [42, 50, 60]) {
|
||||
for (const d of [0.5, 5, 34, 200, 400, 1160, 2280, 4000, 1e9]) {
|
||||
assert.equal(
|
||||
glyphScale(d, fov, undefined),
|
||||
glyphScale(d, fov),
|
||||
`omitting the focus distance changed the answer at ${d} units and ${fov} degrees`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores a focus distance that is not a usable number", () => {
|
||||
for (const bad of [0, -1, NaN, Infinity]) {
|
||||
assert.equal(
|
||||
glyphScale(2280, 50, bad),
|
||||
glyphScale(2280, 50),
|
||||
`a focus distance of ${bad} must fall back to the flat ceiling`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the whole-board pose alone, where the floor is right about everything", () => {
|
||||
/*
|
||||
* At a standoff the camera's target and the aircraft are the same distance
|
||||
* away to within a third, so the clamp must not bind. 1,160 units is the far
|
||||
* end of the California orbit; the aircraft on that board run from about 900
|
||||
* to about 1,400.
|
||||
*/
|
||||
for (const fov of [42, 50, 60]) {
|
||||
for (const aircraft of [900, 1160, 1400]) {
|
||||
assert.equal(
|
||||
glyphScale(aircraft, fov, 1160),
|
||||
glyphScale(aircraft, fov),
|
||||
`the clamp bound at a whole-board pose (${aircraft} units, ${fov} degrees)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("collapses the Golden Gate case, which the flat ceiling only softened", () => {
|
||||
// The measured chapter: the bridge fifteen units off the camera, the traffic
|
||||
// over the Pacific two thousand two hundred and eighty away.
|
||||
const flat = glyphScale(2280, 50);
|
||||
const focused = glyphScale(2280, 50, 15);
|
||||
assert.ok(focused < flat / 5, `focus clamp barely moved the worst case: ${focused} vs ${flat}`);
|
||||
assert.ok(focused >= 1, "and it must never shrink the glyph below its authored size");
|
||||
});
|
||||
|
||||
it("is monotonic in the focus distance: looking further away never shrinks the glyph", () => {
|
||||
let previous = 0;
|
||||
for (const focus of [1, 5, 15, 34, 100, 300, 1160, 5000]) {
|
||||
const s = glyphScale(2280, 50, focus);
|
||||
assert.ok(s >= previous, `scale fell as the camera focused further out, at ${focus}`);
|
||||
previous = s;
|
||||
}
|
||||
assert.equal(
|
||||
previous,
|
||||
glyphScale(2280, 50),
|
||||
"and at a focus distance past the backstop it must equal the unclamped answer",
|
||||
);
|
||||
});
|
||||
|
||||
it("never exceeds the absolute backstop, whatever focus distance it is handed", () => {
|
||||
for (const focus of [1e6, 1e9]) {
|
||||
assert.ok(
|
||||
glyphScale(1e9, 50, focus) <= glyphScale(1e9, 50),
|
||||
"the focus clamp must be a ceiling on top of the old one, never a lift",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* The infrastructure that switches itself on after dark, and the two things
|
||||
* about it a picture cannot check.
|
||||
*
|
||||
* A screenshot tells you a bridge is lit. It does not tell you the lamps are on
|
||||
* the deck *this* build drew rather than on a deck computed a second time and
|
||||
* a metre out, and it does not tell you they will be in the same place in the
|
||||
* 2x render as they were in the 1x preview — which is the property the capture
|
||||
* scripts rely on when they shoot the same frame at two resolutions and compare
|
||||
* them. Both are asserted here.
|
||||
*
|
||||
* The world below is San Francisco's real projection: one scene unit is 94.34 m
|
||||
* and heights carry the pack's 3.6x exaggeration. A tidy 1:1 fake would pass
|
||||
* while a lamp sat 3.6 times too high over the roadway, which is precisely the
|
||||
* class of bug `bridges.ts` already has a comment about.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { bridgeLights, planBridge } from "../../engine/bridges.ts";
|
||||
import { MODEL_X_METRICS, buildHeadlampPool } from "../../assets/vehicles/modelX.ts";
|
||||
import type { Bridge } from "../../engine/types.ts";
|
||||
import type { World } from "../../engine/world.ts";
|
||||
|
||||
const LAT_SCALE = 1180;
|
||||
const CENTRE = { lat: 37.7749, lng: -122.4194 };
|
||||
const METRES_PER_UNIT = 111_320 / LAT_SCALE;
|
||||
const EXAGGERATION = 3.6;
|
||||
|
||||
function board(latScale: number, exaggeration: number, ground = () => 0): World {
|
||||
const metresPerUnit = 111_320 / latScale;
|
||||
const lngScale = latScale * Math.cos((CENTRE.lat * Math.PI) / 180);
|
||||
return {
|
||||
project(lat: number, lng: number): [number, number] {
|
||||
return [(lng - CENTRE.lng) * lngScale, -(lat - CENTRE.lat) * latScale];
|
||||
},
|
||||
groundAt: ground,
|
||||
metres(value: number): number {
|
||||
return (value / metresPerUnit) * exaggeration;
|
||||
},
|
||||
metresPerUnit,
|
||||
} as unknown as World;
|
||||
}
|
||||
|
||||
const bayWorld = () => board(LAT_SCALE, EXAGGERATION);
|
||||
|
||||
/** The pack's Golden Gate, coordinate for coordinate. */
|
||||
const GOLDEN_GATE: Bridge = {
|
||||
name: "Golden Gate Bridge",
|
||||
path: [
|
||||
[37.8025, -122.4752],
|
||||
[37.8106, -122.4775],
|
||||
[37.8155, -122.4783],
|
||||
[37.825, -122.479],
|
||||
[37.8325, -122.4798],
|
||||
[37.8375, -122.4806],
|
||||
],
|
||||
towers: [
|
||||
[37.8155, -122.4783],
|
||||
[37.825, -122.479],
|
||||
],
|
||||
towerHeight: 227,
|
||||
deckHeight: 67,
|
||||
sag: 0.55,
|
||||
color: 0xc0442c,
|
||||
};
|
||||
|
||||
/** Metres, matching the constants `bridgeLights` is written against. */
|
||||
const DECK_LAMP_SPACING_M = 50;
|
||||
const DECK_LAMP_HEIGHT_M = 12;
|
||||
const HEAD_LIGHT_CLEARANCE_M = 6;
|
||||
|
||||
function triples(flat: readonly number[]): [number, number, number][] {
|
||||
const out: [number, number, number][] = [];
|
||||
for (let i = 0; i + 2 < flat.length; i += 3) {
|
||||
out.push([flat[i] as number, flat[i + 1] as number, flat[i + 2] as number]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- The deck run ----------------------------------------------------------
|
||||
|
||||
test("the deck lamps stand on the deck, at lamp height, in pairs", () => {
|
||||
const world = bayWorld();
|
||||
const lit = bridgeLights(world, GOLDEN_GATE);
|
||||
const lamps = triples(lit.deck);
|
||||
|
||||
assert.ok(lamps.length >= 80, `a 2.7 km crossing wants a real run of lamps, got ${lamps.length}`);
|
||||
assert.equal(lamps.length % 2, 0, "lamps come one per kerb, so the count is even");
|
||||
|
||||
// The deck is flat at `deckHeight` everywhere except the ramps at each end,
|
||||
// so the great majority of lamps sit at exactly deck + lamp height. That is
|
||||
// the assertion that fails the moment somebody recomputes the deck profile
|
||||
// somewhere else and the two answers drift.
|
||||
const deckY = world.metres(GOLDEN_GATE.deckHeight);
|
||||
const expected = deckY + world.metres(DECK_LAMP_HEIGHT_M);
|
||||
const onTheFlat = lamps.filter(([, y]) => Math.abs(y - expected) < 1e-6);
|
||||
assert.ok(
|
||||
onTheFlat.length > lamps.length * 0.6,
|
||||
`most lamps belong on the flat deck at ${expected.toFixed(4)}; ${onTheFlat.length} of ${lamps.length} were`,
|
||||
);
|
||||
// The rest are on the ramps at each end, where the deck comes down onto the
|
||||
// shore — so the run is bounded above by the flat deck's lamp height and below
|
||||
// by a lamp standing on the landing. Neither bound is decoration: the first
|
||||
// catches a lamp that has floated up to the cable, the second one that has
|
||||
// sunk into the roadway.
|
||||
const lift = world.metres(DECK_LAMP_HEIGHT_M);
|
||||
for (const [, y] of lamps) {
|
||||
assert.ok(y <= expected + 1e-6, `a deck lamp never rises above the deck run (${y})`);
|
||||
assert.ok(y >= lift - 1e-6, `a deck lamp is above whatever the deck is resting on (${y})`);
|
||||
assert.ok(y < world.metres(GOLDEN_GATE.towerHeight), "a deck lamp is not a tower light");
|
||||
}
|
||||
});
|
||||
|
||||
test("the lamp run is evenly spaced along the whole crossing", () => {
|
||||
const world = bayWorld();
|
||||
const lamps = triples(bridgeLights(world, GOLDEN_GATE).deck);
|
||||
const spacing = DECK_LAMP_SPACING_M / METRES_PER_UNIT;
|
||||
|
||||
// One pair per station along the deck: walk the left kerb only.
|
||||
const left = lamps.filter((_, index) => index % 2 === 0);
|
||||
const plan = planBridge(world, GOLDEN_GATE);
|
||||
const covered = (left.length - 1) * spacing;
|
||||
assert.ok(
|
||||
covered > plan.deckLength * 0.9,
|
||||
`the run should reach both ends: covered ${covered.toFixed(2)} of ${plan.deckLength.toFixed(2)} units`,
|
||||
);
|
||||
|
||||
for (let i = 1; i < left.length; i += 1) {
|
||||
const a = left[i - 1];
|
||||
const b = left[i];
|
||||
if (!a || !b) continue;
|
||||
const step = Math.hypot(b[0] - a[0], b[2] - a[2]);
|
||||
assert.ok(
|
||||
step > spacing * 0.5 && step < spacing * 1.6,
|
||||
`lamp ${i} is ${step.toFixed(3)} units from the last, against a ${spacing.toFixed(3)} pitch`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- The tower heads -------------------------------------------------------
|
||||
|
||||
test("each tower carries two obstruction lights, over the saddle", () => {
|
||||
const world = bayWorld();
|
||||
const lit = bridgeLights(world, GOLDEN_GATE);
|
||||
const heads = triples(lit.heads);
|
||||
|
||||
assert.equal(heads.length, GOLDEN_GATE.towers.length * 2, "one light per tower leg");
|
||||
const expected = world.metres(GOLDEN_GATE.towerHeight) + world.metres(HEAD_LIGHT_CLEARANCE_M);
|
||||
for (const [, y] of heads) {
|
||||
assert.ok(Math.abs(y - expected) < 1e-6, `a head light belongs at ${expected.toFixed(4)}, got ${y}`);
|
||||
}
|
||||
|
||||
// The pair straddles the deck: two legs, `deckHalf` either side of the centre.
|
||||
const [a, b] = heads;
|
||||
assert.ok(a && b);
|
||||
const across = Math.hypot(b[0] - a[0], b[2] - a[2]);
|
||||
assert.ok(
|
||||
Math.abs(across - lit.scale * 2) < 1e-6,
|
||||
`the legs are a deck apart: ${across.toFixed(4)} against ${(lit.scale * 2).toFixed(4)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---- Determinism -----------------------------------------------------------
|
||||
|
||||
test("the same bridge lights identically twice — the capture scripts rely on it", () => {
|
||||
const world = bayWorld();
|
||||
const first = bridgeLights(world, GOLDEN_GATE);
|
||||
const second = bridgeLights(world, GOLDEN_GATE);
|
||||
assert.deepEqual(first.deck, second.deck);
|
||||
assert.deepEqual(first.heads, second.heads);
|
||||
assert.equal(first.scale, second.scale);
|
||||
});
|
||||
|
||||
// ---- Board scale -----------------------------------------------------------
|
||||
|
||||
test("a coarser board gets a smaller lamp, because it draws a smaller bridge", () => {
|
||||
// San Francisco at 94 m to the unit against Los Angeles at 391. A sprite size
|
||||
// in scene units tuned on the first would be several times the width of the
|
||||
// deck on the second, which is the bug `socal.ts` asked the kit to stop having.
|
||||
const fine = bridgeLights(board(1180, 3.6), GOLDEN_GATE);
|
||||
const coarse = bridgeLights(board(285, 2.2), GOLDEN_GATE);
|
||||
assert.ok(coarse.scale < fine.scale, "a coarser board draws a narrower deck");
|
||||
assert.ok(coarse.scale >= 0.09, "…but never below the legibility floor");
|
||||
// The run still reaches both ends of the crossing; it is the pitch floor that
|
||||
// keeps a 391 m/unit board from putting four lamps on a whole bridge.
|
||||
assert.ok(triples(coarse.deck).length >= 8, "a coarse board still gets a run, not a handful");
|
||||
});
|
||||
|
||||
// ---- The headlamp pool -----------------------------------------------------
|
||||
|
||||
test("the headlamp pool lies ahead of the nose, and is off until night", () => {
|
||||
const pool = buildHeadlampPool({ reachM: 26 });
|
||||
pool.mesh.geometry.computeBoundingBox();
|
||||
const box = pool.mesh.geometry.boundingBox;
|
||||
assert.ok(box);
|
||||
|
||||
// The nose is at -Z. The pool starts at the bumper and runs forward from it,
|
||||
// so every vertex is in front of the car and none is behind the rear axle.
|
||||
const nose = -MODEL_X_METRICS.length / 2;
|
||||
assert.ok(box.max.z <= nose + 1e-6, `the pool starts at the bumper (${box.max.z} vs ${nose})`);
|
||||
assert.ok(Math.abs(box.min.z - (nose - 26)) < 1e-6, "…and reaches the asked-for range");
|
||||
// Flat on the road, not a wall.
|
||||
assert.ok(Math.abs(box.max.y - box.min.y) < 1e-6, "the pool is flat");
|
||||
|
||||
assert.equal(pool.mesh.visible, false, "nothing is drawn before a night level arrives");
|
||||
pool.setIntensity(0);
|
||||
assert.equal(pool.mesh.visible, false, "…and nothing at broad daylight either");
|
||||
pool.dispose();
|
||||
});
|
||||
|
||||
test("the pool draws nothing at all with no DOM to draw its beam pattern into", () => {
|
||||
// Every bit of shape this thing has is in the texture's alpha, so a pool built
|
||||
// without one would be a hard-edged glowing rectangle across the carriageway.
|
||||
// Headless is exactly where that would go unseen, which is why it is asserted
|
||||
// here rather than left to a screenshot.
|
||||
assert.equal(typeof document, "undefined", "this test only means anything headless");
|
||||
const pool = buildHeadlampPool();
|
||||
pool.setIntensity(1);
|
||||
assert.equal(pool.mesh.visible, false);
|
||||
pool.dispose();
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* The night sky: the cloud deck's level, and the moon as a drawn object.
|
||||
*
|
||||
* Both of these are judged by a picture and neither can be asserted from one, so
|
||||
* what is pinned here is the arithmetic the picture turns on. The defect this
|
||||
* suite is downstream of is worth restating, because it is the reason a test
|
||||
* that only checks "the layer exists" would have passed on the broken build:
|
||||
*
|
||||
* > At 22:30 PDT over San Francisco, on a keyless clone, the cloud deck lifted a
|
||||
* > night frame's mean luminance from 25.3 to 44.3 out of 255 **without adding a
|
||||
* > single readable shape**. Its shaded flank was measurably brighter than its
|
||||
* > moonward one. Every existing assertion about that layer passed.
|
||||
*
|
||||
* So the three things asserted are the three things that were wrong:
|
||||
*
|
||||
* 1. The deck's level after dark is a fraction of what it is at noon, rather
|
||||
* than being inflated by a hemisphere intensity that `atmosphere.ts`
|
||||
* deliberately *raises* at night to keep the world off black.
|
||||
* 2. The moonward flank of a cloud top is brighter than the flank facing away,
|
||||
* which is what "modelled" means and what a flat wash is missing.
|
||||
* 3. Daylight did not move. The day was never the defect and a fix that
|
||||
* changed it would be a different regression in the same file.
|
||||
*
|
||||
* And the moon is asserted where it is computed rather than where it is drawn:
|
||||
* a disc in a sky-dome fragment shader cannot be inspected without a GL context,
|
||||
* but the record `atmosphere.ts` hands the shader is a plain object with a
|
||||
* position, a phase and a bright-limb axis in it, and every way that record can
|
||||
* be wrong is visible from the outside.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
createAtmosphere,
|
||||
observe,
|
||||
PACIFIC_MARINE_LAYER,
|
||||
nightFactor,
|
||||
} from "../../engine/atmosphere.ts";
|
||||
import type { LightingMoon } from "../../engine/types.ts";
|
||||
|
||||
/** San Francisco, which is the board the marine layer was written for. */
|
||||
const SF = { lat: 37.7749, lng: -122.4194 };
|
||||
|
||||
/** Rec.709 luminance, the same one `clouds.ts` derives its levels with. */
|
||||
function luminance(hex: number): number {
|
||||
const c = new THREE.Color().setHex(hex);
|
||||
return 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
|
||||
}
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||
const smoothstep = (e0: number, e1: number, x: number) => {
|
||||
const t = clamp((x - e0) / (e1 - e0), 0, 1);
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
|
||||
/*
|
||||
* `clouds.ts`'s own arithmetic, restated.
|
||||
*
|
||||
* Restated rather than imported because `createCloudLayer` needs a `World`, a
|
||||
* WebGL context and a canvas to hand back a `setLighting` at all, and none of
|
||||
* those has an opinion about the numbers. What is under test is the *shape* of
|
||||
* the relationship — night is a fraction of day, lit beats shade — and both
|
||||
* halves would have to be edited together for this suite to go quiet while the
|
||||
* picture went wrong, which is the bar a restatement has to clear.
|
||||
*/
|
||||
const SKY_DAY_REFERENCE = 0.838;
|
||||
const SKY_DAY_GAIN = 0.772;
|
||||
const NIGHT_SKY_LOW = 0.09;
|
||||
const NIGHT_SKY_HIGH = 0.42;
|
||||
const NIGHT_TOP_FLOOR = 0.01;
|
||||
const NIGHT_TOP_KEY = 0.155;
|
||||
const NIGHT_SHADE_SKY = 0.1;
|
||||
const NIGHT_SHADE_AMBIENT = 0.1;
|
||||
|
||||
interface Deck {
|
||||
/** Sky-dome luminance, the term the whole normalisation now rides on. */
|
||||
skyLum: number;
|
||||
night: number;
|
||||
/** Luminance of the moonward (or sunward) flank. */
|
||||
lit: number;
|
||||
/** Luminance of the flank facing away. */
|
||||
shade: number;
|
||||
}
|
||||
|
||||
function deckAt(when: Date, marine = PACIFIC_MARINE_LAYER): Deck {
|
||||
const atmosphere = createAtmosphere({
|
||||
lng: SF.lng,
|
||||
metresPerUnit: 94,
|
||||
marineLayer: marine,
|
||||
});
|
||||
const state = atmosphere.apply(observe(SF.lat, SF.lng, when));
|
||||
const skyLum = luminance(state.hemisphere.sky);
|
||||
const day = clamp(skyLum / SKY_DAY_REFERENCE, 0, 1);
|
||||
const night = 1 - smoothstep(NIGHT_SKY_LOW, NIGHT_SKY_HIGH, skyLum);
|
||||
const key = clamp(state.sun.intensity / 2.1, 0, 1);
|
||||
|
||||
const litScale =
|
||||
(1 - night) * (0.06 + SKY_DAY_GAIN * day) + night * (NIGHT_TOP_FLOOR + NIGHT_TOP_KEY * key);
|
||||
const lit = luminance(state.sun.color) * litScale;
|
||||
|
||||
const shade =
|
||||
skyLum * state.hemisphere.intensity * ((1 - night) * 0.55 + night * NIGHT_SHADE_SKY) +
|
||||
luminance(state.ambient.color) *
|
||||
state.ambient.intensity *
|
||||
((1 - night) * 0.7 + night * NIGHT_SHADE_AMBIENT);
|
||||
|
||||
return { skyLum, night, lit, shade };
|
||||
}
|
||||
|
||||
/** 22:30 PDT on 23 August 2026 — the exact frame the defect was measured in. */
|
||||
const THE_NIGHT = new Date("2026-08-23T05:30:00Z");
|
||||
/** 13:00 PDT the same day. */
|
||||
const THE_NOON = new Date("2026-08-23T20:00:00Z");
|
||||
|
||||
describe("the cloud deck after dark", () => {
|
||||
it("is a small fraction of its daylight level, not most of it", () => {
|
||||
const night = deckAt(THE_NIGHT);
|
||||
const noon = deckAt(THE_NOON);
|
||||
assert.ok(night.night > 0.99, "the test's own night frame must actually be night");
|
||||
assert.ok(
|
||||
night.lit < noon.lit * 0.05,
|
||||
`a night cloud top must be a small fraction of a noon one: ${night.lit} vs ${noon.lit}`,
|
||||
);
|
||||
assert.ok(
|
||||
night.shade < noon.shade * 0.1,
|
||||
`and so must its shaded flank: ${night.shade} vs ${noon.shade}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("has a light direction in it — the moonward flank beats the one facing away", () => {
|
||||
/*
|
||||
* This is the assertion that would have failed on the old build. Measured
|
||||
* there: lit 0.072 against shade 0.084, i.e. the side facing the moon was
|
||||
* *darker* than the side facing away, which is not a lighting bug so much as
|
||||
* the absence of lighting. A deck with no direction in it is the flat milky
|
||||
* wash the file header calls blue soup.
|
||||
*/
|
||||
const night = deckAt(THE_NIGHT);
|
||||
assert.ok(
|
||||
night.lit > night.shade,
|
||||
`the moonward flank must be the brighter one: lit ${night.lit}, shade ${night.shade}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("still lands above black on a moonless overcast night", () => {
|
||||
/*
|
||||
* The opposite failure, and the one the file's own comment warns about:
|
||||
* `0.06 is not black`. `NIGHT_TOP_FLOOR` exists so that a deck with no moon
|
||||
* on it is dark rather than absent, and a layer that vanishes is a different
|
||||
* bug report about the same file.
|
||||
*/
|
||||
const night = deckAt(THE_NIGHT);
|
||||
assert.ok(night.lit > 0, "a night deck must still be drawn");
|
||||
assert.ok(night.shade > 0, "and its shaded flank must still be drawn");
|
||||
});
|
||||
|
||||
it("leaves daylight where it was, across the whole daylit range", () => {
|
||||
/*
|
||||
* The previous normalisation, verbatim, against the new one. The day was
|
||||
* right and the change was aimed entirely at the other side of
|
||||
* `NIGHT_SKY_HIGH`; 0.05 is the tolerance the two constants were fitted to
|
||||
* over four places and two seasons, on a multiplier that runs 0.06 to 0.86.
|
||||
*/
|
||||
const atmosphere = createAtmosphere({
|
||||
lng: SF.lng,
|
||||
metresPerUnit: 94,
|
||||
marineLayer: PACIFIC_MARINE_LAYER,
|
||||
});
|
||||
let worst = 0;
|
||||
for (let i = 0; i < 96; i++) {
|
||||
const when = new Date(Date.UTC(2026, 7, 23) + i * 900_000);
|
||||
const state = atmosphere.apply(observe(SF.lat, SF.lng, when));
|
||||
const skyLum = luminance(state.hemisphere.sky);
|
||||
const night = 1 - smoothstep(NIGHT_SKY_LOW, NIGHT_SKY_HIGH, skyLum);
|
||||
if (night > 0.02) continue; // the night path is meant to differ; that is the point
|
||||
const before = 0.06 + 0.92 * clamp((skyLum * state.hemisphere.intensity) / 0.96, 0, 1);
|
||||
const after = 0.06 + SKY_DAY_GAIN * clamp(skyLum / SKY_DAY_REFERENCE, 0, 1);
|
||||
worst = Math.max(worst, Math.abs(before - after));
|
||||
}
|
||||
assert.ok(worst < 0.05, `daylight moved by ${worst}, which is more than a fit's worth`);
|
||||
});
|
||||
|
||||
it("hands over at dusk rather than at a threshold", () => {
|
||||
// Three quarters of an hour either side of the handover must be different
|
||||
// numbers, or the deck steps rather than fades and the step is visible.
|
||||
const levels = [];
|
||||
for (const hour of [2, 3, 4, 5]) {
|
||||
levels.push(deckAt(new Date(Date.UTC(2026, 7, 23, hour, 30))).night);
|
||||
}
|
||||
for (let i = 1; i < levels.length; i++) {
|
||||
assert.ok(levels[i]! >= levels[i - 1]!, "the night term must rise monotonically through dusk");
|
||||
}
|
||||
assert.ok(levels[0]! < 0.9 && levels[3]! > 0.99, "and it must actually traverse the range");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the moon, as a thing to draw", () => {
|
||||
const atmosphere = createAtmosphere({
|
||||
lng: SF.lng,
|
||||
metresPerUnit: 94,
|
||||
marineLayer: PACIFIC_MARINE_LAYER,
|
||||
});
|
||||
const moonAt = (when: Date): LightingMoon | null =>
|
||||
atmosphere.apply(observe(SF.lat, SF.lng, when)).moon ?? null;
|
||||
|
||||
it("is absent while it is below the horizon, rather than drawn underground", () => {
|
||||
/*
|
||||
* Over one synodic month the moon is below the horizon for about half of
|
||||
* every day, so a scan that never returns `null` would mean the horizon test
|
||||
* is not running at all — and a moon drawn under the board is the failure
|
||||
* `satellites.ts` records under HORIZON_FADE_DEG, one layer over.
|
||||
*/
|
||||
let down = 0;
|
||||
let up = 0;
|
||||
for (let i = 0; i < 24 * 30; i++) {
|
||||
const when = new Date(Date.UTC(2026, 7, 1) + i * 3_600_000);
|
||||
const moon = moonAt(when);
|
||||
if (moon === null) down += 1;
|
||||
else up += 1;
|
||||
}
|
||||
assert.ok(down > 200, `the moon must set: only ${down} of 720 hours had it down`);
|
||||
assert.ok(up > 200, `and rise: only ${up} of 720 hours had it up`);
|
||||
});
|
||||
|
||||
it("hands over a unit direction and a bright limb perpendicular to it", () => {
|
||||
/*
|
||||
* Both are consumed by a fragment shader that projects a view direction onto
|
||||
* them, and a non-unit or non-orthogonal pair puts the terminator at the
|
||||
* wrong place on the disc rather than throwing anywhere. This is the failure
|
||||
* that cannot be seen except in a picture of a crescent facing the wrong way.
|
||||
*/
|
||||
let checked = 0;
|
||||
for (let i = 0; i < 24 * 30; i++) {
|
||||
const moon = moonAt(new Date(Date.UTC(2026, 7, 1) + i * 3_600_000));
|
||||
if (moon === null) continue;
|
||||
checked += 1;
|
||||
const d = new THREE.Vector3().fromArray(moon.direction);
|
||||
const limb = new THREE.Vector3().fromArray(moon.brightLimb);
|
||||
assert.ok(Math.abs(d.length() - 1) < 1e-9, `direction was not a unit vector: ${d.length()}`);
|
||||
assert.ok(Math.abs(limb.length() - 1) < 1e-9, `bright limb was not a unit vector`);
|
||||
assert.ok(Math.abs(d.dot(limb)) < 1e-9, `bright limb was not perpendicular: ${d.dot(limb)}`);
|
||||
assert.ok(Number.isFinite(moon.angularRadius) && moon.angularRadius > 0);
|
||||
assert.ok(moon.illuminated >= 0 && moon.illuminated <= 1);
|
||||
assert.ok(moon.visibility >= 0 && moon.visibility <= 1);
|
||||
}
|
||||
assert.ok(checked > 200, "the scan must have found a moon to check");
|
||||
});
|
||||
|
||||
it("points its lit limb away from the sun's own direction, never at it", () => {
|
||||
/*
|
||||
* The one error anybody who has ever looked up will spot instantly. The
|
||||
* bright limb is the sun projected onto the plane of the disc, so its dot
|
||||
* with the sun's true direction has to be positive — a crescent whose horns
|
||||
* point toward the sun is the picture of a mistake.
|
||||
*/
|
||||
let checked = 0;
|
||||
for (let i = 0; i < 24 * 30; i++) {
|
||||
const when = new Date(Date.UTC(2026, 7, 1) + i * 3_600_000);
|
||||
const env = observe(SF.lat, SF.lng, when);
|
||||
const moon = moonAt(when);
|
||||
if (moon === null) continue;
|
||||
// The sun in the same scene frame: azimuth clockwise from north, north -Z.
|
||||
const el = (env.sun.elevation * Math.PI) / 180;
|
||||
const az = (env.sun.azimuth * Math.PI) / 180;
|
||||
const sun = new THREE.Vector3(
|
||||
Math.cos(el) * Math.sin(az),
|
||||
Math.sin(el),
|
||||
-Math.cos(el) * Math.cos(az),
|
||||
);
|
||||
const limb = new THREE.Vector3().fromArray(moon.brightLimb);
|
||||
// Skip the two degenerate instants — full and new — where the projection
|
||||
// has no length and any axis is as right as any other.
|
||||
if (moon.illuminated > 0.995 || moon.illuminated < 0.005) continue;
|
||||
checked += 1;
|
||||
assert.ok(
|
||||
limb.dot(sun) > -1e-9,
|
||||
`the lit limb faced away from the sun at ${when.toISOString()}`,
|
||||
);
|
||||
}
|
||||
assert.ok(checked > 200, "the scan must have found a phase to check");
|
||||
});
|
||||
|
||||
it("is faint in daylight and full-strength at night, without ever disappearing while up", () => {
|
||||
let sawDaylight = false;
|
||||
let sawNight = false;
|
||||
for (let i = 0; i < 24 * 30; i++) {
|
||||
const when = new Date(Date.UTC(2026, 7, 1) + i * 3_600_000);
|
||||
const env = observe(SF.lat, SF.lng, when);
|
||||
const moon = moonAt(when);
|
||||
if (moon === null || env.moon.elevation < 20) continue;
|
||||
if (nightFactor(env.sun.elevation) === 0) {
|
||||
sawDaylight = true;
|
||||
assert.ok(moon.visibility > 0, "a daytime moon well up must still be drawn, faintly");
|
||||
assert.ok(moon.visibility < 0.35, `and faintly: ${moon.visibility}`);
|
||||
}
|
||||
if (nightFactor(env.sun.elevation) > 0.99) sawNight = true;
|
||||
}
|
||||
assert.ok(sawDaylight, "the scan must have found a daytime moon");
|
||||
assert.ok(sawNight, "and a night one");
|
||||
});
|
||||
|
||||
it("is absent from a rig that models no sky at all", () => {
|
||||
/*
|
||||
* An office has walls and no ephemeris, and `LightingMoon` is optional
|
||||
* precisely so that a hand-built rig is not obliged to invent one. The
|
||||
* consumer reads `state.moon ?? null` and draws nothing, which is the same
|
||||
* answer as a moon below the horizon.
|
||||
*/
|
||||
const bare = { sun: { direction: [0, 1, 0], color: 0xffffff, intensity: 1 } };
|
||||
assert.equal((bare as { moon?: unknown }).moon ?? null, null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The two places a flight's riser count is decided, pinned to each other.
|
||||
*
|
||||
* `src/interiors/shell.ts` divides a climbing leg into whole 178 mm risers and
|
||||
* lays a tread on each; `src/engine/officeMinimap.ts` draws one tick per riser
|
||||
* on the floor plan. Neither imports the other — the minimap builds no meshes
|
||||
* and has no business importing the file that does, and the shell has no
|
||||
* business knowing a widget exists — so the constant is stated twice.
|
||||
*
|
||||
* A constant stated twice is a constant that drifts, and the drift here is
|
||||
* quiet: a plan showing eleven ticks on a flight of fourteen looks fine. So the
|
||||
* two are read out of the source and compared, which is the cheapest thing that
|
||||
* actually catches it. Reading source text rather than exporting the constants
|
||||
* is deliberate: neither is part of either module's interface, and widening an
|
||||
* interface to make a test easier is how a private number becomes an API.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
function constantIn(path: string, name: string): number {
|
||||
const source = readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
const match = new RegExp(`const ${name} = ([0-9.]+);`).exec(source);
|
||||
assert.ok(match, `${name} is not declared in ${path}`);
|
||||
const value = Number(match[1]);
|
||||
assert.ok(Number.isFinite(value) && value > 0, `${name} is ${match[1]}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
describe("a drawn flight and a drawn plan agree about how many risers it has", () => {
|
||||
it("uses one riser height in the shell and in the minimap", () => {
|
||||
const shell = constantIn("../../interiors/shell.ts", "TARGET_RISER_M");
|
||||
const minimap = constantIn("../../engine/officeMinimap.ts", "MINIMAP_RISER_M");
|
||||
assert.equal(minimap, shell);
|
||||
});
|
||||
|
||||
it("keeps that height inside what a person can climb", () => {
|
||||
const riser = constantIn("../../interiors/shell.ts", "TARGET_RISER_M");
|
||||
// Commercial stairs run about 150-190 mm. Outside that a flight stops
|
||||
// reading as a flight: too shallow and it is a ramp with lines on it, too
|
||||
// steep and the actor's feet visibly miss the treads.
|
||||
assert.ok(riser >= 0.15 && riser <= 0.19, `${riser} m`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user