From 3cbc17a92604e158681094e18b0051cc5449868f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 14:05:58 +0400 Subject: [PATCH] Match Navara satellite landscapes to the Re:Earth Fuji view --- DATA_SOURCES.md | 1 + docs/SHARE-LINK-FORMAT.md | 2 +- scripts/package-boundaries.json | 4 ++ scripts/qa-engine-switch.mjs | 17 +++++++ scripts/qa-map-source-tray.mjs | 4 +- src/firstRunExperience.test.mjs | 4 ++ src/globe/cesium/maps.js | 2 +- src/globe/navara/index.js | 13 ++++++ src/globe/navara/look.js | 64 ++++++++++++++++++++------- src/globe/navara/look.test.mjs | 58 +++++++++++++++--------- src/globe/navara/maps.js | 29 ++++++++++-- src/globe/navara/maps.test.mjs | 15 +++++++ src/globe/navara/viewOptions.js | 55 ++++++++++------------- src/globe/navara/viewOptions.test.mjs | 42 +++++------------- src/hud.js | 2 + src/layers/submarineCables/policy.js | 2 +- src/mapStackChips.js | 1 + src/mapStackChips.test.mjs | 24 ++++++++-- src/maps/catalog.js | 7 +++ src/maps/imagery.js | 12 +++++ src/maps/sentinel2.js | 6 +++ src/share/viewCodec.js | 1 + src/view/index.js | 1 + src/voice/actionSchemas.js | 1 + src/voice/actionSchemas.test.mjs | 4 +- src/voice/gevActions.js | 3 ++ 26 files changed, 262 insertions(+), 112 deletions(-) create mode 100644 src/maps/sentinel2.js diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index a4035f175..442a5195d 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -33,6 +33,7 @@ How to read this: | **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) | | **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" | | **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) | +| **EOX Sentinel-2 cloudless 2020** | Optional satellite basemap in Cesium and Navara. Navara uses daylight for terrain inspection. | [EOX Maps](https://maps.eox.at/) and [imagery licence](https://cloudless.eox.at/) | Sentinel-2 cloudless 2020 by EOX IT Services GmbH (contains modified Copernicus Sentinel data 2020) | | **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) | | **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" | | **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" | diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index 5728d1ec3..f4079e5b4 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -106,7 +106,7 @@ probability of about 255 in 256 (many of them already fail as base64url). | 12 | `panelState` | LEN | none | packed panel entries, see panels | | 13 | thing | LEN | none | sub-message, see thing views | | 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow (retired) | -| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm | +| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm, sentinel2 | | 16 | `hudVariant` | enum | style | tactical, operator (retired), minimal, cyber | | 17 | `bloomIntensity` | uint | style | | | 18 | `bloomVersion` | uint | 2 | | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 584d68a7a..81ce4d4df 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2450,6 +2450,7 @@ "src/maps/defaultSources.js", "src/maps/google3d.js", "src/maps/imagery.js", + "src/maps/sentinel2.js", "src/maps/registry.js", "src/maps/terrain.js", "src/maps/terrainRetry.js", @@ -3009,6 +3010,7 @@ "src/maps/defaultSources.js", "src/maps/google3d.js", "src/maps/imagery.js", + "src/maps/sentinel2.js", "src/maps/registry.js", "src/maps/terrain.js", "src/maps/terrainRetry.js", @@ -4285,6 +4287,7 @@ "src/maps/credits.js", "src/globe/cesium/surface.js", "src/maps/imagery.js", + "src/maps/sentinel2.js", "src/services/groundSnap.js", "src/maps/google3d.js", "src/globe/cesium/input.js", @@ -5054,6 +5057,7 @@ "src/maps/google3d.js", "src/services/groundSnap.js", "src/maps/imagery.js", + "src/maps/sentinel2.js", "src/globe/cesium/surface.js", "src/maps/credits.js", "src/maps/sceneApply.js", diff --git a/scripts/qa-engine-switch.mjs b/scripts/qa-engine-switch.mjs index d0599eb6c..98f2c956d 100644 --- a/scripts/qa-engine-switch.mjs +++ b/scripts/qa-engine-switch.mjs @@ -196,6 +196,23 @@ try { }); await switchTo('navara'); assert.equal(await stored(), 'cesium'); + // The real Map Source chips move into daylight inspection and back to live lighting. + await page.$eval('[data-stack-id="sentinel2"]', (el) => el.click()); + await page.waitForFunction( + () => + window.__godsEyeView.mapStackController.getActiveId() === 'sentinel2' && + window.__godsEyeView.engine.host.container.hasAttribute( + 'data-terrain-daylight', + ), + ); + await page.$eval('[data-stack-id="osm"]', (el) => el.click()); + await page.waitForFunction( + () => + window.__godsEyeView.mapStackController.getActiveId() === 'osm' && + !window.__godsEyeView.engine.host.container.hasAttribute( + 'data-terrain-daylight', + ), + ); assert.deepEqual(errors, []); console.log( 'PASS Cesium default, both switches, same document, warm engine identity, parked canvas, no startup splash, share pose/map/layers, Back/Forward, remembered engine, blocked preference storage', diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 231725997..523762742 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -316,9 +316,9 @@ try { controls: document.getElementById('control-panel-toggle')?.getAttribute('aria-controls'), })); check( - 'exact five-source presentation; the retired left Map Stack panel is gone', + 'exact six-source presentation; the retired left Map Stack panel is gone', JSON.stringify(presentation.ids) === JSON.stringify([ - 'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm', + 'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'sentinel2', 'osm', ]) && !presentation.retiredPanel, JSON.stringify(presentation), ); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 56548c1da..5f45fa119 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -985,6 +985,10 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter( (tool) => tool.name !== 'set_cyber_sonar', ); + // The EOX basemap extends the map enum, independently of the mission tools. + const stacks = legacyTools.find((tool) => tool.name === 'set_map_stack') + .parameters.properties.stack.enum; + assert.equal(stacks.pop(), 'sentinel2'); const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud') .parameters.properties.layout; assert.deepEqual(hudLayout.enum, ['tactical', 'minimal', 'cyber']); diff --git a/src/globe/cesium/maps.js b/src/globe/cesium/maps.js index eaf2cbd00..6afdd2bb2 100644 --- a/src/globe/cesium/maps.js +++ b/src/globe/cesium/maps.js @@ -12,7 +12,7 @@ import { } from '../../maps/imagery.js'; import { applyTerrain, terrainSourceFor } from '../../maps/terrain.js'; -const IMAGERY_KINDS = new Set(['osm', 'esri-imagery', 'ion']); +const IMAGERY_KINDS = new Set(['osm', 'esri-imagery', 'sentinel2', 'ion']); function imageryCreator(source) { if (typeof source?.imagery === 'function') return source.imagery; diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 30244e159..2bf7485b2 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -116,6 +116,8 @@ export async function createNavaraGlobe({ const plugin = new DefaultPlugin(); view.addPlugin(plugin); await view.init(); + const automaticLandscape = + !effects && new URLSearchParams(search).get('sun') !== 'live'; const plan = navaraScenePlan(effects ?? resolveNavaraLook(search), { mobile: Boolean(view.isMobileOptimized?.()), }); @@ -172,12 +174,22 @@ export async function createNavaraGlobe({ if (!moving && !groundSettled && engine.surface.tilesLoaded()) readGround(); const placed = readCameraPlace(camera); + const landscape = + placed && + lookCarto.height < 100_000 && + plan.globeLighting && + automaticLandscape && + (engine.maps.baseKind === 'sentinel2' || + engine.maps.baseKind === 'esri-imagery'); + if (container.hasAttribute('data-terrain-daylight') !== landscape) + container.toggleAttribute('data-terrain-daylight', landscape); if ( look.frame( placed ? lookCarto.height : Number.NaN, moving, ground, placed ? lookPlace : null, + landscape, ) ) engine.render.request('navara-look'); @@ -206,6 +218,7 @@ export async function createNavaraGlobe({ destroy() { if (destroyed) return; destroyed = true; + container.removeAttribute('data-terrain-daylight'); removeLook(); removePostSampling(); engine.destroy(); diff --git a/src/globe/navara/look.js b/src/globe/navara/look.js index f9a59a666..020e106f1 100644 --- a/src/globe/navara/look.js +++ b/src/globe/navara/look.js @@ -3,8 +3,7 @@ // (the engine clock's frame time in the app: the app clock, which is the wall // clock the HUD's SUN elevation reads unless QA moves it); the cinematic look // lights the globe -// from the atmosphere when seen from space, shows its clouds only for low -// views over low ground and while the sun is up, brightens the atmosphere +// from the atmosphere when seen from space, places its clouds above the terrain while the sun is up, brightens the atmosphere // (createLuminanceScaler, uniforms from internals.js), compiles both // night-side shader variants at startup and shows its stars only at night // or from space. All of it runs only on frames the view draws anyway, so an @@ -12,7 +11,11 @@ import { cloudsSunlitAt, - cloudsVisibleAt, + ACES_FILMIC, + AGX, + cloudTerrainOffset, + landscapeSunTime, + CUMULUS_BASES, globeLightingAt, starsVisibleAt, sunDateStale, @@ -82,14 +85,30 @@ export function buildNavaraLook( let warmUp = aerial ? 2 : 0; let cloudsShown = true; let cloudsSunlit = true; + let landscape = false; + const tone = effect('toneMapping'); let starsShown = true; - const frame = (height, moving = false, ground = Number.NaN, place = null) => { + const frame = ( + height, + moving = false, + ground = Number.NaN, + place = null, + landscapeRequested = false, + ) => { // Every drawn frame: a material Navara rebuilds comes back unscaled. - let changed = brighten(); + let changed = brighten(!landscapeRequested); + if (landscapeRequested !== landscape && tone) { + landscape = landscapeRequested; + view.toneMappingExposure = landscape ? 10 : 1; + tone.update({ toneMapping: { mode: landscape ? AGX : ACES_FILMIC } }); + changed = true; + } const atmosphere = view.atmosphere; - if (atmosphere && sunDateStale(atmosphere.date.getTime(), now())) { - atmosphere.date = new Date(now()); + const time = + landscape && place ? landscapeSunTime(now(), place.lng) : now(); + if (atmosphere && sunDateStale(atmosphere.date.getTime(), time)) { + atmosphere.date = new Date(time); changed = true; } const elevation = @@ -116,12 +135,24 @@ export function buildNavaraLook( changed = true; } } - // Clouds only for a camera below them over low ground, and while the - // sun is up at the camera (after sunset they read as dark puffs); - // switching them costs no recompile, so it follows the camera in motion. + // Terrain-relative layers keep distant cumulus above mountain ridges. + // Keep a downward view clear once the camera rises above the cloud deck. if (clouds) { + const offset = cloudTerrainOffset(height, ground); + const pass = clouds.ref?.raw; + const layers = pass?.cloudLayers; + if (layers) { + for (let i = 0; i < CUMULUS_BASES.length; i++) { + const altitude = CUMULUS_BASES[i] + offset; + if (layers[i].altitude !== altitude) { + layers[i].altitude = altitude; + changed = true; + } + } + } cloudsSunlit = cloudsSunlitAt(elevation, cloudsSunlit); - const next = cloudsSunlit && cloudsVisibleAt(height, ground, cloudsShown); + const ceiling = CUMULUS_BASES[0] + offset + (cloudsShown ? 800 : 500); + const next = cloudsSunlit && height <= ceiling; if (next !== cloudsShown) { cloudsShown = next; clouds.update(next ? VISIBLE : HIDDEN); @@ -175,13 +206,15 @@ export function createLuminanceScaler(scale, forEachUniform, handles) { } return found; }; + let applyScale = true; const record = (key, uniform) => entry(key, uniform.value); const scaleOne = (key, uniform, scaled) => { if (!scaled) return; - const { target } = entry(key, uniform.value); + const { base, target } = entry(key, uniform.value); + const desired = applyScale ? target : base; scaledVectors.add(uniform.value); - if (uniform.value.equals(target)) return; - uniform.value.copy(target); + if (uniform.value.equals(desired)) return; + uniform.value.copy(desired); changed = true; }; const keepBase = (key, uniform, scaled) => { @@ -195,7 +228,8 @@ export function createLuminanceScaler(scale, forEachUniform, handles) { changed = true; } }; - return () => { + return (enabled = true) => { + applyScale = enabled; changed = false; // Bases first, from the values as Navara built them. forEachUniform(handles, record); diff --git a/src/globe/navara/look.test.mjs b/src/globe/navara/look.test.mjs index 6d2a12974..66060ac45 100644 --- a/src/globe/navara/look.test.mjs +++ b/src/globe/navara/look.test.mjs @@ -128,26 +128,21 @@ test('from space the globe is lit by the atmosphere; near the ground the tiles k }); }); -test('the clouds show only below their tops, switching in motion too', () => { +test('cumulus rises over mountain terrain and leaves the orbital view clear', () => { const view = fakeView(0); const look = warmCinematic(view); const clouds = view.added.find(({ desc }) => Object.hasOwn(desc, 'clouds')); - assert.equal(look.frame(900, true), false, 'shown at street level'); - assert.equal(look.frame(20_000, true), true); + const layers = [{ altitude: 1500 }, { altitude: 1800 }]; + const pass = { cloudLayers: layers }; + clouds.ref = { raw: pass }; + assert.equal(look.frame(4350, false, 3100), true); + assert.deepEqual(layers, [{ altitude: 6000 }, { altitude: 6300 }]); + assert.equal(look.frame(4350, false, 3100), false); + look.frame(8_000_000, true, 3100); + assert.deepEqual(layers, [{ altitude: 1500 }, { altitude: 1800 }]); assert.deepEqual(clouds.updates, [{ visible: false }]); - assert.equal(look.frame(3_800, true), false, 'hysteresis'); - assert.equal( - view.atmosphere.overlay.value, - null, - 'no ghost of the last clouds', - ); - assert.equal(view.atmosphere.shadowLength.value, null); - assert.equal(look.frame(3_000, true), true); + look.frame(900, false, 0); assert.deepEqual(clouds.updates.at(-1), { visible: true }); - assert.equal(view.atmosphere.overlay.value, 'overlay', 'put back'); - assert.equal(view.atmosphere.shadowLength.value, 'shafts'); - assert.equal(look.frame(3_000, false, 2_100), true, 'over high ground'); - assert.deepEqual(clouds.updates.at(-1), { visible: false }); }); test('globe lighting switches on only at rest, and off even in motion', () => { @@ -156,8 +151,8 @@ test('globe lighting switches on only at rest, and off even in motion', () => { const aerial = view.added.find(({ desc }) => Object.hasOwn(desc, 'aerialPerspective'), ); - look.frame(20_000, true); // the clouds hide on the way up - assert.equal(look.frame(2_300_000, true), false, 'a flight through space'); + look.frame(20_000, true); + look.frame(2_300_000, true); assert.deepEqual(aerial.updates, []); assert.equal(look.frame(8_000_000, false), true, 'at rest'); assert.equal(look.frame(500_000, true), true, 'coming down'); @@ -166,6 +161,26 @@ test('globe lighting switches on only at rest, and off even in motion', () => { }); }); +test('daylight inspection restores live exposure, tone mapping and time when left', () => { + const view = fakeView(0); + const look = warmCinematic(view); + const tone = view.added.find(({ desc }) => + Object.hasOwn(desc, 'toneMapping'), + ); + const fuji = { lat: 35.2599, lng: 138.6574 }; + look.frame(6508, false, 2600, fuji, true); + assert.equal(view.toneMappingExposure, 10); + assert.equal(tone.updates.at(-1).toneMapping.mode, 7); + assert.ok( + Math.abs(view.atmosphere.date.getTime() / 3_600_000 + fuji.lng / 15 - 13) < + 1e-6, + ); + look.frame(6508, false, 2600, fuji, false); + assert.equal(view.toneMappingExposure, 1); + assert.equal(tone.updates.at(-1).toneMapping.mode, 6); + assert.equal(view.atmosphere.date.getTime(), 0); +}); + test('the sun follows the wall clock, a minute at most behind, only on drawn frames', () => { let now = 1_000_000; const view = fakeView(now); @@ -361,13 +376,16 @@ test('the cinematic clouds hide after sunset at the camera', () => { const place = { lat: 40.7, lng: -74 }; assert.equal(look.frame(900, false, 0, place), true, 'the stars hide'); assert.deepEqual(clouds.updates, []); - elevation = -1; + elevation = -8; assert.equal(look.frame(900, false, 0, place), true); assert.deepEqual(clouds.updates, [{ visible: false }]); - elevation = 1; + elevation = -5; look.frame(900, false, 0, place); assert.deepEqual(clouds.updates, [{ visible: false }], 'hysteresis'); - elevation = 3; + assert.equal(view.atmosphere.overlay.value, null); + look.frame(8_000_000, false, 0, place); + assert.deepEqual(clouds.updates.at(-1), { visible: false }); + elevation = -2; look.frame(900, false, 0, place); assert.deepEqual(clouds.updates.at(-1), { visible: true }); }); diff --git a/src/globe/navara/maps.js b/src/globe/navara/maps.js index ac223c598..97db86f55 100644 --- a/src/globe/navara/maps.js +++ b/src/globe/navara/maps.js @@ -17,6 +17,12 @@ // it back until setBaseImagery. Hidden, not deleted: deleting the last // raster layer stops Navara 0.2.1 loading terrain at the current view. +import { + SENTINEL2_CREDIT, + SENTINEL2_MAX_ZOOM, + SENTINEL2_URL, +} from '../../maps/sentinel2.js'; + const OSM_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; const OSM_CREDIT = { attribution: '© OpenStreetMap contributors', @@ -46,6 +52,12 @@ function imageryFor(source) { const credit = source?.credit != null ? { attributionHtml: String(source.credit) } : null; switch (kind) { + case 'sentinel2': + return { + url: SENTINEL2_URL, + maxZoom: SENTINEL2_MAX_ZOOM, + credit: { attributionHtml: SENTINEL2_CREDIT }, + }; case 'osm': return { url: OSM_URL, credit: credit ?? OSM_CREDIT }; case 'esri-imagery': @@ -165,6 +177,8 @@ export function createNavaraMaps( type: 'quantized-mesh', url: KEYLESS_TERRAIN_URL, maxZoom: KEYLESS_TERRAIN_MAX_ZOOM, + requestVertexNormals: true, + requestWaterMask: true, }; case 'world': { const token = String(source.accessToken || '').trim(); @@ -195,16 +209,23 @@ export function createNavaraMaps( const knownTiles = new Set(); const maps = { + get baseKind() { + return kindOf(currentBase); + }, async setBaseImagery(source) { const generation = ++generations.display; - const { url, credit } = imageryFor(source); + const { url, credit, maxZoom } = imageryFor(source); if (destroyed || generation !== generations.display) return; baseSource = source; currentBase = source; if (slots.imagery?.key !== url) - show('imagery', url, { type: 'raster-tile', url }, { type: 'raster' }, [ - credit, - ]); + show( + 'imagery', + url, + { type: 'raster-tile', url, ...(maxZoom && { maxZoom }) }, + { type: 'raster' }, + [credit], + ); // Under tiles the base imagery waits hidden, credit included. setImageryShown(slots.photoreal == null); }, diff --git a/src/globe/navara/maps.test.mjs b/src/globe/navara/maps.test.mjs index 3c16af355..3737af792 100644 --- a/src/globe/navara/maps.test.mjs +++ b/src/globe/navara/maps.test.mjs @@ -90,6 +90,21 @@ test('setBaseImagery builds Navara raster layers for OSM and Esri, one at a time assert.ok(view.frames > 0); }); +test('Sentinel-2 uses the EOX mosaic with attribution and a capped source zoom', async () => { + const view = fakeView(); + const { maps } = createNavaraMaps(view); + await maps.setBaseImagery({ kind: 'sentinel2' }); + assert.equal(maps.baseKind, 'sentinel2'); + assert.match(view.sources[0].desc.url, /s2cloudless-2020_3857/); + assert.equal(view.sources[0].desc.maxZoom, 14); + assert.match( + view.shown[0].attributionHtml, + /modified Copernicus Sentinel data 2020/, + ); + await maps.setBaseImagery({ kind: 'osm' }); + assert.equal(maps.baseKind, 'osm'); +}); + test('the same imagery source again keeps the live layer', async () => { const view = fakeView(); const { maps } = createNavaraMaps(view); diff --git a/src/globe/navara/viewOptions.js b/src/globe/navara/viewOptions.js index 4cffab0d0..4500cd035 100644 --- a/src/globe/navara/viewOptions.js +++ b/src/globe/navara/viewOptions.js @@ -50,6 +50,16 @@ export function resolveNavaraLook(search = '') { // postprocessing's ToneMappingMode.ACES_FILMIC (Navara's default is AGX, 7). export const ACES_FILMIC = 6; +export const AGX = 7; + +// The Sentinel-2 landscape uses early afternoon at the camera longitude, +// independently of the telemetry clock. Only the displayed sunlight moves. +export function landscapeSunTime(now, longitude) { + const hour = 3_600_000; + const day = 24 * hour; + const offset = (longitude / 15) * hour; + return Math.floor((now + offset) / day) * day + 13 * hour - offset; +} // Camera heights (m) where the cinematic look switches the globe to // atmosphere lighting (night side and terminator) and back. The gap keeps a @@ -70,28 +80,14 @@ export function globeLightingAt(height, current = false) { : height >= GLOBE_LIGHTING_ENTER_M; } -// Where the cinematic clouds show. Their layers span 1,500 to 3,000 m above -// the ellipsoid. Above 4 km the camera would look down on them, and they -// would hide the map under clouds that are a backdrop, not weather; over -// ground above 1,000 m (canyons, the Alps) they would cut into the terrain -// as dark puffs. -// Each limit has a gap so a camera near it does not flicker them. -export const CLOUDS_HIDE_ABOVE_M = 4_000; -export const CLOUDS_SHOW_BELOW_M = 3_500; -export const CLOUDS_HIDE_OVER_GROUND_M = 1_000; -export const CLOUDS_SHOW_OVER_GROUND_M = 800; - -/** - * Whether the cinematic clouds show for a camera at `height` looking at - * ground at `ground` (both metres above the ellipsoid; an unknown ground - * counts as low). - */ -export function cloudsVisibleAt(height, ground, current = true) { - if (!Number.isFinite(height)) return current; - const low = Number.isFinite(ground) ? ground : 0; - return current - ? height <= CLOUDS_HIDE_ABOVE_M && low <= CLOUDS_HIDE_OVER_GROUND_M - : height < CLOUDS_SHOW_BELOW_M && low < CLOUDS_SHOW_OVER_GROUND_M; +// Navara cloud layers use ellipsoid heights. Over uplands raise the two +// cumulus layers together; at world scale a single ground sample no longer +// represents the whole view. Quantize the offset to avoid tiny layer updates. +export const LOCAL_CLOUD_COVERAGE = 0.22; +export const CUMULUS_BASES = Object.freeze([1500, 1800]); +export function cloudTerrainOffset(height, ground) { + if (!Number.isFinite(ground) || height >= STARS_SPACE_ENTER_M) return 0; + return Math.ceil(Math.max(0, ground - 500) / 500) * 750; } // Navara's sky, clouds and haze come from the atmosphere model in physical @@ -113,10 +109,9 @@ export const STARS_HIDE_SUN_ABOVE_DEG = -4; export const STARS_SPACE_ENTER_M = 100_000; export const STARS_SPACE_EXIT_M = 80_000; -// The cinematic clouds hide once the sun at the camera sets (below 0°): -// unlit, they read as dark puffs over a dusk sky. They show again above 2°. -export const CLOUDS_HIDE_SUN_BELOW_DEG = 0; -export const CLOUDS_SHOW_SUN_ABOVE_DEG = 2; +// Keep the sunlit cloud tops through twilight. Hide them locally at night. +export const CLOUDS_HIDE_SUN_BELOW_DEG = -6; +export const CLOUDS_SHOW_SUN_ABOVE_DEG = -4; /** Whether the sun is up enough for the cinematic clouds; NaN keeps `current`. */ export function cloudsSunlitAt(sunElevation, current = true) { @@ -177,17 +172,15 @@ const cinematic = (mobile) => ({ : [ { clouds: { - // 'low' looks the same here and costs about 1 ms less than - // 'medium' (clouds cost about 3 ms a frame at 1280×800). qualityPreset: 'low', - coverage: 0.2, + coverage: LOCAL_CLOUD_COVERAGE, // No cloud shadows on the ground: dark patches over the map // read as features (and cost nothing measurable either way). shadows: false, // The two cumulus layers start at 1,500 m instead of 750 and // 1,000 m, above the city views (Austin's 600 m, Manhattan's // 900 m): clouds fill the sky without hiding the streets. - cloudLayers: [{ altitude: 1500 }, { altitude: 1800 }], + cloudLayers: CUMULUS_BASES.map((altitude) => ({ altitude })), }, }, { lensFlare: {} }, @@ -229,7 +222,7 @@ const PLANS = { * - `photoreal`: the default plugin's addDefaultPhotorealScene (sky, sun, * aerial perspective, lens flare, AgX tone mapping, SMAA); * - `cinematic` (the app's default): photoreal plus volumetric clouds for - * low views over low ground (cloudsVisibleAt), ACES tone mapping, brighter stars, no sun shadow + * mountain and coastal views, ACES tone mapping, brighter stars, no sun shadow * maps, and atmosphere lighting of the globe from space (`globeLighting`, * see globeLightingAt), a brighter atmosphere (ATMOSPHERE_LUMINANCE_SCALE) * and stars only at night or from space (starsVisibleAt). On a mobile-optimized diff --git a/src/globe/navara/viewOptions.test.mjs b/src/globe/navara/viewOptions.test.mjs index c64a56675..ed96520bf 100644 --- a/src/globe/navara/viewOptions.test.mjs +++ b/src/globe/navara/viewOptions.test.mjs @@ -6,11 +6,7 @@ import { GLOBE_LIGHTING_EXIT_M, NAVARA_CONTEXT_ATTRIBUTES, NAVARA_LOOKS, - CLOUDS_HIDE_ABOVE_M, - CLOUDS_HIDE_OVER_GROUND_M, - CLOUDS_SHOW_BELOW_M, - CLOUDS_SHOW_OVER_GROUND_M, - cloudsVisibleAt, + cloudTerrainOffset, globeLightingAt, navaraScenePlan, navaraViewOptions, @@ -106,7 +102,7 @@ test('the cinematic look: photoreal plus clouds and ACES, no sun shadow maps, no useNormalBuffer: false, albedoScale: 2, }); - assert.equal(clouds.clouds.coverage, 0.2); + assert.equal(clouds.clouds.coverage, 0.22); assert.equal(clouds.clouds.shadows, false, 'no cloud shadow maps'); assert.ok( clouds.clouds.cloudLayers.every(({ altitude }) => altitude >= 1000), @@ -154,30 +150,12 @@ test('globe lighting turns on from space and off near the ground, with hysteresi assert.ok(GLOBE_LIGHTING_EXIT_M < GLOBE_LIGHTING_ENTER_M); }); -test('the clouds show for low views over low ground, with hysteresis', () => { - assert.equal(cloudsVisibleAt(900, 10, true), true); - assert.equal(cloudsVisibleAt(900, Number.NaN, true), true, 'unknown ground'); - assert.equal(cloudsVisibleAt(CLOUDS_HIDE_ABOVE_M, 0, true), true); - assert.equal(cloudsVisibleAt(CLOUDS_HIDE_ABOVE_M + 1, 0, true), false); - assert.equal(cloudsVisibleAt(3_800, 0, false), false, 'inside the gap'); - assert.equal(cloudsVisibleAt(CLOUDS_SHOW_BELOW_M - 1, 0, false), true); - assert.equal(cloudsVisibleAt(3_000, 2_100, true), false, 'over a canyon rim'); - assert.equal(cloudsVisibleAt(3_000, 900, false), false, 'inside the gap'); - assert.equal( - cloudsVisibleAt(3_000, CLOUDS_SHOW_OVER_GROUND_M - 1, false), - true, - ); - assert.equal(cloudsVisibleAt(CLOUDS_HIDE_OVER_GROUND_M, 0, true), true); - assert.equal( - cloudsVisibleAt(Number.NaN, 0, false), - false, - 'unknown keeps it', - ); - const layers = navaraScenePlan('cinematic').effects[1].clouds.cloudLayers; - assert.ok( - layers[0].altitude - CLOUDS_HIDE_OVER_GROUND_M >= 500, - 'cloud base well above the highest ground they show over', - ); +test('cumulus layers rise over mountains, with sea-level bases from space', () => { + assert.equal(cloudTerrainOffset(900, 10), 0); + assert.equal(cloudTerrainOffset(4350, 3100), 4500); + assert.equal(cloudTerrainOffset(4350, 3200), 4500); + assert.equal(cloudTerrainOffset(8_000_000, 3100), 0); + assert.equal(cloudTerrainOffset(4350, Number.NaN), 0); }); test('the sun date is stale once it drifts a minute from the clock', () => { @@ -221,8 +199,8 @@ test('stars show at night and from space, not in a daylit sky, with hysteresis', test('the clouds hide once the sun sets at the camera, with hysteresis', () => { assert.equal(cloudsSunlitAt(40, true), true); assert.equal(cloudsSunlitAt(CLOUDS_HIDE_SUN_BELOW_DEG - 0.1, true), false); - assert.equal(cloudsSunlitAt(1, false), false, 'inside the gap'); - assert.equal(cloudsSunlitAt(1, true), true, 'inside the gap'); + assert.equal(cloudsSunlitAt(-5, false), false, 'inside the gap'); + assert.equal(cloudsSunlitAt(-5, true), true, 'inside the gap'); assert.equal(cloudsSunlitAt(CLOUDS_SHOW_SUN_ABOVE_DEG + 0.1, false), true); assert.equal(cloudsSunlitAt(Number.NaN, false), false, 'unknown keeps it'); }); diff --git a/src/hud.js b/src/hud.js index ac258f992..1d8561f99 100644 --- a/src/hud.js +++ b/src/hud.js @@ -500,6 +500,8 @@ export class IntelHUD { const sunEl = this._estimateSunElevation(latDeg, lonDeg); // i18n-ignore: sensor codes readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`; + if (this.engine.host.container?.hasAttribute?.('data-terrain-daylight')) + readouts.alt += ' · DAYLIGHT VIEW'; // Collection timestamp const now = new Date(); diff --git a/src/layers/submarineCables/policy.js b/src/layers/submarineCables/policy.js index 68009af2e..89c18ca8c 100644 --- a/src/layers/submarineCables/policy.js +++ b/src/layers/submarineCables/policy.js @@ -78,7 +78,7 @@ export const CABLE_LABEL_DEPTH_DECISION = Object.freeze({ */ export const CABLE_GLOBE_STACK_IDS = Object.freeze( - new Set(['bing-aerial', 'bing-labels', 'esri-imagery', 'osm']), + new Set(['bing-aerial', 'bing-labels', 'esri-imagery', 'osm', 'sentinel2']), ); /** EntityCluster's private marker collections and their required types. */ diff --git a/src/mapStackChips.js b/src/mapStackChips.js index fed2880b3..9c1738442 100644 --- a/src/mapStackChips.js +++ b/src/mapStackChips.js @@ -18,6 +18,7 @@ export const PRESENTED_MAP_STACK_IDS = Object.freeze([ 'bing-aerial', 'bing-labels', 'esri-imagery', + 'sentinel2', 'osm', ]); diff --git a/src/mapStackChips.test.mjs b/src/mapStackChips.test.mjs index b97486b3d..59c79a6ac 100644 --- a/src/mapStackChips.test.mjs +++ b/src/mapStackChips.test.mjs @@ -48,6 +48,7 @@ const CONTROLLER_STACKS = [ available: true, unavailableReason: null, }, + { id: 'sentinel2', label: 'EOX Sentinel-2', available: true }, { id: 'osm', label: 'OSM', @@ -77,21 +78,36 @@ function publishedChips(stacks, activeId) { return views.at(-1).chips; } -test('the row presents exactly the five owner-approved sources', () => { +test('the row presents exactly the six map sources', () => { const chips = mapStackChipModels(CONTROLLER_STACKS, 'photoreal'); assert.deepEqual( chips.map((chip) => chip.id), - ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm'], + [ + 'photoreal', + 'bing-aerial', + 'bing-labels', + 'esri-imagery', + 'sentinel2', + 'osm', + ], ); assert.deepEqual( chips.map((chip) => chip.label), - ['Google 3D', 'Bing Aerial', 'Bing Labels', 'Esri Satellite', 'OSM'], + [ + 'Google 3D', + 'Bing Aerial', + 'Bing Labels', + 'Esri Satellite', + 'EOX Sentinel-2', + 'OSM', + ], ); assert.deepEqual(PRESENTED_MAP_STACK_IDS, [ 'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', + 'sentinel2', 'osm', ]); }); @@ -105,7 +121,7 @@ test('internal and future stacks stay outside the approved presentation set', () ]; const chips = mapStackChipModels(withHybrid, 'photoreal'); - assert.equal(chips.length, 5); + assert.equal(chips.length, 6); assert.ok(chips.every((chip) => chip.id !== 'hybrid')); }); diff --git a/src/maps/catalog.js b/src/maps/catalog.js index 238263ba9..8934e63b3 100644 --- a/src/maps/catalog.js +++ b/src/maps/catalog.js @@ -30,6 +30,13 @@ export const MAP_STACKS = [ kind: 'esri-imagery', requiresIon: false, }, + { + id: 'sentinel2', + label: 'EOX Sentinel-2', // i18n-ignore: imagery product name + shortLabel: 'EOX', + kind: 'sentinel2', + requiresIon: false, + }, { id: 'osm', label: 'OSM', // i18n-ignore: imagery product name diff --git a/src/maps/imagery.js b/src/maps/imagery.js index a106e2a5a..66e0fd222 100644 --- a/src/maps/imagery.js +++ b/src/maps/imagery.js @@ -1,4 +1,9 @@ import * as Cesium from 'cesium'; +import { + SENTINEL2_CREDIT, + SENTINEL2_MAX_ZOOM, + SENTINEL2_URL, +} from './sentinel2.js'; // Attribution and service rights are documented in DATA_SOURCES.md. export const ESRI_ATTRIBUTION_HTML = @@ -42,6 +47,13 @@ export function createIonImagery(style, accessToken) { * @returns {(request?: { signal?: AbortSignal }) => object | Promise} */ export function imageryFactoryFor(descriptor, ionToken = '') { + if (descriptor.kind === 'sentinel2') + return () => + new Cesium.UrlTemplateImageryProvider({ + url: SENTINEL2_URL, + maximumLevel: SENTINEL2_MAX_ZOOM, + credit: new Cesium.Credit(SENTINEL2_CREDIT, true), + }); if (descriptor.kind === 'ion') return () => createIonImagery(descriptor.style, ionToken); return descriptor.kind === 'osm' ? createOsmImagery : createEsriImagery; diff --git a/src/maps/sentinel2.js b/src/maps/sentinel2.js new file mode 100644 index 000000000..c6717058e --- /dev/null +++ b/src/maps/sentinel2.js @@ -0,0 +1,6 @@ +// EOX's Sentinel-2 cloudless 2020 mosaic, also used by Re:Earth Terrain. +export const SENTINEL2_URL = + 'https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2020_3857/default/g/{z}/{y}/{x}.jpg'; +export const SENTINEL2_MAX_ZOOM = 14; +export const SENTINEL2_CREDIT = + 'EOX Maps · Sentinel-2 cloudless 2020 by EOX IT Services GmbH (contains modified Copernicus Sentinel data 2020)'; diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index 1aa8759fd..85894d96c 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -111,6 +111,7 @@ export const SHARE_VIEW_ENUMS = Object.freeze({ 'bing-labels', 'esri-imagery', 'osm', + 'sentinel2', ]), hudVariant: Object.freeze(['tactical', 'operator', 'minimal', 'cyber']), detectionMode: Object.freeze(['OFF', 'SPARSE', 'BALANCED', 'DENSE']), diff --git a/src/view/index.js b/src/view/index.js index 022914277..4cc082a6d 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -37,6 +37,7 @@ export const VIEW_MAPS = Object.freeze([ 'bing-labels', 'esri-imagery', 'osm', + 'sentinel2', ]); /** diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index a27907666..25a18338f 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -356,6 +356,7 @@ const schemas = [ 'bing-labels', 'esri-imagery', 'osm', + 'sentinel2', ], }, }, diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 4e6586729..3bfc89fb0 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -47,7 +47,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // is consolidated without changing either feature's arguments. // Snow leaves the set_visual_style enum. // The Operator HUD layout leaves set_hud's layout enum. - 'f47a7f78f13b1f79978fa524fad2618e32bd813e1a5dfd746bf35d29909fc91b', + // EOX Sentinel-2 joins the set_map_stack enum. + 'c245fc20cb7927181d7dec212ec60279aaa1fdeb494720a7d2ff11e00008a6c6', ); }); @@ -116,6 +117,7 @@ test('all legacy action arguments are byte-identical after removing the delibera // layerManifest.test.mjs; the two shipped right-rail panels are additive. const property = (name) => legacy.find((tool) => tool.name === name).parameters.properties; + assert.equal(property('set_map_stack').stack.enum.pop(), 'sentinel2'); delete property('control_cockpit').voicePanel; delete property('set_layer_visibility').layerId.enum; delete property('show_data_layers_menu').layerId.enum; diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 981894aca..8eb2d741b 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -204,6 +204,9 @@ const STACK_ALIASES = new Map([ ['esri', 'esri-imagery'], ['esri imagery', 'esri-imagery'], ['esri satellite', 'esri-imagery'], + ['sentinel2', 'sentinel2'], + ['sentinel-2', 'sentinel2'], + ['eox', 'sentinel2'], ['osm', 'osm'], ['openstreetmap', 'osm'], ['open street map', 'osm'],