Complete phase 3 W1 acceptance and close T16 findings

Fix Navara model display tint, overlay ordering, follow gestures and resource cleanup.
Reduce repeated aircraft work and correct fixture enrichment responses.
Strengthen dev/preview QA, preserve failure history and document verified W1 results.

Validation: 7171 ordinary tests pass, one Windows-only skip; 15 allocation probes;
37 W1 acceptance steps; contracts, shell, four performance gates; 18 identical
Cesium baseline PNGs. Known pre-existing CCTV and live-source limits are recorded.
This commit is contained in:
German Khokhlov committed 2026-10-26 23:23:25 +04:00
1 parent 766c786093
commit 0600964249
59 files changed
+2334 -701

No files matched your search

+14
View File
@@ -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));
+20 -1
View File
@@ -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"
]
+29
View File
@@ -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",
+64
View File
@@ -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)
+50 -30
View File
@@ -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
+36 -6
View File
@@ -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;
+1 -1
View File
@@ -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 });
+54 -14
View File
@@ -17,6 +17,7 @@
* --headful drops those flags and is the real-GPU visual sign-off path.
*/
import { redact } from './qa-browserEvidence.mjs';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -29,7 +30,13 @@ const getOpt = (name, fallback = null) => {
};
const hasFlag = (name) => argv.includes(name);
const APP_URL = getOpt('--url', 'http://localhost:4173');
const appUrl = new URL(getOpt('--url', 'http://localhost:4173'));
const requestedEngine = argv.find((value) => value.startsWith('--engine='))?.slice(9) || getOpt('--engine');
if (requestedEngine) {
if (!['cesium', 'navara'].includes(requestedEngine)) throw new Error('Unknown engine');
appUrl.searchParams.set('engine', requestedEngine);
}
const APP_URL = appUrl.href;
const JSON_PATH = path.resolve(getOpt('--json', 'qa-shots/focus-evidence/report.json'));
const SCREENSHOTS_DIR = path.resolve(getOpt('--screenshots-dir', 'qa-shots/focus-evidence'));
const HEADFUL = hasFlag('--headful');
@@ -125,6 +132,10 @@ async function readTileReadiness(page) {
const activeStack = controller?.getActiveId?.() || null;
const tileset = controller?.googleTileset || gev.tileset || null;
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
if (!gev.viewer) return {
tilesSettled: null, tileSettleApplicable: false, activeStack,
reason: 'Navara exposes view idle, not tile readiness independently of animated layers',
};
return {
tilesSettled: !applicable || tileset?.tilesLoaded === true,
tileSettleApplicable: applicable,
@@ -142,6 +153,10 @@ async function awaitTilesSettled(page, scenario) {
// the bootstrap field is retained only as a compatibility fallback.
const tileset = controller?.googleTileset || gev.tileset || null;
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
if (!gev.viewer) return {
tilesSettled: null, tileSettleApplicable: false, activeStack,
reason: 'Navara exposes view idle, not tile readiness independently of animated layers',
};
if (!applicable) {
return {
tilesSettled: true,
@@ -166,7 +181,8 @@ async function awaitTilesSettled(page, scenario) {
const deadline = Date.now() + timeoutMs;
try {
while (Date.now() < deadline) {
gev.viewer.scene.render(gev.viewer.clock.currentTime);
if (gev.viewer) gev.viewer.scene.render(gev.viewer.clock.currentTime);
else gev.engine.render.request('focus-evidence');
if (tileset.tilesLoaded === true || allTilesLoadedObserved) {
return {
tilesSettled: true,
@@ -203,7 +219,7 @@ async function capture(page, scenario, frame) {
// WebGL surface is paused. Reading the explicitly rendered canvas captures
// that same frame without handing frame ownership back to the browser.
const dataUrl = await page.evaluate(() => (
window.__godsEyeView.viewer.scene.canvas.toDataURL('image/png')
window.__godsEyeView.engine.host.canvas.toDataURL('image/png')
));
fs.writeFileSync(out, Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'));
return { file: out, ...tileReadiness };
@@ -232,6 +248,14 @@ async function advanceEvidenceFrame(page, deltaMs = FRAME_MS) {
const viewer = gev.viewer;
const seam = gev.dataManager.layers.get('flights').module.__focusEvidence;
const nowMs = seam.advanceFrameClock(stepMs);
if (!viewer) return new Promise((resolve, reject) => {
const timer = setTimeout(() => { remove(); reject(new Error('Evidence frame timeout')); }, 5000);
const remove = gev.engine.frames.postRender.addEventListener(() => {
remove(); clearTimeout(timer);
setTimeout(() => resolve(nowMs), 0);
});
gev.engine.render.request('focus-evidence');
});
const JulianDate = viewer.clock.currentTime.constructor;
JulianDate.addSeconds(viewer.clock.currentTime, stepMs / 1000, viewer.clock.currentTime);
viewer.dataSourceDisplay?.update(viewer.clock.currentTime);
@@ -266,6 +290,7 @@ async function installSyntheticFetches(page) {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
if (url.includes('/api/flights/track')) return json({ path: [] });
@@ -281,9 +306,10 @@ async function installSyntheticFetches(page) {
async function waitForApp(page) {
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
() => Boolean(window.__godsEyeView?.layers && window.__godsEyeView?.dataManager),
{ timeout: 60_000, polling: 200 },
);
await page.evaluate(async () => { window.__focusGeo = await import('/src/geo/index.js'); });
}
async function setBasemap(page, stackId) {
@@ -317,7 +343,18 @@ async function requireEvidenceSeams(page) {
async function setCamera(page, values) {
await page.evaluate(([lon, lat, height, heading, pitch]) => {
const viewer = window.__godsEyeView.viewer;
const app = window.__godsEyeView;
if (!app.viewer) {
const geo = window.__focusGeo;
app.engine.camera.following()?.stop();
app.engine.camera.cancelFlight();
app.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(lon, lat, height),
orientation: { heading, pitch, roll: 0 },
});
return;
}
const viewer = app.viewer;
const ellipsoid = viewer.scene.globe.ellipsoid;
viewer.trackedEntity = undefined;
viewer.camera.cancelFlight?.();
@@ -345,7 +382,7 @@ async function injectAndTrackTarget(page, extraAircraft = [], target = TARGET) {
throw new Error(`Synthetic target setup failed: ${JSON.stringify(result)}`);
}
await page.waitForFunction(
() => Boolean(window.__godsEyeView.viewer.trackedEntity),
() => Boolean(window.__godsEyeView.engine.camera.following()),
{ timeout: 10_000 },
);
await advanceEvidenceDuration(page, 400);
@@ -356,7 +393,8 @@ async function readTrackedBasis(page) {
const gev = window.__godsEyeView;
const layer = gev.dataManager.layers.get('flights').module;
const target = layer.getAllPositions(100).find((entry) => entry.id === targetId)?.position;
const camera = gev.viewer.camera;
const pose = gev.engine.camera.pose();
const camera = gev.viewer?.camera ?? { positionWC: pose.position, rightWC: pose.right, upWC: pose.up, frustum: { fovy: pose.fovy } };
if (!target) throw new Error('Tracked target position unavailable');
const toTarget = {
x: target.x - camera.positionWC.x,
@@ -364,7 +402,7 @@ async function readTrackedBasis(page) {
z: target.z - camera.positionWC.z,
};
const distance = Math.hypot(toTarget.x, toTarget.y, toTarget.z);
const canvasHeight = gev.viewer.scene.canvas.clientHeight;
const canvasHeight = gev.engine.host.size().height;
const metresPerPixel = (2 * distance * Math.tan(camera.frustum.fovy / 2)) / canvasHeight;
return {
target: [target.x, target.y, target.z],
@@ -427,8 +465,8 @@ async function vesselRowsAroundTarget(page) {
const gev = window.__godsEyeView;
const layer = gev.dataManager.layers.get('flights').module;
const target = layer.getAllPositions(100).find((entry) => entry.id === 'f0c001')?.position;
const Cartographic = gev.viewer.camera.positionCartographic.constructor;
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
const Cartographic = { fromCartesian: window.__focusGeo.cartographic.fromCartesian };
const ellipsoid = window.__focusGeo.ellipsoid.WGS84;
const rows = [];
for (let i = 0; i < 12; i += 1) {
const angle = (i / 12) * Math.PI * 2;
@@ -624,7 +662,9 @@ async function browserContext(page, browser) {
},
caveat: HEADFUL
? 'Headful capture omits SwiftShader flags and is the real-GPU visual sign-off path.'
: 'SwiftShader/headless output is relative-only; rerun with --headful for real-GPU sign-off.',
: process.env.QA_ANGLE === 'metal'
? 'Headless ANGLE/Metal capture; renderer and DPR recorded above.'
: 'SwiftShader/headless output is relative-only.',
};
}
@@ -641,7 +681,7 @@ async function main() {
headless: HEADFUL ? false : 'new',
protocolTimeout: 300000,
args: [
...(HEADFUL ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
...(HEADFUL ? [] : process.env.QA_ANGLE ? ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE}`] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
@@ -655,10 +695,10 @@ async function main() {
const consoleMessages = [];
page.on('console', (message) => {
if (['error', 'warning', 'warn'].includes(message.type())) {
consoleMessages.push({ type: message.type(), text: message.text() });
consoleMessages.push({ type: message.type(), text: redact(message.text()) });
}
});
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message }));
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: redact(error.message) }));
const report = {
generatedAt: new Date().toISOString(),
+57 -24
View File
@@ -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();
+128
View File
@@ -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');
});
});
}
+17 -7
View File
@@ -213,7 +213,8 @@ async function readCameraGround(page, camId) {
* @returns {Promise<number|null>} ellipsoid height in metres, or null on failure.
*/
async function fetchReearthEllipsoid(lat, lon) {
const url = `${APP_URL}/api/terrain/heights?points=${encodeURIComponent(`${lon.toFixed(5)},${lat.toFixed(5)}`)}`;
const url = new URL('/api/terrain/heights', APP_URL);
url.searchParams.set('points', `${lon.toFixed(5)},${lat.toFixed(5)}`);
try {
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
if (!res.ok) return null;
@@ -253,7 +254,7 @@ async function main() {
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-gl=angle',
'--use-angle=swiftshader',
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-background-timer-throttling',
@@ -279,7 +280,7 @@ async function main() {
console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
() => window.__godsEyeView?.layers && window.__godsEyeView.dataManager,
{ timeout: 60000 }
);
await sleep(4000);
@@ -287,6 +288,10 @@ async function main() {
// =========================================================================
// Group 1: CCTV ground correctness
// =========================================================================
const hasCctv = await page.evaluate(() => window.__godsEyeView.layers.available('cctv'));
let allCameras = [], londonCams = [], austinCams = [];
if (!hasCctv) console.log(' [SKIP] CCTV ground/regime groups are outside the Navara W1 catalog');
if (hasCctv) {
console.log('Enabling CCTV layer (full city-packs catalog)...');
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
@@ -337,7 +342,7 @@ async function main() {
// `nearbyPlace` string (NOT a stable "San Francisco" literal), so SF-area
// Caltrans cameras are identified by proximity to the SF anchor instead
// (same anchor vite.config.js's CALTRANS_ANCHORS uses for prioritization).
const allCameras = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().cameras);
allCameras = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().cameras);
console.log(`Camera catalog: ${allCameras.length} total.`);
const SF_ANCHOR = { lat: 37.7793, lon: -122.4193 };
@@ -349,8 +354,8 @@ async function main() {
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
const londonCams = allCameras.filter((c) => c.city === 'London').slice(0, 5);
const austinCams = allCameras.filter((c) => c.city === 'Austin').slice(0, 5);
londonCams = allCameras.filter((c) => c.city === 'London').slice(0, 5);
austinCams = allCameras.filter((c) => c.city === 'Austin').slice(0, 5);
const sfCams = allCameras
.filter((c) => c.provider === 'Caltrans' && haversineKm(c.lat, c.lon, SF_ANCHOR.lat, SF_ANCHOR.lon) < 60)
.slice(0, 3);
@@ -456,6 +461,8 @@ async function main() {
record('Austin active camera google-3d ground ∈ [110,135]m', null, 'no Austin camera available to test');
}
}
// =========================================================================
// Group 2: Aircraft render altitude
// =========================================================================
@@ -511,7 +518,7 @@ async function main() {
// returns everything currently shown regardless of viewer position.
const nearbyList = await page.evaluate(() => {
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
const center = window.__godsEyeView.viewer.camera.position;
const center = window.__godsEyeView.engine.camera.livePosition;
return mod.getNearby(center, Number.MAX_VALUE, 200).map((a) => ({ icao24: a.icao24, altitudeM: a.altitudeM }));
});
const baroById = new Map(nearbyList.map((a) => [a.icao24, a.altitudeM]));
@@ -626,6 +633,7 @@ async function main() {
// =========================================================================
// Group 3: Regime C re-resolve
// =========================================================================
if (hasCctv) {
console.log('Switching map stack to keyless OSM (regime C)...');
// Grab a London camera's ground BEFORE the switch (still whatever regime
// we were in — Google-3D, given the app's default).
@@ -694,6 +702,8 @@ async function main() {
record('CCTV grounds re-resolved for regime C', null, 'no test camera available');
}
}
// -----------------------------------------------------------------------
// Screenshot for human visual review (not a pass/fail gate).
// -----------------------------------------------------------------------
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+7 -5
View File
@@ -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,
+1
View File
@@ -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',
+11 -7
View File
@@ -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)',
+40 -3
View File
@@ -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`);
+2 -1
View File
@@ -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',
+8 -2
View File
@@ -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')
? {
+48
View File
@@ -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);
});
+4 -309
View File
@@ -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';
+6 -8
View File
@@ -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,
+6 -11
View File
@@ -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
+3 -3
View File
@@ -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();
+5
View File
@@ -0,0 +1,5 @@
// Native constants shared by the W1 adapters; keep the SDK import here.
export {
Axis as modelAxis,
ScreenSpaceEventType as trackingEventTypes,
} from 'cesium';
@@ -0,0 +1,309 @@
import * as Cesium from 'cesium';
const ALPHA_STOPS = [
[0, 0.8],
[120000, 0.45],
[600000, 0.2],
[900000, 0.08],
];
export function trailAlpha(ageMs) {
const stops = ALPHA_STOPS;
for (let i = 1; i < stops.length; i++) {
if (ageMs === stops[i][0]) return stops[i][1];
if (ageMs < stops[i][0]) {
const [a, x] = stops[i - 1],
[b, y] = stops[i];
return x + ((y - x) * Math.max(0, ageMs - a)) / (b - a);
}
}
return 0.08;
}
/** One static history batch and one frequently updated head. Never entities. */
export function createContactTrailRenderer(scene) {
const ground =
!!scene.frameState?.context &&
Cesium.GroundPolylinePrimitive.isSupported(scene);
const collection = ground ? scene.groundPrimitives : scene.primitives;
// One 25 m corridor is clipped in material coordinates. Only subdivision
// crossings build geometry; frame updates write a scalar, never vertex buffers.
const headMaterial = (alpha) =>
new Cesium.Material({
fabric: {
type: 'TransitTrailHead',
uniforms: {
color: new Cesium.Color(0.37, 0.94, 0.54, alpha),
fraction: 0,
backing: true,
},
source: `czm_material czm_getMaterial(czm_materialInput materialInput) {
if (materialInput.st.s > fraction) discard;
czm_material m = czm_getDefaultMaterial(materialInput);
bool edge = abs(materialInput.st.t - 0.5) > 0.3;
m.diffuse = czm_gammaCorrect(vec4(edge && backing ? vec3(0.02, 0.03, 0.05) : color.rgb, 1.0)).rgb;
m.alpha = color.a;
return m;
}`,
},
translucent: true,
});
const material = headMaterial(0.8),
depthMaterial = headMaterial(0.55);
depthMaterial.uniforms.backing = false;
const head = { positions: [], show: false, width: 3, material };
const backing = { positions: [], show: false, width: 5 };
let headPrimitive = null,
headRebuilds = 0;
function geometry(positions, width, material = false) {
return ground
? new Cesium.GroundPolylineGeometry({ positions, width, granularity: 0 })
: new Cesium.PolylineGeometry({
positions,
width,
arcType: Cesium.ArcType.NONE,
vertexFormat: material
? Cesium.PolylineMaterialAppearance.VERTEX_FORMAT
: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
});
}
function primitive(instances, appearance, depthFailAppearance) {
return ground
? new Cesium.GroundPolylinePrimitive({
geometryInstances: instances,
appearance,
classificationType: Cesium.ClassificationType.BOTH,
allowPicking: false,
asynchronous: true,
show: visible,
})
: new Cesium.Primitive({
geometryInstances: instances,
appearance,
depthFailAppearance,
allowPicking: false,
asynchronous: false,
show: visible,
});
}
let body = null,
segments = [],
revision = -1,
visible = true,
style = '#5EF08A';
let lastSecond = -1,
completed = -1,
active = null;
const styleColor = Cesium.Color.fromCssColorString(style);
const attributeIds = [];
const color = new Cesium.Color(),
endpoint = new Cesium.Cartesian3();
const headPositions = [new Cesium.Cartesian3(), endpoint];
let rebuilds = 0;
function tint(alpha, dark = false) {
if (dark) {
color.red = 0.02;
color.green = 0.03;
color.blue = 0.05;
} else Cesium.Color.clone(styleColor, color);
color.alpha = alpha;
return color;
}
function replaceHistory(next) {
if (next.revision === revision) return;
revision = next.revision;
segments = next.segments;
completed = -1;
lastSecond = -1;
active = null;
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = null;
head.show = backing.show = false;
if (body) collection.remove(body);
body = null;
const instances = [];
attributeIds.length = 0;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
attributeIds.push([`${i}:0`, `${i}:1`]);
if (segment.positions.length < 2) continue;
for (let lane = 0; lane < 2; lane++) {
instances.push(
new Cesium.GeometryInstance({
id: `${i}:${lane}`,
geometry: geometry(segment.positions, lane ? 3 : 5),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
tint(0.8, !lane),
),
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
tint(0.55),
),
show: new Cesium.ShowGeometryInstanceAttribute(false),
},
}),
);
}
}
if (instances.length) {
body = collection.add(
primitive(
instances,
new Cesium.PolylineColorAppearance({ translucent: true }),
new Cesium.PolylineColorAppearance({ translucent: true }),
),
);
rebuilds++;
}
}
function setDisplaySample(sample, renderedPosition) {
if (!sample || !renderedPosition) {
head.show = false;
backing.show = false;
if (headPrimitive) headPrimitive.show = false;
return;
}
const second = Math.floor(sample.displayT / 1000);
let lo = 0,
hi = segments.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (segments[mid].toT <= sample.displayT) lo = mid + 1;
else hi = mid;
}
const done = lo - 1;
const candidate = segments[lo];
const segment =
candidate &&
candidate.fromT <= sample.displayT &&
candidate.fromSeq === sample.fromSeq &&
candidate.toSeq === sample.toSeq
? candidate
: null;
if (body?.ready && done !== completed) {
for (
let i = Math.min(done, completed) + 1;
i <= Math.max(done, completed);
i++
) {
for (let lane = 0; lane < 2; lane++) {
const attributes = body.getGeometryInstanceAttributes(
attributeIds[i][lane],
);
if (!attributes) continue;
// Cesium getters return copies. Write back the array we changed.
const show = attributes.show;
show[0] = i <= done ? 1 : 0;
attributes.show = show;
}
}
completed = done;
}
if (body?.ready && second !== lastSecond) {
for (let i = 0; i < segments.length; i++) {
const alpha = trailAlpha(sample.displayT - segments[i].toT);
for (let lane = 0; lane < 2; lane++) {
const attributes = body.getGeometryInstanceAttributes(
attributeIds[i][lane],
);
if (!attributes) continue;
const rgba = attributes.color;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(alpha, !lane),
rgba,
);
attributes.color = rgba;
if (!ground) {
const depthRgba = attributes.depthFailColor;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(0.55),
depthRgba,
);
attributes.depthFailColor = depthRgba;
}
}
}
lastSecond = second;
}
const show = visible && !!segment && sample.fraction < 1;
head.show = show;
backing.show = show;
if (headPrimitive) headPrimitive.show = show;
if (!show) return;
if (active !== segment) {
active = segment;
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = collection.add(
primitive(
new Cesium.GeometryInstance({
geometry: geometry(segment.positions, 5, true),
}),
new Cesium.PolylineMaterialAppearance({
material,
translucent: true,
}),
new Cesium.PolylineMaterialAppearance({
material: depthMaterial,
translucent: true,
}),
),
);
headRebuilds++;
headPositions[0] = segment.positions[0];
headPositions[1] = endpoint;
}
Cesium.Cartesian3.clone(renderedPosition, endpoint);
head.positions = headPositions;
backing.positions = headPositions;
material.uniforms.fraction = Math.max(
0,
Math.min(
1,
(sample.displayT - segment.fromT) / (segment.toT - segment.fromT),
),
);
depthMaterial.uniforms.fraction = material.uniforms.fraction;
Cesium.Color.clone(tint(0.8), material.uniforms.color);
Cesium.Color.clone(tint(0.55), depthMaterial.uniforms.color);
}
return {
replaceHistory,
setDisplaySample,
setStyle(next) {
if (next === style) return;
style = next;
Cesium.Color.fromCssColorString(style, styleColor);
lastSecond = -1;
},
setVisible(next) {
visible = next;
if (headPrimitive) headPrimitive.show = next && head.show;
if (body) body.show = next;
},
destroy() {
if (body) collection.remove(body);
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = null;
material.destroy();
depthMaterial.destroy();
body = null;
segments = [];
},
diagnostics() {
return {
revision,
rebuilds,
segments: segments.length,
body,
head,
backing,
headPrimitive,
headStrategy: ground
? 'draped corridor with material clip'
: 'depth-fail corridor with material clip',
ground,
headRebuilds,
entitiesAdded: 0,
};
},
};
}
+16
View File
@@ -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;
}
+141
View File
@@ -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;
},
};
}
+2 -2
View File
@@ -1,4 +1,4 @@
import * as Cesium from 'cesium';
import { trackingEventTypes } from './render/cesiumConstants.js';
import { isPointerFree } from './inputOwnership.js';
export const MAX_TRACKING_CLICK_TRAVEL_PX = 6;
@@ -47,7 +47,7 @@ export function isTrackingClickGesture(gesture = {}) {
*/
export function bindTrackingClickGesture(handler, onClick, options = {}) {
const now = options.now || (() => performance.now());
const eventTypes = options.eventTypes || Cesium.ScreenSpaceEventType;
const eventTypes = options.eventTypes || trackingEventTypes;
let pressActive = false;
let pressStartedAt = 0;
let previousPosition = null;
+1 -141
View File
@@ -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';
+3 -5
View File
@@ -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
+3 -3
View File
@@ -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,
);
};
+10 -2
View File
@@ -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');
+9 -2
View File
@@ -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;
+78
View File
@@ -0,0 +1,78 @@
// One camera snapshot per Navara frame, shared by its mover sets. A camera
// command or resize within the frame invalidates it too. Nonstandard parented
// or manually managed cameras retain the uncached path.
const EARTH_RADIUS_M = 6_371_000;
export function createMoverFrameCamera(view) {
const frame = {
cameraPosition: { x: 0, y: 0, z: 0 },
cameraDirection: { x: 0, y: 0, z: -1 },
cameraHeight: 0,
fovDeg: 60,
viewportHeightPx: 0,
pixelRatio: 1,
};
const previous = {};
return (ctx, time) => {
const raw = view.camera.raw;
const renderer = ctx?.getRenderer();
const height = renderer?.domElement.clientHeight;
if (!raw || !(height > 0)) return null;
const p = raw.position,
q = raw.quaternion,
scale = raw.scale;
const dpr = renderer.getPixelRatio?.() ?? 1;
const cacheable =
Number.isFinite(time) &&
!raw.parent &&
raw.matrixAutoUpdate !== false &&
raw.matrixWorldAutoUpdate !== false &&
scale.x === 1 &&
scale.y === 1 &&
scale.z === 1;
if (
cacheable &&
previous.time === time &&
previous.raw === raw &&
previous.renderer === renderer &&
previous.x === p.x &&
previous.y === p.y &&
previous.z === p.z &&
previous.qx === q.x &&
previous.qy === q.y &&
previous.qz === q.z &&
previous.qw === q.w &&
frame.fovDeg === raw.fov &&
frame.viewportHeightPx === height &&
frame.pixelRatio === dpr
)
return frame;
raw.updateMatrixWorld();
const e = raw.matrixWorld.elements;
const position = frame.cameraPosition;
position.x = e[12];
position.y = e[13];
position.z = e[14];
const direction = frame.cameraDirection;
const length = Math.hypot(e[8], e[9], e[10]) || 1;
direction.x = -e[8] / length;
direction.y = -e[9] / length;
direction.z = -e[10] / length;
frame.cameraHeight =
Math.hypot(position.x, position.y, position.z) - EARTH_RADIUS_M;
frame.fovDeg = raw.fov;
frame.viewportHeightPx = height;
frame.pixelRatio = dpr;
previous.time = cacheable ? time : undefined;
previous.raw = raw;
previous.renderer = renderer;
previous.x = p.x;
previous.y = p.y;
previous.z = p.z;
previous.qx = q.x;
previous.qy = q.y;
previous.qz = q.z;
previous.qw = q.w;
return frame;
};
}
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMoverFrameCamera } from './frameCamera.js';
function setup() {
let updates = 0;
const raw = {
position: { x: 6372000, y: 2, z: 3 },
quaternion: { x: 0, y: 0, z: 0, w: 1 },
scale: { x: 1, y: 1, z: 1 },
parent: null,
matrixAutoUpdate: true,
fov: 60,
matrixWorld: { elements: new Float64Array(16) },
updateMatrixWorld() {
updates++;
const e = this.matrixWorld.elements;
e[12] = this.position.x;
e[13] = this.position.y;
e[14] = this.position.z;
e[8] = 0;
e[9] = this.quaternion.y;
e[10] = 2;
},
};
const renderer = {
domElement: { clientHeight: 800 },
getPixelRatio: () => 1,
};
const ctx = { getRenderer: () => renderer };
const read = createMoverFrameCamera({ camera: { raw } });
return { read, raw, renderer, ctx, updates: () => updates };
}
test('mover sets share one matrix update in a frame and refresh next frame', () => {
const f = setup();
const first = f.read(f.ctx, 100);
for (let i = 0; i < 30; i++) assert.equal(f.read(f.ctx, 100), first);
assert.equal(f.updates(), 1);
assert.deepEqual(first.cameraDirection, { x: -0, y: -0, z: -1 });
assert.equal(first.cameraPosition.x, 6372000);
f.read(f.ctx, 101);
assert.equal(f.updates(), 2);
});
test('same-frame camera moves, rotations, resize and DPR never reuse stale data', () => {
const f = setup();
f.read(f.ctx, 100);
f.raw.position.x += 100;
assert.equal(f.read(f.ctx, 100).cameraPosition.x, 6372100);
f.raw.quaternion.y = 2;
assert.ok(
Math.abs(f.read(f.ctx, 100).cameraDirection.y + Math.SQRT1_2) < 1e-12,
);
f.renderer.domElement.clientHeight = 600;
assert.equal(f.read(f.ctx, 100).viewportHeightPx, 600);
f.renderer.getPixelRatio = () => 2;
assert.equal(f.read(f.ctx, 100).pixelRatio, 2);
assert.equal(f.updates(), 5);
});
test('missing frames and nonstandard camera transforms keep the uncached path', () => {
for (const modify of [
() => {},
(f) => {
f.raw.parent = {};
},
(f) => {
f.raw.matrixAutoUpdate = false;
},
(f) => {
f.raw.scale.x = 2;
},
(f) => {
f.raw.matrixWorldAutoUpdate = false;
},
]) {
const f = setup();
modify(f);
const time =
f.raw.parent ||
!f.raw.matrixAutoUpdate ||
f.raw.scale.x !== 1 ||
f.raw.matrixWorldAutoUpdate === false
? 100
: undefined;
f.read(f.ctx, time);
f.read(f.ctx, time);
assert.equal(f.updates(), 2);
}
});
test('a hidden canvas has no snapshot and independent views do not share scratch state', () => {
const a = setup(),
b = setup();
a.renderer.domElement.clientHeight = 0;
assert.equal(a.read(a.ctx, 100), null);
a.renderer.domElement.clientHeight = 800;
const first = a.read(a.ctx, 100);
b.raw.position.x += 900;
assert.notEqual(first, b.read(b.ctx, 100));
assert.equal(first.cameraPosition.x, 6372000);
});
+20 -40
View File
@@ -2,7 +2,8 @@
// per set (moverMesh.js) in gev's overlay (overlay.js: drawn after tone
// mapping, so their colours are exact); models are a Navara mesh descriptor
// (public MeshDesc API) holding one InstancedMesh per glTF mesh
// (modelMover.js), lit and graded like the scene. Pickable sets register a
// (modelMover.js). In the cinematic look, depth stays in that scene and
// lit model colour resolves in the display pass. Pickable sets register a
// PickableInstancedMeshWrapper so Navara's pick pass sees each instance;
// pickIds.js turns its batch ids back into instances. commit() uploads and
// asks for one frame (view.forceUpdate); per-frame work runs only on frames
@@ -18,10 +19,10 @@ import { createMoverRegistry } from '../../movers.js';
import { createModelMover } from './modelMover.js';
import { createMoverMesh } from './moverMesh.js';
import { createPickIndex } from './pickIds.js';
import { createMoverFrameCamera } from './frameCamera.js';
const DESC_NAME = 'gevMoverSet';
const registered = new WeakSet();
const EARTH_RADIUS_M = 6_371_000;
class GevMoverSetDesc extends MeshDesc {
constructor(view, ctx, config) {
@@ -50,15 +51,15 @@ class GevMoverSetDesc extends MeshDesc {
if (this.destroyedByGev) return;
mover.group.meshes.forEach((mesh, index) => {
group.add(mesh);
register(mesh, `${this.id}#${index}`);
register(mover.group.pickMeshes[index], `${this.id}#${index}`);
});
this.view.forceUpdate();
});
return group;
}
update() {
updateMover(this.view, this.ctx, this.gev.mover);
update(time) {
updateMover(this.gev.readFrameCamera, this.ctx, this.gev.mover, time);
}
onDestroy() {
@@ -69,53 +70,25 @@ class GevMoverSetDesc extends MeshDesc {
}
}
// The camera a mover needs each drawn frame (scratch: no allocation).
const frameCamera = {
cameraPosition: { x: 0, y: 0, z: 0 },
cameraDirection: { x: 0, y: 0, z: -1 },
cameraHeight: 0,
fovDeg: 60,
viewportHeightPx: 0,
pixelRatio: 1,
};
function updateMover(view, ctx, mover) {
const raw = view.camera.raw;
const renderer = ctx?.getRenderer();
const height = renderer?.domElement.clientHeight;
if (!raw || !(height > 0)) return;
raw.updateMatrixWorld();
const e = raw.matrixWorld.elements;
const position = frameCamera.cameraPosition;
position.x = e[12];
position.y = e[13];
position.z = e[14];
// A three camera looks down its local −z.
const direction = frameCamera.cameraDirection;
const length = Math.hypot(e[8], e[9], e[10]) || 1;
direction.x = -e[8] / length;
direction.y = -e[9] / length;
direction.z = -e[10] / length;
frameCamera.cameraHeight =
Math.hypot(position.x, position.y, position.z) - EARTH_RADIUS_M;
frameCamera.fovDeg = raw.fov;
frameCamera.viewportHeightPx = height;
frameCamera.pixelRatio = renderer.getPixelRatio?.() ?? 1;
mover.update(frameCamera);
function updateMover(readFrameCamera, ctx, mover, time) {
const camera = readFrameCamera(ctx, time);
if (camera) mover.update(camera);
}
/**
* @param {object} view an initialised Navara ThreeView
* @param {{ overlay: ReturnType<import('../overlay.js').createNavaraOverlay> }} ports
* @param {{ overlay: ReturnType<import('../overlay.js').createNavaraOverlay>, aces?: boolean }} ports
* @returns {{ movers: import('../../contract.js').GlobeMovers,
* resolvePick: (batchId: number) => ({ set: string, instance: number, pickId: any } | null),
* destroy: () => void }}
*/
export function createNavaraMovers(view, { overlay }) {
export function createNavaraMovers(view, { overlay, aces = false }) {
if (!registered.has(view)) {
view.registerMesh(DESC_NAME, GevMoverSetDesc);
registered.add(view);
}
const pickIndex = createPickIndex();
const readFrameCamera = createMoverFrameCamera(view);
let loader = null;
const loadGltf = (url) => {
loader ??= createGltfLoader();
@@ -137,7 +110,9 @@ export function createNavaraMovers(view, { overlay }) {
overlay.registerPickable(key, wrapper);
pickIndex.add(options.id, wrapper, (index) => store.pickIds[index]);
}
overlay.add(mover.mesh, () => updateMover(view, overlay.ctx, mover));
overlay.add(mover.mesh, (time) =>
updateMover(readFrameCamera, overlay.ctx, mover, time),
);
return {
ready: mover.ready,
commit() {
@@ -158,10 +133,12 @@ export function createNavaraMovers(view, { overlay }) {
const mover = createModelMover(options, store, {
load: loadGltf,
onLoad: request,
overlay: aces ? overlay : null,
});
const handle = view.addMesh({
[DESC_NAME]: {
mover,
readFrameCamera,
kind: options.kind,
pickable: options.pickable,
onPickable: (wrapper) =>
@@ -178,6 +155,9 @@ export function createNavaraMovers(view, { overlay }) {
destroy() {
pickIndex.remove(options.id);
handle.delete();
// A descriptor deleted before its first update may never own a mesh.
// Release the pending load as well; dispose is idempotent.
mover.dispose();
request();
},
};
+93 -29
View File
@@ -18,14 +18,12 @@ import {
Vector3,
} from 'three';
import { footprintPerDepth, originFor } from './pack.js';
import { createModelTint } from './modelTint.js';
/**
* Ambient light on the tinted surface, as an emissive share of it: Cesium
* lights models with image-based lighting besides the sun, so a tinted
* aircraft reads near-white at any sun angle; Navara's sky light alone left
* them grey-brown at dawn. 0.5 matched Cesium's model luminance at the near
* pose (qa-flights-engines --models=on: median 190 vs 193 on Cesium; 139
* without it).
* Ambient share of the base surface: Cesium supplies image-based lighting
* besides the sun. Navara's sky light alone left it grey-brown at dawn.
* The instance colour is applied after lighting, independently of this share.
*/
export const MODEL_AMBIENT = 0.5;
@@ -47,13 +45,12 @@ const scratchTranslation = new Vector3();
const scratchScale = new Vector3();
/**
* Mixes the instance colour, decoded from sRGB (as Cesium takes colours;
* the material works in linear), into the material's colour by uGevBlend (the
* set's colorBlend, as Cesium's colorBlendAmount); the instance alpha is the
* model's opacity (Cesium: a model colour's alpha makes it translucent; the
* aircraft fade with distance through it), not a blend weight.
* Cesium's ModelFS applies modelColorStage after lightingStage in sRGB.
* Mix the lit output in that space, then return to linear for Navara's look.
* Alpha remains opacity before alpha testing, independently of the mix.
*/
function blendInstanceColor(material, uniforms) {
function blendInstanceColor(material, uniforms, displayTint) {
const alpha = 'diffuseColor.a *= vGevColor.a;';
material.onBeforeCompile = (shader) => {
Object.assign(shader.uniforms, uniforms);
shader.vertexShader = shader.vertexShader
@@ -68,29 +65,36 @@ function blendInstanceColor(material, uniforms) {
shader.fragmentShader = shader.fragmentShader
.replace(
'void main() {',
'uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\nvoid main() {',
`uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\nvoid main() {`,
)
.replace(
'#include <emissivemap_fragment>',
'#include <emissivemap_fragment>\ntotalEmissiveRadiance += diffuseColor.rgb * uGevAmbient;',
)
.replace('#include <map_fragment>', `#include <map_fragment>\n${alpha}`)
.replace(
'#include <map_fragment>',
'#include <map_fragment>\ndiffuseColor.rgb = mix(diffuseColor.rgb, sRGBTransferEOTF(vGevColor).rgb, uGevBlend);\ndiffuseColor.a *= vGevColor.a;',
'#include <opaque_fragment>',
`#include <opaque_fragment>\nif (!${displayTint} && uGevBlend > 0.0) {\nvec3 gevLitSrgb = sRGBTransferOETF(vec4(gl_FragColor.rgb, 1.0)).rgb;\nvec3 gevMixedColor = sRGBTransferEOTF(vec4(clamp(mix(gevLitSrgb, vGevColor.rgb, uGevBlend), 0.0, 1.0), 1.0)).rgb;\ngl_FragColor.rgb = gevMixedColor;\n}`,
);
};
material.customProgramCacheKey = () => 'gev-mover-model-blend';
material.customProgramCacheKey = () =>
`gev-mover-model-display-color-${displayTint ? 'overlay' : 'linear'}`;
}
/**
* @param {object} options normalized model set options
* @param {object} store the set's mover store
* @param {{ load: (url: string) => Promise<{ scene: object }>, onLoad?: () => void }} ports
* @param {{ load: (url: string) => Promise<{ scene: object }>, onLoad?: () => void, overlay?: object }} ports
*/
export function createModelMover(options, store, { load, onLoad = () => {} }) {
export function createModelMover(
options,
store,
{ load, onLoad = () => {}, overlay = null },
) {
const { capacity, model } = options;
const uniforms = {
uGevAmbient: { value: MODEL_AMBIENT },
uGevModelExposure: overlay?.uniforms.uGevModelExposure ?? { value: 1 },
uGevBlend: {
value: model.colorBlend
? model.colorBlend.mode === 'replace'
@@ -109,6 +113,13 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
let disposed = false;
const colorAttributes = [];
const materials = [];
// GLTFLoader returns a fresh asset per load. Cloned materials still share
// its textures; Material.dispose() does not release their GPU allocations.
const textures = new Set();
const disposeTextures = () => {
for (const texture of textures) texture.dispose();
textures.clear();
};
// Each instance's drawn diameter (px) at its last write (CPU picks).
const drawnPx = new Float32Array(capacity);
// Translucent only while an instance is faded (opaque models keep the
@@ -175,18 +186,41 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
index * 4,
);
}
for (const { mesh } of parts) {
for (const { mesh, tint } of parts) {
if (tint) {
tint.count = store.count;
tint.visible = store.count > 0;
}
mesh.count = store.count;
mesh.visible = store.count > 0;
mesh.instanceMatrix.needsUpdate = true;
}
for (const attribute of colorAttributes) attribute.needsUpdate = true;
syncTransparency();
};
const group = { meshes: [], ready: null };
group.ready = load(model.url).then((gltf) => {
if (disposed) return;
const group = { meshes: [], pickMeshes: [], ready: null };
const asset = load(model.url).catch(() => {
// A missing asset keeps the layer's glyph fallback. Do not leak the
// rejected loader promise (or its URL) through descriptor creation.
if (!disposed) console.warn('[globe] Model asset unavailable');
return null;
});
group.ready = asset.then((gltf) => {
if (!gltf) return;
const root = gltf.scene;
root.traverse((child) => {
for (const material of Array.isArray(child.material)
? child.material
: [child.material])
if (material)
for (const value of Object.values(material))
if (value?.isTexture) textures.add(value);
});
if (disposed) {
disposeTextures();
return;
}
root.updateMatrixWorld(true);
const sphere = new Box3()
.setFromObject(root)
@@ -201,8 +235,7 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
color: material.color,
});
else material = material.clone();
blendInstanceColor(material, uniforms);
materials.push(material);
blendInstanceColor(material, uniforms, Boolean(overlay));
const geometry = child.geometry.clone();
const color = new InstancedBufferAttribute(
new Float32Array(capacity * 4).fill(1),
@@ -216,21 +249,37 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
mesh.frustumCulled = false;
mesh.renderOrder = options.order;
mesh.count = 0;
parts.push({ mesh, local: child.matrixWorld.clone() });
mesh.visible = false;
const tint = overlay
? createModelTint(mesh, uniforms, overlay.uniforms)
: null;
if (tint) {
// Still present in the scene depth and GPU pick registry. Only colour
// moves to the display pass, where it is toned before raster resolve.
material.colorWrite = false;
overlay.add(tint);
}
materials.push(material);
if (tint) materials.push(tint.material);
parts.push({ mesh, tint, local: child.matrixWorld.clone() });
group.meshes.push(mesh);
group.pickMeshes.push(tint ?? mesh);
});
loaded = true;
if (origin)
for (const { mesh } of parts)
for (const { mesh, tint } of parts) {
mesh.position.set(origin.x, origin.y, origin.z);
tint?.position.copy(mesh.position);
}
write(0, store.count);
onLoad(group.meshes);
});
const placeAt = (next) => {
origin = { x: next.x, y: next.y, z: next.z };
for (const { mesh } of parts) {
for (const { mesh, tint } of parts) {
mesh.position.set(origin.x, origin.y, origin.z);
tint?.position.copy(mesh.position);
mesh.updateMatrix();
mesh.updateMatrixWorld(true);
}
@@ -258,7 +307,15 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
z: store.positions[2],
});
if (range) write(range.from, Math.min(range.to, store.count));
for (const { mesh } of parts) mesh.count = store.count;
for (const { mesh, tint } of parts) {
if (tint) {
tint.count = store.count;
tint.visible = store.count > 0;
}
mesh.count = store.count;
// Retained empty banks must also skip three's material/uniform work.
mesh.visible = store.count > 0;
}
},
/** Each drawn frame: pixel limits need every instance rewritten. */
update({
@@ -278,12 +335,19 @@ export function createModelMover(options, store, { load, onLoad = () => {} }) {
return true;
},
dispose() {
if (disposed) return;
disposed = true;
for (const { mesh } of parts) {
for (const { mesh, tint } of parts) {
if (tint) {
overlay.remove(tint);
tint.material.dispose();
tint.dispose();
}
mesh.geometry.dispose();
mesh.material.dispose();
mesh.dispose?.();
}
disposeTextures();
},
};
}
+193 -4
View File
@@ -7,6 +7,7 @@ import {
Mesh,
MeshStandardMaterial,
Vector3,
Texture,
} from 'three';
import { createMoverStore, normalizeMoverSetOptions } from '../../movers.js';
import { createModelMover, modelPixelDistance } from './modelMover.js';
@@ -91,7 +92,7 @@ test('a far model keeps its minimum pixel size', async () => {
assert.equal(mover.pickRadiusPx(1), null);
});
test("the instance colour (sRGB, decoded) tints by the set's blend amount; its alpha is opacity, as Cesium's model colour", async () => {
test("the instance colour mixes after lighting in sRGB, as Cesium's model colour", async () => {
const store = createMoverStore(options);
const mover = createModelMover(options, store, {
load: async () => fakeGltf(),
@@ -108,14 +109,22 @@ test("the instance colour (sRGB, decoded) tints by the set's blend amount; its a
uniforms: {},
vertexShader: 'void main() {\n#include <begin_vertex>\n}',
fragmentShader:
'void main() {\n#include <map_fragment>\n#include <emissivemap_fragment>\n}',
'void main() {\n#include <map_fragment>\n#include <emissivemap_fragment>\n#include <opaque_fragment>\n}',
};
mesh.material.onBeforeCompile(shader);
assert.match(shader.fragmentShader, /diffuseColor\.a \*= vGevColor\.a;/);
// Cesium ModelFS runs modelColorStage after lightingStage, in sRGB.
assert.match(shader.fragmentShader, /vec3 gevLitSrgb = sRGBTransferOETF/);
assert.match(
shader.fragmentShader,
/diffuseColor\.rgb = mix\(diffuseColor\.rgb, sRGBTransferEOTF\(vGevColor\)\.rgb, uGevBlend\);\s*diffuseColor\.a \*= vGevColor\.a;/,
/mix\(gevLitSrgb, vGevColor\.rgb, uGevBlend\)/,
);
// Ambient light on the tinted surface (Cesium's image-based lighting).
assert.ok(
shader.fragmentShader.indexOf('vec3 gevLitSrgb') >
shader.fragmentShader.indexOf('#include <opaque_fragment>'),
);
assert.doesNotMatch(shader.fragmentShader, /diffuseColor\.rgb = mix/);
// Ambient light belongs to the original surface before the colour mix.
assert.match(
shader.fragmentShader,
/totalEmissiveRadiance \+= diffuseColor\.rgb \* uGevAmbient;/,
@@ -134,3 +143,183 @@ test("the minimum pixel size is taken at Cesium's distance: view depth less the
assert.equal(modelPixelDistance(0, 0, -10, ahead, 30), 0);
assert.equal(modelPixelDistance(4000, 0, -3000, null, 0), 5000);
});
test('disposing a model releases shared textures once, as well as each instanced mesh', async () => {
const gltf = fakeGltf();
const texture = new Texture();
gltf.scene.children[0].material.map = texture;
const second = gltf.scene.children[0].clone();
gltf.scene.add(second);
let released = 0;
texture.addEventListener('dispose', () => released++);
const mover = createModelMover(options, createMoverStore(options), {
load: async () => gltf,
});
await mover.group.ready;
let geometries = 0,
materials = 0;
for (const mesh of mover.group.meshes) {
mesh.geometry.addEventListener('dispose', () => geometries++);
mesh.material.addEventListener('dispose', () => materials++);
}
assert.equal(released, 0, 'textures remain live while the model draws');
mover.dispose();
mover.dispose();
assert.equal(released, 1);
assert.equal(geometries, 2);
assert.equal(materials, 2);
});
test('a model removed before its load completes releases the arriving textures', async () => {
let finish;
const gltf = fakeGltf();
const texture = new Texture();
gltf.scene.children[0].material.map = texture;
let released = 0,
loaded = 0;
texture.addEventListener('dispose', () => released++);
const mover = createModelMover(options, createMoverStore(options), {
load: () =>
new Promise((resolve) => {
finish = resolve;
}),
onLoad: () => loaded++,
});
mover.dispose();
finish(gltf);
await mover.group.ready;
assert.equal(released, 1);
assert.equal(loaded, 0);
assert.equal(mover.ready(), false);
assert.equal(mover.group.meshes.length, 0);
});
test('a failed model load stays not-ready without an unhandled rejection or leaking the URL', async (t) => {
const warnings = [];
t.mock.method(console, 'warn', (message) => warnings.push(message));
const mover = createModelMover(options, createMoverStore(options), {
load: async () => {
throw new Error('unavailable asset URL');
},
});
await mover.group.ready;
assert.equal(mover.ready(), false);
assert.equal(mover.group.meshes.length, 0);
assert.deepEqual(warnings, ['[globe] Model asset unavailable']);
mover.dispose();
});
test('an empty retained model bank leaves the render traversal and restores its cached instances', async () => {
const store = createMoverStore(options);
const mover = createModelMover(options, store, {
load: async () => fakeGltf(),
});
await mover.group.ready;
const [mesh] = mover.group.meshes;
assert.equal(mesh.visible, false, 'loaded before its first instance');
store.set(0, { position: { x: 6378137, y: 0, z: 0 } });
store.setCount(1);
mover.commit(store.dirty());
store.clean();
assert.equal(mesh.visible, true);
const before = mesh.instanceMatrix.array.slice();
store.setCount(0);
mover.commit(store.dirty());
store.clean();
assert.equal(mesh.visible, false);
assert.equal(mover.ready(), true, 'the asset remains cached');
store.setCount(1);
mover.commit(store.dirty());
assert.equal(mesh.visible, true);
assert.deepEqual(mesh.instanceMatrix.array, before);
mover.dispose();
});
test('display tint shares transforms, preserves fade opacity and leaves no overlay resource after destruction', async () => {
const children = new Set();
const overlay = {
uniforms: {
tGevSceneDepth: { value: null },
uGevResolution: { value: [1280, 800] },
},
add: (mesh) => children.add(mesh),
remove: (mesh) => children.delete(mesh),
};
const store = createMoverStore(options);
const mover = createModelMover(options, store, {
load: async () => fakeGltf(),
overlay,
});
store.set(0, {
position: { x: 6378137, y: 0, z: 0 },
color: [1, 0.2, 0.6, 0.4],
});
store.setCount(1);
mover.commit(store.dirty());
await mover.group.ready;
const [base] = mover.group.meshes;
const [tint] = children;
assert.equal(tint.geometry, base.geometry);
assert.equal(tint.instanceMatrix, base.instanceMatrix);
assert.deepEqual(tint.position, base.position);
assert.equal(tint.count, 1);
assert.equal(tint.material.toneMapped, false);
assert.equal(tint.material.depthWrite, false);
const shader = {
uniforms: {},
vertexShader: 'void main() {\n#include <begin_vertex>\n}',
fragmentShader:
'void main() {\n#include <logdepthbuf_fragment>\n#include <opaque_fragment>\n}',
};
tint.material.onBeforeCompile(shader);
assert.equal(shader.uniforms.tGevSceneDepth, overlay.uniforms.tGevSceneDepth);
assert.match(shader.fragmentShader, /if \(gevOverlayHidden\(\)\) discard/);
assert.match(
shader.fragmentShader,
/mix\(gevLitSrgb, vGevColor.rgb, uGevBlend\)/,
);
assert.equal(base.material.colorWrite, false);
assert.equal(
mover.group.pickMeshes[0],
tint,
'GPU picking keeps its color-writing material',
);
store.setCount(0);
mover.commit(store.dirty());
assert.equal(tint.count, 0);
assert.equal(tint.visible, false);
let disposed = 0;
tint.material.addEventListener('dispose', () => disposed++);
mover.dispose();
mover.dispose();
assert.equal(disposed, 1);
assert.equal(children.size, 0);
});
test('the display pass preserves unlit model colour without a lighting tone curve', async () => {
const children = new Set();
const overlay = {
uniforms: {},
add: (mesh) => children.add(mesh),
remove: (mesh) => children.delete(mesh),
};
const spec = { ...options, model: { ...options.model, unlit: true } };
const mover = createModelMover(spec, createMoverStore(spec), {
load: async () => fakeGltf(),
overlay,
});
await mover.group.ready;
const [mesh] = children;
assert.equal(mesh.material.isMeshBasicMaterial, true);
const shader = {
uniforms: {},
vertexShader: 'void main() {\n#include <begin_vertex>\n}',
fragmentShader: 'void main() {\n#include <opaque_fragment>\n}',
};
mesh.material.onBeforeCompile(shader);
assert.doesNotMatch(
shader.fragmentShader,
/gevModelAces\(gl_FragColor.rgb\)/,
);
mover.dispose();
});
+73
View File
@@ -0,0 +1,73 @@
// Models use the scene's depth, but their lit display colour is resolved after
// the view's HDR effects. This keeps MSAA from spreading HDR tint into edges.
import { InstancedMesh } from 'three';
import { OVERLAY_DEPTH_FRAGMENT } from '../overlayDepth.js';
// three's pinned ACESFilmicToneMapping, with the view's public exposure.
const ACES = `
uniform float uGevModelExposure;
vec3 gevModelAces(vec3 color) {
const mat3 inputMatrix = mat3(0.59719,0.076,0.0284, 0.35458,0.90834,0.13383, 0.04823,0.01566,0.83777);
const mat3 outputMatrix = mat3(1.60475,-0.10208,-0.00327, -0.53108,1.10813,-0.07276, -0.07367,-0.00605,1.07602);
color = inputMatrix * (color * uGevModelExposure / 0.6);
color = (color * (color + 0.0245786) - 0.000090537) /
(color * (0.983729 * color + 0.432951) + 0.238081);
return clamp(outputMatrix * color, 0.0, 1.0);
}
`;
export function createModelTint(mesh, uniforms, overlayUniforms) {
const material = mesh.material.clone();
material.depthTest = false;
material.depthWrite = false;
material.toneMapped = false;
const lit = material.isMeshBasicMaterial
? 'gl_FragColor.rgb'
: 'gevModelAces(gl_FragColor.rgb)';
material.onBeforeCompile = (shader) => {
Object.assign(shader.uniforms, uniforms, overlayUniforms, {
uGevDepthMode: { value: 1 },
});
shader.vertexShader = shader.vertexShader
.replace(
'void main() {',
'attribute vec4 gevColor;\nvarying vec4 vGevColor;\nvarying float vGevDepthFree;\nvoid main() {',
)
.replace(
'#include <begin_vertex>',
'#include <begin_vertex>\nvGevColor = gevColor;\nvGevDepthFree = 0.0;',
);
shader.fragmentShader = shader.fragmentShader
.replace(
'void main() {',
`uniform float uGevBlend;\nuniform float uGevAmbient;\nvarying vec4 vGevColor;\n${ACES}\n${OVERLAY_DEPTH_FRAGMENT}\nvoid main() {`,
)
.replace(
'#include <logdepthbuf_fragment>',
'#include <logdepthbuf_fragment>\nif (gevOverlayHidden()) discard;',
)
.replace(
'#include <map_fragment>',
'#include <map_fragment>\ndiffuseColor.a *= vGevColor.a;',
)
.replace(
'#include <emissivemap_fragment>',
'#include <emissivemap_fragment>\ntotalEmissiveRadiance += diffuseColor.rgb * uGevAmbient;',
)
.replace(
'#include <opaque_fragment>',
`#include <opaque_fragment>\nvec3 gevLitSrgb = sRGBTransferOETF(vec4(${lit}, 1.0)).rgb;\ngl_FragColor.rgb = sRGBTransferEOTF(vec4(mix(gevLitSrgb, vGevColor.rgb, uGevBlend), 1.0)).rgb;`,
);
};
material.customProgramCacheKey = () => 'gev-model-lit-display-color';
const tint = new InstancedMesh(
mesh.geometry,
material,
mesh.instanceMatrix.count,
);
tint.userData.gevModelTint = true;
tint.instanceMatrix = mesh.instanceMatrix;
tint.frustumCulled = false;
tint.renderOrder = mesh.renderOrder;
return tint;
}
+52 -27
View File
@@ -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();
},
};
+23
View File
@@ -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;
}
}
}
+43
View File
@@ -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();
});
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -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
+8
View File
@@ -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;
+43
View File
@@ -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);
});
}
+33 -7
View File
@@ -57,6 +57,13 @@ const fieldsNoPick = {
show: undefined,
};
const scratchColor = new Float64Array(4);
// Sprite setters write synchronously into the mover store; reuse their
// field records rather than allocating one per contact and assignment.
const spritePositionField = { position: null };
const spriteShowField = { show: true };
const spriteRotationField = { rotation: 0 };
const spriteScaleField = { scale: 1 };
const spriteColorField = { color: scratchColor };
function writeFields(set, index, fields) {
const scratch = fieldsNoPick;
for (const key in fields) scratch[key] = fields[key];
@@ -371,7 +378,8 @@ export function createAircraftNavaraRenderer({
set position(value) {
geo.cartesian3.clone(value, this._position);
this.write({ position: this._position });
spritePositionField.position = this._position;
this.write(spritePositionField);
}
get show() {
@@ -379,8 +387,11 @@ export function createAircraftNavaraRenderer({
}
set show(value) {
this._show = value !== false;
this.write({ show: this._show });
const next = value !== false;
if (next === this._show) return;
this._show = next;
spriteShowField.show = next;
this.write(spriteShowField);
}
get rotation() {
@@ -388,8 +399,10 @@ export function createAircraftNavaraRenderer({
}
set rotation(value) {
if (value === this._rotation) return;
this._rotation = value;
this.write({ rotation: value });
spriteRotationField.rotation = value;
this.write(spriteRotationField);
}
get scale() {
@@ -397,8 +410,10 @@ export function createAircraftNavaraRenderer({
}
set scale(value) {
if (value === this._scale) return;
this._scale = value;
this.write({ scale: this.drawnScale() });
spriteScaleField.scale = this.drawnScale();
this.write(spriteScaleField);
}
get width() {
@@ -406,8 +421,10 @@ export function createAircraftNavaraRenderer({
}
set width(value) {
if (value === this._width) return;
this._width = value;
this.write({ scale: this.drawnScale() });
spriteScaleField.scale = this.drawnScale();
this.write(spriteScaleField);
}
get height() {
@@ -423,8 +440,17 @@ export function createAircraftNavaraRenderer({
}
set color(value) {
if (
value !== this._color &&
(value?.red ?? 1) === this._color.red &&
(value?.green ?? 1) === this._color.green &&
(value?.blue ?? 1) === this._color.blue &&
(value?.alpha ?? 1) === this._color.alpha
)
return;
this._color = new StoredColor(value);
this.write({ color: rgbaOf(this._color) });
rgbaOf(this._color);
this.write(spriteColorField);
}
get image() {
@@ -293,3 +293,59 @@ test('trails are engine lines in the trail pick namespace; the head follows each
renderer.destroy();
engine.destroy();
});
test('unchanged sprite style does not upload or request another frame', async () => {
const { engine, renderer } = await setup();
const sprite = renderer.fleet.add({
id: 'same',
position: geo.cartesian3.fromDegrees(0, 0, 1000),
image: aircraftIcon('light'),
});
await Promise.resolve();
const [listed] = setsOf(engine, 'flights-sprites');
const part = engine.fake.moverSet(listed.id);
const before = part.commits;
const color = sprite.color;
for (let n = 0; n < 1000; n++) {
sprite.show = true;
sprite.rotation = 0;
sprite.scale = 1;
sprite.width = 20;
sprite.color = WHITE;
}
await Promise.resolve();
assert.equal(part.commits, before);
assert.equal(sprite.color, color);
renderer.destroy();
engine.destroy();
});
test('reused sprite field records are copied, including a mutated color getter', async () => {
const { engine, renderer } = await setup();
const a = renderer.fleet.add({
id: 'a',
position: geo.cartesian3.fromDegrees(0, 0, 1000),
image: aircraftIcon('light'),
});
const b = renderer.fleet.add({
id: 'b',
position: a.position,
image: aircraftIcon('light'),
});
await Promise.resolve();
const [listed] = setsOf(engine, 'flights-sprites');
const { store } = engine.fake.moverSet(listed.id);
const position = geo.cartesian3.create(7, 8, 9);
a.position = position;
position.x = 20;
b.position = position;
a.color.alpha = 0.25;
a.color = a.color;
b.color = WHITE.withAlpha(0.75);
await Promise.resolve();
assert.deepEqual([...store.positions.subarray(0, 6)], [7, 8, 9, 20, 8, 9]);
assert.equal(store.colors[3], 0.25);
assert.equal(store.colors[7], 0.75);
renderer.destroy();
engine.destroy();
});
+5 -1
View File
@@ -1048,7 +1048,11 @@ export function createTracking({
} else if (flightState._renderer.fleet.visible()) {
// Fire-and-forget refresh; only while the layer is actually enabled.
// The query reads the renderer's camera (no viewer argument needed).
void flightsLayer.update(null);
void flightsLayer.update(null).catch((error) => {
// This refresh has no lifecycle-manager caller. Disabling flights
// cancels it locally; retain visibility of unexpected failures.
if (error?.name !== 'AbortError') console.error(error);
});
}
}
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createTracking } from './tracking.js';
function handoff(update) {
return createTracking({
flightState: {
_renderer: { fleet: { attached: () => true, visible: () => true } },
},
services: Object.fromEntries(
[
'context',
'aircraftPresentation',
'geoid',
'groundFloor',
'focus',
'militaryRegistry',
'readout',
'selection',
].map((name) => [name, {}]),
),
parts: {},
layer: { update },
});
}
test('a military-off refresh owns cancellation when flights are disabled before it settles', async (t) => {
const errors = [];
t.mock.method(console, 'error', (error) => errors.push(error));
let reject,
calls = 0;
const tracking = handoff(() => {
calls++;
return new Promise((_resolve, fail) => {
reject = fail;
});
});
tracking._onMilitaryActiveChange(false);
assert.equal(calls, 1);
reject(new DOMException('Flights update aborted', 'AbortError'));
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(errors, []);
});
test('unexpected military-off refresh failures remain visible instead of being swallowed', async (t) => {
const error = new Error('fixture update failure');
const errors = [];
t.mock.method(console, 'error', (value) => errors.push(value));
handoff(async () => {
throw error;
})._onMilitaryActiveChange(false);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(errors, [error]);
});
+13
View File
@@ -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();
+1
View File
@@ -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;
+49
View File
@@ -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;
}
+72
View File
@@ -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);
}
});
+80
View File
@@ -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);
});
+30
View File
@@ -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';" },
+42 -1
View File
@@ -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,
);
}
});
+60
View File
@@ -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'));
});