From f0d38897f440679ebba2e263dce24bf5314212f6 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 21:22:34 +0400 Subject: [PATCH] Drive the Navara volumetric clouds from the cockpit weather behind an optional render.setCloudCover capability The cockpit's weather feed moves out of the Cesium cloud pass and serves both: Cesium keeps its own cloud canvas, an engine with its own clouds takes their cover from the same observations, with no second WebGL canvas. --- scripts/qa-navara-cockpit-clouds.mjs | 252 +++++++++++++ src/app/controls.js | 21 +- src/cockpitCloudEffects.js | 372 +++++++++++++++---- src/cockpitCloudEffects.test.mjs | 171 +++++++++ src/globe/contract.js | 33 ++ src/globe/contract.test.mjs | 26 ++ src/globe/navara/cockpitClouds.js | 21 ++ src/globe/navara/cockpitClouds.test.mjs | 21 ++ src/globe/navara/createNavaraEngine.js | 24 +- src/globe/navara/createNavaraEngine.test.mjs | 24 ++ src/globe/navara/index.js | 2 + src/globe/navara/look.js | 17 + src/globe/navara/look.test.mjs | 30 ++ src/globe/testing/contractSuite.mjs | 47 +++ src/globe/testing/fakeEngine.js | 22 +- 15 files changed, 1004 insertions(+), 79 deletions(-) create mode 100644 scripts/qa-navara-cockpit-clouds.mjs create mode 100644 src/globe/navara/cockpitClouds.js create mode 100644 src/globe/navara/cockpitClouds.test.mjs diff --git a/scripts/qa-navara-cockpit-clouds.mjs b/scripts/qa-navara-cockpit-clouds.mjs new file mode 100644 index 000000000..974fe6f84 --- /dev/null +++ b/scripts/qa-navara-cockpit-clouds.mjs @@ -0,0 +1,252 @@ +// The cockpit weather clouds on Navara (Stage 6 C3): in cockpit mode with +// the cockpit weather on, Navara's own volumetric clouds take their cover +// from the weather under the camera (clear: none; overcast: a heavy deck), +// with no second canvas or WebGL context; leaving the cockpit gives the look +// its own clouds back. On Cesium the cockpit keeps its own cloud canvas and +// the engine draws none. The weather route is a fixture, the page clock is +// shifted to an Austin midday (the look shows clouds only in daylight), and +// cockpit mode is entered through its event (the cloud controllers listen +// to it; a real cockpit needs a tracked aircraft). Keyless preview under the +// strict default-deny network policy. +// +// node scripts/qa-navara-cockpit-clouds.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 { + capture, + checker, + diff, + freeze, + saveCapture, + stats, + visualPageUrl, +} from './qa/navaraVisual.mjs'; + +const base = localPreview(process.argv[2]); +const outDir = process.argv + .find((arg) => arg.startsWith('--out=')) + ?.slice('--out='.length); +const LOW_VIEW = + 'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=90&pitch=-8&map=osm&l='; +const MIDDAY_UTC = '2026-07-15T18:00:00Z'; +const WEATHER = { + clear: { weatherCode: 0, cloudCoverPct: 0, windKph: 6, visibilityM: 20000 }, + overcast: { + weatherCode: 3, + cloudCoverPct: 100, + windKph: 25, + visibilityM: 15000, + }, +}; +let weather = WEATHER.clear; +const asked = []; +const strict = createStrict({ + origin: base.origin, + layers: [], + routes: [ + { + pattern: `${base.origin}/api/weather-effects*`, + respond: (request) => { + asked.push(request.url); + return { + contentType: 'application/json', + body: JSON.stringify({ status: 'live', weather }), + }; + }, + }, + ], +}); +const report = { checks: [] }; +const check = checker(report, assert); +const save = (name, pixels) => saveCapture(outDir, name, pixels); + +// Clouds grey the frame: the blue sky and the map's coloured lines go. +const saturation = (pixels) => stats(pixels).saturation; + +// A few seconds of continuous frames: the clouds converge over frames. +async function converged(page, name) { + await page.evaluate(async () => { + const { engine } = window.__godsEyeView; + engine.render.setContinuous(true); + await new Promise((resolve) => setTimeout(resolve, 3000)); + engine.render.setContinuous(false); + }); + const pixels = await capture(page); + save(name, pixels); + return pixels; +} + +const cockpit = (page, active) => + page.evaluate((active) => { + document.body.classList.toggle('cockpit-mode', active); + window.dispatchEvent( + new CustomEvent('gev:cockpit-mode-changed', { detail: { active } }), + ); + }, active); +const weatherToggle = (page, enabled) => + page.evaluate((enabled) => { + window.dispatchEvent( + new CustomEvent('gev:cockpit-weather-toggle', { detail: { enabled } }), + ); + }, enabled); +const coverage = (page) => + page.evaluate( + () => window.__godsEyeView.engine.render.cloudCover?.().coverage, + ); + +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((utc) => { + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + const offset = Date.parse(utc) - Date.now(); + const RealDate = Date; + class ShiftedDate extends RealDate { + constructor(...args) { + if (args.length === 0) super(RealDate.now() + offset); + else super(...args); + } + static now() { + return RealDate.now() + offset; + } + } + globalThis.Date = ShiftedDate; + }, MIDDAY_UTC); + await page.setViewport({ width: 1280, height: 800 }); + await page.goto(visualPageUrl(base.origin, 'navara', '', LOW_VIEW), { + waitUntil: 'domcontentloaded', + }); + await waitForEngine(page, 'navara'); + await freeze(page); + + const setup = await page.evaluate(() => { + const a = window.__godsEyeView; + return { + cover: a.engine.render.cloudCover?.() ?? null, + canvas: Boolean(document.getElementById('cockpit-cloud-effects')), + canvases: document.querySelectorAll('canvas').length, + }; + }); + check( + 'Navara takes the cockpit cloud cover itself, no cloud canvas', + setup.cover?.coverage === null && !setup.canvas, + setup, + ); + const lookOwn = await converged(page, 'look-own'); + + await weatherToggle(page, true); + await cockpit(page, true); + await page.waitForFunction( + () => window.__godsEyeView.engine.render.cloudCover().coverage === 0, + { timeout: 10000, polling: 100 }, + ); + check('clear weather in the cockpit: no cloud cover', asked.length >= 1, { + requests: asked.length, + }); + const clear = await converged(page, 'cockpit-clear'); + + // Overcast 30 km east: the movement makes the weather stale. + weather = WEATHER.overcast; + await page.evaluate(() => { + const { engine } = window.__godsEyeView; + const place = engine.camera.pose().cartographic; + const east = + place.longitude + 30000 / (6378137 * Math.cos(place.latitude)); + const { sin, cos } = Math; + // WGS84 Earth-fixed position of (east, latitude, height). + const a = 6378137; + const e2 = 0.00669437999014; + const n = a / Math.sqrt(1 - e2 * sin(place.latitude) ** 2); + const h = place.height; + engine.camera.setView({ + destination: { + x: (n + h) * cos(place.latitude) * cos(east), + y: (n + h) * cos(place.latitude) * sin(east), + z: (n * (1 - e2) + h) * sin(place.latitude), + }, + orientation: { heading: Math.PI / 2, pitch: -0.14, roll: 0 }, + }); + }); + await page.waitForFunction( + () => window.__godsEyeView.engine.render.cloudCover().coverage > 0.4, + { timeout: 15000, polling: 100 }, + ); + const overcastCover = await coverage(page); + const overcast = await converged(page, 'cockpit-overcast'); + const greyed = { + lookOwn: saturation(lookOwn), + clear: saturation(clear), + overcast: saturation(overcast), + clearToOvercast: diff(clear, overcast).mean, + }; + check( + 'overcast weather covers the view with Navara clouds, clear leaves it open', + overcastCover > 0.4 && + greyed.overcast < greyed.clear * 0.6 && + greyed.lookOwn < greyed.clear && + greyed.clearToOvercast > 15, + { overcastCover, greyed, requests: asked.length }, + ); + const contexts = await page.evaluate(() => ({ + canvas: Boolean(document.getElementById('cockpit-cloud-effects')), + canvases: document.querySelectorAll('canvas').length, + })); + check( + 'no second canvas or WebGL context in the cockpit', + !contexts.canvas && contexts.canvases === setup.canvases, + { before: setup.canvases, after: contexts }, + ); + + await cockpit(page, false); + check( + 'leaving the cockpit gives the look its own clouds back', + (await coverage(page)) === null, + ); + + // Cesium keeps its own cockpit cloud canvas; its engine draws none. + await strict.switchTo(page, 'cesium'); + await freeze(page); + weather = WEATHER.overcast; + await cockpit(page, true); + await page.waitForFunction( + () => + Number( + document.getElementById('cockpit-cloud-effects')?.dataset + .cloudStrength, + ) > 0.3, + { timeout: 15000, polling: 100 }, + ); + const cesium = await page.evaluate(() => { + const canvas = document.getElementById('cockpit-cloud-effects'); + return { + setCloudCover: typeof window.__godsEyeView.engine.render.setCloudCover, + strength: Number(canvas.dataset.cloudStrength), + cockpit: canvas.dataset.cockpit, + }; + }); + check( + 'Cesium: the cockpit draws its own cloud canvas, the engine none', + cesium.setCloudCover === 'undefined' && cesium.cockpit === 'true', + cesium, + ); + await cockpit(page, false); + await weatherToggle(page, false); + + check('no page errors', errors.length === 0, errors); + if (outDir) + fs.writeFileSync( + path.join(outDir, 'cockpit-clouds.json'), + JSON.stringify(report, null, 2), + ); + }, +); diff --git a/src/app/controls.js b/src/app/controls.js index 957e39a99..d078636f1 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -1,7 +1,11 @@ import { catalogControlServices } from './catalog.js'; import { StyleManager } from '../ui/composition.js'; import { flyToAustin } from '../camera.js'; -import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; +import { + initCockpitCloudEffects, + initEngineCockpitClouds, +} from '../cockpitCloudEffects.js'; +import { engineCapabilities } from '../globe/contract.js'; import { t } from '../i18n/index.js'; import { attachScenePart, currentSceneOf } from './sceneParts.js'; import { shellScenePart } from './shellPart.js'; @@ -67,7 +71,9 @@ export async function createApplicationControls({ ); // The previous multi-canvas weather compositor remains disabled. Cockpit // clouds use a separate, capped low-resolution GPU pass that never attaches - // Cesium fog or post-process stages and is fully stopped in map mode. + // Cesium fog or post-process stages and is fully stopped in map mode. An + // engine that draws its own clouds (Navara, render.setCloudCover) gets + // their cover from the same weather instead, with no canvas of its own. const weatherEffects = null; let cockpitCloudEffects = null; await attachScenePart( @@ -75,10 +81,13 @@ export async function createApplicationControls({ { name: 'cockpit-clouds', attachScene(next, { defer: release }) { - if (!next.viewer) return; - const effects = initCockpitCloudEffects(next.viewer, { - weatherService: operations.requests.weather, - }); + const weatherService = operations.requests.weather; + let effects = null; + if (next.viewer) + effects = initCockpitCloudEffects(next.viewer, { weatherService }); + else if (engineCapabilities(next.engine).cloudCover) + effects = initEngineCockpitClouds(next.engine, { weatherService }); + if (!effects) return; cockpitCloudEffects = effects; release(() => { if (cockpitCloudEffects === effects) cockpitCloudEffects = null; diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index 95d65a249..b99b07483 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -184,13 +184,111 @@ export function cockpitWeatherEnabledFromStoredValue(value) { return value === '1'; } +/** + * The cloud strength (0..1) a weather observation gives at an altitude: the + * weather profile's cloud, faded out above the cloud deck. + */ +export function cockpitCloudStrength(weather, altitudeM) { + const profile = deriveWeatherEffectProfile(weather); + return profile.available + ? profile.cloud * weatherAltitudeFactors(altitudeM).cloud + : 0; +} + +/** Highest cloud cover a globe engine is asked for (overcast, not a wall). */ +export const COCKPIT_CLOUD_COVERAGE_MAX = 0.8; + +/** The globe engine's cloud cover (0..1) for a cockpit cloud strength. */ +export function cockpitCloudCoverage(strength) { + const value = Math.min(1, Math.max(0, Number(strength) || 0)); + return value * COCKPIT_CLOUD_COVERAGE_MAX; +} + +/** + * The cockpit's weather observations, shared by both cloud presentations: + * one request at a time, the last observation and where and when it was + * taken (cockpitWeatherRefreshDue decides when it is stale). + */ +export class CockpitWeatherFeed { + constructor(weatherService = applicationServices.weather) { + this.weatherService = weatherService; + this.weather = null; + this.fetchedAt = 0; + this.anchor = null; + this.pending = null; + this.abort = null; + this.sourceStatus = 'idle'; + } + + /** Whether an observation for `point` must be fetched. */ + due(point, nowMs = Date.now()) { + return cockpitWeatherRefreshDue({ + nowMs, + fetchedAt: this.fetchedAt, + anchor: this.anchor, + point, + hasWeather: Boolean(this.weather), + }); + } + + /** + * Fetch the observation at `point` (replacing a request in flight): + * `onWeather(weather, status)` once it arrives, `onError()` when the + * observation is unavailable (an abort calls neither). Resolves to the + * weather, or null. + */ + fetch(point, { onWeather, onError } = {}) { + this.abort?.abort(); + const request = (this.abort = new AbortController()); + const pending = this.weatherService.getConditions( + point.latitude, + point.longitude, + { signal: request.signal }, + ); + this.pending = pending + .then(async (payload) => { + if (this.abort !== request || request.signal.aborted) return null; + if (!payload?.weather) + throw new Error('Cloud weather observation unavailable'); + this.weather = payload.weather; + this.fetchedAt = Date.now(); + this.anchor = point; + this.sourceStatus = payload.status || 'ready'; + onWeather?.(payload.weather, this.sourceStatus); + return payload.weather; + }) + .catch((error) => { + if (request.signal.aborted || this.abort !== request) return null; + if (error?.name !== 'AbortError') { + this.sourceStatus = 'unavailable'; + onError?.(error); + } + return null; + }) + .finally(() => { + if (this.abort === request) { + this.pending = null; + this.abort = null; + } + }); + return this.pending; + } + + /** Abandon a request in flight; the last observation stays. */ + cancel() { + this.abort?.abort(); + this.abort = null; + this.pending = null; + } +} + /** * Weather-backed volumetric cloud pass that exists only while cockpit mode is * active. It owns no Cesium fog/post-process stages and cannot affect map mode. */ class CockpitCloudEffectsController { constructor(viewer, { weatherService = applicationServices.weather } = {}) { - this.weatherService = weatherService; + this.feed = new CockpitWeatherFeed(weatherService); this.viewer = viewer; this.canvas = document.createElement('canvas'); this.canvas.id = 'cockpit-cloud-effects'; @@ -206,15 +304,10 @@ class CockpitCloudEffectsController { this.startupTimer = null; this.lastFrameMs = 0; this.lastRefreshCheckMs = 0; - this.fetchedAt = 0; - this.anchor = null; - this.weather = null; this.strength = 0; this.targetStrength = 0; this.windDirectionDeg = 0; this.windStrength = 0; - this.pending = null; - this.abort = null; this.destroyed = false; this.reducedMotion = window.matchMedia( '(prefers-reduced-motion: reduce)', @@ -387,9 +480,7 @@ class CockpitCloudEffectsController { this.refreshTimer = null; if (this.startupTimer !== null) window.clearTimeout(this.startupTimer); this.startupTimer = null; - this.abort?.abort(); - this.abort = null; - this.pending = null; + this.feed.cancel(); this.strength = 0; this.targetStrength = 0; this.canvas.classList.remove('active'); @@ -407,66 +498,33 @@ class CockpitCloudEffectsController { if ( this.destroyed || !this.enabled || - this.pending || + this.feed.pending || this.suspended || !document.body.classList.contains('cockpit-mode') ) { - return this.pending; + return this.feed.pending; } const point = this.cameraPoint(); if (!point) return null; - if ( - this.weather && - Date.now() - this.fetchedAt < WEATHER_REFRESH_MS && - greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M - ) { - this.applyWeather(this.weather, point.altitudeM); - return this.weather; + if (!this.feed.due(point)) { + this.applyWeather(this.feed.weather, point.altitudeM); + return this.feed.weather; } - - this.abort?.abort(); - this.abort = new AbortController(); - const pending = this.weatherService.getConditions( - point.latitude, - point.longitude, - { signal: this.abort.signal }, - ); - const request = this.abort; - this.pending = pending - .then(async (payload) => { - if (this.abort !== request || request.signal.aborted) return null; - if (!payload?.weather) - throw new Error('Cloud weather observation unavailable'); - this.weather = payload.weather; - this.fetchedAt = Date.now(); - this.anchor = point; - this.applyWeather(payload.weather, point.altitudeM); - this.canvas.dataset.sourceStatus = payload.status || 'ready'; - return payload.weather; - }) - .catch((error) => { - if (request.signal.aborted || this.abort !== request) return; - if (error?.name !== 'AbortError') { - this.targetStrength = 0; - this.canvas.dataset.sourceStatus = 'unavailable'; - } - return null; - }) - .finally(() => { - if (this.abort === request) { - this.pending = null; - this.abort = null; - } - }); - return this.pending; + return this.feed.fetch(point, { + onWeather: (weather, status) => { + this.applyWeather(weather, point.altitudeM); + this.canvas.dataset.sourceStatus = status; + }, + onError: () => { + this.targetStrength = 0; + this.canvas.dataset.sourceStatus = 'unavailable'; + }, + }); } applyWeather(weather, altitudeM) { const profile = deriveWeatherEffectProfile(weather); - const altitude = weatherAltitudeFactors(altitudeM); - this.targetStrength = profile.available - ? profile.cloud * altitude.cloud - : 0; + this.targetStrength = cockpitCloudStrength(weather, altitudeM); this.windDirectionDeg = profile.windDirectionDeg || 0; this.windStrength = profile.wind || 0; this.canvas.dataset.cloudStrength = this.targetStrength.toFixed(3); @@ -505,17 +563,7 @@ class CockpitCloudEffectsController { if (timeMs - this.lastRefreshCheckMs >= 1000) { this.lastRefreshCheckMs = timeMs; const point = this.cameraPoint(); - if ( - point && - cockpitWeatherRefreshDue({ - nowMs: Date.now(), - fetchedAt: this.fetchedAt, - anchor: this.anchor, - point, - hasWeather: Boolean(this.weather), - }) - ) - void this.refresh(); + if (point && this.feed.due(point)) void this.refresh(); } const blend = this.reducedMotion ? 1 : 0.08; @@ -572,7 +620,7 @@ class CockpitCloudEffectsController { enabled: this.enabled, active: this.canvas.classList.contains('active'), strength: this.targetStrength, - weather: this.weather ? { ...this.weather } : null, + weather: this.feed.weather ? { ...this.feed.weather } : null, renderSize: { width: this.canvas.width, height: this.canvas.height }, sourceStatus: this.canvas.dataset.sourceStatus || 'idle', }; @@ -634,3 +682,187 @@ class CockpitCloudEffectsController { export function initCockpitCloudEffects(viewer, options) { return new CockpitCloudEffectsController(viewer, options); } + +/** + * The cockpit's weather clouds on a globe engine that draws its own clouds + * (render.setCloudCover, the `cloudCover` capability: Navara's volumetric + * clouds), with no canvas or WebGL context of its own. While cockpit mode + * and the cockpit weather toggle are on, the engine's cloud cover follows + * the weather under the camera (the same observations, refresh rule and + * strength as the Cesium pass); otherwise the engine keeps its own clouds. + */ +class EngineCockpitCloudsController { + constructor(engine, { weatherService = applicationServices.weather } = {}) { + this.engine = engine; + this.feed = new CockpitWeatherFeed(weatherService); + this.timer = null; + this.targetStrength = 0; + this.coverage = null; + this.suspended = false; + this.destroyed = false; + this.enabled = this.readEnabledPreference(); + this.onCockpitMode = (event) => { + const active = + event?.detail?.active ?? + document.body.classList.contains('cockpit-mode'); + if (active && this.enabled) this.start(); + else this.stop(); + }; + this.onEnabledChange = (event) => { + const requested = event?.detail?.enabled; + this.setEnabled( + typeof requested === 'boolean' ? requested : !this.enabled, + ); + }; + window.addEventListener('gev:cockpit-mode-changed', this.onCockpitMode); + window.addEventListener('gev:cockpit-weather-toggle', this.onEnabledChange); + this.emitEnabledState(); + if (this.enabled && document.body.classList.contains('cockpit-mode')) + this.start(); + } + + readEnabledPreference() { + try { + return cockpitWeatherEnabledFromStoredValue( + localStorage.getItem(WEATHER_ENABLED_STORAGE_KEY), + ); + } catch { + return false; + } + } + + emitEnabledState() { + window.dispatchEvent( + new CustomEvent('gev:cockpit-weather-state', { + detail: { enabled: this.enabled }, + }), + ); + } + + setEnabled(enabled) { + this.enabled = !!enabled; + try { + localStorage.setItem( + WEATHER_ENABLED_STORAGE_KEY, + this.enabled ? '1' : '0', + ); + } catch { + /* best effort */ + } + if (!this.enabled) this.stop(); + else if (document.body.classList.contains('cockpit-mode')) this.start(); + this.emitEnabledState(); + } + + cameraPoint() { + if (this.engine.isDestroyed?.()) return null; + let place; + try { + place = this.engine.camera.pose().cartographic; + } catch { + return null; + } + const latitude = geo.math.toDegrees(place?.latitude); + const longitude = geo.math.toDegrees(place?.longitude); + if (![latitude, longitude].every(Number.isFinite)) return null; + return { + latitude, + longitude, + altitudeM: Math.max(0, Number(place.height) || 0), + }; + } + + /** Shows the cloud cover for `strength`; null gives the engine its own. */ + showCoverage(coverage) { + if (coverage === this.coverage || this.engine.isDestroyed?.()) return; + this.coverage = coverage; + this.engine.render.setCloudCover({ coverage }); + this.engine.render.request('cockpit-clouds'); + } + + applyWeather(weather, altitudeM) { + this.targetStrength = cockpitCloudStrength(weather, altitudeM); + this.showCoverage(cockpitCloudCoverage(this.targetStrength)); + } + + start() { + if (this.destroyed || !this.enabled || this.suspended || this.timer) return; + // The first observation at once, then the refresh rule every second + // (time or movement), as the Cesium pass checks it. + this.timer = window.setInterval(() => this.check(), 1000); + this.check(); + } + + check() { + if ( + this.destroyed || + !this.enabled || + this.suspended || + !document.body.classList.contains('cockpit-mode') + ) + return; + const point = this.cameraPoint(); + if (!point || this.feed.pending) return; + if (!this.feed.due(point)) { + this.applyWeather(this.feed.weather, point.altitudeM); + return; + } + void this.feed.fetch(point, { + onWeather: (weather) => { + if (this.timer) this.applyWeather(weather, point.altitudeM); + }, + onError: () => { + this.targetStrength = 0; + if (this.timer) this.showCoverage(0); + }, + }); + } + + stop() { + if (this.timer) window.clearInterval(this.timer); + this.timer = null; + this.feed.cancel(); + this.targetStrength = 0; + this.showCoverage(null); + } + + getSnapshot() { + return { + enabled: this.enabled, + active: this.timer !== null && this.coverage !== null, + strength: this.targetStrength, + coverage: this.coverage, + weather: this.feed.weather ? { ...this.feed.weather } : null, + renderSize: null, + sourceStatus: this.feed.sourceStatus, + }; + } + + /** Pauses the weather checks while the document is hidden. */ + setSuspended(suspended) { + this.suspended = Boolean(suspended); + if (this.suspended) { + if (this.timer) window.clearInterval(this.timer); + this.timer = null; + this.feed.cancel(); + } else if (document.body.classList.contains('cockpit-mode')) { + this.start(); + } + } + + destroy() { + if (this.destroyed) return; + this.stop(); + this.destroyed = true; + window.removeEventListener('gev:cockpit-mode-changed', this.onCockpitMode); + window.removeEventListener( + 'gev:cockpit-weather-toggle', + this.onEnabledChange, + ); + } +} + +/** The cockpit weather clouds on a globe engine with the cloudCover capability. */ +export function initEngineCockpitClouds(engine, options) { + return new EngineCockpitCloudsController(engine, options); +} diff --git a/src/cockpitCloudEffects.test.mjs b/src/cockpitCloudEffects.test.mjs index 5c8a565fa..b5f59d70a 100644 --- a/src/cockpitCloudEffects.test.mjs +++ b/src/cockpitCloudEffects.test.mjs @@ -1,10 +1,17 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { createFakeEngine } from './globe/testing/fakeEngine.js'; +import * as geo from './geo/index.js'; import { initCockpitCloudEffects, cockpitCloudRenderSize, cockpitWeatherRefreshDue, cockpitWeatherEnabledFromStoredValue, + COCKPIT_CLOUD_COVERAGE_MAX, + CockpitWeatherFeed, + cockpitCloudCoverage, + cockpitCloudStrength, + initEngineCockpitClouds, } from './cockpitCloudEffects.js'; test('cockpit cloud framebuffer stays low resolution on large displays', () => { @@ -198,3 +205,167 @@ test('a destroyed cockpit cloud controller frees its GPU objects and WebGL conte } } }); + +const OVERCAST = { weatherCode: 3, cloudCoverPct: 100, windKph: 20 }; +const CLEAR = { weatherCode: 0, cloudCoverPct: 0, windKph: 5 }; + +test('the cloud strength is the weather profile cloud under the deck, the cover its share', () => { + assert.ok(cockpitCloudStrength(OVERCAST, 1000) > 0.5); + assert.equal(cockpitCloudStrength(CLEAR, 1000), 0); + assert.equal(cockpitCloudStrength({}, 1000), 0, 'no observation, no cloud'); + assert.equal(cockpitCloudStrength(OVERCAST, 30000), 0, 'above the deck'); + assert.equal(cockpitCloudCoverage(1), COCKPIT_CLOUD_COVERAGE_MAX); + assert.equal(cockpitCloudCoverage(0), 0); + assert.equal(cockpitCloudCoverage(Number.NaN), 0); + assert.equal(cockpitCloudCoverage(0.5), 0.5 * COCKPIT_CLOUD_COVERAGE_MAX); +}); + +test('the weather feed keeps the last observation and reports failures', async () => { + const asked = []; + let answer = { status: 'live', weather: OVERCAST }; + const feed = new CockpitWeatherFeed({ + getConditions: async (lat, lon, { signal }) => { + asked.push([lat, lon, signal.aborted]); + if (answer instanceof Error) throw answer; + return answer; + }, + }); + const point = { latitude: 30, longitude: -97, altitudeM: 900 }; + assert.equal(feed.due(point), true); + const seen = []; + assert.deepEqual( + await feed.fetch(point, { onWeather: (w, s) => seen.push([w, s]) }), + OVERCAST, + ); + assert.deepEqual(seen, [[OVERCAST, 'live']]); + assert.equal(feed.due(point), false, 'fresh and here'); + assert.equal(feed.pending, null); + answer = new Error('down'); + const errors = []; + assert.equal( + await feed.fetch(point, { onError: (e) => errors.push(e) }), + null, + ); + assert.equal(errors.length, 1); + assert.equal(feed.sourceStatus, 'unavailable'); + assert.deepEqual(feed.weather, OVERCAST, 'the last observation stays'); + // A cancelled request reports nothing. + answer = { weather: CLEAR }; + const late = feed.fetch(point, { onWeather: () => seen.push('late') }); + feed.cancel(); + assert.equal(await late, null); + assert.equal(seen.length, 1); +}); + +// The page globals the engine controller reads, restored afterwards. +function withPage(run) { + const names = ['document', 'window', 'localStorage', 'CustomEvent']; + const saved = Object.fromEntries(names.map((n) => [n, globalThis[n]])); + const listeners = new Map(); + const timers = new Map(); + let nextTimer = 1; + const body = new Set(['cockpit-mode']); + let created = 0; + globalThis.document = { + createElement() { + created++; + return {}; + }, + body: { classList: { contains: (name) => body.has(name) } }, + }; + globalThis.window = { + addEventListener: (type, fn) => listeners.set(type, fn), + removeEventListener: (type) => listeners.delete(type), + dispatchEvent() {}, + setInterval: (fn) => { + timers.set(nextTimer, fn); + return nextTimer++; + }, + clearInterval: (id) => timers.delete(id), + }; + globalThis.localStorage = { getItem: () => '1', setItem() {} }; + globalThis.CustomEvent = class { + constructor(type, init) { + this.type = type; + this.detail = init?.detail; + } + }; + const page = { + body, + listeners, + timers, + get canvases() { + return created; + }, + }; + return Promise.resolve(run(page)).finally(() => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete globalThis[name]; + else globalThis[name] = value; + } + }); +} + +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +test('on an engine with its own clouds the cockpit weather sets their cover, no canvas', () => + withPage(async (page) => { + const engine = createFakeEngine({ kind: 'navara', cloudCover: true }); + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(-97.74, 30.27, 1500), + }); + let weather = OVERCAST; + const clouds = initEngineCockpitClouds(engine, { + weatherService: { getConditions: async () => ({ weather }) }, + }); + await settle(); + const overcast = cockpitCloudCoverage(cockpitCloudStrength(OVERCAST, 1500)); + assert.ok(Math.abs(engine.render.cloudCover().coverage - overcast) < 1e-9); + assert.equal(page.canvases, 0, 'no canvas, no second WebGL context'); + assert.equal(page.timers.size, 1, 'one refresh check'); + assert.equal(clouds.getSnapshot().active, true); + // Leaving the cockpit gives the engine its own clouds back. + page.body.delete('cockpit-mode'); + page.listeners.get('gev:cockpit-mode-changed')({ + detail: { active: false }, + }); + assert.deepEqual(engine.render.cloudCover(), { coverage: null }); + assert.equal(page.timers.size, 0); + // Back in, with clear weather after the cache is stale. + page.body.add('cockpit-mode'); + weather = CLEAR; + clouds.feed.fetchedAt = 0; + page.listeners.get('gev:cockpit-mode-changed')({ + detail: { active: true }, + }); + await settle(); + assert.equal(engine.render.cloudCover().coverage, 0); + // The weather toggle off stops it; destroy leaves the engine its own. + page.listeners.get('gev:cockpit-weather-toggle')({ + detail: { enabled: false }, + }); + assert.deepEqual(engine.render.cloudCover(), { coverage: null }); + clouds.destroy(); + assert.equal(page.listeners.size, 0); + })); + +test('a hidden page pauses the engine cockpit clouds checks', () => + withPage(async (page) => { + const engine = createFakeEngine({ kind: 'navara', cloudCover: true }); + let asked = 0; + const clouds = initEngineCockpitClouds(engine, { + weatherService: { + getConditions: async () => { + asked++; + return { weather: OVERCAST }; + }, + }, + }); + await settle(); + clouds.setSuspended(true); + assert.equal(page.timers.size, 0); + clouds.setSuspended(false); + assert.equal(page.timers.size, 1); + clouds.destroy(); + assert.ok(asked >= 1); + })); diff --git a/src/globe/contract.js b/src/globe/contract.js index d074b453b..6e628183e 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -259,6 +259,16 @@ * Cesium mode the app drives the scene's own bloom stage. * @property {() => { enabled: boolean, strength: number }} [bloom] The bloom * last set (a copy). + * @property {(options: { coverage?: number | null }) => void} [setCloudCover] + * Optional, with cloudCover (capability `cloudCover`): how much of the sky + * the engine's own volumetric clouds cover, 0..1, or null for the + * engine's own cover (its look). Where the clouds show (height, daylight) + * stays the engine's. The cockpit drives it from the weather under the + * camera (src/cockpitCloudEffects.js). A coverage outside 0..1 that is not + * null throws a TypeError. Null until set. Cesium has none: in Cesium + * mode the cockpit draws its own cloud pass. + * @property {() => { coverage: number | null }} [cloudCover] The cover last + * set (a copy). * * @typedef {{ intensity: number, uniforms: Record }} StyleState * @@ -681,6 +691,10 @@ const OPTIONAL_SHAPE = { 'render.setBloom': isFunction, 'render.bloom': isFunction, }, + cloudCover: { + 'render.setCloudCover': isFunction, + 'render.cloudCover': isFunction, + }, }; /** The optional capability flags, e.g. 'visualStyles'. */ @@ -843,3 +857,22 @@ export function mergeBloom(current, { enabled, strength } = {}) { strength: strength ?? current.strength, }; } + +/** The cloud cover an engine starts with (GlobeRender.cloudCover): its own. */ +export const CLOUD_COVER_INITIAL = Object.freeze({ coverage: null }); + +/** + * GlobeRender.setCloudCover's argument merged onto `current`: a new state, + * or a TypeError for a coverage that is neither null nor in 0..1. + */ +export function mergeCloudCover(current, { coverage } = {}) { + if ( + coverage !== undefined && + coverage !== null && + !(isNumber(coverage) && coverage >= 0 && coverage <= 1) + ) + throw new TypeError( + `Cloud coverage must be null or a number in 0..1: ${coverage}`, + ); + return { coverage: coverage === undefined ? current.coverage : coverage }; +} diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index 5705a995c..69bfe9300 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -6,6 +6,7 @@ import { INPUT_TYPES, MODIFIERS, BLOOM_INITIAL, + CLOUD_COVER_INITIAL, ENGINE_CAPABILITIES, OPTIONAL_ENGINE_MEMBERS, SHARPEN_INITIAL, @@ -14,6 +15,7 @@ import { engineCapabilities, isEngineEvent, mergeBloom, + mergeCloudCover, mergeSharpen, mergeStyle, } from './contract.js'; @@ -476,3 +478,27 @@ test('the bloom capability: whole or absent, merged and checked', async (t) => { assert.equal(checks.length, 1); for (const check of checks) await t.test(check.name, () => check.run()); }); + +test('the cloudCover capability: whole or absent, null for the engine own', async (t) => { + assert.ok(ENGINE_CAPABILITIES.includes('cloudCover')); + const engine = createFakeEngine({ cloudCover: true }); + assert.equal(engineCapabilities(engine).cloudCover, true); + assert.equal(engineCapabilities(createFakeEngine()).cloudCover, false); + assert.deepEqual(engine.render.cloudCover(), CLOUD_COVER_INITIAL); + const half = { + ...engine, + render: { ...engine.render, cloudCover: undefined }, + }; + assert.throws(() => assertGlobeEngine(half), /render\.cloudCover\b/); + assert.deepEqual(mergeCloudCover({ coverage: 0.3 }, {}), { coverage: 0.3 }); + assert.deepEqual(mergeCloudCover({ coverage: 0.3 }, { coverage: null }), { + coverage: null, + }); + for (const bad of [{ coverage: 2 }, { coverage: -1 }, { coverage: 'x' }]) + assert.throws(() => mergeCloudCover(CLOUD_COVER_INITIAL, bad), TypeError); + const checks = createContractSuite(engine, { + waitFrames: async (count) => engine.fake.frames(count), + }).filter(({ name }) => /\(cloudCover\)/.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/cockpitClouds.js b/src/globe/navara/cockpitClouds.js new file mode 100644 index 000000000..10725950a --- /dev/null +++ b/src/globe/navara/cockpitClouds.js @@ -0,0 +1,21 @@ +// The cloud cover port createNavaraEngine takes (render.setCloudCover): the +// cockpit's weather clouds drawn by Navara's own volumetric clouds, the +// look's cloud effect (look.js), instead of a second WebGL canvas. Only a +// look that draws clouds has it (the cinematic look; not on a +// mobile-optimized view). Destroying it gives the look its own cover back. + +/** + * @param {{ hasClouds: boolean, setCloudCoverage: (coverage: number | null) => boolean }} look + * @returns {{ set: (state: { coverage: number | null }) => void, destroy: () => void } | null} + */ +export function createNavaraCloudCover(look) { + if (!look?.hasClouds) return null; + return { + set({ coverage }) { + look.setCloudCoverage(coverage); + }, + destroy() { + look.setCloudCoverage(null); + }, + }; +} diff --git a/src/globe/navara/cockpitClouds.test.mjs b/src/globe/navara/cockpitClouds.test.mjs new file mode 100644 index 000000000..bbc3e0a87 --- /dev/null +++ b/src/globe/navara/cockpitClouds.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createNavaraCloudCover } from './cockpitClouds.js'; + +test('only a look with clouds gives the engine a cloud cover port', () => { + assert.equal(createNavaraCloudCover(null), null); + assert.equal( + createNavaraCloudCover({ hasClouds: false, setCloudCoverage() {} }), + null, + ); + const set = []; + const port = createNavaraCloudCover({ + hasClouds: true, + setCloudCoverage: (coverage) => set.push(coverage), + }); + port.set({ coverage: 0.4 }); + port.set({ coverage: null }); + port.set({ coverage: 0 }); + port.destroy(); + assert.deepEqual(set, [0.4, null, 0, null], 'destroy gives the look its own'); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 0c0167a5f..773fe7fbb 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -9,10 +9,12 @@ import * as geo from '../../geo/index.js'; import { BLOOM_INITIAL, + CLOUD_COVER_INITIAL, SHARPEN_INITIAL, STYLE_INITIAL, createEngineEvent, mergeBloom, + mergeCloudCover, mergeSharpen, mergeStyle, } from '../contract.js'; @@ -93,6 +95,7 @@ function rayRaisedEllipsoid({ origin, direction }, height) { * appClock?: import('../../time/clock.js').AppClock, * 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 }, * 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 @@ -100,7 +103,9 @@ function rayRaisedEllipsoid({ origin, direction }, height) { * `styles` draws render.setStyle (stylePasses.js; without it the engine * has no visualStyles capability) and the engine destroys it; `bloom` * draws render.setBloom (bloom.js; without it no bloom capability), also - * destroyed with the engine; + * 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; * `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; @@ -121,6 +126,7 @@ export function createNavaraEngine( sharpen: sharpenPort = null, styles: stylesPort = null, bloom: bloomPort = null, + clouds: cloudsPort = null, } = {}, ) { if (!view?.camera?.raw) @@ -331,6 +337,20 @@ export function createNavaraEngine( bloom: () => ({ ...bloom }), } : {}; + let cloudCover = { ...CLOUD_COVER_INITIAL }; + const cloudRender = cloudsPort + ? { + setCloudCover(options) { + const next = mergeCloudCover(cloudCover, options); + const changed = next.coverage !== cloudCover.coverage; + cloudCover = next; + if (!changed || destroyed) return; + cloudsPort.set({ ...next }); + view.forceUpdate(); + }, + cloudCover: () => ({ ...cloudCover }), + } + : {}; return { kind: 'navara', host: { container: container ?? canvas.parentElement, canvas, size }, @@ -361,6 +381,7 @@ export function createNavaraEngine( sharpen: () => ({ ...sharpen }), ...styleRender, ...bloomRender, + ...cloudRender, }, surface, input: input.input, @@ -383,6 +404,7 @@ export function createNavaraEngine( sharpenPort?.destroy(); stylesPort?.destroy(); bloomPort?.destroy(); + cloudsPort?.destroy(); moversPort?.destroy(); linesPort?.destroy(); }, diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index 9752999e7..fb4ef4644 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -291,10 +291,34 @@ test('setBloom hands changed states to the bloom port and draws a frame', () => assert.equal(destroyed, 1); }); +test('setCloudCover hands changed covers to the clouds port and draws a frame', () => { + const view = fakeView(); + const shown = []; + let destroyed = 0; + const engine = createNavaraEngine(view, { + hooks, + clouds: { set: (state) => shown.push(state), destroy: () => destroyed++ }, + }); + assertGlobeEngine(engine); + assert.equal(engineCapabilities(engine).cloudCover, true); + assert.deepEqual(engine.render.cloudCover(), { coverage: null }); + engine.render.setCloudCover({ coverage: 0.6 }); + assert.deepEqual(view.calls.at(-1), ['forceUpdate']); + const calls = view.calls.length; + engine.render.setCloudCover({ coverage: 0.6 }); + assert.equal(view.calls.length, calls, 'an unchanged cover draws nothing'); + engine.render.setCloudCover({ coverage: null }); + assert.throws(() => engine.render.setCloudCover({ coverage: 7 }), TypeError); + assert.deepEqual(shown, [{ coverage: 0.6 }, { coverage: null }]); + 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(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 76380745a..4e4926cc9 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -24,6 +24,7 @@ import { buildNavaraLook } from './look.js'; import { createNavaraSharpen } from './sharpen.js'; import { createNavaraStyles } from './stylePasses.js'; import { createNavaraBloom } from './bloom.js'; +import { createNavaraCloudCover } from './cockpitClouds.js'; import { retroShader } from '../../styles/retro.js'; import { nightVisionShader } from '../../styles/surveillance.js'; import { thermalShader } from '../../styles/thermal.js'; @@ -159,6 +160,7 @@ export async function createNavaraGlobe({ ...(appClock ? { appClock } : {}), sharpen: createNavaraSharpen(view), bloom: createNavaraBloom(view), + clouds: createNavaraCloudCover(look), styles: createNavaraStyles(view, { shaders: STYLE_SHADERS, onStarsDimmed: (amount) => look.setStarsDimmed(amount), diff --git a/src/globe/navara/look.js b/src/globe/navara/look.js index 2ede0c8f6..ac71aee5f 100644 --- a/src/globe/navara/look.js +++ b/src/globe/navara/look.js @@ -16,6 +16,7 @@ import { cloudTerrainOffset, landscapeSunTime, CUMULUS_BASES, + LOCAL_CLOUD_COVERAGE, globeLightingAt, starsVisibleAt, sunDateStale, @@ -205,12 +206,28 @@ export function buildNavaraLook( plan.meshes.find((desc) => Object.hasOwn(desc, 'stars'))?.stars ?.intensity ?? NAVARA_STARS_INTENSITY; let starsDimmed = 0; + let cloudCoverage = LOCAL_CLOUD_COVERAGE; return { handles, frame, resetDaylight() { daylightTime = null; }, + /** Whether the look draws volumetric clouds (cinematic, not on mobile). */ + hasClouds: Boolean(clouds), + /** + * The clouds' cover (0..1), or null for the look's own + * (LOCAL_CLOUD_COVERAGE); true when it changed. Where and when they + * show stays the look's rule (height, sunlight). + */ + setCloudCoverage(coverage) { + if (!clouds) return false; + const next = coverage ?? LOCAL_CLOUD_COVERAGE; + if (next === cloudCoverage) return false; + cloudCoverage = next; + clouds.update({ clouds: { coverage: next } }); + return true; + }, /** Dims the stars by `amount` (0 none, 1 hidden); true when it changed. */ setStarsDimmed(amount) { const next = Math.min(1, Math.max(0, Number(amount) || 0)); diff --git a/src/globe/navara/look.test.mjs b/src/globe/navara/look.test.mjs index c3964acd8..a65b86562 100644 --- a/src/globe/navara/look.test.mjs +++ b/src/globe/navara/look.test.mjs @@ -509,3 +509,33 @@ test('Anime fades the stars from the look own brightness and back', () => { ); assert.equal(photoreal.setStarsDimmed(1), false); }); + +test('the cockpit sets the cinematic clouds cover and null gives the look its own', async () => { + const { LOCAL_CLOUD_COVERAGE } = await import('./viewOptions.js'); + const view = fakeView(0); + const look = warmCinematic(view); + const clouds = view.added.find(({ desc }) => Object.hasOwn(desc, 'clouds')); + assert.equal(look.hasClouds, true); + assert.equal(look.setCloudCoverage(null), false, 'already the look own'); + assert.equal(look.setCloudCoverage(0.7), true); + assert.equal(look.setCloudCoverage(0.7), false); + assert.equal(look.setCloudCoverage(0), true); + assert.equal(look.setCloudCoverage(null), true); + assert.deepEqual( + clouds.updates.filter((update) => update.clouds), + [ + { clouds: { coverage: 0.7 } }, + { clouds: { coverage: 0 } }, + { clouds: { coverage: LOCAL_CLOUD_COVERAGE } }, + ], + ); + // The mobile cinematic and the minimal looks draw no clouds. + const mobile = buildNavaraLook( + fakeView(0), + plugin([]), + navaraScenePlan('cinematic', { mobile: true }), + () => 0, + ); + assert.equal(mobile.hasClouds, false); + assert.equal(mobile.setCloudCoverage(0.5), false); +}); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index e0b6f5c18..c8d23ef16 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -2772,9 +2772,56 @@ export function createContractSuite( if (capabilities.visualStyles) checks.push(...styleChecks(engine.render, settle)); if (capabilities.bloom) checks.push(...bloomChecks(engine.render, settle)); + if (capabilities.cloudCover) + checks.push(...cloudCoverChecks(engine.render, settle)); return checks.map(([name, run]) => ({ name, run })); } +// The optional cloudCover capability (render.setCloudCover). +function cloudCoverChecks(render, settle) { + return [ + [ + 'render.setCloudCover (cloudCover): sets, copies, gives back, rejects bad values', + async () => { + const previous = render.cloudCover(); + try { + render.setCloudCover({ coverage: 0.7 }); + ensure(render.cloudCover().coverage === 0.7, 'coverage not set'); + render.cloudCover().coverage = 0; + ensure( + render.cloudCover().coverage === 0.7, + 'cloudCover() is a copy', + ); + await settle(2); + render.setCloudCover({}); + ensure(render.cloudCover().coverage === 0.7, 'omitted keeps it'); + render.setCloudCover({ coverage: null }); + ensure(render.cloudCover().coverage === null, 'null: the engine own'); + for (const bad of [-0.1, 1.2, Number.NaN, '0.5']) { + let error = null; + try { + render.setCloudCover({ coverage: bad }); + } catch (caught) { + error = caught; + } + ensure( + error instanceof TypeError, + `setCloudCover(${bad}) did not throw a TypeError`, + ); + } + ensure( + render.cloudCover().coverage === null, + 'after rejected values', + ); + } finally { + render.setCloudCover(previous); + await settle(1); + } + }, + ], + ]; +} + // The optional bloom capability (render.setBloom). function bloomChecks(render, settle) { return [ diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index ac3170f88..607118505 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -23,10 +23,12 @@ import { MODIFIERS, normalizeInputOptions, BLOOM_INITIAL, + CLOUD_COVER_INITIAL, SHARPEN_INITIAL, STYLE_INITIAL, createEngineEvent, mergeBloom, + mergeCloudCover, mergeSharpen, mergeStyle, } from '../contract.js'; @@ -231,6 +233,19 @@ function rectangleFromCartographics(points) { return { west, south, east, north }; } +// render.setCloudCover/cloudCover when asked; nothing otherwise. +function fakeCloudCover(enabled, record) { + if (!enabled) return {}; + let cover = { ...CLOUD_COVER_INITIAL }; + return { + setCloudCover(options) { + record('render.setCloudCover', [options]); + cover = mergeCloudCover(cover, options); + }, + cloudCover: () => ({ ...cover }), + }; +} + // render.setBloom/bloom when asked; nothing otherwise. function fakeBloom(enabled, record) { if (!enabled) return {}; @@ -279,7 +294,8 @@ function fakeStyles(visualStyles, record) { * longitude?: number, latitude?: number, altitude?: number, * container?: object, canvas?: object, nowMs?: number, * appClock?: import('../../time/clock.js').AppClock, - * visualStyles?: Record, bloom?: boolean }} [options] + * visualStyles?: Record, bloom?: boolean, + * cloudCover?: 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 @@ -288,7 +304,8 @@ function fakeStyles(visualStyles, record) { * `fake.advanceTime(ms)` (a given appClock keeps its own wall clock). * `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). + * `bloom: true` the optional bloom capability (render.setBloom); + * `cloudCover: true` the optional cloudCover one (render.setCloudCover). * @returns {import('../contract.js').GlobeEngine & { fake: object }} */ export function createFakeEngine(options = {}) { @@ -976,6 +993,7 @@ export function createFakeEngine(options = {}) { sharpen: () => ({ ...sharpen }), ...fakeStyles(options.visualStyles, record), ...fakeBloom(options.bloom, record), + ...fakeCloudCover(options.cloudCover, record), }, surface: { sampleHeight(position, exclude) {