diff --git a/README.md b/README.md index 9ce8d1ff0..c9db8db5d 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,8 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea 7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_ 8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_ +Open **Visual Presets → Globe Engine** to switch between Cesium and experimental Navara. The page reloads at the current view and remembers your choice. A link with `?engine=cesium` or `?engine=navara` overrides the saved choice. A fresh visit starts with Cesium. + **Keyboard:** `1`–`7` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe. --- diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 65bfe7b72..4fe3f247e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1080,6 +1080,7 @@ "build/navaraAssets.js", "scripts/qa-engine-bench.mjs", "scripts/qa-engine-shell.mjs", + "scripts/qa-engine-switch.mjs", "scripts/qa/navara-contract.html", "scripts/qa/navaraContractPage.js", "scripts/qa/tileCache.mjs", diff --git a/scripts/qa-engine-switch.mjs b/scripts/qa-engine-switch.mjs new file mode 100644 index 000000000..dbfb4d678 --- /dev/null +++ b/scripts/qa-engine-switch.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; +import { installTileCache } from './qa/tileCache.mjs'; + +const base = new URL(process.argv[2]); +if ( + !['localhost', '127.0.0.1'].includes(base.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + base.port, + ) +) { + throw new Error('Supply an unreserved local preview URL'); +} +base.search = '?welcome=0&look=cinematic'; +base.hash = 'v=2&lat=42.3554&lon=-71.0605&alt=6000&heading=0&pitch=-90&map=osm'; +const browser = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const page = await browser.newPage(); + await blockPaidQaMaps(page); + await installTileCache(page); + await page.evaluateOnNewDocument(() => { + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + }); + await page.setViewport({ width: 1280, height: 800 }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + async function ready(kind) { + await page.waitForFunction( + (kind) => + window.__godsEyeView?.engine?.kind === kind && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 90000 }, + kind, + ); + await page.evaluate(async () => { + await window.__godsEyeView.styleManager.initialRestorePromise; + if (window.__GOOGLE_MAPS_API_KEY__ || window.__godsEyeView.tileset) + throw new Error('Keyless preview required'); + }); + assert.equal(await page.$eval('#globe-engine', (el) => el.value), kind); + } + const stored = () => page.evaluate(() => localStorage.getItem('gev.engine')); + async function switchTo(kind) { + if ( + await page.$eval( + '#control-panel-toggle', + (el) => el.getAttribute('aria-expanded') !== 'true', + ) + ) { + await page.focus('#control-panel-toggle'); + await page.keyboard.press('Enter'); + } + await page.waitForSelector('#globe-engine', { visible: true }); + await Promise.all([ + page.waitForNavigation({ waitUntil: 'domcontentloaded' }), + page.select('#globe-engine', kind), + ]); + await ready(kind); + const url = new URL(page.url()); + assert.equal(url.searchParams.get('engine'), kind); + assert.equal(url.searchParams.get('look'), 'cinematic'); + const hash = new URLSearchParams(url.hash.slice(1)); + assert.equal(hash.get('map'), 'osm'); + assert.ok(Math.abs(Number(hash.get('lat')) - 42.3554) < 0.01); + assert.ok(Math.abs(Number(hash.get('lon')) + 71.0605) < 0.01); + assert.ok(Math.abs(Number(hash.get('alt')) - 6000) < 30); + const view = await page.evaluate(() => { + const { engine, mapStackController } = window.__godsEyeView; + const { latitude, longitude, height } = engine.camera.pose().cartographic; + return { + latitude: latitude * (180 / Math.PI), + longitude: longitude * (180 / Math.PI), + height, + map: mapStackController.getActiveId(), + }; + }); + assert.equal(view.map, 'osm'); + assert.ok(Math.abs(view.latitude - 42.3554) < 0.01); + assert.ok(Math.abs(view.longitude + 71.0605) < 0.01); + assert.ok(Math.abs(view.height - 6000) < 30); + } + await page.goto(base.href, { waitUntil: 'domcontentloaded' }); + await ready('cesium'); + await switchTo('navara'); + assert.equal(await stored(), 'navara'); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await ready('cesium'); + await page.goto(base.href, { waitUntil: 'domcontentloaded' }); + await ready('navara'); + await switchTo('cesium'); + assert.equal(await stored(), 'cesium'); + await page.evaluate(() => { + const write = Storage.prototype.setItem; + Storage.prototype.setItem = function (key, value) { + if (key === 'gev.engine') + throw new DOMException('Blocked', 'SecurityError'); + return write.call(this, key, value); + }; + }); + await switchTo('navara'); + assert.equal(await stored(), 'cesium'); + assert.deepEqual(errors, []); + console.log( + 'PASS Cesium default, both switches, share pose/map, Back, remembered engine, blocked preference storage', + ); +} finally { + await browser.close(); +} diff --git a/src/sharelink.js b/src/sharelink.js index 028282d65..1178d49ad 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -522,6 +522,12 @@ export class ShareLinkManager { } } + /** Save the current view before leaving the page. */ + flush() { + clearTimeout(this._debounceTimer); + this._updateHash(); + } + _scheduleUpdate() { if (this._destroyed || this._initialRestorePending) return; clearTimeout(this._debounceTimer); diff --git a/src/standalone/controls.js b/src/standalone/controls.js index b168c5e1b..d1131525f 100644 --- a/src/standalone/controls.js +++ b/src/standalone/controls.js @@ -1,8 +1,40 @@ import { createApplicationControls } from '../app/controls.js'; import { getStandaloneCatalog } from './catalog.js'; +import { ENGINE_STORAGE_KEY } from '../app/engineChoice.js'; + export function createStandaloneControls(options) { - return createApplicationControls({ + const controls = createApplicationControls({ catalog: options?.catalog ?? getStandaloneCatalog(), ...options, }); + const select = document.getElementById('globe-engine'); + const container = document.getElementById('globe-engine-control'); + if (select && container) { + const current = options.scene.engine.kind; + select.value = current; + const switchEngine = () => { + const next = select.value; + if (next === current || !['cesium', 'navara'].includes(next)) return; + controls.styleManager.shareLinkManager.flush(); + const url = new URL(window.location.href); + // Keep Back meaningful even when this visit began without ?engine=. + url.searchParams.set('engine', current); + window.history.replaceState(window.history.state, '', url); + url.searchParams.set('engine', next); + try { + window.localStorage.setItem(ENGINE_STORAGE_KEY, next); + } catch { + // The explicit URL also works when storage is blocked. + } + select.value = current; + window.location.assign(url); + }; + select.addEventListener('change', switchEngine); + container.hidden = false; + options.defer(() => { + select.removeEventListener('change', switchEngine); + container.hidden = true; + }); + } + return controls; } diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 8607cd459..9be9b936a 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -824,6 +824,15 @@ } /* Map Source tiles live with Visual Presets in the bottom command tray. */ +#globe-engine-control label { + gap: 8px; + margin-top: 8px; +} + +#globe-engine:focus-visible { + outline: 2px solid currentColor; +} + .map-source-section { display: grid; gap: 6px; diff --git a/src/ui/templates/command-dock.html b/src/ui/templates/command-dock.html index cf1af7efb..f98032a6a 100644 --- a/src/ui/templates/command-dock.html +++ b/src/ui/templates/command-dock.html @@ -49,6 +49,14 @@ 7 +
MAP SOURCE