From 840e34a881ce91357645bd072e089730fd05cd41 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 15 Oct 2026 19:08:47 +0400 Subject: [PATCH] feat(globe): bring the Navara engine up to the shell contract - camera.livePosition: one engine-owned Cartesian3, refreshed on read (the model replaces its state on every move); camera.height() - render.request(reason) takes the diagnostic reason and ignores it - maps.current() returns the sources as passed: the base imagery on the globe (none while tiles replace it) and the tiles shown - maps.releasePhotorealTiles forgets a source and takes its tiles away if they show - base imagery and photoreal share one switch order; tiles off brings back the last base imagery with its credit; imagery chosen while tiles show waits hidden, credit included - Navara QA runs the contract's maps checks with a Google 3D tiles source on the dev server's browser key: 58/58 --- src/globe/navara/cameraModel.js | 8 +++ src/globe/navara/cameraModel.test.mjs | 18 +++++++ src/globe/navara/createNavaraEngine.test.mjs | 2 + src/globe/navara/maps.js | 57 ++++++++++++++++---- src/globe/navara/maps.test.mjs | 55 +++++++++++++++++++ 5 files changed, 130 insertions(+), 10 deletions(-) diff --git a/src/globe/navara/cameraModel.js b/src/globe/navara/cameraModel.js index 25202fb28..0d086f3db 100644 --- a/src/globe/navara/cameraModel.js +++ b/src/globe/navara/cameraModel.js @@ -266,7 +266,15 @@ export function createCameraModel({ } } + // camera.livePosition: one owned object, refreshed on read (place() and + // sync() replace `state`, so it is copied rather than aliased). + const livePosition = cartesian3.create(); + const camera = { + get livePosition() { + return cartesian3.clone(state.position, livePosition); + }, + height: () => cartographic.fromCartesian(state.position, WGS84).height, pose() { const current = view(); const angles = headingPitchRoll( diff --git a/src/globe/navara/cameraModel.test.mjs b/src/globe/navara/cameraModel.test.mjs index d7d9cb266..c47718bb4 100644 --- a/src/globe/navara/cameraModel.test.mjs +++ b/src/globe/navara/cameraModel.test.mjs @@ -439,3 +439,21 @@ test('rounding noise in the drawn camera is no movement, so moveEnd still fires' assert.equal(clock.time >= 960, true); assert.equal(ends, 1, 'moveEnd did not fire while Navara redrew the pose'); }); + +test('livePosition is one engine-owned object holding the current position; height() reads it', () => { + const { camera, port, frame } = setup(); + const live = camera.livePosition; + const destination = cartesian3.fromDegrees(20, 10, 12345); + camera.setView({ destination }); + assert.equal(camera.livePosition, live, 'not the same object'); + near(cartesian3.distance(live, destination), 0, 1e-9, 'follows setView'); + near(camera.height(), 12345, 1e-6, 'height'); + frame(); + const moved = cartesian3.fromDegrees(21, 11, 9e5); + port.drawn = { position: moved, ...orientationVectors(moved, 0.3, -1, 0) }; + frame(); + near(cartesian3.distance(camera.livePosition, moved), 0, 1e-9, 'follows Navara'); + near(camera.height(), 9e5, 1e-6, 'height after a user move'); + const pose = camera.pose(); + assert.notEqual(pose.position, live, 'pose shares the live object'); +}); diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index f2a557b32..f824f56b1 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -115,6 +115,8 @@ test('render requests are forceUpdate and continuous mode is Navara animation', const { engine, view } = setup(); engine.render.request(); assert.deepEqual(view.calls.at(-1), ['forceUpdate']); + engine.render.request('world-overlay'); + assert.deepEqual(view.calls.at(-1), ['forceUpdate'], 'the reason is ignored'); engine.render.setContinuous(true); assert.equal(view.animation, true); assert.equal(engine.render.isContinuous(), true); diff --git a/src/globe/navara/maps.js b/src/globe/navara/maps.js index f1f049988..f5dfc2c8b 100644 --- a/src/globe/navara/maps.js +++ b/src/globe/navara/maps.js @@ -67,7 +67,9 @@ export function createNavaraMaps( { fetch: fetchJson = globalThis.fetch } = {}, ) { const slots = { imagery: null, terrain: null, photoreal: null }; - const generations = { imagery: 0, terrain: 0, photoreal: 0 }; + // Base imagery and photoreal tiles share one switch order (display): the + // call made last decides what shows. + const generations = { display: 0, terrain: 0 }; let destroyed = false; function retire(slot) { @@ -95,7 +97,14 @@ export function createNavaraMaps( ); retire(previous); if (shown.length) view.attribution?.add(shown); - slots[name] = { key, source, layer, layerDesc, credits: shown }; + slots[name] = { + key, + source, + layer, + layerDesc, + credits: shown, + hidden: false, + }; view.forceUpdate(); } @@ -146,15 +155,26 @@ export function createNavaraMaps( } } + // What current() reports: the base imagery on the globe (none while tiles + // replace it) and the tiles shown, as the objects passed in. + let baseSource = null; + let currentBase = null; + let tilesSource = null; + const knownTiles = new Set(); + const maps = { async setBaseImagery(source) { - const generation = ++generations.imagery; + const generation = ++generations.display; const { url, credit } = imageryFor(source); - if (destroyed || generation !== generations.imagery) return; - if (slots.imagery?.key === url) return setImageryShown(true); - show('imagery', url, { type: 'raster-tile', url }, { type: 'raster' }, [ - credit, - ]); + 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, + ]); + // Under tiles the base imagery waits hidden, credit included. + setImageryShown(slots.photoreal == null); }, async setTerrain(source) { const generation = ++generations.terrain; @@ -169,9 +189,15 @@ export function createNavaraMaps( show('terrain', description.url, description, { type: 'terrain' }); }, async setPhotorealTiles(source) { - const generation = ++generations.photoreal; + const generation = ++generations.display; if (source == null) { clear('photoreal'); + tilesSource = null; + // The globe comes back with the last base imagery and its credit. + if (slots.imagery) { + setImageryShown(true); + currentBase = baseSource; + } view.forceUpdate(); return; } @@ -180,9 +206,12 @@ export function createNavaraMaps( throw new Error( 'Google 3D tiles on Navara need a browser key (googleApiKey)', ); - if (destroyed || generation !== generations.photoreal) return; + if (destroyed || generation !== generations.display) return; const url = `${GOOGLE_TILES_ROOT}?key=${encodeURIComponent(key)}`; setImageryShown(false); + currentBase = null; + tilesSource = source; + knownTiles.add(source); if (slots.photoreal?.key === url) return; show( 'photoreal', @@ -201,7 +230,15 @@ export function createNavaraMaps( // them; Navara lists them in its attribution card, collapsed by default. view.attribution?.show(); }, + releasePhotorealTiles(source) { + if (!knownTiles.delete(source)) return; + if (tilesSource !== source) return; + tilesSource = null; + clear('photoreal'); + view.forceUpdate(); + }, photorealActive: () => slots.photoreal != null, + current: () => ({ baseImagery: currentBase, photoreal: tilesSource }), }; return { diff --git a/src/globe/navara/maps.test.mjs b/src/globe/navara/maps.test.mjs index 1a06cf8b0..e8c37eaf5 100644 --- a/src/globe/navara/maps.test.mjs +++ b/src/globe/navara/maps.test.mjs @@ -223,3 +223,58 @@ test('destroy removes every layer, source and credit it added', async () => { assert.deepEqual(view.sources, []); assert.deepEqual(view.shown, []); }); + +test('current() returns the passed sources: base while the globe shows, tiles while they show', async () => { + const view = fakeView(); + const { maps } = createNavaraMaps(view); + const osm = { kind: 'osm' }; + const esri = { kind: 'esri-imagery' }; + const tiles = { kind: 'photoreal', googleApiKey: 'k' }; + assert.deepEqual(maps.current(), { baseImagery: null, photoreal: null }); + await maps.setBaseImagery(osm); + assert.deepEqual(maps.current(), { baseImagery: osm, photoreal: null }); + await maps.setPhotorealTiles(tiles); + assert.equal(maps.current().baseImagery, null, 'tiles replace the base'); + assert.equal(maps.current().photoreal, tiles); + // Imagery chosen while the tiles show goes on the hidden globe. + await maps.setBaseImagery(esri); + const [raster] = layersOf(view, 'raster'); + assert.match(raster.desc.source.desc.url, /World_Imagery/); + assert.equal(raster.desc.raster?.show, false, 'the new layer shows over tiles'); + assert.ok( + !view.shown.some((item) => /esri/i.test(JSON.stringify(item))), + 'its credit shows over tiles', + ); + assert.equal(maps.current().baseImagery, esri); + assert.equal(maps.current().photoreal, tiles); + await maps.setPhotorealTiles(null); + assert.deepEqual(maps.current(), { baseImagery: esri, photoreal: null }); + assert.notEqual(raster.desc.raster?.show, false, 'base not back'); + assert.ok(view.shown.some((item) => /esri/i.test(JSON.stringify(item))), 'credit not back'); +}); + +test('the same tiles under a new source object count as that source', async () => { + const view = fakeView(); + const { maps } = createNavaraMaps(view); + const first = { kind: 'photoreal', googleApiKey: 'k' }; + const second = { kind: 'photoreal', googleApiKey: 'k' }; + await maps.setPhotorealTiles(first); + await maps.setPhotorealTiles(second); + assert.equal(maps.current().photoreal, second); + assert.equal(layersOf(view, '3d-tiles').length, 1); +}); + +test('releasePhotorealTiles forgets the source and takes its tiles away if shown', async () => { + const view = fakeView(); + const { maps } = createNavaraMaps(view); + const tiles = { kind: 'photoreal', googleApiKey: 'k' }; + await maps.setPhotorealTiles(tiles); + maps.releasePhotorealTiles({ kind: 'photoreal', googleApiKey: 'k' }); + assert.equal(maps.photorealActive(), true, 'another object released it'); + maps.releasePhotorealTiles(tiles); + maps.releasePhotorealTiles(tiles); + assert.equal(maps.photorealActive(), false); + assert.equal(maps.current().photoreal, null); + assert.equal(layersOf(view, '3d-tiles').length, 0); +}); +