deadhead-space-freight-tycoon/apps/web/test/belt-haze.test.ts

79 lines
3.6 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
beltScreenRadii,
hazeSource,
hazeStripPixels,
ringGeometry,
beltMapRadii,
} from "../src/map/belt";
import { MAX_ZOOM, MIN_ZOOM, toScreen } from "../src/map/camera";
import { radialScale } from "../src/map/projection";
import { mapStyle } from "../src/map/style";
const view = { width: 1280, height: 720 };
/** Screen distance from the Sun of a point `millionKm` out on the compressed scale. */
function screenRadius(millionKm: number, camera: { zoom: number; panX: number; panY: number }) {
const sun = toScreen({ x: 0, y: 0 }, view, camera);
const edge = toScreen({ x: radialScale(millionKm), y: 0 }, view, camera);
return Math.hypot(edge.x - sun.x, edge.y - sun.y);
}
describe("the belt's haze (Step 16.6, decision 0.5.104)", () => {
it("sits between the compressed-scale screen radii of 314M and 494M km at any zoom", () => {
for (const zoom of [MIN_ZOOM, 0.75, 1, 3, MAX_ZOOM]) {
const camera = { zoom, panX: 40, panY: -25 };
const { center, inner, outer } = beltScreenRadii(view, camera);
expect(center).toEqual(toScreen({ x: 0, y: 0 }, view, camera));
expect(inner).toBeCloseTo(screenRadius(314, camera), 6);
expect(outer).toBeCloseTo(screenRadius(494, camera), 6);
}
});
it("builds the ring mesh exactly on those radii, with the strip wrapped once around", () => {
const { inner, outer } = beltMapRadii();
const ring = ringGeometry(inner, outer, 64);
for (let v = 0; v < ring.positions.length / 2; v++) {
const r = Math.hypot(ring.positions[v * 2]!, ring.positions[v * 2 + 1]!);
// Even vertices are the inner edge (v = 0), odd ones the outer edge (v = 1).
// Float32 vertices: equal to about 7 significant digits.
expect(r).toBeCloseTo(v % 2 === 0 ? inner : outer, 6);
expect(ring.uvs[v * 2 + 1]).toBe(v % 2 === 0 ? 0 : 1);
}
// u runs 0 → 1 around the circle, closing the seam where it started.
expect(ring.uvs[0]).toBe(0);
expect(ring.uvs[ring.uvs.length - 2]).toBe(1);
expect(ring.indices).toHaveLength(64 * 6);
});
it("draws its own haze strip, seamless and fading out at the edges, when no file is given", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const source = hazeSource(new Map<string, string>());
expect(source.kind).toBe("generated");
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
if (source.kind !== "generated") return;
const { width, height, pixels } = source;
expect([width, height]).toEqual([
mapStyle.belt.hazeStrip.width,
mapStyle.belt.hazeStrip.height,
]);
const alpha = (x: number, y: number) => pixels[(y * width + x) * 4 + 3]!;
const rowMean = (y: number) =>
Array.from({ length: width }, (_, x) => alpha(x, y)).reduce((a, b) => a + b, 0) / width;
// Densest in the middle, nearly transparent along the top and bottom rows.
expect(rowMean(0)).toBeLessThan(rowMean(height / 2) * 0.05);
expect(rowMean(height - 1)).toBeLessThan(rowMean(height / 2) * 0.05);
// The left and right edges meet: a ring shows no seam.
let seam = 0;
for (let y = 0; y < height; y++) seam += Math.abs(alpha(0, y) - alpha(width - 1, y));
expect(seam / height).toBeLessThan(12);
});
it("draws the same haze every time, and uses the user's file when there is one", () => {
expect(hazeStripPixels(256, 32, 7)).toEqual(hazeStripPixels(256, 32, 7));
const file = { name: "belt-haze texture" };
expect(hazeSource(new Map([["belt-haze", file]]))).toEqual({ kind: "file", texture: file });
});
});