diff --git a/scripts/qa-tiles-fixture.test.mjs b/scripts/qa-tiles-fixture.test.mjs index c7e1c9adb..156890da6 100644 --- a/scripts/qa-tiles-fixture.test.mjs +++ b/scripts/qa-tiles-fixture.test.mjs @@ -307,12 +307,13 @@ test('Navara accepts the local 3D source without a key or Google credit, and rej globals(t, { location: new URL('http://localhost:4303/') }); const sources = [], credits = []; + const update = t.mock.fn(); const view = { addSource: (desc) => { sources.push(desc); return { delete() {} }; }, - addLayer: () => ({ id: 'fixture', delete() {} }), + addLayer: () => ({ id: 'fixture', update, delete() {} }), forceUpdate() {}, attribution: { add: (items) => credits.push(...items), @@ -342,6 +343,11 @@ test('Navara accepts the local 3D source without a key or Google credit, and rej assert.equal(sources.length, 1); await owner.maps.setPhotorealTiles(null); assert.equal(owner.maps.photorealActive(), false); + assert.deepEqual(update.mock.calls[0].arguments[0].model, { + maxSse: 16, + height: 0, + show: false, + }); }); test('strict keyless assertion permits only the opted-in fixture and its real tileset URL', async (t) => { diff --git a/src/data/osmOverlays.js b/src/data/osmOverlays.js index e706c5282..cb79331d4 100644 --- a/src/data/osmOverlays.js +++ b/src/data/osmOverlays.js @@ -16,12 +16,10 @@ import { NO_IMAGERY_HOST } from '../layers/shared/imageryHost.js'; * needs a vector-tile rendering pipeline, not an imagery-layer drop-in. * * The overlays drape through the globe engine on whatever surface the - * active map stack offers: `engine.imagery` (on Cesium the globe on the OSM - * and Bing stacks, the Google Photorealistic tileset's own imagery on Google - * 3D; the engine follows stack switches), or Navara's own raster overlay - * (`engine.maps.addOverlay`, per-tile levels on its terrain) where the - * engine has one. Only a surface with neither leaves them hidden, and the - * row says so. + * active map stack offers through `engine.imagery`: the globe on imagery + * stacks and the tiles' surface on Google 3D. The engine follows stack + * switches; native globe rasters would disappear when tiles hide the globe. + * Only a surface with neither leaves them hidden, and the row says so. * @module data/osmOverlays */ @@ -69,22 +67,20 @@ export function createOsmOverlayLayer(overlay) { throw new TypeError(`Unknown open map overlay: ${overlay?.id}`); let engine = null; let enabled = false; - /** The drape this instance owns while enabled: an imagery layer or a removal. */ + /** The imagery layer this instance owns while enabled. */ let owned = null; /** Where the drape lies now: 'globe', 'tileset' or 'none'. */ function hostKind() { if (!owned) return 'none'; - if (owned.remove) return 'globe'; return HOST_KIND[engine?.imagery?.surface?.()] ?? 'none'; } function release() { if (!owned) return; - const { layer, remove } = owned; + const layer = owned; owned = null; - if (remove) remove(); - else layer.destroy(); + layer.destroy(); } /** Create or drop the owned drape to match the state. */ @@ -94,28 +90,17 @@ export function createOsmOverlayLayer(overlay) { return; } if (owned) return; - const remove = engine.maps?.addOverlay?.({ - url: overlay.urlTemplate, - maxZoom: overlay.maximumLevel, - credit: overlay.credit, - }); - if (typeof remove === 'function') { - owned = { remove }; - return; - } if (!engine.imagery) return; - owned = { - layer: engine.imagery.createLayer({ - id: `osm-overlay-${overlay.id}`, - source: { - kind: 'tiles', - url: overlay.urlTemplate, - maxLevel: overlay.maximumLevel, - credit: overlay.credit, - }, - order: OVERLAY_ORDER, - }), - }; + owned = engine.imagery.createLayer({ + id: `osm-overlay-${overlay.id}`, + source: { + kind: 'tiles', + url: overlay.urlTemplate, + maxLevel: overlay.maximumLevel, + credit: overlay.credit, + }, + order: OVERLAY_ORDER, + }); } return { diff --git a/src/data/osmOverlays.test.mjs b/src/data/osmOverlays.test.mjs index 0df40972f..718e43669 100644 --- a/src/data/osmOverlays.test.mjs +++ b/src/data/osmOverlays.test.mjs @@ -115,32 +115,48 @@ test('destroy and a renderer detach remove the owned imagery', () => { assert.equal(layers().length, 0); }); -test('on Navara an overlay drapes through engine.maps and follows the globe', () => { - const added = []; - const engineWith = () => { - const engine = createFakeEngine({ kind: 'navara' }); - engine.maps.addOverlay = (options) => { - const entry = { options, removed: false }; - added.push(entry); - return () => (entry.removed = true); - }; - return engine; - }; - const [seaMarks] = createOsmOverlayLayers(); - seaMarks.init(null, { engine: engineWith() }); - assert.equal(added.length, 0, 'nothing until enabled'); - seaMarks.enable(); - assert.equal(added.length, 1); - assert.equal(added[0].options.url, OSM_OVERLAYS[0].urlTemplate); - assert.equal(added[0].options.credit, OSM_OVERLAYS[0].credit); - assert.equal(seaMarks.getStats().error, null); - // An engine switch: the drape leaves with the old globe, comes back on the next. - seaMarks.detachRenderer(); - assert.equal(added[0].removed, true); - seaMarks.attachRenderer({ engine: engineWith() }); - assert.equal(added.length, 2); - assert.equal(added[1].removed, false); - seaMarks.disable(); - assert.equal(added[1].removed, true); - seaMarks.destroy(); -}); +for (const kind of ['cesium', 'navara']) { + test(`${kind}: both overlays follow imagery surfaces and survive an engine switch`, () => { + let surface = 'tiles'; + const engine = createFakeEngine({ kind }); + engine.imagery.surface = () => surface; + const nextEngine = createFakeEngine({ + kind: kind === 'navara' ? 'cesium' : 'navara', + }); + const layers = createOsmOverlayLayers(); + for (const layer of layers) { + layer.enable(); + layer.attachRenderer({ engine }); + layer.enable(); + } + const owned = engine.imagery.list(); + assert.deepEqual( + owned.map(({ id }) => id), + OSM_OVERLAYS.map(({ id }) => `osm-overlay-${id}`), + ); + for (const host of ['tiles', 'globe', 'none', 'tiles']) { + surface = host; + for (const layer of layers) { + assert.deepEqual(layer.diagnostics(), { + host: host === 'tiles' ? 'tileset' : host, + ownedCount: 1, + }); + assert.equal( + layer.getStats().error, + host === 'none' ? NO_IMAGERY_HOST : null, + ); + } + assert.deepEqual(engine.imagery.list(), owned); + } + for (const layer of layers) { + layer.detachRenderer(); + layer.attachRenderer({ engine: nextEngine }); + } + assert.deepEqual(engine.imagery.list(), []); + assert.deepEqual(nextEngine.imagery.list(), owned); + layers[0].disable(); + assert.equal(nextEngine.imagery.list().length, 1); + for (const layer of layers) layer.destroy(); + assert.deepEqual(nextEngine.imagery.list(), []); + }); +} diff --git a/src/globe/cesium/imagery.test.mjs b/src/globe/cesium/imagery.test.mjs index ab35924eb..63c7c5ace 100644 --- a/src/globe/cesium/imagery.test.mjs +++ b/src/globe/cesium/imagery.test.mjs @@ -2,6 +2,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { TILE_RETRY_DELAY_MS, createCesiumImagery } from './imagery.js'; +import { createFakeEngine } from '../testing/fakeEngine.js'; +import { + OSM_OVERLAYS, + createOsmOverlayLayers, +} from '../../data/osmOverlays.js'; const BOX = { west: -97.8, south: 30.2, east: -97.7, north: 30.3 }; const tiles = (extra = {}) => ({ @@ -126,6 +131,44 @@ const layersOf = (collection) => /** The layers on the globe over the fixture's base imagery. */ const drawnOn = (f) => layersOf(f.globe).filter((layer) => layer !== f.base); +test('map overlays preserve transparent tile sources on the globe and tileset', (t) => { + const f = fixture(); + t.after(f.destroy); + const layers = createOsmOverlayLayers(); + for (const layer of layers) { + // The fake engine with this adapter's imagery. + const engine = Object.assign(createFakeEngine({ kind: 'cesium' }), { + imagery: f.imagery, + }); + layer.init(null, { engine }); + layer.enable(); + } + for (const active of [false, true, false]) { + f.viewer.scene.globe.show = !active; + f.tileset.show = active; + f.frame(); + const drawn = active ? layersOf(f.tileset.imageryLayers) : drawnOn(f); + assert.equal(drawn.length, 2); + assert.equal( + active ? drawnOn(f).length : f.tileset.imageryLayers.length, + 0, + ); + for (const [i, overlay] of OSM_OVERLAYS.entries()) { + const { options } = drawn[i].imageryProvider; + assert.equal(options.url, overlay.urlTemplate); + assert.equal(options.maximumLevel, overlay.maximumLevel); + assert.equal(options.credit, overlay.credit); + assert.equal(options.hasAlphaChannel, true); + assert.equal(drawn[i].alpha, 1); + assert.equal(drawn[i].show, true); + assert.equal(layers[i].diagnostics().host, active ? 'tileset' : 'globe'); + } + } + for (const layer of layers) layer.destroy(); + assert.deepEqual(drawnOn(f), []); + assert.equal(f.tileset.imageryLayers.length, 0); +}); + test('a tiles layer is one Web Mercator template provider bounded to its rectangle', () => { const f = fixture(); const layer = f.imagery.createLayer({ diff --git a/src/globe/navara/WORKAROUNDS.md b/src/globe/navara/WORKAROUNDS.md index 3c5f748c8..2acf49cf4 100644 --- a/src/globe/navara/WORKAROUNDS.md +++ b/src/globe/navara/WORKAROUNDS.md @@ -48,7 +48,8 @@ is fixed. Neither excluded check is a signal to remove a workaround. | `src/globe/navara/ground/index.js:145` | The original native-vector path lost the first `show: false` while a new layer was being built. Current ground rendering uses its own pass, so the hide retry is already absent. | 0.2.1 (original path) | No canary: `initial-vector-hide-dropped` timed out before a verdict; the shared readiness error does not identify the failed wait. Requires browser diagnosis. | No current hide retry to delete. Do not remove the ground pass: it also implements draping and materials. | | `src/globe/navara/maps.js:76` | Idle can be reported before a fetched raster tile arrives after a map switch; the map may keep a stale frame. | 0.2.1 | `idle-before-raster-arrives` (browser, confirmed) holds real raster requests across the idle event, then releases local PNG responses. | Remove the `SETTLE_MS` / `SETTLE_EVERY_MS` redraw timer from `changed`. | | `src/globe/navara/maps.js` (`setPhotorealTiles`) | The default `model.height=1` displaces transformed 3D Tiles by −1 m in ECEF X on the synthetic ENU fixture; both rendered meshes and CPU queries inherit it. Set `model.height: 0` to preserve the tileset transform. | 0.2.1 | `scripts/qa-tiles-fixture.test.mjs` loads the real WASM package: default transform reproduces the measured flat-top +0.118 m / roof −0.676 m errors; the adapter restores all probes and 40 roof points within 1 µm against independent triangle rays. `qa-tiles-fixture --report` prints browser samples without asserting them. | Remove the explicit zero only when the package's default preserves authored tile positions; retain the authored-geometry regression test. | -| `src/globe/navara/maps.js:145` | Removing the final raster layer interrupts terrain loading at the current view. | 0.2.1 | No canary: needs terrain-loading continuity across final-layer removal; the raster idle fixture does not test terrain. | Remove the add-before-delete constraint and hidden base-raster retention under 3D tiles. | +| `src/globe/navara/maps.js` (`setImageryShown`, `show`), `src/globe/navara/ground/globe.js` | Hiding raster imagery leaves the terrain surface writing colour, depth and shadows over 3D tiles. Removing the final raster layer interrupts terrain loading. Keep the raster/source alive and hide the globe scene through a descriptor's public context; restore its visibility on clear, release or destruction. | 0.2.1 | Source inspection: `CustomRenderPass` renders the globe before tiles and copies its depth; `TileMesh` still draws without shown imagery. `maps.test.mjs` covers visibility, source/sampling continuity, replacement and teardown. No browser canary: confirm that the cleared globe depth is 1 and ground/imagery classify on tile depth at all camera heights. | Replace the descriptor with a native globe-visibility API that preserves terrain traversal and sampling. Remove raster retention only when terrain loads independently of raster layers. | +| `src/globe/navara/maps.js` (`setPhotorealShown`), `src/globe/navara/surface.js` | Deleting a photoreal layer/source on a base-map switch discards its reusable tile cache. Retain one hidden tileset with `model.show: false`, keep SSE 16 and height 0, and exclude it from surface queries until shown. Explicit release, replacement and destruction still dispose it. | 0.2.1 | `maps-release.test.mjs` runs the installed WASM fixture: five initial payload requests, none while hidden or after restore, the same four models and restored native visibility; fake-view tests cover repeated switches and parked-source release. This proves switch-related refetching, not the cause of excess requests during uninterrupted flights. | Keep the tileset lifetime aligned with Cesium. Replace the visibility bridge if upstream exposes active-surface queries. Diagnose flight requests with URL repetition, eviction and SSE measurements before changing cache or LOD settings. | | `src/globe/navara/maps.js:37`, `src/globe/navara/maps.js:209` | Quantized-mesh root URLs without the tile template can panic. | 0.2.1 | No canary: deliberately panicking WASM would invalidate the remaining view checks; no isolated terrain fixture here. | Use the upstream root-URL API instead of manually appending `{z}/{x}/{y}.terrain`. | | `src/globe/navara/maps.js:65` | No ion imagery path. | 0.2.1 | No canary: explicit unsupported source; requires a real ion imagery endpoint. | Replace the ion-imagery rejection with native source support. | | `src/globe/navara/maps.js:209` | No terrain-request authentication retry hook; an expired ion endpoint token cannot be refreshed after a tile 401. | 0.2.1 | No canary: no authenticated fixture or exposed request callback. This is an unresolved gap, not a working retry. | Add retry through the upstream hook; there is no compensating retry code to delete. | @@ -72,12 +73,13 @@ is fixed. Neither excluded check is a signal to remove a workaround. | `src/globe/navara/viewOptions.js:175` | G-buffer normals for globe/3D tiles are zero, breaking atmosphere lighting. | 0.2.1 | No canary: needs actual G-buffer and lighting pixel checks; Google tiles are not used by these canaries. | Remove `useNormalBuffer: false` when native normals support the lighting path. | | `src/globe/navara/viewOptions.js:191` | Cloud defaults override the low preset's shadow map size and cascade count. | 0.2.1 | No canary: requires inspecting the real cloud preset result; option tests only verify the override. | Remove explicit `shadowMapSize` / `shadowCascadeCount` once the low preset survives native defaults. | | `src/globe/navara/viewOptions.js:93`, `src/globe/navara/look.js:172` | Cloud bases are ellipsoid-relative and do not clear local terrain. | 0.2.1 | No canary: needs sampled relief and cloud-base inspection at local scale. | Remove terrain-dependent cloud offsets if native cloud altitude can be terrain-relative. | -| `src/globe/navara/credits.js:13` | Content-based deduplication has no holder reference count. | 0.2.1 | No canary: current credit tests stub attribution; they do not test the plugin. | Remove local counts when native removal retains a credit until its last holder releases it. | -| `src/globe/navara/credits.js:28` | No separate persistent on-screen credit strip. | 0.2.1 | No canary: UI/API gap; opening the attribution card is only a partial substitute. | Replace forced `attribution.show()` with native on-screen credits. | +| `src/globe/navara/credits.js` (`createNavaraCredits`) | Content-based deduplication has no holder reference count or on-screen flag. Count total and on-screen holders separately so a quiet holder keeps the card entry without keeping it in the strip. | 0.2.1 | `credits-plugin.test.mjs` exercises the installed attribution plugin's deduplication and mixed-holder removal over a DOM; the unused Plugin base is stubbed. | Remove local counts when native removal and on-screen visibility follow the remaining holders. | +| `src/globe/navara/credits.js` (`createCreditStrip`), `src/ui/styles/status.css` | No persistent on-screen strip or public credit snapshot/change event. Closed cards defer tile and zoom-band rendering. Reuse the application's Cesium credit layout, moving native logos and the button/card into the renderer; mirror sanitized list entries for map and on-screen static credits. Private accesses: `populateList`, `requestRender`, `displayedItems`, `currentZoomLevel`, `hasZoomBands`, `lastZoomLevel`, `dock`, `listEl`, `logosEl`. | 0.2.1 | `credits-plugin.test.mjs` reproduces deferred closed-card updates with the installed plugin and checks tile visibility/removal, map replacement, zoom bands, sanitization, card controls and teardown. Method/DOM startup guards detect API changes. No browser pixel canary. | Replace the strip bridge and private hooks with native persistent credits or a public snapshot subscription; retain shared layout and lifecycle tests. | | `src/globe/navara/missingTiles.js:28`, `src/globe/navara/maps.js:108` | Raster sources have no tile discard policy, so Esri's grey "Map data not yet available" tiles (200 responses) draw over the parent level. | 0.2.1 | No canary: needs Esri's live placeholder; `missingTiles.test.mjs` covers the fetch wrapper only. | Remove the fetch wrapper when a raster source can reject a loaded tile (discard callback or missing-tile image). | | `src/globe/navara/bloom.js:59` | Native selective bloom uses emissive/effect masks before the transparent pass; the contract needs whole-frame bloom after overlays. | 0.2.1 | No canary: capability gap; custom effect tests verify our implementation. | Replace the custom bloom descriptor with a native effect only if ordering and settings match. | Private-member checks run through `navaraHooks(view)` before engine construction -and fail with the member path and pinned version. The current recursive source +and fail with the member path and pinned version. Attribution methods and DOM +are checked locally in `createCreditStrip` after plugin initialization. The current recursive source scan found no private access in `ground/`, `imagery/`, `lines/`, `movers/`, or other adapter subfolders. No private-access moves were necessary. diff --git a/src/globe/navara/credits-plugin.test.mjs b/src/globe/navara/credits-plugin.test.mjs new file mode 100644 index 000000000..0c6cb7b85 --- /dev/null +++ b/src/globe/navara/credits-plugin.test.mjs @@ -0,0 +1,305 @@ +import '../../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { createNavaraCredits } from './credits.js'; +import { createNavaraMaps } from './maps.js'; + +// Exercise the installed plugin's DOM, sanitizer and credit tracking. Only +// its unused Plugin base is stubbed, to avoid starting the WASM/WebGL engine. +const bundle = await build({ + entryPoints: [ + new URL( + '../../../node_modules/@navaramap/three/src/plugins/AttributionPlugin/index.ts', + import.meta.url, + ).pathname, + ], + bundle: true, + write: false, + format: 'esm', + plugins: [ + { + name: 'plugin-base', + setup(build) { + build.onResolve({ filter: /^@navaramap\/core$/ }, () => ({ + path: 'plugin-base', + namespace: 'stub', + })); + build.onLoad({ filter: /.*/, namespace: 'stub' }, () => ({ + contents: 'export class Plugin {}', + })); + }, + }, + ], +}); +const { AttributionPlugin } = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString('base64')}` +); + +function events() { + const listeners = new Map(); + return { + on(name, listener) { + if (!listeners.has(name)) listeners.set(name, new Set()); + listeners.get(name).add(listener); + }, + off(name, listener) { + listeners.get(name)?.delete(listener); + }, + emit(name, value) { + for (const listener of listeners.get(name) ?? []) listener(value); + }, + count(name) { + return listeners.get(name)?.size ?? 0; + }, + }; +} + +async function setup(t, { strip = true } = {}) { + const host = document.createElement('div'); + const canvas = document.createElement('canvas'); + host.appendChild(canvas); + document.body.appendChild(host); + const layers = new Map(); + let nextId = 0; + const view = { + ...events(), + canvas, + camera: { zoom: 10 }, + findLayerById: (id) => layers.get(id), + forceUpdate() {}, + addSource(description) { + return { description, delete() {} }; + }, + addLayer(description) { + const layer = { + ...events(), + id: `layer-${nextId++}`, + description, + update() {}, + delete() { + layers.delete(layer.id); + layer.emit('deleted'); + }, + }; + layers.set(layer.id, layer); + return layer; + }, + }; + const attribution = new AttributionPlugin(); + view.attribution = attribution; + await attribution.init(view, {}); + const originalPopulateList = attribution.populateList; + const originalRequestRender = attribution.requestRender; + const adapter = strip ? createNavaraCredits(view) : null; + t.after(() => { + adapter?.destroy(); + attribution.dispose(); + host.remove(); + }); + const row = host.querySelector('#cesium-credits'); + return { + host, + view, + attribution, + layers, + row, + inline: () => row.querySelector('.cesium-credit-textContainer'), + card: document.querySelector('.navara-attr-card'), + originalPopulateList, + originalRequestRender, + ...adapter, + }; +} + +test('the native plugin defers closed-card tile updates and has no credit strip', async (t) => { + const { view, attribution, card, host } = await setup(t, { strip: false }); + const layer = view.addLayer({}); + attribution.add([{ attribution: 'Tiles', creditLayerId: layer.id }]); + layer.emit('featureCreated', { featureSetId: 1n, credit: 'Tile copyright' }); + assert.equal(card.hidden, true); + assert.doesNotMatch(card.textContent, /Tile copyright/); + assert.equal(host.querySelector('#cesium-credits'), null); + attribution.show(); + assert.match(card.textContent, /Tile copyright/); +}); + +test('the native plugin deduplicates content without retaining separate holders', async (t) => { + const { attribution, card } = await setup(t, { strip: false }); + const credit = { attributionHtml: 'Shared credit' }; + attribution.add([credit]); + attribution.add([credit]); + assert.equal(card.textContent.match(/Shared credit/g).length, 1); + attribution.remove([credit]); + assert.doesNotMatch(card.textContent, /Shared credit/); +}); + +test('on-screen holders share one strip entry while quiet credits stay in the card', async (t) => { + const { credits, inline, card } = await setup(t); + const quiet = credits.addStatic('Shared credit'); + const first = credits.addStatic('Shared credit', { onScreen: true }); + const second = credits.addStatic('Shared credit', { onScreen: true }); + credits.addStatic('Card only'); + assert.equal(card.hidden, true); + assert.equal(inline().textContent.match(/Shared credit/g).length, 1); + assert.doesNotMatch(inline().textContent, /Card only/); + assert.match(card.textContent, /Card only/); + first(); + first(); + assert.match(inline().textContent, /Shared credit/); + second(); + assert.doesNotMatch(inline().textContent, /Shared credit/); + assert.match(card.textContent, /Shared credit/); + quiet(); + assert.doesNotMatch(card.textContent, /Shared credit/); +}); + +test('map credits, Google logos and tile copyrights follow source and visibility changes', async (t) => { + const { view, row, inline, card, layers } = await setup(t); + const { maps, destroy } = createNavaraMaps(view, { + setInterval: () => 1, + clearInterval() {}, + }); + t.after(destroy); + await maps.setBaseImagery({ kind: 'osm' }); + assert.match(inline().textContent, /OpenStreetMap/); + await maps.setPhotorealTiles({ googleApiKey: 'test-key' }); + assert.equal(card.hidden, true); + assert.doesNotMatch(inline().textContent, /OpenStreetMap/); + const logo = row.querySelector('.cesium-credit-logoContainer img'); + assert.equal(logo.alt, 'Google'); + assert.equal(logo.src, 'https://assets.ion.cesium.com/google-credit.png'); + const tiles = [...layers.values()].find( + (layer) => layer.description.type === '3d-tiles', + ); + tiles.emit('featureCreated', { featureSetId: 1n, credit: '© A; © Shared' }); + tiles.emit('featureCreated', { featureSetId: 2n, credit: '© B; © Shared' }); + assert.match(inline().textContent, /© A.*© B/); + assert.equal(inline().textContent.match(/© Shared/g).length, 1); + tiles.emit('featureVisibilityChanged', { featureSetId: 1n, visible: false }); + assert.doesNotMatch(inline().textContent, /© A/); + assert.match(inline().textContent, /© B/); + tiles.emit('featureVisibilityChanged', { featureSetId: 1n, visible: true }); + tiles.emit('featureRemoved', { featureSetId: 2n }); + assert.match(inline().textContent, /© A/); + assert.doesNotMatch(inline().textContent, /© B/); + await maps.setPhotorealTiles({ googleApiKey: 'replacement-key' }); + assert.doesNotMatch(inline().textContent, /© A|© Shared/); + assert.equal(tiles.count('featureCreated'), 0); + tiles.emit('featureCreated', { featureSetId: 3n, credit: 'Late credit' }); + assert.doesNotMatch(inline().textContent, /Late credit/); + assert.equal( + row.querySelectorAll('.cesium-credit-logoContainer img').length, + 1, + ); + await maps.setPhotorealTiles(null); + assert.match(inline().textContent, /OpenStreetMap/); + assert.equal( + row.querySelectorAll('.cesium-credit-logoContainer img').length, + 0, + ); + assert.equal(card.hidden, true); +}); + +test('native clear removes strip entries', async (t) => { + const { attribution, inline, card } = await setup(t); + attribution.add([{ attribution: 'Additional source' }]); + assert.match(inline().textContent, /Additional source/); + attribution.clear(); + assert.equal(inline().textContent, 'Navara'); + assert.equal(card.hidden, true); +}); + +test('closed-card credits follow zoom bands without churning an unchanged frame', async (t) => { + const { view, attribution, inline, card } = await setup(t); + attribution.add([ + { + attribution: 'Imagery', + children: [ + { attribution: 'Overview', maxZoom: 10 }, + { attribution: 'Detailed', minZoom: 11 }, + ], + }, + ]); + assert.match(inline().textContent, /Overview/); + assert.doesNotMatch(inline().textContent, /Detailed/); + const content = inline().firstChild; + view.emit('preRender'); + assert.equal(inline().firstChild, content); + view.camera.zoom = 12; + view.emit('preRender'); + assert.match(inline().textContent, /Detailed/); + assert.doesNotMatch(inline().textContent, /Overview/); + assert.equal(card.hidden, true); +}); + +test('the strip preserves sanitized links from static and tile credits', async (t) => { + const { view, attribution, credits, inline } = await setup(t); + const markup = + 'PublisherUnsafe'; + credits.addStatic(markup, { onScreen: true }); + const layer = view.addLayer({}); + attribution.add([{ attribution: 'Tiles', creditLayerId: layer.id }]); + layer.emit('featureCreated', { featureSetId: 1n, credit: markup }); + const links = inline().querySelectorAll('a[href*="example.test"]'); + assert.equal(links.length, 1); + assert.equal(links[0].target, '_blank'); + assert.equal(links[0].rel, 'noopener noreferrer'); + assert.equal( + inline().querySelectorAll('img, [onerror], [href^="javascript:"]').length, + 0, + ); + assert.match(inline().textContent, /Unsafe/); +}); + +test('the native button, close button and Escape still control only the card', async (t) => { + const { credits, row, inline, card, view } = await setup(t); + credits.addStatic('On screen', { onScreen: true }); + const toggle = row.querySelector('.navara-attr-toggle'); + toggle.click(); + assert.equal(card.hidden, false); + assert.equal(toggle.getAttribute('aria-expanded'), 'true'); + row.querySelector('.navara-attr-close').click(); + assert.equal(card.hidden, true); + toggle.click(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + assert.equal(card.hidden, true); + assert.equal(toggle.getAttribute('aria-expanded'), 'false'); + view.emit('preRender'); + credits.addStatic('Another on screen', { onScreen: true }); + assert.equal(card.hidden, true); + assert.match(inline().textContent, /On screen.*Another on screen/); +}); + +test('parking hides the complete credit UI; destroy restores the plugin and releases the strip', async (t) => { + const { + host, + view, + attribution, + credits, + row, + destroy, + originalPopulateList, + originalRequestRender, + } = await setup(t); + const remove = credits.addStatic('Owned', { onScreen: true }); + host.remove(); + assert.equal(document.querySelector('.navara-attr-dock'), null); + assert.equal(document.querySelector('.navara-attr-logoframe'), null); + assert.equal(document.querySelector('#cesium-credits'), null); + document.body.appendChild(host); + assert.equal(document.querySelector('#cesium-credits'), row); + assert.equal(view.count('preRender'), 2); + destroy(); + destroy(); + remove(); + credits.addStatic('After destroy', { onScreen: true }); + assert.equal(view.count('preRender'), 1); + assert.equal(attribution.populateList, originalPopulateList); + assert.equal(attribution.requestRender, originalRequestRender); + assert.equal(host.querySelector('#cesium-credits'), null); + assert.doesNotMatch( + document.querySelector('.navara-attr-list').textContent, + /Owned|After destroy/, + ); +}); diff --git a/src/globe/navara/credits.js b/src/globe/navara/credits.js index 411e100e6..f5f1a45c2 100644 --- a/src/globe/navara/credits.js +++ b/src/globe/navara/credits.js @@ -1,10 +1,89 @@ // Static credits on Navara's attribution UI (view.attribution, the // AttributionPlugin). Navara matches entries by content and drops duplicates, // so one HTML string is shown once and counted here, staying until its last -// holder removes it. Navara has no separate on-screen strip: an `onScreen` -// credit opens the attribution card (collapsed by default) so the list is on -// screen; logos that must stay visible (Google) are sources with a `logo`, -// added by maps.js. +// holder removes it. Map credits and on-screen static credits share the +// application's credit strip; the full list stays behind Navara's button. + +function createCreditStrip(view, counts) { + const parent = view.canvas?.parentElement; + const document = parent?.ownerDocument; + const attribution = view.attribution; + if (!document || !attribution) return { update() {}, destroy() {} }; + // Navara 0.2.1 exposes neither a credit snapshot nor a change event. Keep + // its sanitizer and visible-tile tracking, refreshing the list while closed. + for (const name of [ + 'populateList', + 'requestRender', + 'displayedItems', + 'currentZoomLevel', + ]) { + if (typeof attribution[name] !== 'function') + throw new Error(`Navara attribution API changed: ${name}`); + } + const { dock, listEl, logosEl } = attribution; + if (!dock || !listEl || !logosEl) + throw new Error('Navara attribution DOM is not ready'); + const dockParent = dock.parentElement; + const logosParent = logosEl.parentElement; + const strip = document.createElement('div'); + // Reuse Cesium's credit layout, including dock/mobile/recording clearance + // and the panel obstacle measurements. The renderer owns its lifetime. + strip.id = 'cesium-credits'; + strip.className = 'navara-credit-strip'; + const logos = document.createElement('span'); + logos.className = 'cesium-credit-logoContainer'; + const text = document.createElement('span'); + text.className = 'cesium-credit-textContainer'; + logos.appendChild(logosEl); + strip.append(logos, text, dock); + parent.appendChild(strip); + + function update() { + const entries = new Set(); + attribution.displayedItems().forEach((item, index) => { + const holders = counts.get(item.attributionHtml); + if (holders && !holders.onScreen) return; + const row = listEl.children[index]; + if (!item.logo) { + const name = row.querySelector('.navara-attr-name').lastElementChild; + entries.add(name.innerHTML); + } + for (const credit of row.querySelectorAll('.navara-attr-related > li')) + entries.add(credit.innerHTML); + }); + const html = [...entries] + .map((entry) => `${entry}`) + .join(' • '); + if (text.innerHTML !== html) text.innerHTML = html; + } + + const { populateList, requestRender } = attribution; + attribution.populateList = function () { + populateList.call(this); + update(); + }; + attribution.requestRender = () => attribution.populateList(); + const updateZoom = () => { + if (!attribution.hasZoomBands) return; + const zoom = attribution.currentZoomLevel(); + if (zoom === attribution.lastZoomLevel) return; + attribution.lastZoomLevel = zoom; + attribution.requestRender(); + }; + view.on('preRender', updateZoom); + attribution.populateList(); + return { + update, + destroy() { + view.off('preRender', updateZoom); + attribution.populateList = populateList; + attribution.requestRender = requestRender; + dockParent.appendChild(dock); + logosParent.appendChild(logosEl); + strip.remove(); + }, + }; +} /** * @param {{ attribution?: { add: Function, remove: Function } }} view @@ -12,35 +91,47 @@ */ export function createNavaraCredits(view) { const counts = new Map(); - const release = (html) => { - if (!counts.has(html)) return; + const strip = createCreditStrip(view, counts); + let destroyed = false; + const release = (html, onScreen) => { const count = counts.get(html); - if (count > 1) return counts.set(html, count - 1); - counts.delete(html); - view.attribution?.remove([{ attributionHtml: html }]); + if (!count) return; + count.total--; + if (onScreen) count.onScreen--; + if (count.total === 0) { + counts.delete(html); + view.attribution?.remove([{ attributionHtml: html }]); + } + strip.update(); }; const credits = { addStatic(html, { onScreen = false } = {}) { + if (destroyed) return () => {}; const text = String(html); - const count = counts.get(text) ?? 0; - counts.set(text, count + 1); - if (count === 0) view.attribution?.add([{ attributionHtml: text }]); - if (onScreen) view.attribution?.show(); + const count = counts.get(text) ?? { total: 0, onScreen: 0 }; + count.total++; + if (onScreen) count.onScreen++; + counts.set(text, count); + if (count.total === 1) view.attribution?.add([{ attributionHtml: text }]); + strip.update(); let removed = false; return () => { if (removed) return; removed = true; - release(text); + release(text, onScreen); }; }, }; return { credits, destroy() { + if (destroyed) return; + destroyed = true; for (const html of [...counts.keys()]) { - counts.set(html, 1); - release(html); + counts.delete(html); + view.attribution?.remove([{ attributionHtml: html }]); } + strip.destroy(); }, }; } diff --git a/src/globe/navara/credits.test.mjs b/src/globe/navara/credits.test.mjs index 251592784..d542c1e21 100644 --- a/src/globe/navara/credits.test.mjs +++ b/src/globe/navara/credits.test.mjs @@ -65,11 +65,11 @@ test('destroy removes every credit; a view without attribution UI is a no-op', ( remove(); }); -test('onScreen credits open the attribution list on screen; others do not', () => { +test('static credits never force the attribution card open', () => { const attribution = fakeAttribution(); const { credits } = createNavaraCredits({ attribution }); credits.addStatic('Quiet'); assert.equal(attribution.opened, 0); credits.addStatic('Loud', { onScreen: true }); - assert.equal(attribution.opened, 1); + assert.equal(attribution.opened, 0); }); diff --git a/src/globe/navara/ground/globe.js b/src/globe/navara/ground/globe.js new file mode 100644 index 000000000..8aebc7ca8 --- /dev/null +++ b/src/globe/navara/ground/globe.js @@ -0,0 +1,31 @@ +import { EffectDesc } from '@navaramap/three'; + +// Raster visibility only hides imagery. The globe scene must stop writing +// colour, depth and shadows while 3D tiles supply the rendered surface. +// A descriptor gives us the public scene context without stopping terrain +// traversal or changing the meshes used by CPU height sampling. +class HiddenGlobeDesc extends EffectDesc { + static key = 'gevHiddenGlobe'; + + createPass() { + this.wasVisible = this.ctx.scenes.globe.visible; + this.ctx.scenes.globe.visible = false; + // No screen pass: only the scene's visibility changes. + } + + onDestroy() { + if (this.wasVisible !== undefined) + this.ctx.scenes.globe.visible = this.wasVisible; + super.onDestroy(); + } +} + +const registered = new WeakSet(); + +export function hideGlobe(view) { + if (!registered.has(view)) { + view.registerEffect(HiddenGlobeDesc.key, HiddenGlobeDesc); + registered.add(view); + } + return view.addEffect({ [HiddenGlobeDesc.key]: {} }); +} diff --git a/src/globe/navara/imagery/index.test.mjs b/src/globe/navara/imagery/index.test.mjs index 69a68e6a0..4d1e8538f 100644 --- a/src/globe/navara/imagery/index.test.mjs +++ b/src/globe/navara/imagery/index.test.mjs @@ -2,6 +2,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createEngineEvent } from '../../contract.js'; import { MAX_IMAGERY_SLOTS, createNavaraImagery } from './index.js'; +import { createNavaraMaps } from '../maps.js'; +import { createFakeEngine } from '../../testing/fakeEngine.js'; +import { + OSM_OVERLAYS, + createOsmOverlayLayers, +} from '../../../data/osmOverlays.js'; const BOX = { west: -97.77, south: 30.25, east: -97.71, north: 30.29 }; const tick = () => new Promise((resolve) => setImmediate(resolve)); @@ -83,6 +89,77 @@ const tiles = (extra = {}) => ({ ...extra, }); +for (const photoreal of [false, true]) { + test(`map overlays keep their draped atlases across surface switches (tiles initially ${photoreal})`, async (t) => { + const r = Math.PI / 180; + const f = fixture( + Object.fromEntries( + Object.entries(BOX).map(([key, value]) => [key, value * r]), + ), + ); + t.after(f.destroy); + const mapOwner = createNavaraMaps({ + addSource() { + assert.fail('Map overlays must not create native globe rasters'); + }, + }); + t.after(mapOwner.destroy); + f.setPhotoreal(photoreal); + const layers = createOsmOverlayLayers(); + for (const layer of layers) { + // The fake engine with this adapter's maps and imagery. + const engine = Object.assign(createFakeEngine({ kind: 'navara' }), { + maps: mapOwner.maps, + imagery: f.imagery, + }); + layer.init(null, { engine }); + layer.enable(); + } + await tick(); + const slots = f.last().slots; + assert.equal(slots.length, 2); + for (const [i, overlay] of OSM_OVERLAYS.entries()) { + const source = f.imagery.list()[i]; + assert.equal(source.id, `osm-overlay-${overlay.id}`); + assert.equal(source.order, 10); + assert.equal(f.credits.get(overlay.credit), 1); + assert.deepEqual(slots[i].look, [1, 0, 1, 0]); + assert.equal(slots[i].raise, 0, 'draped on the visible surface'); + assert.ok( + f.jobs.some(({ url }) => + url.startsWith(overlay.urlTemplate.split('{z}')[0]), + ), + ); + } + const decoded = { close() {} }; + f.jobs[0].resolve(decoded); + await tick(); + assert.deepEqual(f.draws, [decoded]); + const jobs = f.jobs.length; + for (const active of [!photoreal, photoreal]) { + f.setPhotoreal(active); + f.changed.raiseEvent(); + assert.equal(f.imagery.surface(), active ? 'tiles' : 'globe'); + assert.deepEqual( + f.last().slots, + slots, + 'the same textures and blend settings', + ); + assert.equal(f.jobs.length, jobs, 'surface switches do not reload tiles'); + for (const layer of layers) + assert.equal(layer.diagnostics().host, active ? 'tileset' : 'globe'); + } + for (const layer of layers) layer.destroy(); + assert.deepEqual(f.last().slots, []); + assert.ok(f.jobs.slice(1).every(({ signal }) => signal.aborted)); + assert.ok( + f.canvases.every(({ width, height }) => width === 0 && height === 0), + ); + for (const overlay of OSM_OVERLAYS) + assert.equal(f.credits.get(overlay.credit), 0); + }); +} + test('a tiles layer mosaics the visible tiles into one atlas; a new source drops it and its loads', async () => { const f = fixture(); const layer = f.imagery.createLayer({ id: 'a', source: tiles() }); diff --git a/src/globe/navara/maps-release.test.mjs b/src/globe/navara/maps-release.test.mjs index 2d69a715d..7ac510b10 100644 --- a/src/globe/navara/maps-release.test.mjs +++ b/src/globe/navara/maps-release.test.mjs @@ -65,6 +65,7 @@ function setup(core) { String(sequence++), update(next) { Object.assign(layer, next); + core?.updateLayer(layer.id, { ...next, source: layer.source.id }); }, delete() { core?.deleteLayer(layer.id); @@ -119,7 +120,7 @@ for (const [name, source] of [ ['Google', google], ]) { for (const action of ['clear', 'release', 'stack', 'destroy']) { - test(`${name} photoreal ${action} removes visible and hidden roots, geometry, source and credit`, async (t) => { + test(`${name} photoreal ${action} releases or parks tiles without stale surface answers or credits`, async (t) => { globalThis.location = { origin: 'http://localhost' }; t.after(() => { delete globalThis.location; @@ -154,10 +155,15 @@ for (const [name, source] of [ maps.releasePhotorealTiles(maps.current().photoreal); if (action === 'stack') await controller.setStack('osm'); if (action === 'destroy') state.destroy(); - assert.deepEqual(hooks.tileRoots(), []); - assert.equal(state.view._meshes.size, 0); - assert.ok(!sources.has(layer.source)); - assert.ok(!layers.has(layer.id)); + const parked = action === 'clear' || action === 'stack'; + assert.deepEqual( + hooks.tileRoots(), + parked ? tiles.map((t) => t.root) : [], + ); + assert.equal(state.view._meshes.size, parked ? 2 : 0); + assert.equal(sources.has(layer.source), parked); + assert.equal(layers.has(layer.id), parked); + if (parked) assert.equal(layer.model.show, false); assert.ok( ![...credits].some((credit) => credit.creditLayerId === layer.id), ); @@ -182,6 +188,12 @@ for (const [name, source] of [ undefined, ); for (const { root, mesh, disposed } of tiles) { + if (parked) { + assert.ok(root.parent); + assert.ok(mesh.geometry.attributes.position); + assert.equal(disposed(), 0); + continue; + } assert.equal(root.parent, null); assert.equal(root.children.length, 0); assert.equal(mesh.geometry.attributes.position, undefined); @@ -226,7 +238,7 @@ test('native tile eviction removes the root from layer cleanup', async (t) => { root.dispatchEvent({ type: 'removedFromWorld' }); root.removeFromParent(); state.view._meshes.delete(root.id); - await state.maps.setPhotorealTiles(null); + state.maps.releasePhotorealTiles(google); assert.equal(layer.listenerCount('deleted'), 0); assert.equal(disposed(), 0, 'native eviction already owns disposal'); }); @@ -260,7 +272,7 @@ test('native budgeted source deletion leaves no adapter tile roots', async (t) = for (let frame = 0; frame < 120; frame++) { if (frame === 60) { assert.equal(added, 4, 'all native fixture models loaded'); - await state.maps.setPhotorealTiles(null); + state.maps.releasePhotorealTiles(fixture); assert.equal(core.getSourceDescription(layer.source.id), undefined); assert.deepEqual(state.hooks.tileRoots(), []); assert.equal(state.view._meshes.size, 0); @@ -309,3 +321,87 @@ test('native budgeted source deletion leaves no adapter tile roots', async (t) = } } }); + +test('native budgeted photoreal hide and restore reuse all tile payloads', async (t) => { + const { Core, initSync } = await import('@navaramap/engine'); + initSync({ + module: readFileSync( + new URL( + '../../../node_modules/@navaramap/engine/navara_wasm_bg.wasm', + import.meta.url, + ), + ), + }); + globalThis.location = { origin: 'http://localhost' }; + t.after(() => { + delete globalThis.location; + }); + const core = new Core('tiles-retention-test'); + core.start(); + core.resize(1280, 800, 1); + core.setCacheBytes(768 * 1024 * 1024); + core.changeCamera(new Float64Array([-97.7431, 30.2672, 1100]), -90, 0, 0); + const state = setup(core); + const requested = [[], [], []]; + let added = 0; + const shown = new Map(); + try { + await state.maps.setPhotorealTiles(fixture); + const layer = [...state.layers.values()][0]; + for (let frame = 0; frame < 180; frame++) { + if (frame === 60) { + assert.equal(added, 4); + await state.maps.setPhotorealTiles(null); + } + if (frame === 120) { + assert.deepEqual([...shown.values()], [false, false, false, false]); + await state.maps.setPhotorealTiles(fixture); + } + core.update(frame * 16); + const events = core.readEvents(); + if (!events) continue; + for (const event of events.take_data_requested()) { + const name = new URL(event.url).pathname.split('/').at(-1); + requested[Math.floor(frame / 60)].push(name); + const bytes = readFileSync( + new URL(`../../../scripts/fixtures/tiles3d/${name}`, import.meta.url), + ); + core.setBufferU8(event.handle, event.bits, bytes.length, (buffer) => + buffer.set(bytes), + ); + event.free(); + } + for (const event of events.take_renderable_feature_added()) { + state.load(layer); + added++; + core.markFeatureIsRendered('model', event.bits); + event.free(); + } + for (const event of events.take_renderable_feature_changed()) { + const feature = event.feature; + const model = feature.model; + const material = model.material; + shown.set(event.bits, model.active && material.show !== false); + material.free(); + model.free(); + feature.free(); + event.free(); + } + for (const event of events.take_mesh_added()) { + core.setTileMeshPrepared(event.tile_handle); + event.free(); + } + events.free(); + } + assert.equal(requested[0].length, 5); + assert.deepEqual(requested.slice(1), [[], []]); + assert.equal(added, 4, 'the same four decoded models are reused'); + assert.deepEqual([...shown.values()], [true, true, true, true]); + assert.deepEqual(layer.model, { maxSse: 16, height: 0, show: true }); + state.maps.releasePhotorealTiles(fixture); + assert.deepEqual(state.hooks.tileRoots(), []); + } finally { + state.destroy(); + core.free(); + } +}); diff --git a/src/globe/navara/maps.js b/src/globe/navara/maps.js index ae0b1317d..60e74602e 100644 --- a/src/globe/navara/maps.js +++ b/src/globe/navara/maps.js @@ -13,8 +13,8 @@ // URL without the {z}/{x}/{y}.terrain template, so URLs always carry it. // - photoreal: Google 3D tiles with a browser key only (no server proxy), // with Google's logo and the tiles' own -// copyright lines (creditLayerId) on screen. Showing them hides the base -// imagery; clearing them restores the last base imagery and its credit. +// copyright lines (creditLayerId) on screen. Showing them hides the +// globe surface; clearing them restores the last base imagery and its credit. // Hidden, not deleted: deleting the last raster layer stops Navara 0.2.1 // loading terrain at the current view. @@ -54,6 +54,11 @@ const GOOGLE_LOGO = 'https://assets.ion.cesium.com/google-credit.png'; // Cesium3DTileset's default maximumScreenSpaceError. const PHOTOREAL_MAX_SSE = 16; +// Hidden tiles remain cached but must not answer rendered-surface queries. +const photorealVisibility = new WeakMap(); +export const photorealTilesVisible = (view) => + photorealVisibility.get(view) !== false; + const kindOf = (source) => (source?.descriptor ?? source)?.kind; function imageryFor(source) { @@ -126,15 +131,8 @@ export function createNavaraMaps( }, SETTLE_EVERY_MS); }; const slots = { imagery: null, terrain: null, photoreal: null }; - // Transparent tile overlays (sea marks, ski pistes) above the base - // imagery: Navara draws raster layers in the order they were added. - const overlays = new Set(); - function restackOverlays() { - for (const overlay of overlays) { - overlay.layer.delete(); - overlay.layer = view.addLayer({ type: 'raster', source: overlay.source }); - } - } + let hiddenGlobe = null; + photorealVisibility.set(view, false); // Base imagery and photoreal tiles share one switch order (display): the // call made last decides what shows. const generations = { display: 0, terrain: 0 }; @@ -163,7 +161,6 @@ export function createNavaraMaps( const previous = slots[name]; const source = view.addSource(sourceDesc); const layer = view.addLayer({ ...layerDesc, source }); - if (name === 'imagery') restackOverlays(); const shown = credits.map((credit) => typeof credit === 'function' ? credit(layer) : credit, ); @@ -196,6 +193,21 @@ export function createNavaraMaps( changed(); } + function setPhotorealShown(shown) { + const slot = slots.photoreal; + photorealVisibility.set(view, shown); + if (!slot || slot.hidden === !shown) return; + slot.hidden = !shown; + slot.layer.update({ + ...slot.layerDesc, + source: slot.source, + model: { ...slot.layerDesc.model, show: shown }, + }); + if (slot.credits.length) + view.attribution?.[shown ? 'add' : 'remove'](slot.credits); + changed(); + } + async function terrainFor(source) { const kind = source?.kind ?? source?.id; switch (kind) { @@ -259,7 +271,7 @@ export function createNavaraMaps( [credit], ); // Under tiles the base imagery waits hidden, credit included. - setImageryShown(slots.photoreal == null); + setImageryShown(!maps.photorealActive()); }, async setTerrain(source) { const generation = ++generations.terrain; @@ -285,7 +297,11 @@ export function createNavaraMaps( async setPhotorealTiles(source) { const generation = ++generations.display; if (source == null) { - clear('photoreal'); + // Keep the tileset warm across base-map switches, as Cesium does. + // Deleting and recreating it refetches even the cached tile contents. + setPhotorealShown(false); + hiddenGlobe?.delete(); + hiddenGlobe = null; tilesSource = null; // The globe comes back with the last base imagery and its credit. if (slots.imagery) { @@ -301,15 +317,24 @@ export function createNavaraMaps( throw new Error( 'Google 3D tiles on Navara need a browser key (googleApiKey)', ); + // A source-only view (terrain sampling without a renderer) has no globe. + const hideGlobe = view.globe + ? (await import('./ground/globe.js')).hideGlobe + : null; if (destroyed || generation !== generations.display) return; const url = tilesetUrl ? new URL(tilesetUrl, globalThis.location.origin).href : `${GOOGLE_TILES_ROOT}?key=${encodeURIComponent(key)}`; setImageryShown(false); + if (!hiddenGlobe && hideGlobe) hiddenGlobe = hideGlobe(view); currentBase = null; tilesSource = source; knownTiles.add(source); - if (slots.photoreal?.key === url) return; + if (slots.photoreal?.key === url) { + slots.photoreal.owner = source; + setPhotorealShown(true); + return; + } show( 'photoreal', url, @@ -326,43 +351,17 @@ export function createNavaraMaps( }), ], ); - // Google's terms want its copyright lines on screen, as Cesium shows - // them; Navara lists them in its attribution card, collapsed by default. - if (!tilesetUrl) view.attribution?.show(); + slots.photoreal.owner = source; + setPhotorealShown(true); }, releasePhotorealTiles(source) { if (!knownTiles.delete(source)) return; - if (tilesSource !== source) return; - void maps.setPhotorealTiles(null); - }, - photorealActive: () => slots.photoreal != null, - /** - * Navara-only extension: a transparent XYZ tile overlay ({z}/{x}/{y} - * `url`) above the base imagery, with its credit. Returns its removal. - */ - addOverlay({ url, maxZoom, credit }) { - if (destroyed) return () => {}; - const source = view.addSource({ - type: 'raster-tile', - url, - ...(maxZoom && { maxZoom }), - }); - const overlay = { - source, - layer: view.addLayer({ type: 'raster', source }), - credits: credit ? [{ attributionHtml: String(credit) }] : [], - }; - overlays.add(overlay); - if (overlay.credits.length) view.attribution?.add(overlay.credits); + if (slots.photoreal?.owner !== source) return; + if (tilesSource === source) void maps.setPhotorealTiles(null); + clear('photoreal'); changed(); - return () => { - if (!overlays.delete(overlay)) return; - overlay.layer.delete(); - overlay.source.delete(); - if (overlay.credits.length) view.attribution?.remove(overlay.credits); - changed(); - }; }, + photorealActive: () => slots.photoreal != null && !slots.photoreal.hidden, current: () => ({ baseImagery: currentBase, photoreal: tilesSource }), terrainRevision: () => terrainRevision, }; @@ -392,12 +391,10 @@ export function createNavaraMaps( destroyed = true; stopSettling(); stopDiscardingTiles(); - for (const overlay of overlays) { - overlay.layer.delete(); - overlay.source.delete(); - } - overlays.clear(); for (const name of Object.keys(slots)) clear(name); + photorealVisibility.set(view, false); + hiddenGlobe?.delete(); + hiddenGlobe = null; }, }; } diff --git a/src/globe/navara/maps.test.mjs b/src/globe/navara/maps.test.mjs index b4d692eab..57b78a657 100644 --- a/src/globe/navara/maps.test.mjs +++ b/src/globe/navara/maps.test.mjs @@ -1,9 +1,51 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { Scene } from 'three'; import { createNavaraMaps } from './maps.js'; +const loader = registerHooks({ + resolve(specifier, context, next) { + return specifier === '@navaramap/three' + ? { + url: `data:text/javascript,${encodeURIComponent(` + export class EffectDesc { + constructor(view, ctx) { this.ctx = ctx; } + onCreate() { this.createPass(); } + onDestroy() {} + } + `)}`, + shortCircuit: true, + } + : next(specifier, context); + }, +}); +await import('./ground/globe.js'); +loader.deregister(); + function fakeView() { const view = { + globe: {}, + scenes: { globe: new Scene(), mrt: new Scene() }, + effects: new Set(), + effectTypes: new Map(), + registerEffect(key, type) { + view.effectTypes.set(key, type); + }, + addEffect(desc) { + const key = Object.keys(desc)[0]; + const Type = view.effectTypes.get(key); + const effect = new Type(view, { scenes: view.scenes }, desc); + effect.onCreate(); + const handle = { + delete() { + if (!view.effects.delete(handle)) return; + effect.onDestroy(); + }, + }; + view.effects.add(handle); + return handle; + }, sources: [], layers: [], shown: [], @@ -205,11 +247,16 @@ test('setPhotorealTiles shows Google 3D tiles with a browser key and the Google assert.deepEqual(view.rasterCounts, []); const google = view.shown.find((item) => item.creditLayerId === tiles.id); assert.ok(google?.logo, 'no Google logo'); - assert.ok(view.opened > 0, 'the copyright lines are not opened on screen'); + assert.equal( + view.opened, + 0, + 'tiles must not force the attribution card open', + ); assert.equal(maps.photorealActive(), true); await maps.setPhotorealTiles(null); assert.equal(maps.photorealActive(), false); - assert.equal(layersOf(view, '3d-tiles').length, 0); + assert.equal(layersOf(view, '3d-tiles').length, 1); + assert.equal(tiles.desc.model.show, false); assert.ok(!view.shown.some((item) => item.creditLayerId)); }); @@ -231,6 +278,105 @@ test('base imagery hidden by photoreal comes back; a new one replaces it without assert.deepEqual(view.rasterCounts, [1], 'a raster gap while replacing'); }); +test('photoreal hides the whole globe while terrain sources and sampling stay live', async (t) => { + const view = fakeView(); + const adapter = createNavaraMaps(view); + const { maps } = adapter; + t.after(adapter.destroy); + const point = { longitude: 1, latitude: 0.5 }; + await maps.setBaseImagery({ kind: 'osm' }); + await maps.setTerrain({ kind: 'keyless' }); + const [terrain] = layersOf(view, 'terrain'); + view.sampleTerrainMostDetailed = async (source) => { + assert.equal(source, terrain.desc.source); + return [{ height: 4200 }]; + }; + const revision = maps.terrainRevision(); + const tiles = { googleApiKey: 'k' }; + await maps.setPhotorealTiles(tiles); + assert.equal(view.scenes.globe.visible, false); + assert.equal(view.scenes.mrt.visible, true, 'tiles still write scene depth'); + assert.deepEqual(await adapter.sampleTerrain([point]), [4200]); + assert.equal(maps.terrainRevision(), revision); + assert.equal(layersOf(view, 'terrain')[0], terrain); + assert.equal(layersOf(view, 'raster').length, 1); + await maps.setBaseImagery({ kind: 'esri-imagery' }); + assert.equal(view.scenes.globe.visible, false); + await maps.setPhotorealTiles(tiles); + assert.equal(view.effects.size, 1, 'same tiles do not stack globe owners'); + await maps.setPhotorealTiles({ googleApiKey: 'replacement' }); + assert.equal(view.effects.size, 1); + assert.equal(view.scenes.globe.visible, false); + await maps.setPhotorealTiles(null); + assert.equal(view.scenes.globe.visible, true); + assert.equal(view.effects.size, 0); + assert.equal(layersOf(view, 'raster')[0].desc.raster.show, true); + assert.deepEqual(await adapter.sampleTerrain([point]), [4200]); +}); + +test('globe visibility is restored on release and destruction, including an already hidden globe', async () => { + for (const visible of [true, false]) { + for (const action of ['release', 'destroy']) { + const view = fakeView(); + view.scenes.globe.visible = visible; + const { maps, destroy } = createNavaraMaps(view); + const tiles = { googleApiKey: 'k' }; + await maps.setPhotorealTiles(tiles); + assert.equal(view.scenes.globe.visible, false); + if (action === 'release') maps.releasePhotorealTiles(tiles); + else destroy(); + assert.equal(view.scenes.globe.visible, visible); + assert.equal(view.effects.size, 0); + destroy(); + } + } +}); + +test('a photoreal request superseded during renderer loading cannot hide the globe', async () => { + const view = fakeView(); + const { maps, destroy } = createNavaraMaps(view); + const pending = maps.setPhotorealTiles({ googleApiKey: 'k' }); + await maps.setPhotorealTiles(null); + await pending; + assert.equal(view.scenes.globe.visible, true); + assert.equal(maps.photorealActive(), false); + assert.equal(view.effects.size, 0); + const next = maps.setPhotorealTiles({ googleApiKey: 'k' }); + destroy(); + await next; + assert.equal(view.scenes.globe.visible, true); + assert.equal(view.layers.length, 0); +}); + +test('map switches reuse the tile cache at the same detail and release a parked source', async (t) => { + const view = fakeView(); + const { maps, destroy } = createNavaraMaps(view); + t.after(destroy); + const tiles = { googleApiKey: 'k' }; + await maps.setBaseImagery({ kind: 'osm' }); + await maps.setPhotorealTiles(tiles); + const [layer] = layersOf(view, '3d-tiles'); + const source = layer.desc.source; + for (let i = 0; i < 3; i++) { + await maps.setPhotorealTiles(null); + assert.equal(maps.photorealActive(), false); + assert.equal(layer.desc.model.show, false); + await maps.setBaseImagery({ kind: 'osm' }); + assert.equal(layersOf(view, 'raster')[0].desc.raster.show, true); + await maps.setPhotorealTiles(tiles); + assert.equal(maps.photorealActive(), true); + assert.equal(layersOf(view, '3d-tiles')[0], layer); + assert.equal(layer.desc.source, source); + assert.deepEqual(layer.desc.model, { maxSse: 16, height: 0, show: true }); + assert.equal(view.sources.length, 2); + } + await maps.setPhotorealTiles(null); + maps.releasePhotorealTiles(tiles); + assert.equal(layersOf(view, '3d-tiles').length, 0); + assert.ok(!view.sources.includes(source)); + assert.equal(view.scenes.globe.visible, true); +}); + test('photoreal without a browser key rejects and changes nothing', async () => { const view = fakeView(); const { maps } = createNavaraMaps(view); @@ -360,43 +506,6 @@ test('after a map change Navara keeps redrawing while tiles arrive, then stops', assert.equal(view.frames, atDestroy); }); -test('a tile overlay stays above the base imagery across map switches and credits its source', async () => { - const view = fakeView(); - const { maps, destroy } = createNavaraMaps(view, { - setInterval: () => 1, - clearInterval() {}, - }); - await maps.setBaseImagery({ kind: 'osm' }); - const remove = maps.addOverlay({ - url: 'https://tiles.example/seamark/{z}/{x}/{y}.png', - maxZoom: 18, - credit: 'Sea marks © example', - }); - const order = () => - view.layers.map( - (layer) => - view.sources.find((source) => source === layer.desc.source)?.desc.url, - ); - assert.deepEqual(order(), [ - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - 'https://tiles.example/seamark/{z}/{x}/{y}.png', - ]); - assert.ok( - view.shown.some((item) => item.attributionHtml === 'Sea marks © example'), - ); - // A new base layer goes in on top; the overlay is put back above it. - await maps.setBaseImagery({ kind: 'esri-imagery' }); - assert.equal(order().at(-1), 'https://tiles.example/seamark/{z}/{x}/{y}.png'); - assert.equal(order().length, 2); - remove(); - assert.equal(order().length, 1); - assert.ok( - !view.shown.some((item) => item.attributionHtml === 'Sea marks © example'), - ); - remove(); - destroy(); -}); - test('world terrain publishes endpoint credits and removes them on replacement', async () => { const view = fakeView(); const { maps, destroy } = createNavaraMaps(view, { diff --git a/src/globe/navara/surface.js b/src/globe/navara/surface.js index cd8c9a77b..3bc3410f8 100644 --- a/src/globe/navara/surface.js +++ b/src/globe/navara/surface.js @@ -21,6 +21,7 @@ import { } from 'three'; import * as geo from '../../geo/index.js'; import { normalizeSurfaceOptions } from '../surfaceDetail.js'; +import { photorealTilesVisible } from './maps.js'; const { cartesian3, cartographic, math } = geo; const WGS84 = geo.ellipsoid.WGS84; @@ -266,7 +267,7 @@ export function createNavaraSurface( }; const sampleHeightDetailed = (place, options) => { const { exclude } = normalizeSurfaceOptions(options); - if (!supported) return terrainDetail(place); + if (!supported || !photorealTilesVisible(view)) return terrainDetail(place); const ray = surfaceRay(place); const roots = hooks.tileRoots(); const excluded = exclude.length ? new Set(exclude) : NO_EXCLUDE; @@ -299,6 +300,7 @@ export function createNavaraSurface( const raycast = ({ origin, direction, far, exclude = [] }) => { if ( !supported || + !photorealTilesVisible(view) || ![ origin?.x, origin?.y, diff --git a/src/ui/styles/status.css b/src/ui/styles/status.css index 1b67d2f21..3ed506d4a 100644 --- a/src/ui/styles/status.css +++ b/src/ui/styles/status.css @@ -439,15 +439,42 @@ html:not([lang='en']) #global-loading-detail { transform: translateX(-50%) translateY(0); } -/* The experimental Navara engine (?engine=navara) shows its attributions in - its own card, fixed bottom-right over the location tray. Keep it on the - left, above the HUD's coordinate lines and clear of the left panel rail: - the list is capped (Google's lines come first; the rest scrolls). */ -body .navara-attr-dock { - right: auto; - left: 16px; - bottom: 176px; - align-items: flex-start; +/* Navara uses the shared credit strip's position and typography. Its native + card and logos stay owned by the plugin, inside the renderer's container. */ +#cesium-credits .navara-attr-logoframe:not([hidden]) { + position: static; + display: inline-flex; + vertical-align: middle; + margin-right: 5px; +} +#cesium-credits .navara-attr-logo { + height: auto; +} +#cesium-credits .navara-attr-dock { + position: static; + display: inline-block; + inset: auto; + margin-left: 5px; + vertical-align: middle; +} +#cesium-credits .navara-attr-toggle { + width: 16px; + height: 16px; + min-width: 16px; + background: transparent; + border: none; + box-shadow: none; + color: inherit; +} +#cesium-credits .navara-attr-card { + position: absolute; + left: 0; + bottom: calc(100% + 8px); + white-space: normal; + text-shadow: none; +} +#cesium-credits .navara-attr-card a { + color: var(--nvr-attr-link, #3a6595); } body .navara-attr-list { max-height: 120px;