mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
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.
This commit is contained in:
1 parent
d8c63c69d8
commit
f0d38897f4
15 files changed
+1004
-79
No files matched your search
@@ -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:<PORT>/ [--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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
Vendored
+15
-6
@@ -1,7 +1,11 @@
|
|||||||
import { catalogControlServices } from './catalog.js';
|
import { catalogControlServices } from './catalog.js';
|
||||||
import { StyleManager } from '../ui/composition.js';
|
import { StyleManager } from '../ui/composition.js';
|
||||||
import { flyToAustin } from '../camera.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 { t } from '../i18n/index.js';
|
||||||
import { attachScenePart, currentSceneOf } from './sceneParts.js';
|
import { attachScenePart, currentSceneOf } from './sceneParts.js';
|
||||||
import { shellScenePart } from './shellPart.js';
|
import { shellScenePart } from './shellPart.js';
|
||||||
@@ -67,7 +71,9 @@ export async function createApplicationControls({
|
|||||||
);
|
);
|
||||||
// The previous multi-canvas weather compositor remains disabled. Cockpit
|
// The previous multi-canvas weather compositor remains disabled. Cockpit
|
||||||
// clouds use a separate, capped low-resolution GPU pass that never attaches
|
// 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;
|
const weatherEffects = null;
|
||||||
let cockpitCloudEffects = null;
|
let cockpitCloudEffects = null;
|
||||||
await attachScenePart(
|
await attachScenePart(
|
||||||
@@ -75,10 +81,13 @@ export async function createApplicationControls({
|
|||||||
{
|
{
|
||||||
name: 'cockpit-clouds',
|
name: 'cockpit-clouds',
|
||||||
attachScene(next, { defer: release }) {
|
attachScene(next, { defer: release }) {
|
||||||
if (!next.viewer) return;
|
const weatherService = operations.requests.weather;
|
||||||
const effects = initCockpitCloudEffects(next.viewer, {
|
let effects = null;
|
||||||
weatherService: operations.requests.weather,
|
if (next.viewer)
|
||||||
});
|
effects = initCockpitCloudEffects(next.viewer, { weatherService });
|
||||||
|
else if (engineCapabilities(next.engine).cloudCover)
|
||||||
|
effects = initEngineCockpitClouds(next.engine, { weatherService });
|
||||||
|
if (!effects) return;
|
||||||
cockpitCloudEffects = effects;
|
cockpitCloudEffects = effects;
|
||||||
release(() => {
|
release(() => {
|
||||||
if (cockpitCloudEffects === effects) cockpitCloudEffects = null;
|
if (cockpitCloudEffects === effects) cockpitCloudEffects = null;
|
||||||
|
|||||||
+302
-70
@@ -184,13 +184,111 @@ export function cockpitWeatherEnabledFromStoredValue(value) {
|
|||||||
return value === '1';
|
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
|
* 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.
|
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
|
||||||
*/
|
*/
|
||||||
class CockpitCloudEffectsController {
|
class CockpitCloudEffectsController {
|
||||||
constructor(viewer, { weatherService = applicationServices.weather } = {}) {
|
constructor(viewer, { weatherService = applicationServices.weather } = {}) {
|
||||||
this.weatherService = weatherService;
|
this.feed = new CockpitWeatherFeed(weatherService);
|
||||||
this.viewer = viewer;
|
this.viewer = viewer;
|
||||||
this.canvas = document.createElement('canvas');
|
this.canvas = document.createElement('canvas');
|
||||||
this.canvas.id = 'cockpit-cloud-effects';
|
this.canvas.id = 'cockpit-cloud-effects';
|
||||||
@@ -206,15 +304,10 @@ class CockpitCloudEffectsController {
|
|||||||
this.startupTimer = null;
|
this.startupTimer = null;
|
||||||
this.lastFrameMs = 0;
|
this.lastFrameMs = 0;
|
||||||
this.lastRefreshCheckMs = 0;
|
this.lastRefreshCheckMs = 0;
|
||||||
this.fetchedAt = 0;
|
|
||||||
this.anchor = null;
|
|
||||||
this.weather = null;
|
|
||||||
this.strength = 0;
|
this.strength = 0;
|
||||||
this.targetStrength = 0;
|
this.targetStrength = 0;
|
||||||
this.windDirectionDeg = 0;
|
this.windDirectionDeg = 0;
|
||||||
this.windStrength = 0;
|
this.windStrength = 0;
|
||||||
this.pending = null;
|
|
||||||
this.abort = null;
|
|
||||||
this.destroyed = false;
|
this.destroyed = false;
|
||||||
this.reducedMotion = window.matchMedia(
|
this.reducedMotion = window.matchMedia(
|
||||||
'(prefers-reduced-motion: reduce)',
|
'(prefers-reduced-motion: reduce)',
|
||||||
@@ -387,9 +480,7 @@ class CockpitCloudEffectsController {
|
|||||||
this.refreshTimer = null;
|
this.refreshTimer = null;
|
||||||
if (this.startupTimer !== null) window.clearTimeout(this.startupTimer);
|
if (this.startupTimer !== null) window.clearTimeout(this.startupTimer);
|
||||||
this.startupTimer = null;
|
this.startupTimer = null;
|
||||||
this.abort?.abort();
|
this.feed.cancel();
|
||||||
this.abort = null;
|
|
||||||
this.pending = null;
|
|
||||||
this.strength = 0;
|
this.strength = 0;
|
||||||
this.targetStrength = 0;
|
this.targetStrength = 0;
|
||||||
this.canvas.classList.remove('active');
|
this.canvas.classList.remove('active');
|
||||||
@@ -407,66 +498,33 @@ class CockpitCloudEffectsController {
|
|||||||
if (
|
if (
|
||||||
this.destroyed ||
|
this.destroyed ||
|
||||||
!this.enabled ||
|
!this.enabled ||
|
||||||
this.pending ||
|
this.feed.pending ||
|
||||||
this.suspended ||
|
this.suspended ||
|
||||||
!document.body.classList.contains('cockpit-mode')
|
!document.body.classList.contains('cockpit-mode')
|
||||||
) {
|
) {
|
||||||
return this.pending;
|
return this.feed.pending;
|
||||||
}
|
}
|
||||||
const point = this.cameraPoint();
|
const point = this.cameraPoint();
|
||||||
if (!point) return null;
|
if (!point) return null;
|
||||||
if (
|
if (!this.feed.due(point)) {
|
||||||
this.weather &&
|
this.applyWeather(this.feed.weather, point.altitudeM);
|
||||||
Date.now() - this.fetchedAt < WEATHER_REFRESH_MS &&
|
return this.feed.weather;
|
||||||
greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M
|
|
||||||
) {
|
|
||||||
this.applyWeather(this.weather, point.altitudeM);
|
|
||||||
return this.weather;
|
|
||||||
}
|
}
|
||||||
|
return this.feed.fetch(point, {
|
||||||
this.abort?.abort();
|
onWeather: (weather, status) => {
|
||||||
this.abort = new AbortController();
|
this.applyWeather(weather, point.altitudeM);
|
||||||
const pending = this.weatherService.getConditions(
|
this.canvas.dataset.sourceStatus = status;
|
||||||
point.latitude,
|
},
|
||||||
point.longitude,
|
onError: () => {
|
||||||
{ signal: this.abort.signal },
|
this.targetStrength = 0;
|
||||||
);
|
this.canvas.dataset.sourceStatus = 'unavailable';
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyWeather(weather, altitudeM) {
|
applyWeather(weather, altitudeM) {
|
||||||
const profile = deriveWeatherEffectProfile(weather);
|
const profile = deriveWeatherEffectProfile(weather);
|
||||||
const altitude = weatherAltitudeFactors(altitudeM);
|
this.targetStrength = cockpitCloudStrength(weather, altitudeM);
|
||||||
this.targetStrength = profile.available
|
|
||||||
? profile.cloud * altitude.cloud
|
|
||||||
: 0;
|
|
||||||
this.windDirectionDeg = profile.windDirectionDeg || 0;
|
this.windDirectionDeg = profile.windDirectionDeg || 0;
|
||||||
this.windStrength = profile.wind || 0;
|
this.windStrength = profile.wind || 0;
|
||||||
this.canvas.dataset.cloudStrength = this.targetStrength.toFixed(3);
|
this.canvas.dataset.cloudStrength = this.targetStrength.toFixed(3);
|
||||||
@@ -505,17 +563,7 @@ class CockpitCloudEffectsController {
|
|||||||
if (timeMs - this.lastRefreshCheckMs >= 1000) {
|
if (timeMs - this.lastRefreshCheckMs >= 1000) {
|
||||||
this.lastRefreshCheckMs = timeMs;
|
this.lastRefreshCheckMs = timeMs;
|
||||||
const point = this.cameraPoint();
|
const point = this.cameraPoint();
|
||||||
if (
|
if (point && this.feed.due(point)) void this.refresh();
|
||||||
point &&
|
|
||||||
cockpitWeatherRefreshDue({
|
|
||||||
nowMs: Date.now(),
|
|
||||||
fetchedAt: this.fetchedAt,
|
|
||||||
anchor: this.anchor,
|
|
||||||
point,
|
|
||||||
hasWeather: Boolean(this.weather),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
void this.refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const blend = this.reducedMotion ? 1 : 0.08;
|
const blend = this.reducedMotion ? 1 : 0.08;
|
||||||
@@ -572,7 +620,7 @@ class CockpitCloudEffectsController {
|
|||||||
enabled: this.enabled,
|
enabled: this.enabled,
|
||||||
active: this.canvas.classList.contains('active'),
|
active: this.canvas.classList.contains('active'),
|
||||||
strength: this.targetStrength,
|
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 },
|
renderSize: { width: this.canvas.width, height: this.canvas.height },
|
||||||
sourceStatus: this.canvas.dataset.sourceStatus || 'idle',
|
sourceStatus: this.canvas.dataset.sourceStatus || 'idle',
|
||||||
};
|
};
|
||||||
@@ -634,3 +682,187 @@ class CockpitCloudEffectsController {
|
|||||||
export function initCockpitCloudEffects(viewer, options) {
|
export function initCockpitCloudEffects(viewer, options) {
|
||||||
return new CockpitCloudEffectsController(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);
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { createFakeEngine } from './globe/testing/fakeEngine.js';
|
||||||
|
import * as geo from './geo/index.js';
|
||||||
import {
|
import {
|
||||||
initCockpitCloudEffects,
|
initCockpitCloudEffects,
|
||||||
cockpitCloudRenderSize,
|
cockpitCloudRenderSize,
|
||||||
cockpitWeatherRefreshDue,
|
cockpitWeatherRefreshDue,
|
||||||
cockpitWeatherEnabledFromStoredValue,
|
cockpitWeatherEnabledFromStoredValue,
|
||||||
|
COCKPIT_CLOUD_COVERAGE_MAX,
|
||||||
|
CockpitWeatherFeed,
|
||||||
|
cockpitCloudCoverage,
|
||||||
|
cockpitCloudStrength,
|
||||||
|
initEngineCockpitClouds,
|
||||||
} from './cockpitCloudEffects.js';
|
} from './cockpitCloudEffects.js';
|
||||||
|
|
||||||
test('cockpit cloud framebuffer stays low resolution on large displays', () => {
|
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);
|
||||||
|
}));
|
||||||
@@ -259,6 +259,16 @@
|
|||||||
* Cesium mode the app drives the scene's own bloom stage.
|
* Cesium mode the app drives the scene's own bloom stage.
|
||||||
* @property {() => { enabled: boolean, strength: number }} [bloom] The bloom
|
* @property {() => { enabled: boolean, strength: number }} [bloom] The bloom
|
||||||
* last set (a copy).
|
* 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<string, number> }} StyleState
|
* @typedef {{ intensity: number, uniforms: Record<string, number> }} StyleState
|
||||||
*
|
*
|
||||||
@@ -681,6 +691,10 @@ const OPTIONAL_SHAPE = {
|
|||||||
'render.setBloom': isFunction,
|
'render.setBloom': isFunction,
|
||||||
'render.bloom': isFunction,
|
'render.bloom': isFunction,
|
||||||
},
|
},
|
||||||
|
cloudCover: {
|
||||||
|
'render.setCloudCover': isFunction,
|
||||||
|
'render.cloudCover': isFunction,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The optional capability flags, e.g. 'visualStyles'. */
|
/** The optional capability flags, e.g. 'visualStyles'. */
|
||||||
@@ -843,3 +857,22 @@ export function mergeBloom(current, { enabled, strength } = {}) {
|
|||||||
strength: strength ?? current.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 };
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
INPUT_TYPES,
|
INPUT_TYPES,
|
||||||
MODIFIERS,
|
MODIFIERS,
|
||||||
BLOOM_INITIAL,
|
BLOOM_INITIAL,
|
||||||
|
CLOUD_COVER_INITIAL,
|
||||||
ENGINE_CAPABILITIES,
|
ENGINE_CAPABILITIES,
|
||||||
OPTIONAL_ENGINE_MEMBERS,
|
OPTIONAL_ENGINE_MEMBERS,
|
||||||
SHARPEN_INITIAL,
|
SHARPEN_INITIAL,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
engineCapabilities,
|
engineCapabilities,
|
||||||
isEngineEvent,
|
isEngineEvent,
|
||||||
mergeBloom,
|
mergeBloom,
|
||||||
|
mergeCloudCover,
|
||||||
mergeSharpen,
|
mergeSharpen,
|
||||||
mergeStyle,
|
mergeStyle,
|
||||||
} from './contract.js';
|
} from './contract.js';
|
||||||
@@ -476,3 +478,27 @@ test('the bloom capability: whole or absent, merged and checked', async (t) => {
|
|||||||
assert.equal(checks.length, 1);
|
assert.equal(checks.length, 1);
|
||||||
for (const check of checks) await t.test(check.name, () => check.run());
|
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());
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -9,10 +9,12 @@
|
|||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
import {
|
import {
|
||||||
BLOOM_INITIAL,
|
BLOOM_INITIAL,
|
||||||
|
CLOUD_COVER_INITIAL,
|
||||||
SHARPEN_INITIAL,
|
SHARPEN_INITIAL,
|
||||||
STYLE_INITIAL,
|
STYLE_INITIAL,
|
||||||
createEngineEvent,
|
createEngineEvent,
|
||||||
mergeBloom,
|
mergeBloom,
|
||||||
|
mergeCloudCover,
|
||||||
mergeSharpen,
|
mergeSharpen,
|
||||||
mergeStyle,
|
mergeStyle,
|
||||||
} from '../contract.js';
|
} from '../contract.js';
|
||||||
@@ -93,6 +95,7 @@ function rayRaisedEllipsoid({ origin, direction }, height) {
|
|||||||
* appClock?: import('../../time/clock.js').AppClock,
|
* appClock?: import('../../time/clock.js').AppClock,
|
||||||
* sharpen?: { set: (state: { enabled: boolean, amount: number }) => void, destroy: () => void },
|
* sharpen?: { set: (state: { enabled: boolean, amount: number }) => void, destroy: () => void },
|
||||||
* bloom?: { set: (state: { enabled: boolean, strength: 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<string, number>, set: (name: string, state: { intensity: number, uniforms: Record<string, number> }) => void, destroy: () => void },
|
* styles?: { names: readonly string[], uniformNames: (name: string) => string[], defaults: (name: string) => Record<string, number>, set: (name: string, state: { intensity: number, uniforms: Record<string, number> }) => void, destroy: () => void },
|
||||||
* }} options `hooks` are internals.js; `container` defaults to the
|
* }} options `hooks` are internals.js; `container` defaults to the
|
||||||
* canvas's parent; `sharpen` draws render.setSharpen (sharpen.js; without
|
* 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
|
* `styles` draws render.setStyle (stylePasses.js; without it the engine
|
||||||
* has no visualStyles capability) and the engine destroys it; `bloom`
|
* has no visualStyles capability) and the engine destroys it; `bloom`
|
||||||
* draws render.setBloom (bloom.js; without it no bloom capability), also
|
* 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;
|
* `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;
|
* `movers` is engine.movers on the view's three runtime (movers/index.js;
|
||||||
* without it createSet throws), `lines` engine.lines (lines/index.js;
|
* without it createSet throws), `lines` engine.lines (lines/index.js;
|
||||||
@@ -121,6 +126,7 @@ export function createNavaraEngine(
|
|||||||
sharpen: sharpenPort = null,
|
sharpen: sharpenPort = null,
|
||||||
styles: stylesPort = null,
|
styles: stylesPort = null,
|
||||||
bloom: bloomPort = null,
|
bloom: bloomPort = null,
|
||||||
|
clouds: cloudsPort = null,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
if (!view?.camera?.raw)
|
if (!view?.camera?.raw)
|
||||||
@@ -331,6 +337,20 @@ export function createNavaraEngine(
|
|||||||
bloom: () => ({ ...bloom }),
|
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 {
|
return {
|
||||||
kind: 'navara',
|
kind: 'navara',
|
||||||
host: { container: container ?? canvas.parentElement, canvas, size },
|
host: { container: container ?? canvas.parentElement, canvas, size },
|
||||||
@@ -361,6 +381,7 @@ export function createNavaraEngine(
|
|||||||
sharpen: () => ({ ...sharpen }),
|
sharpen: () => ({ ...sharpen }),
|
||||||
...styleRender,
|
...styleRender,
|
||||||
...bloomRender,
|
...bloomRender,
|
||||||
|
...cloudRender,
|
||||||
},
|
},
|
||||||
surface,
|
surface,
|
||||||
input: input.input,
|
input: input.input,
|
||||||
@@ -383,6 +404,7 @@ export function createNavaraEngine(
|
|||||||
sharpenPort?.destroy();
|
sharpenPort?.destroy();
|
||||||
stylesPort?.destroy();
|
stylesPort?.destroy();
|
||||||
bloomPort?.destroy();
|
bloomPort?.destroy();
|
||||||
|
cloudsPort?.destroy();
|
||||||
moversPort?.destroy();
|
moversPort?.destroy();
|
||||||
linesPort?.destroy();
|
linesPort?.destroy();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -291,10 +291,34 @@ test('setBloom hands changed states to the bloom port and draws a frame', () =>
|
|||||||
assert.equal(destroyed, 1);
|
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', () => {
|
test('without a styles port the engine has no visualStyles capability', () => {
|
||||||
const { engine } = setup();
|
const { engine } = setup();
|
||||||
assert.equal(engineCapabilities(engine).visualStyles, false);
|
assert.equal(engineCapabilities(engine).visualStyles, false);
|
||||||
assert.equal(engineCapabilities(engine).bloom, false);
|
assert.equal(engineCapabilities(engine).bloom, false);
|
||||||
|
assert.equal(engineCapabilities(engine).cloudCover, false);
|
||||||
assert.equal(engine.render.setStyle, undefined);
|
assert.equal(engine.render.setStyle, undefined);
|
||||||
assert.equal(engine.render.setBloom, undefined);
|
assert.equal(engine.render.setBloom, undefined);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { buildNavaraLook } from './look.js';
|
|||||||
import { createNavaraSharpen } from './sharpen.js';
|
import { createNavaraSharpen } from './sharpen.js';
|
||||||
import { createNavaraStyles } from './stylePasses.js';
|
import { createNavaraStyles } from './stylePasses.js';
|
||||||
import { createNavaraBloom } from './bloom.js';
|
import { createNavaraBloom } from './bloom.js';
|
||||||
|
import { createNavaraCloudCover } from './cockpitClouds.js';
|
||||||
import { retroShader } from '../../styles/retro.js';
|
import { retroShader } from '../../styles/retro.js';
|
||||||
import { nightVisionShader } from '../../styles/surveillance.js';
|
import { nightVisionShader } from '../../styles/surveillance.js';
|
||||||
import { thermalShader } from '../../styles/thermal.js';
|
import { thermalShader } from '../../styles/thermal.js';
|
||||||
@@ -159,6 +160,7 @@ export async function createNavaraGlobe({
|
|||||||
...(appClock ? { appClock } : {}),
|
...(appClock ? { appClock } : {}),
|
||||||
sharpen: createNavaraSharpen(view),
|
sharpen: createNavaraSharpen(view),
|
||||||
bloom: createNavaraBloom(view),
|
bloom: createNavaraBloom(view),
|
||||||
|
clouds: createNavaraCloudCover(look),
|
||||||
styles: createNavaraStyles(view, {
|
styles: createNavaraStyles(view, {
|
||||||
shaders: STYLE_SHADERS,
|
shaders: STYLE_SHADERS,
|
||||||
onStarsDimmed: (amount) => look.setStarsDimmed(amount),
|
onStarsDimmed: (amount) => look.setStarsDimmed(amount),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
cloudTerrainOffset,
|
cloudTerrainOffset,
|
||||||
landscapeSunTime,
|
landscapeSunTime,
|
||||||
CUMULUS_BASES,
|
CUMULUS_BASES,
|
||||||
|
LOCAL_CLOUD_COVERAGE,
|
||||||
globeLightingAt,
|
globeLightingAt,
|
||||||
starsVisibleAt,
|
starsVisibleAt,
|
||||||
sunDateStale,
|
sunDateStale,
|
||||||
@@ -205,12 +206,28 @@ export function buildNavaraLook(
|
|||||||
plan.meshes.find((desc) => Object.hasOwn(desc, 'stars'))?.stars
|
plan.meshes.find((desc) => Object.hasOwn(desc, 'stars'))?.stars
|
||||||
?.intensity ?? NAVARA_STARS_INTENSITY;
|
?.intensity ?? NAVARA_STARS_INTENSITY;
|
||||||
let starsDimmed = 0;
|
let starsDimmed = 0;
|
||||||
|
let cloudCoverage = LOCAL_CLOUD_COVERAGE;
|
||||||
return {
|
return {
|
||||||
handles,
|
handles,
|
||||||
frame,
|
frame,
|
||||||
resetDaylight() {
|
resetDaylight() {
|
||||||
daylightTime = null;
|
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. */
|
/** Dims the stars by `amount` (0 none, 1 hidden); true when it changed. */
|
||||||
setStarsDimmed(amount) {
|
setStarsDimmed(amount) {
|
||||||
const next = Math.min(1, Math.max(0, Number(amount) || 0));
|
const next = Math.min(1, Math.max(0, Number(amount) || 0));
|
||||||
|
|||||||
@@ -509,3 +509,33 @@ test('Anime fades the stars from the look own brightness and back', () => {
|
|||||||
);
|
);
|
||||||
assert.equal(photoreal.setStarsDimmed(1), false);
|
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);
|
||||||
|
});
|
||||||
@@ -2772,9 +2772,56 @@ export function createContractSuite(
|
|||||||
if (capabilities.visualStyles)
|
if (capabilities.visualStyles)
|
||||||
checks.push(...styleChecks(engine.render, settle));
|
checks.push(...styleChecks(engine.render, settle));
|
||||||
if (capabilities.bloom) checks.push(...bloomChecks(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 }));
|
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).
|
// The optional bloom capability (render.setBloom).
|
||||||
function bloomChecks(render, settle) {
|
function bloomChecks(render, settle) {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ import {
|
|||||||
MODIFIERS,
|
MODIFIERS,
|
||||||
normalizeInputOptions,
|
normalizeInputOptions,
|
||||||
BLOOM_INITIAL,
|
BLOOM_INITIAL,
|
||||||
|
CLOUD_COVER_INITIAL,
|
||||||
SHARPEN_INITIAL,
|
SHARPEN_INITIAL,
|
||||||
STYLE_INITIAL,
|
STYLE_INITIAL,
|
||||||
createEngineEvent,
|
createEngineEvent,
|
||||||
mergeBloom,
|
mergeBloom,
|
||||||
|
mergeCloudCover,
|
||||||
mergeSharpen,
|
mergeSharpen,
|
||||||
mergeStyle,
|
mergeStyle,
|
||||||
} from '../contract.js';
|
} from '../contract.js';
|
||||||
@@ -231,6 +233,19 @@ function rectangleFromCartographics(points) {
|
|||||||
return { west, south, east, north };
|
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.
|
// render.setBloom/bloom when asked; nothing otherwise.
|
||||||
function fakeBloom(enabled, record) {
|
function fakeBloom(enabled, record) {
|
||||||
if (!enabled) return {};
|
if (!enabled) return {};
|
||||||
@@ -279,7 +294,8 @@ function fakeStyles(visualStyles, record) {
|
|||||||
* longitude?: number, latitude?: number, altitude?: number,
|
* longitude?: number, latitude?: number, altitude?: number,
|
||||||
* container?: object, canvas?: object, nowMs?: number,
|
* container?: object, canvas?: object, nowMs?: number,
|
||||||
* appClock?: import('../../time/clock.js').AppClock,
|
* appClock?: import('../../time/clock.js').AppClock,
|
||||||
* visualStyles?: Record<string, string[]>, bloom?: boolean }} [options]
|
* visualStyles?: Record<string, string[]>, bloom?: boolean,
|
||||||
|
* cloudCover?: boolean }} [options]
|
||||||
* Canvas size in CSS px, angles in radians; `container`/`canvas` stand in
|
* 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
|
* 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
|
* 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).
|
* `fake.advanceTime(ms)` (a given appClock keeps its own wall clock).
|
||||||
* `visualStyles` ({ style: its uniform names }) adds the optional
|
* `visualStyles` ({ style: its uniform names }) adds the optional
|
||||||
* visualStyles capability (render.setStyle), each uniform starting at 0;
|
* 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 }}
|
* @returns {import('../contract.js').GlobeEngine & { fake: object }}
|
||||||
*/
|
*/
|
||||||
export function createFakeEngine(options = {}) {
|
export function createFakeEngine(options = {}) {
|
||||||
@@ -976,6 +993,7 @@ export function createFakeEngine(options = {}) {
|
|||||||
sharpen: () => ({ ...sharpen }),
|
sharpen: () => ({ ...sharpen }),
|
||||||
...fakeStyles(options.visualStyles, record),
|
...fakeStyles(options.visualStyles, record),
|
||||||
...fakeBloom(options.bloom, record),
|
...fakeBloom(options.bloom, record),
|
||||||
|
...fakeCloudCover(options.cloudCover, record),
|
||||||
},
|
},
|
||||||
surface: {
|
surface: {
|
||||||
sampleHeight(position, exclude) {
|
sampleHeight(position, exclude) {
|
||||||
|
|||||||
Reference in new issue
Block a user