Put the DISPLAY scope mask over Navara, its terminus following the engine camera's height

This commit is contained in:
German Khokhlov committed 2026-11-03 21:46:37 +04:00
1 parent ef174b0ff7
commit 5983c663bd
5 files changed
+599 -98

No files matched your search

+193
View File
@@ -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:<PORT>/
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');
},
);
+2
View File
@@ -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({
+11
View File
@@ -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());
}
+48 -15
View File
@@ -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;
+345 -83
View File
@@ -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();
}
});