mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 20:04:00 +00:00
Put the DISPLAY scope mask over Navara, its terminus following the engine camera's height
This commit is contained in:
1 parent
ef174b0ff7
commit
5983c663bd
5 files changed
+599
-98
No files matched your search
@@ -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');
|
||||
},
|
||||
);
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user