Import GeoJSON, KML, KMZ and GPX files on Navara: shapes as clamped vector layers, pins as sprites, and the graphics it cannot draw counted and reported

This commit is contained in:
German Khokhlov committed 2026-11-03 21:19:55 +04:00
1 parent eee4f48745
commit d8c63c69d8
22 files changed
+1368 -68

No files matched your search

+339
View File
@@ -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:<PORT>/
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: `<img src="https://${TRAP}/props.png">`,
},
geometry: { type: 'Polygon', coordinates: [ring] },
},
{
type: 'Feature',
properties: { name: 'QA pin' },
geometry: { type: 'Point', coordinates: [-122.4862, 37.7694] },
},
],
});
const kml = (iconHref, overlay) => `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<Style id="s"><IconStyle><Icon><href>${iconHref}</href></Icon></IconStyle></Style>
<NetworkLink><Link><href>https://${TRAP}/link.kml</href></Link></NetworkLink>
<Placemark><name>QA KML pin</name><styleUrl>#s</styleUrl>
<description><![CDATA[<img src="https://${TRAP}/balloon.png">]]></description>
<Point><coordinates>-122.47,37.77,0</coordinates></Point></Placemark>
<Placemark><name>QA KML line</name>
<LineString><coordinates>-122.50,37.766 -122.46,37.772</coordinates></LineString></Placemark>
<GroundOverlay><Icon><href>${overlay}</href></Icon>
<LatLonBox><north>37.77</north><south>37.76</south><east>-122.46</east><west>-122.47</west></LatLonBox></GroundOverlay>
</Document>
</kml>`;
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="qa" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="37.769" lon="-122.49"><name>QA waypoint</name></wpt>
<trk><name>QA track</name><trkseg>
<trkpt lat="37.766" lon="-122.505"></trkpt><trkpt lat="37.768" lon="-122.49"></trkpt>
</trkseg></trk>
</gpx>`;
// 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');
},
);
+22 -3
View File
@@ -28,6 +28,19 @@ import { createRecentImageryPanel } from '../ui/recentImagery.js';
// Native mesh capability stays at the composition boundary. // Native mesh capability stays at the composition boundary.
const navaraViews = new WeakMap(); 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. */ /** The Navara globe module, loaded on demand. */
const loadNavaraGlobeModule = async () => const loadNavaraGlobeModule = async () =>
(await import('../globe/navara/index.js')).createNavaraGlobe; (await import('../globe/navara/index.js')).createNavaraGlobe;
@@ -400,7 +413,7 @@ export async function createNavaraScene({
* voice. * voice.
*/ */
export function attachViewerlessTools( export function attachViewerlessTools(
{ engine }, { engine, mapStackController = null },
{ {
data, data,
operations, operations,
@@ -468,8 +481,14 @@ export function attachViewerlessTools(
defer(() => styleManager._clearPersistentStatusNotice?.()); defer(() => styleManager._clearPersistentStatusNotice?.());
// The page session's annotation board, Draw and Import on this engine // The page session's annotation board, Draw and Import on this engine
// (src/app/navaraTools.js). // (src/app/navaraTools.js).
const tools = { engine, views: navaraViews.get(engine) }; const tools = { engine, views: navaraViews.get(engine), mapStackController };
const context = { operations, placeSearch, layerSession, styleManager }; const context = {
operations,
placeSearch,
layerSession,
styleManager,
drawImported,
};
const annotations = attachNavaraAnnotations(tools, context, { const annotations = attachNavaraAnnotations(tools, context, {
signal, signal,
defer, defer,
+28 -4
View File
@@ -3,7 +3,7 @@
// with src/app/navaraMode.js, so the Cesium-mode bundle carries none of it. // with src/app/navaraMode.js, so the Cesium-mode bundle carries none of it.
import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js'; 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 * The annotation board drawn on this Navara scene: draped areas and routes
@@ -38,7 +38,31 @@ export function attachNavaraDraw() {
return null; return null;
} }
/** UTILITIES ▸ Import on this Navara scene; null while it has none. */ /**
export function attachNavaraGeoImport() { * UTILITIES ▸ Import on this Navara scene: the session's files drawn by
return null; * `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;
} }
+43 -10
View File
@@ -1197,14 +1197,16 @@ function checkLocalImages(doc, entries = new Map(), main = '') {
// Cesium also processes KML in deferred tasks; an exception there does not // 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. // 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; let removeError;
try { try {
return await boundedImport( return await boundedImport(
Promise.race([ Promise.race([
Cesium.KmlDataSource.load(doc, { Cesium.KmlDataSource.load(doc, {
camera: viewer.scene.camera, camera: viewer?.scene.camera,
canvas: viewer.scene.canvas, canvas: viewer?.scene.canvas ?? engine?.host.canvas,
clampToGround: true, clampToGround: true,
}), }),
new Promise((_, reject) => { 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. // Resolve a static file's ground pins in batches before they are visualized.
// Per-billboard CLAMP_TO_GROUND subscriptions scale poorly on large imports. // Per-billboard CLAMP_TO_GROUND subscriptions scale poorly on large imports.
const groundPins = new WeakMap(); const groundPins = new WeakMap();
async function settleGroundPins(dataSource, viewer, signal) { async function settleGroundPins(dataSource, { viewer, engine }, signal) {
let pins = groundPins.get(dataSource); let pins = groundPins.get(dataSource);
if (!pins) { if (!pins) {
pins = []; pins = [];
@@ -1264,6 +1266,31 @@ async function settleGroundPins(dataSource, viewer, signal) {
({ cartographic: c }) => ({ cartographic: c }) =>
new Cesium.Cartographic(c.longitude, c.latitude, 0), 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 = const mesh =
viewer.scene?.globe?.show === false && viewer.scene?.globe?.show === false &&
viewer.scene?.clampToHeightSupported; viewer.scene?.clampToHeightSupported;
@@ -1359,14 +1386,18 @@ async function boundedImport(promise, signal) {
* Load a ground footprint, retaining explicit KML altitude modes. Call mount after * Load a ground footprint, retaining explicit KML altitude modes. Call mount after
* adding the source; revoke owns pin and blob cleanup. * adding the source; revoke owns pin and blob cleanup.
* @param {File|Blob & {name?: string}} file * @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, * @returns {Promise<{dataSource: Cesium.DataSource, name: string,
* format: string, entityCount: number, removed: number, mount: Function, revoke: Function}>} * format: string, entityCount: number, removed: number, mount: Function, revoke: Function}>}
*/ */
export async function loadGeoFile( export async function loadGeoFile(
file, file,
{ viewer, color = '#39d0ff', signal } = {}, { viewer = null, engine = null, color = '#39d0ff', signal } = {},
) { ) {
const globe = { viewer, engine };
signal = signal signal = signal
? AbortSignal.any([signal, AbortSignal.timeout(60_000)]) ? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
: AbortSignal.timeout(60_000); : AbortSignal.timeout(60_000);
@@ -1449,7 +1480,7 @@ export async function loadGeoFile(
deduplicateKmlStyles(doc); deduplicateKmlStyles(doc);
const normalizedSize = countKmlGeometry(doc); const normalizedSize = countKmlGeometry(doc);
if (normalizedSize.over) throw overLimitError(normalizedSize); if (normalizedSize.over) throw overLimitError(normalizedSize);
dataSource = await loadKml(doc, viewer, signal); dataSource = await loadKml(doc, globe, signal);
} }
} catch (error) { } catch (error) {
revoke(); revoke();
@@ -1488,7 +1519,7 @@ export async function loadGeoFile(
} }
scrubImportedEntities(dataSource); scrubImportedEntities(dataSource);
try { try {
await settleGroundPins(dataSource, viewer, signal); await settleGroundPins(dataSource, globe, signal);
} catch (error) { } catch (error) {
revoke(); revoke();
throw error instanceof GeoImportError throw error instanceof GeoImportError
@@ -1501,7 +1532,9 @@ export async function loadGeoFile(
// already been stripped and all supported geometries are static. // already been stripped and all supported geometries are static.
detachImportedTime(dataSource); detachImportedTime(dataSource);
try { try {
pins = createImportedPins(dataSource, viewer); pins = viewer
? createImportedPins(dataSource, viewer)
: { mount() {}, sync() {}, destroy() {} };
} catch { } catch {
revoke(); revoke();
throw new GeoImportError(msg('That file has invalid point graphics.')); throw new GeoImportError(msg('That file has invalid point graphics.'));
@@ -1519,7 +1552,7 @@ export async function loadGeoFile(
refreshGround: (refreshSignal) => refreshGround: (refreshSignal) =>
settleGroundPins( settleGroundPins(
dataSource, dataSource,
viewer, globe,
refreshSignal refreshSignal
? AbortSignal.any([refreshSignal, AbortSignal.timeout(15_000)]) ? AbortSignal.any([refreshSignal, AbortSignal.timeout(15_000)])
: AbortSignal.timeout(15_000), : AbortSignal.timeout(15_000),
+62
View File
@@ -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">/, /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;
}
});
+1
View File
@@ -2230,6 +2230,7 @@
"other": "تعذّرت إضافة {count} ملف ({name}: {error})" "other": "تعذّرت إضافة {count} ملف ({name}: {error})"
}, },
"Some KML content was left out.": "أُغفل جزء من محتوى KML.", "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": "اضغط على الأرض لا على السماء", "Press on the ground, not the sky": "اضغط على الأرض لا على السماء",
"Drag to size the box": "اسحب لتحديد حجم المنطقة", "Drag to size the box": "اسحب لتحديد حجم المنطقة",
"light {source} to use your {name} key": "فعّل {source} لاستخدام مفتاح {name} الخاص بك", "light {source} to use your {name} key": "فعّل {source} لاستخدام مفتاح {name} الخاص بك",
+1
View File
@@ -2118,6 +2118,7 @@
"other": "{count} Dateien konnten nicht hinzugefügt werden ({name}: {error})" "other": "{count} Dateien konnten nicht hinzugefügt werden ({name}: {error})"
}, },
"Some KML content was left out.": "Einige KML-Inhalte wurden ausgelassen.", "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", "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", "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", "light {source} to use your {name} key": "{source} einschalten, um den eigenen {name}-Schlüssel zu nutzen",
+1
View File
@@ -2196,6 +2196,7 @@
"other": "No se pudieron añadir {count} archivos ({name}: {error})" "other": "No se pudieron añadir {count} archivos ({name}: {error})"
}, },
"Some KML content was left out.": "Se omitió parte del contenido KML.", "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", "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.", "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", "Import": "Importar",
+1
View File
@@ -1790,6 +1790,7 @@
"other": "{count} fichiers n'ont pas pu être ajoutés ({name} : {error})" "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.", "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", "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", "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}", "light {source} to use your {name} key": "activez {source} pour utiliser votre clé {name}",
+1
View File
@@ -1790,6 +1790,7 @@
"other": "Impossibile aggiungere {count} file ({name}: {error})" "other": "Impossibile aggiungere {count} file ({name}: {error})"
}, },
"Some KML content was left out.": "Una parte del contenuto KML è stata esclusa.", "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", "Press on the ground, not the sky": "Premi sul terreno, non sul cielo",
"Drag to size the box": "Trascina per dimensionare il riquadro", "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}", "light {source} to use your {name} key": "attiva {source} per usare la tua chiave {name}",
+1
View File
@@ -1728,6 +1728,7 @@
"other": "{count}個のファイルを追加できませんでした({name}:{error})" "other": "{count}個のファイルを追加できませんでした({name}:{error})"
}, },
"Some KML content was left out.": "KMLの一部の内容は読み込まれませんでした。", "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": "空ではなく地表を押してください", "Press on the ground, not the sky": "空ではなく地表を押してください",
"Drag to size the box": "ドラッグして範囲の大きさを決めてください", "Drag to size the box": "ドラッグして範囲の大きさを決めてください",
"light {source} to use your {name} key": "{source}をオンにして{name}キーを使う", "light {source} to use your {name} key": "{source}をオンにして{name}キーを使う",
+1
View File
@@ -1790,6 +1790,7 @@
"other": "Não foi possível adicionar {count} arquivos ({name}: {error})" "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.", "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", "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", "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}", "light {source} to use your {name} key": "ative {source} para usar sua chave do {name}",
+1
View File
@@ -2233,6 +2233,7 @@
"other": "Не удалось добавить {count} файла ({name}: {error})" "other": "Не удалось добавить {count} файла ({name}: {error})"
}, },
"Some KML content was left out.": "Часть содержимого KML пропущена.", "Some KML content was left out.": "Часть содержимого KML пропущена.",
"Map objects this engine leaves out of {name}: {count}.": "Объектов из {name}, которые этот движок не показывает: {count}.",
"GNSS Integrity": "Целостность GNSS", "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 (или перетащите их на глобус). Содержимое файлов остаётся на этом устройстве; наложения могут попасть в снимки карты, которые передаются голосовому ассистенту.", "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": "Импорт", "Import": "Импорт",
+1
View File
@@ -1820,6 +1820,7 @@
"other": "Не вдалося додати {count} файлу ({name}: {error})" "other": "Не вдалося додати {count} файлу ({name}: {error})"
}, },
"Some KML content was left out.": "Частину вмісту KML пропущено.", "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": "Натисніть на землю, а не на небо", "Press on the ground, not the sky": "Натисніть на землю, а не на небо",
"Drag to size the box": "Потягніть, щоб задати розмір області", "Drag to size the box": "Потягніть, щоб задати розмір області",
"light {source} to use your {name} key": "увімкніть {source}, щоб використати свій ключ {name}", "light {source} to use your {name} key": "увімкніть {source}, щоб використати свій ключ {name}",
+1
View File
@@ -1705,6 +1705,7 @@
"other": "{count} 个文件无法添加({name}:{error})" "other": "{count} 个文件无法添加({name}:{error})"
}, },
"Some KML content was left out.": "部分 KML 内容未被导入。", "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": "请按在地面,而不是天空", "Press on the ground, not the sky": "请按在地面,而不是天空",
"Drag to size the box": "拖动以调整框选大小", "Drag to size the box": "拖动以调整框选大小",
"light {source} to use your {name} key": "开启 {source} 以使用你的 {name} 密钥", "light {source} to use your {name} key": "开启 {source} 以使用你的 {name} 密钥",
+1
View File
@@ -1695,6 +1695,7 @@
"other": "{count} 個文件無法添加({name}:{error})" "other": "{count} 個文件無法添加({name}:{error})"
}, },
"Some KML content was left out.": "部分 KML 內容未被導入。", "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": "請按在地面,而不是天空", "Press on the ground, not the sky": "請按在地面,而不是天空",
"Drag to size the box": "拖動以調整框選大小", "Drag to size the box": "拖動以調整框選大小",
"light {source} to use your {name} key": "開啟 {source} 以使用你的 {name} 密鑰", "light {source} to use your {name} key": "開啟 {source} 以使用你的 {name} 密鑰",
+401
View File
@@ -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;
}
@@ -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);
});
+36 -2
View File
@@ -3,6 +3,7 @@ import * as Cesium from 'cesium';
import { guardCameraAboveGround } from '../cameraGroundGuard.js'; import { guardCameraAboveGround } from '../cameraGroundGuard.js';
import { rangeForBoundingSphere } from '../data/cameraFraming.js'; import { rangeForBoundingSphere } from '../data/cameraFraming.js';
import { cancelCameraArrival } from '../data/cameraArrival.js'; import { cancelCameraArrival } from '../data/cameraArrival.js';
import { isGlobeEngine } from '../globe/contract.js';
/** All visible positions, path vertices, polygon rings, walls and overlay bounds. */ /** All visible positions, path vertices, polygon rings, walls and overlay bounds. */
export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) { export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) {
@@ -69,7 +70,11 @@ export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) {
return points; 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( export function flyToDataSource(
viewer, viewer,
dataSource, dataSource,
@@ -87,7 +92,12 @@ export function flyToDataSource(
Cesium.Cartographic.fromDegrees(points[0][0], points[0][1]); Cesium.Cartographic.fromDegrees(points[0][0], points[0][1]);
const lat = Cesium.Math.toDegrees(center.latitude), const lat = Cesium.Math.toDegrees(center.latitude),
lon = Cesium.Math.toDegrees(center.longitude); 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 cached = ground?.cachedGroundFloor?.(lat, lon);
const height = const height =
Number.isFinite(terrain) && terrain > 0 Number.isFinite(terrain) && terrain > 0
@@ -109,6 +119,30 @@ export function flyToDataSource(
sphere.radius, sphere.radius,
Cesium.Cartesian3.distance(sphere.center, position), 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, { viewer.camera?.flyToBoundingSphere?.(sphere, {
offset: new Cesium.HeadingPitchRange( offset: new Cesium.HeadingPitchRange(
viewer.camera.heading || 0, viewer.camera.heading || 0,
+17
View File
@@ -161,3 +161,20 @@ test('non-finite positions and hidden features never enter the camera footprint'
assert.equal(points.length, 1); assert.equal(points.length, 1);
assert.ok(Math.abs(points[0][0] - 20) < 1e-6); 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');
});
+113 -49
View File
@@ -42,11 +42,14 @@ export function initGeoFileImport({ viewer, ...options }) {
* and returns their disconnect. * and returns their disconnect.
* *
* The imported files belong to the session; their drawing belongs to the * The imported files belong to the session; their drawing belongs to the
* attached Cesium viewer. `attachScene({ viewer })` draws them (a file kept * attached globe. `attachScene({ viewer })` draws them on a Cesium viewer;
* while no viewer was attached is read again from its bytes) and listens for * `attachScene({ engine, draw })` on a globe engine without one (Navara),
* drops and camera gestures there; `detachScene()` takes them off the viewer * where `draw(dataSource)` resolves with that engine's drawing of a file
* and keeps the list. Without a viewer (an engine without Cesium) the list * ({ report, setShow, refresh, destroy }: src/layers/shared/render/navaraStory.js).
* stays, and import and fly-to wait for one. * 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, * @returns {{destroy: Function, importFiles: Function, list: Function,
* setVisible: Function, flyTo: Function, remove: Function, * setVisible: Function, flyTo: Function, remove: Function,
* diagnostics: Function, attachScene: Function, detachScene: Function}|null} * diagnostics: Function, attachScene: Function, detachScene: Function}|null}
@@ -75,8 +78,8 @@ export function createGeoFileImport({
let cameraIntent = 0; let cameraIntent = 0;
let hint = ''; let hint = '';
const lifetime = new AbortController(); const lifetime = new AbortController();
// The attached viewer and the lifetime of that attachment. // The attached globe ({ viewer, engine, draw, controller }): a Cesium
let viewer = null; // viewer, or an engine and its drawing; null while none is attached.
let scene = null; let scene = null;
const listen = (target, type, listener, options) => { const listen = (target, type, listener, options) => {
@@ -88,13 +91,16 @@ export function createGeoFileImport({
hint = text || ''; hint = text || '';
present(); present();
}; };
const render = () => viewer?.scene?.requestRender?.(); const render = () => {
if (scene?.viewer) scene.viewer.scene?.requestRender?.();
else scene?.engine?.render.request('geo-import');
};
let groundTimer, let groundTimer,
groundQueued = false, groundQueued = false,
groundAgain = false; groundAgain = false;
let groundRemovers = []; let groundRemovers = [];
const scheduleGround = () => { const scheduleGround = () => {
if (destroyed || !viewer || !imports.length) return; if (destroyed || !scene || !imports.length) return;
if (groundQueued) { if (groundQueued) {
groundAgain = true; groundAgain = true;
return; return;
@@ -109,8 +115,10 @@ export function createGeoFileImport({
try { try {
for (const item of imports) { for (const item of imports) {
if (destroyed || scene !== attached) return; if (destroyed || scene !== attached) return;
if (item.visible) if (item.visible) {
await item.refreshGround?.(attached.controller.signal); await item.refreshGround?.(attached.controller.signal);
item.drawing?.refresh?.();
}
} }
render(); render();
} catch { } catch {
@@ -182,6 +190,7 @@ export function createGeoFileImport({
if (!item) return false; if (!item) return false;
item.visible = Boolean(visible); item.visible = Boolean(visible);
if (item.dataSource) item.dataSource.show = item.visible; if (item.dataSource) item.dataSource.show = item.visible;
item.drawing?.setShow(item.visible);
if (item.visible) scheduleGround(); if (item.visible) scheduleGround();
present(); present();
render(); render();
@@ -189,9 +198,9 @@ export function createGeoFileImport({
} }
const fly = (item) => { const fly = (item) => {
if (!viewer || !item.dataSource) return false; if (!scene || !item.dataSource) return false;
if (!item.visible) setVisible(item.id, true); if (!item.visible) setVisible(item.id, true);
flyToSource(viewer, item.dataSource, { ground }); flyToSource(scene.viewer ?? scene.engine, item.dataSource, { ground });
return true; return true;
}; };
@@ -201,7 +210,7 @@ export function createGeoFileImport({
*/ */
function flyTo(id) { function flyTo(id) {
const item = find(id); const item = find(id);
if (!item || !viewer || !item.dataSource) return false; if (!item || !scene || !item.dataSource) return false;
if (typeof navigation?.runImmediateNavigation === 'function') if (typeof navigation?.runImmediateNavigation === 'function')
return ( return (
navigation.runImmediateNavigation('file', () => fly(item)) !== false navigation.runImmediateNavigation('file', () => fly(item)) !== false
@@ -223,9 +232,12 @@ export function createGeoFileImport({
: true; : true;
}; };
/** Take one file's drawing off the viewer; the file stays in the list. */ /** Take one file's drawing off the globe; the file stays in the list. */
function undraw(item, from = viewer) { function undraw(item, from = scene) {
if (item.dataSource) from?.dataSources?.remove?.(item.dataSource, true); if (item.dataSource)
from?.viewer?.dataSources?.remove?.(item.dataSource, true);
item.drawing?.destroy();
item.drawing = null;
item.revoke?.(); item.revoke?.();
item.dataSource = null; item.dataSource = null;
item.revoke = null; item.revoke = null;
@@ -243,17 +255,27 @@ export function createGeoFileImport({
return true; 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) { async function draw(item, attached, loaded) {
item.dataSource = loaded.dataSource; item.dataSource = loaded.dataSource;
item.revoke = loaded.revoke; item.revoke = loaded.revoke;
item.refreshGround = loaded.refreshGround; item.refreshGround = loaded.refreshGround;
item.dataSource.show = item.visible; 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) { if (destroyed || scene !== attached) {
undraw(item, attached.viewer); undraw(item, attached);
return false; return false;
} }
// What this globe leaves out of the file (Navara: labels, models...).
item.undrawn = item.drawing?.report?.undrawn ?? 0;
loaded.mount?.(); loaded.mount?.();
return true; return true;
} }
@@ -267,8 +289,8 @@ export function createGeoFileImport({
function importFiles(files) { function importFiles(files) {
const list = [...(files || [])]; const list = [...(files || [])];
if (destroyed || !list.length) return Promise.resolve([]); if (destroyed || !list.length) return Promise.resolve([]);
if (!viewer) { if (!scene) {
// Import draws on the Cesium globe; this engine has none. // Import draws on the attached globe; there is none.
const error = t('Action unavailable or cancelled'); const error = t('Action unavailable or cancelled');
setHint(error); setHint(error);
return Promise.resolve( return Promise.resolve(
@@ -311,6 +333,7 @@ export function createGeoFileImport({
try { try {
loaded = await load(file, { loaded = await load(file, {
viewer: attached.viewer, viewer: attached.viewer,
engine: attached.engine,
color, color,
signal: attached.controller.signal, signal: attached.controller.signal,
}); });
@@ -343,13 +366,26 @@ export function createGeoFileImport({
removed: loaded.removed || 0, removed: loaded.removed || 0,
color, color,
visible: true, 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, file,
dataSource: null, dataSource: null,
drawing: null,
undrawn: 0,
revoke: null, revoke: null,
refreshGround: 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); imports.push(item);
present(); present();
render(); render();
@@ -358,6 +394,7 @@ export function createGeoFileImport({
ok: true, ok: true,
id: item.id, id: item.id,
removed: item.removed, removed: item.removed,
undrawn: item.undrawn,
}); });
if ( if (
outcomes.filter((o) => o.ok).length === 1 && outcomes.filter((o) => o.ok).length === 1 &&
@@ -370,7 +407,7 @@ export function createGeoFileImport({
return outcomes; 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) { async function redraw(attached) {
for (const item of [...imports]) { for (const item of [...imports]) {
if (destroyed || scene !== attached) return; if (destroyed || scene !== attached) return;
@@ -378,6 +415,7 @@ export function createGeoFileImport({
try { try {
const loaded = await load(item.file, { const loaded = await load(item.file, {
viewer: attached.viewer, viewer: attached.viewer,
engine: attached.engine,
color: item.color, color: item.color,
signal: attached.controller.signal, signal: attached.controller.signal,
}); });
@@ -392,6 +430,9 @@ export function createGeoFileImport({
} }
} }
if (!destroyed && scene === attached) { 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(); present();
render(); render();
} }
@@ -423,35 +464,42 @@ export function createGeoFileImport({
target.removeEventListener(type, listener, options); target.removeEventListener(type, listener, options);
dragDepth = 0; dragDepth = 0;
setDragging(false); setDragging(false);
for (const item of imports) undraw(item, previous.viewer); for (const item of imports) undraw(item, previous);
viewer = null;
} }
function attachScene({ function attachScene({
viewer: next = null, viewer = null,
engine = null,
draw: drawOnEngine = null,
navigation: nextNavigation = navigation, navigation: nextNavigation = navigation,
subscribeMapSource: nextMapSource = subscribeMapSource, subscribeMapSource: nextMapSource = subscribeMapSource,
} = {}) { } = {}) {
if (destroyed) return; if (destroyed) return;
detachScene(); detachScene();
if (!next) return; if (!viewer && !(engine && drawOnEngine)) return;
viewer = next;
// The shell and the map sources of this attachment. // The shell and the map sources of this attachment.
navigation = nextNavigation; navigation = nextNavigation;
const offMapSource = nextMapSource?.(scheduleGround); const offMapSource = nextMapSource?.(scheduleGround);
if (typeof offMapSource === 'function') groundRemovers.push(offMapSource); if (typeof offMapSource === 'function') groundRemovers.push(offMapSource);
const attached = (scene = { viewer, controller: new AbortController() }); const attached = (scene = {
ownEvent(viewer.camera?.moveEnd, scheduleGround); viewer,
ownEvent(viewer.scene?.globe?.tileLoadProgressEvent, (pending) => { 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(); if (!pending) scheduleGround();
}); });
const container = viewer.container; const container = viewer ? viewer.container : engine.host.container;
listen(viewer.scene?.canvas || container, 'pointerdown', gesture, { const canvas = viewer
passive: true, ? viewer.scene?.canvas || container
}); : engine.host.canvas;
listen(viewer.scene?.canvas || container, 'wheel', gesture, { listen(canvas, 'pointerdown', gesture, { passive: true });
passive: true, listen(canvas, 'wheel', gesture, { passive: true });
});
listen(container, 'dragenter', (event) => { listen(container, 'dragenter', (event) => {
if (!hasFiles(event)) return; if (!hasFiles(event)) return;
event.preventDefault(); event.preventDefault();
@@ -487,14 +535,17 @@ export function createGeoFileImport({
const handle = { const handle = {
importFiles, importFiles,
list: () => list: () =>
imports.map(({ id, name, format, entityCount, removed, visible }) => ({ imports.map(
id, ({ id, name, format, entityCount, removed, visible, undrawn }) => ({
name, id,
format, name,
entityCount, format,
removed, entityCount,
visible, removed,
})), visible,
undrawn,
}),
),
setVisible, setVisible,
flyTo, flyTo,
remove, remove,
@@ -505,12 +556,12 @@ export function createGeoFileImport({
drawn: imports.filter((item) => item.dataSource).length, drawn: imports.filter((item) => item.dataSource).length,
entities: imports.reduce((n, item) => n + item.entityCount, 0), entities: imports.reduce((n, item) => n + item.entityCount, 0),
listeners: listeners.length, listeners: listeners.length,
attached: Boolean(viewer), attached: Boolean(scene),
destroyed, destroyed,
}), }),
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
const last = viewer; const last = scene;
detachScene(); detachScene();
destroyed = true; destroyed = true;
disconnect(); disconnect();
@@ -558,5 +609,18 @@ export function summarize(outcomes) {
0, 0,
); );
if (added && stripped) parts.push(t('Some KML content was left out.')); 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(' '); 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)),
});
}
+104
View File
@@ -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); assert.equal(t.tool.diagnostics().drawn, 2);
t.tool.destroy(); 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();
});