mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +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,
|
attachNavaraAnnotations,
|
||||||
attachNavaraDraw,
|
attachNavaraDraw,
|
||||||
attachNavaraGeoImport,
|
attachNavaraGeoImport,
|
||||||
|
attachNavaraScopeMask,
|
||||||
} from './navaraTools.js';
|
} from './navaraTools.js';
|
||||||
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
||||||
|
|
||||||
@@ -495,6 +496,7 @@ export function attachViewerlessTools(
|
|||||||
});
|
});
|
||||||
attachNavaraDraw(tools, { ...context, annotations }, { signal, defer });
|
attachNavaraDraw(tools, { ...context, annotations }, { signal, defer });
|
||||||
attachNavaraGeoImport(tools, context, { 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;
|
// 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.
|
// the Director uses the shared session; viewer-only tools declare availability.
|
||||||
const voiceCommands = attachSessionVoice({
|
const voiceCommands = attachSessionVoice({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js';
|
import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js';
|
||||||
import { navaraDrawSurface } from '../annotations/drawInputNavara.js';
|
import { navaraDrawSurface } from '../annotations/drawInputNavara.js';
|
||||||
|
import { destroyScopeMask, installScopeMask } from '../scopeMask.js';
|
||||||
import {
|
import {
|
||||||
sessionAnnotations,
|
sessionAnnotations,
|
||||||
sessionDrawTool,
|
sessionDrawTool,
|
||||||
@@ -92,3 +93,13 @@ export function attachNavaraGeoImport(
|
|||||||
defer(() => geoFileImport?.detachScene());
|
defer(() => geoFileImport?.detachScene());
|
||||||
return geoFileImport;
|
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 { getKeyholeGeometry } from './celestialRing.js';
|
||||||
|
import { isGlobeEngine } from './globe/contract.js';
|
||||||
import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js';
|
import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,7 +87,11 @@ const SCOPE_TERMINUS_SAMPLE_MS = 120;
|
|||||||
|
|
||||||
let _canvas = null;
|
let _canvas = null;
|
||||||
let _container = 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 _enabled = true;
|
||||||
let _featherRatio = SCOPE_FEATHER_RATIO_DEFAULT;
|
let _featherRatio = SCOPE_FEATHER_RATIO_DEFAULT;
|
||||||
let _resizeObserver = null;
|
let _resizeObserver = null;
|
||||||
@@ -421,15 +426,38 @@ function syncScopeMaskAppearance(enabled = _enabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install the scope mask into the viewer container. Idempotent.
|
* The camera height and frame/camera events of a Cesium viewer or a globe
|
||||||
* @param {Cesium.Viewer} viewer
|
* 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}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export function installScopeMask(viewer) {
|
export function installScopeMask(target) {
|
||||||
if (_canvas || !viewer?.container) return;
|
if (_canvas) return;
|
||||||
|
const globe = globeOf(target);
|
||||||
|
if (!globe.container) return;
|
||||||
syncScopeMaskAppearance();
|
syncScopeMaskAppearance();
|
||||||
_container = viewer.container;
|
_container = globe.container;
|
||||||
_viewer = viewer;
|
_globe = globe;
|
||||||
_canvas = document.createElement('canvas');
|
_canvas = document.createElement('canvas');
|
||||||
_canvas.id = 'scope-mask';
|
_canvas.id = 'scope-mask';
|
||||||
_canvas.setAttribute('aria-hidden', 'true');
|
_canvas.setAttribute('aria-hidden', 'true');
|
||||||
@@ -437,7 +465,7 @@ export function installScopeMask(viewer) {
|
|||||||
_resizeObserver = new ResizeObserver(() => draw());
|
_resizeObserver = new ResizeObserver(() => draw());
|
||||||
_resizeObserver.observe(_container);
|
_resizeObserver.observe(_container);
|
||||||
watchDevicePixelRatio();
|
watchDevicePixelRatio();
|
||||||
watchCameraHeight(viewer);
|
watchCameraHeight(globe);
|
||||||
// Seed from the live camera so the first paint is already correct for the
|
// Seed from the live camera so the first paint is already correct for the
|
||||||
// restored/initial altitude instead of flashing the globe-scale terminus.
|
// restored/initial altitude instead of flashing the globe-scale terminus.
|
||||||
_terminusAlpha = currentTerminusTarget();
|
_terminusAlpha = currentTerminusTarget();
|
||||||
@@ -446,7 +474,12 @@ export function installScopeMask(viewer) {
|
|||||||
|
|
||||||
/** @returns {number} Live camera height above the ellipsoid, or +Inf if unknown. */
|
/** @returns {number} Live camera height above the ellipsoid, or +Inf if unknown. */
|
||||||
function currentCameraHeightM() {
|
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;
|
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
|
* 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.
|
* parked camera renders no frames at all, so this costs nothing at rest.
|
||||||
* moveEnd additionally pins the exact settled value.
|
* moveEnd additionally pins the exact settled value.
|
||||||
* @param {Cesium.Viewer} viewer
|
* @param {{ preRender?: object, moveEnd?: object }} globe
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
function watchCameraHeight(viewer) {
|
function watchCameraHeight(globe) {
|
||||||
const preRender = viewer?.scene?.preRender;
|
const preRender = globe.preRender;
|
||||||
if (preRender?.addEventListener) {
|
if (preRender?.addEventListener) {
|
||||||
_cameraSampleRemover = preRender.addEventListener(() => {
|
_cameraSampleRemover = preRender.addEventListener(() => {
|
||||||
// SCOPE OFF paints nothing, so it samples nothing: no height read, no
|
// SCOPE OFF paints nothing, so it samples nothing: no height read, no
|
||||||
@@ -474,7 +507,7 @@ function watchCameraHeight(viewer) {
|
|||||||
updateScopeTerminusForHeight(currentCameraHeightM());
|
updateScopeTerminusForHeight(currentCameraHeightM());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const moveEnd = viewer?.camera?.moveEnd;
|
const moveEnd = globe.moveEnd;
|
||||||
if (moveEnd?.addEventListener) {
|
if (moveEnd?.addEventListener) {
|
||||||
_cameraMoveEndRemover = moveEnd.addEventListener(() => {
|
_cameraMoveEndRemover = moveEnd.addEventListener(() => {
|
||||||
if (!_enabled) return;
|
if (!_enabled) return;
|
||||||
@@ -550,7 +583,7 @@ export function destroyScopeMask() {
|
|||||||
_canvas?.remove();
|
_canvas?.remove();
|
||||||
_canvas = null;
|
_canvas = null;
|
||||||
_container = null;
|
_container = null;
|
||||||
_viewer = null;
|
_globe = null;
|
||||||
_painted = false;
|
_painted = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,7 +597,7 @@ export function _resetScopeMaskForTest() {
|
|||||||
_canvas?.remove();
|
_canvas?.remove();
|
||||||
_canvas = null;
|
_canvas = null;
|
||||||
_container = null;
|
_container = null;
|
||||||
_viewer = null;
|
_globe = null;
|
||||||
_enabled = true;
|
_enabled = true;
|
||||||
_featherRatio = SCOPE_FEATHER_RATIO_DEFAULT;
|
_featherRatio = SCOPE_FEATHER_RATIO_DEFAULT;
|
||||||
_terminusAlpha = SCOPE_OUTSIDE_ALPHA;
|
_terminusAlpha = SCOPE_OUTSIDE_ALPHA;
|
||||||
|
|||||||
+345
-83
@@ -27,7 +27,10 @@ import {
|
|||||||
_resetScopeMaskForTest,
|
_resetScopeMaskForTest,
|
||||||
} from './scopeMask.js';
|
} from './scopeMask.js';
|
||||||
import { KEYHOLE_OUTER_RADIUS } from './celestialRing.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());
|
beforeEach(() => _resetScopeMaskForTest());
|
||||||
|
|
||||||
@@ -39,7 +42,7 @@ test('geometry anchors the visible edge to the shared keyhole radius', () => {
|
|||||||
assert.equal(geo.centerY, 430);
|
assert.equal(geo.centerY, 430);
|
||||||
// Feather straddles the keyhole edge: inner+outer average = keyhole radius.
|
// 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.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', () => {
|
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', () => {
|
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);
|
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);
|
const wider = scopeMaskGeometry(1200, 900, SCOPE_FEATHER_RATIO_DEFAULT + 0.4);
|
||||||
assert.notEqual(wider.outerR - wider.innerR, omitted.outerR - omitted.innerR);
|
assert.notEqual(wider.outerR - wider.innerR, omitted.outerR - omitted.innerR);
|
||||||
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
|
const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS;
|
||||||
assert.ok(Math.abs((wider.outerR - wider.innerR)
|
assert.ok(
|
||||||
- keyholeR * (SCOPE_FEATHER_RATIO_DEFAULT + 0.4)) < 1e-9);
|
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
|
// 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.
|
// 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.
|
* way moving a window between a 1x and a 2x monitor does.
|
||||||
*/
|
*/
|
||||||
function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) {
|
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
|
// Records every fillStyle assignment and gradient stop so the paint's actual
|
||||||
// colours (not just its call sequence) can be asserted.
|
// colours (not just its call sequence) can be asserted.
|
||||||
const fillStyles = [];
|
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.
|
// Canvas work actually performed — the disabled scope must do none of it.
|
||||||
const ops = { resizes: 0, clears: 0, fills: 0 };
|
const ops = { resizes: 0, clears: 0, fills: 0 };
|
||||||
const canvas = {
|
const canvas = {
|
||||||
id: '', style: {},
|
id: '',
|
||||||
_width: 0, _height: 0,
|
style: {},
|
||||||
set width(value) { ops.resizes += 1; this._width = value; },
|
_width: 0,
|
||||||
get width() { return this._width; },
|
_height: 0,
|
||||||
set height(value) { this._height = value; },
|
set width(value) {
|
||||||
get height() { return this._height; },
|
ops.resizes += 1;
|
||||||
setAttribute() {}, remove() {},
|
this._width = value;
|
||||||
|
},
|
||||||
|
get width() {
|
||||||
|
return this._width;
|
||||||
|
},
|
||||||
|
set height(value) {
|
||||||
|
this._height = value;
|
||||||
|
},
|
||||||
|
get height() {
|
||||||
|
return this._height;
|
||||||
|
},
|
||||||
|
setAttribute() {},
|
||||||
|
remove() {},
|
||||||
getContext: () => ({
|
getContext: () => ({
|
||||||
setTransform() {}, beginPath() {}, rect() {}, arc() {},
|
setTransform() {},
|
||||||
clearRect() { ops.clears += 1; },
|
beginPath() {},
|
||||||
fill() { ops.fills += 1; },
|
rect() {},
|
||||||
fillRect() { ops.fills += 1; },
|
arc() {},
|
||||||
|
clearRect() {
|
||||||
|
ops.clears += 1;
|
||||||
|
},
|
||||||
|
fill() {
|
||||||
|
ops.fills += 1;
|
||||||
|
},
|
||||||
|
fillRect() {
|
||||||
|
ops.fills += 1;
|
||||||
|
},
|
||||||
createRadialGradient: () => ({
|
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); },
|
set fillStyle(value) {
|
||||||
get fillStyle() { return fillStyles[fillStyles.length - 1] || ''; },
|
if (typeof value === 'string') fillStyles.push(value);
|
||||||
|
},
|
||||||
|
get fillStyle() {
|
||||||
|
return fillStyles[fillStyles.length - 1] || '';
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const listeners = new Set();
|
const listeners = new Set();
|
||||||
@@ -158,8 +205,15 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) {
|
|||||||
};
|
};
|
||||||
const documentElement = { dataset: {} };
|
const documentElement = { dataset: {} };
|
||||||
globalThis.document = { documentElement, createElement: () => canvas };
|
globalThis.document = { documentElement, createElement: () => canvas };
|
||||||
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
|
globalThis.ResizeObserver = class {
|
||||||
const container = { clientWidth: width, clientHeight: height, appendChild() {} };
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
};
|
||||||
|
const container = {
|
||||||
|
clientWidth: width,
|
||||||
|
clientHeight: height,
|
||||||
|
appendChild() {},
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
canvas,
|
canvas,
|
||||||
container,
|
container,
|
||||||
@@ -189,16 +243,36 @@ test('scope appearance follows direct state changes and install/destroy lifecycl
|
|||||||
assert.equal(dom.documentElement.dataset.scopeMask, 'off');
|
assert.equal(dom.documentElement.dataset.scopeMask, 'off');
|
||||||
delete dom.documentElement.dataset.scopeMask;
|
delete dom.documentElement.dataset.scopeMask;
|
||||||
installScopeMask({ container: dom.container });
|
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();
|
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 });
|
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);
|
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);
|
setScopeMaskEnabled(false);
|
||||||
_resetScopeMaskForTest();
|
_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 {
|
} finally {
|
||||||
destroyScopeMask();
|
destroyScopeMask();
|
||||||
dom.restore();
|
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
|
// Window dragged to a 2x monitor. The content box never changed, so the
|
||||||
// ResizeObserver stays silent — only the DPR watch can catch this.
|
// ResizeObserver stays silent — only the DPR watch can catch this.
|
||||||
dom.setDpr(2);
|
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);
|
assert.equal(dom.canvas.height, 1600);
|
||||||
|
|
||||||
// And back again — the listener must be re-armed after each change.
|
// And back again — the listener must be re-armed after each change.
|
||||||
dom.setDpr(1);
|
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);
|
assert.equal(dom.canvas.height, 800);
|
||||||
} finally {
|
} finally {
|
||||||
destroyScopeMask();
|
destroyScopeMask();
|
||||||
@@ -251,50 +333,99 @@ test('destroy tears the DPR watch down (no redraw after teardown)', () => {
|
|||||||
// FEATHER is untouched (owner locked 35).
|
// FEATHER is untouched (owner locked 35).
|
||||||
|
|
||||||
test('the terminus band is the owner-approved 10 Mm → 7 Mm fade', () => {
|
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(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(
|
||||||
assert.equal(scopeTerminusAlpha(6_900_000), SCOPE_TERMINUS_ALPHA_NEAR, 'below the band: solid black');
|
scopeTerminusAlpha(10_500_000),
|
||||||
assert.equal(scopeTerminusAlpha(400_000), SCOPE_TERMINUS_ALPHA_NEAR, 'orbital view: solid black');
|
SCOPE_OUTSIDE_ALPHA,
|
||||||
assert.equal(scopeTerminusAlpha(1_500), SCOPE_TERMINUS_ALPHA_NEAR, 'city view: solid black');
|
'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.
|
// 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;
|
const fifth =
|
||||||
assert.ok(scopeTerminusAlpha(fifth) > SCOPE_OUTSIDE_ALPHA + 0.02,
|
SCOPE_TERMINUS_FAR_M - (SCOPE_TERMINUS_FAR_M - SCOPE_TERMINUS_NEAR_M) * 0.5;
|
||||||
'the fade must be well underway by mid-band');
|
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', () => {
|
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), SCOPE_OUTSIDE_ALPHA);
|
||||||
assert.equal(scopeTerminusAlpha(SCOPE_TERMINUS_FAR_M + 1_000_000), SCOPE_OUTSIDE_ALPHA);
|
assert.equal(
|
||||||
assert.equal(scopeTerminusAlpha(SCOPE_TERMINUS_NEAR_M), SCOPE_TERMINUS_ALPHA_NEAR);
|
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);
|
assert.equal(scopeTerminusAlpha(0), SCOPE_TERMINUS_ALPHA_NEAR);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('terminus ramp is monotonic, bounded, and eased at both ends', () => {
|
test('terminus ramp is monotonic, bounded, and eased at both ends', () => {
|
||||||
const mid = (SCOPE_TERMINUS_FAR_M + SCOPE_TERMINUS_NEAR_M) / 2;
|
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,
|
assert.ok(
|
||||||
'smoothstep is symmetric — the midpoint sits halfway');
|
Math.abs(scopeTerminusAlpha(mid) - (SCOPE_OUTSIDE_ALPHA + 0.06 / 2)) < 1e-9,
|
||||||
|
'smoothstep is symmetric — the midpoint sits halfway',
|
||||||
|
);
|
||||||
|
|
||||||
let previous = -Infinity;
|
let previous = -Infinity;
|
||||||
for (let h = 15_000_000; h >= 0; h -= 100_000) {
|
for (let h = 15_000_000; h >= 0; h -= 100_000) {
|
||||||
const alpha = scopeTerminusAlpha(h);
|
const alpha = scopeTerminusAlpha(h);
|
||||||
assert.ok(alpha >= previous, `alpha must never decrease while descending (h=${h})`);
|
assert.ok(
|
||||||
assert.ok(alpha >= SCOPE_OUTSIDE_ALPHA && alpha <= 1, `alpha out of range at h=${h}`);
|
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;
|
previous = alpha;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eased, not linear: near each end the slope is gentler than the midpoint's.
|
// Eased, not linear: near each end the slope is gentler than the midpoint's.
|
||||||
const step = 100_000;
|
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);
|
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', () => {
|
test('terminus ramp survives junk heights without breaking the paint', () => {
|
||||||
assert.equal(scopeTerminusAlpha(Number.NaN), SCOPE_OUTSIDE_ALPHA);
|
assert.equal(scopeTerminusAlpha(Number.NaN), SCOPE_OUTSIDE_ALPHA);
|
||||||
assert.equal(scopeTerminusAlpha(undefined), SCOPE_OUTSIDE_ALPHA);
|
assert.equal(scopeTerminusAlpha(undefined), SCOPE_OUTSIDE_ALPHA);
|
||||||
assert.equal(scopeTerminusAlpha(Number.POSITIVE_INFINITY), SCOPE_OUTSIDE_ALPHA);
|
assert.equal(
|
||||||
assert.equal(scopeTerminusAlpha(-1000), SCOPE_TERMINUS_ALPHA_NEAR, 'below the datum is still "close"');
|
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', () => {
|
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.
|
// 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(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.
|
// A real step repaints exactly once, then holds.
|
||||||
assert.equal(updateScopeTerminusForHeight(SCOPE_TERMINUS_NEAR_M), true);
|
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);
|
updateScopeTerminusForHeight(8_500_000);
|
||||||
const afterMid = getScopeTerminusRepaintCount();
|
const afterMid = getScopeTerminusRepaintCount();
|
||||||
updateScopeTerminusForHeight(8_501_000);
|
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 {
|
} finally {
|
||||||
destroyScopeMask();
|
destroyScopeMask();
|
||||||
dom.restore();
|
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,
|
// 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,
|
// 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.
|
// 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;
|
const repaints = getScopeTerminusRepaintCount() - base;
|
||||||
|
|
||||||
// The ramp spans 0.06 alpha at 0.005 granularity → at most 13 distinct steps.
|
// 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(
|
||||||
assert.ok(repaints >= 5, `expected a visibly smooth ramp, got only ${repaints}`);
|
repaints <= 13,
|
||||||
assert.equal(getScopeTerminusAlpha(), 1, 'the descent must end fully opaque');
|
`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 {
|
} finally {
|
||||||
destroyScopeMask();
|
destroyScopeMask();
|
||||||
dom.restore();
|
dom.restore();
|
||||||
@@ -371,7 +521,11 @@ test('an override pins the terminus and null restores the ramp', () => {
|
|||||||
|
|
||||||
setScopeTerminusOverride(null);
|
setScopeTerminusOverride(null);
|
||||||
assert.equal(getScopeTerminusOverride(), 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);
|
assert.equal(getScopeTerminusAlpha(), 1);
|
||||||
} finally {
|
} finally {
|
||||||
destroyScopeMask();
|
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 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.
|
// the LAST fill: what the hard crop is painting right now, zoomed in.
|
||||||
const beforeDescent = dom.fillStyles().at(-1);
|
const beforeDescent = dom.fillStyles().at(-1);
|
||||||
assert.equal(beforeDescent, `rgba(5,5,8,${SCOPE_OUTSIDE_ALPHA})`,
|
assert.equal(
|
||||||
'at globe scale the hard crop is still the translucent terminus');
|
beforeDescent,
|
||||||
|
`rgba(5,5,8,${SCOPE_OUTSIDE_ALPHA})`,
|
||||||
|
'at globe scale the hard crop is still the translucent terminus',
|
||||||
|
);
|
||||||
|
|
||||||
updateScopeTerminusForHeight(SCOPE_TERMINUS_NEAR_M);
|
updateScopeTerminusForHeight(SCOPE_TERMINUS_NEAR_M);
|
||||||
assert.equal(dom.fillStyles().at(-1), 'rgba(5,5,8,1)',
|
assert.equal(
|
||||||
'a zoomed-in hard crop must paint fully opaque, not the globe-scale 0.94');
|
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 {
|
} finally {
|
||||||
setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT);
|
setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT);
|
||||||
destroyScopeMask();
|
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', () => {
|
test('one quantum is the smallest step that can repaint', () => {
|
||||||
assert.ok(SCOPE_TERMINUS_QUANTUM > 0 && SCOPE_TERMINUS_QUANTUM < 0.06,
|
assert.ok(
|
||||||
'the quantum must be finer than the ramp it gates');
|
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) ─────────────────────
|
// ── SCOPE OFF must be the cheapest state (review round 2) ─────────────────────
|
||||||
@@ -425,13 +587,21 @@ function stubScopeViewer(container, heightM) {
|
|||||||
viewer: {
|
viewer: {
|
||||||
container,
|
container,
|
||||||
scene: { preRender: { addEventListener: addTo(preRenderListeners) } },
|
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) {
|
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 };
|
const beforeDisable = { ...dom.ops };
|
||||||
setScopeMaskEnabled(false);
|
setScopeMaskEnabled(false);
|
||||||
const afterDisable = { ...dom.ops };
|
const afterDisable = { ...dom.ops };
|
||||||
assert.equal(afterDisable.clears, beforeDisable.clears + 1,
|
assert.equal(
|
||||||
'the disable transition clears the mask exactly once');
|
afterDisable.clears,
|
||||||
assert.equal(afterDisable.resizes, beforeDisable.resizes,
|
beforeDisable.clears + 1,
|
||||||
'and clears without a backing-store resize');
|
'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');
|
assert.equal(afterDisable.fills, beforeDisable.fills, 'and paints nothing');
|
||||||
|
|
||||||
// A second disable is inert — the canvas already holds no ink.
|
// A second disable is inert — the canvas already holds no ink.
|
||||||
setScopeMaskEnabled(false);
|
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
|
// Fly the whole band while OFF: the sampler must not read the camera, and
|
||||||
// no draw may touch the backing store.
|
// 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.raisePreRender(40);
|
||||||
rig.raiseMoveEnd();
|
rig.raiseMoveEnd();
|
||||||
setScopeMaskFeather(0.5); // any tuning write still routes through draw()
|
setScopeMaskFeather(0.5); // any tuning write still routes through draw()
|
||||||
assert.deepEqual(dom.ops, afterDisable, 'a disabled scope must do NO canvas work');
|
assert.deepEqual(
|
||||||
assert.equal(getScopeTerminusAlpha(), SCOPE_OUTSIDE_ALPHA, 'and must not track the camera');
|
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.
|
// Re-enable: the alpha it skipped is re-synced once, before the first paint.
|
||||||
const beforeEnable = { ...dom.ops };
|
const beforeEnable = { ...dom.ops };
|
||||||
setScopeMaskEnabled(true);
|
setScopeMaskEnabled(true);
|
||||||
assert.equal(getScopeTerminusAlpha(), SCOPE_TERMINUS_ALPHA_NEAR,
|
assert.equal(
|
||||||
're-enabling must re-sync the terminus it stopped sampling');
|
getScopeTerminusAlpha(),
|
||||||
assert.ok(dom.ops.resizes > beforeEnable.resizes, 'and repaint at the live altitude');
|
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 {
|
} finally {
|
||||||
setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT);
|
setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT);
|
||||||
destroyScopeMask();
|
destroyScopeMask();
|
||||||
@@ -493,10 +687,26 @@ test('a DPR change that also crosses a terminus step paints once, not twice', ()
|
|||||||
const repaintsBefore = getScopeTerminusRepaintCount();
|
const repaintsBefore = getScopeTerminusRepaintCount();
|
||||||
dom.setDpr(2);
|
dom.setDpr(2);
|
||||||
|
|
||||||
assert.equal(dom.ops.resizes - before.resizes, 1, 'both changes must share ONE paint');
|
assert.equal(
|
||||||
assert.equal(dom.canvas.width, 2000, 'and it must be at the new backing-store scale');
|
dom.ops.resizes - before.resizes,
|
||||||
assert.equal(getScopeTerminusAlpha(), 0.97, 'with the stepped terminus already folded in');
|
1,
|
||||||
assert.equal(getScopeTerminusRepaintCount(), repaintsBefore + 1, 'counted as one repaint');
|
'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 {
|
} finally {
|
||||||
destroyScopeMask();
|
destroyScopeMask();
|
||||||
dom.restore();
|
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(SCOPE_TERMINUS_MAX_PCT, 100);
|
||||||
assert.equal(clampScopeTerminusPct(97), 97);
|
assert.equal(clampScopeTerminusPct(97), 97);
|
||||||
assert.equal(clampScopeTerminusPct(96.6), 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(93), 94);
|
||||||
assert.equal(clampScopeTerminusPct(-100), 94);
|
assert.equal(clampScopeTerminusPct(-100), 94);
|
||||||
assert.equal(clampScopeTerminusPct(1000), 100);
|
assert.equal(clampScopeTerminusPct(1000), 100);
|
||||||
assert.equal(clampScopeTerminusPct('98'), 98, 'hash values arrive as strings');
|
assert.equal(
|
||||||
assert.equal(clampScopeTerminusPct(null), null, 'absent means adaptive, not 0');
|
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(undefined), null);
|
||||||
assert.equal(clampScopeTerminusPct(''), null);
|
assert.equal(clampScopeTerminusPct(''), null);
|
||||||
assert.equal(clampScopeTerminusPct('abc'), null);
|
assert.equal(clampScopeTerminusPct('abc'), null);
|
||||||
@@ -528,8 +750,11 @@ test('a pinned override is floored to the band at every entry point', () => {
|
|||||||
try {
|
try {
|
||||||
installScopeMask({ container: dom.container });
|
installScopeMask({ container: dom.container });
|
||||||
setScopeTerminusOverride(0); // an sce=0 link, or any stale caller
|
setScopeTerminusOverride(0); // an sce=0 link, or any stale caller
|
||||||
assert.equal(getScopeTerminusOverride(), SCOPE_OUTSIDE_ALPHA,
|
assert.equal(
|
||||||
'a fully transparent terminus is a hole in the mask, not a scope');
|
getScopeTerminusOverride(),
|
||||||
|
SCOPE_OUTSIDE_ALPHA,
|
||||||
|
'a fully transparent terminus is a hole in the mask, not a scope',
|
||||||
|
);
|
||||||
assert.equal(getScopeTerminusAlpha(), SCOPE_OUTSIDE_ALPHA);
|
assert.equal(getScopeTerminusAlpha(), SCOPE_OUTSIDE_ALPHA);
|
||||||
|
|
||||||
setScopeTerminusOverride(5);
|
setScopeTerminusOverride(5);
|
||||||
@@ -539,3 +764,40 @@ test('a pinned override is floored to the band at every entry point', () => {
|
|||||||
dom.restore();
|
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