mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Move Cesium viewer, map, sky, projection, trail and sonar internals into its engine. Drive styles, bloom, sharpening and contact sonar through engine.render. Count camera-input suspensions across all three engines and migrate the feature callers. Preserve Cesium's effects and input behaviour; fix transit lens clicks on Cesium and make zero sharpening on Navara disable sharpening, as it does on Cesium. Parse imported files into plain records and draw them through movers, lines, ground and imagery on both engines. Follow Cesium's KML style precedence, preserve local KMZ images and frame the drawable records. Open overlays use imagery when the engine has no native map-overlay API. Run every imported-display case on fake and headless Cesium engines. The shared import path retains these known differences from Cesium's old DataSources: wall fills, extruded prisms and gx:LatLonQuad are undrawn; raised polygon fills drape while outlines retain altitude; GroundOverlay rotation, altitude and RGB tint are not applied; rhumb lines become great circles. Ground heights use the loaded surface synchronously instead of waiting for most-detailed sampling. Supported KMZ ground overlays now draw on Navara too. Document the limits rather than claiming full parity. Combine batches B and C because their disjoint path manifests cannot place the shared boundary and format files in a later commit while keeping B independently valid. This combined commit owns the shared docs, package boundaries, format scope, locale catalogs and coupling baseline. Remove five obsolete import strings from all eleven catalogs. Make the load-time source scan include new files and skip deleted paths so the same checks work before and after recording the moves. Coupling: Navara renderer lines fall from 1695 to 1294, Cesium import files from 25 to 9, viewer lines from 1177 to 1026, SDK loads from 4 to 3 and engine-name lines from 62 to 42. No count grows. The full unit suite and the boundary, format and coupling checks pass. Browser validation remains to be run. Browser checks to run GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291 node scripts/qa-contract-suite.mjs http://localhost:4291 node scripts/qa-navara-visual-styles.mjs http://localhost:4291 node scripts/qa-navara-bloom.mjs http://localhost:4291 node scripts/qa-navara-sonar.mjs http://localhost:4291 QA_BASE_URL=http://localhost:4291 node scripts/qa-visual-effects.mjs QA_BASE_URL=http://localhost:4291 node scripts/qa-zoom-out-guard.mjs node scripts/qa-sky-model-lighting.mjs --url http://localhost:4291 node scripts/qa-geo-import.mjs http://localhost:4291 node scripts/qa-geo-import-regressions.mjs http://localhost:4291 node scripts/qa-geo-import-large.mjs http://localhost:4291 node scripts/qa-navara-geo-import.mjs http://localhost:4291 Use the keyless server for every check. Photoreal-only cases remain unverified; do not enable paid tiles to run them.
287 lines
9.9 KiB
JavaScript
287 lines
9.9 KiB
JavaScript
// The Cyber HUD sonar on Navara: with the Cyber HUD on, the
|
||
// sweep lights the contacts it passes and dims the rest to the opacity
|
||
// floor, by each contact's place on screen exactly as the CPU sampler of
|
||
// src/cyberSonar.js rates it; the sweep turns; with the sweep off every
|
||
// contact takes the neutral factor; a parked scene draws at the fleet
|
||
// cadence only while the sweep runs over contacts; leaving the Cyber HUD
|
||
// gives the contacts back unchanged. On Cesium the command adapter keeps the
|
||
// treatment and the engine draws none. The contacts are an engine point set
|
||
// (engine.movers) in a ring around the screen centre. Keyless preview under
|
||
// the strict default-deny network policy.
|
||
//
|
||
// node scripts/qa-navara-sonar.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
|
||
|
||
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import {
|
||
createStrict,
|
||
localPreview,
|
||
waitForEngine,
|
||
} from './qa-strict-harness.mjs';
|
||
import { checker, freeze, visualPageUrl } from './qa/navaraVisual.mjs';
|
||
import { sonarFactorAt } from '../src/globe/navara/sonar.js';
|
||
|
||
const base = localPreview(process.argv[2]);
|
||
const outDir = process.argv
|
||
.find((arg) => arg.startsWith('--out='))
|
||
?.slice('--out='.length);
|
||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||
const report = { checks: [] };
|
||
const check = checker(report, assert);
|
||
const WIDTH = 1280;
|
||
const HEIGHT = 800;
|
||
|
||
// 32 contacts on a ring at half the keyhole radius (11.25° apart: one is
|
||
// always well inside the 24° sector), and one outside the keyhole.
|
||
function contactPlaces() {
|
||
const radius = Math.min(WIDTH * 0.4, HEIGHT * 0.52) * 0.5;
|
||
const places = [];
|
||
for (let i = 0; i < 32; i++) {
|
||
const angle = (i / 32) * Math.PI * 2;
|
||
places.push({
|
||
x: Math.round(WIDTH / 2 + Math.sin(angle) * radius),
|
||
y: Math.round(HEIGHT / 2 - Math.cos(angle) * radius),
|
||
});
|
||
}
|
||
places.push({ x: 120, y: 110 });
|
||
return places;
|
||
}
|
||
|
||
// The luma at each contact (a 3×3 mean) of the next drawn frame, and the
|
||
// sonar state that frame drew with.
|
||
const read = (page, places) =>
|
||
page.evaluate(async (places) => {
|
||
const { engine } = window.__godsEyeView;
|
||
const canvas = document.createElement('canvas');
|
||
const source = engine.host.canvas;
|
||
canvas.width = source.width;
|
||
canvas.height = source.height;
|
||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||
let sonar = null;
|
||
await new Promise((resolve) => {
|
||
const off = engine.frames.postRender.addEventListener(() => {
|
||
off();
|
||
ctx.drawImage(source, 0, 0);
|
||
sonar = engine.render.contactSonar?.() ?? null;
|
||
resolve();
|
||
});
|
||
engine.render.request('qa-sonar');
|
||
});
|
||
const scale = source.width / engine.host.size().width;
|
||
const luma = places.map(({ x, y }) => {
|
||
const data = ctx.getImageData(
|
||
Math.round(x * scale) - 1,
|
||
Math.round(y * scale) - 1,
|
||
3,
|
||
3,
|
||
).data;
|
||
let sum = 0;
|
||
for (let i = 0; i < data.length; i += 4)
|
||
sum += 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
|
||
return sum / 9;
|
||
});
|
||
return { luma, sonar };
|
||
}, places);
|
||
|
||
// Frames the engine draws in `ms` without being asked.
|
||
const framesIn = (page, ms) =>
|
||
page.evaluate(async (ms) => {
|
||
const { engine } = window.__godsEyeView;
|
||
let frames = 0;
|
||
const off = engine.frames.postRender.addEventListener(() => frames++);
|
||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||
off();
|
||
return frames;
|
||
}, ms);
|
||
|
||
const mean = (values) =>
|
||
values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length);
|
||
|
||
await strict.run({}, async (browser) => {
|
||
const page = await browser.newPage();
|
||
const errors = [];
|
||
page.on('pageerror', (error) => errors.push(error.message));
|
||
await page.evaluateOnNewDocument(() => {
|
||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||
});
|
||
await page.setViewport({ width: WIDTH, height: HEIGHT });
|
||
await page.goto(visualPageUrl(base.origin, 'navara'), {
|
||
waitUntil: 'domcontentloaded',
|
||
});
|
||
await waitForEngine(page, 'navara');
|
||
await freeze(page);
|
||
|
||
const places = contactPlaces();
|
||
const placed = await page.evaluate((places) => {
|
||
const { engine } = window.__godsEyeView;
|
||
const set = engine.movers.createSet({
|
||
id: 'qa-sonar-contacts',
|
||
kind: 'point',
|
||
capacity: places.length,
|
||
depthTest: 'always',
|
||
point: { sizePx: 14, outlinePx: 0 },
|
||
});
|
||
window.__qaSonarSet = set;
|
||
let missing = 0;
|
||
places.forEach((place, index) => {
|
||
const position = engine.camera.pickEllipsoid(place);
|
||
if (!position) missing++;
|
||
set.set(index, { position, color: [1, 1, 1, 1] });
|
||
});
|
||
set.setCount(places.length);
|
||
set.commit();
|
||
return { missing, sonar: engine.render.contactSonar?.() ?? null };
|
||
}, places);
|
||
check(
|
||
'Navara treats contacts itself; the sonar starts off',
|
||
placed.missing === 0 && placed.sonar?.enabled === false,
|
||
placed,
|
||
);
|
||
const baseline = await read(page, places);
|
||
|
||
await page.evaluate(() => {
|
||
const { styleManager } = window.__godsEyeView;
|
||
styleManager._setHudVariant('cyber');
|
||
// Normal keeps the contacts' own colours; the HUD stays on (in Normal
|
||
// it would hide itself, and the sweep with it).
|
||
styleManager.setStyle('normal');
|
||
styleManager.hud.setMode('on');
|
||
styleManager._setCyberSonarEnabled(true);
|
||
styleManager._setCyberSonarSetting('opacity', 35);
|
||
});
|
||
await page.waitForFunction(
|
||
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||
);
|
||
const theme = await page.evaluate(() => ({
|
||
theme: document.documentElement.dataset.uiTheme,
|
||
gpu: document.documentElement.dataset.cyberSonarGpu,
|
||
hud: document.getElementById('intel-hud')?.classList.contains('active'),
|
||
}));
|
||
check(
|
||
'the Cyber HUD runs with the contact adapter',
|
||
theme.theme === 'cyber' && theme.gpu === 'supported' && theme.hud,
|
||
theme,
|
||
);
|
||
|
||
const sweeps = [];
|
||
for (const wait of [0, 1300, 1300]) {
|
||
await new Promise((resolve) => setTimeout(resolve, wait));
|
||
const { luma, sonar } = await read(page, places);
|
||
const expected = places.map(({ x, y }) =>
|
||
sonarFactorAt(x, y, WIDTH, HEIGHT, sonar),
|
||
);
|
||
const lit = luma.filter((_, i) => expected[i] > 0.9);
|
||
const dim = luma.filter((_, i) => expected[i] <= sonar.opacity / 100);
|
||
sweeps.push({
|
||
spread: Math.max(...luma.slice(0, 32)) - Math.min(...luma.slice(0, 32)),
|
||
angle: sonar.angleDeg,
|
||
lit: lit.length,
|
||
litLuma: mean(lit),
|
||
dimLuma: mean(dim),
|
||
brightest: luma.indexOf(Math.max(...luma)),
|
||
expectedBrightest: expected.indexOf(Math.max(...expected)),
|
||
sonar,
|
||
});
|
||
check(
|
||
`the sweep at ${Math.round(sonar.angleDeg)}° lights the contacts it passes, the rest sit at the floor`,
|
||
sonar.enabled &&
|
||
sonar.active &&
|
||
sonar.opacity === 35 &&
|
||
lit.length >= 1 &&
|
||
dim.length >= 8 &&
|
||
mean(lit) > mean(dim) + 20 &&
|
||
expected[places.length - 1] === 0.35,
|
||
sweeps.at(-1),
|
||
);
|
||
}
|
||
check(
|
||
'the sweep turns: another contact is lit',
|
||
new Set(sweeps.map((s) => s.expectedBrightest)).size > 1 &&
|
||
new Set(sweeps.map((s) => s.brightest)).size > 1,
|
||
sweeps.map(({ angle, brightest, expectedBrightest }) => ({
|
||
angle,
|
||
brightest,
|
||
expectedBrightest,
|
||
})),
|
||
);
|
||
const cadence = await framesIn(page, 1500);
|
||
check(
|
||
'a parked scene draws at the fleet cadence while the sweep runs over contacts',
|
||
cadence >= 8,
|
||
{ frames: cadence },
|
||
);
|
||
|
||
await page.evaluate(() =>
|
||
window.__godsEyeView.styleManager._setCyberSonarEnabled(false),
|
||
);
|
||
const neutral = await read(page, places);
|
||
const spread =
|
||
Math.max(...neutral.luma.slice(0, 32)) -
|
||
Math.min(...neutral.luma.slice(0, 32));
|
||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||
const idle = await framesIn(page, 1500);
|
||
check(
|
||
'with the sweep off every contact takes the neutral factor and the scene rests',
|
||
neutral.sonar.enabled &&
|
||
!neutral.sonar.active &&
|
||
// What still differs is the map under the translucent contacts.
|
||
spread < Math.min(...sweeps.map((sweep) => sweep.spread)) * 0.6 &&
|
||
idle <= 3,
|
||
{ spread, sweeping: sweeps.map((sweep) => sweep.spread), frames: idle },
|
||
);
|
||
|
||
await page.evaluate(() => {
|
||
const { styleManager } = window.__godsEyeView;
|
||
styleManager._setCyberSonarEnabled(true);
|
||
styleManager._setHudVariant('tactical');
|
||
styleManager.setStyle('normal');
|
||
styleManager.hud.setMode('auto');
|
||
});
|
||
await page.waitForFunction(
|
||
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||
);
|
||
const after = await read(page, places);
|
||
const drift = Math.max(
|
||
...after.luma.map((value, i) => Math.abs(value - baseline.luma[i])),
|
||
);
|
||
check(
|
||
'leaving the Cyber HUD gives the contacts back unchanged',
|
||
after.sonar.enabled === false && drift < 3,
|
||
{ drift },
|
||
);
|
||
await page.evaluate(() => window.__qaSonarSet.destroy());
|
||
|
||
// Cesium draws contact sonar through its engine command adapter.
|
||
await strict.switchTo(page, 'cesium');
|
||
await page.evaluate(() => {
|
||
const { styleManager } = window.__godsEyeView;
|
||
styleManager._setHudVariant('cyber');
|
||
});
|
||
await page.waitForFunction(
|
||
() => document.documentElement.dataset.cyberSonarGpu,
|
||
{ timeout: 10000 },
|
||
);
|
||
const cesium = await page.evaluate(() => ({
|
||
setContactSonar: typeof window.__godsEyeView.engine.render.setContactSonar,
|
||
gpu: document.documentElement.dataset.cyberSonarGpu,
|
||
}));
|
||
check(
|
||
'Cesium: the engine command adapter treats contacts',
|
||
cesium.setContactSonar === 'function' && cesium.gpu === 'supported',
|
||
cesium,
|
||
);
|
||
await page.evaluate(() =>
|
||
window.__godsEyeView.styleManager._setHudVariant('tactical'),
|
||
);
|
||
|
||
check('no page errors', errors.length === 0, errors);
|
||
if (outDir) {
|
||
fs.mkdirSync(outDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(outDir, 'sonar.json'),
|
||
JSON.stringify(report, null, 2),
|
||
);
|
||
}
|
||
});
|