import './_planet.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { fakeRenderer } from './importmap.mjs'; const THREE = await import('three'); const { TileLayer, CONCURRENCY } = await import('../js/tilelayer.js'); const { GlobeView } = await import('../js/globe.js'); const { tileOf, tileKey, tilesForPoints } = await import('../js/tiles.js'); function setup() { const view = new GlobeView(fakeRenderer(), () => {}), pending = []; const load = url => new Promise((res, rej) => pending.push({ url, res: () => res(new THREE.Texture()), rej })); const layer = new TileLayer(view, { url: '/tiles/v1/{layer}/{z}/{x}/{y}.jpg', load }); layer.setLayers('relief', ''); return { layer, pending }; } const grid = (lat, lon, d) => Array.from({ length: 49 }, (_, k) => ({ lat: lat + (Math.floor(k / 7) - 3) * d, lon: lon + ((k % 7) - 3) * d })); const flush = () => new Promise(r => setTimeout(r, 0)); async function settle(pending, rounds = 6) { for (let i = 0; i < rounds; i++) { pending.splice(0).forEach(p => p.res()); await flush(); await flush(); } } test('off below the minimum zoom; loading, capped, when zoomed in', () => { const { layer, pending } = setup(); layer.update(50, grid(10, 20, 1)); assert.equal(layer.active, false); assert.equal(pending.length, 0); layer.update(0.5, grid(10, 20, 0.05)); assert.equal(layer.active, true); assert.equal(layer.z, 9); assert.equal(pending.length, CONCURRENCY); assert.match(pending[0].url, /^\/tiles\/v1\/relief\/9\/\d+\/\d+\.jpg$/); }); test('moving away drops queued tiles that are no longer wanted', () => { const { layer, pending } = setup(); layer.update(0.5, grid(10, 20, 0.05)); assert.ok(layer.queue.length > 0); const pts = grid(-40, -100, 0.05); layer.update(0.5, pts); const want = new Set(tilesForPoints(pts, 9, 1).map(tileKey)); assert.ok(layer.queue.every(e => want.has(e.key))); assert.equal(pending.length, CONCURRENCY); // nothing beyond the cap started }); test('loaded tiles show; an ancestor stands in while children load', async () => { const { layer, pending } = setup(); const here = [{ lat: 10, lon: 20 }]; layer.update(1.0, here); // z 8 await settle(pending); layer.update(1.0, here); const parent = layer.tiles.get(tileKey(tileOf(10, 20, 8))); assert.equal(parent.state, 'ready'); assert.equal(parent.mesh.visible, true); layer.update(0.5, here); // z 9: children still loading assert.equal(layer.z, 9); assert.equal(parent.mesh.visible, true); await settle(pending); layer.update(0.5, here); assert.equal(parent.mesh.visible, false); assert.equal(layer.tiles.get(tileKey(tileOf(10, 20, 9))).mesh.visible, true); }); test('switching layers drops every tile', async () => { const { layer, pending } = setup(); layer.update(1.0, [{ lat: 10, lon: 20 }]); await settle(pending); layer.setLayers('biomes', ''); assert.equal(layer.tiles.size, 0); assert.equal(layer.group.children.length, 0); }); test('coverage before sharpness: too many tiles for the budget → one zoom coarser', () => { const { layer, pending } = setup(); const pts = Array.from({ length: 900 }, (_, k) => ({ lat: 10 + (Math.floor(k / 30) - 15) * 0.1, lon: 20 + ((k % 30) - 15) * 0.1 })); layer.update(0.5, pts, 24); // z 9 would need far more than 24 tiles assert.ok(layer.z < 9, `z ${layer.z}`); assert.ok(layer.queue.length + layer.inflight <= 24); assert.equal(pending.length, CONCURRENCY); }); test('suspend (view hidden) stops loading and frees every tile', async () => { const { layer, pending } = setup(); layer.update(0.5, grid(10, 20, 0.05)); assert.ok(layer.queue.length > 0); layer.suspend(); assert.equal(layer.queue.length, 0); assert.equal(layer.tiles.size, 0); const started = pending.length; pending.splice(0).forEach(p => p.res()); await flush(); await flush(); assert.equal(layer.group.children.length, 0, 'late loads are dropped'); assert.equal(pending.length, 0, `nothing new started (had ${started})`); }); test('with an over-layer a tile costs two requests: still at most 6 in flight', () => { const { layer, pending } = setup(); layer.setLayers('relief', 'biomes'); layer.update(0.5, grid(10, 20, 0.05)); assert.equal(pending.length, CONCURRENCY); assert.equal(layer.inflight, CONCURRENCY); }); test('failed tiles are retried after a backoff', async () => { let clock = 0; const view = new GlobeView(fakeRenderer(), () => {}), pending = []; const load = url => new Promise((res, rej) => pending.push({ url, res: () => res(new THREE.Texture()), rej })); const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', load, now: () => clock }); layer.setLayers('relief', ''); const here = [{ lat: 10, lon: 20 }], key = tileKey(tileOf(10, 20, 8)), url = `/t/relief/${key}.jpg`; layer.update(1.0, here); for (let i = 0; i < 3; i++) { pending.splice(0).forEach(p => (p.url === url ? p.rej(new Error('503')) : p.res())); await flush(); await flush(); } assert.equal(layer.tiles.get(key).state, 'error'); layer.update(1.0, here); assert.ok(!pending.some(p => p.url === url), 'not yet'); clock += 60000; layer.update(1.0, here); assert.ok(pending.some(p => p.url === url), 'retried after the backoff'); }); test('the queue follows the current view, its centre tile first', () => { const { layer } = setup(); layer.update(0.5, grid(10, 20, 0.05)); layer.update(0.5, [{ lat: 11.5, lon: 21.5 }, ...grid(10, 20, 0.05)]); assert.equal(layer.queue[0].key, tileKey(tileOf(11.5, 21.5, 9))); }); test('a failed tile wakes the map when its backoff ends (no input needed)', async () => { const view = new GlobeView(fakeRenderer(), () => {}), pending = [], timers = []; let changes = 0; const load = url => new Promise((res, rej) => pending.push({ url, res: () => res(new THREE.Texture()), rej })); const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', load, onChange: () => changes++, later: (fn, ms) => timers.push({ fn, ms }) }); layer.setLayers('relief', ''); layer.update(1.0, [{ lat: 10, lon: 20 }]); pending.splice(0).forEach(p => p.rej(new Error('503'))); await flush(); await flush(); assert.equal(timers.length, CONCURRENCY); assert.equal(timers[0].ms, 2000); const before = changes; timers[0].fn(); assert.equal(changes, before + 1, 'redraw requested → update() retries'); layer.setLayers('biomes', ''); const after = changes; timers[1].fn(); assert.equal(changes, after, 'stale wake-ups (older generation) do nothing'); }); test('a throw while building a tile marks it failed, frees its textures, rejects nothing', async () => { const view = new GlobeView(fakeRenderer(), () => {}), pending = [], unhandled = []; const onRej = e => unhandled.push(e); process.on('unhandledRejection', onRej); const texes = []; const load = url => new Promise(res => pending.push({ url, res: () => { const t = new THREE.Texture(); t.dispose = () => { t.gone = true; }; texes.push(t); res(t); } })); const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', load, later: () => {} }); layer.setLayers('relief', ''); Object.defineProperty(view, 'shade', { get() { throw new Error('boom'); } }); layer.update(1.0, [{ lat: 10, lon: 20 }]); for (let i = 0; i < 6; i++) { pending.splice(0).forEach(p => p.res()); await flush(); await flush(); } process.off('unhandledRejection', onRej); assert.deepEqual(unhandled, []); const e = layer.tiles.get(tileKey(tileOf(10, 20, 8))); assert.equal(e.state, 'error'); assert.ok(texes.every(t => t.gone)); assert.equal(layer.inflight, 0); }); function terrainSetup(meshes = () => Promise.resolve({ h: new Float32Array(129 * 129).fill(1000), water: new Uint8Array(129 * 129) })) { const view = new GlobeView(fakeRenderer(), () => {}), pending = [], urls = []; const load = url => new Promise((res, rej) => pending.push({ url, res: () => res(new THREE.Texture()), rej })); const loadMesh = url => { urls.push(url); return meshes(url); }; const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', meshUrl: '/t/mesh/{z}/{x}/{y}.bin', load, loadMesh }); layer.setLayers('relief', ''); return { layer, pending, urls, view }; } const radius = e => { const a = e.mesh.geometry.attributes.position.array, c = e.mesh.position; return Math.hypot(a[0] + c.x, a[1] + c.y, a[2] + c.z); }; test('terrain: each tile also loads its heights and is displaced by height × exaggeration', async () => { const { R_KM } = await import('../js/geo.js'); const { layer, pending, urls } = terrainSetup(), here = [{ lat: 10, lon: 20 }]; layer.setTerrain(1); layer.update(1.0, here); assert.equal(pending.length, CONCURRENCY / 2, 'heights (≈ 66 KB) count as a full request'); await settle(pending); const e = layer.tiles.get(tileKey(tileOf(10, 20, 8))); assert.equal(e.state, 'ready'); assert.ok(urls.includes(`/t/mesh/${e.key}.bin`)); assert.ok(Math.abs(radius(e) - (1 + 1000 / (R_KM * 1000))) < 1e-7); assert.ok(Math.abs(layer.heightAt(10, 20) - 1000) < 1e-9); }); test('exaggeration reshapes in place; switching 3D on or off reloads every tile', async () => { const { R_KM } = await import('../js/geo.js'); const { layer, pending } = terrainSetup(), here = [{ lat: 10, lon: 20 }]; layer.setTerrain(1); layer.update(1.0, here); await settle(pending); const e = layer.tiles.get(tileKey(tileOf(10, 20, 8))), mesh = e.mesh; layer.setTerrain(2.5); assert.equal(e.mesh, mesh); assert.ok(Math.abs(radius(e) - (1 + 2500 / (R_KM * 1000))) < 1e-7); layer.setTerrain(0); assert.equal(layer.tiles.size, 0); assert.equal(layer.heightAt(10, 20), null); }); test('a bad heights file fails the tile (retry later), never throws', async () => { const { layer, pending } = terrainSetup(() => Promise.reject(new Error('404'))); layer.setTerrain(1); layer.update(1.0, [{ lat: 10, lon: 20 }]); await settle(pending); assert.equal(layer.tiles.get(tileKey(tileOf(10, 20, 8))).state, 'error'); }); test('flat views never go 3D', async () => { const { FlatView } = await import('../js/flatmap.js'); const layer = new TileLayer(new FlatView(fakeRenderer(), 'equal_earth', () => {}), { url: '/t/{layer}/{z}/{x}/{y}.jpg', meshUrl: '/m' }); layer.setTerrain(2); assert.equal(layer.terrain, 0); }); test('tilted: near samples load fine tiles, far samples coarse ones; budget still drops everything a level', () => { const { layer } = setup(); const near = grid(10, 20, 0.002).map(p => ({ ...p, km: 0.05 })), far = grid(10.5, 20, 0.05).map(p => ({ ...p, km: 2 })); layer.update(1, [...near, ...far]); const zs = new Set(layer.queue.map(e => e.t.z)); assert.equal(layer.z, Math.max(...zs)); assert.ok(zs.size >= 2 && Math.min(...zs) <= 8, [...zs].join()); layer.update(1, [...near, ...far], 4); assert.ok(layer.queue.length + layer.inflight <= 4 + CONCURRENCY); assert.ok(layer.z < 13); }); test('heightAt(…, shown) reads only drawn tiles; heightGen bumps when the drawn heights change', async () => { const { layer, pending } = terrainSetup(), here = [{ lat: 10, lon: 20 }]; layer.setTerrain(1); layer.update(1.0, here); // z 8 await settle(pending); layer.update(1.0, here); const g0 = layer.heightGen; assert.ok(g0 > 0); assert.equal(layer.heightAt(10, 20, true), 1000); layer.update(0.25, here); // z 10 wanted: the z 8 tile still stands in, drawn assert.equal(layer.heightAt(10, 20, true), 1000); await settle(pending); layer.update(0.25, here); // children drawn, the z 8 tile hidden (still cached) assert.ok(layer.heightGen > g0); const e8 = layer.tiles.get(tileKey(tileOf(10, 20, 8))); assert.equal(e8.mesh.visible, false); layer.update(50, here); // zoomed out: nothing drawn assert.equal(layer.heightAt(10, 20, true), null); assert.equal(layer.heightAt(10, 20), 1000, 'the camera floor still uses every loaded height'); }); test('3D: a stand-in ancestor never overlaps its loaded descendants (coarse ground would poke through)', async () => { const { parentTile } = await import('../js/tiles.js'); const failing = tileKey(tileOf(10, 20, 10)); const { layer, pending } = terrainSetup(url => (url.includes(`/${failing}.bin`) ? Promise.reject(new Error('500')) : Promise.resolve({ h: new Float32Array(129 * 129).fill(1000), water: new Uint8Array(129 * 129) }))); layer.setTerrain(1); const here = [{ lat: 10, lon: 20 }]; layer.update(1.0, here); // z 8 loads await settle(pending); layer.update(0.25, here); // z 10: one tile keeps failing await settle(pending); layer.update(0.25, here); const shown = [...layer.tiles.values()].filter(e => e.mesh?.visible).map(e => e.t); const keys = new Set(shown.map(tileKey)); assert.ok(shown.some(t => t.z < 10), 'an ancestor stands in for the failing tile'); for (const t of shown) for (let a = parentTile(t); a.z >= 5; a = parentTile(a)) assert.ok(!keys.has(tileKey(a)), `${tileKey(t)} under ${tileKey(a)}`); }); test('a failed tile still in view keeps its place (and its backoff) when the cache trims', async () => { const view = new GlobeView(fakeRenderer(), () => {}), pending = []; const load = url => new Promise((res, rej) => pending.push({ url, res: () => res(new THREE.Texture()), rej })); let clock = 0; const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', load, now: () => clock++, later: () => {} }); layer.setLayers('relief', ''); const here = { lat: 10, lon: 20 }, key = tileKey(tileOf(10, 20, 9)), url = `/t/relief/${key}.jpg`; layer.update(0.5, [here]); for (let i = 0; i < 4; i++) { pending.splice(0).forEach(p => (p.url === url ? p.rej(new Error('503')) : p.res())); await flush(); await flush(); } assert.equal(layer.tiles.get(key).state, 'error'); for (let k = 0; k < 40; k++) layer.update(0.5, [here, ...grid(-30 + k, 100, 0.05)]); // the view keeps the tile, the cache fills up const e = layer.tiles.get(key); assert.ok(layer.tiles.size <= layer.cacheSize, 'the cache did trim'); assert.ok(e && e.fails >= 1, 'kept with its failure count (its backoff survives)'); }); test('a new tile URL (regions rebuilt) drops every tile and reloads', async () => { const { layer, pending } = setup(); layer.update(1.0, [{ lat: 10, lon: 20 }]); await settle(pending); assert.ok(layer.tiles.size > 0); layer.setUrl('/tiles/v4-new/{layer}/{z}/{x}/{y}.jpg', '/tiles/v4-new/mesh/{z}/{x}/{y}.bin'); assert.equal(layer.tiles.size, 0); layer.update(1.0, [{ lat: 10, lon: 20 }]); assert.match(pending.at(-1).url, /^\/tiles\/v4-new\//); }); test('the default back-off timer works where setTimeout must be called on the window (Firefox)', async () => { const orig = globalThis.setTimeout, unhandled = []; const onRej = e => unhandled.push(e); globalThis.setTimeout = function (fn, ms) { // as Firefox: a method call on another object throws if (this !== undefined && this !== globalThis) throw new TypeError("'setTimeout' called on an object that does not implement interface Window."); return orig(fn, ms); }; process.on('unhandledRejection', onRej); try { const view = new GlobeView(fakeRenderer(), () => {}), pending = []; const load = url => new Promise((res, rej) => pending.push({ url, rej })); const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', load }); layer.setLayers('relief', ''); layer.update(1.0, [{ lat: 10, lon: 20 }]); pending.splice(0).forEach(p => p.rej(new Error('404'))); // e.g. a tile of a region set the server just retired await flush(); await flush(); await flush(); assert.deepEqual(unhandled, []); assert.ok([...layer.tiles.values()].some(e => e.state === 'error'), 'the tile backs off'); } finally { globalThis.setTimeout = orig; process.off('unhandledRejection', onRej); } }); test('heights: water on draws the surface at 0 m, water off the floor', async () => { const { MESH_SEGS } = await import('../js/tiles.js'); const n = (MESH_SEGS + 1) ** 2, h = new Float32Array(n).fill(-3000), water = new Uint8Array(n).fill(1); h[0] = 200; water[0] = 0; const view = new GlobeView(fakeRenderer(), () => {}); const tex = () => Promise.resolve(new THREE.Texture()); const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', meshUrl: '/t/mesh/{z}/{x}/{y}.bin', load: tex, loadMesh: () => Promise.resolve({ h, water }) }); layer.setLayers('relief', ''); layer.setTerrain(1); layer.update(0.05, [{ lat: 10, lon: 20 }]); for (let i = 0; i < 20; i++) await flush(); assert.equal(layer.heightAt(10, 20), 0, 'sea: the water surface'); layer.setWater(false); assert.ok(layer.heightAt(10, 20) < -2000, 'water off: the floor'); }); test('3D patches colour near-vertical faces as rock, not the texture at their foot', async () => { const { STEEP_GLSL } = await import('../js/tilelayer.js'); assert.match(STEEP_GLSL, /dFdx\(rel\)/); // slope from patch-relative positions: world ones (|p| ≈ 1) are float32 noise at street zoom assert.match(STEEP_GLSL, /smoothstep\(0\.42, 0\.71/); // cos 65° … cos 45° const { MESH_SEGS } = await import('../js/tiles.js'); const n = (MESH_SEGS + 1) ** 2; const view = new GlobeView(fakeRenderer(), () => {}); const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', meshUrl: '/t/mesh/{z}/{x}/{y}.bin', load: () => Promise.resolve(new THREE.Texture()), loadMesh: () => Promise.resolve({ h: new Float32Array(n), water: new Uint8Array(n) }) }); layer.setLayers('relief', ''); layer.setTerrain(1); layer.update(0.05, [{ lat: 10, lon: 20 }]); for (let i = 0; i < 20; i++) await flush(); const mesh = [...layer.tiles.values()].find(e => e.mesh)?.mesh; assert.match(mesh.material.fragmentShader, /steepColor\(c, vPos, vRel, limb\)/); assert.match(mesh.material.vertexShader, /vRel = mat3\(modelMatrix\) \* position;/); }); test('3D patches: a constant depth offset per zoom, none scaled by slope (it pulled the edge-on skirts over the ground: a line along tile edges)', async () => { const { MESH_SEGS } = await import('../js/tiles.js'); const n = (MESH_SEGS + 1) ** 2; const layer = new TileLayer(new GlobeView(fakeRenderer(), () => {}), { url: '/t/{layer}/{z}/{x}/{y}.jpg', meshUrl: '/t/mesh/{z}/{x}/{y}.bin', load: () => Promise.resolve(new THREE.Texture()), loadMesh: () => Promise.resolve({ h: new Float32Array(n), water: new Uint8Array(n) }) }); layer.setLayers('relief', ''); layer.setTerrain(1); layer.update(0.05, [{ lat: 10, lon: 20 }]); for (let i = 0; i < 20; i++) await flush(); const meshes = [...layer.tiles.values()].filter(e => e.mesh?.geometry.attributes.skirt).map(e => e.mesh); assert.ok(meshes.length > 0); for (const m of meshes) { assert.equal(m.material.polygonOffsetFactor, 0); assert.ok(m.material.polygonOffsetUnits < 0, 'finer zooms still win over a standing-in ancestor'); } }); test('3D patches: a fine grain fades in where the map texture is magnified, with seamless coordinates, never on water', async () => { const { MESH_SEGS, detailOrigin } = await import('../js/tiles.js'); const n = (MESH_SEGS + 1) ** 2, water = new Uint8Array(n); water[0] = 1; const layer = new TileLayer(new GlobeView(fakeRenderer(), () => {}), { url: '/t/{layer}/{z}/{x}/{y}.jpg', meshUrl: '/t/mesh/{z}/{x}/{y}.bin', load: () => Promise.resolve(new THREE.Texture()), loadMesh: () => Promise.resolve({ h: new Float32Array(n), water }) }); layer.setLayers('relief', ''); layer.setTerrain(1); layer.update(0.05, [{ lat: 10, lon: 20 }]); for (let i = 0; i < 20; i++) await flush(); const e = [...layer.tiles.values()].find(t => t.mesh); const m = e.mesh; assert.deepEqual([...m.material.uniforms.detailOrg.value.toArray()], detailOrigin(e.t)); assert.equal(m.geometry.attributes.wet.array[0], 1); assert.equal(m.geometry.attributes.wet.array[1], 0); assert.equal(m.material.uniforms.waterOn.value, 1); layer.setWater(false); assert.equal(m.material.uniforms.waterOn.value, 0, 'water off: the sea floor gets its grain too'); assert.match(m.material.fragmentShader, /fineGrain\(/); assert.match(m.material.fragmentShader, /fwidth\(/, 'fades in by how magnified the texture is'); });