clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / render / test / globe.test.ts
globe.test.ts 36.6K
1// What the sphere raises over a pin, and how it decides.2//3// Five decisions in `globe/element.ts` are contracts rather than taste, and4// every one of them was wrong before this suite existed.5//6// THE CARD IS CUT TO THE NAME. A label is measured in the font it is drawn7// in, and the canvas, the texture and the sprite's aspect all follow that one8// number. A fixed 256x64 canvas clipped every name longer than it, and a9// fixed sprite scale then stretched whatever survived to the same width — so10// two names of very different lengths came out identical, and neither was the11// size it asked for.12//13// THE CAMERA SPENDS THE BUDGET. 180 names is a decision about a sphere; which14// 180 is a decision about a reader. Ordering by collection priority hands15// every card to whichever collection happens to be rarest, wherever in the16// world it stands, and leaves the ground under the camera bare.17//18// THE HORIZON IS ENFORCED BY HAND. Cards and chips draw with the depth test19// off, so nothing stops a name on the far side of the planet shining straight20// through it but the limb test here.21//22// A MATERIAL IS SHARED. Two thousand pins of twenty collections are forty23// materials, cached by marker and selection, never disposed — and the ringed24// variant is a different entry, not a mutation of the plain one.25//26// A REBUILD GIVES BACK WHAT IT MINTED. Dropping a card out of a group is not27// freeing its texture, and the names are rebuilt every time the camera28// settles somewhere new.29//30// Everything below is a pure function of its arguments, over a stub canvas31// that records what was drawn on it: no WebGL, no globe, no fixtures.3233import test from "node:test";34import { strict as assert } from "node:assert";35// `three` reads nothing of the page as it loads, so it is imported the plain36// way; `globe.gl`, which the seam pulls in, reads the document as it loads,37// which is why the module under test arrives by hand further down.38import * as THREE from "three";39import { setLevel } from "../log.ts";4041setLevel("error");4243// ---- the page the sphere is drawn in ---------------------------------4445/** One call made on a stub canvas, so a test can say what was drawn. */46interface Op {47 readonly name: string;48 readonly args: readonly unknown[];49}5051/** The pitch a font is measured at: enough that a longer name is wider. */52function pitch(font: string): number {53 return Number(/(\d+)px/.exec(font)?.[1] ?? 10) * 0.5;54}5556interface StubCanvas {57 width: number;58 height: number;59 readonly ops: Op[];60 getContext(): Record<string, unknown>;61}6263function stubCanvas(): StubCanvas {64 const ops: Op[] = [];65 const paper: Record<string, unknown> = {66 font: "10px", textAlign: "", textBaseline: "", lineWidth: 0,67 fillStyle: "", strokeStyle: "", globalAlpha: 1, globalCompositeOperation: "",68 measureText: (text: string) => ({ width: text.length * pitch(String(paper.font)) }),69 };70 for (const name of ["fillRect", "fillText", "strokeText", "drawImage",71 "beginPath", "arc", "stroke", "clearRect"]) {72 paper[name] = (...args: unknown[]) => { ops.push({ name, args }); };73 }74 return {75 width: 0, height: 0, ops,76 getContext: () => paper,77 toDataURL: () => "data:image/png;base64,raster",78 style: {}, classList: { add: () => {} },79 appendChild: () => {}, insertBefore: () => {},80 setAttribute: () => {}, addEventListener: () => {},81 } as unknown as StubCanvas;82}8384const host = globalThis as unknown as Record<string, unknown>;85host.HTMLElement = class {};86host.window = globalThis;87host.requestAnimationFrame = (): number => 0;88host.cancelAnimationFrame = (): void => {};89// Images arrive of their own accord here: what the sphere's tests are about90// is what it does with a raster once it has one, and the window before that91// is `markers.test.ts`'s.92host.Image = class {93 crossOrigin = "";94 onload: (() => void) | null = null;95 onerror: (() => void) | null = null;96 private asked = "";9798 get src(): string {99 return this.asked;100 }101102 set src(value: string) {103 this.asked = value;104 setTimeout(() => this.onload?.(), 0);105 }106};107host.document = {108 createElement: stubCanvas,109 createElementNS: stubCanvas,110 createTextNode: stubCanvas,111 getElementsByTagName: () => [stubCanvas()],112 querySelector: () => null,113 querySelectorAll: () => [],114 addEventListener: () => {},115 head: stubCanvas(),116 body: stubCanvas(),117};118119// Imported after the page exists: `globe.gl` reads the document as it loads.120const {121 altitudeForZoom, angularDistance, boundsOf, cellBoundary, densifyRing, detailBlock,122 detailLevel, facesCamera, fillRows, holdControls, initialsOf, labelCandidates,123 markerMaterial, nameCard, release, ringFill, ringLatLng, tileGeometry, wearSkin,124 zoneFill, zoneMesh, zoneOutlines, zoomForAltitude,125} = await import("../globe/element.ts");126type Placed = import("../globe/element.ts").Placed;127128/**129 * The sphere the seam draws on, without a WebGL context under it.130 *131 * `getCoords` is globe.gl's own spelling of a latitude and a longitude at an132 * altitude given as a fraction of the radius, and it is the only thing any of133 * the geometry below asks of a globe — so the stub is that one function, and134 * every assertion about where a vertex landed is an assertion about the seam's135 * arithmetic rather than about three's.136 */137const sphere = {138 getCoords(lat: number, lng: number, altitude = 0): { x: number; y: number; z: number } {139 const radius = 100 * (1 + altitude);140 const phi = (lat * Math.PI) / 180;141 const theta = (lng * Math.PI) / 180;142 return {143 x: radius * Math.cos(phi) * Math.cos(theta),144 y: radius * Math.sin(phi),145 z: radius * Math.cos(phi) * Math.sin(theta),146 };147 },148} as unknown as import("globe.gl").GlobeInstance;149150/** How far a vertex sits from the middle of the planet. */151function radiiOf(mesh: THREE.Object3D): number[] {152 const drawn = mesh as THREE.Mesh;153 const position = drawn.geometry.getAttribute("position");154 const radii: number[] = [];155 for (let at = 0; at < position.count; at++) {156 radii.push(Math.hypot(position.getX(at), position.getY(at), position.getZ(at)));157 }158 return radii;159}160161/** The canvas a sprite's texture was drawn on, with what was drawn on it. */162function drawnOn(sprite: THREE.Sprite): StubCanvas {163 const map = (sprite.material as THREE.SpriteMaterial).map;164 assert.ok(map, "the sprite carries a texture");165 return map.image as StubCanvas;166}167168const somewhere = { x: 0, y: 0, z: 101 };169170// ---- the card is cut to the name -------------------------------------171172test("a name's card is measured in the font it is written in", () => {173 const short = nameCard("Elysium", somewhere);174 const canvas = drawnOn(short);175 // "600 26px Inter" measures at 13 per character here, and the card carries176 // 24 pixels of padding around whatever that came to.177 assert.equal(canvas.width, "Elysium".length * 13 + 24, "the canvas is the name plus its padding");178 assert.equal(canvas.height, 40, "and a fixed 40 tall");179 const filled = canvas.ops.filter((op) => op.name === "fillRect");180 assert.deepEqual(filled[0]?.args, [0, 0, canvas.width, 40], "the card is filled edge to edge");181 const written = canvas.ops.find((op) => op.name === "fillText");182 assert.deepEqual(written?.args, ["Elysium", canvas.width / 2, 21], "centred on its own width");183});184185test("a longer name gets a wider card and a wider sprite, in proportion", () => {186 const short = nameCard("Elysium", somewhere);187 const long = nameCard("Olympus Mons and the Tharsis Rise", somewhere);188 for (const card of [short, long]) {189 const canvas = drawnOn(card);190 assert.equal(card.scale.y, 0.028, "every card is the same height on screen");191 assert.equal(card.scale.x, (0.028 * canvas.width) / 40, "and as wide as its own canvas");192 }193 assert.ok(long.scale.x > short.scale.x * 2, "the longer name is much the wider");194 // The defect this pins down: one fixed canvas and one fixed scale made195 // these two identical, and clipped the longer one to fit.196 assert.notEqual(short.scale.x, long.scale.x);197});198199test("a card floats above its pin and draws over the planet", () => {200 const card = nameCard("Hellas", somewhere);201 const material = card.material as THREE.SpriteMaterial;202 assert.equal(material.depthTest, false, "the card is not clipped by the curve it stands on");203 assert.equal(material.depthWrite, false);204 assert.equal(material.sizeAttenuation, false, "screen-sized, not world-sized");205 assert.equal(material.transparent, true);206 assert.equal(card.renderOrder, 5, "over the grid (1, 2, 4) and over the pin it names (3)");207 assert.deepEqual([card.center.x, card.center.y], [0.5, 0], "anchored by its bottom edge");208 assert.deepEqual([card.position.x, card.position.y, card.position.z], [0, 0, 101]);209});210211// ---- the camera spends the budget ------------------------------------212213test("the great-circle distance is what near means on a sphere", () => {214 assert.equal(angularDistance({ lat: 0, lng: 0 }, { lat: 0, lng: 0 }), 0);215 assert.ok(Math.abs(angularDistance({ lat: 0, lng: 0 }, { lat: 0, lng: 90 }) - 90) < 1e-9);216 assert.ok(Math.abs(angularDistance({ lat: 90, lng: 0 }, { lat: -90, lng: 0 }) - 180) < 1e-9);217 // Two places a hundred and seventy degrees of longitude apart, at the218 // eightieth parallel, are nine degrees apart on the ground. Comparing219 // latitudes and longitudes would call that most of a planet.220 assert.ok(angularDistance({ lat: 80, lng: -85 }, { lat: 80, lng: 85 }) < 35);221});222223test("the names raised are the nearest ones, in order, out to the rim", () => {224 const placed: Placed[] = [225 { title: "far side", lat: 0, lng: 179 },226 { title: "third", lat: 0, lng: 30 },227 { title: "over the rim", lat: 0, lng: 86 },228 { title: "first", lat: 0, lng: 2 },229 { title: "at the rim", lat: 0, lng: 84.9 },230 { title: "second", lat: 0, lng: -10 },231 ];232 const raised = labelCandidates({ lat: 0, lng: 0 }, placed, 180);233 assert.deepEqual(234 raised.map((entry) => entry.title),235 ["first", "second", "third", "at the rim"],236 "ascending by distance, and nothing past eighty-five degrees");237});238239test("the budget is spent on the nearest names and stops there", () => {240 // Two hundred pins along the equator, the farthest of them still inside the241 // reach, handed over in the worst possible order.242 const placed: Placed[] = [];243 for (let at = 200; at > 0; at--) {244 placed.push({ title: `pin ${at}`, lat: 0, lng: at * 0.4 });245 }246 const raised = labelCandidates({ lat: 0, lng: 0 }, placed, 180);247 assert.equal(raised.length, 180, "the budget is the budget");248 assert.equal(raised[0]?.title, "pin 1", "and it is spent nearest first");249 assert.equal(raised[179]?.title, "pin 180");250 // Which is the whole defect: in the order they arrived, the 180 kept would251 // have been the twenty nearest and a hundred and sixty of the farthest.252 assert.ok(!raised.some((entry) => entry.title === "pin 181"));253});254255test("a budget larger than the standing set raises everything inside the reach", () => {256 const placed: Placed[] = [257 { title: "here", lat: 10, lng: 10 },258 { title: "behind", lat: -10, lng: -170 },259 ];260 assert.deepEqual(261 labelCandidates({ lat: 10, lng: 10 }, placed, 180).map((entry) => entry.title),262 ["here"]);263});264265// ---- the horizon is enforced by hand ---------------------------------266267test("a card on the near side faces the camera and one on the far side does not", () => {268 // A camera three radii out: the limb sits where the cosine of a point's269 // angle from the camera's axis is a third.270 const camera = { x: 0, y: 0, z: 300 };271 assert.equal(facesCamera({ x: 0, y: 0, z: 100 }, camera), true, "straight ahead");272 assert.equal(facesCamera({ x: 0, y: 0, z: -100 }, camera), false, "straight behind");273 assert.equal(facesCamera({ x: 100, y: 0, z: 0 }, camera), false, "square on the rim");274});275276test("the limb itself is the far side, and a hair inside it is not", () => {277 const camera = { x: 0, y: 0, z: 300 };278 const at = (cosine: number) => ({279 x: 100 * Math.sqrt(1 - cosine ** 2), y: 0, z: 100 * cosine,280 });281 assert.equal(facesCamera(at(1 / 3), camera), false, "exactly at the limb is not visible");282 assert.equal(facesCamera(at(1 / 3 + 1e-6), camera), true, "a hair inside it is");283 assert.equal(facesCamera(at(1 / 3 - 1e-6), camera), false, "a hair outside it is not");284});285286test("the closer the camera comes the less of the planet it can see", () => {287 const anchor = { x: 100 * Math.sin(0.9), y: 0, z: 100 * Math.cos(0.9) };288 assert.equal(facesCamera(anchor, { x: 0, y: 0, z: 400 }), true, "from far out, visible");289 assert.equal(facesCamera(anchor, { x: 0, y: 0, z: 120 }), false, "from close in, over the rim");290});291292// ---- the skin is worn, not multiplied away ---------------------------293//294// The sphere is the one surface in this lane whose whole picture can be wrong295// with every number about it right, and this is how it went wrong: globe.gl296// hands out a material tinted black to say "no picture yet", and a Phong297// material multiplies its map by its tint. The seam assigned the map and left298// the tint, so six baselines' worth of globe steps were correct about a black299// planet. The test is the multiplication, stated: after the skin is on, the300// tint must be identity.301302test("the sphere's skin is hung on a material that does not tint it away", () => {303 // What globe.gl hands out when nothing has given it an image: the comment in304 // its own source is "Black globe if no image".305 const material = new THREE.MeshPhongMaterial({ color: 0x000000 });306 const skin = new THREE.CanvasTexture(stubCanvas() as unknown as HTMLCanvasElement);307 wearSkin(material, skin);308 assert.equal(material.map, skin, "the skin is not the material's map");309 assert.equal(material.color.getHex(), 0xffffff,310 "the material multiplies its map by its colour, and this colour is not identity" +311 " — a black planet with every count right is what that looks like");312 // `needsUpdate` is write-only on a three material — it bumps `version` and313 // keeps nothing — so the version is what says the shader was recompiled.314 assert.equal(material.version, 1, "the material was not told to recompile");315});316317// ---- a material is shared --------------------------------------------318319const mark = {320 asset: "", url: "", picture: false, color: "#4fb3d5",321 outset: "rgba(7, 9, 7, 0.98)", title: "Impact Craters",322};323324/** Let the microtasks behind a delivered image run. */325function settle(): Promise<void> {326 return new Promise((resolve) => { setTimeout(resolve, 0); });327}328329test("a collection's marker is built once and shared by every pin wearing it", () => {330 const first = markerMaterial(mark, false);331 assert.equal(markerMaterial(mark, false), first, "the same marker is the same material");332 assert.equal(markerMaterial({ ...mark }, false), first, "keyed by what it draws, not by identity");333 assert.notEqual(markerMaterial({ ...mark, color: "#c8734f" }, false), first,334 "a different colour is a different marker");335 assert.equal(first.depthWrite, false);336 assert.equal(first.sizeAttenuation, false, "a pin keeps its size however close the camera comes");337 // THE DEFECT THIS PINS DOWN. A pin's sprite is screen-sized around a point338 // half a unit off the skin, so at the limb most of that square stands over339 // ground nearer the camera than its own anchor: tested against the depth340 // buffer, the planet swallowed the buried half and the reader saw icons sunk341 // to the waist in the rim. An icon is drawn on top of the raster, and the342 // far side is kept out by the horizon instead (`cull`).343 assert.equal(first.depthTest, false, "a pin is not clipped by the ground it stands on");344});345346test("a marker with no picture wears its collection's initials in its colour", () => {347 const material = markerMaterial({ ...mark, color: "#123456" }, false);348 const canvas = (material.map as THREE.Texture).image as StubCanvas;349 assert.equal(canvas.width, 80);350 assert.equal(canvas.height, 80);351 const stroked = canvas.ops.find((op) => op.name === "strokeText");352 const filled = canvas.ops.find((op) => op.name === "fillText");353 assert.deepEqual(stroked?.args, ["IC", 40, 41], "the rim goes down first");354 assert.deepEqual(filled?.args, ["IC", 40, 41], "and the colour over it");355 assert.equal(canvas.ops.some((op) => op.name === "arc"), false, "an unchosen pin wears no ring");356});357358test("the chosen variant is a second entry in the cache, and it is the ringed one", () => {359 const ringed = markerMaterial(mark, true);360 assert.notEqual(ringed, markerMaterial(mark, false), "chosen and unchosen are two materials");361 assert.equal(markerMaterial(mark, true), ringed, "and the chosen one is cached too");362 const canvas = (ringed.map as THREE.Texture).image as StubCanvas;363 const ring = canvas.ops.find((op) => op.name === "arc");364 assert.deepEqual(ring?.args, [40, 40, 36, 0, Math.PI * 2], "a ring around the whole marker");365 assert.equal(canvas.ops.some((op) => op.name === "stroke"), true);366});367368test("the sphere wears the chart's raster rather than composing one of its own", async () => {369 const material = markerMaterial(370 { ...mark, asset: "shrine.svg", url: "/icons/shrine.svg", color: "#123456" }, false);371 await settle();372 await settle();373 const canvas = (material.map as THREE.Texture).image as StubCanvas;374 assert.equal(canvas.width, 80, "the sphere's own canvas is still 80 square");375 const drawn = canvas.ops.filter((op) => op.name === "drawImage");376 assert.equal(drawn.length, 1, "one image: the finished symbol");377 assert.deepEqual(drawn[0]?.args.slice(1), [8, 8, 64, 64],378 "the 64-square raster, inset by eight so the ring has somewhere to go");379 // The tint and the halo are composed into the raster the chart hands over,380 // and a sphere that tinted it a second time would be deciding for itself381 // what a Shrine looks like -- which is how the two panes drifted apart.382 assert.equal(canvas.ops.some((op) => op.name === "fillRect"), false);383 assert.equal(canvas.ops.some((op) => op.name === "fillText"), false,384 "and it is a symbol, not initials");385});386387test("a collection's initials are its first two words' first letters", () => {388 assert.equal(initialsOf("Impact Craters"), "IC");389 assert.equal(initialsOf("Landing Sites and Rovers"), "LS");390 assert.equal(initialsOf("Volcanoes"), "V");391 assert.equal(initialsOf(""), "");392});393394// ---- a rebuild gives back what it minted ------------------------------395396/** Count what a rebuild frees: three dispatches `dispose` on the way out. */397function watch(398 target: THREE.EventDispatcher<{ dispose: object }>, seen: string[], name: string,399): void {400 target.addEventListener("dispose", () => { seen.push(name); });401}402403test("releasing the raised names disposes every texture and material they held", () => {404 const group = new THREE.Group();405 const seen: string[] = [];406 for (const title of ["Elysium", "Hellas", "Arsia Mons"]) {407 const card = nameCard(title, somewhere);408 watch((card.material as THREE.SpriteMaterial).map as THREE.Texture, seen, `${title}: texture`);409 watch(card.material as THREE.SpriteMaterial, seen, `${title}: material`);410 group.add(card);411 }412 release(group);413 assert.equal(group.children.length, 0, "the group is empty");414 assert.deepEqual(seen.sort(), [415 "Arsia Mons: material", "Arsia Mons: texture",416 "Elysium: material", "Elysium: texture",417 "Hellas: material", "Hellas: texture",418 ], "and nothing it minted is still held");419});420421test("a rebuild frees a mesh's geometry and leaves three's shared sprite quad alone", () => {422 const group = new THREE.Group();423 const seen: string[] = [];424 const mesh = new THREE.Mesh(425 new THREE.BufferGeometry(), new THREE.MeshBasicMaterial());426 watch(mesh.geometry, seen, "mesh geometry");427 const card = nameCard("Elysium", somewhere);428 // Every sprite in the scene shares one quad: disposing it here would take429 // the geometry out from under the pins as well.430 watch(card.geometry, seen, "sprite geometry");431 group.add(mesh, card);432 release(group);433 assert.deepEqual(seen, ["mesh geometry"]);434});435436test("the shared marker materials survive a release, because no group owns them", () => {437 const material = markerMaterial({ ...mark, title: "Dunes" }, false);438 const seen: string[] = [];439 watch(material, seen, "marker");440 const group = new THREE.Group();441 group.add(nameCard("Dunes", somewhere));442 release(group);443 assert.deepEqual(seen, [], "a name coming down is not a collection losing its mark");444 assert.equal(markerMaterial({ ...mark, title: "Dunes" }, false), material);445});446447// ---- the grid follows the curve ---------------------------------------448//449// THE DEFECT, stated as arithmetic. A cell's ring is four corners, and at the450// root of a plan two of them are forty-five degrees apart. Joined by a451// straight line in three dimensions that is a chord, and a chord across 45° of452// a hundred-unit sphere passes seven units *inside* it — so a fill built from453// the corners alone was a sheet drawn through the crust, and the boundary over454// it went the same way. The bigger the cell the deeper it sank, which is why455// the dimmed neighbourhood was the part that "did not render": it was drawn,456// underground. Densifying by span is the whole fix, and the tests below are457// the two clamps and the lift that make it one.458459test("a ring is subdivided by the span of its own segments", () => {460 // Forty-five degrees at two degrees a step, and the last vertex of a461 // segment belongs to the next one: an open loop, never a repeated point.462 const quarter = densifyRing([[0, 0], [0, 45]], 1);463 assert.equal(quarter.length, 23, "ceil(45 / 2) steps across the segment");464 assert.deepEqual(quarter[0], [0, 0], "starting where the segment starts");465 assert.ok(Math.abs((quarter.at(-1)?.[1] ?? 0) - (45 * 22) / 23) < 1e-9,466 "and stopping short of its end");467});468469test("the subdivision has a floor and a ceiling, and the caller sets the floor", () => {470 const hair: [number, number][] = [[0, 0], [0, 0.5]];471 assert.equal(densifyRing(hair, 1).length, 1, "a fill wants at least one step");472 assert.equal(densifyRing(hair, 4).length, 4,473 "a boundary wants four: it is what a reader sees the curve of");474 // A pole cell's ring walks the whole planet, and 180 steps of a single475 // segment is a boundary nobody can see the difference in.476 assert.equal(densifyRing([[0, -180], [0, 180]], 1).length, 48, "capped at 48 a segment");477});478479test("a fill's rings of triangles are counted by span, between two and twenty-four", () => {480 assert.equal(fillRows(0), 2, "even the smallest cell gets two");481 assert.equal(fillRows(45), 8, "ceil(45 / 6)");482 assert.equal(fillRows(360), 24, "and no cell gets more than twenty-four");483});484485test("every vertex of a fill is lifted back onto the sphere it is drawn on", () => {486 // The root plan's own cells: 45 degrees of longitude, 45 of latitude.487 const ring: [number, number][] = [[0, 0], [45, 0], [45, 45], [0, 45], [0, 0]];488 const corners = boundsOf(ring);489 assert.ok(corners);490 const mesh = ringFill(sphere, ring, corners, [22.5, 22.5], { color: "#050810", opacity: 0.3 });491 assert.ok(mesh, "the cell has a sheet");492 const radii = radiiOf(mesh);493 const wanted = 100.32;494 // The vertices are stored as 32-bit floats, so "on the surface" is to a495 // ten-thousandth of a unit; the defect this pins down was seven whole units.496 for (const radius of radii) {497 assert.ok(Math.abs(radius - wanted) < 1e-4,498 `a vertex at ${radius} is not on the surface — the sheet sags into the planet`);499 }500 // Which is the defect measured: the chord between two corners 45 apart, and501 // the straight walk from the middle out to them, both cut deep inside.502 assert.ok(radii.length > 5 * 4, "and there are far more vertices than the ring had corners");503});504505test("a fill is drawn under the boundary and over the tiles, writing no depth", () => {506 const ring: [number, number][] = [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]];507 const corners = boundsOf(ring);508 assert.ok(corners);509 const mesh = ringFill(sphere, ring, corners, [5, 5], { color: "#4fb3d5", opacity: 0.14 });510 assert.ok(mesh);511 const material = mesh.material as THREE.MeshBasicMaterial;512 assert.equal(mesh.renderOrder, 1,513 "over the tiles (0), under the boundary (2), the pins (3), the chip (4) and the cards (5)");514 assert.equal(material.transparent, true);515 assert.equal(material.opacity, 0.14);516 assert.equal(material.depthWrite, false, "a sheet that wrote depth would erase its own boundary");517 assert.equal(material.side, THREE.DoubleSide);518 assert.equal(material.color.getHexString(), "4fb3d5");519});520521// ---- the boundary carries its weight ----------------------------------522523const square: [number, number][] = [[0, 0], [20, 0], [20, 20], [0, 20], [0, 0]];524525test("a boundary is drawn at the width its role is weighed at", () => {526 // The role table of `gridTheme.widths`, which WebGL's native line ignores527 // outright: every one of these came out a hairline, and the hierarchy the528 // weights encode was invisible on the sphere while the chart drew it.529 for (const width of [2.5, 1.8, 1.4, 1]) {530 const loop = cellBoundary(sphere, square, { color: "#ffffff", opacity: 1, widthPx: width },531 { width: 1280, height: 800 });532 assert.ok(loop);533 const material = loop.material;534 assert.equal(material.linewidth, width, "the width is the token's, honestly drawn");535 assert.deepEqual([material.resolution.x, material.resolution.y], [1280, 800],536 "a width in pixels is measured against the window it is drawn in");537 assert.equal(loop.renderOrder, 2);538 assert.equal(material.depthWrite, false);539 }540});541542test("a boundary follows the curve and closes on itself", () => {543 const loop = cellBoundary(sphere, square, { color: "#c9924b", opacity: 0.44, widthPx: 1 },544 { width: 900, height: 600 });545 assert.ok(loop);546 // Ten steps a side at twenty degrees, and the first point repeated to close.547 const positions = loop.geometry.getAttribute("instanceStart");548 assert.equal(positions.count, 40, "four sides of ten segments, closed");549 for (let at = 0; at < positions.count; at++) {550 const radius = Math.hypot(551 positions.getX(at), positions.getY(at), positions.getZ(at));552 assert.ok(Math.abs(radius - 100.45) < 1e-4, "and every vertex of it is on the surface");553 }554 assert.equal(loop.material.opacity, 0.44);555});556557test("a boundary's geometry and material are given back on release", () => {558 const group = new THREE.Group();559 const loop = cellBoundary(sphere, square, { color: "#ffffff", opacity: 1, widthPx: 2.5 },560 { width: 1280, height: 800 });561 assert.ok(loop);562 const seen: string[] = [];563 watch(loop.geometry, seen, "line geometry");564 watch(loop.material, seen, "line material");565 group.add(loop);566 release(group);567 assert.equal(group.children.length, 0);568 assert.deepEqual(seen.sort(), ["line geometry", "line material"],569 "a boundary is rebuilt every time the camera changes what fits: leaking one leaks dozens");570});571572// ---- the ring is the sphere's, not the sheet's -------------------------573574test("a ring that carried its longitudes past the antimeridian is left alone", () => {575 // The mapping a world declares: the whole equirect window, 8192 by 4096.576 const mapping = {577 toLatLng: (x: number, y: number): [number, number] => [(y / 4096) * 180, (x / 8192) * 360 - 180],578 toWorld: (): [number, number] => [0, 0],579 };580 const ring = ringLatLng([[7680, 0], [8704, 0], [8704, -1024], [7680, -1024], [7680, 0]], mapping);581 const corners = boundsOf(ring);582 assert.ok(corners);583 assert.ok(corners.east > 180,584 "the loop stays continuous: the sphere is periodic and a cut would tear the cell in two");585 assert.equal(ring.length, 5, "and it is still one ring");586});587588// ---- the pyramid under the camera --------------------------------------589//590// The worked case both tests below share: the camera at altitude 0.68 over591// 0,0 draws 81 tiles of level 5. Both numbers fall out of the two functions592// below and nothing else — the altitude reads as zoom 3.878, rounds to 4,593// and one deeper is 5; and at level 5 a nine-by-nine block is the widest594// reach that stays inside a 96-tile budget.595596test("the level is one deeper than the distance reads as, capped by the capture", () => {597 const ceiling = 8;598 const table: [number, number][] = [599 [2.5, 3], // the whole disc reads as zoom 2600 [0.68, 5], // the worked case: 3.878 rounds to 4, and one deeper is 5601 [0.08, 8], // pressed against the nearest altitude602 ];603 for (const [altitude, wanted] of table) {604 assert.equal(detailLevel(zoomForAltitude(altitude, ceiling), 6),605 Math.min(wanted, 6), `at altitude ${altitude}`);606 }607 assert.equal(detailLevel(5.4, 6), 6, "5.4 rounds to 5 and one deeper is 6");608 assert.equal(detailLevel(5.6, 6), 6, "and the capture's own floor is the ceiling");609 assert.equal(detailLevel(2, 4), 3);610});611612test("the neighbourhood is the block the horizon asks for, pulled into its budget", () => {613 const wanted = detailBlock({ lat: 0, lng: 0, altitude: 0.68 }, 5, 96);614 assert.equal(wanted.length, 81,615 "the widest block under the budget: reach 6 is 169 tiles, 5 is 121, and 4 is 81 ≤ 96");616 const columns = new Set(wanted.map(([x]) => x));617 const rows = new Set(wanted.map(([, y]) => y));618 assert.equal(columns.size, 9, "nine columns");619 assert.equal(rows.size, 9, "by nine rows");620 assert.ok([...columns].every((x) => x >= 0 && x < 32), "wrapped in longitude: the sphere closes");621});622623test("the block is clipped at the poles and wrapped at the seam", () => {624 const pole = detailBlock({ lat: 88, lng: 0, altitude: 0.68 }, 5, 96);625 assert.ok(pole.length < 81, "there is no row above the pole to ask for");626 assert.ok(pole.every(([, y]) => y >= 0 && y < 16));627 const seam = detailBlock({ lat: 0, lng: 179, altitude: 0.68 }, 5, 96);628 assert.equal(seam.length, 81);629 assert.ok(seam.some(([x]) => x === 0) && seam.some(([x]) => x === 31),630 "a camera on the antimeridian reads tiles from both ends of the pyramid");631});632633test("a shallower level has room for a wider reach", () => {634 // The budget is a count of tiles, not of degrees: at level 3 the whole635 // horizon is three tiles across, the block is seven columns wide and nothing636 // has to be given up — and the four rows are every row the pyramid has.637 const wanted = detailBlock({ lat: 0, lng: 0, altitude: 2.5 }, 3, 96);638 assert.equal(wanted.length, 28);639 assert.equal(new Set(wanted.map(([x]) => x)).size, 7);640 assert.equal(new Set(wanted.map(([, y]) => y)).size, 4, "clipped to the pyramid's own rows");641});642643// ---- a tile is draped, not composited ----------------------------------644//645// The base skin is one 4096-pixel canvas of the whole equirect window, which646// is level four exactly. A level-six tile composited into it is a 256-pixel647// square drawn 64 wide — three quarters of the capture thrown away before648// anything is drawn, which is what made the sphere read two levels shallower649// than the chart at the same camera. Draped as its own mesh, a tile covers its650// own ground at its own pitch.651652test("a tile is draped on a grid that follows the curve, at the pyramid's own radius", () => {653 const geometry = tileGeometry(sphere, -180, 90, 11.25);654 const position = geometry.getAttribute("position");655 assert.equal(position.count, 81, "nine by nine points: an eight-segment grid");656 assert.equal(geometry.getIndex()?.count, 8 * 8 * 6, "two triangles a square");657 for (let at = 0; at < position.count; at++) {658 const radius = Math.hypot(position.getX(at), position.getY(at), position.getZ(at));659 assert.ok(Math.abs(radius - 100.2) < 1e-4,660 "just off the skin, under everything the grid draws");661 }662 const uv = geometry.getAttribute("uv");663 assert.deepEqual([uv.getX(0), uv.getY(0)], [0, 1], "the tile's top-left is the texture's");664 assert.deepEqual([uv.getX(80), uv.getY(80)], [1, 0], "and its bottom-right likewise");665});666667test("the wheel is held to the same clamps as every other door", () => {668 // globe.gl's own controls would dolly the camera to a hair above the skin669 // -- under the detail shell and the pins, where every face is a back face670 // and the pane renders black -- and out to a hundred radii. holdControls671 // gives the wheel the same two rails steer, changeZoom and the flip672 // already ride: the pairing's own clamps, read back here through the673 // exported conversion at a zoom past either end (the sphere's radius in674 // globe.gl's units is 100).675 const controls = { minDistance: 0, maxDistance: Infinity };676 holdControls(controls);677 assert.equal(controls.minDistance, 100 * (1 + altitudeForZoom(999)));678 assert.equal(controls.maxDistance, 100 * (1 + altitudeForZoom(-999)));679 // The nearest stand keeps the camera outside everything drawn off the680 // skin: the detail tiles at radius 100.2 and the pins above them.681 assert.ok(controls.minDistance > 100.6, `camera can reach ${controls.minDistance}`);682 // And the farthest keeps the planet bigger than a dot.683 assert.ok(controls.maxDistance <= 1000, `camera can leave to ${controls.maxDistance}`);684});685686// ---- zones on the sphere ---------------------------------------------687//688// The chart has always drawn a world's area and path features; the sphere689// grew its zone layer when the included Earth became the first volume to put690// areas on a spherical world. What is contractual is small and exact: which691// outlines a shape draws (holes ride their areas, paths stay open), and that692// every vertex of the landed mesh sits on its own radius rather than on a693// chord through the planet -- the same promise the grid's boundaries make.694695type ShapeRecord = import("../world/model.ts").ShapeRecord;696697/** The identity mapping: a ground whose world units are already degrees. */698const degrees = {699 toLatLng: (x: number, y: number): [number, number] => [y, x],700 toWorld: (lat: number, lng: number): [number, number] => [lng, lat],701};702703function zoneShape(overrides: Partial<ShapeRecord>): ShapeRecord {704 return {705 id: "9", title: "Vale", subtitle: "", kind: "area", shard: 0,706 collection: { id: 1, title: "Countries", kind: "area", visible: true },707 lines: [], holes: [], center: null, feature: { id: 9, title: "Vale", geometry: [] },708 ...overrides,709 } as ShapeRecord;710}711712// A square area in world coordinates (y negative-down), with one hole.713const zoneOuter = [[10, -10], [20, -10], [20, -20], [10, -20], [10, -10]] as [number, number][];714const zoneHole = [[12, -12], [14, -12], [14, -14], [12, -14], [12, -12]] as [number, number][];715716test("an area's holes ride beside its outer rings, closed", () => {717 const outlines = zoneOutlines(zoneShape({ lines: [zoneOuter], holes: [[zoneHole]] }));718 assert.equal(outlines.length, 2);719 assert.deepEqual(outlines.map((held) => held.close), [true, true]);720 assert.equal(outlines[0]?.ring, zoneOuter);721 assert.equal(outlines[1]?.ring, zoneHole);722});723724test("a path's lines stay open", () => {725 const walk = [[0, 0], [30, -5]] as [number, number][];726 const outlines = zoneOutlines(zoneShape({ kind: "path", lines: [walk], holes: [[]] }));727 assert.deepEqual(outlines.map((held) => held.close), [false]);728});729730test("every landed zone vertex sits on its own radius, not on a chord", () => {731 const mesh = zoneMesh(sphere, zoneShape({ lines: [zoneOuter], holes: [[zoneHole]] }),732 degrees, { color: "#88aaff", opacity: 0.85, widthPx: 1.4 }, { width: 800, height: 600 });733 assert.ok(mesh, "a shape with ground drew nothing");734 const start = mesh.geometry.getAttribute("instanceStart");735 assert.ok(start.count > 8, "the segments were not densified");736 const radius = Math.hypot(start.getX(0), start.getY(0), start.getZ(0));737 for (let at = 0; at < start.count; at++) {738 const held = Math.hypot(start.getX(at), start.getY(at), start.getZ(at));739 assert.ok(Math.abs(held - radius) < 0.01,740 `vertex ${at} sits at radius ${held}, the surface is at ${radius}`);741 }742});743744test("a shape with no ground draws nothing on the sphere", () => {745 const mesh = zoneMesh(sphere, zoneShape({ lines: [], holes: [] }),746 degrees, { color: "#88aaff", opacity: 0.85, widthPx: 1.4 }, { width: 800, height: 600 });747 assert.equal(mesh, null);748});749750test("a zone's ground is tessellated onto the sphere, every vertex on its radius", () => {751 const mesh = zoneFill(sphere, zoneShape({ lines: [zoneOuter], holes: [[zoneHole]] }), degrees);752 assert.ok(mesh, "an area with ground filled nothing");753 const position = mesh.geometry.getAttribute("position");754 assert.ok(position.count >= 3 && position.count % 3 === 0,755 `${position.count} vertices is not a triangle list`);756 const radius = Math.hypot(position.getX(0), position.getY(0), position.getZ(0));757 for (let at = 0; at < position.count; at++) {758 const held = Math.hypot(position.getX(at), position.getY(at), position.getZ(at));759 assert.ok(Math.abs(held - radius) < 0.01,760 `vertex ${at} sits at radius ${held}, the surface is at ${radius}`);761 }762 // The fill is dressed the way the chart dresses ground at rest, under the763 // outline, writing no depth.764 const worn = mesh.material as THREE.MeshBasicMaterial;765 assert.equal(worn.opacity, 0.1);766 assert.equal(worn.depthWrite, false);767});768769test("a path fills nothing; only areas are ground", () => {770 const walk = [[0, 0], [30, -5]] as [number, number][];771 assert.equal(zoneFill(sphere, zoneShape({ kind: "path", lines: [walk], holes: [[]] }), degrees), null);772});