diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 96e9cf002..18c15afc1 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2608,6 +2608,7 @@ "src/data/distanceFormat.js", "src/ui/placeNames.js", "src/cyberSonarScene.js", + "src/cyberSonarEngine.js", "src/cyberSonar.js", "src/hudLayouts.js", "src/hudLayoutPolicy.js", @@ -3600,6 +3601,7 @@ "src/globe/navara/bloom.js", "src/globe/navara/bloomSettings.js", "src/globe/navara/cockpitClouds.js", + "src/globe/navara/sonar.js", "src/globe/navara/surface.js", "src/globe/navara/viewOptions.js", "src/app/engineChoice.js", diff --git a/scripts/qa-navara-sonar.mjs b/scripts/qa-navara-sonar.mjs new file mode 100644 index 000000000..a4cea62ca --- /dev/null +++ b/scripts/qa-navara-sonar.mjs @@ -0,0 +1,290 @@ +// The Cyber HUD sonar on Navara (Stage 6 C2): with the Cyber HUD on, the +// sweep lights the contacts it passes and dims the rest to the opacity +// floor, by each contact's place on screen exactly as the CPU sampler of +// src/cyberSonar.js rates it; the sweep turns; with the sweep off every +// contact takes the neutral factor; a parked scene draws at the fleet +// cadence only while the sweep runs over contacts; leaving the Cyber HUD +// gives the contacts back unchanged. On Cesium the command adapter keeps the +// treatment and the engine draws none. The contacts are an engine point set +// (engine.movers) in a ring around the screen centre. Keyless preview under +// the strict default-deny network policy. +// +// node scripts/qa-navara-sonar.mjs http://127.0.0.1:/ [--out=DIR] + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + createStrict, + localPreview, + waitForEngine, +} from './qa-strict-harness.mjs'; +import { checker, freeze, visualPageUrl } from './qa/navaraVisual.mjs'; +import { sonarFactorAt } from '../src/globe/navara/sonar.js'; + +const base = localPreview(process.argv[2]); +const outDir = process.argv + .find((arg) => arg.startsWith('--out=')) + ?.slice('--out='.length); +const strict = createStrict({ origin: base.origin, layers: [] }); +const report = { checks: [] }; +const check = checker(report, assert); +const WIDTH = 1280; +const HEIGHT = 800; + +// 32 contacts on a ring at half the keyhole radius (11.25° apart: one is +// always well inside the 24° sector), and one outside the keyhole. +function contactPlaces() { + const radius = Math.min(WIDTH * 0.4, HEIGHT * 0.52) * 0.5; + const places = []; + for (let i = 0; i < 32; i++) { + const angle = (i / 32) * Math.PI * 2; + places.push({ + x: Math.round(WIDTH / 2 + Math.sin(angle) * radius), + y: Math.round(HEIGHT / 2 - Math.cos(angle) * radius), + }); + } + places.push({ x: 120, y: 110 }); + return places; +} + +// The luma at each contact (a 3×3 mean) of the next drawn frame, and the +// sonar state that frame drew with. +const read = (page, places) => + page.evaluate(async (places) => { + const { engine } = window.__godsEyeView; + const canvas = document.createElement('canvas'); + const source = engine.host.canvas; + canvas.width = source.width; + canvas.height = source.height; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + let sonar = null; + await new Promise((resolve) => { + const off = engine.frames.postRender.addEventListener(() => { + off(); + ctx.drawImage(source, 0, 0); + sonar = engine.render.contactSonar?.() ?? null; + resolve(); + }); + engine.render.request('qa-sonar'); + }); + const scale = source.width / engine.host.size().width; + const luma = places.map(({ x, y }) => { + const data = ctx.getImageData( + Math.round(x * scale) - 1, + Math.round(y * scale) - 1, + 3, + 3, + ).data; + let sum = 0; + for (let i = 0; i < data.length; i += 4) + sum += 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; + return sum / 9; + }); + return { luma, sonar }; + }, places); + +// Frames the engine draws in `ms` without being asked. +const framesIn = (page, ms) => + page.evaluate(async (ms) => { + const { engine } = window.__godsEyeView; + let frames = 0; + const off = engine.frames.postRender.addEventListener(() => frames++); + await new Promise((resolve) => setTimeout(resolve, ms)); + off(); + return frames; + }, ms); + +const mean = (values) => + values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length); + +await strict.run( + { args: ['--use-angle=metal', '--enable-gpu'] }, + async (browser) => { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.evaluateOnNewDocument(() => { + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + }); + await page.setViewport({ width: WIDTH, height: HEIGHT }); + await page.goto(visualPageUrl(base.origin, 'navara'), { + waitUntil: 'domcontentloaded', + }); + await waitForEngine(page, 'navara'); + await freeze(page); + + const places = contactPlaces(); + const placed = await page.evaluate((places) => { + const { engine } = window.__godsEyeView; + const set = engine.movers.createSet({ + id: 'qa-sonar-contacts', + kind: 'point', + capacity: places.length, + depthTest: 'always', + point: { sizePx: 14, outlinePx: 0 }, + }); + window.__qaSonarSet = set; + let missing = 0; + places.forEach((place, index) => { + const position = engine.camera.pickEllipsoid(place); + if (!position) missing++; + set.set(index, { position, color: [1, 1, 1, 1] }); + }); + set.setCount(places.length); + set.commit(); + return { missing, sonar: engine.render.contactSonar?.() ?? null }; + }, places); + check( + 'Navara treats contacts itself; the sonar starts off', + placed.missing === 0 && placed.sonar?.enabled === false, + placed, + ); + const baseline = await read(page, places); + + await page.evaluate(() => { + const { styleManager } = window.__godsEyeView; + styleManager._setHudVariant('cyber'); + // Normal keeps the contacts' own colours; the HUD stays on (in Normal + // it would hide itself, and the sweep with it). + styleManager.setStyle('normal'); + styleManager.hud.setMode('on'); + styleManager._setCyberSonarEnabled(true); + styleManager._setCyberSonarSetting('opacity', 35); + }); + await page.waitForFunction( + () => window.__godsEyeView.styleManager.transitions.size === 0, + ); + const theme = await page.evaluate(() => ({ + theme: document.documentElement.dataset.uiTheme, + gpu: document.documentElement.dataset.cyberSonarGpu, + hud: document.getElementById('intel-hud')?.classList.contains('active'), + })); + check( + 'the Cyber HUD runs with the contact adapter', + theme.theme === 'cyber' && theme.gpu === 'supported' && theme.hud, + theme, + ); + + const sweeps = []; + for (const wait of [0, 1300, 1300]) { + await new Promise((resolve) => setTimeout(resolve, wait)); + const { luma, sonar } = await read(page, places); + const expected = places.map(({ x, y }) => + sonarFactorAt(x, y, WIDTH, HEIGHT, sonar), + ); + const lit = luma.filter((_, i) => expected[i] > 0.9); + const dim = luma.filter((_, i) => expected[i] <= sonar.opacity / 100); + sweeps.push({ + spread: Math.max(...luma.slice(0, 32)) - Math.min(...luma.slice(0, 32)), + angle: sonar.angleDeg, + lit: lit.length, + litLuma: mean(lit), + dimLuma: mean(dim), + brightest: luma.indexOf(Math.max(...luma)), + expectedBrightest: expected.indexOf(Math.max(...expected)), + sonar, + }); + check( + `the sweep at ${Math.round(sonar.angleDeg)}° lights the contacts it passes, the rest sit at the floor`, + sonar.enabled && + sonar.active && + sonar.opacity === 35 && + lit.length >= 1 && + dim.length >= 8 && + mean(lit) > mean(dim) + 20 && + expected[places.length - 1] === 0.35, + sweeps.at(-1), + ); + } + check( + 'the sweep turns: another contact is lit', + new Set(sweeps.map((s) => s.expectedBrightest)).size > 1 && + new Set(sweeps.map((s) => s.brightest)).size > 1, + sweeps.map(({ angle, brightest, expectedBrightest }) => ({ + angle, + brightest, + expectedBrightest, + })), + ); + const cadence = await framesIn(page, 1500); + check( + 'a parked scene draws at the fleet cadence while the sweep runs over contacts', + cadence >= 8, + { frames: cadence }, + ); + + await page.evaluate(() => + window.__godsEyeView.styleManager._setCyberSonarEnabled(false), + ); + const neutral = await read(page, places); + const spread = + Math.max(...neutral.luma.slice(0, 32)) - + Math.min(...neutral.luma.slice(0, 32)); + await new Promise((resolve) => setTimeout(resolve, 300)); + const idle = await framesIn(page, 1500); + check( + 'with the sweep off every contact takes the neutral factor and the scene rests', + neutral.sonar.enabled && + !neutral.sonar.active && + // What still differs is the map under the translucent contacts. + spread < Math.min(...sweeps.map((sweep) => sweep.spread)) * 0.6 && + idle <= 3, + { spread, sweeping: sweeps.map((sweep) => sweep.spread), frames: idle }, + ); + + await page.evaluate(() => { + const { styleManager } = window.__godsEyeView; + styleManager._setCyberSonarEnabled(true); + styleManager._setHudVariant('tactical'); + styleManager.setStyle('normal'); + styleManager.hud.setMode('auto'); + }); + await page.waitForFunction( + () => window.__godsEyeView.styleManager.transitions.size === 0, + ); + const after = await read(page, places); + const drift = Math.max( + ...after.luma.map((value, i) => Math.abs(value - baseline.luma[i])), + ); + check( + 'leaving the Cyber HUD gives the contacts back unchanged', + after.sonar.enabled === false && drift < 3, + { drift }, + ); + await page.evaluate(() => window.__qaSonarSet.destroy()); + + // Cesium keeps its command adapter; its engine draws no sonar. + await strict.switchTo(page, 'cesium'); + await page.evaluate(() => { + const { styleManager } = window.__godsEyeView; + styleManager._setHudVariant('cyber'); + }); + await page.waitForFunction( + () => document.documentElement.dataset.cyberSonarGpu, + { timeout: 10000 }, + ); + const cesium = await page.evaluate(() => ({ + setContactSonar: + typeof window.__godsEyeView.engine.render.setContactSonar, + gpu: document.documentElement.dataset.cyberSonarGpu, + })); + check( + 'Cesium: the command adapter treats contacts, the engine none', + cesium.setContactSonar === 'undefined' && cesium.gpu === 'supported', + cesium, + ); + await page.evaluate(() => + window.__godsEyeView.styleManager._setHudVariant('tactical'), + ); + + check('no page errors', errors.length === 0, errors); + if (outDir) { + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync( + path.join(outDir, 'sonar.json'), + JSON.stringify(report, null, 2), + ); + } + }, +); diff --git a/src/app/data.js b/src/app/data.js index 32b80b891..9be26fca8 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -1,6 +1,7 @@ import { LayerLifecycle } from '../data/lifecycle.js'; import { LayerPresentation } from './layerPresentation.js'; import { createCyberSonarScene } from '../cyberSonarScene.js'; +import { createCyberSonarEngineScene } from '../cyberSonarEngine.js'; import { panelAwareVisibility } from './embed.js'; import { attachScenePart, currentSceneOf } from './sceneParts.js'; @@ -104,9 +105,11 @@ export function createApplicationData({ nextCatalog.weatherClock, ); styleManager.attachDataManager(dataManager); - // The sonar scene draws on the viewer; the Navara engine has none. + // The sonar treats contacts on the viewer, or through an engine + // that draws it (render.setContactSonar, Navara). if (next.viewer) release(createCyberSonarScene(next.viewer, dataManager)); + else release(createCyberSonarEngineScene(next.engine, dataManager)); if (!binding) return; // A teardown or switch during the attachment must not wait for slow // layer attachments: it ends this wait, and the detach releases the diff --git a/src/cyberSonarEngine.js b/src/cyberSonarEngine.js new file mode 100644 index 000000000..f2d1b3c1a --- /dev/null +++ b/src/cyberSonarEngine.js @@ -0,0 +1,94 @@ +// The Cyber HUD sonar's contact treatment on a globe engine that draws it +// itself (render.setContactSonar, the `contactSonar` capability: Navara's +// sprite and point movers), the counterpart of src/cyberSonarScene.js on a +// Cesium viewer. Each drawn frame hands the engine the theme's state, the +// sweep's settings and its angle on the shared clock (the CSS sweep's), so +// the contacts and the rings turn together; while the sweep runs over +// visible contacts a parked scene draws at the fleet cadence, as on Cesium. + +import { engineCapabilities } from './globe/contract.js'; +import { + CYBER_SONAR_RENDER_INTERVAL_MS, + cyberSonarAngleDeg, + isCyberContactSonarActive, + isCyberContactThemeActive, + isCyberSonarActive, + readCyberSonarSettings, + syncCyberSonarAnimation, +} from './cyberSonar.js'; + +/** The engine's sonar state for this moment (render.setContactSonar). */ +export function cyberSonarEngineState( + nowMs, + root = globalThis.document?.documentElement, +) { + const settings = readCyberSonarSettings(root); + return { + enabled: isCyberContactThemeActive({ root }), + active: isCyberContactSonarActive({ root }), + angleDeg: cyberSonarAngleDeg(nowMs), + range: settings.range, + opacity: settings.opacity, + sector: settings.sector, + }; +} + +/** Sprite and point instances the engine draws (models keep their colours). */ +const contactCount = (engine) => + engine.movers + .list() + .filter((set) => set.kind !== 'model') + .reduce((count, set) => count + set.count, 0); + +/** + * @param {import('./globe/contract.js').GlobeEngine} engine + * @param {{ subscribe?: Function }} [manager] The layer manager: a + * visibility change draws a frame. + * @returns {() => void} Removes the treatment. + */ +export function createCyberSonarEngineScene( + engine, + manager, + { + now = () => performance.now(), + root = globalThis.document?.documentElement, + setTimer = (callback, ms) => setTimeout(callback, ms), + clearTimer = (id) => clearTimeout(id), + } = {}, +) { + if (!engineCapabilities(engine).contactSonar) return () => {}; + let timer = null; + let disposed = false; + // The Cyber contact theme counts on the contact adapter (cyberSonar.js + // isCyberContactThemeActive): this engine has one. + if (root?.dataset) root.dataset.cyberSonarGpu = 'supported'; + const removeRender = engine.frames.preRender.addEventListener(() => { + if (isCyberSonarActive({ root })) syncCyberSonarAnimation(root); + engine.render.setContactSonar(cyberSonarEngineState(now(), root)); + }); + const removePostRender = engine.frames.postRender.addEventListener(() => { + if (timer !== null) clearTimer(timer); + timer = null; + if (isCyberContactSonarActive({ root }) && contactCount(engine)) + timer = setTimer(() => { + timer = null; + if (!disposed && isCyberContactSonarActive({ root })) + engine.render.request('cyber-sonar'); + }, CYBER_SONAR_RENDER_INTERVAL_MS); + }); + const unsubscribe = manager?.subscribe?.((change) => { + if (change.type === 'visibility') engine.render.request('cyber-sonar'); + }); + return () => { + if (disposed) return; + disposed = true; + removeRender(); + removePostRender(); + unsubscribe?.(); + if (timer !== null) clearTimer(timer); + timer = null; + if (root?.dataset) delete root.dataset.cyberSonarGpu; + if (!engine.isDestroyed?.()) + engine.render.setContactSonar({ enabled: false, active: false }); + }; +} diff --git a/src/cyberSonarEngine.test.mjs b/src/cyberSonarEngine.test.mjs new file mode 100644 index 000000000..c991728a7 --- /dev/null +++ b/src/cyberSonarEngine.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createFakeEngine } from './globe/testing/fakeEngine.js'; +import { CYBER_SONAR_RENDER_INTERVAL_MS } from './cyberSonar.js'; +import { + createCyberSonarEngineScene, + cyberSonarEngineState, +} from './cyberSonarEngine.js'; + +// The DOM the Cyber theme reads: the root's dataset, the body's cockpit +// class, the HUD's visibility and the reduced-motion preference. +function withCyberPage(run) { + const names = ['document', 'matchMedia']; + const saved = Object.fromEntries(names.map((n) => [n, globalThis[n]])); + const root = { + dataset: { uiTheme: 'cyber', cyberSonarOpacity: '40' }, + style: { + setProperty() {}, + removeProperty() {}, + getPropertyValue: () => '', + }, + querySelector: () => null, + }; + const hud = { active: true }; + globalThis.document = { + documentElement: root, + body: { classList: { contains: () => false } }, + getElementById: (id) => + id === 'intel-hud' + ? { classList: { contains: (name) => name === 'active' && hud.active } } + : null, + }; + globalThis.matchMedia = () => ({ matches: false }); + return Promise.resolve(run({ root, hud })).finally(() => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete globalThis[name]; + else globalThis[name] = value; + } + }); +} + +test('without the contactSonar capability the engine scene does nothing', () => + withCyberPage(({ root }) => { + const dispose = createCyberSonarEngineScene(createFakeEngine(), null, { + root, + }); + assert.equal(root.dataset.cyberSonarGpu, undefined); + dispose(); + })); + +test('each drawn frame hands the engine the theme, the settings and the sweep angle', () => + withCyberPage(async ({ root, hud }) => { + const engine = createFakeEngine({ kind: 'navara', contactSonar: true }); + let now = 1300; + const timers = new Map(); + let nextTimer = 1; + const dispose = createCyberSonarEngineScene(engine, null, { + root, + now: () => now, + setTimer: (callback, ms) => { + timers.set(nextTimer, [callback, ms]); + return nextTimer++; + }, + clearTimer: (id) => timers.delete(id), + }); + assert.equal(root.dataset.cyberSonarGpu, 'supported'); + engine.fake.frame(); + assert.deepEqual(engine.render.contactSonar(), { + enabled: true, + active: true, + angleDeg: 90, + range: 100, + opacity: 40, + sector: 24, + }); + assert.deepEqual( + engine.render.contactSonar(), + cyberSonarEngineState(now, root), + ); + // No contacts: no render demand of its own. + assert.equal(timers.size, 0); + const set = engine.movers.createSet({ + id: 'qa-contacts', + kind: 'point', + capacity: 2, + point: { sizePx: 8 }, + }); + set.set(0, { position: { x: 6378137, y: 0, z: 0 } }); + set.setCount(1); + set.commit(); + now = 2600; + engine.fake.frame(); + assert.equal(engine.render.contactSonar().angleDeg, 180); + assert.equal(timers.size, 1); + const [[callback, ms]] = timers.values(); + assert.equal(ms, CYBER_SONAR_RENDER_INTERVAL_MS); + const before = engine.fake.renderRequests; + callback(); + assert.equal(engine.fake.renderRequests, before + 1); + // The HUD hidden: the treatment ends on the next frame. + hud.active = false; + engine.fake.frame(); + assert.equal(engine.render.contactSonar().enabled, false); + dispose(); + assert.equal(root.dataset.cyberSonarGpu, undefined); + assert.equal(engine.render.contactSonar().active, false); + const calls = engine.fake.calls.length; + engine.fake.frame(); + assert.equal( + engine.fake.calls + .slice(calls) + .filter(({ method }) => method === 'render.setContactSonar').length, + 0, + 'nothing after dispose', + ); + })); diff --git a/src/globe/contract.js b/src/globe/contract.js index 6e628183e..9ae798da5 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -269,6 +269,27 @@ * mode the cockpit draws its own cloud pass. * @property {() => { coverage: number | null }} [cloudCover] The cover last * set (a copy). + * @property {(options: Partial) => void} [setContactSonar] + * Optional, with contactSonar (capability `contactSonar`): the Cyber HUD + * sonar's treatment of contacts (sprite and point movers): while + * `enabled`, each contact's colour moves toward the scanner tint and its + * alpha scales by its factor, from its anchor's place on screen against the + * sweep (src/cyberSonar.js createCyberSonarSampler maths); with the sweep + * off (`active` false) every contact takes the neutral factor. Positions + * and picks are unchanged. Omitted fields keep their value; a bad value + * throws a TypeError. Takes effect on the next drawn frame (a call outside + * a frame requests one). Off until set. Cesium has none: in Cesium mode + * src/cyberSonarScene.js adapts Cesium's own draw commands. + * @property {() => ContactSonar} [contactSonar] The sonar last set (a copy). + * + * @typedef {object} ContactSonar + * @property {boolean} enabled The Cyber contact treatment applies. + * @property {boolean} active The sweep runs (else the neutral factor). + * @property {number} angleDeg The sweep's heading, clockwise from screen up. + * @property {number} range The keyhole radius in percent (100: the smaller + * of 40 % of the width and 52 % of the height). + * @property {number} opacity The factor outside the sweep, percent (0..100). + * @property {number} sector The sector's width in degrees (0..360]. * * @typedef {{ intensity: number, uniforms: Record }} StyleState * @@ -695,6 +716,10 @@ const OPTIONAL_SHAPE = { 'render.setCloudCover': isFunction, 'render.cloudCover': isFunction, }, + contactSonar: { + 'render.setContactSonar': isFunction, + 'render.contactSonar': isFunction, + }, }; /** The optional capability flags, e.g. 'visualStyles'. */ @@ -876,3 +901,43 @@ export function mergeCloudCover(current, { coverage } = {}) { ); return { coverage: coverage === undefined ? current.coverage : coverage }; } + +/** The contact sonar an engine starts with (GlobeRender.contactSonar): off. */ +export const CONTACT_SONAR_INITIAL = Object.freeze({ + enabled: false, + active: false, + angleDeg: 0, + range: 100, + opacity: 84, + sector: 24, +}); + +/** + * GlobeRender.setContactSonar's argument merged onto `current`: a new + * state, or a TypeError for a non-boolean flag, a non-finite angle, a range + * that is not positive, an opacity outside 0..100 or a sector outside + * (0, 360]. + */ +export function mergeContactSonar(current, options = {}) { + const next = { ...current }; + for (const key of ['enabled', 'active']) { + if (options[key] === undefined) continue; + if (!isBoolean(options[key])) + throw new TypeError(`Sonar ${key} must be a boolean: ${options[key]}`); + next[key] = options[key]; + } + const numbers = { + angleDeg: () => true, + range: (value) => value > 0, + opacity: (value) => value >= 0 && value <= 100, + sector: (value) => value > 0 && value <= 360, + }; + for (const [key, valid] of Object.entries(numbers)) { + const value = options[key]; + if (value === undefined) continue; + if (!isNumber(value) || !valid(value)) + throw new TypeError(`Sonar ${key} is out of range: ${value}`); + next[key] = value; + } + return next; +} diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index 69bfe9300..b0dba175f 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -7,6 +7,7 @@ import { MODIFIERS, BLOOM_INITIAL, CLOUD_COVER_INITIAL, + CONTACT_SONAR_INITIAL, ENGINE_CAPABILITIES, OPTIONAL_ENGINE_MEMBERS, SHARPEN_INITIAL, @@ -16,6 +17,7 @@ import { isEngineEvent, mergeBloom, mergeCloudCover, + mergeContactSonar, mergeSharpen, mergeStyle, } from './contract.js'; @@ -502,3 +504,34 @@ test('the cloudCover capability: whole or absent, null for the engine own', asyn assert.equal(checks.length, 1); for (const check of checks) await t.test(check.name, () => check.run()); }); + +test('the contactSonar capability: whole or absent, merged and checked', async (t) => { + assert.deepEqual(ENGINE_CAPABILITIES, [ + 'visualStyles', + 'bloom', + 'cloudCover', + 'contactSonar', + ]); + const engine = createFakeEngine({ contactSonar: true }); + assert.equal(engineCapabilities(engine).contactSonar, true); + assert.deepEqual(engine.render.contactSonar(), CONTACT_SONAR_INITIAL); + const half = { + ...engine, + render: { ...engine.render, contactSonar: undefined }, + }; + assert.throws(() => assertGlobeEngine(half), /render\.contactSonar\b/); + assert.deepEqual( + mergeContactSonar(CONTACT_SONAR_INITIAL, { enabled: true, angleDeg: 400 }), + { ...CONTACT_SONAR_INITIAL, enabled: true, angleDeg: 400 }, + ); + for (const bad of [{ opacity: -1 }, { range: -5 }, { active: 'no' }]) + assert.throws( + () => mergeContactSonar(CONTACT_SONAR_INITIAL, bad), + TypeError, + ); + const checks = createContractSuite(engine, { + waitFrames: async (count) => engine.fake.frames(count), + }).filter(({ name }) => /\(contactSonar\)/.test(name)); + assert.equal(checks.length, 1); + for (const check of checks) await t.test(check.name, () => check.run()); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 773fe7fbb..4c33ef059 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -10,11 +10,13 @@ import * as geo from '../../geo/index.js'; import { BLOOM_INITIAL, CLOUD_COVER_INITIAL, + CONTACT_SONAR_INITIAL, SHARPEN_INITIAL, STYLE_INITIAL, createEngineEvent, mergeBloom, mergeCloudCover, + mergeContactSonar, mergeSharpen, mergeStyle, } from '../contract.js'; @@ -96,6 +98,7 @@ function rayRaisedEllipsoid({ origin, direction }, height) { * sharpen?: { set: (state: { enabled: boolean, amount: number }) => void, destroy: () => void }, * bloom?: { set: (state: { enabled: boolean, strength: number }) => void, destroy: () => void }, * clouds?: { set: (state: { coverage: number | null }) => void, destroy: () => void }, + * sonar?: { set: (state: import('../contract.js').ContactSonar) => void, destroy: () => void }, * styles?: { names: readonly string[], uniformNames: (name: string) => string[], defaults: (name: string) => Record, set: (name: string, state: { intensity: number, uniforms: Record }) => void, destroy: () => void }, * }} options `hooks` are internals.js; `container` defaults to the * canvas's parent; `sharpen` draws render.setSharpen (sharpen.js; without @@ -105,7 +108,9 @@ function rayRaisedEllipsoid({ origin, direction }, height) { * draws render.setBloom (bloom.js; without it no bloom capability), also * destroyed with the engine; `clouds` draws render.setCloudCover * (cockpitClouds.js; without it, as on a look without clouds, no - * cloudCover capability), destroyed with the engine; + * cloudCover capability), destroyed with the engine; `sonar` draws + * render.setContactSonar (sonar.js; without it no contactSonar + * capability), destroyed with the engine; * `appClock` (the engine clock's time) defaults to the page's app clock; * `movers` is engine.movers on the view's three runtime (movers/index.js; * without it createSet throws), `lines` engine.lines (lines/index.js; @@ -127,6 +132,7 @@ export function createNavaraEngine( styles: stylesPort = null, bloom: bloomPort = null, clouds: cloudsPort = null, + sonar: sonarPort = null, } = {}, ) { if (!view?.camera?.raw) @@ -185,12 +191,16 @@ export function createNavaraEngine( }); // Defined below, with the surface and input it needs. let groundFloor = null; + // Between the view's preRender and postRender: a change made then shows + // in the frame being drawn and asks for no other. + let drawing = false; const handlers = { preUpdate: (time) => { engineClock.beginFrame(); frames.preUpdate.raiseEvent(time); }, preRender: (time) => { + drawing = true; model.sync(); // After the user's camera is read, before anything draws. followStep.raiseEvent(); @@ -198,6 +208,7 @@ export function createNavaraEngine( frames.preRender.raiseEvent(time); }, postRender: (time) => { + drawing = false; model.afterFrame(); frames.postRender.raiseEvent(time); }, @@ -351,6 +362,22 @@ export function createNavaraEngine( cloudCover: () => ({ ...cloudCover }), } : {}; + let contactSonar = { ...CONTACT_SONAR_INITIAL }; + const sonarRender = sonarPort + ? { + setContactSonar(options) { + const next = mergeContactSonar(contactSonar, options); + const changed = Object.keys(next).some( + (key) => next[key] !== contactSonar[key], + ); + contactSonar = next; + if (!changed || destroyed) return; + sonarPort.set({ ...next }); + if (!drawing) view.forceUpdate(); + }, + contactSonar: () => ({ ...contactSonar }), + } + : {}; return { kind: 'navara', host: { container: container ?? canvas.parentElement, canvas, size }, @@ -382,6 +409,7 @@ export function createNavaraEngine( ...styleRender, ...bloomRender, ...cloudRender, + ...sonarRender, }, surface, input: input.input, @@ -405,6 +433,7 @@ export function createNavaraEngine( stylesPort?.destroy(); bloomPort?.destroy(); cloudsPort?.destroy(); + sonarPort?.destroy(); moversPort?.destroy(); linesPort?.destroy(); }, diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index fb4ef4644..7de9802bb 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -314,11 +314,46 @@ test('setCloudCover hands changed covers to the clouds port and draws a frame', assert.equal(destroyed, 1); }); +test('setContactSonar hands changes to the sonar port, asking a frame only outside one', () => { + const view = fakeView(); + const shown = []; + let destroyed = 0; + const engine = createNavaraEngine(view, { + hooks, + sonar: { set: (state) => shown.push(state), destroy: () => destroyed++ }, + }); + assertGlobeEngine(engine); + assert.equal(engineCapabilities(engine).contactSonar, true); + assert.equal(engine.render.contactSonar().enabled, false); + engine.render.setContactSonar({ enabled: true, active: true }); + assert.deepEqual(view.calls.at(-1), ['forceUpdate']); + assert.equal(shown.length, 1); + // Inside a drawn frame the change shows in that frame: no other frame. + engine.frames.preRender.addEventListener(() => + engine.render.setContactSonar({ angleDeg: 45 }), + ); + const calls = view.calls.length; + view.emit('preRender', 1); + view.emit('postRender', 2); + assert.equal(shown.at(-1).angleDeg, 45); + assert.equal( + view.calls.slice(calls).filter(([name]) => name === 'forceUpdate').length, + 0, + ); + assert.throws( + () => engine.render.setContactSonar({ opacity: 120 }), + TypeError, + ); + engine.destroy(); + assert.equal(destroyed, 1); +}); + test('without a styles port the engine has no visualStyles capability', () => { const { engine } = setup(); assert.equal(engineCapabilities(engine).visualStyles, false); assert.equal(engineCapabilities(engine).bloom, false); assert.equal(engineCapabilities(engine).cloudCover, false); + assert.equal(engineCapabilities(engine).contactSonar, false); assert.equal(engine.render.setStyle, undefined); assert.equal(engine.render.setBloom, undefined); }); diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 4e4926cc9..54894c2a2 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -25,6 +25,7 @@ import { createNavaraSharpen } from './sharpen.js'; import { createNavaraStyles } from './stylePasses.js'; import { createNavaraBloom } from './bloom.js'; import { createNavaraCloudCover } from './cockpitClouds.js'; +import { createNavaraSonar } from './sonar.js'; import { retroShader } from '../../styles/retro.js'; import { nightVisionShader } from '../../styles/surveillance.js'; import { thermalShader } from '../../styles/thermal.js'; @@ -161,6 +162,7 @@ export async function createNavaraGlobe({ sharpen: createNavaraSharpen(view), bloom: createNavaraBloom(view), clouds: createNavaraCloudCover(look), + sonar: createNavaraSonar(view), styles: createNavaraStyles(view, { shaders: STYLE_SHADERS, onStarsDimmed: (amount) => look.setStarsDimmed(amount), diff --git a/src/globe/navara/movers/moverMesh.js b/src/globe/navara/movers/moverMesh.js index b6b0b4f92..02e75c505 100644 --- a/src/globe/navara/movers/moverMesh.js +++ b/src/globe/navara/movers/moverMesh.js @@ -30,6 +30,7 @@ import { OVERLAY_DEPTH_MODES, OVERLAY_DEPTH_VERTEX, } from '../overlayDepth.js'; +import { DETACHED_SONAR, SONAR_VERTEX } from '../sonar.js'; const VERTEX_HEAD = /* glsl */ ` attribute vec4 gevStyle; @@ -48,7 +49,10 @@ varying vec2 vGevLocal; varying float vGevOutline; varying float vGevPixelDistance; varying float vGevTranslucency; +varying float vGevSonarFactor; +uniform vec2 uGevResolution; ${OVERLAY_DEPTH_VERTEX} +${SONAR_VERTEX} `; const VERTEX_PROJECT = /* glsl */ ` @@ -96,7 +100,10 @@ vec2 gevTurned = vec2( vec4 mvPosition = mvCenter; mvPosition.xy += gevTurned * gevSizePx * uGevFootprint * max(-mvCenter.z, 0.0); gl_Position = projectionMatrix * mvPosition; -vGevColor = gevColor; +// The Cyber sonar (sonar.js): by the anchor's place on screen, never the +// corner's, so a contact is treated whole; the position stays as it is. +vGevSonarFactor = gevSonarFactor(projectionMatrix * mvCenter, uGevResolution); +vGevColor = gevSonarColor(gevColor, vGevSonarFactor); vGevLocal = gevCorner; #ifndef GEV_ALWAYS_VISIBLE vGevDepthFree = gevDistance < uGevDisableWithinM ? 1.0 : 0.0; @@ -117,6 +124,7 @@ varying vec2 vGevLocal; varying float vGevOutline; varying float vGevPixelDistance; varying float vGevTranslucency; +varying float vGevSonarFactor; ${OVERLAY_DEPTH_FRAGMENT} `; @@ -135,7 +143,8 @@ float gevDistanceToCenter = gevRadius * 0.5; float gevWholeAlpha = 1.0 - smoothstep(gevMaxDistance, 0.5, gevDistanceToCenter); float gevInner = 1.0 - vGevOutline; float gevInnerAlpha = 1.0 - smoothstep(gevMaxDistance * gevInner, 0.5 * gevInner, gevDistanceToCenter); -diffuseColor *= sRGBTransferEOTF(mix(uGevOutlineColor, vGevColor, gevInnerAlpha)); +vec4 gevOutline = vec4(uGevOutlineColor.rgb, uGevOutlineColor.a * vGevSonarFactor); +diffuseColor *= sRGBTransferEOTF(mix(gevOutline, vGevColor, gevInnerAlpha)); diffuseColor.a *= gevWholeAlpha * vGevTranslucency; if (diffuseColor.a < 0.005) discard; ` @@ -290,6 +299,12 @@ export function createMoverMesh( uGevResolution: overlayUniforms.uGevResolution, uGevDisplayBlend: overlayUniforms.uGevDisplayBlend ?? DETACHED_OVERLAY.uGevDisplayBlend, + // Shared by every overlay of the view: the Cyber sonar (sonar.js). + uGevSonar: overlayUniforms.uGevSonar ?? DETACHED_SONAR.uGevSonar, + uGevSonarSector: + overlayUniforms.uGevSonarSector ?? DETACHED_SONAR.uGevSonarSector, + uGevSonarEnabled: + overlayUniforms.uGevSonarEnabled ?? DETACHED_SONAR.uGevSonarEnabled, }; // A canvas atlas is there at once; a URL one once its image loads. let textureReady = point || Boolean(options.sprite.atlas.canvas); diff --git a/src/globe/navara/overlay.js b/src/globe/navara/overlay.js index 5b0281691..3f8d7da1b 100644 --- a/src/globe/navara/overlay.js +++ b/src/globe/navara/overlay.js @@ -15,6 +15,7 @@ import { withFrameUpdates } from './frameDescriptor.js'; import { EffectDesc, Pass as NavaraPass } from '@navaramap/three'; import { GevOverlayPass } from './overlayPass.js'; +import { sonarUniformsFor } from './sonar.js'; import { RGBADepthPacking, Scene } from 'three'; export const OVERLAY_EFFECT_KEY = 'gevOverlay'; @@ -110,6 +111,8 @@ export function createNavaraOverlay(view, { beforeSharpen = false } = {}) { uGevDisplayBlend: { value: 0 }, tGevSceneDepth: { value: null }, uGevResolution: { value: [0, 0] }, + // The Cyber sonar's, shared by the view's overlays (sonar.js). + ...sonarUniformsFor(view), }, attach(context) { ctx = context; diff --git a/src/globe/navara/sonar.js b/src/globe/navara/sonar.js new file mode 100644 index 000000000..1054a257a --- /dev/null +++ b/src/globe/navara/sonar.js @@ -0,0 +1,129 @@ +// The Cyber HUD's contact sonar on Navara (render.setContactSonar): the +// sweep's treatment of contacts, drawn by the sprite and point movers' +// shaders (movers/moverMesh.js) from uniforms every overlay of a view +// shares. The rings and the sweep itself are the HUD's own (CSS, +// src/cyberSonar.js); this is the contact side, the counterpart of the +// Cesium command adapter (src/cyberSonarGpu.js), with the same maths: each +// contact's factor from its anchor's place on screen against the sweep (the +// sampler of src/cyberSonar.js createCyberSonarSampler), its colour pulled +// toward the scanner tint by that factor and its alpha scaled by it. +// Positions, picks and the instance data stay as the layers set them. +// Models keep their colours (Cesium tints them on the CPU). + +const views = new WeakMap(); + +/** + * The sonar uniforms of a view, one object for all its overlays (overlay.js + * spreads them into each overlay's uniforms): + * - uGevSonar: angle (deg, clockwise from screen up), range (fraction of + * the keyhole radius min(0.4 width, 0.52 height)), opacity floor (0..1), + * sweep on (1) or off (0); + * - uGevSonarSector: the sector's width (deg); + * - uGevSonarEnabled: 1 while the Cyber contact treatment applies. + */ +export function sonarUniformsFor(view) { + let uniforms = views.get(view); + if (!uniforms) { + uniforms = { + uGevSonar: { value: [0, 1, 0.84, 0] }, + uGevSonarSector: { value: 24 }, + uGevSonarEnabled: { value: 0 }, + }; + views.set(view, uniforms); + } + return uniforms; +} + +/** Sonar uniforms of a mesh drawn outside an overlay (tests): off. */ +export const DETACHED_SONAR = Object.freeze({ + uGevSonar: { value: [0, 1, 0.84, 0] }, + uGevSonarSector: { value: 24 }, + uGevSonarEnabled: { value: 0 }, +}); + +/** + * Vertex declarations and gevSonarFactor(anchor, resolution): the factor of + * a contact whose anchor is at clip position `anchor`, in a target of + * `resolution` pixels; 1 when the treatment is off. + */ +export const SONAR_VERTEX = /* glsl */ ` +uniform vec4 uGevSonar; +uniform float uGevSonarSector; +uniform float uGevSonarEnabled; +float gevSonarFactor(vec4 anchor, vec2 resolution) { + if (uGevSonarEnabled < 0.5) return 1.0; + float floorValue = uGevSonar.z; + if (uGevSonar.w < 0.5) return floorValue + (1.0 - floorValue) * 0.46; + // Invalid projections fail open, as the CPU sampler does. + if (anchor.w <= 0.0 || resolution.x < 1.0 || resolution.y < 1.0) return 1.0; + vec2 pixel = anchor.xy / anchor.w * resolution * 0.5; + float radius = min(resolution.x * 0.4, resolution.y * 0.52) * uGevSonar.y; + float distanceSquared = dot(pixel, pixel); + float radiusSquared = radius * radius; + if (distanceSquared > radiusSquared) return floorValue; + if (distanceSquared < radiusSquared * 0.000625) return 1.0; + float delta = mod(degrees(atan(pixel.x, pixel.y)) - uGevSonar.x + 360.0, 360.0); + if (delta > 359.9999) delta = 0.0; + if (delta > uGevSonarSector) return floorValue; + float feather = min(10.0, uGevSonarSector * 0.42); + return floorValue + (1.0 - floorValue) * min(1.0, (uGevSonarSector - delta) / feather); +} +vec4 gevSonarColor(vec4 color, float factor) { + if (uGevSonarEnabled < 0.5) return color; + float floorValue = uGevSonar.z; + float coverage = floorValue >= 0.9999 ? 1.0 : clamp((factor - floorValue) / (1.0 - floorValue), 0.0, 1.0); + vec3 tint = color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72; + return vec4(tint, color.a * factor); +} +`; + +/** + * gevSonarFactor in JS, line for line (tests hold it to the CPU sampler of + * src/cyberSonar.js): `x`, `y` CSS pixels from the top-left of a + * `width` × `height` view; `state` as render.setContactSonar takes it. + */ +export function sonarFactorAt(x, y, width, height, state) { + if (!state.enabled) return 1; + const floor = state.opacity / 100; + if (!state.active) return floor + (1 - floor) * 0.46; + if (![x, y].every(Number.isFinite) || width < 1 || height < 1) return 1; + // Screen up is +y here, as in clip space. + const px = x - width / 2; + const py = height / 2 - y; + const radius = (Math.min(width * 0.4, height * 0.52) * state.range) / 100; + const distanceSquared = px * px + py * py; + const radiusSquared = radius * radius; + if (distanceSquared > radiusSquared) return floor; + if (distanceSquared < radiusSquared * 0.000625) return 1; + let delta = + ((((Math.atan2(px, py) * 180) / Math.PI - state.angleDeg + 360) % 360) + + 360) % + 360; + if (delta > 359.9999) delta = 0; + if (delta > state.sector) return floor; + const feather = Math.min(10, state.sector * 0.42); + return floor + (1 - floor) * Math.min(1, (state.sector - delta) / feather); +} + +/** + * The sonar port createNavaraEngine takes: `set(state)` (already checked) + * writes the view's sonar uniforms; `destroy()` turns the treatment off. + * @param {object} view an initialised ThreeView + */ +export function createNavaraSonar(view) { + const uniforms = sonarUniformsFor(view); + return { + set({ enabled, active, angleDeg, range, opacity, sector }) { + const sonar = uniforms.uGevSonar.value; + sonar[0] = angleDeg; + sonar[1] = range / 100; + sonar[2] = opacity / 100; + sonar[3] = active ? 1 : 0; + uniforms.uGevSonarSector.value = sector; + uniforms.uGevSonarEnabled.value = enabled ? 1 : 0; + }, + destroy() { + uniforms.uGevSonarEnabled.value = 0; + }, + }; +} diff --git a/src/globe/navara/sonar.test.mjs b/src/globe/navara/sonar.test.mjs new file mode 100644 index 000000000..adaebaeb0 --- /dev/null +++ b/src/globe/navara/sonar.test.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createCyberSonarSampler } from '../../cyberSonar.js'; +import { patchMoverShader } from './movers/moverMesh.js'; +import { + DETACHED_SONAR, + SONAR_VERTEX, + createNavaraSonar, + sonarFactorAt, + sonarUniformsFor, +} from './sonar.js'; + +// A document root carrying the Cyber sonar settings, as the HUD writes them. +const rootWith = ({ range, opacity, sector }) => ({ + dataset: { + cyberSonarRange: String(range), + cyberSonarOpacity: String(opacity), + cyberSonarSector: String(sector), + }, +}); + +test('the shader factor is the CPU sampler of cyberSonar.js', () => { + const settings = [ + { range: 100, opacity: 84, sector: 24 }, + { range: 60, opacity: 35, sector: 8 }, + { range: 120, opacity: 50, sector: 60 }, + ]; + for (const { width, height } of [ + { width: 1280, height: 800 }, + { width: 390, height: 844 }, + ]) + for (const setting of settings) + for (const nowMs of [0, 700, 2600, 4100, 5199]) { + const sampler = createCyberSonarSampler( + width, + height, + nowMs, + rootWith(setting), + ); + const angleDeg = ((((nowMs % 5200) + 5200) % 5200) / 5200) * 360; + const state = { enabled: true, active: true, angleDeg, ...setting }; + for (let y = 0; y <= height; y += height / 16) + for (let x = 0; x <= width; x += width / 16) { + const expected = sampler.at(x, y); + const actual = sonarFactorAt(x, y, width, height, state); + assert.ok( + Math.abs(actual - expected) < 1e-9, + `${width}x${height} ${JSON.stringify(setting)} t=${nowMs} (${x}, ${y}): ${actual} vs ${expected}`, + ); + } + // The sweep off: the sampler's neutral factor everywhere. + assert.ok( + Math.abs( + sonarFactorAt(10, 10, width, height, { ...state, active: false }) - + sampler.neutral, + ) < 1e-12, + ); + } + // Off: contacts unchanged. + assert.equal( + sonarFactorAt(5, 5, 100, 100, { enabled: false, active: true }), + 1, + ); +}); + +test('the GLSL carries the same rule as its JS twin', () => { + for (const term of [ + 'radiusSquared * 0.000625', + 'min(resolution.x * 0.4, resolution.y * 0.52) * uGevSonar.y', + 'floorValue + (1.0 - floorValue) * 0.46', + 'min(10.0, uGevSonarSector * 0.42)', + 'degrees(atan(pixel.x, pixel.y)) - uGevSonar.x + 360.0', + 'color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72', + 'color.a * factor', + ]) + assert.ok(SONAR_VERTEX.includes(term), term); +}); + +test('the mover shaders treat contacts by their anchor and keep their position', () => { + for (const point of [false, true]) { + const shader = patchMoverShader( + { + uniforms: {}, + vertexShader: + 'void main() {\n#include \n#include \n}', + fragmentShader: + 'void main() {\n#include \n#include \n}', + }, + {}, + { point }, + ); + assert.match( + shader.vertexShader, + /vGevSonarFactor = gevSonarFactor\(projectionMatrix \* mvCenter, uGevResolution\);/, + ); + assert.match( + shader.vertexShader, + /vGevColor = gevSonarColor\(gevColor, vGevSonarFactor\);/, + ); + // gl_Position comes from the corner before the sonar runs. + assert.ok( + shader.vertexShader.indexOf( + 'gl_Position = projectionMatrix * mvPosition', + ) < shader.vertexShader.indexOf('gevSonarFactor(projectionMatrix'), + ); + if (point) + assert.match( + shader.fragmentShader, + /uGevOutlineColor\.a \* vGevSonarFactor/, + ); + } +}); + +test('one set of sonar uniforms per view, written by the port', () => { + const view = {}; + const uniforms = sonarUniformsFor(view); + assert.equal(sonarUniformsFor(view), uniforms, 'shared by its overlays'); + assert.notEqual(sonarUniformsFor({}), uniforms); + assert.equal(DETACHED_SONAR.uGevSonarEnabled.value, 0); + const port = createNavaraSonar(view); + port.set({ + enabled: true, + active: true, + angleDeg: 120, + range: 80, + opacity: 50, + sector: 30, + }); + assert.deepEqual(uniforms.uGevSonar.value, [120, 0.8, 0.5, 1]); + assert.equal(uniforms.uGevSonarSector.value, 30); + assert.equal(uniforms.uGevSonarEnabled.value, 1); + port.destroy(); + assert.equal(uniforms.uGevSonarEnabled.value, 0); +}); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index c8d23ef16..422979cee 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -2774,9 +2774,76 @@ export function createContractSuite( if (capabilities.bloom) checks.push(...bloomChecks(engine.render, settle)); if (capabilities.cloudCover) checks.push(...cloudCoverChecks(engine.render, settle)); + if (capabilities.contactSonar) + checks.push(...contactSonarChecks(engine.render, settle)); return checks.map(([name, run]) => ({ name, run })); } +// The optional contactSonar capability (render.setContactSonar). +function contactSonarChecks(render, settle) { + return [ + [ + 'render.setContactSonar (contactSonar): merges, copies, rejects bad values, draws', + async () => { + const previous = render.contactSonar(); + ensure( + previous.enabled === false, + `the sonar starts ${JSON.stringify(previous)}`, + ); + try { + render.setContactSonar({ + enabled: true, + active: true, + angleDeg: 90, + range: 100, + opacity: 40, + sector: 30, + }); + await settle(2); + render.setContactSonar({ angleDeg: 180 }); + const state = render.contactSonar(); + ensure( + state.enabled && state.active && state.angleDeg === 180, + `after an angle alone: ${JSON.stringify(state)}`, + ); + state.angleDeg = 0; + ensure( + render.contactSonar().angleDeg === 180, + 'contactSonar() is a copy', + ); + for (const bad of [ + { enabled: 'yes' }, + { active: 1 }, + { angleDeg: Number.NaN }, + { range: 0 }, + { opacity: 101 }, + { sector: 0 }, + { sector: 400 }, + ]) { + let error = null; + try { + render.setContactSonar(bad); + } catch (caught) { + error = caught; + } + ensure( + error instanceof TypeError, + `setContactSonar(${JSON.stringify(bad)}) did not throw a TypeError`, + ); + } + ensure( + render.contactSonar().opacity === 40, + 'a rejected call changed the sonar', + ); + } finally { + render.setContactSonar(previous); + await settle(1); + } + }, + ], + ]; +} + // The optional cloudCover capability (render.setCloudCover). function cloudCoverChecks(render, settle) { return [ diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 607118505..12da11d67 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -24,11 +24,13 @@ import { normalizeInputOptions, BLOOM_INITIAL, CLOUD_COVER_INITIAL, + CONTACT_SONAR_INITIAL, SHARPEN_INITIAL, STYLE_INITIAL, createEngineEvent, mergeBloom, mergeCloudCover, + mergeContactSonar, mergeSharpen, mergeStyle, } from '../contract.js'; @@ -233,6 +235,19 @@ function rectangleFromCartographics(points) { return { west, south, east, north }; } +// render.setContactSonar/contactSonar when asked; nothing otherwise. +function fakeContactSonar(enabled, record) { + if (!enabled) return {}; + let sonar = { ...CONTACT_SONAR_INITIAL }; + return { + setContactSonar(options) { + record('render.setContactSonar', [options]); + sonar = mergeContactSonar(sonar, options); + }, + contactSonar: () => ({ ...sonar }), + }; +} + // render.setCloudCover/cloudCover when asked; nothing otherwise. function fakeCloudCover(enabled, record) { if (!enabled) return {}; @@ -295,7 +310,7 @@ function fakeStyles(visualStyles, record) { * container?: object, canvas?: object, nowMs?: number, * appClock?: import('../../time/clock.js').AppClock, * visualStyles?: Record, bloom?: boolean, - * cloudCover?: boolean }} [options] + * cloudCover?: boolean, contactSonar?: boolean }} [options] * Canvas size in CSS px, angles in radians; `container`/`canvas` stand in * for the host elements (DOM mocks for shell tests). The initial pose looks straight * down from `altitude` metres (10 000 km). The engine clock reads @@ -305,7 +320,8 @@ function fakeStyles(visualStyles, record) { * `visualStyles` ({ style: its uniform names }) adds the optional * visualStyles capability (render.setStyle), each uniform starting at 0; * `bloom: true` the optional bloom capability (render.setBloom); - * `cloudCover: true` the optional cloudCover one (render.setCloudCover). + * `cloudCover: true` the optional cloudCover one (render.setCloudCover); + * `contactSonar: true` the contactSonar one (render.setContactSonar). * @returns {import('../contract.js').GlobeEngine & { fake: object }} */ export function createFakeEngine(options = {}) { @@ -994,6 +1010,7 @@ export function createFakeEngine(options = {}) { ...fakeStyles(options.visualStyles, record), ...fakeBloom(options.bloom, record), ...fakeCloudCover(options.cloudCover, record), + ...fakeContactSonar(options.contactSonar, record), }, surface: { sampleHeight(position, exclude) {