mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
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:
1 parent
eee4f48745
commit
d8c63c69d8
22 files changed
+1368
-68
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
+28
-4
@@ -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;
|
||||
}
|
||||
+43
-10
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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} الخاص بك",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}キーを使う",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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": "Импорт",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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} 密钥",
|
||||
|
||||
@@ -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} 密鑰",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
+113
-49
@@ -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)),
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user