From 06009642499a0b91517d281c864caeae27e42ef8 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 26 Oct 2026 23:22:53 +0400 Subject: [PATCH] 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. --- scripts/check-import-directions.mjs | 14 + scripts/format-scope.json | 21 +- scripts/package-boundaries.json | 29 ++ scripts/qa-browserEvidence.mjs | 64 ++++ scripts/qa-enrich-ambient.mjs | 80 +++-- scripts/qa-floor-hold.mjs | 42 ++- scripts/qa-floor-verify.mjs | 2 +- scripts/qa-focus-evidence.mjs | 68 +++- scripts/qa-heading-b3.mjs | 81 +++-- scripts/qa-heading-navara.mjs | 128 +++++++ scripts/qa-height-datum.mjs | 24 +- scripts/qa-label-readability.mjs | 2 +- scripts/qa-labels.mjs | 2 +- scripts/qa-overlay-baseline.mjs | 12 +- scripts/qa-perf.mjs | 1 + scripts/qa-sprites-b5.mjs | 18 +- scripts/qa-vessel-cards.mjs | 43 ++- scripts/qa-voice-routing.mjs | 3 +- src/app/layers/qaSources.js | 10 +- src/app/layers/qaSources.test.mjs | 48 +++ src/data/contactTrailRenderer.js | 313 +----------------- src/data/focusDeemphasis.js | 14 +- src/data/iconOrientation.js | 17 +- src/data/modelVisualAnchor.js | 6 +- src/data/render/cesiumConstants.js | 5 + src/data/render/cesiumContactTrailRenderer.js | 309 +++++++++++++++++ src/data/render/cesiumProjection.js | 16 + src/data/render/cesiumTrailRenderer.js | 141 ++++++++ src/data/trackingClickGesture.js | 4 +- src/data/trailRenderer.js | 142 +------- src/globe/contract.js | 8 +- src/globe/navara/followGestures.js | 6 +- src/globe/navara/followGestures.test.mjs | 12 +- src/globe/navara/index.js | 11 +- src/globe/navara/movers/frameCamera.js | 78 +++++ src/globe/navara/movers/frameCamera.test.mjs | 103 ++++++ src/globe/navara/movers/index.js | 60 ++-- src/globe/navara/movers/modelMover.js | 122 +++++-- src/globe/navara/movers/modelMover.test.mjs | 197 ++++++++++- src/globe/navara/movers/modelTint.js | 73 ++++ src/globe/navara/overlay.js | 79 +++-- src/globe/navara/overlayPass.js | 23 ++ src/globe/navara/overlayPass.test.mjs | 43 +++ src/globe/navara/sharpen.js | 4 +- src/globe/navara/viewOptions.js | 2 +- src/layers/aircraft/qaDrawn.js | 8 + src/layers/aircraft/qaDrawn.test.mjs | 43 +++ src/layers/aircraft/render/navara.js | 40 ++- src/layers/aircraft/render/navara.test.mjs | 56 ++++ src/layers/flights/tracking.js | 6 +- src/layers/flights/trackingRefresh.test.mjs | 54 +++ src/layers/localAdsb/index.js | 13 + src/layers/localAdsb/models.js | 1 + src/layers/localAdsb/qaDrawn.js | 49 +++ src/layers/localAdsb/qaDrawn.test.mjs | 72 ++++ src/qaVesselCardsHarness.test.mjs | 80 +++++ src/tooling/importDirections.test.mjs | 30 ++ src/tooling/streetLevelQa.test.mjs | 43 ++- src/tooling/w1RuntimeEvidence.test.mjs | 60 ++++ 59 files changed, 2334 insertions(+), 701 deletions(-) create mode 100644 scripts/qa-heading-navara.mjs create mode 100644 src/data/render/cesiumConstants.js create mode 100644 src/data/render/cesiumContactTrailRenderer.js create mode 100644 src/data/render/cesiumProjection.js create mode 100644 src/data/render/cesiumTrailRenderer.js create mode 100644 src/globe/navara/movers/frameCamera.js create mode 100644 src/globe/navara/movers/frameCamera.test.mjs create mode 100644 src/globe/navara/movers/modelTint.js create mode 100644 src/globe/navara/overlayPass.js create mode 100644 src/globe/navara/overlayPass.test.mjs create mode 100644 src/layers/aircraft/qaDrawn.test.mjs create mode 100644 src/layers/flights/trackingRefresh.test.mjs create mode 100644 src/layers/localAdsb/qaDrawn.js create mode 100644 src/layers/localAdsb/qaDrawn.test.mjs create mode 100644 src/tooling/w1RuntimeEvidence.test.mjs diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 0d1652527..648ecfec2 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -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)); diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2d2aa61b4..e8179a0c4 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d4a83f7d2..f059dda89 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-browserEvidence.mjs b/scripts/qa-browserEvidence.mjs index 46f889d50..8861bf5fe 100644 --- a/scripts/qa-browserEvidence.mjs +++ b/scripts/qa-browserEvidence.mjs @@ -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) diff --git a/scripts/qa-enrich-ambient.mjs b/scripts/qa-enrich-ambient.mjs index 3a6cd8ad5..3c8c62a88 100644 --- a/scripts/qa-enrich-ambient.mjs +++ b/scripts/qa-enrich-ambient.mjs @@ -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 diff --git a/scripts/qa-floor-hold.mjs b/scripts/qa-floor-hold.mjs index 0b67b3994..b601667d5 100644 --- a/scripts/qa-floor-hold.mjs +++ b/scripts/qa-floor-hold.mjs @@ -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; diff --git a/scripts/qa-floor-verify.mjs b/scripts/qa-floor-verify.mjs index b7a8a80da..ebb75303b 100644 --- a/scripts/qa-floor-verify.mjs +++ b/scripts/qa-floor-verify.mjs @@ -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 }); diff --git a/scripts/qa-focus-evidence.mjs b/scripts/qa-focus-evidence.mjs index 1baaf6083..707c81f2e 100644 --- a/scripts/qa-focus-evidence.mjs +++ b/scripts/qa-focus-evidence.mjs @@ -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(), diff --git a/scripts/qa-heading-b3.mjs b/scripts/qa-heading-b3.mjs index cbba1b88e..c8d458e88 100644 --- a/scripts/qa-heading-b3.mjs +++ b/scripts/qa-heading-b3.mjs @@ -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(); diff --git a/scripts/qa-heading-navara.mjs b/scripts/qa-heading-navara.mjs new file mode 100644 index 000000000..1ae9a98f8 --- /dev/null +++ b/scripts/qa-heading-navara.mjs @@ -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'); + }); + }); +} diff --git a/scripts/qa-height-datum.mjs b/scripts/qa-height-datum.mjs index b9f42fefd..05b96b47c 100644 --- a/scripts/qa-height-datum.mjs +++ b/scripts/qa-height-datum.mjs @@ -213,7 +213,8 @@ async function readCameraGround(page, camId) { * @returns {Promise} 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). // ----------------------------------------------------------------------- diff --git a/scripts/qa-label-readability.mjs b/scripts/qa-label-readability.mjs index f3f8219d6..0d22a7393 100644 --- a/scripts/qa-label-readability.mjs +++ b/scripts/qa-label-readability.mjs @@ -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', diff --git a/scripts/qa-labels.mjs b/scripts/qa-labels.mjs index 4556b4306..c37386f89 100644 --- a/scripts/qa-labels.mjs +++ b/scripts/qa-labels.mjs @@ -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', diff --git a/scripts/qa-overlay-baseline.mjs b/scripts/qa-overlay-baseline.mjs index 72682c089..fc22904b0 100644 --- a/scripts/qa-overlay-baseline.mjs +++ b/scripts/qa-overlay-baseline.mjs @@ -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, diff --git a/scripts/qa-perf.mjs b/scripts/qa-perf.mjs index 86b40ba40..e878722e3 100644 --- a/scripts/qa-perf.mjs +++ b/scripts/qa-perf.mjs @@ -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', diff --git a/scripts/qa-sprites-b5.mjs b/scripts/qa-sprites-b5.mjs index 68351f5ef..d640c3cf4 100644 --- a/scripts/qa-sprites-b5.mjs +++ b/scripts/qa-sprites-b5.mjs @@ -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)', diff --git a/scripts/qa-vessel-cards.mjs b/scripts/qa-vessel-cards.mjs index 4f8889b5c..f9f121b7d 100644 --- a/scripts/qa-vessel-cards.mjs +++ b/scripts/qa-vessel-cards.mjs @@ -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`); diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index f1b2b0ced..f074c4d0f 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -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', diff --git a/src/app/layers/qaSources.js b/src/app/layers/qaSources.js index d86b5e38b..616c97172 100644 --- a/src/app/layers/qaSources.js +++ b/src/app/layers/qaSources.js @@ -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') ? { diff --git a/src/app/layers/qaSources.test.mjs b/src/app/layers/qaSources.test.mjs index ea7f73cb6..7d62074a0 100644 --- a/src/app/layers/qaSources.test.mjs +++ b/src/app/layers/qaSources.test.mjs @@ -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); +}); diff --git a/src/data/contactTrailRenderer.js b/src/data/contactTrailRenderer.js index 1f82f3b9c..fae52209e 100644 --- a/src/data/contactTrailRenderer.js +++ b/src/data/contactTrailRenderer.js @@ -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'; diff --git a/src/data/focusDeemphasis.js b/src/data/focusDeemphasis.js index 6d39baf04..4e2081e44 100644 --- a/src/data/focusDeemphasis.js +++ b/src/data/focusDeemphasis.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, diff --git a/src/data/iconOrientation.js b/src/data/iconOrientation.js index 0c1c41975..a401a005f 100644 --- a/src/data/iconOrientation.js +++ b/src/data/iconOrientation.js @@ -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 diff --git a/src/data/modelVisualAnchor.js b/src/data/modelVisualAnchor.js index 613332618..2dd2302d7 100644 --- a/src/data/modelVisualAnchor.js +++ b/src/data/modelVisualAnchor.js @@ -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(); diff --git a/src/data/render/cesiumConstants.js b/src/data/render/cesiumConstants.js new file mode 100644 index 000000000..44d7054e4 --- /dev/null +++ b/src/data/render/cesiumConstants.js @@ -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'; diff --git a/src/data/render/cesiumContactTrailRenderer.js b/src/data/render/cesiumContactTrailRenderer.js new file mode 100644 index 000000000..1f82f3b9c --- /dev/null +++ b/src/data/render/cesiumContactTrailRenderer.js @@ -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, + }; + }, + }; +} diff --git a/src/data/render/cesiumProjection.js b/src/data/render/cesiumProjection.js new file mode 100644 index 000000000..3075904c2 --- /dev/null +++ b/src/data/render/cesiumProjection.js @@ -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; +} diff --git a/src/data/render/cesiumTrailRenderer.js b/src/data/render/cesiumTrailRenderer.js new file mode 100644 index 000000000..34226c381 --- /dev/null +++ b/src/data/render/cesiumTrailRenderer.js @@ -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; + }, + }; +} diff --git a/src/data/trackingClickGesture.js b/src/data/trackingClickGesture.js index 86cc30814..7ac611893 100644 --- a/src/data/trackingClickGesture.js +++ b/src/data/trackingClickGesture.js @@ -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; diff --git a/src/data/trailRenderer.js b/src/data/trailRenderer.js index 8c69ca31c..6ade3eed2 100644 --- a/src/data/trailRenderer.js +++ b/src/data/trailRenderer.js @@ -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'; diff --git a/src/globe/contract.js b/src/globe/contract.js index a0f640752..368259b85 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.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 diff --git a/src/globe/navara/followGestures.js b/src/globe/navara/followGestures.js index 67dc35323..c4becaa9d 100644 --- a/src/globe/navara/followGestures.js +++ b/src/globe/navara/followGestures.js @@ -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, ); }; diff --git a/src/globe/navara/followGestures.test.mjs b/src/globe/navara/followGestures.test.mjs index 7d3f7ebe1..dd90a7fa7 100644 --- a/src/globe/navara/followGestures.test.mjs +++ b/src/globe/navara/followGestures.test.mjs @@ -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'); diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index f47f6ff2d..16ddcda88 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -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; diff --git a/src/globe/navara/movers/frameCamera.js b/src/globe/navara/movers/frameCamera.js new file mode 100644 index 000000000..e58afd4a4 --- /dev/null +++ b/src/globe/navara/movers/frameCamera.js @@ -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; + }; +} diff --git a/src/globe/navara/movers/frameCamera.test.mjs b/src/globe/navara/movers/frameCamera.test.mjs new file mode 100644 index 000000000..816dc1664 --- /dev/null +++ b/src/globe/navara/movers/frameCamera.test.mjs @@ -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); +}); diff --git a/src/globe/navara/movers/index.js b/src/globe/navara/movers/index.js index 446873b5e..f63b33454 100644 --- a/src/globe/navara/movers/index.js +++ b/src/globe/navara/movers/index.js @@ -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 }} ports + * @param {{ overlay: ReturnType, 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(); }, }; diff --git a/src/globe/navara/movers/modelMover.js b/src/globe/navara/movers/modelMover.js index ed946e861..9451d4c39 100644 --- a/src/globe/navara/movers/modelMover.js +++ b/src/globe/navara/movers/modelMover.js @@ -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 ', '#include \ntotalEmissiveRadiance += diffuseColor.rgb * uGevAmbient;', ) + .replace('#include ', `#include \n${alpha}`) .replace( - '#include ', - '#include \ndiffuseColor.rgb = mix(diffuseColor.rgb, sRGBTransferEOTF(vGevColor).rgb, uGevBlend);\ndiffuseColor.a *= vGevColor.a;', + '#include ', + `#include \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(); }, }; } diff --git a/src/globe/navara/movers/modelMover.test.mjs b/src/globe/navara/movers/modelMover.test.mjs index 034d650e5..287a2888a 100644 --- a/src/globe/navara/movers/modelMover.test.mjs +++ b/src/globe/navara/movers/modelMover.test.mjs @@ -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 \n}', fragmentShader: - 'void main() {\n#include \n#include \n}', + 'void main() {\n#include \n#include \n#include \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 '), + ); + 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 \n}', + fragmentShader: + 'void main() {\n#include \n#include \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 \n}', + fragmentShader: 'void main() {\n#include \n}', + }; + mesh.material.onBeforeCompile(shader); + assert.doesNotMatch( + shader.fragmentShader, + /gevModelAces\(gl_FragColor.rgb\)/, + ); + mover.dispose(); +}); diff --git a/src/globe/navara/movers/modelTint.js b/src/globe/navara/movers/modelTint.js new file mode 100644 index 000000000..f2c879fd3 --- /dev/null +++ b/src/globe/navara/movers/modelTint.js @@ -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 ', + '#include \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 ', + '#include \nif (gevOverlayHidden()) discard;', + ) + .replace( + '#include ', + '#include \ndiffuseColor.a *= vGevColor.a;', + ) + .replace( + '#include ', + '#include \ntotalEmissiveRadiance += diffuseColor.rgb * uGevAmbient;', + ) + .replace( + '#include ', + `#include \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; +} diff --git a/src/globe/navara/overlay.js b/src/globe/navara/overlay.js index 4fda33aba..99e5b6f77 100644 --- a/src/globe/navara/overlay.js +++ b/src/globe/navara/overlay.js @@ -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(); }, }; diff --git a/src/globe/navara/overlayPass.js b/src/globe/navara/overlayPass.js new file mode 100644 index 000000000..379f98a00 --- /dev/null +++ b/src/globe/navara/overlayPass.js @@ -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; + } + } +} diff --git a/src/globe/navara/overlayPass.test.mjs b/src/globe/navara/overlayPass.test.mjs new file mode 100644 index 000000000..ba159c768 --- /dev/null +++ b/src/globe/navara/overlayPass.test.mjs @@ -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(); +}); diff --git a/src/globe/navara/sharpen.js b/src/globe/navara/sharpen.js index f4a445074..651f03c39 100644 --- a/src/globe/navara/sharpen.js +++ b/src/globe/navara/sharpen.js @@ -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); diff --git a/src/globe/navara/viewOptions.js b/src/globe/navara/viewOptions.js index b04076a0e..4cffab0d0 100644 --- a/src/globe/navara/viewOptions.js +++ b/src/globe/navara/viewOptions.js @@ -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 diff --git a/src/layers/aircraft/qaDrawn.js b/src/layers/aircraft/qaDrawn.js index 4a2fd087e..42ca00f0e 100644 --- a/src/layers/aircraft/qaDrawn.js +++ b/src/layers/aircraft/qaDrawn.js @@ -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; diff --git a/src/layers/aircraft/qaDrawn.test.mjs b/src/layers/aircraft/qaDrawn.test.mjs new file mode 100644 index 000000000..d73924b5b --- /dev/null +++ b/src/layers/aircraft/qaDrawn.test.mjs @@ -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); + }); +} diff --git a/src/layers/aircraft/render/navara.js b/src/layers/aircraft/render/navara.js index 05fe7ad6b..6b1c13afe 100644 --- a/src/layers/aircraft/render/navara.js +++ b/src/layers/aircraft/render/navara.js @@ -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() { diff --git a/src/layers/aircraft/render/navara.test.mjs b/src/layers/aircraft/render/navara.test.mjs index 08730e0ec..88b7d31e4 100644 --- a/src/layers/aircraft/render/navara.test.mjs +++ b/src/layers/aircraft/render/navara.test.mjs @@ -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(); +}); diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index 6ecfe7543..f7fac861b 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -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); + }); } } diff --git a/src/layers/flights/trackingRefresh.test.mjs b/src/layers/flights/trackingRefresh.test.mjs new file mode 100644 index 000000000..040bd130d --- /dev/null +++ b/src/layers/flights/trackingRefresh.test.mjs @@ -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]); +}); diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index b92a2f34c..954051c32 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -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(); diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js index adb8ff978..85377fbbb 100644 --- a/src/layers/localAdsb/models.js +++ b/src/layers/localAdsb/models.js @@ -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; diff --git a/src/layers/localAdsb/qaDrawn.js b/src/layers/localAdsb/qaDrawn.js new file mode 100644 index 000000000..2f2feb7ee --- /dev/null +++ b/src/layers/localAdsb/qaDrawn.js @@ -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; +} diff --git a/src/layers/localAdsb/qaDrawn.test.mjs b/src/layers/localAdsb/qaDrawn.test.mjs new file mode 100644 index 000000000..697c3dad2 --- /dev/null +++ b/src/layers/localAdsb/qaDrawn.test.mjs @@ -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); + } +}); diff --git a/src/qaVesselCardsHarness.test.mjs b/src/qaVesselCardsHarness.test.mjs index 3902ea680..1a5ce7138 100644 --- a/src/qaVesselCardsHarness.test.mjs +++ b/src/qaVesselCardsHarness.test.mjs @@ -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); +}); diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index c36b4f56c..47d3c1dc9 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -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';" }, diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index e1e38bfb5..c1a1a59dd 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -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, + ); + } +}); diff --git a/src/tooling/w1RuntimeEvidence.test.mjs b/src/tooling/w1RuntimeEvidence.test.mjs new file mode 100644 index 000000000..d5f21186f --- /dev/null +++ b/src/tooling/w1RuntimeEvidence.test.mjs @@ -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')); +});