mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 20:04:00 +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 { 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;
|
||||
|
||||
+302
-70
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}));
|
||||
@@ -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<string, number> }} 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 };
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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 {
|
||||
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<string, number>, set: (name: string, state: { intensity: number, uniforms: Record<string, number> }) => 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();
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 [
|
||||
|
||||
@@ -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<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
|
||||
* 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) {
|
||||
|
||||
Reference in new issue
Block a user