mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Complete phase 3 W1 acceptance and close T16 findings
Fix Navara model display tint, overlay ordering, follow gestures and resource cleanup. Reduce repeated aircraft work and correct fixture enrichment responses. Strengthen dev/preview QA, preserve failure history and document verified W1 results. Validation: 7171 ordinary tests pass, one Windows-only skip; 15 allocation probes; 37 W1 acceptance steps; contracts, shell, four performance gates; 18 identical Cesium baseline PNGs. Known pre-existing CCTV and live-source limits are recorded.
This commit is contained in:
1 parent
766c786093
commit
0600964249
59 files changed
+2334
-701
No files matched your search
@@ -43,7 +43,21 @@ const RENDERED_LAYER_DIRS = [
|
||||
'src/layers/aircraft/',
|
||||
'src/layers/shared/',
|
||||
];
|
||||
const W1_SHARED_FILES = new Set(
|
||||
[
|
||||
'trackedCamera',
|
||||
'trailRenderer',
|
||||
'contactTrailRenderer',
|
||||
'focusDeemphasis',
|
||||
'spriteOrder',
|
||||
'iconOrientation',
|
||||
'modelVisualAnchor',
|
||||
'trackingClickGesture',
|
||||
].map((name) => `src/data/${name}.js`),
|
||||
);
|
||||
const renderedLayer = (file) =>
|
||||
W1_SHARED_FILES.has(file) ||
|
||||
file.startsWith('src/data/render/') ||
|
||||
file === 'src/celestialRing.js' ||
|
||||
file.startsWith('src/celestialRing/') ||
|
||||
RENDERED_LAYER_DIRS.some((dir) => file.startsWith(dir));
|
||||
|
||||
@@ -1160,5 +1160,24 @@
|
||||
"src/layers/launches/render/navara.test.mjs",
|
||||
"scripts/qa-launches-compare.mjs",
|
||||
"src/layers/satellites/interaction.test.mjs",
|
||||
"src/layers/launches/lifecycleSnapshot.test.mjs"
|
||||
"src/layers/launches/lifecycleSnapshot.test.mjs",
|
||||
"scripts/vite-w1-qa.config.mjs",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/render/cesiumTrailRenderer.js",
|
||||
"src/data/render/cesiumContactTrailRenderer.js",
|
||||
"scripts/qa-follow-gestures.mjs",
|
||||
"scripts/qa-w1-baseline-pixels.mjs",
|
||||
"src/tooling/w1QaComparison.test.mjs",
|
||||
"src/layers/flights/trackingRefresh.test.mjs",
|
||||
"scripts/qa-heading-navara.mjs",
|
||||
"src/layers/aircraft/qaDrawn.test.mjs",
|
||||
"src/data/render/cesiumConstants.js",
|
||||
"src/globe/navara/movers/frameCamera.js",
|
||||
"src/globe/navara/overlayPass.js",
|
||||
"src/globe/navara/overlayPass.test.mjs",
|
||||
"src/globe/navara/movers/modelTint.js",
|
||||
"src/globe/navara/movers/frameCamera.test.mjs",
|
||||
"src/layers/localAdsb/qaDrawn.js",
|
||||
"src/layers/localAdsb/qaDrawn.test.mjs",
|
||||
"src/tooling/w1RuntimeEvidence.test.mjs"
|
||||
]
|
||||
@@ -858,10 +858,12 @@
|
||||
"src/data/cockpitContactDot.js",
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/render/cesiumConstants.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/routePlausible.js",
|
||||
@@ -922,10 +924,12 @@
|
||||
"src/data/cockpitContactDot.js",
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/render/cesiumConstants.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/trackedModelRegime.js",
|
||||
@@ -981,11 +985,13 @@
|
||||
"src/data/cockpitContactDot.js",
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/render/cesiumConstants.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/routePlausible.js",
|
||||
@@ -1035,6 +1041,7 @@
|
||||
"src/layers/vessels/tracking.js",
|
||||
"src/layers/shared/color.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/layers/vessels/records.js",
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
@@ -1090,6 +1097,7 @@
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/firmsLabels.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/layers/firms/anchors.js",
|
||||
"src/layers/firms/cards.js",
|
||||
"src/layers/firms/index.js",
|
||||
@@ -1276,6 +1284,7 @@
|
||||
"src/cyberSonar.js",
|
||||
"src/cockpitMath.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
"src/layers/awareness/controls.js",
|
||||
"src/layers/awareness/dependencies.js",
|
||||
@@ -1423,7 +1432,9 @@
|
||||
"src/data/cctvLod.js",
|
||||
"src/data/cctvViewshed.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/trackingClickGesture.js",
|
||||
"src/data/render/cesiumConstants.js",
|
||||
"src/layers/cctv/calibration.js",
|
||||
"src/layers/cctv/cards.js",
|
||||
"src/layers/cctv/catalog.js",
|
||||
@@ -1576,6 +1587,7 @@
|
||||
"exports": ["./layers/radio"],
|
||||
"modules": [
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
@@ -1925,6 +1937,7 @@
|
||||
"src/data/flights.js",
|
||||
"src/data/flowMatch.js",
|
||||
"src/data/focusDeemphasis.js",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/geoid.js",
|
||||
"src/data/groundFloor.js",
|
||||
"src/data/groundSnap.js",
|
||||
@@ -1954,6 +1967,7 @@
|
||||
"src/data/militaryInstallations.js",
|
||||
"src/data/militaryRegistry.js",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/render/cesiumConstants.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/adminBoundaries.js",
|
||||
@@ -1991,6 +2005,7 @@
|
||||
"src/data/trafficPresetStyle.js",
|
||||
"src/data/trafficQueue.js",
|
||||
"src/data/trailRenderer.js",
|
||||
"src/data/render/cesiumTrailRenderer.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/firstRunExperience.js",
|
||||
"src/hud.js",
|
||||
@@ -2442,6 +2457,7 @@
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/contactTrailRenderer.js",
|
||||
"src/data/render/cesiumContactTrailRenderer.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/modelEligibility.js",
|
||||
@@ -2520,6 +2536,7 @@
|
||||
"src/layers/localAdsb/feedNames.js",
|
||||
"src/layers/localAdsb/motion.js",
|
||||
"src/layers/localAdsb/models.js",
|
||||
"src/layers/localAdsb/qaDrawn.js",
|
||||
"src/layers/localAdsb/enrichment.js",
|
||||
"src/sources/adsbRecords.js",
|
||||
"src/sdr/controller.js",
|
||||
@@ -2633,7 +2650,10 @@
|
||||
"src/globe/navara/overlayDepth.js",
|
||||
"src/globe/navara/movers/moverMesh.js",
|
||||
"src/globe/navara/movers/pack.js",
|
||||
"src/globe/navara/movers/frameCamera.js",
|
||||
"src/globe/navara/overlayPass.js",
|
||||
"src/globe/navara/movers/modelMover.js",
|
||||
"src/globe/navara/movers/modelTint.js",
|
||||
"src/globe/navara/movers/index.js",
|
||||
"src/globe/surfaceDetail.js",
|
||||
"src/globe/navara/followGestures.js",
|
||||
@@ -2770,6 +2790,7 @@
|
||||
"src/data/firmsLabels.js",
|
||||
"src/data/flowMatch.js",
|
||||
"src/data/focusDeemphasis.js",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/geoid.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/infrastructure.js",
|
||||
@@ -2789,6 +2810,7 @@
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
"src/data/militaryInstallationData.js",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/render/cesiumConstants.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/adminBoundaries.js",
|
||||
@@ -2816,6 +2838,7 @@
|
||||
"src/data/trafficPresetStyle.js",
|
||||
"src/data/trafficQueue.js",
|
||||
"src/data/trailRenderer.js",
|
||||
"src/data/render/cesiumTrailRenderer.js",
|
||||
"src/data/vesselLabels.js",
|
||||
"src/keylessGeocoder.js",
|
||||
"src/layers/aircraft/classification.js",
|
||||
@@ -3113,6 +3136,7 @@
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/contactTrailRenderer.js",
|
||||
"src/data/render/cesiumContactTrailRenderer.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/layers/transit/trails.js",
|
||||
@@ -3147,6 +3171,7 @@
|
||||
"src/layers/localAdsb/feedNames.js",
|
||||
"src/layers/localAdsb/motion.js",
|
||||
"src/layers/localAdsb/models.js",
|
||||
"src/layers/localAdsb/qaDrawn.js",
|
||||
"src/layers/localAdsb/enrichment.js",
|
||||
"src/sources/adsbRecords.js",
|
||||
"src/sdr/controller.js",
|
||||
@@ -3748,6 +3773,7 @@
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/contactTrailRenderer.js",
|
||||
"src/data/render/cesiumContactTrailRenderer.js",
|
||||
"src/data/dataCredits.js",
|
||||
"src/data/detectionCohort.js",
|
||||
"src/data/detectionPolicy.js",
|
||||
@@ -3755,6 +3781,7 @@
|
||||
"src/data/groundFloor.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/meshFloorSampler.js",
|
||||
@@ -3824,6 +3851,7 @@
|
||||
"exports": ["./layers/transit"],
|
||||
"modules": [
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitIcons.js",
|
||||
@@ -3843,6 +3871,7 @@
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/contactTrailRenderer.js",
|
||||
"src/data/render/cesiumContactTrailRenderer.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/layers/transit/trails.js",
|
||||
|
||||
@@ -23,6 +23,70 @@ export function redact(text) {
|
||||
.replace(/AIza[\w-]{30,}/g, 'AIza…REDACTED');
|
||||
}
|
||||
|
||||
/** The standalone Vite preview has no setup API; opt in explicitly. */
|
||||
export function isExpectedPreviewSetupError(
|
||||
message,
|
||||
{ preview = false, baseUrl } = {},
|
||||
) {
|
||||
if (
|
||||
!preview ||
|
||||
message.type() !== 'error' ||
|
||||
!/^Failed to load resource: .*\b404\b/.test(message.text())
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
const url = new URL(message.location()?.url);
|
||||
return (
|
||||
url.origin === new URL(baseUrl).origin &&
|
||||
url.pathname === '/api/setup/status'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// W1 acceptance shares one strict runtime verdict across its main page and
|
||||
// every share-link page. Response diagnostics never retain query credentials.
|
||||
export function watchW1Page(page, report, { name, preview = false, baseUrl }) {
|
||||
const errors = (report.browserErrors ??= []);
|
||||
report.expectedPreviewErrors ??= [];
|
||||
report.failedResponses ??= [];
|
||||
(report.runtimePages ??= []).push(name);
|
||||
const resourcePath = (value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return redact(url.origin + url.pathname);
|
||||
} catch {
|
||||
return '(location unavailable)';
|
||||
}
|
||||
};
|
||||
page.on('console', (message) => {
|
||||
if (isExpectedPreviewSetupError(message, { preview, baseUrl })) {
|
||||
report.expectedPreviewErrors.push('/api/setup/status: 404');
|
||||
return;
|
||||
}
|
||||
if (message.type() === 'error')
|
||||
errors.push(
|
||||
`[${name}] ${redact(message.text())} — ${resourcePath(message.location()?.url)}`,
|
||||
);
|
||||
});
|
||||
for (const event of ['pageerror', 'error'])
|
||||
page.on(event, (error) =>
|
||||
errors.push(`[${name}] ${redact(error.message)}`),
|
||||
);
|
||||
page.on('response', (response) => {
|
||||
if (
|
||||
response.status() >= 400 &&
|
||||
report.failedResponses.length < EVIDENCE_LIMIT
|
||||
)
|
||||
report.failedResponses.push({
|
||||
page: name,
|
||||
status: response.status(),
|
||||
resource: resourcePath(response.url()),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function push(list, entry) {
|
||||
list.push(entry);
|
||||
if (list.length > EVIDENCE_LIMIT)
|
||||
|
||||
@@ -28,15 +28,15 @@
|
||||
* E3 requests : the sweep requested all 12 hexes, each exactly once
|
||||
* E4 diversity : ≥5 distinct glyph data-URIs displayed ambiently after
|
||||
* enrichment (expected: 8)
|
||||
* E5 glyphs : every billboard's image === aircraftIcon(classify(typeCode))
|
||||
* E5 glyphs : exact classified silhouette at its registered 64/192 px raster
|
||||
* E6 scales : every billboard's scale === CLASS_SCALE_2D[klass]
|
||||
* E7 bounds : ≤4 concurrent requests, ≥185 ms between dispatches (≤5/s
|
||||
* drip), total ≤300 (budget ceiling)
|
||||
* E8 next poll : another poll neither reverts glyphs nor re-requests hexes
|
||||
* E10 exhaust : the ROLLING ambient budget exhausts — new on-screen planes
|
||||
* stop being requested once the bucket is empty (shrunk via
|
||||
* the __GEV_ENRICH_AMBIENT_QA seam; a real 5-min window can't
|
||||
* be waited out headlessly)
|
||||
* stop at the real 300-token initial ceiling; the QA seam
|
||||
* shortens only the refill window (a real 5-min window
|
||||
* cannot be waited out headlessly)
|
||||
* E11 resume : after a refill window passes, ambient enrichment RESUMES
|
||||
* for the still-unrequested planes (the 2026-07-03 field bug:
|
||||
* the old one-shot session cap never refilled, so hours-long
|
||||
@@ -81,13 +81,13 @@ const DISPATCH_GAP_MS = 200; // ENRICH_DISPATCH_GAP_MS (≤5 req/s)
|
||||
const GAP_TOLERANCE_MS = 15; // clock-quantization allowance on the gap check
|
||||
const SESSION_CAP = 300; // ENRICH_AMBIENT_BUDGET_CEIL (rolling-bucket ceiling)
|
||||
|
||||
// Rolling-budget QA seam (window.__GEV_ENRICH_AMBIENT_QA in flights.js):
|
||||
// shrunk so the exhaust→refill cycle is observable headlessly. windowMs starts
|
||||
// Rolling-budget QA seam: exercise the real initial ceiling and shorten
|
||||
// only the refill window so the exhaust→refill cycle is observable. windowMs starts
|
||||
// effectively-infinite (1 h) so E10 exhausts deterministically (no refill can
|
||||
// land mid-phase); E11 then shortens it in-page so refill windows have
|
||||
// "passed" and the bucket refills (clamped at ceil). ceil=20 leaves 8 tokens
|
||||
// after the 12 baseline planes — E10's batch exhausts those 8 and stalls.
|
||||
const BUDGET_QA = { ceil: 20, refillTokens: 6, windowMs: 3600000 };
|
||||
// "passed" and the bucket refills (clamped at ceil). The initial production
|
||||
// ceiling of 300 is exercised without assuming the override resets state.
|
||||
const BUDGET_QA = { ceil: SESSION_CAP, refillTokens: 6, windowMs: 3600000 };
|
||||
const RESUME_WINDOW_MS = 1000; // E11 swaps windowMs to this to unlock refills
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
@@ -165,6 +165,9 @@ const expected = new Map(Object.entries(TYPES).map(([hex, tc]) => [
|
||||
hex, classifyAircraft(tc ? { typeCode: tc } : { category: 0 }),
|
||||
]));
|
||||
const AIRLINER_ICON = aircraftIcon(classifyAircraft({ category: 0 })); // pre-enrichment default
|
||||
// Adaptive raster resolution preserves the classified silhouette. Compare
|
||||
// exact registered assets at either supported resolution, never another class.
|
||||
const matchesClassIcon = (image, klass) => image === aircraftIcon(klass) || image === aircraftIcon(klass, 192);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
@@ -190,7 +193,7 @@ async function main() {
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
@@ -213,6 +216,7 @@ async function main() {
|
||||
|
||||
// ---- Fetch shim: synthetic fleet + instrumented, holdable adsbdb -------
|
||||
await page.evaluateOnNewDocument((spec) => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__ENR = spec;
|
||||
window.__ENR.epochMs = Date.now();
|
||||
// Rolling-budget QA seam — must exist BEFORE flights.js initializes so
|
||||
@@ -297,6 +301,9 @@ async function main() {
|
||||
() => window.__godsEyeView && window.__godsEyeView.engine && window.__godsEyeView.dataManager && window.__godsEyeView.layers,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise);
|
||||
await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 });
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
|
||||
console.log(' App globals ready.');
|
||||
|
||||
// ---- In-page billboard probe (same walk as qa-sprites-b5.mjs) ----------
|
||||
@@ -347,6 +354,7 @@ async function main() {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer ?? null;
|
||||
window.__ENR.timeOffsetSec = -32;
|
||||
dm.setLayerParams('flights', { models3d: false }); // ambient budget excludes model-priority enrichment
|
||||
await dm.setEnabled('flights', true);
|
||||
const fl = dm.layers.get('flights').module;
|
||||
for (const off of [-24, -16, -8, 0]) {
|
||||
@@ -355,11 +363,11 @@ async function main() {
|
||||
}
|
||||
window.__ENR.timeOffsetSec = 0;
|
||||
window.__ENR_DRIVER = setInterval(() => { fl.update(v); }, 15000);
|
||||
return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length };
|
||||
return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length, models3d: fl.getParams().models3d };
|
||||
});
|
||||
console.log(` flights count=${primed.count} | adsbdb requests started (held): ${primed.starts}`);
|
||||
record('E1 ingest: all synthetic category-0 planes in the flights layer',
|
||||
primed.count === SPEC.planes.length, `count=${primed.count}/${SPEC.planes.length}`);
|
||||
primed.count === SPEC.planes.length && primed.models3d === false, `count=${primed.count}/${SPEC.planes.length}, ambient-only models3d=${primed.models3d}`);
|
||||
if (primed.count === 0) { finish(); return; }
|
||||
|
||||
// ========================================================================
|
||||
@@ -425,12 +433,11 @@ async function main() {
|
||||
const scaleBad = [];
|
||||
for (const [hex, klass] of expected) {
|
||||
const bb = afterById.get(hex);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantScale = CLASS_SCALE_2D[klass] || 1;
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${hex}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (!bb || !matchesClassIcon(bb.image, klass)) glyphBad.push(`${hex}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${hex}: ${bb.scale} != ${wantScale}`);
|
||||
}
|
||||
record('E5 glyphs: every billboard matches aircraftIcon(classify(typeCode))',
|
||||
record('E5 glyphs: every billboard matches its classified 64/192 px asset',
|
||||
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expected.size} matched (incl. the found:false miss staying airliner)`);
|
||||
record('E6 scales: every billboard has its per-class CLASS_SCALE_2D',
|
||||
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact (composes with scaleByDistance)');
|
||||
@@ -479,7 +486,7 @@ async function main() {
|
||||
const pollBad = [];
|
||||
for (const [hex, klass] of expected) {
|
||||
const bb = afterPollById.get(hex);
|
||||
if (!bb || bb.image !== aircraftIcon(klass)) pollBad.push(`${hex}(${klass})${bb ? ':reverted' : ':missing'}`);
|
||||
if (!bb || !matchesClassIcon(bb.image, klass)) pollBad.push(`${hex}(${klass})${bb ? ':reverted' : ':missing'}`);
|
||||
else if (Math.abs(bb.scale - (CLASS_SCALE_2D[klass] || 1)) > 1e-9) pollBad.push(`${hex}:scale-reverted`);
|
||||
}
|
||||
const noReRequests = afterPoll.starts === Object.keys(TYPES).length;
|
||||
@@ -491,16 +498,15 @@ async function main() {
|
||||
// E10 — rolling-budget exhaustion (2026-07-03 field bug, red/green): the
|
||||
// old ONE-SHOT session cap burned out and never refilled, so every new
|
||||
// region after the first polls showed airliner monoculture. With the seam
|
||||
// ceil=20 and 12 tokens spent above, only 8 remain — a batch of 26 fresh
|
||||
// on-screen planes must stall at exactly 8 new requests, and further polls
|
||||
// initial ceiling=300 and 12 tokens spent above, 288 remain. More than
|
||||
// 300 total candidates must stall at exactly 300 requests; further polls
|
||||
// must enqueue NOTHING while the bucket is empty (windowMs is 1 h here, so
|
||||
// no refill can land mid-phase). Pre-fix code fails this phase: there is
|
||||
// no rolling bucket to exhaust (the seam is unread and the 300 cap simply
|
||||
// admits the whole batch).
|
||||
// no refill can land mid-phase). E10 verifies the initial limit; E11
|
||||
// distinguishes a rolling refill from the old one-shot session cap.
|
||||
// ========================================================================
|
||||
console.log('\nE10 — rolling budget exhausts (batch of 26 new planes, 8 tokens left)');
|
||||
const BATCH_COUNT = 26;
|
||||
const batchHexes = Array.from({ length: BATCH_COUNT }, (_, i) => `ab00${(i + 1).toString(16).padStart(2, '0')}`);
|
||||
console.log('\nE10 — rolling budget exhausts (real 300-request initial bucket)');
|
||||
const BATCH_COUNT = BUDGET_QA.ceil + 26;
|
||||
const batchHexes = Array.from({ length: BATCH_COUNT }, (_, i) => (0xab0000 + i + 1).toString(16).padStart(6, '0'));
|
||||
const startsBeforeBatch = await page.evaluate(() => window.__ENRICH_LOG.starts.length);
|
||||
await page.evaluate(async ({ hexes, rowLat, rowLon0, stepDeg }) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
@@ -517,17 +523,28 @@ async function main() {
|
||||
lat0: rowLat + (i < 13 ? 0.02 : 0.04),
|
||||
courseDeg: 0,
|
||||
speedMps: 0,
|
||||
altM: 2600 + i * 15,
|
||||
altM: 2600 + (i % 26) * 15,
|
||||
});
|
||||
});
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const engine = window.__godsEyeView.engine;
|
||||
engine.camera.cancelFlight();
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(rowLon0 + 5.5 * stepDeg, rowLat, 20000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
for (let frame = 0; frame < 30; frame++) {
|
||||
engine.render.request('qa-enrich-budget');
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
const fl = dm.layers.get('flights').module;
|
||||
await fl.update(v); // sweep enqueues the last 8 tokens
|
||||
await fl.update(v); // bucket empty — must enqueue nothing more
|
||||
await fl.update(v); // sweep enqueues up to 150 ambient jobs
|
||||
await fl.update(v); // spend the remaining initial tokens; later polls must stall
|
||||
}, { hexes: batchHexes, rowLat: ROW_LAT, rowLon0: ROW_LON0, stepDeg: ROW_STEP_DEG });
|
||||
// Wait for the drip to drain whatever was enqueued, then settle.
|
||||
await page.waitForFunction(
|
||||
(min) => window.__ENRICH_LOG.starts.length >= min && window.__ENRICH_LOG.inflight === 0,
|
||||
{ timeout: 20000, polling: 100 }, startsBeforeBatch + (BUDGET_QA.ceil - startsBeforeBatch)
|
||||
{ timeout: 90000, polling: 100 }, BUDGET_QA.ceil
|
||||
).catch(() => { /* asserted below */ });
|
||||
await sleep(1500); // any illegal post-exhaustion enqueue would dispatch within ~200 ms
|
||||
// One more poll while exhausted — still nothing new.
|
||||
@@ -539,11 +556,14 @@ async function main() {
|
||||
const exhausted = await page.evaluate(() => ({
|
||||
starts: window.__ENRICH_LOG.starts.length,
|
||||
inflight: window.__ENRICH_LOG.inflight,
|
||||
sourceCount: window.__godsEyeView.dataManager.layers.get('flights').module.getStats().count,
|
||||
fixtureCount: window.__ENR.planes.length,
|
||||
cameraHeightM: window.__godsEyeView.engine.camera.pose().cartographic.height,
|
||||
}));
|
||||
record(`E10 exhaust: requests stall at the budget ceiling (${BUDGET_QA.ceil}) with unrequested planes on-screen`,
|
||||
exhausted.starts === BUDGET_QA.ceil && exhausted.inflight === 0,
|
||||
exhausted.starts === BUDGET_QA.ceil && exhausted.inflight === 0 && exhausted.sourceCount === startsBeforeBatch + BATCH_COUNT,
|
||||
`starts=${exhausted.starts} (want ${BUDGET_QA.ceil}: ${startsBeforeBatch} baseline + ${BUDGET_QA.ceil - startsBeforeBatch} tokens; `
|
||||
+ `${BATCH_COUNT - (BUDGET_QA.ceil - startsBeforeBatch)} planes left waiting) inflight=${exhausted.inflight}`);
|
||||
+ `${BATCH_COUNT - (BUDGET_QA.ceil - startsBeforeBatch)} planes left waiting) inflight=${exhausted.inflight}, source=${exhausted.sourceCount}/${exhausted.fixtureCount}, camera=${exhausted.cameraHeightM.toFixed(1)}m`);
|
||||
|
||||
// ========================================================================
|
||||
// E11 — refill window passes → enrichment RESUMES. Shorten windowMs so the
|
||||
|
||||
@@ -48,7 +48,7 @@ const SITE = {
|
||||
lon: Number.isFinite(Number(argv.lon)) ? Number(argv.lon) : -97.6660,
|
||||
};
|
||||
const ICAO = 'aa0f01';
|
||||
const OUT_DIR = 'qa-shots/floorhold';
|
||||
const OUT_DIR = new URL(APP_URL).searchParams.get('engine') === 'navara' ? 'qa-shots/floorhold/navara' : 'qa-shots/floorhold';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const results = [];
|
||||
@@ -107,7 +107,7 @@ await page.evaluateOnNewDocument((site, icao) => {
|
||||
|
||||
console.log(`\nFloor hold through a terrain outage\n App : ${APP_URL}\n GPU : ANGLE/${ANGLE}\n Site : ${SITE.lat}, ${SITE.lon}\n`);
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
await page.waitForFunction(() => window.__godsEyeView?.layers && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 150000 });
|
||||
await sleep(12000); // let the boot fly-to settle before pinning
|
||||
await page.evaluate(() => {
|
||||
@@ -116,8 +116,19 @@ await page.evaluate(() => {
|
||||
await sleep(600); // let the launcher dismissal finish before taking evidence
|
||||
|
||||
await page.evaluate((site) => { window.__SITE = site; }, SITE);
|
||||
const pin = () => page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const pin = () => page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
if (!app.viewer) {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
app.engine.camera.cancelFlight();
|
||||
app.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(window.__SITE.lon, window.__SITE.lat - 0.008, 900),
|
||||
orientation: { heading: 0, pitch: -0.45, roll: 0 },
|
||||
});
|
||||
app.engine.camera.moveEnd.raiseEvent();
|
||||
return;
|
||||
}
|
||||
const v = app.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.cancelFlight();
|
||||
v.camera.setView({
|
||||
@@ -142,7 +153,24 @@ record('the billboard floor test is explicitly in 2D aircraft mode', billboardMo
|
||||
|
||||
/** Reads the contact's rendered height and the rendered mesh beneath it. */
|
||||
const measure = () => page.evaluate(async (icao) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const app = window.__godsEyeView;
|
||||
if (!app.viewer) {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const bb = app.layers.drawn('flights').find((row) => row.id === icao && !row.trackedModel);
|
||||
if (!bb?.position) return { error: 'contact billboard not found' };
|
||||
const carto = geo.cartographic.fromCartesian(bb.position);
|
||||
let mesh = null;
|
||||
for (let attempt = 0; attempt < 10 && mesh == null; attempt++) {
|
||||
app.engine.render.request('floor-hold');
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const height = app.engine.surface.sampleHeight(carto);
|
||||
if (Number.isFinite(height)) mesh = height;
|
||||
else await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
}
|
||||
return { spriteH: carto.height, meshH: mesh, shown: bb.show === true,
|
||||
lat: +(carto.latitude * 180 / Math.PI).toFixed(5), lon: +(carto.longitude * 180 / Math.PI).toFixed(5), terrain: { ...window.__TERRAIN } };
|
||||
}
|
||||
const v = app.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
const sprites = []; const models = []; let bb = null;
|
||||
const walk = (coll) => {
|
||||
@@ -201,7 +229,9 @@ async function demTruth(lat, lon) {
|
||||
if (truth.has(key)) return truth.get(key);
|
||||
let h = null;
|
||||
try {
|
||||
const res = await fetch(`${APP_URL}/api/terrain/heights?points=${lon.toFixed(5)},${lat.toFixed(5)}`);
|
||||
const url = new URL('/api/terrain/heights', APP_URL);
|
||||
url.searchParams.set('points', `${lon.toFixed(5)},${lat.toFixed(5)}`);
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
const v = Number((await res.json())?.results?.[0]?.ellipsoid);
|
||||
if (Number.isFinite(v)) h = v;
|
||||
|
||||
@@ -37,7 +37,7 @@ const chrome = [
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new', executablePath: chrome,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist', '--no-sandbox'],
|
||||
args: ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`, '--enable-webgl', '--ignore-gpu-blocklist', '--no-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 900 });
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* --headful drops those flags and is the real-GPU visual sign-off path.
|
||||
*/
|
||||
|
||||
import { redact } from './qa-browserEvidence.mjs';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -29,7 +30,13 @@ const getOpt = (name, fallback = null) => {
|
||||
};
|
||||
const hasFlag = (name) => argv.includes(name);
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const appUrl = new URL(getOpt('--url', 'http://localhost:4173'));
|
||||
const requestedEngine = argv.find((value) => value.startsWith('--engine='))?.slice(9) || getOpt('--engine');
|
||||
if (requestedEngine) {
|
||||
if (!['cesium', 'navara'].includes(requestedEngine)) throw new Error('Unknown engine');
|
||||
appUrl.searchParams.set('engine', requestedEngine);
|
||||
}
|
||||
const APP_URL = appUrl.href;
|
||||
const JSON_PATH = path.resolve(getOpt('--json', 'qa-shots/focus-evidence/report.json'));
|
||||
const SCREENSHOTS_DIR = path.resolve(getOpt('--screenshots-dir', 'qa-shots/focus-evidence'));
|
||||
const HEADFUL = hasFlag('--headful');
|
||||
@@ -125,6 +132,10 @@ async function readTileReadiness(page) {
|
||||
const activeStack = controller?.getActiveId?.() || null;
|
||||
const tileset = controller?.googleTileset || gev.tileset || null;
|
||||
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
||||
if (!gev.viewer) return {
|
||||
tilesSettled: null, tileSettleApplicable: false, activeStack,
|
||||
reason: 'Navara exposes view idle, not tile readiness independently of animated layers',
|
||||
};
|
||||
return {
|
||||
tilesSettled: !applicable || tileset?.tilesLoaded === true,
|
||||
tileSettleApplicable: applicable,
|
||||
@@ -142,6 +153,10 @@ async function awaitTilesSettled(page, scenario) {
|
||||
// the bootstrap field is retained only as a compatibility fallback.
|
||||
const tileset = controller?.googleTileset || gev.tileset || null;
|
||||
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
||||
if (!gev.viewer) return {
|
||||
tilesSettled: null, tileSettleApplicable: false, activeStack,
|
||||
reason: 'Navara exposes view idle, not tile readiness independently of animated layers',
|
||||
};
|
||||
if (!applicable) {
|
||||
return {
|
||||
tilesSettled: true,
|
||||
@@ -166,7 +181,8 @@ async function awaitTilesSettled(page, scenario) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
gev.viewer.scene.render(gev.viewer.clock.currentTime);
|
||||
if (gev.viewer) gev.viewer.scene.render(gev.viewer.clock.currentTime);
|
||||
else gev.engine.render.request('focus-evidence');
|
||||
if (tileset.tilesLoaded === true || allTilesLoadedObserved) {
|
||||
return {
|
||||
tilesSettled: true,
|
||||
@@ -203,7 +219,7 @@ async function capture(page, scenario, frame) {
|
||||
// WebGL surface is paused. Reading the explicitly rendered canvas captures
|
||||
// that same frame without handing frame ownership back to the browser.
|
||||
const dataUrl = await page.evaluate(() => (
|
||||
window.__godsEyeView.viewer.scene.canvas.toDataURL('image/png')
|
||||
window.__godsEyeView.engine.host.canvas.toDataURL('image/png')
|
||||
));
|
||||
fs.writeFileSync(out, Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'));
|
||||
return { file: out, ...tileReadiness };
|
||||
@@ -232,6 +248,14 @@ async function advanceEvidenceFrame(page, deltaMs = FRAME_MS) {
|
||||
const viewer = gev.viewer;
|
||||
const seam = gev.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
const nowMs = seam.advanceFrameClock(stepMs);
|
||||
if (!viewer) return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { remove(); reject(new Error('Evidence frame timeout')); }, 5000);
|
||||
const remove = gev.engine.frames.postRender.addEventListener(() => {
|
||||
remove(); clearTimeout(timer);
|
||||
setTimeout(() => resolve(nowMs), 0);
|
||||
});
|
||||
gev.engine.render.request('focus-evidence');
|
||||
});
|
||||
const JulianDate = viewer.clock.currentTime.constructor;
|
||||
JulianDate.addSeconds(viewer.clock.currentTime, stepMs / 1000, viewer.clock.currentTime);
|
||||
viewer.dataSourceDisplay?.update(viewer.clock.currentTime);
|
||||
@@ -266,6 +290,7 @@ async function installSyntheticFetches(page) {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input?.url || '';
|
||||
if (url.includes('/api/flights/track')) return json({ path: [] });
|
||||
@@ -281,9 +306,10 @@ async function installSyntheticFetches(page) {
|
||||
async function waitForApp(page) {
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
|
||||
() => Boolean(window.__godsEyeView?.layers && window.__godsEyeView?.dataManager),
|
||||
{ timeout: 60_000, polling: 200 },
|
||||
);
|
||||
await page.evaluate(async () => { window.__focusGeo = await import('/src/geo/index.js'); });
|
||||
}
|
||||
|
||||
async function setBasemap(page, stackId) {
|
||||
@@ -317,7 +343,18 @@ async function requireEvidenceSeams(page) {
|
||||
|
||||
async function setCamera(page, values) {
|
||||
await page.evaluate(([lon, lat, height, heading, pitch]) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const app = window.__godsEyeView;
|
||||
if (!app.viewer) {
|
||||
const geo = window.__focusGeo;
|
||||
app.engine.camera.following()?.stop();
|
||||
app.engine.camera.cancelFlight();
|
||||
app.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(lon, lat, height),
|
||||
orientation: { heading, pitch, roll: 0 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const viewer = app.viewer;
|
||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
viewer.trackedEntity = undefined;
|
||||
viewer.camera.cancelFlight?.();
|
||||
@@ -345,7 +382,7 @@ async function injectAndTrackTarget(page, extraAircraft = [], target = TARGET) {
|
||||
throw new Error(`Synthetic target setup failed: ${JSON.stringify(result)}`);
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__godsEyeView.viewer.trackedEntity),
|
||||
() => Boolean(window.__godsEyeView.engine.camera.following()),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await advanceEvidenceDuration(page, 400);
|
||||
@@ -356,7 +393,8 @@ async function readTrackedBasis(page) {
|
||||
const gev = window.__godsEyeView;
|
||||
const layer = gev.dataManager.layers.get('flights').module;
|
||||
const target = layer.getAllPositions(100).find((entry) => entry.id === targetId)?.position;
|
||||
const camera = gev.viewer.camera;
|
||||
const pose = gev.engine.camera.pose();
|
||||
const camera = gev.viewer?.camera ?? { positionWC: pose.position, rightWC: pose.right, upWC: pose.up, frustum: { fovy: pose.fovy } };
|
||||
if (!target) throw new Error('Tracked target position unavailable');
|
||||
const toTarget = {
|
||||
x: target.x - camera.positionWC.x,
|
||||
@@ -364,7 +402,7 @@ async function readTrackedBasis(page) {
|
||||
z: target.z - camera.positionWC.z,
|
||||
};
|
||||
const distance = Math.hypot(toTarget.x, toTarget.y, toTarget.z);
|
||||
const canvasHeight = gev.viewer.scene.canvas.clientHeight;
|
||||
const canvasHeight = gev.engine.host.size().height;
|
||||
const metresPerPixel = (2 * distance * Math.tan(camera.frustum.fovy / 2)) / canvasHeight;
|
||||
return {
|
||||
target: [target.x, target.y, target.z],
|
||||
@@ -427,8 +465,8 @@ async function vesselRowsAroundTarget(page) {
|
||||
const gev = window.__godsEyeView;
|
||||
const layer = gev.dataManager.layers.get('flights').module;
|
||||
const target = layer.getAllPositions(100).find((entry) => entry.id === 'f0c001')?.position;
|
||||
const Cartographic = gev.viewer.camera.positionCartographic.constructor;
|
||||
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
||||
const Cartographic = { fromCartesian: window.__focusGeo.cartographic.fromCartesian };
|
||||
const ellipsoid = window.__focusGeo.ellipsoid.WGS84;
|
||||
const rows = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const angle = (i / 12) * Math.PI * 2;
|
||||
@@ -624,7 +662,9 @@ async function browserContext(page, browser) {
|
||||
},
|
||||
caveat: HEADFUL
|
||||
? 'Headful capture omits SwiftShader flags and is the real-GPU visual sign-off path.'
|
||||
: 'SwiftShader/headless output is relative-only; rerun with --headful for real-GPU sign-off.',
|
||||
: process.env.QA_ANGLE === 'metal'
|
||||
? 'Headless ANGLE/Metal capture; renderer and DPR recorded above.'
|
||||
: 'SwiftShader/headless output is relative-only.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -641,7 +681,7 @@ async function main() {
|
||||
headless: HEADFUL ? false : 'new',
|
||||
protocolTimeout: 300000,
|
||||
args: [
|
||||
...(HEADFUL ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||
...(HEADFUL ? [] : process.env.QA_ANGLE ? ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE}`] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
@@ -655,10 +695,10 @@ async function main() {
|
||||
const consoleMessages = [];
|
||||
page.on('console', (message) => {
|
||||
if (['error', 'warning', 'warn'].includes(message.type())) {
|
||||
consoleMessages.push({ type: message.type(), text: message.text() });
|
||||
consoleMessages.push({ type: message.type(), text: redact(message.text()) });
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message }));
|
||||
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: redact(error.message) }));
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
|
||||
+57
-24
@@ -99,6 +99,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { installNavaraHeadingProbe } from './qa-heading-navara.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Args (same shape as track-regression.mjs)
|
||||
@@ -110,7 +111,13 @@ const getOpt = (name, dflt) => {
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const appUrl = new URL(getOpt('--url', 'http://localhost:4173'));
|
||||
const requestedEngine = argv.find((value) => value.startsWith('--engine='))?.slice(9) || getOpt('--engine');
|
||||
if (requestedEngine) {
|
||||
if (!['cesium', 'navara'].includes(requestedEngine)) throw new Error('Unknown engine');
|
||||
appUrl.searchParams.set('engine', requestedEngine);
|
||||
}
|
||||
const APP_URL = appUrl.href;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/b3');
|
||||
const SHOT_DIR_V2 = path.resolve('qa-shots/heading-v2');
|
||||
@@ -148,7 +155,7 @@ function record(name, ok, detail) {
|
||||
|
||||
async function observeTrackedHostPaint(page, timeoutMs = 5000) {
|
||||
return page.evaluate((boundedTimeoutMs) => new Promise((resolve) => {
|
||||
const viewer = window.__godsEyeView?.viewer;
|
||||
const viewer = (window.__headingView ?? window.__godsEyeView?.viewer);
|
||||
const scene = viewer?.scene;
|
||||
const history = [];
|
||||
const startedAt = performance.now();
|
||||
@@ -453,7 +460,7 @@ async function main() {
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
...(HEADFUL ? [] : ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`]),
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
@@ -480,6 +487,7 @@ async function main() {
|
||||
|
||||
// ---- Turning-plane fetch shim (installed before any app code runs) ------
|
||||
await page.evaluateOnNewDocument((turn) => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__TURN = turn;
|
||||
window.__TURN.epochMs = Date.now(); // arc phase zero = shim install time
|
||||
window.__TURN_HITS = { opensky: 0, mil: 0 };
|
||||
@@ -574,9 +582,12 @@ async function main() {
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
() => window.__godsEyeView?.layers && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise);
|
||||
await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 });
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
|
||||
console.log(' App globals ready.');
|
||||
|
||||
// ---- Model finder + course derivation, installed once ------------------
|
||||
@@ -589,7 +600,7 @@ async function main() {
|
||||
// ~90° course spike into the sampled series — a measurement flake, not
|
||||
// a product regression.)
|
||||
window.__findTrackedModel = function (icao) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
@@ -625,7 +636,7 @@ async function main() {
|
||||
// Invert _modelMatrix (pitch=roll=0): local x in ENU = (cos h, −sin h, 0)
|
||||
// → h = atan2(−x·north, x·east); world course = h − headingOffsetDeg.
|
||||
window.__courseFromModelMatrix = function (mm, headingOffsetDeg) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const C3 = v.camera.position.constructor;
|
||||
const Carto = v.camera.positionCartographic.constructor;
|
||||
const carto = Carto.fromCartesian(new C3(mm[12], mm[13], mm[14]));
|
||||
@@ -646,7 +657,7 @@ async function main() {
|
||||
// preUpdate/preRender listeners and reads the rendered model matrix, but
|
||||
// does not depend on ambient render-loop scheduling for sample count.
|
||||
window.__sampleModelCourse = async function (icao, headingOffsetDeg, windowMs, stepMs = 100) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const tileset = window.__godsEyeView.tileset;
|
||||
const out = [];
|
||||
const start = performance.now();
|
||||
@@ -685,13 +696,15 @@ async function main() {
|
||||
};
|
||||
});
|
||||
|
||||
await installNavaraHeadingProbe(page);
|
||||
|
||||
// ---- Enable layers with fixes back-dated 32 s, then prime forward ------
|
||||
// First update() fires inside setEnabled, so the FIRST fix must already be
|
||||
// the oldest (history appends only monotonically-newer fix times).
|
||||
console.log('Priming turning history through the render delay (30 s / 15 s)...');
|
||||
const primed = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
window.__TURN.timeOffsetSec = -32;
|
||||
await dm.setEnabled('flights', true);
|
||||
await dm.setEnabled('military', true);
|
||||
@@ -726,7 +739,10 @@ async function main() {
|
||||
|
||||
const flModelUp = await page.waitForFunction((icao) => {
|
||||
const m = window.__findTrackedModel(icao);
|
||||
return !!(m && m.ready && m.show);
|
||||
const app = window.__godsEyeView;
|
||||
const selected = app.dataManager.layers.get('flights').module.getTrackedInfo();
|
||||
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
|
||||
return !!(m && m.ready && m.show && selected?.icao24 === icao && following);
|
||||
}, { timeout: 40000, polling: 250 }, TURN.flights[0].icao).then(() => true).catch(() => false);
|
||||
record('flights: tracked 3D model rendered (ready+shown)', flModelUp, flModelUp ? 'model up' : 'model never became ready');
|
||||
if (!flModelUp) { finish(); return; }
|
||||
@@ -783,14 +799,14 @@ async function main() {
|
||||
await sleep(800);
|
||||
const shots = [
|
||||
['flights-far-orbitA.png', async () => { /* default follow framing */ }],
|
||||
['flights-far-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateRight(1.9); })],
|
||||
['flights-far-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.9); })],
|
||||
['flights-near-orbitA.png', () => page.evaluate(() => {
|
||||
const cam = window.__godsEyeView.viewer.camera;
|
||||
const cam = (window.__headingView ?? window.__godsEyeView.viewer).camera;
|
||||
// Synthetic cruise frame settles ~5.6 km out; leave ~430 m so model,
|
||||
// readout anchor, tracking line, and trail can be judged close-up.
|
||||
cam.rotateRight(-1.9); cam.zoomIn(5200);
|
||||
})],
|
||||
['flights-near-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateLeft(1.2); })],
|
||||
['flights-near-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateLeft(1.2); })],
|
||||
];
|
||||
for (const [name, move] of shots) {
|
||||
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
|
||||
@@ -812,7 +828,10 @@ async function main() {
|
||||
|
||||
const milModelUp = await page.waitForFunction((hex) => {
|
||||
const m = window.__findTrackedModel(hex);
|
||||
return !!(m && m.ready && m.show);
|
||||
const app = window.__godsEyeView;
|
||||
const selected = app.dataManager.layers.get('military').module.getTrackedInfo();
|
||||
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
|
||||
return !!(m && m.ready && m.show && selected?.icao24 === hex && following);
|
||||
}, { timeout: 40000, polling: 250 }, TURN.military[0].hex).then(() => true).catch(() => false);
|
||||
record('military: tracked 3D model rendered (ready+shown)', milModelUp, milModelUp ? 'model up' : 'model never became ready');
|
||||
if (milModelUp) {
|
||||
@@ -841,14 +860,14 @@ async function main() {
|
||||
await sleep(800);
|
||||
for (const [name, move] of [
|
||||
['military-orbitA.png', async () => { }],
|
||||
['military-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateRight(1.6); })],
|
||||
['military-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.6); })],
|
||||
['military-near-orbitA.png', () => page.evaluate(() => {
|
||||
const cam = window.__godsEyeView.viewer.camera;
|
||||
const cam = (window.__headingView ?? window.__godsEyeView.viewer).camera;
|
||||
// 12,000 ft synthetic frame settles ~6.3 km out; leave ~630 m.
|
||||
cam.rotateLeft(1.6); cam.zoomIn(5700);
|
||||
})],
|
||||
['military-near-orbitB.png', () => page.evaluate(() => {
|
||||
window.__godsEyeView.viewer.camera.rotateRight(1.2);
|
||||
(window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.2);
|
||||
})],
|
||||
]) {
|
||||
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
|
||||
@@ -877,7 +896,10 @@ async function main() {
|
||||
}, icao);
|
||||
const up = await page.waitForFunction((id) => {
|
||||
const m = window.__findTrackedModel(id);
|
||||
return !!(m && m.ready && m.show);
|
||||
const app = window.__godsEyeView;
|
||||
const selected = app.dataManager.layers.get('flights').module.getTrackedInfo();
|
||||
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
|
||||
return !!(m && m.ready && m.show && selected?.icao24 === id && following);
|
||||
}, { timeout: 40000, polling: 250 }, icao).then(() => true).catch(() => false);
|
||||
record(`${label}: tracked 3D model rendered (ready+shown)`, up, up ? 'model up' : 'model never became ready');
|
||||
return up;
|
||||
@@ -914,7 +936,7 @@ async function main() {
|
||||
});
|
||||
for (const [name, move] of [
|
||||
['slowturn-far.png', async () => { /* default follow framing */ }],
|
||||
['slowturn-near.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.zoomIn(2500); })],
|
||||
['slowturn-near.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.zoomIn(2500); })],
|
||||
]) {
|
||||
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
|
||||
await sleep(1200);
|
||||
@@ -930,8 +952,9 @@ async function main() {
|
||||
// ========================================================================
|
||||
console.log('\nPhase 5 — 65 kt helicopter: tracked↔fleet course handoff (click / click-away)');
|
||||
await page.evaluate(() => {
|
||||
if (window.__headingView) { window.__findModelByIcao = window.__findTrackedModel; return; }
|
||||
window.__findModelByIcao = function (icao) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
@@ -957,7 +980,7 @@ async function main() {
|
||||
window.__TURN.heli65.active = true;
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
fl.setParams({ models3d: false });
|
||||
await fl.update(window.__godsEyeView.viewer); // ingest the first heli fix
|
||||
await fl.update((window.__headingView ?? window.__godsEyeView.viewer)); // ingest the first heli fix
|
||||
});
|
||||
await sleep(4000); // several fleet ticks establish the heli's fleet course
|
||||
await page.evaluate((icao) => {
|
||||
@@ -966,7 +989,7 @@ async function main() {
|
||||
const heliTracked = await page.waitForFunction((icao) => {
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const ti = fl.getTrackedInfo();
|
||||
return !!(ti && ti.icao24 === icao && window.__godsEyeView.viewer.trackedEntity);
|
||||
return !!(ti && ti.icao24 === icao && (window.__headingView ?? window.__godsEyeView.viewer).trackedEntity);
|
||||
}, { timeout: 15000, polling: 250 }, TURN.heli65.icao).then(() => true).catch(() => false);
|
||||
record('heli65: tracking engaged (2D billboard mode)', heliTracked,
|
||||
heliTracked ? 'tracked' : 'tracking never engaged');
|
||||
@@ -983,9 +1006,10 @@ async function main() {
|
||||
// rotation after. The release-in-place keeps the camera static, so
|
||||
// rotation deltas across the boundary are course deltas.
|
||||
const c1 = await page.evaluate(async ({ icao, actionAtMs, windowMs }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const findFleetBillboard = (id) => {
|
||||
if (window.__headingView) return window.__godsEyeView.layers.drawn('flights').find((row) => row.id === id && row.show);
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
@@ -1037,7 +1061,16 @@ async function main() {
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.setParams({ models3d: true });
|
||||
});
|
||||
const fleetModelUp = await page.evaluate(async (icao) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
if (window.__headingView) {
|
||||
const deadline = performance.now() + 40000;
|
||||
while (performance.now() < deadline) {
|
||||
if (window.__findModelByIcao(icao)) return true;
|
||||
window.__godsEyeView.engine.render.request('heading-probe');
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const tileset = window.__godsEyeView.tileset;
|
||||
const priorDefaultLoop = v.useDefaultRenderLoop;
|
||||
const priorTilesetShow = tileset?.show;
|
||||
@@ -1061,7 +1094,7 @@ async function main() {
|
||||
record('heli65 C2 (click): fleet model rendered before re-track', false, 'fleet model never became ready');
|
||||
} else {
|
||||
const c2 = await page.evaluate(async ({ icao, offsetDeg, actionAtMs, windowMs }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const out = [];
|
||||
const start = performance.now();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Navara ports for the existing heading scenarios. No app viewer is installed.
|
||||
// Samples come from the renderer handles through the read-only layer probe.
|
||||
export async function installNavaraHeadingProbe(page) {
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
if (app.engine.kind !== 'navara') return;
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const engine = app.engine;
|
||||
const drawn = (id) =>
|
||||
['flights', 'military']
|
||||
.flatMap((layer) => app.layers.drawn(layer) ?? [])
|
||||
.filter((row) => row.id === id);
|
||||
window.__findTrackedModel = (id) => {
|
||||
const rows = drawn(id);
|
||||
const row =
|
||||
rows.find((r) => r.trackedModel && r.model?.ready && r.model.show) ??
|
||||
rows.find((r) => r.model?.ready && r.model.show);
|
||||
return row
|
||||
? {
|
||||
id,
|
||||
ready: row.model.ready,
|
||||
show: row.model.show,
|
||||
modelMatrix: row.model.matrix,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
window.__courseFromModelMatrix = (mm, offset) => {
|
||||
if (!mm) return null;
|
||||
const c = geo.cartographic.fromCartesian({
|
||||
x: mm[12],
|
||||
y: mm[13],
|
||||
z: mm[14],
|
||||
});
|
||||
const ex = -Math.sin(c.longitude),
|
||||
ey = Math.cos(c.longitude);
|
||||
const nx = -Math.sin(c.latitude) * Math.cos(c.longitude);
|
||||
const ny = -Math.sin(c.latitude) * Math.sin(c.longitude),
|
||||
nz = Math.cos(c.latitude);
|
||||
const h =
|
||||
(Math.atan2(
|
||||
-(mm[0] * nx + mm[1] * ny + mm[2] * nz),
|
||||
mm[0] * ex + mm[1] * ey,
|
||||
) *
|
||||
180) /
|
||||
Math.PI;
|
||||
return (((h - offset) % 360) + 360) % 360;
|
||||
};
|
||||
const turn = (angle = 0, nearer = 0) => {
|
||||
const handle = engine.camera.following();
|
||||
const target = handle?.target();
|
||||
if (!target)
|
||||
throw new Error('Heading screenshot needs a followed target');
|
||||
const frame = geo.transforms.eastNorthUpToFixedFrame(target);
|
||||
const inverse = geo.matrix4.inverseTransformation(
|
||||
frame,
|
||||
geo.matrix4.create(),
|
||||
);
|
||||
const p = geo.matrix4.multiplyByPoint(
|
||||
inverse,
|
||||
engine.camera.pose().position,
|
||||
geo.cartesian3.create(),
|
||||
);
|
||||
const x = p.x * Math.cos(angle) - p.y * Math.sin(angle);
|
||||
const y = p.x * Math.sin(angle) + p.y * Math.cos(angle);
|
||||
const range = Math.hypot(x, y, p.z);
|
||||
const scale = Math.max(150, range - nearer) / range;
|
||||
handle.retarget({
|
||||
viewFrom: { x: x * scale, y: y * scale, z: p.z * scale },
|
||||
});
|
||||
};
|
||||
window.__headingView = {
|
||||
clock: { currentTime: undefined },
|
||||
get trackedEntity() {
|
||||
const handle = engine.camera.following();
|
||||
if (!handle) return null;
|
||||
const label = handle.label;
|
||||
const id = label?.id?.split(':').at(-1);
|
||||
return {
|
||||
gevTrackedId: label?.id,
|
||||
gevLabelModel:
|
||||
typeof label?.model === 'function' ? label.model() : label?.model,
|
||||
gevDisplayPosition: handle.target,
|
||||
billboard: {
|
||||
rotation: {
|
||||
getValue: () => drawn(id).find((r) => r.tracked)?.trackedRotation,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
camera: {
|
||||
rotateRight: (a) => turn(a),
|
||||
rotateLeft: (a) => turn(-a),
|
||||
zoomIn: (m) => turn(0, m),
|
||||
},
|
||||
scene: {
|
||||
preRender: engine.frames.preRender,
|
||||
postRender: engine.frames.postRender,
|
||||
requestRender: () => engine.render.request('heading-probe'),
|
||||
},
|
||||
};
|
||||
// Observe real Navara frames; unlike Cesium this port does not own a
|
||||
// manual viewer loop. The 600 ms case samples at that cadence while
|
||||
// Navara continues rendering; the dt-clamp branch is covered by unit QA.
|
||||
window.__sampleModelCourse = (id, offset, windowMs, stepMs = 100) =>
|
||||
new Promise((resolve) => {
|
||||
const samples = [];
|
||||
let last = -Infinity;
|
||||
const remove = engine.frames.preRender.addEventListener(() => {
|
||||
const now = performance.now();
|
||||
if (now - last < stepMs) return;
|
||||
last = now;
|
||||
const model = window.__findTrackedModel(id);
|
||||
samples.push({
|
||||
tMs: now,
|
||||
epochMs: Date.now(),
|
||||
course: model
|
||||
? window.__courseFromModelMatrix(model.modelMatrix, offset)
|
||||
: null,
|
||||
});
|
||||
});
|
||||
setTimeout(() => {
|
||||
remove();
|
||||
resolve(samples);
|
||||
}, windowMs);
|
||||
engine.render.request('heading-probe');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -213,7 +213,8 @@ async function readCameraGround(page, camId) {
|
||||
* @returns {Promise<number|null>} ellipsoid height in metres, or null on failure.
|
||||
*/
|
||||
async function fetchReearthEllipsoid(lat, lon) {
|
||||
const url = `${APP_URL}/api/terrain/heights?points=${encodeURIComponent(`${lon.toFixed(5)},${lat.toFixed(5)}`)}`;
|
||||
const url = new URL('/api/terrain/heights', APP_URL);
|
||||
url.searchParams.set('points', `${lon.toFixed(5)},${lat.toFixed(5)}`);
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||||
if (!res.ok) return null;
|
||||
@@ -253,7 +254,7 @@ async function main() {
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
@@ -279,7 +280,7 @@ async function main() {
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
() => window.__godsEyeView?.layers && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
await sleep(4000);
|
||||
@@ -287,6 +288,10 @@ async function main() {
|
||||
// =========================================================================
|
||||
// Group 1: CCTV ground correctness
|
||||
// =========================================================================
|
||||
const hasCctv = await page.evaluate(() => window.__godsEyeView.layers.available('cctv'));
|
||||
let allCameras = [], londonCams = [], austinCams = [];
|
||||
if (!hasCctv) console.log(' [SKIP] CCTV ground/regime groups are outside the Navara W1 catalog');
|
||||
if (hasCctv) {
|
||||
console.log('Enabling CCTV layer (full city-packs catalog)...');
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
@@ -337,7 +342,7 @@ async function main() {
|
||||
// `nearbyPlace` string (NOT a stable "San Francisco" literal), so SF-area
|
||||
// Caltrans cameras are identified by proximity to the SF anchor instead
|
||||
// (same anchor vite.config.js's CALTRANS_ANCHORS uses for prioritization).
|
||||
const allCameras = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().cameras);
|
||||
allCameras = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().cameras);
|
||||
console.log(`Camera catalog: ${allCameras.length} total.`);
|
||||
|
||||
const SF_ANCHOR = { lat: 37.7793, lon: -122.4193 };
|
||||
@@ -349,8 +354,8 @@ async function main() {
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
};
|
||||
|
||||
const londonCams = allCameras.filter((c) => c.city === 'London').slice(0, 5);
|
||||
const austinCams = allCameras.filter((c) => c.city === 'Austin').slice(0, 5);
|
||||
londonCams = allCameras.filter((c) => c.city === 'London').slice(0, 5);
|
||||
austinCams = allCameras.filter((c) => c.city === 'Austin').slice(0, 5);
|
||||
const sfCams = allCameras
|
||||
.filter((c) => c.provider === 'Caltrans' && haversineKm(c.lat, c.lon, SF_ANCHOR.lat, SF_ANCHOR.lon) < 60)
|
||||
.slice(0, 3);
|
||||
@@ -456,6 +461,8 @@ async function main() {
|
||||
record('Austin active camera google-3d ground ∈ [110,135]m', null, 'no Austin camera available to test');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Group 2: Aircraft render altitude
|
||||
// =========================================================================
|
||||
@@ -511,7 +518,7 @@ async function main() {
|
||||
// returns everything currently shown regardless of viewer position.
|
||||
const nearbyList = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const center = window.__godsEyeView.viewer.camera.position;
|
||||
const center = window.__godsEyeView.engine.camera.livePosition;
|
||||
return mod.getNearby(center, Number.MAX_VALUE, 200).map((a) => ({ icao24: a.icao24, altitudeM: a.altitudeM }));
|
||||
});
|
||||
const baroById = new Map(nearbyList.map((a) => [a.icao24, a.altitudeM]));
|
||||
@@ -626,6 +633,7 @@ async function main() {
|
||||
// =========================================================================
|
||||
// Group 3: Regime C re-resolve
|
||||
// =========================================================================
|
||||
if (hasCctv) {
|
||||
console.log('Switching map stack to keyless OSM (regime C)...');
|
||||
// Grab a London camera's ground BEFORE the switch (still whatever regime
|
||||
// we were in — Google-3D, given the app's default).
|
||||
@@ -694,6 +702,8 @@ async function main() {
|
||||
record('CCTV grounds re-resolved for regime C', null, 'no test camera available');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Screenshot for human visual review (not a pass/fail gate).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -245,7 +245,7 @@ async function main() {
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox',
|
||||
'--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
|
||||
@@ -234,7 +234,7 @@ async function main() {
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
|
||||
@@ -655,9 +655,9 @@ async function prepareMission(page) {
|
||||
});
|
||||
if (!selected) return 'Mission roster did not expose a selectable mission';
|
||||
try {
|
||||
await page.waitForFunction(() => String(window.__godsEyeView.viewer?.selectedEntity?.id || '').startsWith('rocket-launch:'), { timeout: 10_000 });
|
||||
await page.waitForFunction(() => Boolean(window.__godsEyeView.layers.drawn('rocket-launches')?.selected), { timeout: 10_000 });
|
||||
} catch {
|
||||
return 'Mission selection did not reach the Cesium selected entity';
|
||||
return 'Mission selection did not reach the layer probe';
|
||||
}
|
||||
await sleep(2_000);
|
||||
return null;
|
||||
@@ -897,7 +897,7 @@ async function main() {
|
||||
executablePath: CHROME_EXECUTABLE,
|
||||
headless: HEADFUL ? false : 'new',
|
||||
args: [
|
||||
...(HARDWARE_GPU ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||
...(process.env.QA_ANGLE ? ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE}`] : HARDWARE_GPU ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
@@ -950,9 +950,11 @@ async function main() {
|
||||
webglRenderer: browserContext.webglRenderer,
|
||||
},
|
||||
viewport: { ...VIEWPORT, dpr: browserContext.dpr },
|
||||
gpuMode: HARDWARE_GPU ? 'hardware' : 'swiftshader',
|
||||
gpuMode: process.env.QA_ANGLE || (HARDWARE_GPU ? 'hardware' : 'swiftshader'),
|
||||
chromeExecutable: CHROME_EXECUTABLE,
|
||||
chromeArgs: HARDWARE_GPU
|
||||
chromeArgs: process.env.QA_ANGLE
|
||||
? ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE}`, '--no-sandbox']
|
||||
: HARDWARE_GPU
|
||||
? ['--no-sandbox']
|
||||
: ['--enable-unsafe-swiftshader', '--use-gl=swiftshader', '--no-sandbox'],
|
||||
sampleMs: SAMPLE_MS,
|
||||
|
||||
@@ -102,6 +102,7 @@ const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
...(process.env.QA_ANGLE ? [`--use-angle=${process.env.QA_ANGLE}`] : []),
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--window-size=1440,900',
|
||||
|
||||
@@ -179,7 +179,7 @@ async function main() {
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
@@ -206,6 +206,7 @@ async function main() {
|
||||
|
||||
// ---- Straight-flight fetch shim (installed before any app code runs) ----
|
||||
await page.evaluateOnNewDocument((spec) => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__SPR = spec;
|
||||
window.__SPR.epochMs = Date.now();
|
||||
window.__SPR_HITS = { opensky: 0, mil: 0 };
|
||||
@@ -284,6 +285,9 @@ async function main() {
|
||||
() => window.__godsEyeView && window.__godsEyeView.engine && window.__godsEyeView.dataManager && window.__godsEyeView.layers,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise);
|
||||
await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 });
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
|
||||
console.log(' App globals ready.');
|
||||
|
||||
// ---- In-page probes: billboard walk, model walk, tracked-model finder ---
|
||||
@@ -292,7 +296,7 @@ async function main() {
|
||||
window.__collectBillboards = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
if (!v)
|
||||
return (window.__godsEyeView.layers.drawn('flights') ?? [])
|
||||
return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? [])
|
||||
.filter((row) => !row.trackedModel)
|
||||
.map(({ id, image, scale, show }) => ({ id, image, scale, show }));
|
||||
const out = [];
|
||||
@@ -315,7 +319,7 @@ async function main() {
|
||||
window.__collectModels = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
if (!v)
|
||||
return (window.__godsEyeView.layers.drawn('flights') ?? [])
|
||||
return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? [])
|
||||
.filter((row) => row.model && (row.trackedModel || !row.tracked))
|
||||
.map(({ id, model }) => ({ id, ...model, radius: null }));
|
||||
const out = [];
|
||||
@@ -396,16 +400,16 @@ async function main() {
|
||||
let scaleBad = [];
|
||||
for (const [id, klass] of expectedFlights) {
|
||||
const bb = bbById.get(id);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantImg = [aircraftIcon(klass), aircraftIcon(klass, 192)];
|
||||
const wantScale = CLASS_SCALE_2D[klass] || 1;
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (!bb || !wantImg.includes(bb.image)) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`);
|
||||
}
|
||||
for (const [id, klass] of hasMilitary ? expectedMilitary : []) {
|
||||
const bb = bbById.get(id);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantImg = [aircraftIcon(klass), aircraftIcon(klass, 192)];
|
||||
const wantScale = MIL_BILLBOARD_SCALE * (CLASS_SCALE_2D[klass] || 1);
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (!bb || !wantImg.includes(bb.image)) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`);
|
||||
}
|
||||
record('B2a: every synthetic billboard has its class glyph (data-URI match)',
|
||||
|
||||
@@ -73,6 +73,40 @@ export function parseDataMode(args = []) {
|
||||
return mode;
|
||||
}
|
||||
|
||||
/** A synthetic card capture owns its source instead of competing with qaFeed. */
|
||||
export function vesselCardCaptureUrl(pageUrl, dataMode) {
|
||||
const url = new URL(pageUrl);
|
||||
if (dataMode === 'synthetic') url.searchParams.delete('qaFeed');
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/** Install before navigation; every asynchronous refresh keeps the same rows. */
|
||||
export function installSyntheticVesselFeed(rows) {
|
||||
const realFetch = window.fetch.bind(window);
|
||||
window.fetch = (input, init) => {
|
||||
const url = new URL(
|
||||
typeof input === 'string' ? input : (input.url ?? input),
|
||||
location.href,
|
||||
);
|
||||
if (url.origin === location.origin && url.pathname === '/api/vessels') {
|
||||
const nowMs = Date.now();
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
rows: rows.map((row) => ({
|
||||
...row,
|
||||
last_position_epoch: nowMs / 1000,
|
||||
})),
|
||||
newestPositionAt: nowMs,
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
}
|
||||
return realFetch(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a screenshot name whose data provenance cannot collide with another mode. */
|
||||
export function vesselCardScreenshotFilename({ dataMode, tag, portKey }) {
|
||||
const provenanceSlug = DATA_PROVENANCE_SLUGS[dataMode];
|
||||
@@ -262,22 +296,25 @@ async function main() {
|
||||
}));
|
||||
console.log(` ▸ ${key} → ${outPath}`);
|
||||
|
||||
const syntheticRows = DATA_MODE === 'synthetic' ? syntheticVesselRows(key, port) : [];
|
||||
const captureUrl = vesselCardCaptureUrl(PAGE_URL, DATA_MODE);
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
// This harness inspects cards, so keep first-run chrome out of the view.
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
});
|
||||
if (DATA_MODE === 'synthetic')
|
||||
await page.evaluateOnNewDocument(installSyntheticVesselFeed, syntheticRows);
|
||||
page.on('pageerror', (err) => console.error(` [page-error] ${err.message}`));
|
||||
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.goto(captureUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
if (ENGINE)
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.layers, { timeout: 180000 });
|
||||
else
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 60000 });
|
||||
|
||||
// Frame the port (kill the intro flight first) and enable the layer.
|
||||
const syntheticRows = DATA_MODE === 'synthetic' ? syntheticVesselRows(key, port) : [];
|
||||
const dataEvidence = await page.evaluate(async ({ p, dataMode, fixtureRows, engineNeutral }) => {
|
||||
const gev = window.__godsEyeView;
|
||||
if (engineNeutral) {
|
||||
@@ -431,7 +468,7 @@ async function main() {
|
||||
hardwareAccelerated: hardwareRenderer,
|
||||
passed: capturePassed,
|
||||
},
|
||||
appUrl: APP_URL,
|
||||
appUrl: captureUrl,
|
||||
capturedAt: new Date().toISOString(),
|
||||
});
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
|
||||
@@ -1305,7 +1305,8 @@ async function runBehaviorLayer() {
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
...(HEADFUL ? [] : ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`]),
|
||||
'--mute-audio',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
|
||||
@@ -88,8 +88,12 @@ export function createQaSources(entries, { now = () => Date.now() } = {}) {
|
||||
if (layer === 'flights')
|
||||
sources.flights = createFlightSource({
|
||||
now,
|
||||
fetchImpl: async () =>
|
||||
json(syntheticFlights({ nowMs: now(), count: count ?? 8000 })),
|
||||
fetchImpl: async (url) => {
|
||||
const request = String(url);
|
||||
if (request.includes('/api/adsbdb/')) return json({ found: false });
|
||||
if (request.includes('/api/flights/track')) return json({ path: [] });
|
||||
return json(syntheticFlights({ nowMs: now(), count: count ?? 8000 }));
|
||||
},
|
||||
});
|
||||
if (layer === 'military')
|
||||
sources.military = createMilitarySource({
|
||||
@@ -107,6 +111,8 @@ export function createQaSources(entries, { now = () => Date.now() } = {}) {
|
||||
if (layer === 'vessels')
|
||||
sources.vessels = createVesselSource({
|
||||
fetchImpl: async (url) => {
|
||||
if (String(url).includes('/api/vessels/track'))
|
||||
return json({ samples: [] });
|
||||
const params = new URL(url).searchParams;
|
||||
const area = params.has('lat')
|
||||
? {
|
||||
|
||||
@@ -86,3 +86,51 @@ test('local-adsb:fixture answers as the local receivers do (1090 MHz and UAT)',
|
||||
assert.ok(state.records.some((record) => record.band === '978'));
|
||||
assert.ok(state.records.some((record) => record.band === '1090'));
|
||||
});
|
||||
|
||||
test('flight fixture enrichment returns a not-found reply without regenerating the fleet', async () => {
|
||||
let clockReads = 0;
|
||||
const { flights } = createQaSources(
|
||||
parseQaFeed('?qaFeed=flights:synthetic:10000'),
|
||||
{
|
||||
now: () => {
|
||||
clockReads++;
|
||||
return Date.UTC(2026, 9, 16, 12);
|
||||
},
|
||||
},
|
||||
);
|
||||
for (const kind of ['type', 'route']) {
|
||||
const reply = await flights.getEnrichment({ kind, id: 'a00001' });
|
||||
assert.equal(reply.found, false);
|
||||
assert.deepEqual(Object.keys(reply), ['found']);
|
||||
}
|
||||
assert.equal(clockReads, 0);
|
||||
});
|
||||
|
||||
test('flight fixture track reads do not regenerate a snapshot', async () => {
|
||||
let clockReads = 0;
|
||||
const { flights } = createQaSources(
|
||||
parseQaFeed('?qaFeed=flights:synthetic:10000'),
|
||||
{
|
||||
now: () => {
|
||||
clockReads++;
|
||||
return Date.UTC(2026, 9, 16, 12);
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(await flights.getTrack('a00001'), {
|
||||
records: [],
|
||||
complete: false,
|
||||
});
|
||||
assert.equal(clockReads, 0);
|
||||
});
|
||||
|
||||
test('vessel fixture answers a relative track URL and retains its full snapshot', async () => {
|
||||
const { vessels } = createQaSources(parseQaFeed('?qaFeed=vessels:fixture'), {
|
||||
now: () => Date.UTC(2026, 9, 16, 12),
|
||||
});
|
||||
assert.deepEqual(await vessels.getTrack('990000001'), {
|
||||
records: [],
|
||||
complete: false,
|
||||
});
|
||||
assert.equal((await vessels.getSnapshot({})).records.length, 12000);
|
||||
});
|
||||
@@ -1,309 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const ALPHA_STOPS = [
|
||||
[0, 0.8],
|
||||
[120000, 0.45],
|
||||
[600000, 0.2],
|
||||
[900000, 0.08],
|
||||
];
|
||||
export function trailAlpha(ageMs) {
|
||||
const stops = ALPHA_STOPS;
|
||||
for (let i = 1; i < stops.length; i++) {
|
||||
if (ageMs === stops[i][0]) return stops[i][1];
|
||||
if (ageMs < stops[i][0]) {
|
||||
const [a, x] = stops[i - 1],
|
||||
[b, y] = stops[i];
|
||||
return x + ((y - x) * Math.max(0, ageMs - a)) / (b - a);
|
||||
}
|
||||
}
|
||||
return 0.08;
|
||||
}
|
||||
|
||||
/** One static history batch and one frequently updated head. Never entities. */
|
||||
export function createContactTrailRenderer(scene) {
|
||||
const ground =
|
||||
!!scene.frameState?.context &&
|
||||
Cesium.GroundPolylinePrimitive.isSupported(scene);
|
||||
const collection = ground ? scene.groundPrimitives : scene.primitives;
|
||||
// One 25 m corridor is clipped in material coordinates. Only subdivision
|
||||
// crossings build geometry; frame updates write a scalar, never vertex buffers.
|
||||
const headMaterial = (alpha) =>
|
||||
new Cesium.Material({
|
||||
fabric: {
|
||||
type: 'TransitTrailHead',
|
||||
uniforms: {
|
||||
color: new Cesium.Color(0.37, 0.94, 0.54, alpha),
|
||||
fraction: 0,
|
||||
backing: true,
|
||||
},
|
||||
source: `czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
if (materialInput.st.s > fraction) discard;
|
||||
czm_material m = czm_getDefaultMaterial(materialInput);
|
||||
bool edge = abs(materialInput.st.t - 0.5) > 0.3;
|
||||
m.diffuse = czm_gammaCorrect(vec4(edge && backing ? vec3(0.02, 0.03, 0.05) : color.rgb, 1.0)).rgb;
|
||||
m.alpha = color.a;
|
||||
return m;
|
||||
}`,
|
||||
},
|
||||
translucent: true,
|
||||
});
|
||||
const material = headMaterial(0.8),
|
||||
depthMaterial = headMaterial(0.55);
|
||||
depthMaterial.uniforms.backing = false;
|
||||
const head = { positions: [], show: false, width: 3, material };
|
||||
const backing = { positions: [], show: false, width: 5 };
|
||||
let headPrimitive = null,
|
||||
headRebuilds = 0;
|
||||
function geometry(positions, width, material = false) {
|
||||
return ground
|
||||
? new Cesium.GroundPolylineGeometry({ positions, width, granularity: 0 })
|
||||
: new Cesium.PolylineGeometry({
|
||||
positions,
|
||||
width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: material
|
||||
? Cesium.PolylineMaterialAppearance.VERTEX_FORMAT
|
||||
: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
});
|
||||
}
|
||||
function primitive(instances, appearance, depthFailAppearance) {
|
||||
return ground
|
||||
? new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
allowPicking: false,
|
||||
asynchronous: true,
|
||||
show: visible,
|
||||
})
|
||||
: new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
depthFailAppearance,
|
||||
allowPicking: false,
|
||||
asynchronous: false,
|
||||
show: visible,
|
||||
});
|
||||
}
|
||||
let body = null,
|
||||
segments = [],
|
||||
revision = -1,
|
||||
visible = true,
|
||||
style = '#5EF08A';
|
||||
let lastSecond = -1,
|
||||
completed = -1,
|
||||
active = null;
|
||||
const styleColor = Cesium.Color.fromCssColorString(style);
|
||||
const attributeIds = [];
|
||||
const color = new Cesium.Color(),
|
||||
endpoint = new Cesium.Cartesian3();
|
||||
const headPositions = [new Cesium.Cartesian3(), endpoint];
|
||||
let rebuilds = 0;
|
||||
function tint(alpha, dark = false) {
|
||||
if (dark) {
|
||||
color.red = 0.02;
|
||||
color.green = 0.03;
|
||||
color.blue = 0.05;
|
||||
} else Cesium.Color.clone(styleColor, color);
|
||||
color.alpha = alpha;
|
||||
return color;
|
||||
}
|
||||
function replaceHistory(next) {
|
||||
if (next.revision === revision) return;
|
||||
revision = next.revision;
|
||||
segments = next.segments;
|
||||
completed = -1;
|
||||
lastSecond = -1;
|
||||
active = null;
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = null;
|
||||
head.show = backing.show = false;
|
||||
if (body) collection.remove(body);
|
||||
body = null;
|
||||
const instances = [];
|
||||
attributeIds.length = 0;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
attributeIds.push([`${i}:0`, `${i}:1`]);
|
||||
if (segment.positions.length < 2) continue;
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
id: `${i}:${lane}`,
|
||||
geometry: geometry(segment.positions, lane ? 3 : 5),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.8, !lane),
|
||||
),
|
||||
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.55),
|
||||
),
|
||||
show: new Cesium.ShowGeometryInstanceAttribute(false),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (instances.length) {
|
||||
body = collection.add(
|
||||
primitive(
|
||||
instances,
|
||||
new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
),
|
||||
);
|
||||
rebuilds++;
|
||||
}
|
||||
}
|
||||
function setDisplaySample(sample, renderedPosition) {
|
||||
if (!sample || !renderedPosition) {
|
||||
head.show = false;
|
||||
backing.show = false;
|
||||
if (headPrimitive) headPrimitive.show = false;
|
||||
return;
|
||||
}
|
||||
const second = Math.floor(sample.displayT / 1000);
|
||||
let lo = 0,
|
||||
hi = segments.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (segments[mid].toT <= sample.displayT) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
const done = lo - 1;
|
||||
const candidate = segments[lo];
|
||||
const segment =
|
||||
candidate &&
|
||||
candidate.fromT <= sample.displayT &&
|
||||
candidate.fromSeq === sample.fromSeq &&
|
||||
candidate.toSeq === sample.toSeq
|
||||
? candidate
|
||||
: null;
|
||||
if (body?.ready && done !== completed) {
|
||||
for (
|
||||
let i = Math.min(done, completed) + 1;
|
||||
i <= Math.max(done, completed);
|
||||
i++
|
||||
) {
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
const attributes = body.getGeometryInstanceAttributes(
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
// Cesium getters return copies. Write back the array we changed.
|
||||
const show = attributes.show;
|
||||
show[0] = i <= done ? 1 : 0;
|
||||
attributes.show = show;
|
||||
}
|
||||
}
|
||||
completed = done;
|
||||
}
|
||||
if (body?.ready && second !== lastSecond) {
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const alpha = trailAlpha(sample.displayT - segments[i].toT);
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
const attributes = body.getGeometryInstanceAttributes(
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
const rgba = attributes.color;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(alpha, !lane),
|
||||
rgba,
|
||||
);
|
||||
attributes.color = rgba;
|
||||
if (!ground) {
|
||||
const depthRgba = attributes.depthFailColor;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(0.55),
|
||||
depthRgba,
|
||||
);
|
||||
attributes.depthFailColor = depthRgba;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastSecond = second;
|
||||
}
|
||||
const show = visible && !!segment && sample.fraction < 1;
|
||||
head.show = show;
|
||||
backing.show = show;
|
||||
if (headPrimitive) headPrimitive.show = show;
|
||||
if (!show) return;
|
||||
if (active !== segment) {
|
||||
active = segment;
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = collection.add(
|
||||
primitive(
|
||||
new Cesium.GeometryInstance({
|
||||
geometry: geometry(segment.positions, 5, true),
|
||||
}),
|
||||
new Cesium.PolylineMaterialAppearance({
|
||||
material,
|
||||
translucent: true,
|
||||
}),
|
||||
new Cesium.PolylineMaterialAppearance({
|
||||
material: depthMaterial,
|
||||
translucent: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
headRebuilds++;
|
||||
headPositions[0] = segment.positions[0];
|
||||
headPositions[1] = endpoint;
|
||||
}
|
||||
Cesium.Cartesian3.clone(renderedPosition, endpoint);
|
||||
head.positions = headPositions;
|
||||
backing.positions = headPositions;
|
||||
material.uniforms.fraction = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(sample.displayT - segment.fromT) / (segment.toT - segment.fromT),
|
||||
),
|
||||
);
|
||||
depthMaterial.uniforms.fraction = material.uniforms.fraction;
|
||||
Cesium.Color.clone(tint(0.8), material.uniforms.color);
|
||||
Cesium.Color.clone(tint(0.55), depthMaterial.uniforms.color);
|
||||
}
|
||||
return {
|
||||
replaceHistory,
|
||||
setDisplaySample,
|
||||
setStyle(next) {
|
||||
if (next === style) return;
|
||||
style = next;
|
||||
Cesium.Color.fromCssColorString(style, styleColor);
|
||||
lastSecond = -1;
|
||||
},
|
||||
setVisible(next) {
|
||||
visible = next;
|
||||
if (headPrimitive) headPrimitive.show = next && head.show;
|
||||
if (body) body.show = next;
|
||||
},
|
||||
destroy() {
|
||||
if (body) collection.remove(body);
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = null;
|
||||
material.destroy();
|
||||
depthMaterial.destroy();
|
||||
body = null;
|
||||
segments = [];
|
||||
},
|
||||
diagnostics() {
|
||||
return {
|
||||
revision,
|
||||
rebuilds,
|
||||
segments: segments.length,
|
||||
body,
|
||||
head,
|
||||
backing,
|
||||
headPrimitive,
|
||||
headStrategy: ground
|
||||
? 'draped corridor with material clip'
|
||||
: 'depth-fail corridor with material clip',
|
||||
ground,
|
||||
headRebuilds,
|
||||
entitiesAdded: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
export {
|
||||
createContactTrailRenderer,
|
||||
trailAlpha,
|
||||
} from './render/cesiumContactTrailRenderer.js';
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { worldToWindowCoordinates } from './render/cesiumProjection.js';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
|
||||
|
||||
// A window position through the engine's projector when `scene` has a
|
||||
@@ -9,7 +10,7 @@ function worldToWindow(scene, position, result) {
|
||||
const projector = engineFromViewer(scene)?.projector;
|
||||
return projector
|
||||
? projector.worldToWindow(position, result)
|
||||
: Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, result);
|
||||
: worldToWindowCoordinates(scene, position, result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +43,7 @@ export const DEFAULT_FOCUS_DEEMPHASIS_PARAMS = Object.freeze({
|
||||
let _params = { ...DEFAULT_FOCUS_DEEMPHASIS_PARAMS };
|
||||
let _focusTarget = null;
|
||||
const _spriteStates = new WeakMap();
|
||||
const _scratchScreen = new Cesium.Cartesian2();
|
||||
const _scratchScreen = geo.cartesian2.create();
|
||||
const _advanceResult = {
|
||||
factor: 1,
|
||||
changed: false,
|
||||
@@ -240,10 +241,7 @@ export function publishFocusTargetFromCachedPosition({
|
||||
bottom: screen.y + halfHeight + padding,
|
||||
},
|
||||
paddingPx: padding,
|
||||
cameraDistance: Cesium.Cartesian3.distance(
|
||||
camera.positionWC,
|
||||
displayPosition,
|
||||
),
|
||||
cameraDistance: geo.cartesian3.distance(camera.positionWC, displayPosition),
|
||||
frameNumber: scene.frameState?.frameNumber ?? -1,
|
||||
};
|
||||
if (appeared) {
|
||||
@@ -491,7 +489,7 @@ export function advanceProjectedSpriteFocus(
|
||||
position && scene ? worldToWindow(scene, position, _scratchScreen) : null;
|
||||
const cameraDistance =
|
||||
position && camera?.positionWC
|
||||
? Cesium.Cartesian3.distance(camera.positionWC, position)
|
||||
? geo.cartesian3.distance(camera.positionWC, position)
|
||||
: Number.NaN;
|
||||
return advanceSpriteFocus(sprite, {
|
||||
screenPosition: screen,
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
* keeps rotation continuous through tracked orbits; field evidence, not an
|
||||
* unverified math swap, decides whether exact projection becomes preferred.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
worldToWindowCoordinates,
|
||||
horizonOccluder,
|
||||
} from './render/cesiumProjection.js';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
|
||||
|
||||
@@ -31,7 +34,7 @@ function worldToWindow(scene, position, result) {
|
||||
const projector = engineFromViewer(scene)?.projector;
|
||||
return projector
|
||||
? projector.worldToWindow(position, result)
|
||||
: Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, result);
|
||||
: worldToWindowCoordinates(scene, position, result);
|
||||
}
|
||||
|
||||
/** Meters used to give the course vector a stable projection magnitude. */
|
||||
@@ -126,11 +129,6 @@ export function stabilizeScreenRotation(
|
||||
return Math.abs(delta) < Math.max(0, deadbandRad) ? previous : next;
|
||||
}
|
||||
|
||||
const _occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Half-width of the crossfade band centred on the ellipsoid silhouette, in
|
||||
* radians. ~1.1° per side: at the product's default 60° frustum on a 1280×800
|
||||
@@ -256,10 +254,7 @@ export function skyBackdropFactor(
|
||||
* @param {Cesium.Camera} camera - The scene camera.
|
||||
* @returns {Cesium.EllipsoidalOccluder}
|
||||
*/
|
||||
export function horizonOccluder(camera) {
|
||||
_occluder.cameraPosition = camera.positionWC;
|
||||
return _occluder;
|
||||
}
|
||||
export { horizonOccluder };
|
||||
|
||||
/**
|
||||
* Cheap camera pose signature for "did the camera move" gating of rotation
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { modelAxis } from './render/cesiumConstants.js';
|
||||
import * as geo from '../geo/index.js';
|
||||
/**
|
||||
* Model-space visual centres measured from the shipped GLBs' scene-space AABBs.
|
||||
@@ -78,8 +78,8 @@ export const MODEL_TRAIL_ANCHOR_NATIVE = Object.freeze({
|
||||
* trail anchor has to ride the same chain or it lands on a different axis than
|
||||
* the aircraft it is supposed to be attached to. */
|
||||
const AXIS_CORRECTION = geo.matrix4.multiplyTransformation(
|
||||
Cesium.Axis.Y_UP_TO_Z_UP,
|
||||
Cesium.Axis.Z_UP_TO_X_UP,
|
||||
modelAxis.Y_UP_TO_Z_UP,
|
||||
modelAxis.Z_UP_TO_X_UP,
|
||||
geo.matrix4.create(),
|
||||
);
|
||||
const _anchorScratch = geo.cartesian3.create();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Native constants shared by the W1 adapters; keep the SDK import here.
|
||||
export {
|
||||
Axis as modelAxis,
|
||||
ScreenSpaceEventType as trackingEventTypes,
|
||||
} from 'cesium';
|
||||
@@ -0,0 +1,309 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const ALPHA_STOPS = [
|
||||
[0, 0.8],
|
||||
[120000, 0.45],
|
||||
[600000, 0.2],
|
||||
[900000, 0.08],
|
||||
];
|
||||
export function trailAlpha(ageMs) {
|
||||
const stops = ALPHA_STOPS;
|
||||
for (let i = 1; i < stops.length; i++) {
|
||||
if (ageMs === stops[i][0]) return stops[i][1];
|
||||
if (ageMs < stops[i][0]) {
|
||||
const [a, x] = stops[i - 1],
|
||||
[b, y] = stops[i];
|
||||
return x + ((y - x) * Math.max(0, ageMs - a)) / (b - a);
|
||||
}
|
||||
}
|
||||
return 0.08;
|
||||
}
|
||||
|
||||
/** One static history batch and one frequently updated head. Never entities. */
|
||||
export function createContactTrailRenderer(scene) {
|
||||
const ground =
|
||||
!!scene.frameState?.context &&
|
||||
Cesium.GroundPolylinePrimitive.isSupported(scene);
|
||||
const collection = ground ? scene.groundPrimitives : scene.primitives;
|
||||
// One 25 m corridor is clipped in material coordinates. Only subdivision
|
||||
// crossings build geometry; frame updates write a scalar, never vertex buffers.
|
||||
const headMaterial = (alpha) =>
|
||||
new Cesium.Material({
|
||||
fabric: {
|
||||
type: 'TransitTrailHead',
|
||||
uniforms: {
|
||||
color: new Cesium.Color(0.37, 0.94, 0.54, alpha),
|
||||
fraction: 0,
|
||||
backing: true,
|
||||
},
|
||||
source: `czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
if (materialInput.st.s > fraction) discard;
|
||||
czm_material m = czm_getDefaultMaterial(materialInput);
|
||||
bool edge = abs(materialInput.st.t - 0.5) > 0.3;
|
||||
m.diffuse = czm_gammaCorrect(vec4(edge && backing ? vec3(0.02, 0.03, 0.05) : color.rgb, 1.0)).rgb;
|
||||
m.alpha = color.a;
|
||||
return m;
|
||||
}`,
|
||||
},
|
||||
translucent: true,
|
||||
});
|
||||
const material = headMaterial(0.8),
|
||||
depthMaterial = headMaterial(0.55);
|
||||
depthMaterial.uniforms.backing = false;
|
||||
const head = { positions: [], show: false, width: 3, material };
|
||||
const backing = { positions: [], show: false, width: 5 };
|
||||
let headPrimitive = null,
|
||||
headRebuilds = 0;
|
||||
function geometry(positions, width, material = false) {
|
||||
return ground
|
||||
? new Cesium.GroundPolylineGeometry({ positions, width, granularity: 0 })
|
||||
: new Cesium.PolylineGeometry({
|
||||
positions,
|
||||
width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: material
|
||||
? Cesium.PolylineMaterialAppearance.VERTEX_FORMAT
|
||||
: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
});
|
||||
}
|
||||
function primitive(instances, appearance, depthFailAppearance) {
|
||||
return ground
|
||||
? new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
allowPicking: false,
|
||||
asynchronous: true,
|
||||
show: visible,
|
||||
})
|
||||
: new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
depthFailAppearance,
|
||||
allowPicking: false,
|
||||
asynchronous: false,
|
||||
show: visible,
|
||||
});
|
||||
}
|
||||
let body = null,
|
||||
segments = [],
|
||||
revision = -1,
|
||||
visible = true,
|
||||
style = '#5EF08A';
|
||||
let lastSecond = -1,
|
||||
completed = -1,
|
||||
active = null;
|
||||
const styleColor = Cesium.Color.fromCssColorString(style);
|
||||
const attributeIds = [];
|
||||
const color = new Cesium.Color(),
|
||||
endpoint = new Cesium.Cartesian3();
|
||||
const headPositions = [new Cesium.Cartesian3(), endpoint];
|
||||
let rebuilds = 0;
|
||||
function tint(alpha, dark = false) {
|
||||
if (dark) {
|
||||
color.red = 0.02;
|
||||
color.green = 0.03;
|
||||
color.blue = 0.05;
|
||||
} else Cesium.Color.clone(styleColor, color);
|
||||
color.alpha = alpha;
|
||||
return color;
|
||||
}
|
||||
function replaceHistory(next) {
|
||||
if (next.revision === revision) return;
|
||||
revision = next.revision;
|
||||
segments = next.segments;
|
||||
completed = -1;
|
||||
lastSecond = -1;
|
||||
active = null;
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = null;
|
||||
head.show = backing.show = false;
|
||||
if (body) collection.remove(body);
|
||||
body = null;
|
||||
const instances = [];
|
||||
attributeIds.length = 0;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
attributeIds.push([`${i}:0`, `${i}:1`]);
|
||||
if (segment.positions.length < 2) continue;
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
id: `${i}:${lane}`,
|
||||
geometry: geometry(segment.positions, lane ? 3 : 5),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.8, !lane),
|
||||
),
|
||||
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.55),
|
||||
),
|
||||
show: new Cesium.ShowGeometryInstanceAttribute(false),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (instances.length) {
|
||||
body = collection.add(
|
||||
primitive(
|
||||
instances,
|
||||
new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
),
|
||||
);
|
||||
rebuilds++;
|
||||
}
|
||||
}
|
||||
function setDisplaySample(sample, renderedPosition) {
|
||||
if (!sample || !renderedPosition) {
|
||||
head.show = false;
|
||||
backing.show = false;
|
||||
if (headPrimitive) headPrimitive.show = false;
|
||||
return;
|
||||
}
|
||||
const second = Math.floor(sample.displayT / 1000);
|
||||
let lo = 0,
|
||||
hi = segments.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (segments[mid].toT <= sample.displayT) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
const done = lo - 1;
|
||||
const candidate = segments[lo];
|
||||
const segment =
|
||||
candidate &&
|
||||
candidate.fromT <= sample.displayT &&
|
||||
candidate.fromSeq === sample.fromSeq &&
|
||||
candidate.toSeq === sample.toSeq
|
||||
? candidate
|
||||
: null;
|
||||
if (body?.ready && done !== completed) {
|
||||
for (
|
||||
let i = Math.min(done, completed) + 1;
|
||||
i <= Math.max(done, completed);
|
||||
i++
|
||||
) {
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
const attributes = body.getGeometryInstanceAttributes(
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
// Cesium getters return copies. Write back the array we changed.
|
||||
const show = attributes.show;
|
||||
show[0] = i <= done ? 1 : 0;
|
||||
attributes.show = show;
|
||||
}
|
||||
}
|
||||
completed = done;
|
||||
}
|
||||
if (body?.ready && second !== lastSecond) {
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const alpha = trailAlpha(sample.displayT - segments[i].toT);
|
||||
for (let lane = 0; lane < 2; lane++) {
|
||||
const attributes = body.getGeometryInstanceAttributes(
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
const rgba = attributes.color;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(alpha, !lane),
|
||||
rgba,
|
||||
);
|
||||
attributes.color = rgba;
|
||||
if (!ground) {
|
||||
const depthRgba = attributes.depthFailColor;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(0.55),
|
||||
depthRgba,
|
||||
);
|
||||
attributes.depthFailColor = depthRgba;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastSecond = second;
|
||||
}
|
||||
const show = visible && !!segment && sample.fraction < 1;
|
||||
head.show = show;
|
||||
backing.show = show;
|
||||
if (headPrimitive) headPrimitive.show = show;
|
||||
if (!show) return;
|
||||
if (active !== segment) {
|
||||
active = segment;
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = collection.add(
|
||||
primitive(
|
||||
new Cesium.GeometryInstance({
|
||||
geometry: geometry(segment.positions, 5, true),
|
||||
}),
|
||||
new Cesium.PolylineMaterialAppearance({
|
||||
material,
|
||||
translucent: true,
|
||||
}),
|
||||
new Cesium.PolylineMaterialAppearance({
|
||||
material: depthMaterial,
|
||||
translucent: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
headRebuilds++;
|
||||
headPositions[0] = segment.positions[0];
|
||||
headPositions[1] = endpoint;
|
||||
}
|
||||
Cesium.Cartesian3.clone(renderedPosition, endpoint);
|
||||
head.positions = headPositions;
|
||||
backing.positions = headPositions;
|
||||
material.uniforms.fraction = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(sample.displayT - segment.fromT) / (segment.toT - segment.fromT),
|
||||
),
|
||||
);
|
||||
depthMaterial.uniforms.fraction = material.uniforms.fraction;
|
||||
Cesium.Color.clone(tint(0.8), material.uniforms.color);
|
||||
Cesium.Color.clone(tint(0.55), depthMaterial.uniforms.color);
|
||||
}
|
||||
return {
|
||||
replaceHistory,
|
||||
setDisplaySample,
|
||||
setStyle(next) {
|
||||
if (next === style) return;
|
||||
style = next;
|
||||
Cesium.Color.fromCssColorString(style, styleColor);
|
||||
lastSecond = -1;
|
||||
},
|
||||
setVisible(next) {
|
||||
visible = next;
|
||||
if (headPrimitive) headPrimitive.show = next && head.show;
|
||||
if (body) body.show = next;
|
||||
},
|
||||
destroy() {
|
||||
if (body) collection.remove(body);
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = null;
|
||||
material.destroy();
|
||||
depthMaterial.destroy();
|
||||
body = null;
|
||||
segments = [];
|
||||
},
|
||||
diagnostics() {
|
||||
return {
|
||||
revision,
|
||||
rebuilds,
|
||||
segments: segments.length,
|
||||
body,
|
||||
head,
|
||||
backing,
|
||||
headPrimitive,
|
||||
headStrategy: ground
|
||||
? 'draped corridor with material clip'
|
||||
: 'depth-fail corridor with material clip',
|
||||
ground,
|
||||
headRebuilds,
|
||||
entitiesAdded: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as Cesium from 'cesium';
|
||||
export function worldToWindowCoordinates(scene, position, result) {
|
||||
return Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
position,
|
||||
result,
|
||||
);
|
||||
}
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
export function horizonOccluder(camera) {
|
||||
occluder.cameraPosition = camera.positionWC;
|
||||
return occluder;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @module trailRenderer
|
||||
* @description Shared track-history renderer (PRD WS-F F4; rebuilt round 6,
|
||||
* 2026-07-06).
|
||||
*
|
||||
* One trail = one ENTITY polyline. Round 6 replaced the faded per-vertex
|
||||
* Primitive for two owner directives from the field:
|
||||
* - "the line must ALWAYS be visible": the Primitive's depthFailAppearance
|
||||
* did not reliably render segments below the photoreal mesh — entity
|
||||
* polylines with `depthFailMaterial` DO (in-repo proof: CCTV's frustum
|
||||
* wireframes read through geometry with exactly this), so occluded
|
||||
* segments now draw dimmed instead of vanishing.
|
||||
* - "show actual tracks, don't style them too much": the tail-fade is gone;
|
||||
* the whole history renders at one readable alpha (dimmer where it passes
|
||||
* behind/below geometry).
|
||||
*
|
||||
* Trails update at poll cadence (~15-60 s) plus once on history backfill, so
|
||||
* assigning a fresh positions array per update is cheap. `allowPicking` has
|
||||
* no entity equivalent; the polyline entity is excluded from clicks by never
|
||||
* carrying a pick id the layers' click handlers resolve.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { registerPickOwner } from '../pickRegistry.js';
|
||||
|
||||
// Round 6: trail ENTITIES are pickable (the old Primitive had
|
||||
// allowPicking:false). A trail hugs its aircraft, so an unclaimed pick would
|
||||
// read as "empty space" in every layer's click handler and deselect the very
|
||||
// plane being tracked. Claiming the 'gev-trail:' id namespace makes
|
||||
// isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op
|
||||
// everywhere. Registered once at module load; the predicate is pure.
|
||||
registerPickOwner('trails', (pickedId) =>
|
||||
String(pickedId).startsWith('gev-trail:'),
|
||||
);
|
||||
|
||||
/** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */
|
||||
let _trailSeq = 0;
|
||||
|
||||
/** @constant {number} Alpha where the trail passes the depth test. */
|
||||
const TRAIL_ALPHA = 0.85;
|
||||
/** @constant {number} Alpha where the trail is behind/below scene geometry —
|
||||
* visible (owner: never vanish) but readable as occluded. */
|
||||
const TRAIL_OCCLUDED_ALPHA = 0.4;
|
||||
/** @constant {number} Squared distance (m^2) below which consecutive points are merged. */
|
||||
const MIN_SEGMENT_DISTANCE_SQ = 0.01;
|
||||
|
||||
/**
|
||||
* Create an always-visible polyline trail bound to a viewer.
|
||||
* @param {Cesium.Viewer} viewer - Viewer whose entity collection owns the trail.
|
||||
* @param {object} options - Trail options.
|
||||
* @param {string} options.color - CSS color string for the trail hue.
|
||||
* @param {number} [options.width=2.5] - Polyline width in pixels.
|
||||
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
|
||||
* Trail handle: setPositions replaces the geometry, setVisible temporarily
|
||||
* hides it without discarding history, clear empties it, and destroy removes
|
||||
* the entity permanently.
|
||||
*/
|
||||
export function createTrail(viewer, { color, width = 2.5 }) {
|
||||
const baseColor = Cesium.Color.fromCssColorString(color);
|
||||
/** @type {Cesium.Cartesian3[]} Current deduped positions (owned copy). */
|
||||
let current = [];
|
||||
let destroyed = false;
|
||||
let visible = true;
|
||||
/** @type {Cesium.Entity|null} */
|
||||
let entity = null;
|
||||
|
||||
function ensureEntity() {
|
||||
if (entity || destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
entity = viewer.entities.add({
|
||||
id: `gev-trail:${++_trailSeq}`,
|
||||
show: visible,
|
||||
polyline: {
|
||||
// CallbackProperty so a positions swap never rebuilds the entity —
|
||||
// Cesium re-reads on change; `false` marks it non-constant.
|
||||
positions: new Cesium.CallbackProperty(() => current, false),
|
||||
width,
|
||||
material: baseColor.withAlpha(TRAIL_ALPHA),
|
||||
// The owner-locked rule (round 6): a segment below the photoreal
|
||||
// mesh renders dimmed — it must never disappear into the ground.
|
||||
depthFailMaterial: baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA),
|
||||
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
|
||||
// globe"): NONE draws straight 3D chords between waypoints — over a
|
||||
// sparse trans-oceanic trace a single segment spans hundreds of km
|
||||
// and tunnels through the planet. GEODESIC subdivides each segment
|
||||
// along the curved surface (heights interpolated), so long legs hug
|
||||
// the globe instead of chording through it.
|
||||
arcType: Cesium.ArcType.GEODESIC,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Replace the trail geometry with a chronological position list
|
||||
* (oldest first). Fewer than 2 distinct positions clears the trail.
|
||||
* @param {Cesium.Cartesian3[]} cartesians - Positions, oldest -> newest.
|
||||
*/
|
||||
setPositions(cartesians) {
|
||||
if (destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
// Drop consecutive near-duplicates: zero-length segments add nothing.
|
||||
const positions = [];
|
||||
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
||||
if (!position) continue;
|
||||
const last = positions[positions.length - 1];
|
||||
if (
|
||||
last &&
|
||||
Cesium.Cartesian3.distanceSquared(last, position) <
|
||||
MIN_SEGMENT_DISTANCE_SQ
|
||||
)
|
||||
continue;
|
||||
positions.push(position);
|
||||
}
|
||||
current = positions.length >= 2 ? positions : [];
|
||||
ensureEntity();
|
||||
},
|
||||
|
||||
/** Temporarily hide/show the trail without discarding accumulated history. */
|
||||
setVisible(nextVisible) {
|
||||
visible = nextVisible !== false;
|
||||
if (entity) entity.show = visible;
|
||||
},
|
||||
|
||||
/** Empty the trail without removing the entity (cheap re-arm). */
|
||||
clear() {
|
||||
current = [];
|
||||
},
|
||||
|
||||
/** Remove the trail entity permanently (layer disable/teardown). */
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
current = [];
|
||||
if (entity && viewer && !viewer.isDestroyed()) {
|
||||
try {
|
||||
viewer.entities.remove(entity);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
}
|
||||
entity = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { trackingEventTypes } from './render/cesiumConstants.js';
|
||||
import { isPointerFree } from './inputOwnership.js';
|
||||
|
||||
export const MAX_TRACKING_CLICK_TRAVEL_PX = 6;
|
||||
@@ -47,7 +47,7 @@ export function isTrackingClickGesture(gesture = {}) {
|
||||
*/
|
||||
export function bindTrackingClickGesture(handler, onClick, options = {}) {
|
||||
const now = options.now || (() => performance.now());
|
||||
const eventTypes = options.eventTypes || Cesium.ScreenSpaceEventType;
|
||||
const eventTypes = options.eventTypes || trackingEventTypes;
|
||||
let pressActive = false;
|
||||
let pressStartedAt = 0;
|
||||
let previousPosition = null;
|
||||
|
||||
+1
-141
@@ -1,141 +1 @@
|
||||
/**
|
||||
* @module trailRenderer
|
||||
* @description Shared track-history renderer (PRD WS-F F4; rebuilt round 6,
|
||||
* 2026-07-06).
|
||||
*
|
||||
* One trail = one ENTITY polyline. Round 6 replaced the faded per-vertex
|
||||
* Primitive for two owner directives from the field:
|
||||
* - "the line must ALWAYS be visible": the Primitive's depthFailAppearance
|
||||
* did not reliably render segments below the photoreal mesh — entity
|
||||
* polylines with `depthFailMaterial` DO (in-repo proof: CCTV's frustum
|
||||
* wireframes read through geometry with exactly this), so occluded
|
||||
* segments now draw dimmed instead of vanishing.
|
||||
* - "show actual tracks, don't style them too much": the tail-fade is gone;
|
||||
* the whole history renders at one readable alpha (dimmer where it passes
|
||||
* behind/below geometry).
|
||||
*
|
||||
* Trails update at poll cadence (~15-60 s) plus once on history backfill, so
|
||||
* assigning a fresh positions array per update is cheap. `allowPicking` has
|
||||
* no entity equivalent; the polyline entity is excluded from clicks by never
|
||||
* carrying a pick id the layers' click handlers resolve.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { registerPickOwner } from './pickRegistry.js';
|
||||
|
||||
// Round 6: trail ENTITIES are pickable (the old Primitive had
|
||||
// allowPicking:false). A trail hugs its aircraft, so an unclaimed pick would
|
||||
// read as "empty space" in every layer's click handler and deselect the very
|
||||
// plane being tracked. Claiming the 'gev-trail:' id namespace makes
|
||||
// isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op
|
||||
// everywhere. Registered once at module load; the predicate is pure.
|
||||
registerPickOwner('trails', (pickedId) =>
|
||||
String(pickedId).startsWith('gev-trail:'),
|
||||
);
|
||||
|
||||
/** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */
|
||||
let _trailSeq = 0;
|
||||
|
||||
/** @constant {number} Alpha where the trail passes the depth test. */
|
||||
const TRAIL_ALPHA = 0.85;
|
||||
/** @constant {number} Alpha where the trail is behind/below scene geometry —
|
||||
* visible (owner: never vanish) but readable as occluded. */
|
||||
const TRAIL_OCCLUDED_ALPHA = 0.4;
|
||||
/** @constant {number} Squared distance (m^2) below which consecutive points are merged. */
|
||||
const MIN_SEGMENT_DISTANCE_SQ = 0.01;
|
||||
|
||||
/**
|
||||
* Create an always-visible polyline trail bound to a viewer.
|
||||
* @param {Cesium.Viewer} viewer - Viewer whose entity collection owns the trail.
|
||||
* @param {object} options - Trail options.
|
||||
* @param {string} options.color - CSS color string for the trail hue.
|
||||
* @param {number} [options.width=2.5] - Polyline width in pixels.
|
||||
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
|
||||
* Trail handle: setPositions replaces the geometry, setVisible temporarily
|
||||
* hides it without discarding history, clear empties it, and destroy removes
|
||||
* the entity permanently.
|
||||
*/
|
||||
export function createTrail(viewer, { color, width = 2.5 }) {
|
||||
const baseColor = Cesium.Color.fromCssColorString(color);
|
||||
/** @type {Cesium.Cartesian3[]} Current deduped positions (owned copy). */
|
||||
let current = [];
|
||||
let destroyed = false;
|
||||
let visible = true;
|
||||
/** @type {Cesium.Entity|null} */
|
||||
let entity = null;
|
||||
|
||||
function ensureEntity() {
|
||||
if (entity || destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
entity = viewer.entities.add({
|
||||
id: `gev-trail:${++_trailSeq}`,
|
||||
show: visible,
|
||||
polyline: {
|
||||
// CallbackProperty so a positions swap never rebuilds the entity —
|
||||
// Cesium re-reads on change; `false` marks it non-constant.
|
||||
positions: new Cesium.CallbackProperty(() => current, false),
|
||||
width,
|
||||
material: baseColor.withAlpha(TRAIL_ALPHA),
|
||||
// The owner-locked rule (round 6): a segment below the photoreal
|
||||
// mesh renders dimmed — it must never disappear into the ground.
|
||||
depthFailMaterial: baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA),
|
||||
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
|
||||
// globe"): NONE draws straight 3D chords between waypoints — over a
|
||||
// sparse trans-oceanic trace a single segment spans hundreds of km
|
||||
// and tunnels through the planet. GEODESIC subdivides each segment
|
||||
// along the curved surface (heights interpolated), so long legs hug
|
||||
// the globe instead of chording through it.
|
||||
arcType: Cesium.ArcType.GEODESIC,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Replace the trail geometry with a chronological position list
|
||||
* (oldest first). Fewer than 2 distinct positions clears the trail.
|
||||
* @param {Cesium.Cartesian3[]} cartesians - Positions, oldest -> newest.
|
||||
*/
|
||||
setPositions(cartesians) {
|
||||
if (destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
// Drop consecutive near-duplicates: zero-length segments add nothing.
|
||||
const positions = [];
|
||||
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
||||
if (!position) continue;
|
||||
const last = positions[positions.length - 1];
|
||||
if (
|
||||
last &&
|
||||
Cesium.Cartesian3.distanceSquared(last, position) <
|
||||
MIN_SEGMENT_DISTANCE_SQ
|
||||
)
|
||||
continue;
|
||||
positions.push(position);
|
||||
}
|
||||
current = positions.length >= 2 ? positions : [];
|
||||
ensureEntity();
|
||||
},
|
||||
|
||||
/** Temporarily hide/show the trail without discarding accumulated history. */
|
||||
setVisible(nextVisible) {
|
||||
visible = nextVisible !== false;
|
||||
if (entity) entity.show = visible;
|
||||
},
|
||||
|
||||
/** Empty the trail without removing the entity (cheap re-arm). */
|
||||
clear() {
|
||||
current = [];
|
||||
},
|
||||
|
||||
/** Remove the trail entity permanently (layer disable/teardown). */
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
current = [];
|
||||
if (entity && viewer && !viewer.isDestroyed()) {
|
||||
try {
|
||||
viewer.entities.remove(entity);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
}
|
||||
entity = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
export { createTrail } from './render/cesiumTrailRenderer.js';
|
||||
@@ -282,11 +282,9 @@
|
||||
* - Cesium's viewer.clock: the app never animates it (shouldAnimate false,
|
||||
* its time frozen at boot); Cesium's sun and lighting and every entity
|
||||
* property read at viewer.clock.currentTime stay on that time;
|
||||
* - launches: the replay timeline (replay.js, Date.now/performance.now),
|
||||
* the mission orbit's GMST frame (orbitRendering.js new Date()), launch
|
||||
* pad and panel throttles (phase 3 Task 15a);
|
||||
* - wall-clock throttles and feed receipt stamps everywhere (they measure
|
||||
* real elapsed time, not scene time).
|
||||
* Launch replay, satellite propagation and orbit GMST now use this clock.
|
||||
* @property {() => number} now The current app time.
|
||||
* @property {() => number} frameTime The app time latched at the start of the
|
||||
* engine's current frame: every reader in one frame gets the same value; it
|
||||
@@ -374,8 +372,8 @@
|
||||
* straight segments.
|
||||
* @property {Matrix4} [modelMatrix] Applied to the positions (an orbit ring
|
||||
* turned by GMST).
|
||||
* @property {any} [pickId] What a Cesium pick of the line returns (Navara
|
||||
* lines are not pickable yet).
|
||||
* @property {any} [pickId] What a pick of the line returns (Navara uses
|
||||
* CPU segment picks; lines do not participate in its GPU pass).
|
||||
*
|
||||
* @typedef {object} Line
|
||||
* @property {(positions: Cartesian3[] | Float64Array) => void} setPositions
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// (ScreenSpaceCameraController rotate3D and zoom3D):
|
||||
// - left drag: heading turns by rate × dx / width × 2π and the camera rises
|
||||
// by rate × −dy / height × π, rate = 1.77 (Cesium's maximum rotate rate,
|
||||
// which a tracked camera beyond a few metres always reaches); each move
|
||||
// counts at most a tenth of the canvas (maximumMovementRatio); a drag
|
||||
// which a tracked camera beyond a few metres always reaches); the signed
|
||||
// window ratio uses Cesium's maximumMovementRatio cap; a drag
|
||||
// right turns the heading clockwise;
|
||||
// - right drag: a dolly by dy / height (down closes in);
|
||||
// - wheel: one notch (deltaY 100) is 20 % of the range.
|
||||
@@ -46,7 +46,7 @@ export function navaraFollowGestures(
|
||||
return;
|
||||
}
|
||||
orbit(
|
||||
ROTATE_RATE * Math.min(dx / width, MAX_MOVEMENT_RATIO) * 2 * Math.PI,
|
||||
-ROTATE_RATE * Math.min(-dx / width, MAX_MOVEMENT_RATIO) * 2 * Math.PI,
|
||||
ROTATE_RATE * Math.min(-dy / height, MAX_MOVEMENT_RATIO) * Math.PI,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,14 +52,22 @@ test('drag turns and right drag dollies as Cesium tracked camera; wheel scales;
|
||||
win.dispatchEvent(event('pointerup', { button: 0 }));
|
||||
assert.ok(calls[0][2] < 0, 'pitch down');
|
||||
assert.ok(Math.abs(calls[0][2] + (1.77 * 40 * Math.PI) / 800) < 1e-12);
|
||||
// Per move at most a tenth of the canvas counts (maximumMovementRatio).
|
||||
// Cesium rotate3D caps (startX - endX) / width, before the sign
|
||||
// reversal into heading. Large right and left drags are asymmetric.
|
||||
calls.length = 0;
|
||||
canvas.dispatchEvent(
|
||||
event('pointerdown', { button: 0, clientX: 0, clientY: 0 }),
|
||||
);
|
||||
win.dispatchEvent(event('pointermove', { clientX: 640, clientY: 0 }));
|
||||
win.dispatchEvent(event('pointerup', { button: 0 }));
|
||||
assert.ok(Math.abs(calls[0][1] - 1.77 * 0.1 * 2 * Math.PI) < 1e-12);
|
||||
assert.ok(Math.abs(calls[0][1] - 1.77 * 0.5 * 2 * Math.PI) < 1e-12);
|
||||
calls.length = 0;
|
||||
canvas.dispatchEvent(
|
||||
event('pointerdown', { button: 0, clientX: 640, clientY: 0 }),
|
||||
);
|
||||
win.dispatchEvent(event('pointermove', { clientX: 0, clientY: 0 }));
|
||||
win.dispatchEvent(event('pointerup', { button: 0 }));
|
||||
assert.ok(Math.abs(calls[0][1] + 1.77 * 0.1 * 2 * Math.PI) < 1e-12);
|
||||
// A right drag down: a dolly by the window ratio (Cesium zoom3D).
|
||||
calls.length = 0;
|
||||
const context = event('contextmenu');
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createNavaraLines } from './lines/index.js';
|
||||
import { createNavaraOverlay } from './overlay.js';
|
||||
import {
|
||||
NAVARA_CONTEXT_ATTRIBUTES,
|
||||
ACES_FILMIC,
|
||||
navaraScenePlan,
|
||||
navaraViewOptions,
|
||||
resolveNavaraLook,
|
||||
@@ -120,14 +121,20 @@ export async function createNavaraGlobe({
|
||||
);
|
||||
// The look's shadowed sun leaked a light per frame (internals.js).
|
||||
keepCascadedLightIds(view);
|
||||
// Sprites, points and lines, drawn after tone mapping (overlay.js).
|
||||
// Screen overlays and model display colour after tone mapping (overlay.js).
|
||||
overlay = createNavaraOverlay(view);
|
||||
engine = createNavaraEngine(view, {
|
||||
hooks: navaraHooks(view),
|
||||
container,
|
||||
...(appClock ? { appClock } : {}),
|
||||
sharpen: createNavaraSharpen(view),
|
||||
movers: createNavaraMovers(view, { overlay }),
|
||||
movers: createNavaraMovers(view, {
|
||||
overlay,
|
||||
// Model colour follows the display-space stage, after the look's ACES.
|
||||
aces: plan.effects.some(
|
||||
({ toneMapping }) => toneMapping?.mode === ACES_FILMIC,
|
||||
),
|
||||
}),
|
||||
lines: createNavaraLines(view, { overlay }),
|
||||
});
|
||||
const camera = engine.camera;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// One camera snapshot per Navara frame, shared by its mover sets. A camera
|
||||
// command or resize within the frame invalidates it too. Nonstandard parented
|
||||
// or manually managed cameras retain the uncached path.
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
|
||||
export function createMoverFrameCamera(view) {
|
||||
const frame = {
|
||||
cameraPosition: { x: 0, y: 0, z: 0 },
|
||||
cameraDirection: { x: 0, y: 0, z: -1 },
|
||||
cameraHeight: 0,
|
||||
fovDeg: 60,
|
||||
viewportHeightPx: 0,
|
||||
pixelRatio: 1,
|
||||
};
|
||||
const previous = {};
|
||||
return (ctx, time) => {
|
||||
const raw = view.camera.raw;
|
||||
const renderer = ctx?.getRenderer();
|
||||
const height = renderer?.domElement.clientHeight;
|
||||
if (!raw || !(height > 0)) return null;
|
||||
const p = raw.position,
|
||||
q = raw.quaternion,
|
||||
scale = raw.scale;
|
||||
const dpr = renderer.getPixelRatio?.() ?? 1;
|
||||
const cacheable =
|
||||
Number.isFinite(time) &&
|
||||
!raw.parent &&
|
||||
raw.matrixAutoUpdate !== false &&
|
||||
raw.matrixWorldAutoUpdate !== false &&
|
||||
scale.x === 1 &&
|
||||
scale.y === 1 &&
|
||||
scale.z === 1;
|
||||
if (
|
||||
cacheable &&
|
||||
previous.time === time &&
|
||||
previous.raw === raw &&
|
||||
previous.renderer === renderer &&
|
||||
previous.x === p.x &&
|
||||
previous.y === p.y &&
|
||||
previous.z === p.z &&
|
||||
previous.qx === q.x &&
|
||||
previous.qy === q.y &&
|
||||
previous.qz === q.z &&
|
||||
previous.qw === q.w &&
|
||||
frame.fovDeg === raw.fov &&
|
||||
frame.viewportHeightPx === height &&
|
||||
frame.pixelRatio === dpr
|
||||
)
|
||||
return frame;
|
||||
raw.updateMatrixWorld();
|
||||
const e = raw.matrixWorld.elements;
|
||||
const position = frame.cameraPosition;
|
||||
position.x = e[12];
|
||||
position.y = e[13];
|
||||
position.z = e[14];
|
||||
const direction = frame.cameraDirection;
|
||||
const length = Math.hypot(e[8], e[9], e[10]) || 1;
|
||||
direction.x = -e[8] / length;
|
||||
direction.y = -e[9] / length;
|
||||
direction.z = -e[10] / length;
|
||||
frame.cameraHeight =
|
||||
Math.hypot(position.x, position.y, position.z) - EARTH_RADIUS_M;
|
||||
frame.fovDeg = raw.fov;
|
||||
frame.viewportHeightPx = height;
|
||||
frame.pixelRatio = dpr;
|
||||
previous.time = cacheable ? time : undefined;
|
||||
previous.raw = raw;
|
||||
previous.renderer = renderer;
|
||||
previous.x = p.x;
|
||||
previous.y = p.y;
|
||||
previous.z = p.z;
|
||||
previous.qx = q.x;
|
||||
previous.qy = q.y;
|
||||
previous.qz = q.z;
|
||||
previous.qw = q.w;
|
||||
return frame;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createMoverFrameCamera } from './frameCamera.js';
|
||||
|
||||
function setup() {
|
||||
let updates = 0;
|
||||
const raw = {
|
||||
position: { x: 6372000, y: 2, z: 3 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 1, y: 1, z: 1 },
|
||||
parent: null,
|
||||
matrixAutoUpdate: true,
|
||||
fov: 60,
|
||||
matrixWorld: { elements: new Float64Array(16) },
|
||||
updateMatrixWorld() {
|
||||
updates++;
|
||||
const e = this.matrixWorld.elements;
|
||||
e[12] = this.position.x;
|
||||
e[13] = this.position.y;
|
||||
e[14] = this.position.z;
|
||||
e[8] = 0;
|
||||
e[9] = this.quaternion.y;
|
||||
e[10] = 2;
|
||||
},
|
||||
};
|
||||
const renderer = {
|
||||
domElement: { clientHeight: 800 },
|
||||
getPixelRatio: () => 1,
|
||||
};
|
||||
const ctx = { getRenderer: () => renderer };
|
||||
const read = createMoverFrameCamera({ camera: { raw } });
|
||||
return { read, raw, renderer, ctx, updates: () => updates };
|
||||
}
|
||||
|
||||
test('mover sets share one matrix update in a frame and refresh next frame', () => {
|
||||
const f = setup();
|
||||
const first = f.read(f.ctx, 100);
|
||||
for (let i = 0; i < 30; i++) assert.equal(f.read(f.ctx, 100), first);
|
||||
assert.equal(f.updates(), 1);
|
||||
assert.deepEqual(first.cameraDirection, { x: -0, y: -0, z: -1 });
|
||||
assert.equal(first.cameraPosition.x, 6372000);
|
||||
f.read(f.ctx, 101);
|
||||
assert.equal(f.updates(), 2);
|
||||
});
|
||||
|
||||
test('same-frame camera moves, rotations, resize and DPR never reuse stale data', () => {
|
||||
const f = setup();
|
||||
f.read(f.ctx, 100);
|
||||
f.raw.position.x += 100;
|
||||
assert.equal(f.read(f.ctx, 100).cameraPosition.x, 6372100);
|
||||
f.raw.quaternion.y = 2;
|
||||
assert.ok(
|
||||
Math.abs(f.read(f.ctx, 100).cameraDirection.y + Math.SQRT1_2) < 1e-12,
|
||||
);
|
||||
f.renderer.domElement.clientHeight = 600;
|
||||
assert.equal(f.read(f.ctx, 100).viewportHeightPx, 600);
|
||||
f.renderer.getPixelRatio = () => 2;
|
||||
assert.equal(f.read(f.ctx, 100).pixelRatio, 2);
|
||||
assert.equal(f.updates(), 5);
|
||||
});
|
||||
|
||||
test('missing frames and nonstandard camera transforms keep the uncached path', () => {
|
||||
for (const modify of [
|
||||
() => {},
|
||||
(f) => {
|
||||
f.raw.parent = {};
|
||||
},
|
||||
(f) => {
|
||||
f.raw.matrixAutoUpdate = false;
|
||||
},
|
||||
(f) => {
|
||||
f.raw.scale.x = 2;
|
||||
},
|
||||
(f) => {
|
||||
f.raw.matrixWorldAutoUpdate = false;
|
||||
},
|
||||
]) {
|
||||
const f = setup();
|
||||
modify(f);
|
||||
const time =
|
||||
f.raw.parent ||
|
||||
!f.raw.matrixAutoUpdate ||
|
||||
f.raw.scale.x !== 1 ||
|
||||
f.raw.matrixWorldAutoUpdate === false
|
||||
? 100
|
||||
: undefined;
|
||||
f.read(f.ctx, time);
|
||||
f.read(f.ctx, time);
|
||||
assert.equal(f.updates(), 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('a hidden canvas has no snapshot and independent views do not share scratch state', () => {
|
||||
const a = setup(),
|
||||
b = setup();
|
||||
a.renderer.domElement.clientHeight = 0;
|
||||
assert.equal(a.read(a.ctx, 100), null);
|
||||
a.renderer.domElement.clientHeight = 800;
|
||||
const first = a.read(a.ctx, 100);
|
||||
b.raw.position.x += 900;
|
||||
assert.notEqual(first, b.read(b.ctx, 100));
|
||||
assert.equal(first.cameraPosition.x, 6372000);
|
||||
});
|
||||
@@ -2,7 +2,8 @@
|
||||
// per set (moverMesh.js) in gev's overlay (overlay.js: drawn after tone
|
||||
// mapping, so their colours are exact); models are a Navara mesh descriptor
|
||||
// (public MeshDesc API) holding one InstancedMesh per glTF mesh
|
||||
// (modelMover.js), lit and graded like the scene. Pickable sets register a
|
||||
// (modelMover.js). In the cinematic look, depth stays in that scene and
|
||||
// lit model colour resolves in the display pass. Pickable sets register a
|
||||
// PickableInstancedMeshWrapper so Navara's pick pass sees each instance;
|
||||
// pickIds.js turns its batch ids back into instances. commit() uploads and
|
||||
// asks for one frame (view.forceUpdate); per-frame work runs only on frames
|
||||
@@ -18,10 +19,10 @@ import { createMoverRegistry } from '../../movers.js';
|
||||
import { createModelMover } from './modelMover.js';
|
||||
import { createMoverMesh } from './moverMesh.js';
|
||||
import { createPickIndex } from './pickIds.js';
|
||||
import { createMoverFrameCamera } from './frameCamera.js';
|
||||
|
||||
const DESC_NAME = 'gevMoverSet';
|
||||
const registered = new WeakSet();
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
|
||||
class GevMoverSetDesc extends MeshDesc {
|
||||
constructor(view, ctx, config) {
|
||||
@@ -50,15 +51,15 @@ class GevMoverSetDesc extends MeshDesc {
|
||||
if (this.destroyedByGev) return;
|
||||
mover.group.meshes.forEach((mesh, index) => {
|
||||
group.add(mesh);
|
||||
register(mesh, `${this.id}#${index}`);
|
||||
register(mover.group.pickMeshes[index], `${this.id}#${index}`);
|
||||
});
|
||||
this.view.forceUpdate();
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
update() {
|
||||
updateMover(this.view, this.ctx, this.gev.mover);
|
||||
update(time) {
|
||||
updateMover(this.gev.readFrameCamera, this.ctx, this.gev.mover, time);
|
||||
}
|
||||
|
||||
onDestroy() {
|
||||
@@ -69,53 +70,25 @@ class GevMoverSetDesc extends MeshDesc {
|
||||
}
|
||||
}
|
||||
|
||||
// The camera a mover needs each drawn frame (scratch: no allocation).
|
||||
const frameCamera = {
|
||||
cameraPosition: { x: 0, y: 0, z: 0 },
|
||||
cameraDirection: { x: 0, y: 0, z: -1 },
|
||||
cameraHeight: 0,
|
||||
fovDeg: 60,
|
||||
viewportHeightPx: 0,
|
||||
pixelRatio: 1,
|
||||
};
|
||||
function updateMover(view, ctx, mover) {
|
||||
const raw = view.camera.raw;
|
||||
const renderer = ctx?.getRenderer();
|
||||
const height = renderer?.domElement.clientHeight;
|
||||
if (!raw || !(height > 0)) return;
|
||||
raw.updateMatrixWorld();
|
||||
const e = raw.matrixWorld.elements;
|
||||
const position = frameCamera.cameraPosition;
|
||||
position.x = e[12];
|
||||
position.y = e[13];
|
||||
position.z = e[14];
|
||||
// A three camera looks down its local −z.
|
||||
const direction = frameCamera.cameraDirection;
|
||||
const length = Math.hypot(e[8], e[9], e[10]) || 1;
|
||||
direction.x = -e[8] / length;
|
||||
direction.y = -e[9] / length;
|
||||
direction.z = -e[10] / length;
|
||||
frameCamera.cameraHeight =
|
||||
Math.hypot(position.x, position.y, position.z) - EARTH_RADIUS_M;
|
||||
frameCamera.fovDeg = raw.fov;
|
||||
frameCamera.viewportHeightPx = height;
|
||||
frameCamera.pixelRatio = renderer.getPixelRatio?.() ?? 1;
|
||||
mover.update(frameCamera);
|
||||
function updateMover(readFrameCamera, ctx, mover, time) {
|
||||
const camera = readFrameCamera(ctx, time);
|
||||
if (camera) mover.update(camera);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} view an initialised Navara ThreeView
|
||||
* @param {{ overlay: ReturnType<import('../overlay.js').createNavaraOverlay> }} ports
|
||||
* @param {{ overlay: ReturnType<import('../overlay.js').createNavaraOverlay>, aces?: boolean }} ports
|
||||
* @returns {{ movers: import('../../contract.js').GlobeMovers,
|
||||
* resolvePick: (batchId: number) => ({ set: string, instance: number, pickId: any } | null),
|
||||
* destroy: () => void }}
|
||||
*/
|
||||
export function createNavaraMovers(view, { overlay }) {
|
||||
export function createNavaraMovers(view, { overlay, aces = false }) {
|
||||
if (!registered.has(view)) {
|
||||
view.registerMesh(DESC_NAME, GevMoverSetDesc);
|
||||
registered.add(view);
|
||||
}
|
||||
const pickIndex = createPickIndex();
|
||||
const readFrameCamera = createMoverFrameCamera(view);
|
||||
let loader = null;
|
||||
const loadGltf = (url) => {
|
||||
loader ??= createGltfLoader();
|
||||
@@ -137,7 +110,9 @@ export function createNavaraMovers(view, { overlay }) {
|
||||
overlay.registerPickable(key, wrapper);
|
||||
pickIndex.add(options.id, wrapper, (index) => store.pickIds[index]);
|
||||
}
|
||||
overlay.add(mover.mesh, () => updateMover(view, overlay.ctx, mover));
|
||||
overlay.add(mover.mesh, (time) =>
|
||||
updateMover(readFrameCamera, overlay.ctx, mover, time),
|
||||
);
|
||||
return {
|
||||
ready: mover.ready,
|
||||
commit() {
|
||||
@@ -158,10 +133,12 @@ export function createNavaraMovers(view, { overlay }) {
|
||||
const mover = createModelMover(options, store, {
|
||||
load: loadGltf,
|
||||
onLoad: request,
|
||||
overlay: aces ? overlay : null,
|
||||
});
|
||||
const handle = view.addMesh({
|
||||
[DESC_NAME]: {
|
||||
mover,
|
||||
readFrameCamera,
|
||||
kind: options.kind,
|
||||
pickable: options.pickable,
|
||||
onPickable: (wrapper) =>
|
||||
@@ -178,6 +155,9 @@ export function createNavaraMovers(view, { overlay }) {
|
||||
destroy() {
|
||||
pickIndex.remove(options.id);
|
||||
handle.delete();
|
||||
// A descriptor deleted before its first update may never own a mesh.
|
||||
// Release the pending load as well; dispose is idempotent.
|
||||
mover.dispose();
|
||||
request();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,14 +18,12 @@ import {
|
||||
Vector3,
|
||||
} from 'three';
|
||||
import { footprintPerDepth, originFor } from './pack.js';
|
||||
import { createModelTint } from './modelTint.js';
|
||||
|
||||
/**
|
||||
* Ambient light on the tinted surface, as an emissive share of it: Cesium
|
||||
* lights models with image-based lighting besides the sun, so a tinted
|
||||
* aircraft reads near-white at any sun angle; Navara's sky light alone left
|
||||
* them grey-brown at dawn. 0.5 matched Cesium's model luminance at the near
|
||||
* pose (qa-flights-engines --models=on: median 190 vs 193 on Cesium; 139
|
||||
* without it).
|
||||
* Ambient share of the base surface: Cesium supplies image-based lighting
|
||||
* besides the sun. Navara's sky light alone left it grey-brown at dawn.
|
||||
* The instance colour is applied after lighting, independently of this share.
|
||||
*/
|
||||
export const MODEL_AMBIENT = 0.5;
|
||||
|
||||
@@ -47,13 +45,12 @@ const scratchTranslation = new Vector3();
|
||||
const scratchScale = new Vector3();
|
||||
|
||||
/**
|
||||
* Mixes the instance colour, decoded from sRGB (as Cesium takes colours;
|
||||
* the material works in linear), into the material's colour by uGevBlend (the
|
||||
* set's colorBlend, as Cesium's colorBlendAmount); the instance alpha is the
|
||||
* model's opacity (Cesium: a model colour's alpha makes it translucent; the
|
||||
* aircraft fade with distance through it), not a blend weight.
|
||||
* Cesium's ModelFS applies modelColorStage after lightingStage in sRGB.
|
||||
* Mix the lit output in that space, then return to linear for Navara's look.
|
||||
* Alpha remains opacity before alpha testing, independently of the mix.
|
||||
*/
|
||||
function blendInstanceColor(material, uniforms) {
|
||||
function blendInstanceColor(material, uniforms, displayTint) {
|
||||
const alpha = 'diffuseColor.a *= vGevColor.a;';
|
||||
material.onBeforeCompile = (shader) => {
|
||||
Object.assign(shader.uniforms, uniforms);
|
||||
shader.vertexShader = shader.vertexShader
|
||||
@@ -68,29 +65,36 @@ function blendInstanceColor(material, uniforms) {
|
||||
shader.fragmentShader = shader.fragmentShader
|
||||
.replace(
|
||||
'void main() {',
|
||||
'uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\nvoid main() {',
|
||||
`uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\nvoid main() {`,
|
||||
)
|
||||
.replace(
|
||||
'#include <emissivemap_fragment>',
|
||||
'#include <emissivemap_fragment>\ntotalEmissiveRadiance += diffuseColor.rgb * uGevAmbient;',
|
||||
)
|
||||
.replace('#include <map_fragment>', `#include <map_fragment>\n${alpha}`)
|
||||
.replace(
|
||||
'#include <map_fragment>',
|
||||
'#include <map_fragment>\ndiffuseColor.rgb = mix(diffuseColor.rgb, sRGBTransferEOTF(vGevColor).rgb, uGevBlend);\ndiffuseColor.a *= vGevColor.a;',
|
||||
'#include <opaque_fragment>',
|
||||
`#include <opaque_fragment>\nif (!${displayTint} && uGevBlend > 0.0) {\nvec3 gevLitSrgb = sRGBTransferOETF(vec4(gl_FragColor.rgb, 1.0)).rgb;\nvec3 gevMixedColor = sRGBTransferEOTF(vec4(clamp(mix(gevLitSrgb, vGevColor.rgb, uGevBlend), 0.0, 1.0), 1.0)).rgb;\ngl_FragColor.rgb = gevMixedColor;\n}`,
|
||||
);
|
||||
};
|
||||
material.customProgramCacheKey = () => 'gev-mover-model-blend';
|
||||
material.customProgramCacheKey = () =>
|
||||
`gev-mover-model-display-color-${displayTint ? 'overlay' : 'linear'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options normalized model set options
|
||||
* @param {object} store the set's mover store
|
||||
* @param {{ load: (url: string) => Promise<{ scene: object }>, onLoad?: () => void }} ports
|
||||
* @param {{ load: (url: string) => Promise<{ scene: object }>, onLoad?: () => void, overlay?: object }} ports
|
||||
*/
|
||||
export function createModelMover(options, store, { load, onLoad = () => {} }) {
|
||||
export function createModelMover(
|
||||
options,
|
||||
store,
|
||||
{ load, onLoad = () => {}, overlay = null },
|
||||
) {
|
||||
const { capacity, model } = options;
|
||||
const uniforms = {
|
||||
uGevAmbient: { value: MODEL_AMBIENT },
|
||||
uGevModelExposure: overlay?.uniforms.uGevModelExposure ?? { value: 1 },
|
||||
uGevBlend: {
|
||||
value: model.colorBlend
|
||||
? model.colorBlend.mode === 'replace'
|
||||
@@ -109,6 +113,13 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
|
||||
let disposed = false;
|
||||
const colorAttributes = [];
|
||||
const materials = [];
|
||||
// GLTFLoader returns a fresh asset per load. Cloned materials still share
|
||||
// its textures; Material.dispose() does not release their GPU allocations.
|
||||
const textures = new Set();
|
||||
const disposeTextures = () => {
|
||||
for (const texture of textures) texture.dispose();
|
||||
textures.clear();
|
||||
};
|
||||
// Each instance's drawn diameter (px) at its last write (CPU picks).
|
||||
const drawnPx = new Float32Array(capacity);
|
||||
// Translucent only while an instance is faded (opaque models keep the
|
||||
@@ -175,18 +186,41 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
|
||||
index * 4,
|
||||
);
|
||||
}
|
||||
for (const { mesh } of parts) {
|
||||
for (const { mesh, tint } of parts) {
|
||||
if (tint) {
|
||||
tint.count = store.count;
|
||||
tint.visible = store.count > 0;
|
||||
}
|
||||
mesh.count = store.count;
|
||||
mesh.visible = store.count > 0;
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
for (const attribute of colorAttributes) attribute.needsUpdate = true;
|
||||
syncTransparency();
|
||||
};
|
||||
|
||||
const group = { meshes: [], ready: null };
|
||||
group.ready = load(model.url).then((gltf) => {
|
||||
if (disposed) return;
|
||||
const group = { meshes: [], pickMeshes: [], ready: null };
|
||||
const asset = load(model.url).catch(() => {
|
||||
// A missing asset keeps the layer's glyph fallback. Do not leak the
|
||||
// rejected loader promise (or its URL) through descriptor creation.
|
||||
if (!disposed) console.warn('[globe] Model asset unavailable');
|
||||
return null;
|
||||
});
|
||||
group.ready = asset.then((gltf) => {
|
||||
if (!gltf) return;
|
||||
const root = gltf.scene;
|
||||
root.traverse((child) => {
|
||||
for (const material of Array.isArray(child.material)
|
||||
? child.material
|
||||
: [child.material])
|
||||
if (material)
|
||||
for (const value of Object.values(material))
|
||||
if (value?.isTexture) textures.add(value);
|
||||
});
|
||||
if (disposed) {
|
||||
disposeTextures();
|
||||
return;
|
||||
}
|
||||
root.updateMatrixWorld(true);
|
||||
const sphere = new Box3()
|
||||
.setFromObject(root)
|
||||
@@ -201,8 +235,7 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
|
||||
color: material.color,
|
||||
});
|
||||
else material = material.clone();
|
||||
blendInstanceColor(material, uniforms);
|
||||
materials.push(material);
|
||||
blendInstanceColor(material, uniforms, Boolean(overlay));
|
||||
const geometry = child.geometry.clone();
|
||||
const color = new InstancedBufferAttribute(
|
||||
new Float32Array(capacity * 4).fill(1),
|
||||
@@ -216,21 +249,37 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
|
||||
mesh.frustumCulled = false;
|
||||
mesh.renderOrder = options.order;
|
||||
mesh.count = 0;
|
||||
parts.push({ mesh, local: child.matrixWorld.clone() });
|
||||
mesh.visible = false;
|
||||
const tint = overlay
|
||||
? createModelTint(mesh, uniforms, overlay.uniforms)
|
||||
: null;
|
||||
if (tint) {
|
||||
// Still present in the scene depth and GPU pick registry. Only colour
|
||||
// moves to the display pass, where it is toned before raster resolve.
|
||||
material.colorWrite = false;
|
||||
overlay.add(tint);
|
||||
}
|
||||
materials.push(material);
|
||||
if (tint) materials.push(tint.material);
|
||||
parts.push({ mesh, tint, local: child.matrixWorld.clone() });
|
||||
group.meshes.push(mesh);
|
||||
group.pickMeshes.push(tint ?? mesh);
|
||||
});
|
||||
loaded = true;
|
||||
if (origin)
|
||||
for (const { mesh } of parts)
|
||||
for (const { mesh, tint } of parts) {
|
||||
mesh.position.set(origin.x, origin.y, origin.z);
|
||||
tint?.position.copy(mesh.position);
|
||||
}
|
||||
write(0, store.count);
|
||||
onLoad(group.meshes);
|
||||
});
|
||||
|
||||
const placeAt = (next) => {
|
||||
origin = { x: next.x, y: next.y, z: next.z };
|
||||
for (const { mesh } of parts) {
|
||||
for (const { mesh, tint } of parts) {
|
||||
mesh.position.set(origin.x, origin.y, origin.z);
|
||||
tint?.position.copy(mesh.position);
|
||||
mesh.updateMatrix();
|
||||
mesh.updateMatrixWorld(true);
|
||||
}
|
||||
@@ -258,7 +307,15 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
|
||||
z: store.positions[2],
|
||||
});
|
||||
if (range) write(range.from, Math.min(range.to, store.count));
|
||||
for (const { mesh } of parts) mesh.count = store.count;
|
||||
for (const { mesh, tint } of parts) {
|
||||
if (tint) {
|
||||
tint.count = store.count;
|
||||
tint.visible = store.count > 0;
|
||||
}
|
||||
mesh.count = store.count;
|
||||
// Retained empty banks must also skip three's material/uniform work.
|
||||
mesh.visible = store.count > 0;
|
||||
}
|
||||
},
|
||||
/** Each drawn frame: pixel limits need every instance rewritten. */
|
||||
update({
|
||||
@@ -278,12 +335,19 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
|
||||
return true;
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
for (const { mesh } of parts) {
|
||||
for (const { mesh, tint } of parts) {
|
||||
if (tint) {
|
||||
overlay.remove(tint);
|
||||
tint.material.dispose();
|
||||
tint.dispose();
|
||||
}
|
||||
mesh.geometry.dispose();
|
||||
mesh.material.dispose();
|
||||
mesh.dispose?.();
|
||||
}
|
||||
disposeTextures();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
Vector3,
|
||||
Texture,
|
||||
} from 'three';
|
||||
import { createMoverStore, normalizeMoverSetOptions } from '../../movers.js';
|
||||
import { createModelMover, modelPixelDistance } from './modelMover.js';
|
||||
@@ -91,7 +92,7 @@ test('a far model keeps its minimum pixel size', async () => {
|
||||
assert.equal(mover.pickRadiusPx(1), null);
|
||||
});
|
||||
|
||||
test("the instance colour (sRGB, decoded) tints by the set's blend amount; its alpha is opacity, as Cesium's model colour", async () => {
|
||||
test("the instance colour mixes after lighting in sRGB, as Cesium's model colour", async () => {
|
||||
const store = createMoverStore(options);
|
||||
const mover = createModelMover(options, store, {
|
||||
load: async () => fakeGltf(),
|
||||
@@ -108,14 +109,22 @@ test("the instance colour (sRGB, decoded) tints by the set's blend amount; its a
|
||||
uniforms: {},
|
||||
vertexShader: 'void main() {\n#include <begin_vertex>\n}',
|
||||
fragmentShader:
|
||||
'void main() {\n#include <map_fragment>\n#include <emissivemap_fragment>\n}',
|
||||
'void main() {\n#include <map_fragment>\n#include <emissivemap_fragment>\n#include <opaque_fragment>\n}',
|
||||
};
|
||||
mesh.material.onBeforeCompile(shader);
|
||||
assert.match(shader.fragmentShader, /diffuseColor\.a \*= vGevColor\.a;/);
|
||||
// Cesium ModelFS runs modelColorStage after lightingStage, in sRGB.
|
||||
assert.match(shader.fragmentShader, /vec3 gevLitSrgb = sRGBTransferOETF/);
|
||||
assert.match(
|
||||
shader.fragmentShader,
|
||||
/diffuseColor\.rgb = mix\(diffuseColor\.rgb, sRGBTransferEOTF\(vGevColor\)\.rgb, uGevBlend\);\s*diffuseColor\.a \*= vGevColor\.a;/,
|
||||
/mix\(gevLitSrgb, vGevColor\.rgb, uGevBlend\)/,
|
||||
);
|
||||
// Ambient light on the tinted surface (Cesium's image-based lighting).
|
||||
assert.ok(
|
||||
shader.fragmentShader.indexOf('vec3 gevLitSrgb') >
|
||||
shader.fragmentShader.indexOf('#include <opaque_fragment>'),
|
||||
);
|
||||
assert.doesNotMatch(shader.fragmentShader, /diffuseColor\.rgb = mix/);
|
||||
// Ambient light belongs to the original surface before the colour mix.
|
||||
assert.match(
|
||||
shader.fragmentShader,
|
||||
/totalEmissiveRadiance \+= diffuseColor\.rgb \* uGevAmbient;/,
|
||||
@@ -134,3 +143,183 @@ test("the minimum pixel size is taken at Cesium's distance: view depth less the
|
||||
assert.equal(modelPixelDistance(0, 0, -10, ahead, 30), 0);
|
||||
assert.equal(modelPixelDistance(4000, 0, -3000, null, 0), 5000);
|
||||
});
|
||||
|
||||
test('disposing a model releases shared textures once, as well as each instanced mesh', async () => {
|
||||
const gltf = fakeGltf();
|
||||
const texture = new Texture();
|
||||
gltf.scene.children[0].material.map = texture;
|
||||
const second = gltf.scene.children[0].clone();
|
||||
gltf.scene.add(second);
|
||||
let released = 0;
|
||||
texture.addEventListener('dispose', () => released++);
|
||||
const mover = createModelMover(options, createMoverStore(options), {
|
||||
load: async () => gltf,
|
||||
});
|
||||
await mover.group.ready;
|
||||
let geometries = 0,
|
||||
materials = 0;
|
||||
for (const mesh of mover.group.meshes) {
|
||||
mesh.geometry.addEventListener('dispose', () => geometries++);
|
||||
mesh.material.addEventListener('dispose', () => materials++);
|
||||
}
|
||||
assert.equal(released, 0, 'textures remain live while the model draws');
|
||||
mover.dispose();
|
||||
mover.dispose();
|
||||
assert.equal(released, 1);
|
||||
assert.equal(geometries, 2);
|
||||
assert.equal(materials, 2);
|
||||
});
|
||||
|
||||
test('a model removed before its load completes releases the arriving textures', async () => {
|
||||
let finish;
|
||||
const gltf = fakeGltf();
|
||||
const texture = new Texture();
|
||||
gltf.scene.children[0].material.map = texture;
|
||||
let released = 0,
|
||||
loaded = 0;
|
||||
texture.addEventListener('dispose', () => released++);
|
||||
const mover = createModelMover(options, createMoverStore(options), {
|
||||
load: () =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
onLoad: () => loaded++,
|
||||
});
|
||||
mover.dispose();
|
||||
finish(gltf);
|
||||
await mover.group.ready;
|
||||
assert.equal(released, 1);
|
||||
assert.equal(loaded, 0);
|
||||
assert.equal(mover.ready(), false);
|
||||
assert.equal(mover.group.meshes.length, 0);
|
||||
});
|
||||
|
||||
test('a failed model load stays not-ready without an unhandled rejection or leaking the URL', async (t) => {
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (message) => warnings.push(message));
|
||||
const mover = createModelMover(options, createMoverStore(options), {
|
||||
load: async () => {
|
||||
throw new Error('unavailable asset URL');
|
||||
},
|
||||
});
|
||||
await mover.group.ready;
|
||||
assert.equal(mover.ready(), false);
|
||||
assert.equal(mover.group.meshes.length, 0);
|
||||
assert.deepEqual(warnings, ['[globe] Model asset unavailable']);
|
||||
mover.dispose();
|
||||
});
|
||||
|
||||
test('an empty retained model bank leaves the render traversal and restores its cached instances', async () => {
|
||||
const store = createMoverStore(options);
|
||||
const mover = createModelMover(options, store, {
|
||||
load: async () => fakeGltf(),
|
||||
});
|
||||
await mover.group.ready;
|
||||
const [mesh] = mover.group.meshes;
|
||||
assert.equal(mesh.visible, false, 'loaded before its first instance');
|
||||
store.set(0, { position: { x: 6378137, y: 0, z: 0 } });
|
||||
store.setCount(1);
|
||||
mover.commit(store.dirty());
|
||||
store.clean();
|
||||
assert.equal(mesh.visible, true);
|
||||
const before = mesh.instanceMatrix.array.slice();
|
||||
store.setCount(0);
|
||||
mover.commit(store.dirty());
|
||||
store.clean();
|
||||
assert.equal(mesh.visible, false);
|
||||
assert.equal(mover.ready(), true, 'the asset remains cached');
|
||||
store.setCount(1);
|
||||
mover.commit(store.dirty());
|
||||
assert.equal(mesh.visible, true);
|
||||
assert.deepEqual(mesh.instanceMatrix.array, before);
|
||||
mover.dispose();
|
||||
});
|
||||
|
||||
test('display tint shares transforms, preserves fade opacity and leaves no overlay resource after destruction', async () => {
|
||||
const children = new Set();
|
||||
const overlay = {
|
||||
uniforms: {
|
||||
tGevSceneDepth: { value: null },
|
||||
uGevResolution: { value: [1280, 800] },
|
||||
},
|
||||
add: (mesh) => children.add(mesh),
|
||||
remove: (mesh) => children.delete(mesh),
|
||||
};
|
||||
const store = createMoverStore(options);
|
||||
const mover = createModelMover(options, store, {
|
||||
load: async () => fakeGltf(),
|
||||
overlay,
|
||||
});
|
||||
store.set(0, {
|
||||
position: { x: 6378137, y: 0, z: 0 },
|
||||
color: [1, 0.2, 0.6, 0.4],
|
||||
});
|
||||
store.setCount(1);
|
||||
mover.commit(store.dirty());
|
||||
await mover.group.ready;
|
||||
const [base] = mover.group.meshes;
|
||||
const [tint] = children;
|
||||
assert.equal(tint.geometry, base.geometry);
|
||||
assert.equal(tint.instanceMatrix, base.instanceMatrix);
|
||||
assert.deepEqual(tint.position, base.position);
|
||||
assert.equal(tint.count, 1);
|
||||
assert.equal(tint.material.toneMapped, false);
|
||||
assert.equal(tint.material.depthWrite, false);
|
||||
const shader = {
|
||||
uniforms: {},
|
||||
vertexShader: 'void main() {\n#include <begin_vertex>\n}',
|
||||
fragmentShader:
|
||||
'void main() {\n#include <logdepthbuf_fragment>\n#include <opaque_fragment>\n}',
|
||||
};
|
||||
tint.material.onBeforeCompile(shader);
|
||||
assert.equal(shader.uniforms.tGevSceneDepth, overlay.uniforms.tGevSceneDepth);
|
||||
assert.match(shader.fragmentShader, /if \(gevOverlayHidden\(\)\) discard/);
|
||||
assert.match(
|
||||
shader.fragmentShader,
|
||||
/mix\(gevLitSrgb, vGevColor.rgb, uGevBlend\)/,
|
||||
);
|
||||
assert.equal(base.material.colorWrite, false);
|
||||
assert.equal(
|
||||
mover.group.pickMeshes[0],
|
||||
tint,
|
||||
'GPU picking keeps its color-writing material',
|
||||
);
|
||||
store.setCount(0);
|
||||
mover.commit(store.dirty());
|
||||
assert.equal(tint.count, 0);
|
||||
assert.equal(tint.visible, false);
|
||||
let disposed = 0;
|
||||
tint.material.addEventListener('dispose', () => disposed++);
|
||||
mover.dispose();
|
||||
mover.dispose();
|
||||
assert.equal(disposed, 1);
|
||||
assert.equal(children.size, 0);
|
||||
});
|
||||
|
||||
test('the display pass preserves unlit model colour without a lighting tone curve', async () => {
|
||||
const children = new Set();
|
||||
const overlay = {
|
||||
uniforms: {},
|
||||
add: (mesh) => children.add(mesh),
|
||||
remove: (mesh) => children.delete(mesh),
|
||||
};
|
||||
const spec = { ...options, model: { ...options.model, unlit: true } };
|
||||
const mover = createModelMover(spec, createMoverStore(spec), {
|
||||
load: async () => fakeGltf(),
|
||||
overlay,
|
||||
});
|
||||
await mover.group.ready;
|
||||
const [mesh] = children;
|
||||
assert.equal(mesh.material.isMeshBasicMaterial, true);
|
||||
const shader = {
|
||||
uniforms: {},
|
||||
vertexShader: 'void main() {\n#include <begin_vertex>\n}',
|
||||
fragmentShader: 'void main() {\n#include <opaque_fragment>\n}',
|
||||
};
|
||||
mesh.material.onBeforeCompile(shader);
|
||||
assert.doesNotMatch(
|
||||
shader.fragmentShader,
|
||||
/gevModelAces\(gl_FragColor.rgb\)/,
|
||||
);
|
||||
mover.dispose();
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// Models use the scene's depth, but their lit display colour is resolved after
|
||||
// the view's HDR effects. This keeps MSAA from spreading HDR tint into edges.
|
||||
import { InstancedMesh } from 'three';
|
||||
import { OVERLAY_DEPTH_FRAGMENT } from '../overlayDepth.js';
|
||||
|
||||
// three's pinned ACESFilmicToneMapping, with the view's public exposure.
|
||||
const ACES = `
|
||||
uniform float uGevModelExposure;
|
||||
vec3 gevModelAces(vec3 color) {
|
||||
const mat3 inputMatrix = mat3(0.59719,0.076,0.0284, 0.35458,0.90834,0.13383, 0.04823,0.01566,0.83777);
|
||||
const mat3 outputMatrix = mat3(1.60475,-0.10208,-0.00327, -0.53108,1.10813,-0.07276, -0.07367,-0.00605,1.07602);
|
||||
color = inputMatrix * (color * uGevModelExposure / 0.6);
|
||||
color = (color * (color + 0.0245786) - 0.000090537) /
|
||||
(color * (0.983729 * color + 0.432951) + 0.238081);
|
||||
return clamp(outputMatrix * color, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export function createModelTint(mesh, uniforms, overlayUniforms) {
|
||||
const material = mesh.material.clone();
|
||||
material.depthTest = false;
|
||||
material.depthWrite = false;
|
||||
material.toneMapped = false;
|
||||
const lit = material.isMeshBasicMaterial
|
||||
? 'gl_FragColor.rgb'
|
||||
: 'gevModelAces(gl_FragColor.rgb)';
|
||||
material.onBeforeCompile = (shader) => {
|
||||
Object.assign(shader.uniforms, uniforms, overlayUniforms, {
|
||||
uGevDepthMode: { value: 1 },
|
||||
});
|
||||
shader.vertexShader = shader.vertexShader
|
||||
.replace(
|
||||
'void main() {',
|
||||
'attribute vec4 gevColor;\nvarying vec4 vGevColor;\nvarying float vGevDepthFree;\nvoid main() {',
|
||||
)
|
||||
.replace(
|
||||
'#include <begin_vertex>',
|
||||
'#include <begin_vertex>\nvGevColor = gevColor;\nvGevDepthFree = 0.0;',
|
||||
);
|
||||
shader.fragmentShader = shader.fragmentShader
|
||||
.replace(
|
||||
'void main() {',
|
||||
`uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\n${ACES}\n${OVERLAY_DEPTH_FRAGMENT}\nvoid main() {`,
|
||||
)
|
||||
.replace(
|
||||
'#include <logdepthbuf_fragment>',
|
||||
'#include <logdepthbuf_fragment>\nif (gevOverlayHidden()) discard;',
|
||||
)
|
||||
.replace(
|
||||
'#include <map_fragment>',
|
||||
'#include <map_fragment>\ndiffuseColor.a *= vGevColor.a;',
|
||||
)
|
||||
.replace(
|
||||
'#include <emissivemap_fragment>',
|
||||
'#include <emissivemap_fragment>\ntotalEmissiveRadiance += diffuseColor.rgb * uGevAmbient;',
|
||||
)
|
||||
.replace(
|
||||
'#include <opaque_fragment>',
|
||||
`#include <opaque_fragment>\nvec3 gevLitSrgb = sRGBTransferOETF(vec4(${lit}, 1.0)).rgb;\ngl_FragColor.rgb = sRGBTransferEOTF(vec4(mix(gevLitSrgb, vGevColor.rgb, uGevBlend), 1.0)).rgb;`,
|
||||
);
|
||||
};
|
||||
material.customProgramCacheKey = () => 'gev-model-lit-display-color';
|
||||
const tint = new InstancedMesh(
|
||||
mesh.geometry,
|
||||
material,
|
||||
mesh.instanceMatrix.count,
|
||||
);
|
||||
tint.userData.gevModelTint = true;
|
||||
tint.instanceMatrix = mesh.instanceMatrix;
|
||||
tint.frustumCulled = false;
|
||||
tint.renderOrder = mesh.renderOrder;
|
||||
return tint;
|
||||
}
|
||||
+52
-27
@@ -1,44 +1,26 @@
|
||||
// gev's screen overlays on Navara: sprites, points and lines (engine.movers
|
||||
// and engine.lines) drawn into the frame after Navara's tone mapping and
|
||||
// before its anti-aliasing (SMAA/FXAA) and the gev sharpening, so their
|
||||
// and engine.lines) drawn into the frame after Navara's tone mapping,
|
||||
// anti-aliasing (SMAA/FXAA) and the gev sharpening, so their
|
||||
// colours are the colours asked for (as Cesium draws billboards and
|
||||
// polylines), not tone-mapped or graded with the look. A public Navara
|
||||
// EffectDesc whose pass renders one three scene over the input buffer; the
|
||||
// post-processing chain has no depth buffer, so each overlay material tests
|
||||
// the scene depth from Navara's MRT pass itself (overlayDepth.js). The
|
||||
// pass costs nothing while the scene is empty. Models stay in Navara's
|
||||
// scene, lit and graded like the 3D tiles.
|
||||
// pass costs nothing while no geometry is visible. Models retain scene
|
||||
// depth; their lit display colour is resolved here so HDR effects and MSAA
|
||||
// cannot spread a tint into neighbouring pixels.
|
||||
|
||||
import { EffectDesc, Pass as NavaraPass } from '@navaramap/three';
|
||||
import { Pass } from 'postprocessing';
|
||||
import { GevOverlayPass } from './overlayPass.js';
|
||||
import { RGBADepthPacking, Scene } from 'three';
|
||||
|
||||
export const OVERLAY_EFFECT_KEY = 'gevOverlay';
|
||||
|
||||
class GevOverlayPass extends Pass {
|
||||
constructor(scene, camera, overlay) {
|
||||
super('GevOverlayPass', scene, camera);
|
||||
this.needsSwap = false;
|
||||
this.overlay = overlay;
|
||||
}
|
||||
|
||||
render(renderer, inputBuffer) {
|
||||
if (!this.scene.children.length) return;
|
||||
const resolution = this.overlay.uniforms.uGevResolution.value;
|
||||
resolution[0] = inputBuffer?.width ?? 0;
|
||||
resolution[1] = inputBuffer?.height ?? 0;
|
||||
const autoClear = renderer.autoClear;
|
||||
renderer.autoClear = false;
|
||||
renderer.setRenderTarget(this.renderToScreen ? null : inputBuffer);
|
||||
renderer.render(this.scene, this.camera);
|
||||
renderer.autoClear = autoClear;
|
||||
}
|
||||
}
|
||||
|
||||
class GevOverlayEffectDesc extends EffectDesc {
|
||||
static key = OVERLAY_EFFECT_KEY;
|
||||
static insertAfter = ['toneMapping'];
|
||||
static insertBefore = ['gevSharpen', 'smaa', 'fxaa', 'final'];
|
||||
// Navara takes the first existing anchor, so the latest pass comes first.
|
||||
static insertAfter = ['fxaa', 'smaa', 'gevSharpen', 'toneMapping'];
|
||||
static insertBefore = ['final'];
|
||||
|
||||
constructor(view, ctx, config) {
|
||||
super(view, ctx, config);
|
||||
@@ -77,11 +59,14 @@ export function createNavaraOverlay(view) {
|
||||
view.registerEffect(OVERLAY_EFFECT_KEY, GevOverlayEffectDesc);
|
||||
const scene = new Scene();
|
||||
const frameHooks = new Map();
|
||||
const lights = new Map();
|
||||
let ctx = null;
|
||||
const pendingPicks = new Map();
|
||||
const overlay = {
|
||||
scene,
|
||||
modelTints: new Set(),
|
||||
uniforms: {
|
||||
uGevModelExposure: { value: 1 },
|
||||
tGevSceneDepth: { value: null },
|
||||
uGevResolution: { value: [0, 0] },
|
||||
},
|
||||
@@ -91,7 +76,42 @@ export function createNavaraOverlay(view) {
|
||||
ctx.registerPickableMesh(key, wrapper);
|
||||
pendingPicks.clear();
|
||||
},
|
||||
hasDrawables() {
|
||||
for (const child of scene.children)
|
||||
if (
|
||||
!child.isLight &&
|
||||
child.visible &&
|
||||
(child.count === undefined || child.count > 0)
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
},
|
||||
beforeFrame(time) {
|
||||
overlay.uniforms.uGevModelExposure.value = view.toneMappingExposure ?? 1;
|
||||
// Public descriptor lights are copied, never reparented from Navara.
|
||||
if (overlay.modelTints.size && ctx) {
|
||||
for (const light of ctx.scenes.light.children) {
|
||||
if (!light.isLight) continue;
|
||||
let copy = lights.get(light);
|
||||
if (!copy) {
|
||||
copy = light.clone(false);
|
||||
copy.castShadow = false;
|
||||
copy.matrixAutoUpdate = false;
|
||||
lights.set(light, copy);
|
||||
scene.add(copy);
|
||||
}
|
||||
copy.color.copy(light.color);
|
||||
copy.intensity = light.intensity;
|
||||
copy.matrix.copy(light.matrixWorld);
|
||||
copy.sh?.copy(light.sh);
|
||||
copy.groundColor?.copy(light.groundColor);
|
||||
if (copy.target && light.target) {
|
||||
copy.target.matrixAutoUpdate = false;
|
||||
copy.target.matrix.copy(light.target.matrixWorld);
|
||||
copy.target.updateMatrixWorld(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const hook of frameHooks.values()) hook(time);
|
||||
},
|
||||
get ctx() {
|
||||
@@ -107,10 +127,12 @@ export function createNavaraOverlay(view) {
|
||||
uniforms: overlay.uniforms,
|
||||
add(object, onFrame) {
|
||||
scene.add(object);
|
||||
if (object.userData.gevModelTint) overlay.modelTints.add(object);
|
||||
if (onFrame) frameHooks.set(object, onFrame);
|
||||
},
|
||||
remove(object) {
|
||||
scene.remove(object);
|
||||
overlay.modelTints.delete(object);
|
||||
frameHooks.delete(object);
|
||||
},
|
||||
registerPickable(key, wrapper) {
|
||||
@@ -127,6 +149,9 @@ export function createNavaraOverlay(view) {
|
||||
destroy() {
|
||||
handle.delete();
|
||||
scene.clear();
|
||||
overlay.modelTints.clear();
|
||||
for (const light of lights.values()) light.dispose?.();
|
||||
lights.clear();
|
||||
frameHooks.clear();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Pass } from 'postprocessing';
|
||||
|
||||
export class GevOverlayPass extends Pass {
|
||||
constructor(scene, camera, overlay) {
|
||||
super('GevOverlayPass', scene, camera);
|
||||
this.needsSwap = false;
|
||||
this.overlay = overlay;
|
||||
}
|
||||
render(renderer, inputBuffer) {
|
||||
if (!this.overlay.hasDrawables()) return;
|
||||
const resolution = this.overlay.uniforms.uGevResolution.value;
|
||||
resolution[0] = inputBuffer?.width ?? 0;
|
||||
resolution[1] = inputBuffer?.height ?? 0;
|
||||
const autoClear = renderer.autoClear;
|
||||
renderer.autoClear = false;
|
||||
try {
|
||||
renderer.setRenderTarget(this.renderToScreen ? null : inputBuffer);
|
||||
renderer.render(this.scene, this.camera);
|
||||
} finally {
|
||||
renderer.autoClear = autoClear;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Scene, PerspectiveCamera } from 'three';
|
||||
import { GevOverlayPass } from './overlayPass.js';
|
||||
test('overlay draws in place only with visible objects and restores renderer state on failure', () => {
|
||||
let active = false;
|
||||
const overlay = {
|
||||
hasDrawables: () => active,
|
||||
uniforms: { uGevResolution: { value: [0, 0] } },
|
||||
};
|
||||
const pass = new GevOverlayPass(
|
||||
new Scene(),
|
||||
new PerspectiveCamera(),
|
||||
overlay,
|
||||
);
|
||||
const input = { width: 1280, height: 800 };
|
||||
let target,
|
||||
renders = 0;
|
||||
const renderer = {
|
||||
autoClear: true,
|
||||
setRenderTarget: (value) => {
|
||||
target = value;
|
||||
},
|
||||
render: () => {
|
||||
renders++;
|
||||
},
|
||||
};
|
||||
pass.render(renderer, input);
|
||||
assert.equal(renders, 0);
|
||||
active = true;
|
||||
pass.render(renderer, input);
|
||||
assert.equal(target, input);
|
||||
assert.equal(renders, 1);
|
||||
assert.equal(pass.needsSwap, false);
|
||||
assert.equal(renderer.autoClear, true);
|
||||
assert.deepEqual(overlay.uniforms.uGevResolution.value, [1280, 800]);
|
||||
renderer.render = () => {
|
||||
throw new Error('render failed');
|
||||
};
|
||||
assert.throws(() => pass.render(renderer, input), /render failed/);
|
||||
assert.equal(renderer.autoClear, true);
|
||||
pass.dispose();
|
||||
});
|
||||
@@ -86,8 +86,8 @@ class UnsharpMaskEffect extends Effect {
|
||||
/** config.gevSharpen: { amount } (Cesium's `amount` uniform). */
|
||||
export class SharpenEffectDesc extends EffectDesc {
|
||||
static key = SHARPEN_EFFECT_KEY;
|
||||
static insertAfter = ['gevOverlay', 'toneMapping'];
|
||||
static insertBefore = ['smaa', 'fxaa', 'final'];
|
||||
static insertAfter = ['toneMapping'];
|
||||
static insertBefore = ['smaa', 'fxaa', 'gevOverlay', 'final'];
|
||||
|
||||
constructor(view, ctx, config) {
|
||||
super(view, ctx, config);
|
||||
|
||||
@@ -49,7 +49,7 @@ export function resolveNavaraLook(search = '') {
|
||||
}
|
||||
|
||||
// postprocessing's ToneMappingMode.ACES_FILMIC (Navara's default is AGX, 7).
|
||||
const ACES_FILMIC = 6;
|
||||
export const ACES_FILMIC = 6;
|
||||
|
||||
// Camera heights (m) where the cinematic look switches the globe to
|
||||
// atmosphere lighting (night side and terminator) and back. The gap keeps a
|
||||
|
||||
@@ -26,6 +26,12 @@ export function aircraftQaDrawn(state) {
|
||||
width: sprite.width,
|
||||
show: sprite.show,
|
||||
rotation: sprite.rotation,
|
||||
trackedRotation:
|
||||
id === state._trackedIcao
|
||||
? (state._trackedEntity?.rotation ??
|
||||
state._lastTrackedRotation ??
|
||||
null)
|
||||
: null,
|
||||
alpha: sprite.color?.alpha ?? null,
|
||||
position: sprite.position
|
||||
? {
|
||||
@@ -43,6 +49,7 @@ export function aircraftQaDrawn(state) {
|
||||
ready: model.ready,
|
||||
// Where it is drawn: its transform's translation.
|
||||
position: translationOf(model.modelMatrix),
|
||||
matrix: model.modelMatrix ? Array.from(model.modelMatrix) : null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
@@ -56,6 +63,7 @@ export function aircraftQaDrawn(state) {
|
||||
scale: tracked.scale,
|
||||
show: tracked.show,
|
||||
ready: tracked.ready,
|
||||
matrix: tracked.modelMatrix ? Array.from(tracked.modelMatrix) : null,
|
||||
},
|
||||
});
|
||||
return out;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { aircraftQaDrawn } from './qaDrawn.js';
|
||||
|
||||
for (const native of [false, true]) {
|
||||
test(`heading probe copies model transforms and reads tracked glyph (${native ? 'native' : 'engine'})`, () => {
|
||||
const matrix = Float64Array.from({ length: 16 }, (_, i) => i);
|
||||
const sprite = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
rotation: 0.4,
|
||||
show: false,
|
||||
};
|
||||
const model = { modelMatrix: matrix, show: true, ready: true };
|
||||
const state = {
|
||||
_billboards: new Map([['a', sprite]]),
|
||||
_models: new Map([['a', model]]),
|
||||
_trackedIcao: 'a',
|
||||
_lastTrackedRotation: 0.7,
|
||||
_trackedModel: model,
|
||||
_trackedEntity: native
|
||||
? {
|
||||
billboard: {
|
||||
rotation: {
|
||||
getValue: () => {
|
||||
throw new Error(
|
||||
'Probe must not evaluate a reactive property',
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: { rotation: 0.7 },
|
||||
};
|
||||
const [fleet, tracked] = aircraftQaDrawn(state);
|
||||
assert.equal(fleet.trackedRotation, 0.7);
|
||||
assert.equal(fleet.rotation, 0.4);
|
||||
assert.equal(tracked.model.matrix[12], 12);
|
||||
fleet.model.matrix[12] = -1;
|
||||
tracked.model.matrix[12] = -2;
|
||||
assert.equal(matrix[12], 12);
|
||||
assert.equal(aircraftQaDrawn(state)[1].model.matrix[12], 12);
|
||||
});
|
||||
}
|
||||
@@ -57,6 +57,13 @@ const fieldsNoPick = {
|
||||
show: undefined,
|
||||
};
|
||||
const scratchColor = new Float64Array(4);
|
||||
// Sprite setters write synchronously into the mover store; reuse their
|
||||
// field records rather than allocating one per contact and assignment.
|
||||
const spritePositionField = { position: null };
|
||||
const spriteShowField = { show: true };
|
||||
const spriteRotationField = { rotation: 0 };
|
||||
const spriteScaleField = { scale: 1 };
|
||||
const spriteColorField = { color: scratchColor };
|
||||
function writeFields(set, index, fields) {
|
||||
const scratch = fieldsNoPick;
|
||||
for (const key in fields) scratch[key] = fields[key];
|
||||
@@ -371,7 +378,8 @@ export function createAircraftNavaraRenderer({
|
||||
|
||||
set position(value) {
|
||||
geo.cartesian3.clone(value, this._position);
|
||||
this.write({ position: this._position });
|
||||
spritePositionField.position = this._position;
|
||||
this.write(spritePositionField);
|
||||
}
|
||||
|
||||
get show() {
|
||||
@@ -379,8 +387,11 @@ export function createAircraftNavaraRenderer({
|
||||
}
|
||||
|
||||
set show(value) {
|
||||
this._show = value !== false;
|
||||
this.write({ show: this._show });
|
||||
const next = value !== false;
|
||||
if (next === this._show) return;
|
||||
this._show = next;
|
||||
spriteShowField.show = next;
|
||||
this.write(spriteShowField);
|
||||
}
|
||||
|
||||
get rotation() {
|
||||
@@ -388,8 +399,10 @@ export function createAircraftNavaraRenderer({
|
||||
}
|
||||
|
||||
set rotation(value) {
|
||||
if (value === this._rotation) return;
|
||||
this._rotation = value;
|
||||
this.write({ rotation: value });
|
||||
spriteRotationField.rotation = value;
|
||||
this.write(spriteRotationField);
|
||||
}
|
||||
|
||||
get scale() {
|
||||
@@ -397,8 +410,10 @@ export function createAircraftNavaraRenderer({
|
||||
}
|
||||
|
||||
set scale(value) {
|
||||
if (value === this._scale) return;
|
||||
this._scale = value;
|
||||
this.write({ scale: this.drawnScale() });
|
||||
spriteScaleField.scale = this.drawnScale();
|
||||
this.write(spriteScaleField);
|
||||
}
|
||||
|
||||
get width() {
|
||||
@@ -406,8 +421,10 @@ export function createAircraftNavaraRenderer({
|
||||
}
|
||||
|
||||
set width(value) {
|
||||
if (value === this._width) return;
|
||||
this._width = value;
|
||||
this.write({ scale: this.drawnScale() });
|
||||
spriteScaleField.scale = this.drawnScale();
|
||||
this.write(spriteScaleField);
|
||||
}
|
||||
|
||||
get height() {
|
||||
@@ -423,8 +440,17 @@ export function createAircraftNavaraRenderer({
|
||||
}
|
||||
|
||||
set color(value) {
|
||||
if (
|
||||
value !== this._color &&
|
||||
(value?.red ?? 1) === this._color.red &&
|
||||
(value?.green ?? 1) === this._color.green &&
|
||||
(value?.blue ?? 1) === this._color.blue &&
|
||||
(value?.alpha ?? 1) === this._color.alpha
|
||||
)
|
||||
return;
|
||||
this._color = new StoredColor(value);
|
||||
this.write({ color: rgbaOf(this._color) });
|
||||
rgbaOf(this._color);
|
||||
this.write(spriteColorField);
|
||||
}
|
||||
|
||||
get image() {
|
||||
|
||||
@@ -293,3 +293,59 @@ test('trails are engine lines in the trail pick namespace; the head follows each
|
||||
renderer.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('unchanged sprite style does not upload or request another frame', async () => {
|
||||
const { engine, renderer } = await setup();
|
||||
const sprite = renderer.fleet.add({
|
||||
id: 'same',
|
||||
position: geo.cartesian3.fromDegrees(0, 0, 1000),
|
||||
image: aircraftIcon('light'),
|
||||
});
|
||||
await Promise.resolve();
|
||||
const [listed] = setsOf(engine, 'flights-sprites');
|
||||
const part = engine.fake.moverSet(listed.id);
|
||||
const before = part.commits;
|
||||
const color = sprite.color;
|
||||
for (let n = 0; n < 1000; n++) {
|
||||
sprite.show = true;
|
||||
sprite.rotation = 0;
|
||||
sprite.scale = 1;
|
||||
sprite.width = 20;
|
||||
sprite.color = WHITE;
|
||||
}
|
||||
await Promise.resolve();
|
||||
assert.equal(part.commits, before);
|
||||
assert.equal(sprite.color, color);
|
||||
renderer.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('reused sprite field records are copied, including a mutated color getter', async () => {
|
||||
const { engine, renderer } = await setup();
|
||||
const a = renderer.fleet.add({
|
||||
id: 'a',
|
||||
position: geo.cartesian3.fromDegrees(0, 0, 1000),
|
||||
image: aircraftIcon('light'),
|
||||
});
|
||||
const b = renderer.fleet.add({
|
||||
id: 'b',
|
||||
position: a.position,
|
||||
image: aircraftIcon('light'),
|
||||
});
|
||||
await Promise.resolve();
|
||||
const [listed] = setsOf(engine, 'flights-sprites');
|
||||
const { store } = engine.fake.moverSet(listed.id);
|
||||
const position = geo.cartesian3.create(7, 8, 9);
|
||||
a.position = position;
|
||||
position.x = 20;
|
||||
b.position = position;
|
||||
a.color.alpha = 0.25;
|
||||
a.color = a.color;
|
||||
b.color = WHITE.withAlpha(0.75);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual([...store.positions.subarray(0, 6)], [7, 8, 9, 20, 8, 9]);
|
||||
assert.equal(store.colors[3], 0.25);
|
||||
assert.equal(store.colors[7], 0.75);
|
||||
renderer.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
@@ -1048,7 +1048,11 @@ export function createTracking({
|
||||
} else if (flightState._renderer.fleet.visible()) {
|
||||
// Fire-and-forget refresh; only while the layer is actually enabled.
|
||||
// The query reads the renderer's camera (no viewer argument needed).
|
||||
void flightsLayer.update(null);
|
||||
void flightsLayer.update(null).catch((error) => {
|
||||
// This refresh has no lifecycle-manager caller. Disabling flights
|
||||
// cancels it locally; retain visibility of unexpected failures.
|
||||
if (error?.name !== 'AbortError') console.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createTracking } from './tracking.js';
|
||||
|
||||
function handoff(update) {
|
||||
return createTracking({
|
||||
flightState: {
|
||||
_renderer: { fleet: { attached: () => true, visible: () => true } },
|
||||
},
|
||||
services: Object.fromEntries(
|
||||
[
|
||||
'context',
|
||||
'aircraftPresentation',
|
||||
'geoid',
|
||||
'groundFloor',
|
||||
'focus',
|
||||
'militaryRegistry',
|
||||
'readout',
|
||||
'selection',
|
||||
].map((name) => [name, {}]),
|
||||
),
|
||||
parts: {},
|
||||
layer: { update },
|
||||
});
|
||||
}
|
||||
|
||||
test('a military-off refresh owns cancellation when flights are disabled before it settles', async (t) => {
|
||||
const errors = [];
|
||||
t.mock.method(console, 'error', (error) => errors.push(error));
|
||||
let reject,
|
||||
calls = 0;
|
||||
const tracking = handoff(() => {
|
||||
calls++;
|
||||
return new Promise((_resolve, fail) => {
|
||||
reject = fail;
|
||||
});
|
||||
});
|
||||
tracking._onMilitaryActiveChange(false);
|
||||
assert.equal(calls, 1);
|
||||
reject(new DOMException('Flights update aborted', 'AbortError'));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test('unexpected military-off refresh failures remain visible instead of being swallowed', async (t) => {
|
||||
const error = new Error('fixture update failure');
|
||||
const errors = [];
|
||||
t.mock.method(console, 'error', (value) => errors.push(value));
|
||||
handoff(async () => {
|
||||
throw error;
|
||||
})._onMilitaryActiveChange(false);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(errors, [error]);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from './card.js';
|
||||
import { createLocalAdsbEnrichment, localAdsbClass } from './enrichment.js';
|
||||
import { createLocalAdsbModels } from './models.js';
|
||||
import { localAdsbQaDrawn } from './qaDrawn.js';
|
||||
import { LocalAdsbMotion } from './motion.js';
|
||||
import {
|
||||
ENTITY_PREFIX,
|
||||
@@ -714,6 +715,18 @@ export function createLocalAdsbLayer({
|
||||
void receiver.destroy?.();
|
||||
},
|
||||
|
||||
...(import.meta.env?.DEV
|
||||
? {
|
||||
qaDrawn() {
|
||||
return localAdsbQaDrawn(markers, models?.qaModels?.(), {
|
||||
enabled,
|
||||
cameraPosition: renderer?.view.camera?.positionWC,
|
||||
isVisible: markerVisible,
|
||||
});
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
getStats() {
|
||||
const current = receiver.getState();
|
||||
const at = now();
|
||||
|
||||
@@ -178,6 +178,7 @@ export function createLocalAdsbModels({
|
||||
}
|
||||
|
||||
return {
|
||||
...(import.meta.env?.DEV ? { qaModels: () => models } : {}),
|
||||
/** Ids whose model admission is current; the layer enriches these. */
|
||||
get eligible() {
|
||||
return eligible;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// On-demand QA snapshots of the cached local renderer state. Never read
|
||||
// an entity CallbackProperty: that would evaluate rendering during a probe.
|
||||
export function localAdsbQaDrawn(
|
||||
markers,
|
||||
models,
|
||||
{ enabled, cameraPosition, isVisible },
|
||||
) {
|
||||
const rows = [];
|
||||
for (const marker of markers.values()) {
|
||||
const position = marker.position;
|
||||
const distance =
|
||||
position && cameraPosition
|
||||
? Math.hypot(
|
||||
position.x - cameraPosition.x,
|
||||
position.y - cameraPosition.y,
|
||||
position.z - cameraPosition.z,
|
||||
)
|
||||
: Infinity;
|
||||
const show = Boolean(
|
||||
enabled &&
|
||||
!marker.modelOwnsVisual &&
|
||||
isVisible(marker) &&
|
||||
distance <= 2_000_000,
|
||||
);
|
||||
const model = models?.get(marker.id);
|
||||
const matrix = model?.modelMatrix;
|
||||
rows.push({
|
||||
id: marker.record.icao,
|
||||
show,
|
||||
rotation: marker.lastRotation,
|
||||
alpha: show ? 1 : 0,
|
||||
position: position
|
||||
? { x: position.x, y: position.y, z: position.z }
|
||||
: null,
|
||||
model: model
|
||||
? {
|
||||
show: model.show,
|
||||
ready: model.ready,
|
||||
computedScale: model.computedScale,
|
||||
position: matrix
|
||||
? { x: matrix[12], y: matrix[13], z: matrix[14] }
|
||||
: null,
|
||||
matrix: matrix ? Array.from(matrix) : null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { localAdsbQaDrawn } from './qaDrawn.js';
|
||||
function setup() {
|
||||
const marker = {
|
||||
id: 'local-adsb:abc123',
|
||||
record: { icao: 'abc123' },
|
||||
position: { x: 6378137, y: 2, z: 3 },
|
||||
lastRotation: 0.7,
|
||||
modelOwnsVisual: false,
|
||||
};
|
||||
Object.defineProperty(marker, 'entity', {
|
||||
get() {
|
||||
throw new Error('A snapshot must not evaluate native callbacks');
|
||||
},
|
||||
});
|
||||
const matrix = new Float64Array(16);
|
||||
matrix[12] = 6378138;
|
||||
matrix[13] = 4;
|
||||
matrix[14] = 5;
|
||||
const model = {
|
||||
ready: true,
|
||||
show: true,
|
||||
computedScale: 12,
|
||||
modelMatrix: matrix,
|
||||
};
|
||||
const markers = new Map([[marker.id, marker]]),
|
||||
models = new Map([[marker.id, model]]);
|
||||
const options = {
|
||||
enabled: true,
|
||||
cameraPosition: { x: 6379137, y: 2, z: 3 },
|
||||
isVisible: () => true,
|
||||
};
|
||||
return { marker, matrix, markers, models, options };
|
||||
}
|
||||
test('local QA copies the drawn model and reads cached glyph rotation without callbacks', () => {
|
||||
const f = setup();
|
||||
const [row] = localAdsbQaDrawn(f.markers, f.models, f.options);
|
||||
assert.equal(row.id, 'abc123');
|
||||
assert.equal(row.rotation, 0.7);
|
||||
assert.equal(row.alpha, 1);
|
||||
assert.equal(row.model.computedScale, 12);
|
||||
assert.equal(row.model.ready, true);
|
||||
row.position.x = 0;
|
||||
row.model.matrix[12] = 0;
|
||||
row.model.position.x = 0;
|
||||
assert.equal(f.marker.position.x, 6378137);
|
||||
assert.equal(f.matrix[12], 6378138);
|
||||
});
|
||||
test('local QA respects disabled, horizon, model handoff and the two-million-metre glyph limit', () => {
|
||||
for (const change of [
|
||||
(f) => {
|
||||
f.options.enabled = false;
|
||||
},
|
||||
(f) => {
|
||||
f.options.isVisible = () => false;
|
||||
},
|
||||
(f) => {
|
||||
f.marker.modelOwnsVisual = true;
|
||||
},
|
||||
(f) => {
|
||||
f.options.cameraPosition.x += 2_000_000;
|
||||
},
|
||||
]) {
|
||||
const f = setup();
|
||||
change(f);
|
||||
const [row] = localAdsbQaDrawn(f.markers, f.models, f.options);
|
||||
assert.equal(row.show, false);
|
||||
assert.equal(row.alpha, 0);
|
||||
assert.equal(row.model.ready, true);
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
|
||||
import {
|
||||
DEFAULT_DATA_MODE,
|
||||
@@ -7,6 +8,8 @@ import {
|
||||
isHardwareRenderer,
|
||||
isHeadfulMode,
|
||||
parseDataMode,
|
||||
vesselCardCaptureUrl,
|
||||
installSyntheticVesselFeed,
|
||||
vesselCardManifestFilename,
|
||||
vesselCardProvenanceLines,
|
||||
vesselCardScreenshotFilename,
|
||||
@@ -89,3 +92,80 @@ test('vessel-card QA distinguishes hardware evidence from software rendering', (
|
||||
assert.equal(isHardwareRenderer({ vendor: 'Google Inc.', renderer: 'ANGLE (SwiftShader)' }), false);
|
||||
assert.equal(isHardwareRenderer({ vendor: 'unavailable', renderer: 'unavailable' }), false);
|
||||
});
|
||||
|
||||
test('synthetic card URL removes competing qaFeed while live mode preserves it', () => {
|
||||
const input =
|
||||
'http://127.0.0.1:4193/?engine=navara&qaFeed=vessels:fixture#v=2';
|
||||
const synthetic = new URL(vesselCardCaptureUrl(input, 'synthetic'));
|
||||
assert.equal(synthetic.searchParams.has('qaFeed'), false);
|
||||
assert.equal(synthetic.searchParams.get('engine'), 'navara');
|
||||
assert.equal(synthetic.hash, '#v=2');
|
||||
assert.equal(
|
||||
new URL(vesselCardCaptureUrl(input, 'live')).searchParams.get('qaFeed'),
|
||||
'vessels:fixture',
|
||||
);
|
||||
});
|
||||
|
||||
function fixtureWindow() {
|
||||
const rows = [{ mmsi: '990100000', lat: 51.95, lon: 4.1, name: 'QA' }];
|
||||
const clock = { nowMs: 1000 };
|
||||
const calls = [];
|
||||
const window = {
|
||||
fetch: (...args) => {
|
||||
calls.push(args);
|
||||
return Promise.resolve('passthrough');
|
||||
},
|
||||
};
|
||||
runInNewContext(`(${installSyntheticVesselFeed.toString()})(rows)`, {
|
||||
rows,
|
||||
window,
|
||||
location: {
|
||||
href: 'http://127.0.0.1:4193/',
|
||||
origin: 'http://127.0.0.1:4193',
|
||||
},
|
||||
URL,
|
||||
Response,
|
||||
Date: { now: () => clock.nowMs },
|
||||
});
|
||||
return { rows, clock, calls, window };
|
||||
}
|
||||
|
||||
test('a late vessel refresh retains the exact card fixture with fresh receipt stamps', async () => {
|
||||
const { window, rows, clock, calls } = fixtureWindow();
|
||||
const first = await (await window.fetch('/api/vessels?lat=51.95')).json();
|
||||
clock.nowMs = 5000;
|
||||
const late = await (
|
||||
await window.fetch(
|
||||
new Request('http://127.0.0.1:4193/api/vessels?lat=29.7'),
|
||||
)
|
||||
).json();
|
||||
assert.deepEqual(
|
||||
first.rows.map((row) => row.mmsi),
|
||||
['990100000'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
late.rows.map((row) => row.mmsi),
|
||||
['990100000'],
|
||||
);
|
||||
assert.equal(late.newestPositionAt, 5000);
|
||||
assert.equal(late.rows[0].last_position_epoch, 5);
|
||||
const urlInput = await (
|
||||
await window.fetch(new URL('http://127.0.0.1:4193/api/vessels'))
|
||||
).json();
|
||||
assert.deepEqual(
|
||||
urlInput.rows.map((row) => row.mmsi),
|
||||
['990100000'],
|
||||
);
|
||||
assert.equal(rows[0].last_position_epoch, undefined);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('the card fixture leaves other paths and external origins to the original fetch', async () => {
|
||||
const { window, calls } = fixtureWindow();
|
||||
assert.equal(await window.fetch('/api/vessels/track'), 'passthrough');
|
||||
assert.equal(
|
||||
await window.fetch('https://example.invalid/api/vessels'),
|
||||
'passthrough',
|
||||
);
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
@@ -82,6 +82,36 @@ for (const [name, files, pattern] of [
|
||||
},
|
||||
/reaches browser globals/,
|
||||
],
|
||||
[
|
||||
'W1 shared focusDeemphasis keeps native imports in render',
|
||||
{ 'src/data/focusDeemphasis.js': "import 'cesium';" },
|
||||
/cesium only in the layer's render/,
|
||||
],
|
||||
[
|
||||
'W1 shared trailRenderer keeps native imports in render',
|
||||
{ 'src/data/trailRenderer.js': "import 'cesium';" },
|
||||
/cesium only in the layer's render/,
|
||||
],
|
||||
[
|
||||
'W1 shared contactTrailRenderer keeps native imports in render',
|
||||
{ 'src/data/contactTrailRenderer.js': "import 'cesium';" },
|
||||
/cesium only in the layer's render/,
|
||||
],
|
||||
[
|
||||
'W1 shared iconOrientation keeps native imports in render',
|
||||
{ 'src/data/iconOrientation.js': "import 'cesium';" },
|
||||
/cesium only in the layer's render/,
|
||||
],
|
||||
[
|
||||
'W1 shared modelVisualAnchor keeps native imports in render',
|
||||
{ 'src/data/modelVisualAnchor.js': "import 'cesium';" },
|
||||
/cesium only in the layer's render/,
|
||||
],
|
||||
[
|
||||
'W1 shared trackingClickGesture keeps native imports in render',
|
||||
{ 'src/data/trackingClickGesture.js': "import 'cesium';" },
|
||||
/cesium only in the layer's render/,
|
||||
],
|
||||
[
|
||||
'geo module imports cesium',
|
||||
{ 'src/geo/demo.js': "import 'cesium';" },
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
photoImages,
|
||||
THUMB_HOST,
|
||||
} from '../../scripts/fixtures/street-level/mapillaryGraph.mjs';
|
||||
import { redact } from '../../scripts/qa-browserEvidence.mjs';
|
||||
import {
|
||||
redact,
|
||||
isExpectedPreviewSetupError,
|
||||
} from '../../scripts/qa-browserEvidence.mjs';
|
||||
|
||||
// The press helpers wait for paint; Node has no frames, so a tick stands in.
|
||||
globalThis.requestAnimationFrame ??= (callback) =>
|
||||
@@ -639,3 +642,41 @@ test('CI runs the gates hermetically against a production build, strict, with ev
|
||||
'a missed header press fails CI now that the rail settles',
|
||||
);
|
||||
});
|
||||
|
||||
test('preview setup API 404 is expected only with explicit preview mode', () => {
|
||||
const message = {
|
||||
type: () => 'error',
|
||||
text: () =>
|
||||
'Failed to load resource: the server responded with a status of 404 (Not Found)',
|
||||
location: () => ({ url: 'http://127.0.0.1:4195/api/setup/status' }),
|
||||
};
|
||||
const options = { preview: true, baseUrl: 'http://127.0.0.1:4195/' };
|
||||
assert.equal(isExpectedPreviewSetupError(message, options), true);
|
||||
assert.equal(
|
||||
isExpectedPreviewSetupError(message, { ...options, preview: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('preview mode never waives other errors, origins, paths or missing locations', () => {
|
||||
const options = { preview: true, baseUrl: 'http://127.0.0.1:4195/' };
|
||||
for (const [text, url] of [
|
||||
['Failed to load resource: 404', 'http://127.0.0.1:4195/api/flights'],
|
||||
['Failed to load resource: 404', 'https://example.com/api/setup/status'],
|
||||
['Failed to load resource: 500', 'http://127.0.0.1:4195/api/setup/status'],
|
||||
['Unhandled TypeError: 404', 'http://127.0.0.1:4195/api/setup/status'],
|
||||
['Failed to load resource: 404', undefined],
|
||||
]) {
|
||||
assert.equal(
|
||||
isExpectedPreviewSetupError(
|
||||
{
|
||||
type: () => 'error',
|
||||
text: () => text,
|
||||
location: () => ({ url }),
|
||||
},
|
||||
options,
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { watchW1Page } from '../../scripts/qa-browserEvidence.mjs';
|
||||
const baseUrl = 'http://127.0.0.1:4195/';
|
||||
const message = (type, text, url) => ({
|
||||
type: () => type,
|
||||
text: () => text,
|
||||
location: () => ({ url }),
|
||||
});
|
||||
test('W1 main and share pages aggregate console, runtime and crash errors', () => {
|
||||
const report = {},
|
||||
main = new EventEmitter(),
|
||||
shared = new EventEmitter();
|
||||
watchW1Page(main, report, { name: 'main', baseUrl });
|
||||
watchW1Page(shared, report, { name: 'share', baseUrl });
|
||||
main.emit('console', message('warning', 'ordinary warning', baseUrl));
|
||||
shared.emit(
|
||||
'console',
|
||||
message('error', 'late error', baseUrl + 'api/x?key=sample-secret'),
|
||||
);
|
||||
shared.emit('pageerror', new Error('runtime failure'));
|
||||
shared.emit('error', new Error('renderer crashed'));
|
||||
assert.deepEqual(report.runtimePages, ['main', 'share']);
|
||||
assert.equal(report.browserErrors.length, 3);
|
||||
assert.match(report.browserErrors[0], /\[share\] late error/);
|
||||
assert.ok(!JSON.stringify(report).includes('sample-secret'));
|
||||
});
|
||||
test('W1 preview exception stays exact on a share page', () => {
|
||||
const report = {},
|
||||
shared = new EventEmitter();
|
||||
watchW1Page(shared, report, { name: 'share', baseUrl, preview: true });
|
||||
const failure = (url) =>
|
||||
shared.emit(
|
||||
'console',
|
||||
message(
|
||||
'error',
|
||||
'Failed to load resource: the server responded with a status of 404',
|
||||
url,
|
||||
),
|
||||
);
|
||||
failure(baseUrl + 'api/setup/status');
|
||||
failure(baseUrl + 'api/other');
|
||||
failure('https://example.invalid/api/setup/status');
|
||||
assert.deepEqual(report.expectedPreviewErrors, ['/api/setup/status: 404']);
|
||||
assert.equal(report.browserErrors.length, 2);
|
||||
});
|
||||
test('W1 response diagnostics retain page/status/path without query credentials', () => {
|
||||
const report = {},
|
||||
shared = new EventEmitter();
|
||||
watchW1Page(shared, report, { name: 'share', baseUrl });
|
||||
shared.emit('response', {
|
||||
status: () => 503,
|
||||
url: () => baseUrl + 'api/vessels?token=sample-secret',
|
||||
});
|
||||
assert.deepEqual(report.failedResponses, [
|
||||
{ page: 'share', status: 503, resource: baseUrl + 'api/vessels' },
|
||||
]);
|
||||
assert.ok(!JSON.stringify(report).includes('sample-secret'));
|
||||
});
|
||||
Reference in new issue
Block a user