From 5983c663bd35c239127c17d2ecdd658a76a0a07d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 21:46:37 +0400 Subject: [PATCH] Put the DISPLAY scope mask over Navara, its terminus following the engine camera's height --- scripts/qa-navara-scope.mjs | 193 ++++++++++++++++ src/app/navaraMode.js | 2 + src/app/navaraTools.js | 11 + src/scopeMask.js | 63 ++++-- src/scopeMask.test.mjs | 428 +++++++++++++++++++++++++++++------- 5 files changed, 599 insertions(+), 98 deletions(-) create mode 100644 scripts/qa-navara-scope.mjs diff --git a/scripts/qa-navara-scope.mjs b/scripts/qa-navara-scope.mjs new file mode 100644 index 000000000..f5c2b1561 --- /dev/null +++ b/scripts/qa-navara-scope.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +// The DISPLAY scope mask and the celestial ring on Navara, under the strict +// network policy: at city altitude the mask canvas sits in the engine's +// container with an opaque outside, the screen's edge goes dark with the +// scope on and shows the map with it off, and the ring overlay draws; the +// same holds after a switch to Cesium and back. +// +// node scripts/qa-navara-scope.mjs http://127.0.0.1:/ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import sharp from 'sharp'; +import { fromDegrees } from '../src/geo/cartesian3.js'; +import { createStrict, localPreview } from './qa-strict-harness.mjs'; + +const base = localPreview(process.argv[2]); +base.search = '?engine=navara&welcome=0'; +base.hash = + 'v=2&lat=37.7749&lon=-122.4194&alt=3000&heading=0&pitch=-90&roll=0&map=osm&l='; +const strict = createStrict({ + origin: base.origin, + layers: ['scope-mask', 'celestial-ring'], +}); +const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-scope'; +fs.mkdirSync(SHOTS, { recursive: true }); +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +// The screen's right edge below the rail: outside the keyhole, clear of UI. +const GLOBE = fromDegrees(-122.4194, 37.7749, 20_000_000); +const CITY = fromDegrees(-122.4194, 37.7749, 3000); +const EDGE = { x: 1250, y: 560, width: 26, height: 90 }; + +await strict.run( + { + args: [ + '--use-angle=metal', + '--enable-gpu', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ], + }, + async (browser) => { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 860 }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(base.href); + const ready = async (kind) => { + await page.waitForFunction( + (k) => + window.__godsEyeView?.engine?.kind === k && + !document.querySelector('#globe-engine')?.disabled && + !document.querySelector('.engine-switch-frame') && + document.querySelector('#loading-screen.hidden'), + { timeout: 120000 }, + kind, + ); + await page.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + }; + await ready('navara'); + assert.equal( + await page.evaluate(() => + Boolean(window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__), + ), + false, + 'keyless preview required', + ); + const edgeLuminance = async () => { + await wait(900); + const { data, info } = await sharp(await page.screenshot({ clip: EDGE })) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + let sum = 0; + for (let i = 0; i < data.length; i += info.channels) + sum += 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]; + return sum / (data.length / info.channels); + }; + // DISPLAY ▸ Scope, as its checkbox sends it. + const setScope = async (enabled) => { + const found = await page.evaluate((on) => { + const toggle = document.getElementById('scope-toggle'); + if (!toggle) return false; + toggle.checked = on; + toggle.dispatchEvent(new Event('change', { bubbles: true })); + return true; + }, enabled); + assert.ok(found, 'the DISPLAY scope toggle'); + }; + + for (const [round, kind] of ['navara', 'cesium', 'navara'].entries()) { + if ( + (await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind + ) { + await strict.pick(page, kind); + await ready(kind); + } + await wait(2500); + const mask = await page.evaluate(() => { + const canvas = document.getElementById('scope-mask'); + const { engine } = window.__godsEyeView; + const context = canvas?.getContext('2d'); + const corner = context?.getImageData(2, 2, 1, 1).data; + const middle = context?.getImageData( + Math.round(canvas.width / 2), + Math.round(canvas.height / 2), + 1, + 1, + ).data; + return { + exists: Boolean(canvas), + inContainer: canvas?.parentElement === engine.host.container, + count: document.querySelectorAll('#scope-mask').length, + height: Math.round(engine.camera.height()), + cornerAlpha: corner?.[3], + middleAlpha: middle?.[3], + }; + }); + await page.screenshot({ path: `${SHOTS}/${kind}-${round}-scope.png` }); + const on = await edgeLuminance(); + await setScope(false); + const off = await edgeLuminance(); + const cleared = await page.evaluate( + () => + document + .getElementById('scope-mask') + ?.getContext('2d') + .getImageData(2, 2, 1, 1).data[3], + ); + await setScope(true); + console.log(kind, JSON.stringify({ mask, on, off, cleared })); + await strict.check( + page, + 'scope-mask', + 'one scope canvas in the engine container, opaque outside and clear inside at city altitude', + mask.exists && + mask.inContainer && + mask.count === 1 && + mask.cornerAlpha === 255 && + mask.middleAlpha === 0, + mask, + ); + await strict.check( + page, + 'scope-mask', + 'the screen edge is dark with the scope on and shows the map with it off', + on < 12 && off > on + 30 && cleared === 0, + { on, off, cleared }, + ); + // The ring is a full-globe view: up to the whole Earth, ring on. + await page.evaluate((destination) => { + const { engine, styleManager } = window.__godsEyeView; + engine.camera.setView({ destination }); + styleManager.setCelestialRingEnabled(true); + }, GLOBE); + await wait(2500); + const ring = await page.evaluate(() => { + const overlay = document.getElementById('celestial-ring-overlay'); + const canvases = [...(overlay?.querySelectorAll('canvas') ?? [])]; + return { + exists: Boolean(overlay), + disabled: overlay?.classList.contains('disabled') ?? null, + visible: overlay?.classList.contains('visible') ?? null, + updates: Number(overlay?.dataset.ephemerisUpdates ?? 0), + canvases: canvases.map((c) => [c.width, c.height]), + }; + }); + await page.screenshot({ path: `${SHOTS}/${kind}-${round}-ring.png` }); + console.log(kind, 'ring', JSON.stringify(ring)); + await strict.check( + page, + 'celestial-ring', + 'at full-globe altitude the celestial ring draws its sun and moon', + ring.exists && + ring.disabled === false && + ring.visible === true && + ring.updates >= 1 && + ring.canvases.length === 2 && + ring.canvases.every(([w, h]) => w > 0 && h > 0), + ring, + ); + // Back down to the city for the next engine. + await page.evaluate((destination) => { + const { engine, styleManager } = window.__godsEyeView; + styleManager.setCelestialRingEnabled(false); + engine.camera.setView({ destination }); + }, CITY); + await wait(1000); + } + assert.deepEqual(errors, []); + console.log('PASS scope mask and celestial ring on both engines'); + }, +); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 797ee8ba1..85c0b5c20 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -22,6 +22,7 @@ import { attachNavaraAnnotations, attachNavaraDraw, attachNavaraGeoImport, + attachNavaraScopeMask, } from './navaraTools.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js'; @@ -495,6 +496,7 @@ export function attachViewerlessTools( }); attachNavaraDraw(tools, { ...context, annotations }, { signal, defer }); attachNavaraGeoImport(tools, context, { signal, defer }); + attachNavaraScopeMask(tools, context, { signal, defer }); // Voice on the engine (phase 3, Task 9): the W1 tools drive the engine; // the Director uses the shared session; viewer-only tools declare availability. const voiceCommands = attachSessionVoice({ diff --git a/src/app/navaraTools.js b/src/app/navaraTools.js index 6542de244..6a8706d11 100644 --- a/src/app/navaraTools.js +++ b/src/app/navaraTools.js @@ -4,6 +4,7 @@ import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js'; import { navaraDrawSurface } from '../annotations/drawInputNavara.js'; +import { destroyScopeMask, installScopeMask } from '../scopeMask.js'; import { sessionAnnotations, sessionDrawTool, @@ -92,3 +93,13 @@ export function attachNavaraGeoImport( defer(() => geoFileImport?.detachScene()); return geoFileImport; } + +/** + * The DISPLAY scope mask over this Navara scene: the same canvas as on + * Cesium, in the engine's container, its terminus following the engine + * camera's height. It goes with the engine. + */ +export function attachNavaraScopeMask({ engine }, _context, { defer }) { + installScopeMask(engine); + defer(() => destroyScopeMask()); +} diff --git a/src/scopeMask.js b/src/scopeMask.js index c43108377..a8ecf12f2 100644 --- a/src/scopeMask.js +++ b/src/scopeMask.js @@ -1,4 +1,5 @@ import { getKeyholeGeometry } from './celestialRing.js'; +import { isGlobeEngine } from './globe/contract.js'; import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js'; /** @@ -86,7 +87,11 @@ const SCOPE_TERMINUS_SAMPLE_MS = 120; let _canvas = null; let _container = null; -let _viewer = null; +/** + * The globe the mask sits over: { height(), preRender, moveEnd } from a + * Cesium viewer or a globe engine (Navara). + */ +let _globe = null; let _enabled = true; let _featherRatio = SCOPE_FEATHER_RATIO_DEFAULT; let _resizeObserver = null; @@ -421,15 +426,38 @@ function syncScopeMaskAppearance(enabled = _enabled) { } /** - * Install the scope mask into the viewer container. Idempotent. - * @param {Cesium.Viewer} viewer + * The camera height and frame/camera events of a Cesium viewer or a globe + * engine, and the container the mask goes into. + */ +function globeOf(target) { + if (isGlobeEngine(target)) + return { + container: target.host.container, + height: () => target.camera.height(), + preRender: target.frames.preRender, + moveEnd: target.camera.moveEnd, + }; + return { + container: target?.container, + height: () => target?.camera?.positionCartographic?.height, + preRender: target?.scene?.preRender, + moveEnd: target?.camera?.moveEnd, + }; +} + +/** + * Install the scope mask into the globe's container: a Cesium viewer's, or + * a globe engine's host container (Navara). Idempotent. + * @param {Cesium.Viewer | import('./globe/contract.js').GlobeEngine} target * @returns {void} */ -export function installScopeMask(viewer) { - if (_canvas || !viewer?.container) return; +export function installScopeMask(target) { + if (_canvas) return; + const globe = globeOf(target); + if (!globe.container) return; syncScopeMaskAppearance(); - _container = viewer.container; - _viewer = viewer; + _container = globe.container; + _globe = globe; _canvas = document.createElement('canvas'); _canvas.id = 'scope-mask'; _canvas.setAttribute('aria-hidden', 'true'); @@ -437,7 +465,7 @@ export function installScopeMask(viewer) { _resizeObserver = new ResizeObserver(() => draw()); _resizeObserver.observe(_container); watchDevicePixelRatio(); - watchCameraHeight(viewer); + watchCameraHeight(globe); // Seed from the live camera so the first paint is already correct for the // restored/initial altitude instead of flashing the globe-scale terminus. _terminusAlpha = currentTerminusTarget(); @@ -446,7 +474,12 @@ export function installScopeMask(viewer) { /** @returns {number} Live camera height above the ellipsoid, or +Inf if unknown. */ function currentCameraHeightM() { - const height = _viewer?.camera?.positionCartographic?.height; + let height; + try { + height = _globe?.height(); + } catch { + height = undefined; + } return Number.isFinite(height) ? height : Number.POSITIVE_INFINITY; } @@ -456,11 +489,11 @@ function currentCameraHeightM() { * warranted. Two cheap compares per rendered frame; under the idle governor a * parked camera renders no frames at all, so this costs nothing at rest. * moveEnd additionally pins the exact settled value. - * @param {Cesium.Viewer} viewer + * @param {{ preRender?: object, moveEnd?: object }} globe * @returns {void} */ -function watchCameraHeight(viewer) { - const preRender = viewer?.scene?.preRender; +function watchCameraHeight(globe) { + const preRender = globe.preRender; if (preRender?.addEventListener) { _cameraSampleRemover = preRender.addEventListener(() => { // SCOPE OFF paints nothing, so it samples nothing: no height read, no @@ -474,7 +507,7 @@ function watchCameraHeight(viewer) { updateScopeTerminusForHeight(currentCameraHeightM()); }); } - const moveEnd = viewer?.camera?.moveEnd; + const moveEnd = globe.moveEnd; if (moveEnd?.addEventListener) { _cameraMoveEndRemover = moveEnd.addEventListener(() => { if (!_enabled) return; @@ -550,7 +583,7 @@ export function destroyScopeMask() { _canvas?.remove(); _canvas = null; _container = null; - _viewer = null; + _globe = null; _painted = false; } @@ -564,7 +597,7 @@ export function _resetScopeMaskForTest() { _canvas?.remove(); _canvas = null; _container = null; - _viewer = null; + _globe = null; _enabled = true; _featherRatio = SCOPE_FEATHER_RATIO_DEFAULT; _terminusAlpha = SCOPE_OUTSIDE_ALPHA; diff --git a/src/scopeMask.test.mjs b/src/scopeMask.test.mjs index d37b6b23b..b65d36d1c 100644 --- a/src/scopeMask.test.mjs +++ b/src/scopeMask.test.mjs @@ -27,7 +27,10 @@ import { _resetScopeMaskForTest, } from './scopeMask.js'; import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js'; -import { SCOPE_FEATHER_RATIO_MAX, clampScopeFeatherPct } from './scopeMaskPolicy.js'; +import { + SCOPE_FEATHER_RATIO_MAX, + clampScopeFeatherPct, +} from './scopeMaskPolicy.js'; beforeEach(() => _resetScopeMaskForTest()); @@ -39,7 +42,7 @@ test('geometry anchors the visible edge to the shared keyhole radius', () => { assert.equal(geo.centerY, 430); // Feather straddles the keyhole edge: inner+outer average = keyhole radius. assert.ok(Math.abs((geo.innerR + geo.outerR) / 2 - keyholeR) < 1e-9); - assert.ok(Math.abs((geo.outerR - geo.innerR) - keyholeR * 0.4) < 1e-9); + assert.ok(Math.abs(geo.outerR - geo.innerR - keyholeR * 0.4) < 1e-9); }); test('zero feather produces a hard edge exactly at the keyhole radius', () => { @@ -72,7 +75,15 @@ test('feather ratio reaches the center at 200% and clamps larger input', () => { }); test('UI and share feather percentages share one bounded integer range', () => { - for (const [value, expected] of [[0, 0], [11, 11], [175.6, 176], [200, 200], [350, 200], [-4, 0], [Number.NaN, 0]]) { + for (const [value, expected] of [ + [0, 0], + [11, 11], + [175.6, 176], + [200, 200], + [350, 200], + [-4, 0], + [Number.NaN, 0], + ]) { assert.equal(clampScopeFeatherPct(value), expected); } }); @@ -99,8 +110,13 @@ test('an omitted feather argument uses the module default, whatever it is', () = const wider = scopeMaskGeometry(1200, 900, SCOPE_FEATHER_RATIO_DEFAULT + 0.4); assert.notEqual(wider.outerR - wider.innerR, omitted.outerR - omitted.innerR); const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS; - assert.ok(Math.abs((wider.outerR - wider.innerR) - - keyholeR * (SCOPE_FEATHER_RATIO_DEFAULT + 0.4)) < 1e-9); + assert.ok( + Math.abs( + wider.outerR - + wider.innerR - + keyholeR * (SCOPE_FEATHER_RATIO_DEFAULT + 0.4), + ) < 1e-9, + ); // The default's VALUE (hidden feather, owner directive 2026-08-22) is pinned // with the rest of the first-run batch in reasonableDefaults.test.mjs. }); @@ -119,7 +135,11 @@ test('backing-store scale is clamped to 2x and survives junk input', () => { * way moving a window between a 1x and a 2x monitor does. */ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) { - const saved = { window: globalThis.window, document: globalThis.document, ResizeObserver: globalThis.ResizeObserver }; + const saved = { + window: globalThis.window, + document: globalThis.document, + ResizeObserver: globalThis.ResizeObserver, + }; // Records every fillStyle assignment and gradient stop so the paint's actual // colours (not just its call sequence) can be asserted. const fillStyles = []; @@ -127,23 +147,50 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) { // Canvas work actually performed — the disabled scope must do none of it. const ops = { resizes: 0, clears: 0, fills: 0 }; const canvas = { - id: '', style: {}, - _width: 0, _height: 0, - set width(value) { ops.resizes += 1; this._width = value; }, - get width() { return this._width; }, - set height(value) { this._height = value; }, - get height() { return this._height; }, - setAttribute() {}, remove() {}, + id: '', + style: {}, + _width: 0, + _height: 0, + set width(value) { + ops.resizes += 1; + this._width = value; + }, + get width() { + return this._width; + }, + set height(value) { + this._height = value; + }, + get height() { + return this._height; + }, + setAttribute() {}, + remove() {}, getContext: () => ({ - setTransform() {}, beginPath() {}, rect() {}, arc() {}, - clearRect() { ops.clears += 1; }, - fill() { ops.fills += 1; }, - fillRect() { ops.fills += 1; }, + setTransform() {}, + beginPath() {}, + rect() {}, + arc() {}, + clearRect() { + ops.clears += 1; + }, + fill() { + ops.fills += 1; + }, + fillRect() { + ops.fills += 1; + }, createRadialGradient: () => ({ - addColorStop(offset, color) { gradientStops.push({ offset, color }); }, + addColorStop(offset, color) { + gradientStops.push({ offset, color }); + }, }), - set fillStyle(value) { if (typeof value === 'string') fillStyles.push(value); }, - get fillStyle() { return fillStyles[fillStyles.length - 1] || ''; }, + set fillStyle(value) { + if (typeof value === 'string') fillStyles.push(value); + }, + get fillStyle() { + return fillStyles[fillStyles.length - 1] || ''; + }, }), }; const listeners = new Set(); @@ -158,8 +205,15 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) { }; const documentElement = { dataset: {} }; globalThis.document = { documentElement, createElement: () => canvas }; - globalThis.ResizeObserver = class { observe() {} disconnect() {} }; - const container = { clientWidth: width, clientHeight: height, appendChild() {} }; + globalThis.ResizeObserver = class { + observe() {} + disconnect() {} + }; + const container = { + clientWidth: width, + clientHeight: height, + appendChild() {}, + }; return { canvas, container, @@ -189,16 +243,36 @@ test('scope appearance follows direct state changes and install/destroy lifecycl assert.equal(dom.documentElement.dataset.scopeMask, 'off'); delete dom.documentElement.dataset.scopeMask; installScopeMask({ container: dom.container }); - assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'install publishes retained OFF state'); + assert.equal( + dom.documentElement.dataset.scopeMask, + 'off', + 'install publishes retained OFF state', + ); destroyScopeMask(); - assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'destroy clears the document marker'); + assert.equal( + dom.documentElement.dataset.scopeMask, + undefined, + 'destroy clears the document marker', + ); installScopeMask({ container: dom.container }); - assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'reinstall retains the preference'); + assert.equal( + dom.documentElement.dataset.scopeMask, + 'off', + 'reinstall retains the preference', + ); setScopeMaskEnabled(true); - assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'ON uses the original presentation'); + assert.equal( + dom.documentElement.dataset.scopeMask, + undefined, + 'ON uses the original presentation', + ); setScopeMaskEnabled(false); _resetScopeMaskForTest(); - assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'test reset clears the marker'); + assert.equal( + dom.documentElement.dataset.scopeMask, + undefined, + 'test reset clears the marker', + ); } finally { destroyScopeMask(); dom.restore(); @@ -216,12 +290,20 @@ test('a DPR change with no resize still repaints the backing store', () => { // Window dragged to a 2x monitor. The content box never changed, so the // ResizeObserver stays silent — only the DPR watch can catch this. dom.setDpr(2); - assert.equal(dom.canvas.width, 2000, 'backing store must follow the new DPR'); + assert.equal( + dom.canvas.width, + 2000, + 'backing store must follow the new DPR', + ); assert.equal(dom.canvas.height, 1600); // And back again — the listener must be re-armed after each change. dom.setDpr(1); - assert.equal(dom.canvas.width, 1000, 'DPR watch must re-arm, not fire once'); + assert.equal( + dom.canvas.width, + 1000, + 'DPR watch must re-arm, not fire once', + ); assert.equal(dom.canvas.height, 800); } finally { destroyScopeMask(); @@ -251,50 +333,99 @@ test('destroy tears the DPR watch down (no redraw after teardown)', () => { // FEATHER is untouched (owner locked 35). test('the terminus band is the owner-approved 10 Mm → 7 Mm fade', () => { - assert.equal(SCOPE_TERMINUS_FAR_M, 10_000_000, 'the relaxed corners start above 10 Mm'); + assert.equal( + SCOPE_TERMINUS_FAR_M, + 10_000_000, + 'the relaxed corners start above 10 Mm', + ); assert.equal(SCOPE_TERMINUS_NEAR_M, 7_000_000, 'and are gone by 7 Mm'); - assert.equal(scopeTerminusAlpha(10_500_000), SCOPE_OUTSIDE_ALPHA, 'full-globe view keeps its stars'); - assert.equal(scopeTerminusAlpha(6_900_000), SCOPE_TERMINUS_ALPHA_NEAR, 'below the band: solid black'); - assert.equal(scopeTerminusAlpha(400_000), SCOPE_TERMINUS_ALPHA_NEAR, 'orbital view: solid black'); - assert.equal(scopeTerminusAlpha(1_500), SCOPE_TERMINUS_ALPHA_NEAR, 'city view: solid black'); + assert.equal( + scopeTerminusAlpha(10_500_000), + SCOPE_OUTSIDE_ALPHA, + 'full-globe view keeps its stars', + ); + assert.equal( + scopeTerminusAlpha(6_900_000), + SCOPE_TERMINUS_ALPHA_NEAR, + 'below the band: solid black', + ); + assert.equal( + scopeTerminusAlpha(400_000), + SCOPE_TERMINUS_ALPHA_NEAR, + 'orbital view: solid black', + ); + assert.equal( + scopeTerminusAlpha(1_500), + SCOPE_TERMINUS_ALPHA_NEAR, + 'city view: solid black', + ); // Quick, not gradual: a fifth of the way down the band it is already past halfway. - const fifth = SCOPE_TERMINUS_FAR_M - (SCOPE_TERMINUS_FAR_M - SCOPE_TERMINUS_NEAR_M) * 0.5; - assert.ok(scopeTerminusAlpha(fifth) > SCOPE_OUTSIDE_ALPHA + 0.02, - 'the fade must be well underway by mid-band'); + const fifth = + SCOPE_TERMINUS_FAR_M - (SCOPE_TERMINUS_FAR_M - SCOPE_TERMINUS_NEAR_M) * 0.5; + assert.ok( + scopeTerminusAlpha(fifth) > SCOPE_OUTSIDE_ALPHA + 0.02, + 'the fade must be well underway by mid-band', + ); }); test('terminus ramp: translucent at globe scale, full black up close', () => { assert.equal(scopeTerminusAlpha(SCOPE_TERMINUS_FAR_M), SCOPE_OUTSIDE_ALPHA); - assert.equal(scopeTerminusAlpha(SCOPE_TERMINUS_FAR_M + 1_000_000), SCOPE_OUTSIDE_ALPHA); - assert.equal(scopeTerminusAlpha(SCOPE_TERMINUS_NEAR_M), SCOPE_TERMINUS_ALPHA_NEAR); + assert.equal( + scopeTerminusAlpha(SCOPE_TERMINUS_FAR_M + 1_000_000), + SCOPE_OUTSIDE_ALPHA, + ); + assert.equal( + scopeTerminusAlpha(SCOPE_TERMINUS_NEAR_M), + SCOPE_TERMINUS_ALPHA_NEAR, + ); assert.equal(scopeTerminusAlpha(0), SCOPE_TERMINUS_ALPHA_NEAR); }); test('terminus ramp is monotonic, bounded, and eased at both ends', () => { const mid = (SCOPE_TERMINUS_FAR_M + SCOPE_TERMINUS_NEAR_M) / 2; - assert.ok(Math.abs(scopeTerminusAlpha(mid) - (SCOPE_OUTSIDE_ALPHA + 0.06 / 2)) < 1e-9, - 'smoothstep is symmetric — the midpoint sits halfway'); + assert.ok( + Math.abs(scopeTerminusAlpha(mid) - (SCOPE_OUTSIDE_ALPHA + 0.06 / 2)) < 1e-9, + 'smoothstep is symmetric — the midpoint sits halfway', + ); let previous = -Infinity; for (let h = 15_000_000; h >= 0; h -= 100_000) { const alpha = scopeTerminusAlpha(h); - assert.ok(alpha >= previous, `alpha must never decrease while descending (h=${h})`); - assert.ok(alpha >= SCOPE_OUTSIDE_ALPHA && alpha <= 1, `alpha out of range at h=${h}`); + assert.ok( + alpha >= previous, + `alpha must never decrease while descending (h=${h})`, + ); + assert.ok( + alpha >= SCOPE_OUTSIDE_ALPHA && alpha <= 1, + `alpha out of range at h=${h}`, + ); previous = alpha; } // Eased, not linear: near each end the slope is gentler than the midpoint's. const step = 100_000; - const nearEnd = scopeTerminusAlpha(SCOPE_TERMINUS_NEAR_M + step) - scopeTerminusAlpha(SCOPE_TERMINUS_NEAR_M); + const nearEnd = + scopeTerminusAlpha(SCOPE_TERMINUS_NEAR_M + step) - + scopeTerminusAlpha(SCOPE_TERMINUS_NEAR_M); const middle = scopeTerminusAlpha(mid) - scopeTerminusAlpha(mid + step); - assert.ok(Math.abs(nearEnd) < Math.abs(middle), 'the ramp must ease into the near clamp'); + assert.ok( + Math.abs(nearEnd) < Math.abs(middle), + 'the ramp must ease into the near clamp', + ); }); test('terminus ramp survives junk heights without breaking the paint', () => { assert.equal(scopeTerminusAlpha(Number.NaN), SCOPE_OUTSIDE_ALPHA); assert.equal(scopeTerminusAlpha(undefined), SCOPE_OUTSIDE_ALPHA); - assert.equal(scopeTerminusAlpha(Number.POSITIVE_INFINITY), SCOPE_OUTSIDE_ALPHA); - assert.equal(scopeTerminusAlpha(-1000), SCOPE_TERMINUS_ALPHA_NEAR, 'below the datum is still "close"'); + assert.equal( + scopeTerminusAlpha(Number.POSITIVE_INFINITY), + SCOPE_OUTSIDE_ALPHA, + ); + assert.equal( + scopeTerminusAlpha(-1000), + SCOPE_TERMINUS_ALPHA_NEAR, + 'below the datum is still "close"', + ); }); test('quantization snaps to the repaint grid', () => { @@ -315,7 +446,11 @@ test('repaint gate: only a QUANTIZED step repaints; hovering costs nothing', () // no repaint — and repeating the identical sample stays free. assert.equal(updateScopeTerminusForHeight(14_000_000), false); assert.equal(updateScopeTerminusForHeight(14_000_000), false); - assert.equal(getScopeTerminusRepaintCount(), base, 'a still camera must never repaint'); + assert.equal( + getScopeTerminusRepaintCount(), + base, + 'a still camera must never repaint', + ); // A real step repaints exactly once, then holds. assert.equal(updateScopeTerminusForHeight(SCOPE_TERMINUS_NEAR_M), true); @@ -328,7 +463,11 @@ test('repaint gate: only a QUANTIZED step repaints; hovering costs nothing', () updateScopeTerminusForHeight(8_500_000); const afterMid = getScopeTerminusRepaintCount(); updateScopeTerminusForHeight(8_501_000); - assert.equal(getScopeTerminusRepaintCount(), afterMid, 'sub-quantum drift must not repaint'); + assert.equal( + getScopeTerminusRepaintCount(), + afterMid, + 'sub-quantum drift must not repaint', + ); } finally { destroyScopeMask(); dom.restore(); @@ -344,13 +483,24 @@ test('a full zoom-in gesture costs only a handful of repaints', () => { // 20 Mm → ground in 10 km steps: ~2000 samples across the whole descent, // far denser than the 120 ms sampler could ever produce in a real gesture, // and covering the entire retuned band with room on both sides. - for (let h = 20_000_000; h >= 0; h -= 10_000) updateScopeTerminusForHeight(h); + for (let h = 20_000_000; h >= 0; h -= 10_000) + updateScopeTerminusForHeight(h); const repaints = getScopeTerminusRepaintCount() - base; // The ramp spans 0.06 alpha at 0.005 granularity → at most 13 distinct steps. - assert.ok(repaints <= 13, `expected ≤13 repaints across the descent, got ${repaints}`); - assert.ok(repaints >= 5, `expected a visibly smooth ramp, got only ${repaints}`); - assert.equal(getScopeTerminusAlpha(), 1, 'the descent must end fully opaque'); + assert.ok( + repaints <= 13, + `expected ≤13 repaints across the descent, got ${repaints}`, + ); + assert.ok( + repaints >= 5, + `expected a visibly smooth ramp, got only ${repaints}`, + ); + assert.equal( + getScopeTerminusAlpha(), + 1, + 'the descent must end fully opaque', + ); } finally { destroyScopeMask(); dom.restore(); @@ -371,7 +521,11 @@ test('an override pins the terminus and null restores the ramp', () => { setScopeTerminusOverride(null); assert.equal(getScopeTerminusOverride(), null); - assert.equal(updateScopeTerminusForHeight(0), true, 'the ramp is live again'); + assert.equal( + updateScopeTerminusForHeight(0), + true, + 'the ramp is live again', + ); assert.equal(getScopeTerminusAlpha(), 1); } finally { destroyScopeMask(); @@ -388,12 +542,18 @@ test('the hard-crop (feather 0) path honors the same terminus alpha', () => { // The globe-scale seed paint is legitimately in the history, so assert on // the LAST fill: what the hard crop is painting right now, zoomed in. const beforeDescent = dom.fillStyles().at(-1); - assert.equal(beforeDescent, `rgba(5,5,8,${SCOPE_OUTSIDE_ALPHA})`, - 'at globe scale the hard crop is still the translucent terminus'); + assert.equal( + beforeDescent, + `rgba(5,5,8,${SCOPE_OUTSIDE_ALPHA})`, + 'at globe scale the hard crop is still the translucent terminus', + ); updateScopeTerminusForHeight(SCOPE_TERMINUS_NEAR_M); - assert.equal(dom.fillStyles().at(-1), 'rgba(5,5,8,1)', - 'a zoomed-in hard crop must paint fully opaque, not the globe-scale 0.94'); + assert.equal( + dom.fillStyles().at(-1), + 'rgba(5,5,8,1)', + 'a zoomed-in hard crop must paint fully opaque, not the globe-scale 0.94', + ); } finally { setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT); destroyScopeMask(); @@ -402,8 +562,10 @@ test('the hard-crop (feather 0) path honors the same terminus alpha', () => { }); test('one quantum is the smallest step that can repaint', () => { - assert.ok(SCOPE_TERMINUS_QUANTUM > 0 && SCOPE_TERMINUS_QUANTUM < 0.06, - 'the quantum must be finer than the ramp it gates'); + assert.ok( + SCOPE_TERMINUS_QUANTUM > 0 && SCOPE_TERMINUS_QUANTUM < 0.06, + 'the quantum must be finer than the ramp it gates', + ); }); // ── SCOPE OFF must be the cheapest state (review round 2) ───────────────────── @@ -425,13 +587,21 @@ function stubScopeViewer(container, heightM) { viewer: { container, scene: { preRender: { addEventListener: addTo(preRenderListeners) } }, - camera: { positionCartographic, moveEnd: { addEventListener: addTo(moveEndListeners) } }, + camera: { + positionCartographic, + moveEnd: { addEventListener: addTo(moveEndListeners) }, + }, + }, + setHeight(next) { + positionCartographic.height = next; }, - setHeight(next) { positionCartographic.height = next; }, raisePreRender(times = 1) { - for (let i = 0; i < times; i += 1) for (const fn of [...preRenderListeners]) fn(); + for (let i = 0; i < times; i += 1) + for (const fn of [...preRenderListeners]) fn(); + }, + raiseMoveEnd() { + for (const fn of [...moveEndListeners]) fn(); }, - raiseMoveEnd() { for (const fn of [...moveEndListeners]) fn(); }, }; } @@ -447,15 +617,25 @@ test('a disabled scope does no canvas work and samples no camera heights', () => const beforeDisable = { ...dom.ops }; setScopeMaskEnabled(false); const afterDisable = { ...dom.ops }; - assert.equal(afterDisable.clears, beforeDisable.clears + 1, - 'the disable transition clears the mask exactly once'); - assert.equal(afterDisable.resizes, beforeDisable.resizes, - 'and clears without a backing-store resize'); + assert.equal( + afterDisable.clears, + beforeDisable.clears + 1, + 'the disable transition clears the mask exactly once', + ); + assert.equal( + afterDisable.resizes, + beforeDisable.resizes, + 'and clears without a backing-store resize', + ); assert.equal(afterDisable.fills, beforeDisable.fills, 'and paints nothing'); // A second disable is inert — the canvas already holds no ink. setScopeMaskEnabled(false); - assert.deepEqual(dom.ops, afterDisable, 'a repeated disable must re-clear nothing'); + assert.deepEqual( + dom.ops, + afterDisable, + 'a repeated disable must re-clear nothing', + ); // Fly the whole band while OFF: the sampler must not read the camera, and // no draw may touch the backing store. @@ -463,15 +643,29 @@ test('a disabled scope does no canvas work and samples no camera heights', () => rig.raisePreRender(40); rig.raiseMoveEnd(); setScopeMaskFeather(0.5); // any tuning write still routes through draw() - assert.deepEqual(dom.ops, afterDisable, 'a disabled scope must do NO canvas work'); - assert.equal(getScopeTerminusAlpha(), SCOPE_OUTSIDE_ALPHA, 'and must not track the camera'); + assert.deepEqual( + dom.ops, + afterDisable, + 'a disabled scope must do NO canvas work', + ); + assert.equal( + getScopeTerminusAlpha(), + SCOPE_OUTSIDE_ALPHA, + 'and must not track the camera', + ); // Re-enable: the alpha it skipped is re-synced once, before the first paint. const beforeEnable = { ...dom.ops }; setScopeMaskEnabled(true); - assert.equal(getScopeTerminusAlpha(), SCOPE_TERMINUS_ALPHA_NEAR, - 're-enabling must re-sync the terminus it stopped sampling'); - assert.ok(dom.ops.resizes > beforeEnable.resizes, 'and repaint at the live altitude'); + assert.equal( + getScopeTerminusAlpha(), + SCOPE_TERMINUS_ALPHA_NEAR, + 're-enabling must re-sync the terminus it stopped sampling', + ); + assert.ok( + dom.ops.resizes > beforeEnable.resizes, + 'and repaint at the live altitude', + ); } finally { setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT); destroyScopeMask(); @@ -493,10 +687,26 @@ test('a DPR change that also crosses a terminus step paints once, not twice', () const repaintsBefore = getScopeTerminusRepaintCount(); dom.setDpr(2); - assert.equal(dom.ops.resizes - before.resizes, 1, 'both changes must share ONE paint'); - assert.equal(dom.canvas.width, 2000, 'and it must be at the new backing-store scale'); - assert.equal(getScopeTerminusAlpha(), 0.97, 'with the stepped terminus already folded in'); - assert.equal(getScopeTerminusRepaintCount(), repaintsBefore + 1, 'counted as one repaint'); + assert.equal( + dom.ops.resizes - before.resizes, + 1, + 'both changes must share ONE paint', + ); + assert.equal( + dom.canvas.width, + 2000, + 'and it must be at the new backing-store scale', + ); + assert.equal( + getScopeTerminusAlpha(), + 0.97, + 'with the stepped terminus already folded in', + ); + assert.equal( + getScopeTerminusRepaintCount(), + repaintsBefore + 1, + 'counted as one repaint', + ); } finally { destroyScopeMask(); dom.restore(); @@ -510,12 +720,24 @@ test('terminus percents clamp into the supported band; junk is absent, not zero' assert.equal(SCOPE_TERMINUS_MAX_PCT, 100); assert.equal(clampScopeTerminusPct(97), 97); assert.equal(clampScopeTerminusPct(96.6), 97); - assert.equal(clampScopeTerminusPct(0), 94, 'a sub-band value is floored, never honoured'); + assert.equal( + clampScopeTerminusPct(0), + 94, + 'a sub-band value is floored, never honoured', + ); assert.equal(clampScopeTerminusPct(93), 94); assert.equal(clampScopeTerminusPct(-100), 94); assert.equal(clampScopeTerminusPct(1000), 100); - assert.equal(clampScopeTerminusPct('98'), 98, 'hash values arrive as strings'); - assert.equal(clampScopeTerminusPct(null), null, 'absent means adaptive, not 0'); + assert.equal( + clampScopeTerminusPct('98'), + 98, + 'hash values arrive as strings', + ); + assert.equal( + clampScopeTerminusPct(null), + null, + 'absent means adaptive, not 0', + ); assert.equal(clampScopeTerminusPct(undefined), null); assert.equal(clampScopeTerminusPct(''), null); assert.equal(clampScopeTerminusPct('abc'), null); @@ -528,8 +750,11 @@ test('a pinned override is floored to the band at every entry point', () => { try { installScopeMask({ container: dom.container }); setScopeTerminusOverride(0); // an sce=0 link, or any stale caller - assert.equal(getScopeTerminusOverride(), SCOPE_OUTSIDE_ALPHA, - 'a fully transparent terminus is a hole in the mask, not a scope'); + assert.equal( + getScopeTerminusOverride(), + SCOPE_OUTSIDE_ALPHA, + 'a fully transparent terminus is a hole in the mask, not a scope', + ); assert.equal(getScopeTerminusAlpha(), SCOPE_OUTSIDE_ALPHA); setScopeTerminusOverride(5); @@ -539,3 +764,40 @@ test('a pinned override is floored to the band at every entry point', () => { dom.restore(); } }); + +test('on a globe engine (Navara) the mask goes into its host container and follows its camera height', async () => { + const { createFakeEngine } = await import('./globe/testing/fakeEngine.js'); + const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); + const appended = []; + dom.container.appendChild = (child) => appended.push(child); + // Globe scale: the translucent terminus. + const engine = createFakeEngine({ kind: 'navara', altitude: 14_000_000 }); + engine.host.container = dom.container; + try { + installScopeMask(engine); + assert.deepEqual(appended, [dom.canvas]); + assert.equal(dom.canvas.id, 'scope-mask'); + assert.equal(getScopeTerminusAlpha(), SCOPE_OUTSIDE_ALPHA); + // Down to a city: the engine's moveEnd pins the opaque terminus. + engine.fake.setPose({ longitude: 0, latitude: 0, height: 2000 }); + engine.fake.frames(3); + assert.equal(getScopeTerminusAlpha(), SCOPE_TERMINUS_ALPHA_NEAR); + assert.ok( + dom.fillStyles().some((style) => style.endsWith(',1)')) || + dom.gradientStops().some(({ color }) => color.endsWith(',1)')), + 'an opaque outside at low altitude', + ); + destroyScopeMask(); + const repaints = getScopeTerminusRepaintCount(); + engine.fake.setPose({ longitude: 0, latitude: 0, height: 14_000_000 }); + engine.fake.frames(3); + assert.equal( + getScopeTerminusRepaintCount(), + repaints, + 'no engine listener after destroy', + ); + } finally { + destroyScopeMask(); + dom.restore(); + } +});