Match Navara satellite landscapes to the Re:Earth Fuji view

This commit is contained in:
German Khokhlov committed 2026-11-02 14:05:58 +04:00
1 parent 5e658d8c9d
commit 3cbc17a926
26 files changed
+262 -112

No files matched your search

+1
View File
@@ -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" |
+1 -1
View File
@@ -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 | |
+4
View File
@@ -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",
+17
View File
@@ -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',
+2 -2
View File
@@ -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),
);
+4
View File
@@ -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']);
+1 -1
View File
@@ -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;
+13
View File
@@ -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();
+49 -15
View File
@@ -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);
+38 -20
View File
@@ -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 });
});
+25 -4
View File
@@ -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);
},
+15
View File
@@ -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);
+24 -31
View File
@@ -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
+10 -32
View File
@@ -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');
});
+2
View File
@@ -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();
+1 -1
View File
@@ -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. */
+1
View File
@@ -18,6 +18,7 @@ export const PRESENTED_MAP_STACK_IDS = Object.freeze([
'bing-aerial',
'bing-labels',
'esri-imagery',
'sentinel2',
'osm',
]);
+20 -4
View File
@@ -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'));
});
+7
View File
@@ -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
+12
View File
@@ -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<object>}
*/
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;
+6
View File
@@ -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 =
'<a href="https://maps.eox.at/">EOX Maps</a> · <a href="https://s2maps.eu/">Sentinel-2 cloudless 2020</a> by EOX IT Services GmbH (contains modified Copernicus Sentinel data 2020)';
+1
View File
@@ -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']),
+1
View File
@@ -37,6 +37,7 @@ export const VIEW_MAPS = Object.freeze([
'bing-labels',
'esri-imagery',
'osm',
'sentinel2',
]);
/**
+1
View File
@@ -356,6 +356,7 @@ const schemas = [
'bing-labels',
'esri-imagery',
'osm',
'sentinel2',
],
},
},
+3 -1
View File
@@ -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;
+3
View File
@@ -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'],