mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Navara now matches Cesium where the review found it diverging: a flight holds the camera inputs, camera.changed counts turns as Cesium does, render requests made during a frame survive it, follow pinches zoom, ground widths follow the pixel ratio and Cyber sonar reaches glyph caches and models. On Cesium, a staged imagery swap keeps loading tiles, setView replaces a running flight, and a cleared weather detail goes away. The contract gains MoverSet.failed, maps.terrainRevision, render.maxTextureSize and surface.sampleTerrainMostDetailed on every engine, so layers retry failed models, key road heights by terrain, size weather textures to the GPU and place Bhote Koshi cards on detailed terrain. Engine switches keep the live view, follows, focus, layer options and cards: a pick hands over the exact pose, applies no address options and pans no carried-over card; a history visit still applies its entry. Layers put GhostMaps pins, cable landings, OSM Places and directions on the measured surface without per-frame sampling, draw the Cockpit contact dot, hide disabled CCTV on attach, and keep ALPR running outside secure contexts. KML imports draw raised polygon fills, rotated and coloured ground overlays and north-referenced icon headings, and report files with nothing to draw. QA scripts read the contract instead of Cesium internals (test:track is 109/109 again), wait for the camera to rest before clicking, and give cold switches and parked engines realistic time. Docs follow the code.
334 lines
12 KiB
JavaScript
334 lines
12 KiB
JavaScript
#!/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 and packed ground overlay; unsupported content is counted
|
||
// 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 { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||
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: [
|
||
'--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:is([disabled], [aria-busy="true"])',
|
||
) &&
|
||
!document.querySelector('.engine-switch-frame') &&
|
||
document.querySelector('#loading-screen.hidden'),
|
||
{ timeout: 120000 },
|
||
kind,
|
||
);
|
||
await page.evaluate(
|
||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||
);
|
||
};
|
||
await ready('navara');
|
||
await assertKeylessPreview(page);
|
||
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 || '',
|
||
// Both engines draw imported pins through mover sets.
|
||
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 },
|
||
);
|
||
// Packed overlays draw through imagery; remote overlays are removed on import.
|
||
const undrawn = Object.fromEntries(
|
||
imported.files.map((f) => [f.name, f.undrawn]),
|
||
);
|
||
await strict.check(
|
||
page,
|
||
'geo-import',
|
||
'all supported geometry, including the packed overlay, draws on Navara',
|
||
undrawn['packed.kmz'] === 0 &&
|
||
undrawn['park.geojson'] === 0 &&
|
||
undrawn['remote-bits.kml'] === 0 &&
|
||
undrawn['walk.gpx'] === 0,
|
||
{ 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');
|
||
},
|
||
);
|