diff --git a/scripts/qa-navara-geo-import.mjs b/scripts/qa-navara-geo-import.mjs new file mode 100644 index 000000000..e13bd729f --- /dev/null +++ b/scripts/qa-navara-geo-import.mjs @@ -0,0 +1,339 @@ +#!/usr/bin/env node +// UTILITIES ▸ Import on Navara under the strict network policy: GeoJSON, +// KML, KMZ (a packed icon and a ground overlay) and GPX through the real +// file input, each one row; Navara draws their shapes in the row colour and +// their pins, counts what it cannot draw (the KMZ ground overlay) and says +// so in the hint; hide, fly-to and remove work on the engine; the files +// survive a switch to Cesium and back and draw on each engine. No file makes +// the page request anything it names (the strict policy fails any request +// outside the preview and the free tile cache). +// +// node scripts/qa-navara-geo-import.mjs http://127.0.0.1:/ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import sharp from 'sharp'; +import { fromDegrees } from '../src/geo/cartesian3.js'; +import { createStrict, localPreview } from './qa-strict-harness.mjs'; + +const VIEW = { lat: 37.7694, lon: -122.4862, alt: 4200 }; +const base = localPreview(process.argv[2]); +base.search = '?engine=navara&welcome=0'; +base.hash = `v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch=-90&roll=0&map=osm&l=`; +const strict = createStrict({ origin: base.origin, layers: ['geo-import'] }); +const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-geo-import'; +fs.mkdirSync(SHOTS, { recursive: true }); +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const TRAP = 'gev-qa-trap.invalid'; + +/* ── fixtures (as scripts/qa-geo-import.mjs) ───────────────────────────── */ +const ring = [ + [-122.511, 37.7645], + [-122.4545, 37.7645], + [-122.4545, 37.7745], + [-122.511, 37.7745], + [-122.511, 37.7645], +]; +const geojson = JSON.stringify({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + name: 'QA park', + description: ``, + }, + geometry: { type: 'Polygon', coordinates: [ring] }, + }, + { + type: 'Feature', + properties: { name: 'QA pin' }, + geometry: { type: 'Point', coordinates: [-122.4862, 37.7694] }, + }, + ], +}); +const kml = (iconHref, overlay) => ` + + + + https://${TRAP}/link.kml + QA KML pin#s + ]]> + -122.47,37.77,0 + QA KML line + -122.50,37.766 -122.46,37.772 + ${overlay} + 37.7737.76-122.46-122.47 + +`; +const gpx = ` + + QA waypoint + QA track + + +`; +// 8×8 opaque white PNG. +const PNG = await sharp({ + create: { width: 8, height: 8, channels: 4, background: '#ffffff' }, +}) + .png() + .toBuffer(); +function storedZip(files) { + const parts = []; + const central = []; + let offset = 0; + for (const [name, data] of files) { + const n = Buffer.from(name); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(n.length, 26); + const c = Buffer.alloc(46); + c.writeUInt32LE(0x02014b50, 0); + c.writeUInt32LE(data.length, 20); + c.writeUInt32LE(data.length, 24); + c.writeUInt16LE(n.length, 28); + c.writeUInt32LE(offset, 42); + parts.push(local, n, data); + central.push(c, n); + offset += 30 + n.length + data.length; + } + const dir = Buffer.concat(central); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(dir.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...parts, dir, end]); +} +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-qa-navara-import-')); +const fixture = (name, data) => { + const file = path.join(dir, name); + fs.writeFileSync(file, data); + return file; +}; +const FILES = [ + fixture('park.geojson', geojson), + fixture( + 'remote-bits.kml', + kml(`https://${TRAP}/icon.png`, `https://${TRAP}/overlay.png`), + ), + fixture( + 'packed.kmz', + storedZip([ + ['doc.kml', Buffer.from(kml('files/pin.png', 'files/overlay.png'))], + ['files/pin.png', PNG], + ['files/overlay.png', PNG], + ]), + ), + fixture('walk.gpx', gpx), +]; + +await strict.run( + { + args: [ + '--use-angle=metal', + '--enable-gpu', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ], + }, + async (browser) => { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 860 }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(base.href); + const ready = async (kind) => { + await page.waitForFunction( + (k) => + window.__godsEyeView?.engine?.kind === k && + window.__gevGeoImport?.diagnostics().attached && + !document.querySelector('#globe-engine')?.disabled && + !document.querySelector('.engine-switch-frame') && + document.querySelector('#loading-screen.hidden'), + { timeout: 120000 }, + kind, + ); + await page.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + }; + await ready('navara'); + assert.equal( + await page.evaluate(() => + Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__), + ), + false, + 'keyless preview required', + ); + const state = () => + page.evaluate(() => ({ + files: window.__gevGeoImport.list(), + diagnostics: window.__gevGeoImport.diagnostics(), + rows: document.querySelectorAll('#geo-import-list .geo-import-row') + .length, + hint: document.getElementById('geo-import-hint')?.textContent || '', + pins: window.__godsEyeView.engine.movers + .list() + .filter(({ id }) => id.startsWith('geo-import-')) + .reduce((n, set) => n + set.count, 0), + })); + /** Pixels near the first file's colour (#39d0ff) on the canvas. */ + const cyan = async () => { + await page.evaluate(() => window.__godsEyeView.requestRender?.('qa')); + await wait(900); + const { data, info } = await sharp( + await page.screenshot({ + clip: { x: 400, y: 260, width: 480, height: 300 }, + }), + ) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + let n = 0; + for (let i = 0; i < data.length; i += info.channels) { + const [r, g, b] = [data[i], data[i + 1], data[i + 2]]; + if (b > 120 && g > 90 && b - r > 50) n++; + } + return n; + }; + const setAll = (visible) => + page.evaluate((v) => { + for (const file of window.__gevGeoImport.list()) + window.__gevGeoImport.setVisible(file.id, v); + }, visible); + + await page.evaluate(() => { + const panel = document.getElementById('utilities-panel'); + if (panel?.classList.contains('collapsed')) + document + .querySelector( + '.panel-collapse-btn[data-collapse-target="utilities-panel"]', + ) + ?.click(); + }); + const input = await page.$('#geo-import-input'); + assert.ok(input, 'the Import file input'); + await input.uploadFile(...FILES); + await page.waitForFunction( + () => + window.__gevGeoImport.list().length === 4 && + !/Reading/.test( + document.getElementById('geo-import-hint')?.textContent || '', + ), + { timeout: 90000 }, + ); + const imported = await state(); + console.log('navara import', JSON.stringify(imported)); + await strict.check( + page, + 'geo-import', + 'GeoJSON, KML, KMZ and GPX each become one row and one drawing on Navara', + imported.files.length === 4 && + imported.rows === 4 && + imported.diagnostics.drawn === 4, + { files: imported.files.map((f) => f.format), rows: imported.rows }, + ); + // The KMZ's packed ground overlay is a rectangle Navara has no drawing + // for; the KML's remote one was left out of the file on import. + const undrawn = Object.fromEntries( + imported.files.map((f) => [f.name, f.undrawn]), + ); + await strict.check( + page, + 'geo-import', + 'the ground overlay Navara cannot draw is counted and reported in the hint', + undrawn['packed.kmz'] === 1 && + undrawn['park.geojson'] === 0 && + undrawn['remote-bits.kml'] === 0 && + undrawn['walk.gpx'] === 0 && + /leaves out of packed\.kmz: 1/.test(imported.hint), + { undrawn, hint: imported.hint }, + ); + await strict.check( + page, + 'geo-import', + 'the files’ pins are drawn as Navara sprites', + imported.pins >= 4, + { pins: imported.pins }, + ); + // The first file is flown to after the batch; settle there. + await wait(3000); + await page.screenshot({ path: `${SHOTS}/navara-imported.png` }); + + for (const kind of ['navara', 'cesium', 'navara']) { + if ( + (await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind + ) { + await strict.pick(page, kind); + await ready(kind); + await page.waitForFunction( + () => window.__gevGeoImport.diagnostics().drawn === 4, + { timeout: 60000 }, + ); + await wait(2500); + await page.screenshot({ path: `${SHOTS}/${kind}-after-switch.png` }); + } + const shown = await cyan(); + await setAll(false); + const hidden = await cyan(); + await setAll(true); + console.log(kind, 'cyan', { shown, hidden }); + await strict.check( + page, + 'geo-import', + 'the imported shapes paint the first file’s colour and stop when hidden', + shown - hidden > 1500, + { shown, hidden }, + ); + const kept = await state(); + await strict.check( + page, + 'geo-import', + 'the same four files are listed and drawn on this engine', + kept.files.length === 4 && kept.diagnostics.drawn === 4, + kept.diagnostics, + ); + } + // Fly-to on Navara goes through the engine camera. + const flight = await page.evaluate( + async (destination) => { + const { engine } = window.__godsEyeView; + engine.camera.setView({ destination }); + const before = engine.camera.height(); + const [first] = window.__gevGeoImport.list(); + const ok = window.__gevGeoImport.flyTo(first.id); + await new Promise((r) => setTimeout(r, 2500)); + return { ok, before, after: engine.camera.height() }; + }, + fromDegrees(-100, 40, 3e6), + ); + await strict.check( + page, + 'geo-import', + 'Fly to a file moves the Navara camera onto it', + flight.ok && flight.after < flight.before / 2, + flight, + ); + // Remove gives the drawing back. + await page.evaluate(() => { + for (const file of window.__gevGeoImport.list()) + window.__gevGeoImport.remove(file.id); + }); + const removed = await state(); + await strict.check( + page, + 'geo-import', + 'Remove takes every file and its pins off Navara', + removed.files.length === 0 && removed.rows === 0 && removed.pins === 0, + { files: removed.files.length, pins: removed.pins }, + ); + assert.deepEqual(errors, []); + console.log('PASS Navara import of GeoJSON, KML, KMZ and GPX'); + }, +); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index aceaf8194..797ee8ba1 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -28,6 +28,19 @@ import { createRecentImageryPanel } from '../ui/recentImagery.js'; // Native mesh capability stays at the composition boundary. const navaraViews = new WeakMap(); +/** + * An imported file's data source drawn on `engine` (UTILITIES ▸ Import); the + * drawing loads on first use, like the layers' Navara renderers. + */ +async function drawImported(engine, dataSource) { + const { drawImportedSource, measureImportImages } = + await import('../layers/shared/render/navaraStory.js'); + const sizes = await measureImportImages(dataSource); + return drawImportedSource(engine, navaraViews.get(engine), dataSource, { + sizes, + }); +} + /** The Navara globe module, loaded on demand. */ const loadNavaraGlobeModule = async () => (await import('../globe/navara/index.js')).createNavaraGlobe; @@ -400,7 +413,7 @@ export async function createNavaraScene({ * voice. */ export function attachViewerlessTools( - { engine }, + { engine, mapStackController = null }, { data, operations, @@ -468,8 +481,14 @@ export function attachViewerlessTools( defer(() => styleManager._clearPersistentStatusNotice?.()); // The page session's annotation board, Draw and Import on this engine // (src/app/navaraTools.js). - const tools = { engine, views: navaraViews.get(engine) }; - const context = { operations, placeSearch, layerSession, styleManager }; + const tools = { engine, views: navaraViews.get(engine), mapStackController }; + const context = { + operations, + placeSearch, + layerSession, + styleManager, + drawImported, + }; const annotations = attachNavaraAnnotations(tools, context, { signal, defer, diff --git a/src/app/navaraTools.js b/src/app/navaraTools.js index e8c1b945f..c3fc1f2ab 100644 --- a/src/app/navaraTools.js +++ b/src/app/navaraTools.js @@ -3,7 +3,7 @@ // with src/app/navaraMode.js, so the Cesium-mode bundle carries none of it. import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js'; -import { sessionAnnotations } from './sessionTools.js'; +import { sessionAnnotations, sessionGeoImport } from './sessionTools.js'; /** * The annotation board drawn on this Navara scene: draped areas and routes @@ -38,7 +38,31 @@ export function attachNavaraDraw() { return null; } -/** UTILITIES ▸ Import on this Navara scene; null while it has none. */ -export function attachNavaraGeoImport() { - return null; +/** + * UTILITIES ▸ Import on this Navara scene: the session's files drawn by + * `drawImported(engine, dataSource)` (src/layers/shared/render/navaraStory.js, + * loaded by src/app/navaraMode.js), what Navara leaves out of them reported + * in the control's hint; drops and camera gestures on this engine's canvas. + * Returns the control (null on a page without it or without the view). + */ +export function attachNavaraGeoImport( + { engine, views, mapStackController }, + { layerSession, styleManager, operations, drawImported }, + { defer }, +) { + if (!views?.view || typeof drawImported !== 'function') return null; + const geoFileImport = sessionGeoImport({ + layerSession, + styleManager, + operations, + defer, + }); + geoFileImport?.attachScene({ + engine, + draw: (dataSource) => drawImported(engine, dataSource), + navigation: styleManager, + subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener), + }); + defer(() => geoFileImport?.detachScene()); + return geoFileImport; } diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js index 2d444092e..62b502d4c 100644 --- a/src/data/geoFileImport.js +++ b/src/data/geoFileImport.js @@ -1197,14 +1197,16 @@ function checkLocalImages(doc, entries = new Map(), main = '') { // Cesium also processes KML in deferred tasks; an exception there does not // reject its load promise. Keep a failed file from blocking the serial queue. -async function loadKml(doc, viewer, signal) { +// The camera and canvas only feed network links, which sanitizing removed; a +// globe engine without a Cesium camera (Navara) passes its canvas alone. +async function loadKml(doc, { viewer, engine }, signal) { let removeError; try { return await boundedImport( Promise.race([ Cesium.KmlDataSource.load(doc, { - camera: viewer.scene.camera, - canvas: viewer.scene.canvas, + camera: viewer?.scene.camera, + canvas: viewer?.scene.canvas ?? engine?.host.canvas, clampToGround: true, }), new Promise((_, reject) => { @@ -1225,7 +1227,7 @@ async function loadKml(doc, viewer, signal) { // Resolve a static file's ground pins in batches before they are visualized. // Per-billboard CLAMP_TO_GROUND subscriptions scale poorly on large imports. const groundPins = new WeakMap(); -async function settleGroundPins(dataSource, viewer, signal) { +async function settleGroundPins(dataSource, { viewer, engine }, signal) { let pins = groundPins.get(dataSource); if (!pins) { pins = []; @@ -1264,6 +1266,31 @@ async function settleGroundPins(dataSource, viewer, signal) { ({ cartographic: c }) => new Cesium.Cartographic(c.longitude, c.latitude, 0), ); + // A globe engine without a viewer: its terrain under each pin. + if (!viewer && engine) { + for (let i = 0; i < batch.length; i++) { + const item = batch[i]; + const sampled = engine.surface.terrainHeightSupported + ? engine.surface.terrainHeight(positions[i]) + : undefined; + const ground = Number.isFinite(sampled) + ? sampled + : (item.groundHeight ?? 0); + item.groundHeight = ground; + const height = ground + item.offset; + item.entity.position = + Math.abs(height - item.cartographic.height) < 1e-6 + ? item.original + : Cesium.Cartesian3.fromRadians( + item.cartographic.longitude, + item.cartographic.latitude, + height, + ); + } + if (at + batch.length < pins.length) + await new Promise((resolve) => setTimeout(resolve, 0)); + continue; + } const mesh = viewer.scene?.globe?.show === false && viewer.scene?.clampToHeightSupported; @@ -1359,14 +1386,18 @@ async function boundedImport(promise, signal) { * Load a ground footprint, retaining explicit KML altitude modes. Call mount after * adding the source; revoke owns pin and blob cleanup. * @param {File|Blob & {name?: string}} file - * @param {{viewer: Cesium.Viewer, color?: string}} options `color` is a CSS color. + * @param {{viewer?: Cesium.Viewer, engine?: object, color?: string}} options + * `color` is a CSS color. Without a viewer, `engine` (a globe engine: + * Navara) supplies the ground under the pins, and the pins stay entities + * for that engine's own drawing (src/layers/shared/render/navaraStory.js). * @returns {Promise<{dataSource: Cesium.DataSource, name: string, * format: string, entityCount: number, removed: number, mount: Function, revoke: Function}>} */ export async function loadGeoFile( file, - { viewer, color = '#39d0ff', signal } = {}, + { viewer = null, engine = null, color = '#39d0ff', signal } = {}, ) { + const globe = { viewer, engine }; signal = signal ? AbortSignal.any([signal, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000); @@ -1449,7 +1480,7 @@ export async function loadGeoFile( deduplicateKmlStyles(doc); const normalizedSize = countKmlGeometry(doc); if (normalizedSize.over) throw overLimitError(normalizedSize); - dataSource = await loadKml(doc, viewer, signal); + dataSource = await loadKml(doc, globe, signal); } } catch (error) { revoke(); @@ -1488,7 +1519,7 @@ export async function loadGeoFile( } scrubImportedEntities(dataSource); try { - await settleGroundPins(dataSource, viewer, signal); + await settleGroundPins(dataSource, globe, signal); } catch (error) { revoke(); throw error instanceof GeoImportError @@ -1501,7 +1532,9 @@ export async function loadGeoFile( // already been stripped and all supported geometries are static. detachImportedTime(dataSource); try { - pins = createImportedPins(dataSource, viewer); + pins = viewer + ? createImportedPins(dataSource, viewer) + : { mount() {}, sync() {}, destroy() {} }; } catch { revoke(); throw new GeoImportError(msg('That file has invalid point graphics.')); @@ -1519,7 +1552,7 @@ export async function loadGeoFile( refreshGround: (refreshSignal) => settleGroundPins( dataSource, - viewer, + globe, refreshSignal ? AbortSignal.any([refreshSignal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), diff --git a/src/data/geoFileImport.test.mjs b/src/data/geoFileImport.test.mjs index 9b147e1d2..0c7f80ec9 100644 --- a/src/data/geoFileImport.test.mjs +++ b/src/data/geoFileImport.test.mjs @@ -1262,3 +1262,65 @@ test('KML namespace repair ignores declaration text inside quoted attribute valu /note="xmlns:xsi='x'" xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance">/, ); }); + +test('on a globe engine without a viewer the pins stay entities on the engine terrain', async () => { + const { createFakeEngine } = await import('../globe/testing/fakeEngine.js'); + const previous = Cesium.PinBuilder.prototype.fromColor; + Cesium.PinBuilder.prototype.fromColor = () => ({ + toDataURL: () => 'data:image/png;base64,local', + tagName: 'CANVAS', + }); + try { + const engine = createFakeEngine({ kind: 'navara' }); + engine.fake.script('terrainHeight', 321); + const object = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { name: 'Pin' }, + geometry: { type: 'Point', coordinates: [1, 2] }, + }, + { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [1, 2], + [2, 3], + ], + }, + }, + ], + }; + const loaded = await loadGeoFile( + new File([JSON.stringify(object)], 'pins.geojson'), + { engine }, + ); + const entities = loaded.dataSource.entities.values; + assert.equal(entities.length, 2, 'no Cesium billboard collection'); + assert.equal(loaded.dataSource.importedBillboards, undefined); + const [pin] = entities.filter((entity) => entity.billboard); + assert.ok( + Math.abs( + Cesium.Cartographic.fromCartesian( + pin.position.getValue(Cesium.JulianDate.now()), + ).height - 321, + ) < 1e-3, + ); + // A later ground refresh reads the engine again. + engine.fake.script('terrainHeight', 50); + await loaded.refreshGround(); + assert.ok( + Math.abs( + Cesium.Cartographic.fromCartesian( + pin.position.getValue(Cesium.JulianDate.now()), + ).height - 50, + ) < 1e-3, + ); + loaded.mount(); + loaded.revoke(); + } finally { + Cesium.PinBuilder.prototype.fromColor = previous; + } +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 8e5dcd274..ebba538d5 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -2230,6 +2230,7 @@ "other": "تعذّرت إضافة {count} ملف ({name}: {error})" }, "Some KML content was left out.": "أُغفل جزء من محتوى KML.", + "Map objects this engine leaves out of {name}: {count}.": "عناصر الخريطة من {name} التي لا يعرضها هذا المحرك: {count}.", "Press on the ground, not the sky": "اضغط على الأرض لا على السماء", "Drag to size the box": "اسحب لتحديد حجم المنطقة", "light {source} to use your {name} key": "فعّل {source} لاستخدام مفتاح {name} الخاص بك", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 4ea1833ad..975fd831a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2118,6 +2118,7 @@ "other": "{count} Dateien konnten nicht hinzugefügt werden ({name}: {error})" }, "Some KML content was left out.": "Einige KML-Inhalte wurden ausgelassen.", + "Map objects this engine leaves out of {name}: {count}.": "Kartenobjekte aus {name}, die diese Engine nicht darstellt: {count}.", "Press on the ground, not the sky": "Auf den Boden klicken, nicht in den Himmel", "Drag to size the box": "Ziehen, um die Größe des Bereichs festzulegen", "light {source} to use your {name} key": "{source} einschalten, um den eigenen {name}-Schlüssel zu nutzen", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 599a9ddc3..7fe55f7fe 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2196,6 +2196,7 @@ "other": "No se pudieron añadir {count} archivos ({name}: {error})" }, "Some KML content was left out.": "Se omitió parte del contenido KML.", + "Map objects this engine leaves out of {name}: {count}.": "Objetos del mapa de {name} que este motor no muestra: {count}.", "GNSS Integrity": "Integridad GNSS", "Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "Muestra en el globo tus propios archivos GeoJSON, KML, KMZ o GPX (o suéltalos sobre él). Los archivos no salen de este dispositivo; las capas pueden aparecer en capturas del mapa compartidas con la voz.", "Import": "Importar", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index f7646e24a..ab7275784 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1790,6 +1790,7 @@ "other": "{count} fichiers n'ont pas pu être ajoutés ({name} : {error})" }, "Some KML content was left out.": "Une partie du contenu KML a été ignorée.", + "Map objects this engine leaves out of {name}: {count}.": "Objets de carte de {name} que ce moteur n’affiche pas : {count}.", "Press on the ground, not the sky": "Appuyez sur le sol, pas sur le ciel", "Drag to size the box": "Faites glisser pour dimensionner la zone", "light {source} to use your {name} key": "activez {source} pour utiliser votre clé {name}", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 3fb3702f0..fa53ccc52 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1790,6 +1790,7 @@ "other": "Impossibile aggiungere {count} file ({name}: {error})" }, "Some KML content was left out.": "Una parte del contenuto KML è stata esclusa.", + "Map objects this engine leaves out of {name}: {count}.": "Oggetti della mappa di {name} che questo motore non mostra: {count}.", "Press on the ground, not the sky": "Premi sul terreno, non sul cielo", "Drag to size the box": "Trascina per dimensionare il riquadro", "light {source} to use your {name} key": "attiva {source} per usare la tua chiave {name}", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b49b8ea89..6d21f5ab5 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1728,6 +1728,7 @@ "other": "{count}個のファイルを追加できませんでした({name}:{error})" }, "Some KML content was left out.": "KMLの一部の内容は読み込まれませんでした。", + "Map objects this engine leaves out of {name}: {count}.": "このエンジンで表示されない {name} の地図オブジェクト: {count}。", "Press on the ground, not the sky": "空ではなく地表を押してください", "Drag to size the box": "ドラッグして範囲の大きさを決めてください", "light {source} to use your {name} key": "{source}をオンにして{name}キーを使う", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 4db98adaf..862a98a39 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1790,6 +1790,7 @@ "other": "Não foi possível adicionar {count} arquivos ({name}: {error})" }, "Some KML content was left out.": "Parte do conteúdo KML foi deixada de fora.", + "Map objects this engine leaves out of {name}: {count}.": "Objetos do mapa de {name} que este motor não mostra: {count}.", "Press on the ground, not the sky": "Pressione no chão, não no céu", "Drag to size the box": "Arraste para definir o tamanho da área", "light {source} to use your {name} key": "ative {source} para usar sua chave do {name}", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 42e92d7fa..22e0ff578 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2233,6 +2233,7 @@ "other": "Не удалось добавить {count} файла ({name}: {error})" }, "Some KML content was left out.": "Часть содержимого KML пропущена.", + "Map objects this engine leaves out of {name}: {count}.": "Объектов из {name}, которые этот движок не показывает: {count}.", "GNSS Integrity": "Целостность GNSS", "Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "Показать на глобусе свои файлы GeoJSON, KML, KMZ или GPX (или перетащите их на глобус). Содержимое файлов остаётся на этом устройстве; наложения могут попасть в снимки карты, которые передаются голосовому ассистенту.", "Import": "Импорт", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 321988529..c9121e0cb 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1820,6 +1820,7 @@ "other": "Не вдалося додати {count} файлу ({name}: {error})" }, "Some KML content was left out.": "Частину вмісту KML пропущено.", + "Map objects this engine leaves out of {name}: {count}.": "Обʼєктів із {name}, які цей рушій не показує: {count}.", "Press on the ground, not the sky": "Натисніть на землю, а не на небо", "Drag to size the box": "Потягніть, щоб задати розмір області", "light {source} to use your {name} key": "увімкніть {source}, щоб використати свій ключ {name}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 39b997495..6ad47accf 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1705,6 +1705,7 @@ "other": "{count} 个文件无法添加({name}:{error})" }, "Some KML content was left out.": "部分 KML 内容未被导入。", + "Map objects this engine leaves out of {name}: {count}.": "此引擎未显示的 {name} 地图对象:{count}。", "Press on the ground, not the sky": "请按在地面,而不是天空", "Drag to size the box": "拖动以调整框选大小", "light {source} to use your {name} key": "开启 {source} 以使用你的 {name} 密钥", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 4e605f49b..5ee8bf580 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1695,6 +1695,7 @@ "other": "{count} 個文件無法添加({name}:{error})" }, "Some KML content was left out.": "部分 KML 內容未被導入。", + "Map objects this engine leaves out of {name}: {count}.": "此引擎未顯示的 {name} 地圖對象:{count}。", "Press on the ground, not the sky": "請按在地面,而不是天空", "Drag to size the box": "拖動以調整框選大小", "light {source} to use your {name} key": "開啟 {source} 以使用你的 {name} 密鑰", diff --git a/src/layers/shared/render/navaraStory.js b/src/layers/shared/render/navaraStory.js index ba1b99eac..1bc60af42 100644 --- a/src/layers/shared/render/navaraStory.js +++ b/src/layers/shared/render/navaraStory.js @@ -171,3 +171,404 @@ export function drawStorySource(engine, { view, NavaraColor }, data) { }, }; } + +// Graphics an imported file (UTILITIES ▸ Import) may carry that Navara does +// not draw; each one present is counted and reported. +const UNDRAWN_GRAPHICS = Object.freeze([ + 'label', + 'model', + 'rectangle', + 'wall', + 'ellipse', + 'ellipsoid', + 'corridor', + 'box', + 'cylinder', + 'path', + 'plane', + 'polylineVolume', + 'tileset', +]); +const DEGREES = 180 / Math.PI; +// Unique mover set ids for imported files. +let importSets = 0; +const css = (c) => + '#' + + [c.red, c.green, c.blue] + .map((v) => + Math.round(Math.max(0, Math.min(1, v)) * 255) + .toString(16) + .padStart(2, '0'), + ) + .join(''); +const lonLat = (position) => { + const c = position && geo.cartographic.fromCartesian(position); + return c && Number.isFinite(c.longitude) && Number.isFinite(c.latitude) + ? [c.longitude * DEGREES, c.latitude * DEGREES] + : null; +}; +const ringOf = (positions) => { + const ring = (positions ?? []).map(lonLat).filter(Boolean); + if (ring.length >= 3) { + const [first] = ring; + const last = ring[ring.length - 1]; + if (first[0] !== last[0] || first[1] !== last[1]) ring.push([...first]); + } + return ring; +}; +const colorOf = (material, fallback) => { + const value = val(material); + return value?.color ?? val(material?.color) ?? fallback; +}; + +/** + * The plan for an imported data source on Navara, as plain data: GeoJSON + * features grouped by paint (`fills` and `lines`: Map of style key → + * { color: '#rrggbb', opacity, width?, features }), point sprites (`points`) + * and billboards grouped by image (`images`: Map of image key → + * { image, sizePx, items }), and what is left out (`undrawn`: graphic kind → + * count; `drawn`: entities with something drawn). + */ +export function planImportedSource(dataSource, { sizes = new Map() } = {}) { + const fills = new Map(); + const lines = new Map(); + const points = []; + const images = new Map(); + const undrawn = {}; + let drawn = 0; + const group = (map, key, entry) => { + let target = map.get(key); + if (!target) map.set(key, (target = { ...entry, features: [] })); + return target; + }; + for (const entity of dataSource.entities.values) { + if (val(entity.show) === false) continue; + let any = false; + const polygon = entity.polygon; + if (polygon && val(polygon.fill) !== false) { + const hierarchy = val(polygon.hierarchy); + const outer = ringOf(hierarchy?.positions); + if (outer.length >= 4) { + const holes = (hierarchy.holes ?? []) + .map((hole) => ringOf(hole?.positions)) + .filter((ring) => ring.length >= 4); + const color = colorOf(polygon.material, null); + const fill = color ? css(color) : '#ffffff'; + const opacity = color?.alpha ?? 1; + group(fills, `${fill}|${opacity.toFixed(3)}`, { + color: fill, + opacity, + }).features.push({ + type: 'Feature', + properties: {}, + geometry: { type: 'Polygon', coordinates: [outer, ...holes] }, + }); + any = true; + } + } + const polyline = entity.polyline; + if (polyline) { + const path = (val(polyline.positions) ?? []).map(lonLat).filter(Boolean); + if (path.length >= 2) { + const color = colorOf(polyline.material, null); + const stroke = color ? css(color) : '#ffffff'; + const opacity = color?.alpha ?? 1; + const width = Number(val(polyline.width)) || 1; + group(lines, `${stroke}|${opacity.toFixed(3)}|${width}`, { + color: stroke, + opacity, + width, + }).features.push({ + type: 'Feature', + properties: {}, + geometry: { type: 'LineString', coordinates: path }, + }); + any = true; + } + } + const position = val(entity.position); + if (position && entity.billboard && val(entity.billboard.image)) { + const raw = val(entity.billboard.image); + // A URL (a Cesium Resource's too), else a canvas; one sprite set per + // image (Cesium's pin builder hands every marker of a colour the same + // canvas). + const image = + typeof raw === 'string' + ? raw + : typeof raw?.url === 'string' + ? raw.url + : typeof raw?.getContext === 'function' + ? raw + : typeof raw?.src === 'string' + ? raw.src + : null; + if (!image) { + undrawn.billboard = (undrawn.billboard ?? 0) + 1; + continue; + } + const natural = sizes.get(image); + const width = + Number(val(entity.billboard.width)) || + Number(image.width) || + natural?.width || + 32; + const height = + Number(val(entity.billboard.height)) || + Number(image.height) || + natural?.height || + width; + const entry = group(images, image, { + image, + sizePx: Math.max(width, height), + }); + const tint = val(entity.billboard.color); + entry.features.push({ + entity, + position, + scale: Number(val(entity.billboard.scale)) || 1, + color: tint ? rgba(tint) : [1, 1, 1, 1], + }); + any = true; + } else if (position && entity.point) { + const p = entity.point; + const color = val(p.color); + const outline = val(p.outlineColor); + points.push({ + entity, + position, + sizePx: Number(val(p.pixelSize)) || 1, + color: color ? rgba(color) : [1, 1, 1, 1], + outline: outline && Number(val(p.outlineWidth)) ? rgba(outline) : null, + }); + any = true; + } + for (const kind of UNDRAWN_GRAPHICS) { + const graphic = entity[kind]; + if (!graphic || val(graphic.show) === false) continue; + if (kind === 'label' && !val(graphic.text)) continue; + undrawn[kind] = (undrawn[kind] ?? 0) + 1; + } + if (any) drawn++; + } + return { fills, lines, points, images, undrawn, drawn }; +} + +/** + * Draw an imported file's data source (UTILITIES ▸ Import) on Navara: its + * polygons and lines as clamped GeoJSON vector layers (one per paint), its + * points as circle sprites and its billboards as sprites of their own image. + * Labels, models and the other graphics Navara has no drawing for are left + * out and counted in `report`. `setShow` follows the row's visibility; + * `refresh` re-reads the pins' positions after a ground refresh. + */ +export function drawImportedSource( + engine, + { view }, + dataSource, + { sizes } = {}, +) { + const plan = planImportedSource(dataSource, { sizes }); + const layers = []; + const sets = []; + let shown = dataSource.show !== false; + const layerStyle = (kind, entry) => + kind === 'polygon' + ? { + polygon: { + color: Number.parseInt(entry.color.slice(1), 16), + show: shown, + clampToGround: true, + transparent: true, + opacity: entry.opacity, + }, + } + : { + polyline: { + color: Number.parseInt(entry.color.slice(1), 16), + show: shown, + clampToGround: true, + width: entry.width, + transparent: true, + opacity: entry.opacity, + }, + }; + const id = (kind) => `geo-import-${kind}-${++importSets}`; + try { + for (const [kind, groups] of [ + ['polygon', plan.fills], + ['polyline', plan.lines], + ]) + for (const entry of groups.values()) { + const source = view.addSource({ + type: 'geojson', + data: { type: 'FeatureCollection', features: entry.features }, + }); + const record = { kind, entry, source, layer: null }; + layers.push(record); + record.layer = view.addLayer({ + type: 'vector', + source, + ...layerStyle(kind, entry), + }); + } + if (plan.points.length) { + const set = engine.movers.createSet({ + id: id('points'), + kind: 'sprite', + capacity: plan.points.length * 2, + pickable: false, + depthTest: 'always', + sprite: { sizePx: 64, atlas: circleAtlas() }, + }); + const write = () => { + let at = 0; + for (const item of plan.points) { + const scale = item.sizePx / 58; + set.set(at++, { + position: item.position, + cell: 'fill', + color: item.color, + scale, + show: true, + }); + if (item.outline) + set.set(at++, { + position: item.position, + cell: 'rim', + color: item.outline, + scale, + show: true, + }); + } + return at; + }; + sets.push({ set, count: write(), write }); + } + for (const entry of plan.images.values()) { + const { image } = entry; + const set = engine.movers.createSet({ + id: id('pins'), + kind: 'sprite', + capacity: entry.features.length, + pickable: false, + depthTest: 'always', + sprite: { + sizePx: entry.sizePx, + atlas: { + ...(typeof image === 'string' ? { url: image } : { canvas: image }), + cells: { image: [0, 0, 1, 1] }, + }, + }, + }); + const write = () => { + entry.features.forEach((item, index) => + set.set(index, { + position: item.position, + cell: 'image', + color: item.color, + scale: item.scale, + show: true, + }), + ); + return entry.features.length; + }; + sets.push({ set, count: write(), write }); + } + for (const record of sets) { + record.set.setCount(shown ? record.count : 0); + record.set.commit(); + } + } catch (error) { + release(); + throw error; + } + engine.render.request('geo-import'); + function release() { + for (const { layer, source } of layers.splice(0)) { + try { + layer?.delete(); + source.delete(); + } catch { + /* the view went first */ + } + } + for (const { set } of sets.splice(0)) set.destroy(); + } + const undrawn = Object.values(plan.undrawn).reduce((n, c) => n + c, 0); + return { + report: { drawn: plan.drawn, undrawn, kinds: { ...plan.undrawn } }, + setShow(show) { + if (show === shown) return; + shown = show; + for (const { kind, entry, source, layer } of layers) + layer.update({ type: 'vector', source, ...layerStyle(kind, entry) }); + for (const record of sets) { + record.set.setCount(shown ? record.count : 0); + record.set.commit(); + } + engine.render.request('geo-import'); + }, + /** The pins' positions moved onto refreshed ground heights. */ + refresh() { + const items = [ + ...plan.points, + ...[...plan.images.values()].flatMap((entry) => entry.features), + ]; + for (const item of items) + item.position = val(item.entity.position) ?? item.position; + for (const record of sets) { + record.count = record.write(); + record.set.setCount(shown ? record.count : 0); + record.set.commit(); + } + engine.render.request('geo-import'); + }, + destroy() { + release(); + engine.render.request('geo-import'); + }, + }; +} + +/** + * The natural sizes of an imported file's pin images (the file's own, as + * data: or blob: URLs), so each pin draws at the size Cesium gives it. + * Remote URLs are never loaded. Resolves with a Map of URL → { width, height }. + */ +export async function measureImportImages( + dataSource, + { timeoutMs = 2000, createImage = () => new Image() } = {}, +) { + const urls = new Set(); + for (const entity of dataSource.entities.values) { + const image = val(entity.billboard?.image); + const url = typeof image === 'string' ? image : image?.url; + if (typeof url === 'string' && /^(data|blob):/.test(url)) urls.add(url); + } + const sizes = new Map(); + await Promise.all( + [...urls].map(async (url) => { + const image = createImage(); + image.src = url; + let timer; + try { + await Promise.race([ + image.decode(), + new Promise((_, reject) => { + timer = setTimeout(reject, timeoutMs); + }), + ]); + if (image.naturalWidth > 0) + sizes.set(url, { + width: image.naturalWidth, + height: image.naturalHeight, + }); + } catch { + /* drawn at the default size */ + } finally { + clearTimeout(timer); + } + }), + ); + return sizes; +} diff --git a/src/layers/shared/render/navaraStory.test.mjs b/src/layers/shared/render/navaraStory.test.mjs new file mode 100644 index 000000000..9cc7cf83b --- /dev/null +++ b/src/layers/shared/render/navaraStory.test.mjs @@ -0,0 +1,192 @@ +import '../../../testSupport/registerDom.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; +import { + drawImportedSource, + measureImportImages, + planImportedSource, +} from './navaraStory.js'; + +// happy-dom has no 2D canvas; the circle atlas only needs to draw. +globalThis.HTMLCanvasElement.prototype.getContext = () => ({ + beginPath() {}, + arc() {}, + fill() {}, + stroke() {}, +}); + +const degrees = (...pairs) => Cesium.Cartesian3.fromDegreesArray(pairs.flat()); + +/** A file's data source as Cesium's loaders build one. */ +function importedSource() { + const source = new Cesium.CustomDataSource('file'); + const amber = Cesium.Color.fromCssColorString('#ffb547'); + source.entities.add({ + polygon: { + hierarchy: new Cesium.PolygonHierarchy( + degrees([0, 0], [1, 0], [1, 1], [0, 1]), + [ + new Cesium.PolygonHierarchy( + degrees([0.2, 0.2], [0.4, 0.2], [0.4, 0.4]), + ), + ], + ), + material: amber.withAlpha(0.25), + }, + }); + source.entities.add({ + polyline: { positions: degrees([0, 0], [2, 2]), width: 3, material: amber }, + }); + source.entities.add({ + polyline: { positions: degrees([1, 0], [2, 1]), width: 3, material: amber }, + }); + source.entities.add({ + position: Cesium.Cartesian3.fromDegrees(0.5, 0.5), + point: { pixelSize: 10, color: Cesium.Color.RED }, + }); + // Two pins sharing one image, one with a label Navara cannot draw. + for (const lon of [3, 4]) + source.entities.add({ + position: Cesium.Cartesian3.fromDegrees(lon, 0), + billboard: { image: 'data:image/png;base64,AAAA', scale: 0.5 }, + ...(lon === 3 ? { label: { text: 'Pin' } } : {}), + }); + source.entities.add({ + position: Cesium.Cartesian3.fromDegrees(5, 0), + model: { uri: 'model.glb' }, + }); + source.entities.add({ + show: false, + position: Cesium.Cartesian3.fromDegrees(6, 0), + point: { pixelSize: 4 }, + }); + return source; +} + +function fakeView() { + const sources = []; + const layers = []; + return { + sources, + layers, + view: { + addSource(config) { + const source = { + config, + deleted: false, + delete: () => (source.deleted = true), + }; + sources.push(source); + return source; + }, + addLayer(config) { + const layer = { + config, + updates: [], + deleted: false, + update: (next) => layer.updates.push(next), + delete: () => (layer.deleted = true), + }; + layers.push(layer); + return layer; + }, + }, + }; +} + +test('an imported file plans fills, lines, points and pins, and counts what Navara leaves out', () => { + const plan = planImportedSource(importedSource()); + assert.equal(plan.fills.size, 1); + const [fill] = plan.fills.values(); + assert.equal(fill.color, '#ffb547'); + assert.ok(Math.abs(fill.opacity - 0.25) < 1e-6); + const [polygon] = fill.features; + assert.equal(polygon.geometry.coordinates.length, 2, 'outer ring and hole'); + assert.deepEqual( + polygon.geometry.coordinates[0][0].map((v) => Math.round(v)), + polygon.geometry.coordinates[0].at(-1).map((v) => Math.round(v)), + 'rings close', + ); + assert.equal(plan.lines.size, 1, 'lines of one paint share a group'); + assert.equal([...plan.lines.values()][0].features.length, 2); + assert.equal(plan.points.length, 1); + assert.equal(plan.images.size, 1, 'pins sharing an image share a set'); + assert.equal([...plan.images.values()][0].features.length, 2); + assert.deepEqual(plan.undrawn, { label: 1, model: 1 }); + assert.equal(plan.drawn, 6, 'the hidden point is not drawn'); +}); + +test('the Navara drawing builds vector layers and sprite sets, follows the row and lets go', () => { + const engine = createFakeEngine({ kind: 'navara' }); + const { view, sources, layers } = fakeView(); + const drawing = drawImportedSource(engine, { view }, importedSource()); + assert.deepEqual(drawing.report, { + drawn: 6, + undrawn: 2, + kinds: { label: 1, model: 1 }, + }); + assert.equal(sources.length, 2); + assert.deepEqual( + layers.map((layer) => + Object.keys(layer.config).filter((k) => k !== 'type' && k !== 'source'), + ), + [['polygon'], ['polyline']], + ); + assert.equal(layers[0].config.polygon.clampToGround, true); + assert.equal(layers[0].config.polygon.color, 0xffb547); + assert.equal(layers[1].config.polyline.width, 3); + const sets = engine.movers.list(); + assert.deepEqual( + sets.map(({ count }) => count).sort(), + [1, 2], + 'one point (no outline) and two pins', + ); + drawing.setShow(false); + assert.equal(layers[0].updates.at(-1).polygon.show, false); + assert.ok(engine.movers.list().every(({ count }) => count === 0)); + drawing.setShow(true); + assert.deepEqual( + engine.movers + .list() + .map(({ count }) => count) + .sort(), + [1, 2], + ); + drawing.refresh(); + drawing.destroy(); + assert.ok(sources.every((s) => s.deleted) && layers.every((l) => l.deleted)); + assert.equal(engine.movers.list().length, 0); +}); + +test('pin images are measured from the file itself, never fetched from elsewhere', async () => { + const source = new Cesium.CustomDataSource('file'); + for (const image of [ + 'data:image/png;base64,AAAA', + 'blob:http://127.0.0.1/abc', + 'https://example.invalid/icon.png', + ]) + source.entities.add({ + position: Cesium.Cartesian3.fromDegrees(0, 0), + billboard: { image }, + }); + const loaded = []; + const sizes = await measureImportImages(source, { + createImage: () => { + const image = { + naturalWidth: 12, + naturalHeight: 20, + decode: async () => loaded.push(image.src), + }; + return image; + }, + }); + assert.deepEqual(loaded.sort(), [ + 'blob:http://127.0.0.1/abc', + 'data:image/png;base64,AAAA', + ]); + const plan = planImportedSource(source, { sizes }); + assert.equal(plan.images.get('data:image/png;base64,AAAA').sizePx, 20); + assert.equal(plan.images.get('https://example.invalid/icon.png').sizePx, 32); +}); diff --git a/src/ui/flyToDataSource.js b/src/ui/flyToDataSource.js index bf4b9336a..1a18c4c0d 100644 --- a/src/ui/flyToDataSource.js +++ b/src/ui/flyToDataSource.js @@ -3,6 +3,7 @@ import * as Cesium from 'cesium'; import { guardCameraAboveGround } from '../cameraGroundGuard.js'; import { rangeForBoundingSphere } from '../data/cameraFraming.js'; import { cancelCameraArrival } from '../data/cameraArrival.js'; +import { isGlobeEngine } from '../globe/contract.js'; /** All visible positions, path vertices, polygon rings, walls and overlay bounds. */ export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) { @@ -69,7 +70,11 @@ export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) { return points; } -/** Fly synchronously to the footprint; later navigation always owns its camera. */ +/** + * Fly synchronously to the footprint; later navigation always owns its + * camera. `viewer` is a Cesium viewer or a globe engine (Navara), which flies + * the same sphere through the contract's flyToSphere. + */ export function flyToDataSource( viewer, dataSource, @@ -87,7 +92,12 @@ export function flyToDataSource( Cesium.Cartographic.fromDegrees(points[0][0], points[0][1]); const lat = Cesium.Math.toDegrees(center.latitude), lon = Cesium.Math.toDegrees(center.longitude); - const terrain = viewer.scene?.globe?.getHeight?.(center); + const engine = isGlobeEngine(viewer) ? viewer : null; + const terrain = engine + ? engine.surface.terrainHeightSupported + ? engine.surface.terrainHeight(center) + : undefined + : viewer.scene?.globe?.getHeight?.(center); const cached = ground?.cachedGroundFloor?.(lat, lon); const height = Number.isFinite(terrain) && terrain > 0 @@ -109,6 +119,30 @@ export function flyToDataSource( sphere.radius, Cesium.Cartesian3.distance(sphere.center, position), ); + if (engine) { + const pose = engine.camera.pose(); + const aspect = pose.aspectRatio ?? 1; + // Cesium's frustum fov: the wider side's angle. + const fov = + aspect > 1 ? 2 * Math.atan(Math.tan(pose.fovy / 2) * aspect) : pose.fovy; + engine.camera.flyToSphere( + { center: sphere.center, radius: sphere.radius }, + { + heading: pose.heading || 0, + pitch: -Math.PI / 2, + range: Math.max( + 800, + rangeForBoundingSphere( + { camera: { frustum: { fov, aspectRatio: aspect } } }, + sphere.radius, + ), + ), + duration, + complete: () => guardCameraAboveGround(engine, { lat, lon }), + }, + ); + return true; + } viewer.camera?.flyToBoundingSphere?.(sphere, { offset: new Cesium.HeadingPitchRange( viewer.camera.heading || 0, diff --git a/src/ui/flyToDataSource.test.mjs b/src/ui/flyToDataSource.test.mjs index 62f6073a6..6129b9fb9 100644 --- a/src/ui/flyToDataSource.test.mjs +++ b/src/ui/flyToDataSource.test.mjs @@ -161,3 +161,20 @@ test('non-finite positions and hidden features never enter the camera footprint' assert.equal(points.length, 1); assert.ok(Math.abs(points[0][0] - 20) < 1e-6); }); + +test('on a globe engine the footprint is flown to through flyToSphere, straight down at the surface', async () => { + const { createFakeEngine } = await import('../globe/testing/fakeEngine.js'); + const engine = createFakeEngine({ kind: 'navara' }); + engine.fake.script('terrainHeight', 120); + assert.equal(flyToDataSource(engine, source(), { duration: 0.5 }), true); + const [call] = engine.fake.callsOf('camera.flyToSphere'); + assert.ok(call, 'one sphere flight'); + const [sphere, options] = call.args; + const center = Cesium.Cartographic.fromCartesian(sphere.center); + assert.ok(Math.abs(center.height - 120) < 1, 'centre on the terrain'); + assert.ok(sphere.radius > 100); + assert.equal(options.pitch, -Math.PI / 2); + assert.equal(options.duration, 0.5); + assert.ok(options.range >= 800); + assert.equal(typeof options.complete, 'function'); +}); diff --git a/src/ui/geoFileImport.js b/src/ui/geoFileImport.js index 81c0e9a88..bf30bdaaa 100644 --- a/src/ui/geoFileImport.js +++ b/src/ui/geoFileImport.js @@ -42,11 +42,14 @@ export function initGeoFileImport({ viewer, ...options }) { * and returns their disconnect. * * The imported files belong to the session; their drawing belongs to the - * attached Cesium viewer. `attachScene({ viewer })` draws them (a file kept - * while no viewer was attached is read again from its bytes) and listens for - * drops and camera gestures there; `detachScene()` takes them off the viewer - * and keeps the list. Without a viewer (an engine without Cesium) the list - * stays, and import and fly-to wait for one. + * attached globe. `attachScene({ viewer })` draws them on a Cesium viewer; + * `attachScene({ engine, draw })` on a globe engine without one (Navara), + * where `draw(dataSource)` resolves with that engine's drawing of a file + * ({ report, setShow, refresh, destroy }: src/layers/shared/render/navaraStory.js). + * A file kept while no globe was attached is read again from its bytes; the + * control listens for drops and camera gestures on the attached globe. + * `detachScene()` takes the files off the globe and keeps the list. Without a + * globe the list stays, and import and fly-to wait for one. * @returns {{destroy: Function, importFiles: Function, list: Function, * setVisible: Function, flyTo: Function, remove: Function, * diagnostics: Function, attachScene: Function, detachScene: Function}|null} @@ -75,8 +78,8 @@ export function createGeoFileImport({ let cameraIntent = 0; let hint = ''; const lifetime = new AbortController(); - // The attached viewer and the lifetime of that attachment. - let viewer = null; + // The attached globe ({ viewer, engine, draw, controller }): a Cesium + // viewer, or an engine and its drawing; null while none is attached. let scene = null; const listen = (target, type, listener, options) => { @@ -88,13 +91,16 @@ export function createGeoFileImport({ hint = text || ''; present(); }; - const render = () => viewer?.scene?.requestRender?.(); + const render = () => { + if (scene?.viewer) scene.viewer.scene?.requestRender?.(); + else scene?.engine?.render.request('geo-import'); + }; let groundTimer, groundQueued = false, groundAgain = false; let groundRemovers = []; const scheduleGround = () => { - if (destroyed || !viewer || !imports.length) return; + if (destroyed || !scene || !imports.length) return; if (groundQueued) { groundAgain = true; return; @@ -109,8 +115,10 @@ export function createGeoFileImport({ try { for (const item of imports) { if (destroyed || scene !== attached) return; - if (item.visible) + if (item.visible) { await item.refreshGround?.(attached.controller.signal); + item.drawing?.refresh?.(); + } } render(); } catch { @@ -182,6 +190,7 @@ export function createGeoFileImport({ if (!item) return false; item.visible = Boolean(visible); if (item.dataSource) item.dataSource.show = item.visible; + item.drawing?.setShow(item.visible); if (item.visible) scheduleGround(); present(); render(); @@ -189,9 +198,9 @@ export function createGeoFileImport({ } const fly = (item) => { - if (!viewer || !item.dataSource) return false; + if (!scene || !item.dataSource) return false; if (!item.visible) setVisible(item.id, true); - flyToSource(viewer, item.dataSource, { ground }); + flyToSource(scene.viewer ?? scene.engine, item.dataSource, { ground }); return true; }; @@ -201,7 +210,7 @@ export function createGeoFileImport({ */ function flyTo(id) { const item = find(id); - if (!item || !viewer || !item.dataSource) return false; + if (!item || !scene || !item.dataSource) return false; if (typeof navigation?.runImmediateNavigation === 'function') return ( navigation.runImmediateNavigation('file', () => fly(item)) !== false @@ -223,9 +232,12 @@ export function createGeoFileImport({ : true; }; - /** Take one file's drawing off the viewer; the file stays in the list. */ - function undraw(item, from = viewer) { - if (item.dataSource) from?.dataSources?.remove?.(item.dataSource, true); + /** Take one file's drawing off the globe; the file stays in the list. */ + function undraw(item, from = scene) { + if (item.dataSource) + from?.viewer?.dataSources?.remove?.(item.dataSource, true); + item.drawing?.destroy(); + item.drawing = null; item.revoke?.(); item.dataSource = null; item.revoke = null; @@ -243,17 +255,27 @@ export function createGeoFileImport({ return true; } - /** Read a file for the attached viewer. Null once that viewer went. */ + /** Draw a file read for the attached globe. False once that globe went. */ async function draw(item, attached, loaded) { item.dataSource = loaded.dataSource; item.revoke = loaded.revoke; item.refreshGround = loaded.refreshGround; item.dataSource.show = item.visible; - await attached.viewer.dataSources.add(item.dataSource); + if (attached.viewer) await attached.viewer.dataSources.add(item.dataSource); + else { + try { + item.drawing = await attached.draw(item.dataSource); + } catch (error) { + undraw(item, attached); + throw error; + } + } if (destroyed || scene !== attached) { - undraw(item, attached.viewer); + undraw(item, attached); return false; } + // What this globe leaves out of the file (Navara: labels, models...). + item.undrawn = item.drawing?.report?.undrawn ?? 0; loaded.mount?.(); return true; } @@ -267,8 +289,8 @@ export function createGeoFileImport({ function importFiles(files) { const list = [...(files || [])]; if (destroyed || !list.length) return Promise.resolve([]); - if (!viewer) { - // Import draws on the Cesium globe; this engine has none. + if (!scene) { + // Import draws on the attached globe; there is none. const error = t('Action unavailable or cancelled'); setHint(error); return Promise.resolve( @@ -311,6 +333,7 @@ export function createGeoFileImport({ try { loaded = await load(file, { viewer: attached.viewer, + engine: attached.engine, color, signal: attached.controller.signal, }); @@ -343,13 +366,26 @@ export function createGeoFileImport({ removed: loaded.removed || 0, color, visible: true, - // The bytes, so a later viewer can read the file again. + // The bytes, so a later globe can read the file again. file, dataSource: null, + drawing: null, + undrawn: 0, revoke: null, refreshGround: null, }; - if (!(await draw(item, attached, loaded))) break; + try { + if (!(await draw(item, attached, loaded))) break; + } catch (error) { + // A Cesium viewer's own failure stops the batch, as it always did. + if (attached.viewer) throw error; + outcomes.push({ + name: fileName, + ok: false, + error: t('That file could not be read.'), + }); + continue; + } imports.push(item); present(); render(); @@ -358,6 +394,7 @@ export function createGeoFileImport({ ok: true, id: item.id, removed: item.removed, + undrawn: item.undrawn, }); if ( outcomes.filter((o) => o.ok).length === 1 && @@ -370,7 +407,7 @@ export function createGeoFileImport({ return outcomes; } - // Draw the files kept while no viewer was attached, as they were. + // Draw the files kept while no globe was attached, as they were. async function redraw(attached) { for (const item of [...imports]) { if (destroyed || scene !== attached) return; @@ -378,6 +415,7 @@ export function createGeoFileImport({ try { const loaded = await load(item.file, { viewer: attached.viewer, + engine: attached.engine, color: item.color, signal: attached.controller.signal, }); @@ -392,6 +430,9 @@ export function createGeoFileImport({ } } if (!destroyed && scene === attached) { + // Say what this globe leaves out of the files it drew again. + const left = imports.filter((item) => item.undrawn); + if (left.length) setHint(undrawnHint(left)); present(); render(); } @@ -423,35 +464,42 @@ export function createGeoFileImport({ target.removeEventListener(type, listener, options); dragDepth = 0; setDragging(false); - for (const item of imports) undraw(item, previous.viewer); - viewer = null; + for (const item of imports) undraw(item, previous); } function attachScene({ - viewer: next = null, + viewer = null, + engine = null, + draw: drawOnEngine = null, navigation: nextNavigation = navigation, subscribeMapSource: nextMapSource = subscribeMapSource, } = {}) { if (destroyed) return; detachScene(); - if (!next) return; - viewer = next; + if (!viewer && !(engine && drawOnEngine)) return; // The shell and the map sources of this attachment. navigation = nextNavigation; const offMapSource = nextMapSource?.(scheduleGround); if (typeof offMapSource === 'function') groundRemovers.push(offMapSource); - const attached = (scene = { viewer, controller: new AbortController() }); - ownEvent(viewer.camera?.moveEnd, scheduleGround); - ownEvent(viewer.scene?.globe?.tileLoadProgressEvent, (pending) => { + const attached = (scene = { + viewer, + engine: viewer ? null : engine, + draw: viewer ? null : drawOnEngine, + controller: new AbortController(), + }); + ownEvent( + viewer ? viewer.camera?.moveEnd : engine.camera.moveEnd, + scheduleGround, + ); + ownEvent(viewer?.scene?.globe?.tileLoadProgressEvent, (pending) => { if (!pending) scheduleGround(); }); - const container = viewer.container; - listen(viewer.scene?.canvas || container, 'pointerdown', gesture, { - passive: true, - }); - listen(viewer.scene?.canvas || container, 'wheel', gesture, { - passive: true, - }); + const container = viewer ? viewer.container : engine.host.container; + const canvas = viewer + ? viewer.scene?.canvas || container + : engine.host.canvas; + listen(canvas, 'pointerdown', gesture, { passive: true }); + listen(canvas, 'wheel', gesture, { passive: true }); listen(container, 'dragenter', (event) => { if (!hasFiles(event)) return; event.preventDefault(); @@ -487,14 +535,17 @@ export function createGeoFileImport({ const handle = { importFiles, list: () => - imports.map(({ id, name, format, entityCount, removed, visible }) => ({ - id, - name, - format, - entityCount, - removed, - visible, - })), + imports.map( + ({ id, name, format, entityCount, removed, visible, undrawn }) => ({ + id, + name, + format, + entityCount, + removed, + visible, + undrawn, + }), + ), setVisible, flyTo, remove, @@ -505,12 +556,12 @@ export function createGeoFileImport({ drawn: imports.filter((item) => item.dataSource).length, entities: imports.reduce((n, item) => n + item.entityCount, 0), listeners: listeners.length, - attached: Boolean(viewer), + attached: Boolean(scene), destroyed, }), destroy() { if (destroyed) return; - const last = viewer; + const last = scene; detachScene(); destroyed = true; disconnect(); @@ -558,5 +609,18 @@ export function summarize(outcomes) { 0, ); if (added && stripped) parts.push(t('Some KML content was left out.')); + const left = outcomes.filter((o) => o.ok && o.undrawn); + if (left.length) parts.push(undrawnHint(left)); return parts.join(' '); } + +/** + * What the attached globe leaves out of the given files (each `{ name, + * undrawn }`): the first file's name and the total count. + */ +export function undrawnHint(items) { + return t('Map objects this engine leaves out of {name}: {count}.', { + name: items[0].name, + count: formatNumber(items.reduce((n, item) => n + item.undrawn, 0)), + }); +} diff --git a/src/ui/geoFileImport.test.mjs b/src/ui/geoFileImport.test.mjs index 24efac19a..0300e62e5 100644 --- a/src/ui/geoFileImport.test.mjs +++ b/src/ui/geoFileImport.test.mjs @@ -690,3 +690,107 @@ test('an engine switch keeps the files and draws them again on the next viewer', assert.equal(t.tool.diagnostics().drawn, 2); t.tool.destroy(); }); + +test('on Navara the files draw through the engine drawing, report what it leaves out and switch back to Cesium', async () => { + const t = setup(); + await t.tool.importFiles([file('a.geojson')]); + t.tool.detachScene(); + const canvas = new FakeElement('canvas'); + const container = new FakeElement('div', 'cesiumContainer'); + const moveEnd = new Set(); + const requests = []; + const engine = { + host: { canvas, container }, + camera: { + moveEnd: { + addEventListener: (fn) => (moveEnd.add(fn), () => moveEnd.delete(fn)), + }, + }, + render: { request: (reason) => requests.push(reason) }, + }; + const drawings = []; + const draw = async (dataSource) => { + const drawing = { + dataSource, + shown: [], + destroyed: false, + report: { drawn: 2, undrawn: 3, kinds: { label: 3 } }, + setShow: (show) => drawing.shown.push(show), + refresh() {}, + destroy() { + drawing.destroyed = true; + }, + }; + drawings.push(drawing); + return drawing; + }; + t.loads.length = 0; + t.tool.attachScene({ engine, draw }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + // The kept file is read again for the engine and drawn by it. + assert.deepEqual( + t.loads.map(({ opts }) => [opts.viewer, opts.engine === engine]), + [[null, true]], + ); + assert.equal(drawings.length, 1); + assert.match(t.shown().hint, /leaves out of a\.geojson: 3/); + assert.equal(t.tool.list()[0].undrawn, 3); + assert.ok(canvas.listeners.length > 0, 'gestures on the engine canvas'); + assert.ok(container.listeners.length > 0, 'drops on its container'); + assert.equal(moveEnd.size, 1); + // A new file: drawn, counted, reported in the batch hint. + const outcomes = await t.tool.importFiles([file('b.geojson')]); + assert.deepEqual( + outcomes.map(({ ok, undrawn }) => [ok, undrawn]), + [[true, 3]], + ); + assert.match(t.shown().hint, /Added 1 file\. .*leaves out of b\.geojson: 3/); + const [a] = rows(t); + press(t, a, 'toggle'); + assert.deepEqual(drawings[0].shown, [false]); + // Fly-to goes to the engine. + assert.equal(t.tool.flyTo(a.id), true); + assert.equal(t.flights.length > 0, true); + // A switch to Cesium takes the drawing off the engine. + t.tool.detachScene(); + assert.ok(drawings.every((drawing) => drawing.destroyed)); + assert.equal(canvas.listeners.length, 0); + assert.equal(moveEnd.size, 0); + t.tool.attachScene({ viewer: t.viewer }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(t.tool.diagnostics().drawn, 2); + t.tool.destroy(); +}); + +test('an engine drawing that fails reports the file instead of stopping the batch', async () => { + const t = setup(); + t.tool.detachScene(); + const engine = { + host: { canvas: new FakeElement('canvas'), container: new FakeElement() }, + camera: { moveEnd: { addEventListener: () => () => {} } }, + render: { request() {} }, + }; + let calls = 0; + t.tool.attachScene({ + engine, + draw: async () => { + if (calls++ === 0) throw new Error('no GPU'); + return { report: { undrawn: 0 }, setShow() {}, destroy() {} }; + }, + }); + const outcomes = await t.tool.importFiles([ + file('bad.geojson'), + file('good.geojson'), + ]); + assert.deepEqual( + outcomes.map(({ name, ok }) => [name, ok]), + [ + ['bad.geojson', false], + ['good.geojson', true], + ], + ); + assert.deepEqual(t.revoked, ['bad.geojson'], 'the failed read is released'); + t.tool.destroy(); +});