Move scene effects and imported geometry behind the globe contract

Move Cesium viewer, map, sky, projection, trail and sonar internals into
its engine. Drive styles, bloom, sharpening and contact sonar through
engine.render. Count camera-input suspensions across all three engines
and migrate the feature callers. Preserve Cesium's effects and input
behaviour; fix transit lens clicks on Cesium and make zero sharpening on
Navara disable sharpening, as it does on Cesium.

Parse imported files into plain records and draw them through movers,
lines, ground and imagery on both engines. Follow Cesium's KML style
precedence, preserve local KMZ images and frame the drawable records.
Open overlays use imagery when the engine has no native map-overlay API.
Run every imported-display case on fake and headless Cesium engines.

The shared import path retains these known differences from Cesium's old
DataSources: wall fills, extruded prisms and gx:LatLonQuad are undrawn;
raised polygon fills drape while outlines retain altitude; GroundOverlay
rotation, altitude and RGB tint are not applied; rhumb lines become great
circles. Ground heights use the loaded surface synchronously instead of
waiting for most-detailed sampling. Supported KMZ ground overlays now
draw on Navara too. Document the limits rather than claiming full parity.

Combine batches B and C because their disjoint path manifests cannot
place the shared boundary and format files in a later commit while
keeping B independently valid. This combined commit owns the shared
docs, package boundaries, format scope, locale catalogs and coupling
baseline. Remove five obsolete import strings from all eleven catalogs.
Make the load-time source scan include new files and skip deleted paths
so the same checks work before and after recording the moves.

Coupling: Navara renderer lines fall from 1695 to 1294, Cesium import
files from 25 to 9, viewer lines from 1177 to 1026, SDK loads from 4 to 3
and engine-name lines from 62 to 42. No count grows.

The full unit suite and the boundary, format and coupling checks pass.
Browser validation remains to be run.

Browser checks to run
GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291
node scripts/qa-contract-suite.mjs http://localhost:4291
node scripts/qa-navara-visual-styles.mjs http://localhost:4291
node scripts/qa-navara-bloom.mjs http://localhost:4291
node scripts/qa-navara-sonar.mjs http://localhost:4291
QA_BASE_URL=http://localhost:4291 node scripts/qa-visual-effects.mjs
QA_BASE_URL=http://localhost:4291 node scripts/qa-zoom-out-guard.mjs
node scripts/qa-sky-model-lighting.mjs --url http://localhost:4291
node scripts/qa-geo-import.mjs http://localhost:4291
node scripts/qa-geo-import-regressions.mjs http://localhost:4291
node scripts/qa-geo-import-large.mjs http://localhost:4291
node scripts/qa-navara-geo-import.mjs http://localhost:4291

Use the keyless server for every check. Photoreal-only cases remain
unverified; do not enable paid tiles to run them.
This commit is contained in:
German Khokhlov committed 2026-11-07 12:45:21 +04:00
1 parent c079b329d0
commit 97c4dc76ce
130 files changed
+4763 -3495

No files matched your search

+4 -3
View File
@@ -9,7 +9,7 @@ import {
installFreeLookHeightLimit,
installGlobeInViewRecovery,
installTrackpadPinchZoom,
} from './viewer.js';
} from '../globe/cesium/viewer.js';
import * as globeInView from '../globeInView.js';
const { FREE_LOOK_MAX_HEIGHT_METERS } = globeInView;
@@ -300,8 +300,9 @@ test('the separate viewer export imports without constructing a browser viewer',
});
test('the viewer gets the starry sky as an option, which keeps the Sun and Moon', async () => {
const { applicationViewerOptions } = await import('./viewer.js');
const { StarrySky } = await import('../sky/starrySky.js');
const { applicationViewerOptions } =
await import('../globe/cesium/viewer.js');
const { StarrySky } = await import('../globe/cesium/starrySky.js');
const creditContainer = {};
const options = applicationViewerOptions(creditContainer);
// CesiumWidget skips scene.sun and scene.moon when skyBox is false.
+1 -1
View File
@@ -1,7 +1,7 @@
import { catalogControlServices } from './catalog.js';
import { StyleManager } from '../ui/composition.js';
import { flyToAustin } from '../camera.js';
import { installZoomOutGuard } from './zoomOutGuard.js';
import { installZoomOutGuard } from '../globe/cesium/zoomOutGuard.js';
import {
initCockpitCloudEffects,
initEngineCockpitClouds,
+1 -1
View File
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
import * as Cesium from 'cesium';
import '../testSupport/stubMgrs.mjs';
import { installVueDom } from '../testSupport/vueDom.mjs';
import { MAXIMUM_ZOOM_DISTANCE_M } from './zoomOutGuard.js';
import { MAXIMUM_ZOOM_DISTANCE_M } from '../globe/cesium/zoomOutGuard.js';
const { createApplicationControls } = await import('./controls.js');
+3 -6
View File
@@ -1,6 +1,5 @@
import { LayerLifecycle } from '../data/lifecycle.js';
import { LayerPresentation } from './layerPresentation.js';
import { createCyberSonarScene } from '../cyberSonarScene.js';
import { createCyberSonarEngineScene } from '../cyberSonarEngine.js';
import { panelAwareVisibility } from './embed.js';
import { attachScenePart, currentSceneOf } from './sceneParts.js';
@@ -105,11 +104,9 @@ export function createApplicationData({
nextCatalog.weatherClock,
);
styleManager.attachDataManager(dataManager);
// The sonar treats contacts on the viewer, or through an engine
// that draws it (render.setContactSonar, Navara).
if (next.viewer)
release(createCyberSonarScene(next.viewer, dataManager));
else release(createCyberSonarEngineScene(next.engine, dataManager));
// The engine draws the sonar's contact treatment
// (render.setContactSonar).
release(createCyberSonarEngineScene(next.engine, dataManager));
if (!binding) return;
// A teardown or switch during the attachment must not wait for slow
// layer attachments: it ends this wait, and the detach releases the
+2 -23
View File
@@ -15,18 +15,6 @@ import {
import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js';
import { navaraDrawSurface } from '../annotations/drawInputNavara.js';
/**
* An imported file's data source drawn on `engine` with Navara's `views`
* (UTILITIES ▸ Import); the drawing loads on first use, like the layers'
* Navara renderers.
*/
async function drawImported(engine, views, dataSource) {
const { drawImportedSource, measureImportImages } =
await import('../layers/shared/render/navaraStory.js');
const sizes = await measureImportImages(dataSource);
return drawImportedSource(engine, views, dataSource, { sizes });
}
/** The Navara globe module, loaded on demand. */
const loadNavaraGlobeModule = async () =>
(await import('../globe/navara/index.js')).createNavaraGlobe;
@@ -165,8 +153,8 @@ export async function createNavaraScene({
/**
* The Navara tool backend (src/app/tools.js) of one Navara scene: where the
* annotation board, Draw and Import draw (on the engine and Navara's view;
* null without the view), the Recent Imagery panel's globe, the render
* annotation board and Draw draw (on the engine and Navara's view; null
* without the view), the Recent Imagery panel's globe, the render
* governor on the engine, and this engine's own parts: the daylight of a
* restored view and the engine notice.
*/
@@ -181,15 +169,6 @@ export function navaraToolBackend({ engine }) {
// The session's tool on the engine's input and preview
// (src/annotations/drawInputNavara.js).
draw: () => ({ surface: navaraDrawSurface(engine) }),
// The session's files drawn by src/layers/shared/render/navaraStory.js;
// what Navara leaves out of them is reported in the control's hint.
geoImport: () =>
drawn
? {
engine,
draw: (dataSource) => drawImported(engine, views, dataSource),
}
: null,
recentImagery: () => ({ engine }),
// Holds drive engine.render.setContinuous; requests go to the engine. A
// parked engine uninstalls it (createNavaraScene suspend).
+3 -3
View File
@@ -89,12 +89,12 @@ test('Navara draws the page session board and its switch takes only the drawing'
second.detach();
lifetime.abort();
assert.equal(window.__gevAnnotations, undefined);
// Without the Navara view there is nothing to draw on: no board, no
// Import.
// Without the Navara view there is nothing to draw on: no board. Import
// draws through the engine contract, outside the backend.
const viewless = navaraToolBackend({
engine: createFakeEngine({ kind: 'navara' }),
});
assert.equal(viewless.board(), null);
assert.equal(viewless.geoImport(), null);
assert.equal('geoImport' in viewless, false);
assert.deepEqual(Object.keys(viewless.recentImagery()), ['engine']);
});
+3 -4
View File
@@ -1,15 +1,14 @@
import { releaseCameraVerbs } from '../cameraVerbs.js';
import { createApplicationOperations } from './operations.js';
import * as Cesium from 'cesium';
import {
createApplicationViewer,
installFreeLookHeightLimit,
installTrackpadPinchZoom,
} from '../app/viewer.js';
loadCesiumPhotorealTileset,
} from '../globe/cesium/viewer.js';
import { registerDataCredits } from '../data/dataCredits.js';
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
import { MapStackController } from '../mapStackController.js';
import { loadPhotorealisticTileset } from '../mapStartup.js';
import { initLogoGaze } from '../logoGaze.js';
import {
uninstallRenderGovernor,
@@ -174,7 +173,7 @@ async function createRenderer({
googleApiKey || googleTokens || cesiumToken
? t('Loading Google 3D Tiles...')
: t('Loading the keyless globe...');
const photoreal = await loadPhotorealisticTileset(Cesium, {
const photoreal = await loadCesiumPhotorealTileset({
googleApiKey,
googleTokens,
cesiumToken,
+7 -7
View File
@@ -19,7 +19,7 @@ import {
isScopeMaskEnabled,
} from '../scopeMask.js';
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
import { installGlobeInViewRecovery } from './viewer.js';
import { installGlobeInViewRecovery } from '../globe/cesium/viewer.js';
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
import {
installRenderGovernor,
@@ -272,7 +272,6 @@ function cesiumToolBackend({ engine, tileset }) {
viewer,
board: () => ({ viewer, engine, tileset }),
draw: () => ({ viewer, engine }),
geoImport: () => ({ viewer, engine }),
// The tileset lets the layer drape while the globe is hidden.
recentImagery(layer) {
layer.attachTileset(tileset);
@@ -336,8 +335,8 @@ function cesiumToolBackend({ engine, tileset }) {
* Import, the Recent Imagery box and panel, the render governor, the scope
* mask, hidden-tab handling and voice, and the engine's own tools. The
* engine kind's backend (TOOL_BACKENDS) says where the shared tools draw:
* `board()`, `draw()` and `geoImport()` give their attachScene arguments
* (null: not on this engine), `recentImagery(layer)` the panel's globe,
* `board()` and `draw()` give their attachScene arguments (null: not on
* this engine), `recentImagery(layer)` the panel's globe,
* `installGovernor()` the governor's; `attach(context, attachment)` adds
* the engine's own tools, `suspend(hidden, controls)` its hidden-tab
* handling and `attachViews(context, attachment)` its embedded views. The
@@ -369,8 +368,9 @@ async function attachEngineTools(
defer,
);
attachDraw(backend.draw, { layerSession, annotations, styleManager }, defer);
// Import draws through the globe contract, the same on every engine.
attachGeoImport(
backend.geoImport(),
{ engine },
{ layerSession, styleManager, operations, mapStackController },
defer,
);
@@ -513,8 +513,8 @@ function attachDraw(
}
/**
* UTILITIES ▸ Import on this globe (`importScene`, null: not on it): the
* next globe reads the session's files again. The drop listeners belong to
* UTILITIES ▸ Import on this globe (`importScene`: its engine): the next
* globe reads the session's files again. The drop listeners belong to
* this globe. Returns the control (null on a page without it).
*/
function attachGeoImport(
+1 -1
View File
@@ -20,7 +20,7 @@ import {
globeFrameShare as poseFrameShare,
globeOutOfFrame as poseOutOfFrame,
} from './globeInView.js';
import { cesiumFramePose } from './app/viewer.js';
import { cesiumFramePose } from './globe/cesium/viewer.js';
import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js';
import { setScopeMaskEnabled } from './scopeMask.js';
+1 -1
View File
@@ -4,7 +4,7 @@ import {
cesiumIcrfToFixed,
projectCelestialPoint,
flyCelestialViewer,
} from './celestialRing/render/cesium.js';
} from './globe/cesium/celestial.js';
import { createCelestialEphemeris } from './celestialRing/ephemeris.js';
import { appClock } from './time/clock.js';
import { governorRequestRender } from './renderGovernor.js';
+20 -2
View File
@@ -163,11 +163,29 @@ export function createCyberSonarSampler(
root = globalThis.document?.documentElement,
) {
if (now === undefined) now = globalThis.performance?.now?.() ?? 0;
const settings = readCyberSonarSettings(root);
const { range, opacity, sector } = readCyberSonarSettings(root);
return cyberSonarSampler(width, height, {
angleDeg: cyberSonarAngleDeg(now),
range,
opacity,
sector,
});
}
/**
* The sampler of one paint from a sonar state (the globe contract's
* ContactSonar: the sweep's angle and the range, opacity and sector
* settings), as an engine that is handed the state samples it.
*/
export function cyberSonarSampler(
width,
height,
{ angleDeg: angle, range, opacity, sector },
) {
const settings = { range, opacity, sector };
const floor = settings.opacity / 100;
const radius = (Math.min(width * 0.4, height * 0.52) * settings.range) / 100;
const radiusSquared = radius * radius;
const angle = cyberSonarAngleDeg(now);
const feather = Math.min(CYBER_SONAR_FEATHER_DEG, settings.sector * 0.42);
return {
floor,
+96
View File
@@ -2,8 +2,12 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { transformWithEsbuild } from 'vite';
import * as Cesium from 'cesium';
import { BillboardCollection, Color, PointPrimitive } from 'cesium';
import {
createCyberSonarSampler,
cyberSonarSampler,
isCyberContactSonarActive,
syncCyberSonarAnimation,
applyCyberSonarPrimitive,
cyberSonarBaseAlpha,
@@ -368,3 +372,95 @@ test('the selected Cyber theme seeds and releases the compositor sweep phase', (
assert.equal(values.has('--cyber-sonar-angle'), false);
assert.equal(values.has('--cyber-sonar-phase-delay'), false);
});
test('frame sampler matches reference without per-contact settings access', () => {
let reads = 0;
const values = {
cyberSonarOpacity: '62',
cyberSonarSector: '40',
cyberSonarRange: '110',
};
const root = {
dataset: new Proxy(values, {
get(o, k) {
reads++;
return o[k];
},
}),
};
const frame = createCyberSonarSampler(1000, 800, 1234, root),
once = reads;
for (let x = 0; x < 1000; x += 33)
for (let y = 0; y < 800; y += 31) frame.at(x, y);
assert.equal(reads, once);
for (let x = 0; x < 1000; x += 33)
for (let y = 0; y < 800; y += 31)
assert.ok(
Math.abs(
frame.at(x, y) -
cyberSonarIntensityAtScreenPoint(x, y, 1000, 800, 1234, {
dataset: values,
}),
) < 1e-10,
);
});
test('a sampler from a sonar state matches the one from the settings it carries', () => {
const root = {
dataset: {
cyberSonarOpacity: '62',
cyberSonarSector: '40',
cyberSonarRange: '110',
},
};
const fromSettings = createCyberSonarSampler(1000, 800, 1234, root);
const fromState = cyberSonarSampler(1000, 800, {
angleDeg: cyberSonarAngleDeg(1234),
range: 110,
opacity: 62,
sector: 40,
});
assert.equal(fromState.floor, fromSettings.floor);
assert.equal(fromState.neutral, fromSettings.neutral);
for (let x = 0; x < 1000; x += 37)
for (let y = 0; y < 800; y += 29)
assert.equal(fromState.at(x, y), fromSettings.at(x, y));
});
test('models retain render-pass alpha and owner refresh survives teardown', () => {
const frame = createCyberSonarSampler(1000, 800, 0);
const model = { color: new Cesium.Color(1, 0.5, 0.2, 1) };
applyCyberSonarPrimitive(model, 0.5, true, frame, 'color', true);
assert.equal(model.color.alpha, 1);
assert.ok(model.color.red < 1);
const owner = new Cesium.Color(0.2, 0.3, 0.4, 0.7);
model.color = owner;
applyCyberSonarPrimitive(model, 1, false);
assert.equal(model.color, owner);
const unset = {};
applyCyberSonarPrimitive(unset, 0.5, true, frame, 'color', true);
applyCyberSonarPrimitive(unset, 1, false);
assert.equal(unset.color, undefined);
});
test('removed CSS mode cannot disable GPU contacts; unsupported adapters fail closed', () => {
const root = { dataset: { uiTheme: 'cyber' }, style: { setProperty() {} } };
const options = {
root,
body: { classList: { contains: () => false } },
hud: { classList: { contains: () => true } },
reducedMotion: false,
};
root.dataset.cyberSonarMode = 'css';
assert.equal(applyCyberSonarSettings({ mode: 'css' }, root).mode, undefined);
assert.equal(root.dataset.cyberSonarMode, undefined);
assert.equal(isCyberSonarActive(options), true);
assert.equal(isCyberContactSonarActive(options), true);
assert.equal(
applyCyberSonarSettings({ mode: 'invalid' }, root).mode,
undefined,
);
assert.equal(isCyberContactSonarActive(options), true);
root.dataset.cyberSonarGpu = 'unsupported';
assert.equal(isCyberContactSonarActive(options), false);
});
+39 -10
View File
@@ -1,10 +1,13 @@
// The Cyber HUD sonar's contact treatment on a globe engine that draws it
// itself (render.setContactSonar, the `contactSonar` capability: Navara's
// sprite and point movers), the counterpart of src/cyberSonarScene.js on a
// Cesium viewer. Each drawn frame hands the engine the theme's state, the
// sweep's settings and its angle on the shared clock (the CSS sweep's), so
// the contacts and the rings turn together; while the sweep runs over
// visible contacts a parked scene draws at the fleet cadence, as on Cesium.
// The Cyber HUD sonar's contact treatment on the globe engine, which draws
// it (render.setContactSonar, the `contactSonar` capability: Navara's
// sprite and point movers, Cesium's native draw commands and models). Each
// drawn frame hands the engine the theme's state, the sweep's settings and
// its angle on the shared clock (the CSS sweep's), so the contacts and the
// rings turn together; while the sweep runs over visible contacts a parked
// scene draws at the fleet cadence. An engine that can lose its treatment
// (Cesium's command adapter is tested against one Cesium version) says so
// through contactSonarDiagnostics, and the theme then leaves native
// contacts as they are.
import { engineCapabilities } from './globe/contract.js';
import {
@@ -40,6 +43,9 @@ const contactCount = (engine) =>
.filter((set) => set.kind !== 'model')
.reduce((count, set) => count + set.count, 0);
const UNSUPPORTED_MESSAGE =
'Contact GPU unavailable. Native contacts remain visible.';
/**
* @param {import('./globe/contract.js').GlobeEngine} engine
* @param {{ subscribe?: Function }} [manager] The layer manager: a
@@ -52,6 +58,7 @@ export function createCyberSonarEngineScene(
{
now = () => performance.now(),
root = globalThis.document?.documentElement,
documentRef = globalThis.document,
setTimer = (callback, ms) => setTimeout(callback, ms),
clearTimer = (id) => clearTimeout(id),
} = {},
@@ -59,17 +66,39 @@ export function createCyberSonarEngineScene(
if (!engineCapabilities(engine).contactSonar) return () => {};
let timer = null;
let disposed = false;
// Not contract: what the engine's treatment holds (Cesium's), or null.
const diagnostics = () => engine.contactSonarDiagnostics?.() ?? null;
// The Cyber contact theme counts on the contact adapter (cyberSonar.js
// isCyberContactThemeActive): this engine has one.
if (root?.dataset) root.dataset.cyberSonarGpu = 'supported';
// isCyberContactThemeActive). Returns whether its support changed.
const syncSupport = () => {
const supported = diagnostics()?.supported !== false;
const support = supported ? 'supported' : 'unsupported';
const status = documentRef?.getElementById?.('cyber-sonar-status');
const message = supported ? '' : UNSUPPORTED_MESSAGE;
if (status && status.textContent !== message) status.textContent = message;
if (!root?.dataset || root.dataset.cyberSonarGpu === support) return false;
root.dataset.cyberSonarGpu = support;
return true;
};
syncSupport();
const removeRender = engine.frames.preRender.addEventListener(() => {
if (isCyberSonarActive({ root })) syncCyberSonarAnimation(root);
engine.render.setContactSonar(cyberSonarEngineState(now(), root));
});
// Contacts the sweep passes over: the engine's movers, and what its own
// treatment drew (Cesium's tinted models and visible native batches).
const contactsShown = () => {
if (contactCount(engine)) return true;
const drawn = diagnostics();
return Boolean(drawn?.contacts || drawn?.gpu?.visibleCommands);
};
const removePostRender = engine.frames.postRender.addEventListener(() => {
// A treatment lost while the scene is parked (Sonar OFF, reduced
// motion) draws once more, so the native contacts come back.
if (syncSupport()) engine.render.request('cyber-sonar');
if (timer !== null) clearTimer(timer);
timer = null;
if (isCyberContactSonarActive({ root }) && contactCount(engine))
if (isCyberContactSonarActive({ root }) && contactsShown())
timer = setTimer(() => {
timer = null;
if (!disposed && isCyberContactSonarActive({ root }))
+52
View File
@@ -114,3 +114,55 @@ test('each drawn frame hands the engine the theme, the settings and the sweep an
'nothing after dispose',
);
}));
test("an engine's own contacts keep the sweep drawing, and a lost treatment wakes the scene once", () =>
withCyberPage(async ({ root }) => {
const engine = createFakeEngine({ kind: 'cesium', contactSonar: true });
// Cesium's report (src/globe/cesium/sonar.js), not contract.
const report = {
supported: true,
contacts: 0,
gpu: { visibleCommands: 0 },
};
engine.contactSonarDiagnostics = () => report;
const status = { textContent: '' };
const timers = new Map();
let nextTimer = 1;
const dispose = createCyberSonarEngineScene(engine, null, {
root,
documentRef: {
getElementById: (id) => (id === 'cyber-sonar-status' ? status : null),
},
setTimer: (callback, ms) => {
timers.set(nextTimer, [callback, ms]);
return nextTimer++;
},
clearTimer: (id) => timers.delete(id),
});
assert.equal(root.dataset.cyberSonarGpu, 'supported');
engine.fake.frame();
assert.equal(timers.size, 0, 'culled batches must not schedule a draw');
report.gpu.visibleCommands = 1;
engine.fake.frame();
assert.equal(timers.size, 1, 'a visible batch keeps the sweep moving');
report.gpu.visibleCommands = 0;
report.contacts = 2;
engine.fake.frame();
assert.equal(timers.size, 1, 'and so does a tinted model');
report.contacts = 0;
const before = engine.fake.renderRequests;
report.supported = false;
engine.fake.frame();
assert.equal(root.dataset.cyberSonarGpu, 'unsupported');
assert.match(status.textContent, /Native contacts remain visible/);
assert.equal(
engine.fake.renderRequests,
before + 1,
'support loss explicitly wakes the parked scene',
);
engine.fake.frame();
assert.equal(engine.render.contactSonar().enabled, false);
assert.equal(engine.fake.renderRequests, before + 1, 'and only once');
dispose();
assert.equal(root.dataset.cyberSonarGpu, undefined);
}));
-211
View File
@@ -1,211 +0,0 @@
import * as Cesium from 'cesium';
import { createCyberSonarGpu } from './cyberSonarGpu.js';
import {
applyCyberSonarPrimitive,
createCyberSonarSampler,
isCyberContactThemeActive,
isCyberContactSonarActive,
isCyberSonarActive,
readCyberSonarSettings,
cyberSonarAngleDeg,
syncCyberSonarAnimation,
CYBER_SONAR_RENDER_INTERVAL_MS,
} from './cyberSonar.js';
const alive = (value) => value && !value.isDestroyed?.();
const diagnostics = new WeakMap();
export const getCyberSonarSceneDiagnostics = (viewer) => ({
...diagnostics.get(viewer),
});
export function visitCyberSonarModels(primitive, visit, visible = true) {
if (!alive(primitive)) return;
visible = visible && primitive.show !== false;
if (primitive instanceof Cesium.PrimitiveCollection) {
for (let i = 0; i < primitive.length; i++)
visitCyberSonarModels(primitive.get(i), visit, visible);
} else if (primitive instanceof Cesium.Model) {
visit(primitive, primitive, 'model', visible && primitive.ready);
}
}
/** Native dots, icons and glyphs use GPU draw-command treatment. Models retain
* their existing reversible CPU color treatment without walking native records.
*/
export function createCyberSonarScene(viewer, manager) {
const scene = viewer?.scene;
if (
!scene?.postUpdate?.addEventListener ||
!scene?.preRender?.addEventListener
)
return () => {};
const tracked = new Map();
const matrix = new Cesium.Matrix4();
const position = new Cesium.Cartesian3();
let epoch = 0,
timer = null,
disposed = false;
let sampler, width, height, active, count;
const stats = { contacts: 0, updateMs: 0, frames: 0 };
const root = globalThis.document?.documentElement;
const gpu = createCyberSonarGpu(scene, () => ({
...readCyberSonarSettings(),
enabled: isCyberContactThemeActive(),
active: isCyberContactSonarActive(),
angle: cyberSonarAngleDeg(performance.now()),
}));
stats.gpu = gpu.stats;
let gpuSupported = gpu.stats.supported;
diagnostics.set(viewer, stats);
const restore = (visual, state) => {
if (!alive(state.owner)) return;
for (const key of state.keys)
applyCyberSonarPrimitive(visual, 1, false, undefined, key);
};
const visit = (visual, owner, kind, visible) => {
let state = tracked.get(visual);
if (!visible) {
if (state) {
restore(visual, state);
tracked.delete(visual);
}
return;
}
let p = visual.position;
if (kind === 'model')
p = Cesium.Matrix4.getTranslation(visual.modelMatrix, position);
else if (
p &&
owner.modelMatrix &&
!Cesium.Matrix4.equals(owner.modelMatrix, Cesium.Matrix4.IDENTITY)
)
p = Cesium.Matrix4.multiplyByPoint(owner.modelMatrix, p, position);
if (!p) return;
const w = matrix[3] * p.x + matrix[7] * p.y + matrix[11] * p.z + matrix[15];
const x =
(matrix[0] * p.x + matrix[4] * p.y + matrix[8] * p.z + matrix[12]) / w;
const y =
(matrix[1] * p.x + matrix[5] * p.y + matrix[9] * p.z + matrix[13]) / w;
if (w <= 0 || Math.abs(x) > 1.1 || Math.abs(y) > 1.1) return;
if (!state) {
state = {
owner,
keys:
kind === 'label'
? ['fillColor', 'outlineColor', 'backgroundColor']
: kind === 'marker' && visual.outlineColor
? ['color', 'outlineColor']
: ['color'],
epoch,
};
tracked.set(visual, state);
}
state.epoch = epoch;
const factor = active
? sampler.at((x + 1) * width * 0.5, (1 - y) * height * 0.5)
: sampler.neutral;
for (const key of state.keys) {
if (key === 'outlineColor' && visual.outlineWidth === 0) continue;
if (key === 'backgroundColor' && !visual.showBackground) continue;
applyCyberSonarPrimitive(
visual,
kind === 'label' ? sampler.label(factor) : factor,
true,
sampler,
key,
kind === 'model',
);
}
count++;
};
const update = () => {
if (disposed) return;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
if (!isCyberContactThemeActive()) {
for (const [visual, state] of tracked) restore(visual, state);
tracked.clear();
stats.contacts = 0;
stats.updateMs = 0;
return;
}
const started = performance.now();
width = scene.canvas.clientWidth;
height = scene.canvas.clientHeight;
sampler = createCyberSonarSampler(width, height, started);
active = isCyberContactSonarActive();
Cesium.Matrix4.multiply(
scene.camera.frustum.projectionMatrix,
scene.camera.viewMatrix,
matrix,
);
epoch++;
count = 0;
visitCyberSonarModels(scene.primitives, visit);
for (const [visual, state] of tracked)
if (state.epoch !== epoch) {
restore(visual, state);
tracked.delete(visual);
}
stats.contacts = count;
stats.updateMs = performance.now() - started;
stats.frames++;
// No contacts, no scanner-owned render demand. In continuous scenes the
// next rendered frame cancels this timer; parked scenes refresh at fleet
// cadence. Idle update-only ticks must not continually postpone the timer.
};
let removeRender;
// Aircraft fleet writers run in preRender, while tracked models and entity
// visualizers update earlier. Compose AFTER all those owners and BEFORE
// Cesium uploads primitive attributes for this draw. Requeue at postUpdate
// so writers installed later (enable/re-enable/track) cannot overwrite sonar.
// This only reorders one callback; the contact walk runs once per actual draw.
const removeUpdate = scene.postUpdate.addEventListener(() => {
if (isCyberSonarActive()) syncCyberSonarAnimation();
const supportChanged = gpuSupported !== gpu.stats.supported;
gpuSupported = gpu.stats.supported;
const support = gpu.stats.supported ? 'supported' : 'unsupported';
if (root?.dataset && root.dataset.cyberSonarGpu !== support)
root.dataset.cyberSonarGpu = support;
// Shader setup runs after preRender's model tint. If it fails while the
// scene is parked (including Sonar OFF/reduced motion), draw once more so
// the now-unsupported contact path restores models before becoming idle.
if (supportChanged) scene.requestRender();
const status = globalThis.document?.getElementById?.('cyber-sonar-status');
const message = gpu.stats.supported
? ''
: 'Contact GPU unavailable. Native contacts remain visible.';
if (status && status.textContent !== message) status.textContent = message;
removeRender?.();
removeRender = scene.preRender.addEventListener(update);
});
const removePostRender = scene.postRender?.addEventListener(() => {
if (timer !== null) clearTimeout(timer);
timer = null;
if (isCyberContactSonarActive() && (count || gpu.stats.visibleCommands))
timer = setTimeout(() => {
timer = null;
if (!disposed && isCyberContactSonarActive()) scene.requestRender();
}, CYBER_SONAR_RENDER_INTERVAL_MS);
});
const unsubscribe = manager?.subscribe?.((change) => {
if (change.type !== 'visibility') return;
scene.requestRender();
});
return () => {
if (disposed) return;
disposed = true;
removeUpdate();
removeRender?.();
removePostRender?.();
gpu.destroy();
if (root?.dataset) delete root.dataset.cyberSonarGpu;
unsubscribe?.();
if (timer !== null) clearTimeout(timer);
for (const [visual, state] of tracked) restore(visual, state);
tracked.clear();
diagnostics.delete(viewer);
};
}
-314
View File
@@ -1,314 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
createCyberSonarSampler,
cyberSonarIntensityAtScreenPoint,
applyCyberSonarPrimitive,
applyCyberSonarSettings,
isCyberContactSonarActive,
isCyberSonarActive,
} from './cyberSonar.js';
import {
createCyberSonarScene,
visitCyberSonarModels,
getCyberSonarSceneDiagnostics,
} from './cyberSonarScene.js';
test('frame sampler matches reference without per-contact settings access', () => {
let reads = 0;
const values = {
cyberSonarOpacity: '62',
cyberSonarSector: '40',
cyberSonarRange: '110',
};
const root = {
dataset: new Proxy(values, {
get(o, k) {
reads++;
return o[k];
},
}),
};
const frame = createCyberSonarSampler(1000, 800, 1234, root),
once = reads;
for (let x = 0; x < 1000; x += 33)
for (let y = 0; y < 800; y += 31) frame.at(x, y);
assert.equal(reads, once);
for (let x = 0; x < 1000; x += 33)
for (let y = 0; y < 800; y += 31)
assert.ok(
Math.abs(
frame.at(x, y) -
cyberSonarIntensityAtScreenPoint(x, y, 1000, 800, 1234, {
dataset: values,
}),
) < 1e-10,
);
});
test('model walk never enumerates native contact or EntityCluster records', () => {
const root = new Cesium.PrimitiveCollection();
const points = root.add(new Cesium.PointPrimitiveCollection());
points.add();
points.get = () => {
throw new Error('must not enumerate contacts');
};
const cluster = root.add(new Cesium.EntityCluster());
Object.defineProperty(cluster, '_billboardCollection', {
get() {
throw new Error('must not access cluster internals');
},
configurable: true,
});
const seen = [];
visitCyberSonarModels(root, (...v) => seen.push(v));
assert.equal(seen.length, 0);
delete cluster._billboardCollection;
root.destroy();
});
test('models retain render-pass alpha and owner refresh survives teardown', () => {
const frame = createCyberSonarSampler(1000, 800, 0);
const model = { color: new Cesium.Color(1, 0.5, 0.2, 1) };
applyCyberSonarPrimitive(model, 0.5, true, frame, 'color', true);
assert.equal(model.color.alpha, 1);
assert.ok(model.color.red < 1);
const owner = new Cesium.Color(0.2, 0.3, 0.4, 0.7);
model.color = owner;
applyCyberSonarPrimitive(model, 1, false);
assert.equal(model.color, owner);
const unset = {};
applyCyberSonarPrimitive(unset, 0.5, true, frame, 'color', true);
applyCyberSonarPrimitive(unset, 1, false);
assert.equal(unset.color, undefined);
});
test('removed CSS mode cannot disable GPU contacts; unsupported adapters fail closed', () => {
const root = { dataset: { uiTheme: 'cyber' }, style: { setProperty() {} } };
const options = {
root,
body: { classList: { contains: () => false } },
hud: { classList: { contains: () => true } },
reducedMotion: false,
};
root.dataset.cyberSonarMode = 'css';
assert.equal(applyCyberSonarSettings({ mode: 'css' }, root).mode, undefined);
assert.equal(root.dataset.cyberSonarMode, undefined);
assert.equal(isCyberSonarActive(options), true);
assert.equal(isCyberContactSonarActive(options), true);
assert.equal(
applyCyberSonarSettings({ mode: 'invalid' }, root).mode,
undefined,
);
assert.equal(isCyberContactSonarActive(options), true);
root.dataset.cyberSonarGpu = 'unsupported';
assert.equal(isCyberContactSonarActive(options), false);
});
test('native owner colors remain untouched and scene teardown releases listeners', () => {
const previous = globalThis.document;
const root = { dataset: { uiTheme: 'cyber', cyberSonar: 'on' } };
let cockpit = false;
globalThis.document = {
documentElement: root,
body: { classList: { contains: () => cockpit } },
getElementById: () => ({ classList: { contains: () => true } }),
};
const primitives = new Cesium.PrimitiveCollection();
const points = primitives.add(new Cesium.PointPrimitiveCollection());
const point = points.add({ color: new Cesium.Color(0.3, 0.7, 0.5, 0.8) });
const original = primitives.update;
const scene = {
primitives,
postUpdate: new Cesium.Event(),
preRender: new Cesium.Event(),
postRender: new Cesium.Event(),
canvas: { clientWidth: 1000, clientHeight: 800 },
camera: {
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
viewMatrix: Cesium.Matrix4.IDENTITY,
},
requestRender() {},
};
const viewer = { scene };
const dispose = createCyberSonarScene(viewer);
try {
for (const enabled of ['on', 'off', 'on']) {
root.dataset.cyberSonar = enabled;
for (const opacity of [35, 84, 100]) {
root.dataset.cyberSonarOpacity = String(opacity);
point.color = new Cesium.Color(0.6, 0.4, 0.2, 0.7);
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
assert.equal(point.color.alpha, 0.7);
assert.equal(point.color.red, 0.6);
}
}
const previousTimeout = globalThis.setTimeout;
const previousClear = globalThis.clearTimeout;
let timers = 0;
globalThis.setTimeout = () => {
timers++;
return 1;
};
globalThis.clearTimeout = () => {};
try {
const gpu = getCyberSonarSceneDiagnostics(viewer).gpu;
gpu.commands = 6;
gpu.visibleCommands = 0;
scene.postRender.raiseEvent();
assert.equal(timers, 0, 'culled batches must not schedule a sonar draw');
gpu.visibleCommands = 1;
scene.postRender.raiseEvent();
assert.equal(timers, 1, 'a visible batch keeps the sweep moving');
root.dataset.cyberSonar = 'off';
scene.postRender.raiseEvent();
assert.equal(timers, 1, 'OFF does not schedule contact draws');
} finally {
globalThis.setTimeout = previousTimeout;
globalThis.clearTimeout = previousClear;
}
cockpit = true;
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
root.dataset.uiTheme = 'tactical';
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
dispose();
dispose();
assert.equal(primitives.update, original);
assert.equal(scene.postUpdate.numberOfListeners, 0);
assert.equal(scene.preRender.numberOfListeners, 0);
assert.equal(scene.postRender.numberOfListeners, 0);
assert.equal(root.dataset.cyberSonarGpu, undefined);
} finally {
dispose();
primitives.destroy();
globalThis.document = previous;
}
});
for (const reducedMotion of [false, true]) {
test(`shader failure requests one parked restoration draw with ${reducedMotion ? 'reduced motion' : 'Sonar OFF'}`, () => {
const previousDocument = globalThis.document;
const previousMatchMedia = globalThis.matchMedia;
const previousTimeout = globalThis.setTimeout;
const previousClear = globalThis.clearTimeout;
const root = {
dataset: { uiTheme: 'cyber', cyberSonar: reducedMotion ? 'on' : 'off' },
};
const status = { textContent: '' };
globalThis.document = {
documentElement: root,
body: { classList: { contains: () => false } },
getElementById: (id) =>
id === 'cyber-sonar-status'
? status
: { classList: { contains: () => true } },
};
globalThis.matchMedia = () => ({ matches: reducedMotion });
let scheduled = 0;
globalThis.setTimeout = () => {
scheduled++;
return 1;
};
globalThis.clearTimeout = () => {};
// A model-shaped fixture exercises the actual scene walk without loading a
// glTF or allocating a WebGL context. Rendering remains the browser gate.
const nativeColor = new Cesium.Color(0.2, 0.4, 0.8, 1);
const model = Object.create(Cesium.Model.prototype, {
ready: { value: true },
show: { value: true },
color: { value: nativeColor.clone(), writable: true },
modelMatrix: { value: Cesium.Matrix4.IDENTITY },
});
const primitives = new Cesium.PrimitiveCollection({
destroyPrimitives: false,
});
primitives.add(model);
const points = new Cesium.PointPrimitiveCollection();
const nativeCommand = {
owner: points,
uniformMap: {},
shaderProgram: {
vertexShaderSource: new Cesium.ShaderSource({
sources: ['void main() {}'],
}),
},
};
primitives.update = (frame) => frame.commandList.push(nativeCommand);
let requests = 0;
const scene = {
primitives,
postUpdate: new Cesium.Event(),
preRender: new Cesium.Event(),
postRender: new Cesium.Event(),
canvas: { clientWidth: 1000, clientHeight: 800 },
drawingBufferWidth: 1000,
drawingBufferHeight: 800,
camera: {
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
viewMatrix: Cesium.Matrix4.IDENTITY,
},
requestRender() {
requests++;
},
};
const viewer = { scene };
const dispose = createCyberSonarScene(viewer);
try {
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
assert.notDeepEqual(
model.color,
nativeColor,
'Cyber neutral treatment precedes shader compilation',
);
const frame = { commandList: [], passes: { render: true } };
primitives.update(frame);
assert.equal(getCyberSonarSceneDiagnostics(viewer).gpu.supported, false);
assert.equal(frame.commandList[0], nativeCommand);
scene.postRender.raiseEvent();
assert.equal(
scheduled,
0,
'inactive sweep has no timer to perform recovery',
);
scene.postUpdate.raiseEvent();
assert.equal(root.dataset.cyberSonarGpu, 'unsupported');
assert.match(status.textContent, /Native contacts remain visible/);
assert.equal(
requests,
1,
'support loss explicitly wakes the parked scene',
);
scene.preRender.raiseEvent();
assert.deepEqual(
model.color,
nativeColor,
'the requested draw restores native model color',
);
assert.equal(getCyberSonarSceneDiagnostics(viewer).contacts, 0);
scene.postRender.raiseEvent();
for (let i = 0; i < 3; i++) scene.postUpdate.raiseEvent();
assert.equal(
requests,
1,
'unsupported idle ticks do not create a render loop',
);
assert.equal(scheduled, 0);
dispose();
scene.postUpdate.raiseEvent();
assert.equal(requests, 1, 'disposed callbacks cannot wake the scene');
} finally {
dispose();
points.destroy();
primitives.destroy();
globalThis.document = previousDocument;
globalThis.matchMedia = previousMatchMedia;
globalThis.setTimeout = previousTimeout;
globalThis.clearTimeout = previousClear;
}
});
}
+2 -3
View File
@@ -1,9 +1,8 @@
import * as Cesium from 'cesium';
/** Camera range with breathing room in both viewport dimensions. */
export function rangeForBoundingSphere(viewer, radius) {
const frustum = viewer.camera?.frustum;
const verticalFov = Number(frustum?.fov) || Cesium.Math.toRadians(60);
// Cesium's default frustum: 60°.
const verticalFov = Number(frustum?.fov) || Math.PI / 3;
const aspectRatio = Math.max(0.5, Number(frustum?.aspectRatio) || 1);
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspectRatio);
const limitingFov = Math.min(verticalFov, horizontalFov);
+1 -1
View File
@@ -1,4 +1,4 @@
import { worldToWindowCoordinates } from './render/cesiumProjection.js';
import { worldToWindowCoordinates } from '../globe/cesium/sceneProjection.js';
import * as geo from '../geo/index.js';
// A window position through the engine projector the scene carries (the
+36 -354
View File
@@ -1,10 +1,15 @@
/** Session-local geodata loading with bounded geometry and local-only file references. */
import * as Cesium from 'cesium';
import { cesiumSdkReady, loadCesiumSdk } from '../app/loadCesium.js';
import { createImportImageBudget } from './geoImportImages.js';
import { createImportedPins } from './geoImportPins.js';
import { readKmlRecords } from './geoImportKml.js';
import {
geoJsonRecords,
importFeatureName,
recordCount,
} from './geoImportRecords.js';
import { msg } from '../i18n/marker.js';
export { importFeatureName };
/** Largest file accepted, in bytes. */
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
/** Largest total a KMZ may inflate to — a zip bomb stops here. */
@@ -17,8 +22,6 @@ export const MAX_IMPORT_ENTITIES = 20_000;
export const MAX_IMPORT_POSITIONS = 250_000;
/** Most normalized style definitions and local image links in one KML/KMZ. */
const MAX_KML_LINKS = 2_000;
/** Longest plain-text name kept from a file's features. */
const MAX_IMPORT_NAME_LENGTH = 120;
/** What the file picker offers. */
export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx';
@@ -213,8 +216,8 @@ export function countGeoJsonGeometry(
MultiPolygon: 3,
}[node.type];
if (depth !== undefined) {
// Multipart geometries create one Cesium entity per component, even
// when the document wraps them in just one Feature.
// Multipart geometries create one record per component, even when
// the document wraps them in just one Feature.
entities += node.type.startsWith('Multi')
? Array.isArray(node.coordinates)
? node.coordinates.length
@@ -294,7 +297,7 @@ function overLimitError({ over, features }) {
}
// The import uses WGS84 ground footprints and a short name only. Remove
// loader-sensitive metadata before Cesium allocates properties or entity IDs.
// everything else before the records are built.
function prepareGeoJson(geojson) {
const stack = [geojson];
while (stack.length) {
@@ -521,7 +524,7 @@ export function kmzImageResolver(
* Strip everything from a parsed KML document that would make the page reach
* outside the file. `resolveLocal(href)` returns a URL for an image packed in
* the same KMZ (or null); every other href is removed, so icons fall back to
* Cesium's default pin and a ground overlay without a local image is dropped.
* the default pin and a ground overlay without a local image is dropped.
* @param {Document} doc
* @param {(href: string) => string|null} [resolveLocal]
* @returns {{removed: number}} How many elements were taken out.
@@ -558,8 +561,8 @@ export function sanitizeKmlDocument(doc, resolveLocal = () => null) {
drop(overlay);
continue;
}
// An empty Icon explicitly hides the billboard in Cesium. Remove the
// blocked icon definition so the loader supplies its local default pin.
// An empty Icon explicitly hides the billboard. Remove the blocked
// icon definition so the reader supplies its local default pin.
// Authored empty Icons (with no blocked href) still mean no marker.
drop(
icon?.localName === 'Icon' && icon.parentNode?.localName === 'IconStyle'
@@ -755,8 +758,8 @@ export function deduplicateKmlStyles(
}
/**
* Count placemarks and vertices in a parsed KML document before Cesium reads
* it: every `<coordinates>` tuple and every `gx:coord`, plus normalized
* Count placemarks and vertices in a parsed KML document before it is
* read: every `<coordinates>` tuple and every `gx:coord`, plus normalized
* style definitions and distinct image `<href>` values (`links`).
* Inline StyleMaps are references, not additional shared style definitions.
* @param {Document} doc
@@ -806,8 +809,9 @@ export function countKmlGeometry(
)
throw new GeoImportError(msg('That KML has invalid geometry bounds.'));
}
// Cesium creates entities for folders, a parent for every placemark, and
// a child for each geometry in a MultiGeometry, including nested containers.
// The budget counts what Cesium made entities of: folders, a parent for
// every placemark, and a child for each geometry in a MultiGeometry,
// including nested containers.
if (
name === 'Placemark' ||
name === 'Folder' ||
@@ -953,95 +957,6 @@ export function gpxToGeoJson(
return { type: 'FeatureCollection', features };
}
/** A feature name as short plain text (no control characters), or undefined. */
export function importFeatureName(value) {
const text = String(value ?? '')
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!text) return undefined;
return text.length > MAX_IMPORT_NAME_LENGTH
? `${text.slice(0, MAX_IMPORT_NAME_LENGTH - 1)}…`
: text;
}
/**
* Make every entity of an imported data source inert: no description,
* balloon, extended data or properties, no native label, and a name that is
* short plain text. Generated pin canvases share a data URL so Cesium uploads
* each image once, rather than allocating a texture entry for every entity.
* Returns how many entities were touched.
* @param {{entities: {values: object[]}}} dataSource
* @returns {number}
*/
export function scrubImportedEntities(dataSource) {
const collection = dataSource?.entities;
const list = collection?.values || [];
const images = new Map();
collection?.suspendEvents?.();
try {
for (let i = 0; i < list.length; i++) {
const entity = list[i];
let name;
try {
name = importFeatureName(
typeof entity.name === 'string' ? entity.name : undefined,
);
} catch {
name = undefined;
}
entity.name = name;
entity.description = undefined;
entity.label = undefined;
entity.properties = undefined;
if (entity.kml && typeof entity.kml === 'object') entity.kml = undefined;
if (
entity.billboard?.heightReference?.getValue?.() ===
Cesium.HeightReference.CLAMP_TO_GROUND
)
entity.billboard.disableDepthTestDistance = Number.POSITIVE_INFINITY;
const property = entity.billboard?.image;
if (property?.isConstant === false) continue;
const image = property?.getValue?.();
if (image?.tagName === 'CANVAS') {
let url = images.get(image);
if (!url) {
url = image.toDataURL();
images.set(image, url);
}
entity.billboard.image = url;
}
}
} finally {
collection?.resumeEvents?.();
}
return list.length;
}
/**
* Take time out of an imported data source so adding it cannot move the app's
* shared clock: the data source reports no clock (the viewer adopts the clock
* of every added source that has one), every entity is available for all
* time. KML tracks have already become their original static vertices in
* `sanitizeKmlDocument`, so no sampled-position fallback is needed here.
* @param {object} dataSource
* @returns {object} The same data source.
*/
export function detachImportedTime(dataSource) {
Object.defineProperty(dataSource, 'clock', {
configurable: true,
enumerable: false,
get: () => undefined,
set: () => {},
});
const list = dataSource?.entities?.values || [];
for (let i = 0; i < list.length; i++) {
const entity = list[i];
entity.availability = undefined;
}
return dataSource;
}
/** Repair only the first KML opening tag, scanning its delimiter once. */
export function repairKmlRootNamespaces(value, what = 'KML') {
const text = String(value);
@@ -1196,171 +1111,6 @@ function checkLocalImages(doc, entries = new Map(), main = '') {
}
}
// Cesium also processes KML in deferred tasks; an exception there does not
// reject its load promise. Keep a failed file from blocking the serial queue.
// The camera and canvas only feed network links, which sanitizing removed; a
// globe engine without a Cesium camera (Navara) passes its canvas alone.
async function loadKml(doc, { viewer, engine }, signal) {
let removeError;
try {
return await boundedImport(
Promise.race([
Cesium.KmlDataSource.load(doc, {
camera: viewer?.scene.camera,
canvas: viewer?.scene.canvas ?? engine?.host.canvas,
clampToGround: true,
}),
new Promise((_, reject) => {
const failed = () => {
reject(new GeoImportError(msg('That KML could not be read.')));
};
globalThis.addEventListener?.('error', failed);
removeError = () => globalThis.removeEventListener?.('error', failed);
}),
]),
signal,
);
} finally {
removeError?.();
}
}
// Resolve a static file's ground pins in batches before they are visualized.
// Per-billboard CLAMP_TO_GROUND subscriptions scale poorly on large imports.
const groundPins = new WeakMap();
async function settleGroundPins(dataSource, { viewer, engine }, signal) {
let pins = groundPins.get(dataSource);
if (!pins) {
pins = [];
for (const entity of dataSource.entities.values) {
const reference = entity.billboard?.heightReference?.getValue?.();
if (
reference === Cesium.HeightReference.CLAMP_TO_GROUND ||
reference === Cesium.HeightReference.RELATIVE_TO_GROUND
) {
const position = entity.position?.getValue?.(Cesium.JulianDate.now());
if (!position) continue;
const cartographic = Cesium.Cartographic.fromCartesian(position);
if (!cartographic)
throw new GeoImportError(
msg('That file has invalid point coordinates.'),
);
pins.push({
entity,
original: position,
cartographic,
offset:
reference === Cesium.HeightReference.RELATIVE_TO_GROUND
? cartographic.height
: 0,
});
entity.billboard.heightReference = Cesium.HeightReference.NONE;
entity.billboard.disableDepthTestDistance = Number.POSITIVE_INFINITY;
}
}
groundPins.set(dataSource, pins);
}
for (let at = 0; at < pins.length; at += 256) {
signal?.throwIfAborted();
const batch = pins.slice(at, at + 256);
const positions = batch.map(
({ cartographic: c }) =>
new Cesium.Cartographic(c.longitude, c.latitude, 0),
);
// A globe engine without a viewer: its terrain under each pin.
if (!viewer && engine) {
for (let i = 0; i < batch.length; i++) {
const item = batch[i];
const sampled = engine.surface.terrainHeightSupported
? engine.surface.terrainHeight(positions[i])
: undefined;
const ground = Number.isFinite(sampled)
? sampled
: (item.groundHeight ?? 0);
item.groundHeight = ground;
const height = ground + item.offset;
item.entity.position =
Math.abs(height - item.cartographic.height) < 1e-6
? item.original
: Cesium.Cartesian3.fromRadians(
item.cartographic.longitude,
item.cartographic.latitude,
height,
);
}
if (at + batch.length < pins.length)
await new Promise((resolve) => setTimeout(resolve, 0));
continue;
}
const mesh =
viewer.scene?.globe?.show === false &&
viewer.scene?.clampToHeightSupported;
// A coarse rendered ellipsoid mesh can report a height kilometres below
// zero. Its provider's exact height is zero even while a 3D map hides it;
// a buried starting point can make the downward mesh ray miss the surface.
const ellipsoid =
viewer.terrainProvider instanceof Cesium.EllipsoidTerrainProvider;
for (let i = 0; i < positions.length; i++) {
const sampled = ellipsoid
? 0
: viewer.scene?.globe?.getHeight?.(positions[i]);
positions[i].height = mesh
? (batch[i].groundHeight ??
Math.max(0, Number.isFinite(sampled) ? sampled : 0))
: Number.isFinite(sampled)
? sampled
: batch[i].groundHeight || 0;
}
const points = positions.map((c) =>
Cesium.Cartesian3.fromRadians(c.longitude, c.latitude, c.height),
);
const height = viewer.camera?.positionCartographic?.height;
if (mesh && Number.isFinite(height) && height < 250_000) {
const rectangle = viewer.camera.computeViewRectangle?.();
const indices = [];
for (let i = 0; i < positions.length; i++)
if (rectangle && Cesium.Rectangle.contains(rectangle, positions[i]))
indices.push(i);
if (indices.length) {
const resolved = await boundedImport(
viewer.scene.clampToHeightMostDetailed(
indices.map((i) => {
const c = positions[i];
return Cesium.Cartesian3.fromRadians(
c.longitude,
c.latitude,
Math.max(0, c.height),
);
}),
),
signal,
);
for (let i = 0; i < indices.length; i++)
if (resolved[i]) points[indices[i]] = resolved[i];
}
}
signal?.throwIfAborted();
for (let i = 0; i < batch.length; i++) {
const item = batch[i];
const c = points[i] && Cesium.Cartographic.fromCartesian(points[i]);
if (!c)
throw new GeoImportError(
msg(
'Ground heights are not available for that file. Try again when the map has loaded.',
),
);
item.groundHeight = c.height;
c.height += item.offset;
item.entity.position =
Math.abs(c.height - item.cartographic.height) < 1e-6
? item.original
: Cesium.Cartesian3.fromRadians(c.longitude, c.latitude, c.height);
}
if (at + batch.length < pins.length)
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function boundedImport(promise, signal) {
let timer, abort;
try {
@@ -1384,21 +1134,18 @@ async function boundedImport(promise, signal) {
}
/**
* Load a ground footprint, retaining explicit KML altitude modes. Call mount after
* adding the source; revoke owns pin and blob cleanup.
* Read a file into the import's plain records (src/data/geoImportRecords.js),
* keeping explicit KML altitude modes; no engine is involved, and the
* drawing is src/data/geoImportDisplay.js's. `revoke` releases the blob URLs
* of a KMZ's packed images, which the records name.
* @param {File|Blob & {name?: string}} file
* @param {{viewer?: Cesium.Viewer, engine?: object, color?: string}} options
* `color` is a CSS color. Without a viewer, `engine` (a globe engine:
* Navara) supplies the ground under the pins, and the pins stay entities
* for that engine's own drawing (src/layers/shared/render/navaraStory.js).
* @returns {Promise<{dataSource: Cesium.DataSource, name: string,
* format: string, entityCount: number, removed: number, mount: Function, revoke: Function}>}
* @param {{color?: string, signal?: AbortSignal}} [options] `color` is the
* row's `#rrggbb`: GeoJSON and GPX draw in it; KML keeps its own styles.
* @returns {Promise<{records: import('./geoImportRecords.js').ImportedRecords,
* name: string, format: string, entityCount: number, removed: number,
* revoke: Function}>}
*/
export async function loadGeoFile(
file,
{ viewer = null, engine = null, color = '#39d0ff', signal } = {},
) {
const globe = { viewer, engine };
export async function loadGeoFile(file, { color = '#39d0ff', signal } = {}) {
signal = signal
? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
: AbortSignal.timeout(60_000);
@@ -1417,18 +1164,12 @@ export async function loadGeoFile(
throw new GeoImportError(
msg('Only GeoJSON, KML, KMZ and GPX files can be imported.'),
);
// The import builds Cesium data sources on either globe: a Navara page
// loads the Cesium SDK first.
if (!cesiumSdkReady()) await boundedImport(loadCesiumSdk(), signal);
const stroke = Cesium.Color.fromCssColorString(color);
const urls = [];
let pins;
const revoke = () => {
pins?.destroy();
urls.splice(0).forEach((url) => URL.revokeObjectURL(url));
};
const name = importDisplayName(file.name);
let dataSource;
let records;
let removed = 0;
try {
if (format === 'geojson' || format === 'gpx') {
@@ -1444,18 +1185,7 @@ export async function loadGeoFile(
throw new GeoImportError(msg('That file has nothing to draw.'));
prepareGeoJson(geojson);
bytes = null;
dataSource = await boundedImport(
Cesium.GeoJsonDataSource.load(geojson, {
clampToGround: true,
stroke,
fill: stroke.withAlpha(0.25),
strokeWidth: 3,
markerColor: stroke,
// No HTML description table is built from the file's properties.
describe: () => undefined,
}),
signal,
);
records = geoJsonRecords(geojson, color);
} else {
let text;
let entries;
@@ -1484,7 +1214,7 @@ export async function loadGeoFile(
deduplicateKmlStyles(doc);
const normalizedSize = countKmlGeometry(doc);
if (normalizedSize.over) throw overLimitError(normalizedSize);
dataSource = await loadKml(doc, globe, signal);
records = readKmlRecords(doc);
}
} catch (error) {
revoke();
@@ -1495,7 +1225,7 @@ export async function loadGeoFile(
{ format: format.toUpperCase() },
);
}
const entityCount = dataSource.entities.values.length;
const entityCount = recordCount(records);
if (entityCount > MAX_IMPORT_ENTITIES) {
revoke();
throw new GeoImportError(
@@ -1507,59 +1237,11 @@ export async function loadGeoFile(
},
);
}
const now = Cesium.JulianDate.now();
const drawable = dataSource.entities.values.some(
(e) =>
e.billboard?.image?.getValue(now) ||
e.point ||
e.rectangle ||
e.polyline?.positions?.getValue(now)?.length >= 2 ||
e.wall?.positions?.getValue(now)?.length >= 2 ||
e.polygon?.hierarchy?.getValue(now)?.positions?.length >= 3,
);
if (!drawable) {
if (!entityCount) {
revoke();
throw new GeoImportError(msg('That file has nothing to draw.'));
}
scrubImportedEntities(dataSource);
try {
await settleGroundPins(dataSource, globe, signal);
} catch (error) {
revoke();
throw error instanceof GeoImportError
? error
: new GeoImportError(
msg('Ground heights could not be read. Try that file again.'),
);
}
// Keep the clock invariant explicit even though unused KML dates have
// already been stripped and all supported geometries are static.
detachImportedTime(dataSource);
try {
pins = viewer
? createImportedPins(dataSource, viewer)
: { mount() {}, sync() {}, destroy() {} };
} catch {
revoke();
throw new GeoImportError(msg('That file has invalid point graphics.'));
}
// The display name travels beside the data source (the file name, not
// The display name travels beside the records (the file name, not
// anything the document says about itself).
return {
dataSource,
name,
format,
entityCount,
removed,
revoke,
mount: () => pins.mount(),
refreshGround: (refreshSignal) =>
settleGroundPins(
dataSource,
globe,
refreshSignal
? AbortSignal.any([refreshSignal, AbortSignal.timeout(15_000)])
: AbortSignal.timeout(15_000),
).then(() => pins.sync()),
};
return { records, name, format, entityCount, removed, revoke };
}
+57 -346
View File
@@ -2,7 +2,6 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { deflateRawSync } from 'node:zlib';
import { spawnSync } from 'node:child_process';
import * as Cesium from 'cesium';
import {
GeoImportError,
repairKmlRootNamespaces,
@@ -12,10 +11,8 @@ import {
countGeoJsonGeometry,
countKmlGeometry,
deduplicateKmlStyles,
detachImportedTime,
gpxToGeoJson,
importFeatureName,
scrubImportedEntities,
sanitizeKmlDocument,
detectGeoFileFormat,
importDisplayName,
@@ -28,104 +25,59 @@ import {
} from './geoFileImport.js';
import { createImportImageBudget, importImageSize } from './geoImportImages.js';
test('real loader grounds XYZ GeoJSON and strips loader-sensitive metadata before allocation', async () => {
const previous = Cesium.PinBuilder.prototype.fromColor;
Cesium.PinBuilder.prototype.fromColor = () => ({
toDataURL: () => 'data:image/png;base64,local',
tagName: 'CANVAS',
});
try {
const object = {
type: 'FeatureCollection',
crs: { type: 'name', properties: { name: 'unsupported' } },
features: [
{
type: 'Feature',
id: 'kartaview-owned',
properties: { name: 'Test', addProperty: 1, 'marker-symbol': 'rail' },
geometry: { type: 'Point', coordinates: [1, 2, 999] },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[1, 2, 0],
[2, 2, 0],
[2, 3, 0],
[1, 2, 0],
],
],
},
},
],
};
const loaded = await loadGeoFile(
new File([JSON.stringify(object)], 'xyz.geojson'),
test('real loader grounds XYZ GeoJSON and keeps only names and footprints in its records', async () => {
const object = {
type: 'FeatureCollection',
crs: { type: 'name', properties: { name: 'unsupported' } },
features: [
{
viewer: {
scene: {
frameState: { mode: Cesium.SceneMode.SCENE3D },
primitives: new Cesium.PrimitiveCollection(),
globe: { getHeight: () => 1234 },
},
type: 'Feature',
id: 'kartaview-owned',
properties: { name: 'Test', addProperty: 1, 'marker-symbol': 'rail' },
geometry: { type: 'Point', coordinates: [1, 2, 999] },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[1, 2, 0],
[2, 2, 0],
[2, 3, 0],
[1, 2, 0],
],
],
},
},
);
const [point, polygon] =
loaded.dataSource.importedEntities || loaded.dataSource.entities.values;
assert.notEqual(point.id, 'kartaview-owned');
assert.equal(point.name, 'Test');
assert.equal(point.properties, undefined);
assert.equal(
point.billboard.heightReference.getValue(),
Cesium.HeightReference.NONE,
);
assert.equal(point.billboard.disableDepthTestDistance.getValue(), Infinity);
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(
point.position.getValue(Cesium.JulianDate.now()),
).height - 1234,
) < 1e-6,
);
assert.notEqual(polygon.polygon.perPositionHeight?.getValue(), true);
assert.equal(loaded.dataSource.clock, undefined);
assert.equal(
loaded.dataSource.entities.values.length,
1,
'only polygon geometry remains in the per-frame entity collection',
);
assert.equal(loaded.dataSource.importedBillboards.length, 1);
const update = Cesium.BillboardCollection.prototype.update;
let passes = 0;
Cesium.BillboardCollection.prototype.update = () => passes++;
try {
const collection = loaded.dataSource.importedBillboards;
collection.update({ passes: { pick: true, offscreen: true } });
assert.equal(passes, 0, 'height picks ignore imported markers');
collection.update({ passes: { pick: true, offscreen: false } });
collection.update({ passes: { render: true, offscreen: true } });
assert.equal(passes, 2, 'ordinary picks and screenshots include markers');
} finally {
Cesium.BillboardCollection.prototype.update = update;
}
loaded.mount();
loaded.dataSource.show = false;
assert.equal(loaded.dataSource.importedBillboards.show, false);
loaded.dataSource.show = true;
await loaded.refreshGround();
assert.equal(
point.billboard.heightReference.getValue(),
Cesium.HeightReference.NONE,
);
loaded.revoke();
assert.ok(loaded.dataSource.importedBillboards.isDestroyed());
} finally {
Cesium.PinBuilder.prototype.fromColor = previous;
}
],
};
const loaded = await loadGeoFile(
new File([JSON.stringify(object)], 'xyz.geojson'),
{ color: '#ffb547' },
);
assert.equal(loaded.format, 'geojson');
assert.equal(loaded.entityCount, 2);
const [point] = loaded.records.points;
const [polygon] = loaded.records.polygons;
assert.deepEqual(Object.keys(point).sort(), [
'altitude',
'icon',
'name',
'position',
]);
assert.equal(point.name, 'Test');
assert.equal(point.altitude, 'ground', 'an XYZ point stands on the ground');
assert.deepEqual(point.position, [1, 2, 0]);
// Cesium's GeoJSON pin in the row colour (a marker-symbol is ignored).
assert.equal(point.icon.image.pinSize, 48);
assert.deepEqual(point.icon.image.pin, [1, 181 / 255, 71 / 255, 1]);
assert.equal(point.icon.origin, 'bottom');
assert.equal(polygon.raised, false, 'XYZ polygons drape');
assert.deepEqual(polygon.fill, [1, 181 / 255, 71 / 255, 0.25]);
assert.equal(polygon.name, undefined);
assert.equal('dataSource' in loaded, false, 'no Cesium data source');
loaded.revoke();
});
test('real loader refuses empty files and malformed nested geometry before allocating entities', async () => {
@@ -144,9 +96,7 @@ test('real loader refuses empty files and malformed nested geometry before alloc
].map((coordinates) => ({ type: 'Point', coordinates })),
])
await assert.rejects(
loadGeoFile(new File([JSON.stringify(object)], 'bad.geojson'), {
viewer: {},
}),
loadGeoFile(new File([JSON.stringify(object)], 'bad.geojson')),
GeoImportError,
);
});
@@ -235,90 +185,11 @@ test('pending file reads can be cancelled without waiting for the loader timeout
size: 1,
arrayBuffer: () => new Promise(() => {}),
};
const promise = loadGeoFile(file, { viewer: {}, signal: controller.signal });
const promise = loadGeoFile(file, { signal: controller.signal });
controller.abort(new Error('cancelled by teardown'));
await assert.rejects(promise, /cancelled by teardown/);
});
test('a hidden coarse globe never seeds a mesh-clamp ray below the surface', async () => {
const previous = Cesium.PinBuilder.prototype.fromColor;
Cesium.PinBuilder.prototype.fromColor = () => ({
tagName: 'CANVAS',
toDataURL: () => 'data:image/png;base64,local',
});
const starts = [];
try {
for (const terrainProvider of [new Cesium.EllipsoidTerrainProvider(), {}]) {
const viewer = {
terrainProvider,
camera: {
positionCartographic: { height: 100000 },
computeViewRectangle: () => Cesium.Rectangle.MAX_VALUE,
},
scene: {
frameState: { mode: Cesium.SceneMode.SCENE3D },
globe: { show: false, getHeight: () => -12311 },
clampToHeightSupported: true,
clampToHeightMostDetailed: async (points) =>
points.map((p) => {
const c = Cesium.Cartographic.fromCartesian(p);
starts.push(c.height);
return c.height < -9000
? undefined
: Cesium.Cartesian3.fromRadians(c.longitude, c.latitude, 2000);
}),
},
};
const loaded = await loadGeoFile(
new File(
[JSON.stringify({ type: 'Point', coordinates: [1, 2] })],
'surface.geojson',
),
{ viewer },
);
assert.ok(
Math.abs(starts.at(-1)) < 1e-6,
`ray started at ${starts.at(-1)}`,
);
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(
loaded.dataSource.importedBillboards.get(0).position,
).height - 2000,
) < 1e-6,
);
const sampled = starts.length;
viewer.camera.computeViewRectangle = () => undefined;
await loaded.refreshGround();
assert.equal(
starts.length,
sampled,
'distant points do not start a detailed mesh sweep',
);
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(
loaded.dataSource.importedBillboards.get(0).position,
).height - 2000,
) < 1e-6,
'the last valid mesh height survives outside the camera footprint',
);
const point = loaded.dataSource.importedEntities[0];
const other = new Cesium.Entity({ id: 'unrelated-aircraft' });
viewer.trackedEntity = starts.length === 1 ? other : point;
viewer.selectedEntity = point;
loaded.revoke();
assert.equal(viewer.selectedEntity, undefined);
assert.equal(
viewer.trackedEntity,
starts.length === 1 ? other : undefined,
);
}
} finally {
Cesium.PinBuilder.prototype.fromColor = previous;
}
});
const bytes = (text) => new TextEncoder().encode(text);
/** A minimal zip writer: stored (0) or deflated (8) entries, optional flags. */
@@ -1050,71 +921,14 @@ test('a KMZ image gets one blob URL however many hrefs name it', () => {
assert.deepEqual(kept, ['blob:test/1', 'blob:test/2']);
});
test('imported entities keep only a short plain-text name: no description, label or properties', () => {
const ds = new Cesium.CustomDataSource('t');
const e = ds.entities.add({
name: 'Trail\u0007 <b>head</b>\n' + 'x'.repeat(300),
description: '<img src="https://example.invalid/x.png" onerror="alert(1)">',
label: { text: 'Trail head' },
properties: { note: '<script>alert(1)</script>' },
position: Cesium.Cartesian3.fromDegrees(1, 2),
});
e.kml = { extendedData: { note: { value: '<iframe src=//x>' } } };
ds.entities.add({ name: 42, position: Cesium.Cartesian3.fromDegrees(1, 2) });
assert.equal(scrubImportedEntities(ds), 2);
assert.equal(e.description, undefined);
assert.equal(e.label, undefined);
assert.equal(e.properties, undefined);
assert.equal(e.kml, undefined);
assert.ok(e.name.startsWith('Trail <b>head</b> x'));
assert.ok(e.name.length <= 120 && !/[\u0000-\u001f]/.test(e.name));
assert.equal(ds.entities.values[1].name, undefined);
test('feature names are short plain text', () => {
const name = importFeatureName('Trail\u0007 <b>head</b>\n' + 'x'.repeat(300));
assert.ok(name.startsWith('Trail <b>head</b> x'));
assert.ok(name.length <= 120 && !/[\u0000-\u001f]/.test(name));
assert.equal(importFeatureName(42), '42');
assert.equal(importFeatureName(' '), undefined);
});
test('generated pin canvases are encoded once and shared without changing authored image URLs', () => {
const ds = new Cesium.CustomDataSource('pins');
let encoded = 0;
const canvas = {
tagName: 'CANVAS',
toDataURL: () => {
encoded++;
return 'data:image/png;base64,shared-pin';
},
};
for (let i = 0; i < 1000; i++)
ds.entities.add({
billboard: { image: canvas },
label: { text: 'discard' },
properties: { discard: true },
});
const authored = ds.entities.add({
billboard: { image: 'blob:packed-image' },
});
const dynamicImage = new Cesium.CallbackProperty(() => canvas, false);
const dynamic = ds.entities.add({ billboard: { image: dynamicImage } });
let notifications = 0;
ds.entities.collectionChanged.addEventListener(() => notifications++);
scrubImportedEntities(ds);
assert.equal(encoded, 1);
assert.equal(
notifications,
1,
'normalization publishes one complete collection change',
);
for (const e of ds.entities.values.slice(0, 1000))
assert.equal(
e.billboard.image.getValue(),
'data:image/png;base64,shared-pin',
);
assert.equal(authored.billboard.image.getValue(), 'blob:packed-image');
assert.equal(
dynamic.billboard.image,
dynamicImage,
'time-dependent image properties stay intact',
);
});
test('unused KML time and camera metadata is removed before loading without changing geometry', () => {
const coordinates = xmlEl('coordinates', {}, '1,2,0 3,4,0');
const placemark = xmlEl('Placemark', {}, [
@@ -1133,47 +947,6 @@ test('unused KML time and camera metadata is removed before loading without chan
assert.equal(coordinates.textContent, '1,2,0 3,4,0');
});
test('a dated import exposes no clock and draws for all time', () => {
// A data source with its own clock, as KmlDataSource has for a dated file.
class DatedSource extends Cesium.CustomDataSource {
get clock() {
return this._testClock;
}
}
const ds = new DatedSource('dated');
ds._testClock = new Cesium.DataSourceClock();
const start = Cesium.JulianDate.fromIso8601('2020-01-01T00:00:00Z');
const stop = Cesium.JulianDate.fromIso8601('2020-01-01T01:00:00Z');
const span = new Cesium.TimeIntervalCollection([
new Cesium.TimeInterval({ start, stop }),
]);
// KML tracks become their original static vertices before Cesium loading.
const track = ds.entities.add({
availability: span,
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray([10, 10, 10.1, 10.1]),
clampToGround: true,
},
});
const recorded = track.polyline.positions;
const pin = ds.entities.add({
availability: span,
position: Cesium.Cartesian3.fromDegrees(1, 1),
});
assert.ok(ds.clock);
detachImportedTime(ds);
assert.equal(ds.clock, undefined);
// The viewer reads `clock` from every added source; setting it is ignored.
ds.clock = new Cesium.DataSourceClock();
assert.equal(ds.clock, undefined);
assert.equal(pin.availability, undefined);
assert.ok(pin.isAvailable(Cesium.JulianDate.now()));
assert.equal(track.availability, undefined);
assert.equal(track.polyline.positions, recorded);
assert.equal(recorded.getValue(Cesium.JulianDate.now()).length, 2);
assert.ok(track.polyline.clampToGround.getValue());
});
test('KML root namespace repair respects quoted delimiters and preserves the document tail', () => {
const text =
'<KML note="a > b" xmlns:x="first" xmlns:x="second"><Placemark xsi:type="test"/></KML>';
@@ -1262,65 +1035,3 @@ test('KML namespace repair ignores declaration text inside quoted attribute valu
/note="xmlns:xsi='x'" xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance">/,
);
});
test('on a globe engine without a viewer the pins stay entities on the engine terrain', async () => {
const { createFakeEngine } = await import('../globe/testing/fakeEngine.js');
const previous = Cesium.PinBuilder.prototype.fromColor;
Cesium.PinBuilder.prototype.fromColor = () => ({
toDataURL: () => 'data:image/png;base64,local',
tagName: 'CANVAS',
});
try {
const engine = createFakeEngine({ kind: 'navara' });
engine.fake.script('terrainHeight', 321);
const object = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { name: 'Pin' },
geometry: { type: 'Point', coordinates: [1, 2] },
},
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[1, 2],
[2, 3],
],
},
},
],
};
const loaded = await loadGeoFile(
new File([JSON.stringify(object)], 'pins.geojson'),
{ engine },
);
const entities = loaded.dataSource.entities.values;
assert.equal(entities.length, 2, 'no Cesium billboard collection');
assert.equal(loaded.dataSource.importedBillboards, undefined);
const [pin] = entities.filter((entity) => entity.billboard);
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(
pin.position.getValue(Cesium.JulianDate.now()),
).height - 321,
) < 1e-3,
);
// A later ground refresh reads the engine again.
engine.fake.script('terrainHeight', 50);
await loaded.refreshGround();
assert.ok(
Math.abs(
Cesium.Cartographic.fromCartesian(
pin.position.getValue(Cesium.JulianDate.now()),
).height - 50,
) < 1e-3,
);
loaded.mount();
loaded.revoke();
} finally {
Cesium.PinBuilder.prototype.fromColor = previous;
}
});
+438
View File
@@ -0,0 +1,438 @@
// One imported file (UTILITIES ▸ Import) drawn on the globe contract, the
// same on every engine, from its plain records (src/data/geoImportRecords.js),
// with Cesium's look as the reference:
// - polygons: one ground 'polygon' set of their fills; a raised polygon
// (KML absolute or relative) also gets its outline as lines at its
// heights, as Cesium drew it (its fill drapes: the ground has no heights);
// - ground lines: one ground 'corridor' set in pixels, over the fills;
// - lines in the air: one engine line each;
// - points: one sprite set per look, each sprite a square centred on its
// point holding the billboard where Cesium placed it (src/data/
// geoImportPins.js); points on the ground draw over the scene and are
// hidden beyond the horizon instead, as Cesium's clamped billboards
// ignored the depth test;
// - ground overlays: one image imagery layer each over its rectangle.
// Nothing is pickable: an imported file has no card. Ground heights under
// the points come from engine.surface, sampled again by refreshGround().
import * as geo from '../geo/index.js';
import { normalizeGroundItem } from '../globe/ground.js';
import { composeIcon } from './geoImportPins.js';
const POLYGON_ORDER = 0;
const LINE_ORDER = 1;
// Over the layers' rasters, as Cesium's ground rectangle drew over imagery.
const OVERLAY_ORDER = 30;
// The horizon test lifts each point by this much (as GhostMaps does):
// ground can lie below the ellipsoid.
const HORIZON_LIFT_M = 2000;
// Under this camera height, points on 3D tiles are clamped to the tiles
// drawn now; higher up the terrain answers (Cesium's 250 km).
const CLAMP_TILES_BELOW_M = 250_000;
const BATCH = 256;
let serial = 0;
const imageFromUrl = async (url) => {
const image = new Image();
image.src = url;
await image.decode();
return image;
};
// A ground item the contract accepts, or null (a degenerate ring or line).
function checked(kind, item) {
try {
normalizeGroundItem(kind, item);
return item;
} catch {
return null;
}
}
const flat = (positions) => positions.map(([lon, lat]) => [lon, lat]);
const cartesian = ([lon, lat, height = 0]) =>
geo.cartesian3.fromDegrees(lon, lat, height);
/** One key per drawn look of an icon, so equal pins share a sprite set. */
const lookKey = (icon, depth) =>
JSON.stringify([
icon.image,
icon.widthPx,
icon.heightPx,
icon.scale,
icon.origin,
icon.offsetPx,
icon.subRegion,
icon.scaleByDistance,
depth,
]);
/**
* Draw one imported file's records on `engine`. Resolves once its icons
* are decoded and every set is committed.
* @param {import('../globe/contract.js').GlobeEngine} engine
* @param {import('./geoImportRecords.js').ImportedRecords} records
* @param {{ visible?: boolean, loadImage?: (url: string) => Promise<object>,
* createCanvas?: () => object, pixelRatio?: number }} [options]
* `loadImage` and `createCanvas` are test seams (icon decoding and the
* sprite canvases).
* @returns {Promise<{ report: { drawn: number, undrawn: number,
* kinds: Object<string, number> }, setShow: (visible: boolean) => void,
* refreshGround: (signal?: AbortSignal) => Promise<void>,
* destroy: () => void }>}
*/
export async function drawImportedFile(
engine,
records,
{
visible = true,
loadImage = imageFromUrl,
createCanvas = () => document.createElement('canvas'),
pixelRatio = Math.max(1, Math.min(2, globalThis.devicePixelRatio || 1)),
} = {},
) {
if (!engine?.ground || !engine?.movers || !engine?.lines)
throw new TypeError('The imported file display needs a globe engine');
const prefix = `geo-import-${++serial}`;
const request = () => engine.render.request('geo-import');
let shown = visible;
let destroyed = false;
let removeCull = null;
const groundSets = [];
const lines = [];
const layers = [];
const looks = new Map();
let drawn = 0;
function release() {
destroyed = true;
removeCull?.();
removeCull = null;
for (const set of groundSets.splice(0)) set.destroy();
for (const line of lines.splice(0)) line.destroy();
for (const layer of layers.splice(0)) layer.destroy();
for (const look of looks.values()) look.set?.destroy();
looks.clear();
}
try {
// Fills under ground lines; a plain-colour overlay is a fill too.
const fills = [];
for (const polygon of records.polygons) {
let any = false;
if (polygon.fill) {
const item = checked('polygon', {
rings: polygon.rings.map(flat),
fill: polygon.fill,
});
if (item) {
fills.push(item);
any = true;
}
}
if (polygon.raised && polygon.outline)
for (const ring of polygon.rings) {
if (ring.length < 2) continue;
const closed = [...ring, ring[0]].map(cartesian);
const line = engine.lines.createLine({
id: `${prefix}-outline-${lines.length}`,
widthPx: 1,
color: polygon.outline.color,
});
line.setPositions(closed);
line.show(shown);
lines.push(line);
any = true;
}
if (any) drawn++;
}
for (const overlay of records.overlays) {
if (overlay.url) {
layers.push(
engine.imagery.createLayer({
id: `${prefix}-overlay-${layers.length}`,
source: {
kind: 'image',
url: overlay.url,
rectangle: overlay.rectangle,
},
// Cesium's transparent image material: at most 0.99.
alpha: Math.min(0.99, overlay.color[3]),
show: shown,
order: OVERLAY_ORDER,
}),
);
drawn++;
continue;
}
const { west, south, east, north } = overlay.rectangle;
const item = checked('cell', { west, south, east, north });
if (!item) continue;
const rings = [
[
[west, south],
[east < west ? east + 360 : east, south],
[east < west ? east + 360 : east, north],
[west, north],
],
];
fills.push({ rings, fill: overlay.color });
drawn++;
}
if (fills.length) {
const set = engine.ground.createSet({
id: `${prefix}-polygons`,
kind: 'polygon',
order: POLYGON_ORDER,
});
set.replace(fills);
set.commit();
set.show(shown);
groundSets.push(set);
}
const corridors = [];
for (const record of records.lines) {
if (record.ground) {
const item = checked('corridor', {
positions: flat(record.positions),
color: record.color,
widthPx: record.widthPx,
});
if (!item) continue;
corridors.push(item);
} else {
const line = engine.lines.createLine({
id: `${prefix}-line-${lines.length}`,
widthPx: record.widthPx,
color: record.color,
});
line.setPositions(record.positions.map(cartesian));
line.show(shown);
lines.push(line);
}
drawn++;
}
if (corridors.length) {
const set = engine.ground.createSet({
id: `${prefix}-lines`,
kind: 'corridor',
order: LINE_ORDER,
});
set.replace(corridors);
set.commit();
set.show(shown);
groundSets.push(set);
}
for (const point of records.points) {
const depth = point.altitude === 'absolute' ? 'normal' : 'always';
const key = lookKey(point.icon, depth);
let look = looks.get(key);
if (!look)
looks.set(
key,
(look = { icon: point.icon, depth, pins: [], set: null, count: 0 }),
);
const [lon, lat, height = 0] = point.position;
look.pins.push({
lon,
lat,
offset: point.altitude === 'relative' ? height : 0,
height: point.altitude === 'absolute' ? height : null,
ground: undefined,
position: null,
horizon: null,
show: true,
color: point.icon.color ?? [1, 1, 1, 1],
rotation: point.icon.rotation ?? 0,
});
}
await Promise.all(
[...looks.values()].map(async (look) => {
const url = look.icon.image?.url;
let image;
if (url) {
try {
image = await loadImage(url);
} catch {
// An icon that does not decode draws nothing, as a billboard
// without an image.
return;
}
}
if (destroyed) return;
const { canvas, side } = composeIcon(look.icon, {
image,
createCanvas,
ratio: pixelRatio,
});
look.set = engine.movers.createSet({
id: `${prefix}-pins-${look.depth}-${looks.size}-${++serial}`,
kind: 'sprite',
capacity: look.pins.length,
depthTest: look.depth,
pickable: false,
sprite: {
sizePx: side,
atlas: { canvas, cells: { icon: [0, 0, 1, 1] } },
...(look.icon.scaleByDistance
? { scaleByDistance: [...look.icon.scaleByDistance] }
: {}),
},
});
drawn += look.pins.length;
}),
);
if (destroyed) throw new Error('The imported file was removed');
settle(heightContext());
for (const look of looks.values()) writePins(look);
syncCull();
} catch (error) {
release();
throw error;
}
request();
// Where heights come from now: the terrain, or the 3D tiles under a low
// camera when they are the drawn surface.
function heightContext() {
const surface = engine.surface;
const tiles =
engine.imagery?.surface?.() === 'tiles' &&
surface.clampToHeightSupported &&
engine.camera.height() < CLAMP_TILES_BELOW_M;
const exclude = [...looks.values()].map((look) => look.set).filter(Boolean);
return { surface, tiles, exclude };
}
// The ground under a pin, falling back to the last known one.
function groundUnder(pin, { surface, tiles, exclude }) {
const at = geo.cartographic.fromDegrees(pin.lon, pin.lat, 0);
const terrain = surface.terrainHeightSupported
? surface.terrainHeight(at)
: undefined;
// A coarse ellipsoid mesh under 3D tiles can answer far below zero.
let ground = Number.isFinite(terrain)
? tiles
? Math.max(0, terrain)
: terrain
: (pin.ground ?? 0);
if (tiles) {
const top = surface.clampToHeight(
geo.cartesian3.fromDegrees(pin.lon, pin.lat, Math.max(0, ground)),
exclude,
);
const height = top && geo.cartographic.fromCartesian(top)?.height;
ground = Number.isFinite(height) ? height : (pin.ground ?? ground);
}
return ground;
}
function settle(context, from = 0, to = Infinity) {
let at = 0;
for (const look of looks.values())
for (const pin of look.pins) {
if (at++ < from || at > to) continue;
if (pin.height === null) pin.ground = groundUnder(pin, context);
const height = pin.height ?? pin.ground + pin.offset;
pin.position = geo.cartesian3.fromDegrees(pin.lon, pin.lat, height);
pin.horizon = geo.cartesian3.fromDegrees(
pin.lon,
pin.lat,
height + HORIZON_LIFT_M,
);
}
}
function writePins(look) {
const { set, pins } = look;
if (!set) return;
const culled = look.depth === 'always';
pins.forEach((pin, index) => {
if (culled) pin.show = engine.projector.horizonVisible(pin.horizon);
set.set(index, {
position: pin.position,
cell: 'icon',
color: pin.color,
rotation: pin.rotation,
show: pin.show,
});
});
look.count = pins.length;
set.setCount(shown ? look.count : 0);
set.commit();
}
// Hides the ground points beyond the horizon; true when any show changed.
function cull(look) {
let changed = false;
look.pins.forEach((pin, index) => {
const show = engine.projector.horizonVisible(pin.horizon);
if (show === pin.show) return;
pin.show = show;
look.set.set(index, { show });
changed = true;
});
if (changed) look.set.commit();
return changed;
}
function syncCull() {
const culled = [...looks.values()].filter(
(look) => look.set && look.depth === 'always',
);
if (!culled.length || !shown) {
removeCull?.();
removeCull = null;
return;
}
const lastCamera = geo.cartesian3.create(Number.NaN, 0, 0);
removeCull ??= engine.frames.preRender.addEventListener(() => {
const live = engine.camera.livePosition;
if (geo.cartesian3.equals(live, lastCamera)) return;
geo.cartesian3.clone(live, lastCamera);
let changed = false;
for (const look of culled) changed = cull(look) || changed;
if (changed) request();
});
}
const undrawn = Object.values(records.undrawn).reduce((n, c) => n + c, 0);
return {
report: { drawn, undrawn, kinds: { ...records.undrawn } },
setShow(visibleNow) {
visibleNow = Boolean(visibleNow);
if (destroyed || visibleNow === shown) return;
shown = visibleNow;
for (const set of groundSets) set.show(shown);
for (const line of lines) line.show(shown);
for (const layer of layers) layer.show(shown);
for (const look of looks.values()) {
look.set?.setCount(shown ? look.count : 0);
look.set?.commit();
}
syncCull();
request();
},
/** Sample the ground under the points again (a map or the terrain changed). */
async refreshGround(signal) {
const total = [...looks.values()].reduce((n, l) => n + l.pins.length, 0);
for (let from = 0; from < total; from += BATCH) {
signal?.throwIfAborted();
if (destroyed) return;
settle(heightContext(), from, from + BATCH);
if (from + BATCH < total)
await new Promise((resolve) => setTimeout(resolve, 0));
}
signal?.throwIfAborted();
if (destroyed) return;
for (const look of looks.values()) writePins(look);
request();
},
destroy() {
if (destroyed) return;
release();
request();
},
};
}
+307
View File
@@ -0,0 +1,307 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
import { GROUND_HARNESSES } from '../testSupport/groundDisplayEngines.mjs';
import { drawImportedFile } from './geoImportDisplay.js';
import { geoJsonRecords } from './geoImportRecords.js';
// A 2D context that only records what draws.
const createCanvas = () => ({
getContext: () =>
new Proxy({}, { get: (_, key) => (key in {} ? undefined : () => {}) }),
});
for (const make of GROUND_HARNESSES) {
const displayTest = (name, run) =>
test(`${name} (${make.name})`, async (t) => {
const harness = make({
engineOptions: {
imageryFactories: {
loadImage: async () => ({ width: 1, height: 1 }),
},
},
});
const { engine, viewer } = harness;
if (viewer) {
viewer.imageryLayers = new Cesium.ImageryLayerCollection();
viewer.scene.globe.show = true;
viewer.scene.globe.getHeight = () => 0;
}
t.after(() => harness.destroy());
const pins = [];
const create = engine.movers.createSet;
engine.movers.createSet = (options) => {
const set = create(options);
const record = { options, items: [], set };
pins.push(record);
const write = set.set;
set.set = (i, fields) => {
write(i, fields);
record.items[i] = { ...record.items[i], ...fields };
};
return set;
};
await run({ ...harness, pins });
});
displayTest(
'an imported file draws through the contract and lets go',
async ({ engine, viewer }) => {
if (viewer) viewer.scene.globe.getHeight = () => 25;
else engine.fake.script('terrainHeight', 25);
const records = geoJsonRecords(
{
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { name: 'pin' },
geometry: { type: 'Point', coordinates: [1, 2] },
},
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[1, 2],
[2, 3],
],
},
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[0, 0],
[1, 0],
[1, 1],
[0, 0],
],
],
},
},
],
},
'#39d0ff',
);
records.lines.push({
positions: [
[0, 0, 100],
[1, 1, 200],
],
ground: false,
color: [1, 0, 0, 1],
widthPx: 2,
});
records.overlays.push({
url: 'data:image/png;base64,AA',
rectangle: { west: 0, south: 0, east: 1, north: 1 },
color: [1, 1, 1, 1],
rotation: 0,
});
const drawing = await drawImportedFile(engine, records, { createCanvas });
const ours = (list) =>
list.filter(({ id }) => id.startsWith('geo-import-'));
assert.deepEqual(
ours(engine.ground.list()).map(({ kind }) => kind),
['polygon', 'corridor'],
);
assert.equal(
ours(engine.movers.list()).reduce((n, set) => n + set.count, 0),
1,
);
assert.equal(ours(engine.lines.list()).length, 1);
assert.equal(ours(engine.imagery.list()).length, 1);
assert.equal(drawing.report.drawn, 5);
drawing.setShow(false);
assert.ok(ours(engine.ground.list()).every(({ shown }) => !shown));
assert.equal(ours(engine.movers.list())[0].count, 0);
assert.ok(ours(engine.lines.list()).every(({ shown }) => !shown));
assert.ok(ours(engine.imagery.list()).every(({ shown }) => !shown));
await drawing.refreshGround();
assert.equal(ours(engine.movers.list())[0].count, 0);
drawing.setShow(true);
assert.ok(ours(engine.ground.list()).every(({ shown }) => shown));
assert.ok(ours(engine.lines.list()).every(({ shown }) => shown));
assert.ok(ours(engine.imagery.list()).every(({ shown }) => shown));
assert.equal(ours(engine.movers.list())[0].count, 1);
drawing.destroy();
drawing.destroy();
assert.equal(
ours(engine.ground.list()).length +
ours(engine.movers.list()).length +
ours(engine.lines.list()).length +
ours(engine.imagery.list()).length,
0,
);
},
);
displayTest(
'pins preserve ground, relative and absolute heights across refreshes',
async ({ engine, viewer, pins }) => {
let ground = 25;
if (viewer) viewer.scene.globe.getHeight = () => ground;
else engine.fake.script('terrainHeight', () => ground);
const records = geoJsonRecords(
{
type: 'MultiPoint',
coordinates: [
[1, 2],
[1, 2, 10],
[1, 2, 500],
],
},
'#39d0ff',
);
records.points[1].altitude = 'relative';
records.points[2].altitude = 'absolute';
const drawing = await drawImportedFile(engine, records, { createCanvas });
const heights = () =>
pins.flatMap(({ items }) =>
items.map(({ position }) =>
Math.round(geo.cartographic.fromCartesian(position).height),
),
);
assert.deepEqual(heights(), [25, 35, 500]);
assert.deepEqual(
pins.map(({ options }) => options.depthTest),
['always', 'normal'],
);
assert.ok(pins.every(({ options }) => options.pickable === false));
ground = 70;
await drawing.refreshGround();
assert.deepEqual(heights(), [70, 80, 500]);
ground = undefined;
await drawing.refreshGround();
assert.deepEqual(heights(), [70, 80, 500]);
drawing.destroy();
},
);
displayTest(
'fills keep holes, raised outlines and plain overlays',
async ({ engine, set }) => {
const records = geoJsonRecords(
{
type: 'Polygon',
coordinates: [
[
[0, 0],
[2, 0],
[2, 2],
[0, 0],
],
[
[0.5, 0.5],
[1, 0.5],
[1, 1],
[0.5, 0.5],
],
],
},
'#39d0ff',
);
records.polygons[0].raised = true;
records.polygons[0].rings = records.polygons[0].rings.map((ring) =>
ring.map(([lon, lat]) => [lon, lat, 100]),
);
records.overlays.push({
url: null,
rectangle: { west: 4, south: 4, east: 5, north: 5 },
color: [0, 1, 0, 1],
});
records.undrawn = { wall: 1, extruded: 2 };
const drawing = await drawImportedFile(engine, records, {
createCanvas,
visible: false,
});
const fill = set(engine.ground.list()[0].id);
assert.equal(fill.items.length, 2);
assert.equal(fill.items[0].rings.length, 2);
assert.ok(fill.items[0].rings.flat().every((p) => p.length === 2));
assert.equal(fill.shown, false);
assert.equal(engine.lines.list().length, 2);
assert.ok(engine.lines.list().every((line) => !line.shown));
assert.deepEqual(drawing.report, {
drawn: 2,
undrawn: 3,
kinds: { wall: 1, extruded: 2 },
});
drawing.destroy();
},
);
displayTest(
'failed icon composition releases geometry already created',
async ({ engine }) => {
const records = geoJsonRecords(
{
type: 'GeometryCollection',
geometries: [
{ type: 'Point', coordinates: [1, 2] },
{
type: 'LineString',
coordinates: [
[1, 2],
[2, 3],
],
},
],
},
'#39d0ff',
);
await assert.rejects(
drawImportedFile(engine, records, {
createCanvas: () => ({ getContext: () => null }),
}),
/need a canvas/,
);
assert.equal(engine.ground.list().length, 0);
assert.equal(engine.movers.list().length, 0);
},
);
displayTest(
'ground pins follow the horizon and release frame listeners',
async ({ engine, pins }) => {
let front = true;
engine.projector.horizonVisible = () => front;
const records = geoJsonRecords(
{ type: 'Point', coordinates: [1, 2] },
'#39d0ff',
);
const drawing = await drawImportedFile(engine, records, { createCanvas });
assert.equal(pins[0].items[0].show, true);
front = false;
engine.frames.preRender.raiseEvent();
assert.equal(pins[0].items[0].show, false);
drawing.setShow(false);
front = true;
drawing.setShow(true);
engine.frames.preRender.raiseEvent();
assert.equal(pins[0].items[0].show, true);
drawing.destroy();
engine.frames.preRender.raiseEvent();
assert.equal(engine.movers.list().length, 0);
},
);
displayTest(
'an aborted ground refresh leaves the existing drawing owned',
async ({ engine }) => {
const records = geoJsonRecords(
{ type: 'Point', coordinates: [1, 2] },
'#39d0ff',
);
const drawing = await drawImportedFile(engine, records, { createCanvas });
await assert.rejects(drawing.refreshGround(AbortSignal.abort()), {
name: 'AbortError',
});
assert.equal(engine.movers.list()[0].count, 1);
drawing.destroy();
},
);
}
+410
View File
@@ -0,0 +1,410 @@
// A sanitized KML document (src/data/geoFileImport.js: no network links,
// remote images, models, descriptions or times, tracks as their recorded
// lines) read into the import's plain records (src/data/geoImportRecords.js)
// the way Cesium's KmlDataSource read it, without Cesium: the same shared,
// inline and StyleMap styles and their precedence, the same defaults (a
// yellow map pin, white lines and fills), billboards of 32 px with KML's
// distance scale and hot spot, ground lines for clamped and tessellated
// paths, lines in the air for absolute and relative ones, and ground
// overlays as image rectangles. Labels are not drawn (the import never
// drew them); walls and extruded polygons are counted in `undrawn`.
import { emptyRecords, importFeatureName } from './geoImportRecords.js';
// KmlDataSource's billboard: 32 px, scaled by camera distance.
const BILLBOARD_SIZE = 32;
const BILLBOARD_SCALE_BY_DISTANCE = Object.freeze([
2414016, 1.0, 1.6093e7, 0.1,
]);
// Its pin where no icon is given: Cesium.Color.YELLOW, drawn at 64 px.
const DEFAULT_PIN = Object.freeze({
pin: Object.freeze([1, 1, 0, 1]),
pinSize: 64,
});
const WHITE = Object.freeze([1, 1, 1, 1]);
const GEOMETRY = new Set([
'Point',
'LineString',
'LinearRing',
'Polygon',
'MultiGeometry',
'Model',
'Track',
'MultiTrack',
]);
/** Child elements of `el` with this local name (any namespace). */
const childrenNamed = (el, localName) => {
const out = [];
const kids = el?.childNodes || [];
for (let i = 0; i < kids.length; i++)
if (kids[i].nodeType === 1 && kids[i].localName === localName)
out.push(kids[i]);
return out;
};
const first = (el, localName) => childrenNamed(el, localName)[0];
/** A child's trimmed text, or undefined without the child. */
const textOf = (el, localName) => {
const child = first(el, localName);
return child ? child.textContent.trim() : undefined;
};
const numberOf = (el, localName) => {
const value = Number.parseFloat(textOf(el, localName));
return Number.isFinite(value) ? value : undefined;
};
const booleanOf = (el, localName) => {
const value = textOf(el, localName);
return value === undefined
? undefined
: value === '1' || /^true$/i.test(value);
};
/**
* A KML colour (aabbggrr) as rgba in 0..1, or undefined; `random`
* (colorMode random) draws each non-zero channel at random up to its
* value, as Cesium does, on every read.
*/
export function kmlColor(value, random = false) {
const text = String(value ?? '')
.trim()
.replace(/^#/, '');
if (!text) return undefined;
const channel = (at) => Number.parseInt(text.substring(at, at + 2), 16) / 255;
const [alpha, blue, green, red] = [0, 2, 4, 6].map(channel);
const pick = (v) => (random && v > 0 ? Math.random() * v : v);
return [pick(red), pick(green), pick(blue), alpha];
}
const colorOf = (el, localName) =>
kmlColor(textOf(el, localName), textOf(el, 'colorMode') === 'random');
/** One coordinate tuple; Google Earth reads what is missing as 0. */
function tuple(text) {
const [lon = 0, lat = 0, height = 0] = String(text ?? '')
.split(',')
.map((v) => Number.parseFloat(v))
.map((v) => (Number.isFinite(v) ? v : 0));
return [lon, lat, height];
}
const coordinates = (el) =>
el ? (el.textContent.match(/\S+/g) ?? []).map(tuple) : [];
/** Where a geometry sits, from its altitude modes (Cesium's reading). */
function altitudeOf(geometry) {
const modes = childrenNamed(geometry, 'altitudeMode');
const isGx = (el) => /google\.com\/kml\/ext/.test(el.namespaceURI || '');
const mode = modes.find((el) => !isGx(el))?.textContent.trim();
const gx = modes.find(isGx)?.textContent.trim();
if (mode === 'absolute') return 'absolute';
if (mode === 'relativeToGround') return 'relative';
if (!mode || mode === 'clampToGround')
return gx === 'relativeToSeaFloor' && !mode ? 'relative' : 'ground';
return gx === 'relativeToSeaFloor' ? 'relative' : 'ground';
}
// A style as KmlDataSource builds it on an entity: `icon` (its billboard),
// `line` (its polyline) and `poly` (its polygon); a property left undefined
// is filled by a merged style.
function applyStyle(styleEl, target) {
for (const node of childrenNamed(styleEl, 'IconStyle')) {
const iconEl = first(node, 'Icon');
const href = textOf(iconEl, 'href');
const icon = (target.icon ??= {});
// Blank <Icon> tags mean no icon at all.
icon.image = href ? { url: href } : iconEl ? false : undefined;
icon.scale = numberOf(node, 'scale');
icon.color = colorOf(node, 'color');
const region = ['x', 'y', 'w', 'h'].map((key) => numberOf(iconEl, key));
if (region.some((v) => v !== undefined))
icon.subRegion = region.map((v) => v ?? 0);
const heading = numberOf(node, 'heading');
// Google Earth reads a heading of 0 as none.
if (heading !== undefined && heading !== 0)
icon.rotation = (-heading * Math.PI) / 180;
const offset = hotSpotOffset(first(node, 'hotSpot'), icon.scale ?? 1);
if (offset) icon.offsetPx = offset;
}
for (const node of childrenNamed(styleEl, 'LineStyle'))
target.line = {
width: numberOf(node, 'width'),
color: colorOf(node, 'color'),
};
for (const node of childrenNamed(styleEl, 'PolyStyle')) {
const poly = (target.poly ??= { outline: true, outlineColor: WHITE });
poly.color = colorOf(node, 'color') ?? poly.color;
poly.fill = booleanOf(node, 'fill') ?? poly.fill;
poly.outline = booleanOf(node, 'outline') ?? poly.outline;
}
}
/** A KML hot spot as Cesium's billboard pixel offset (+y down), or null. */
function hotSpotOffset(el, scale) {
if (!el) return null;
const read = (name) => {
const value = Number.parseFloat(el.getAttribute(name));
return Number.isFinite(value) ? value : undefined;
};
const x = read('x');
const y = read('y');
let dx;
let dy;
if (x !== undefined) {
const unit = el.getAttribute('xunits');
if (unit === 'pixels') dx = -x * scale;
else if (unit === 'insetPixels') dx = (x - BILLBOARD_SIZE) * scale;
else if (unit === 'fraction') dx = -x * BILLBOARD_SIZE * scale;
dx = (dx ?? Number.NaN) + BILLBOARD_SIZE * 0.5 * scale;
}
if (y !== undefined) {
const unit = el.getAttribute('yunits');
if (unit === 'pixels') dy = y * scale;
else if (unit === 'insetPixels') dy = (-y + BILLBOARD_SIZE) * scale;
else if (unit === 'fraction') dy = y * BILLBOARD_SIZE * scale;
dy = (dy ?? Number.NaN) - BILLBOARD_SIZE * 0.5 * scale;
}
if (dx === undefined && dy === undefined) return null;
const finite = (v) => (Number.isFinite(v) ? v : 0);
return [finite(dx), finite(dy)];
}
const cloneStyle = (style) => ({
icon: style.icon && { ...style.icon },
line: style.line && { ...style.line },
poly: style.poly && { ...style.poly },
});
/** Entity.merge: what `target` leaves undefined comes from `source`. */
function mergeStyle(target, source) {
if (!source) return target;
for (const key of ['icon', 'line', 'poly']) {
if (!source[key]) continue;
if (!target[key]) target[key] = { ...source[key] };
else
for (const [name, value] of Object.entries(source[key]))
target[key][name] ??= value;
}
return target;
}
/** The document's shared styles by '#id', as KmlDataSource collects them. */
function sharedStyles(doc) {
const styles = new Map();
const all = [...doc.getElementsByTagName('*')];
for (const el of all) {
if (el.localName !== 'Style') continue;
const id = el.getAttribute('id');
if (id === null || styles.has(`#${id}`)) continue;
const style = {};
applyStyle(el, style);
styles.set(`#${id}`, style);
}
for (const el of all) {
if (el.localName !== 'StyleMap') continue;
const id = el.getAttribute('id');
if (id === null) continue;
for (const pair of childrenNamed(el, 'Pair')) {
if (textOf(pair, 'key') !== 'normal' || styles.has(`#${id}`)) continue;
const style = {};
const url = textOf(pair, 'styleUrl');
if (url !== undefined)
mergeStyle(style, styles.get(url.startsWith('#') ? url : `#${url}`));
else {
const inline = first(pair, 'Style');
if (inline) applyStyle(inline, style);
}
styles.set(`#${id}`, style);
}
}
return styles;
}
/** A placemark's final style: its last inline style, then its styleUrl. */
function placemarkStyle(placemark, styles) {
const lookup = (url) => styles.get(url) ?? styles.get(`#${url}`);
const result = {};
const inline = [...(placemark.childNodes || [])]
.filter(
(el) =>
el.nodeType === 1 &&
(el.localName === 'Style' || el.localName === 'StyleMap'),
)
.at(-1);
if (inline?.localName === 'Style') applyStyle(inline, result);
else if (inline)
for (const pair of childrenNamed(inline, 'Pair')) {
if (textOf(pair, 'key') !== 'normal') continue;
const url = textOf(pair, 'styleUrl');
if (url !== undefined) mergeStyle(result, lookup(url));
else {
const style = first(pair, 'Style');
if (style) applyStyle(style, result);
}
}
const external = textOf(placemark, 'styleUrl');
if (external !== undefined) mergeStyle(result, lookup(external));
return result;
}
/** The polyline Cesium draws for a style: its colour (white) and width (1). */
const lineLook = (style) => ({
color: style.line?.color ?? WHITE,
widthPx: style.line?.width ?? 1,
});
function readGeometry(geometry, style, name, records) {
const kind = geometry.localName;
if (kind === 'MultiGeometry') {
for (const child of geometry.childNodes || [])
if (child.nodeType === 1 && GEOMETRY.has(child.localName))
readGeometry(child, style, name, records);
return;
}
const altitude = altitudeOf(geometry);
const extrude = booleanOf(geometry, 'extrude') === true;
if (kind === 'Point') {
const [position = [0, 0, 0]] = coordinates(first(geometry, 'coordinates'));
const icon = style.icon ?? {};
if (icon.image !== false)
records.points.push({
name,
position,
altitude,
icon: {
image: icon.image || DEFAULT_PIN,
widthPx: BILLBOARD_SIZE,
heightPx: BILLBOARD_SIZE,
scale: icon.scale,
color: icon.color,
offsetPx: icon.offsetPx,
subRegion: icon.subRegion,
rotation: icon.rotation,
scaleByDistance: BILLBOARD_SCALE_BY_DISTANCE,
},
});
// A drop line from an extruded point down to the ellipsoid.
if (extrude && altitude !== 'ground')
records.lines.push({
name,
positions: [position, [position[0], position[1], 0]],
ground: false,
...lineLook(style),
});
return;
}
if (kind === 'LineString' || kind === 'LinearRing') {
const positions = coordinates(first(geometry, 'coordinates'));
if (positions.length < 2) return;
const tessellate = booleanOf(geometry, 'tessellate') === true;
if (altitude !== 'ground' && extrude) {
// A wall; its outline's top edge draws as the path.
records.undrawn.wall = (records.undrawn.wall ?? 0) + 1;
records.lines.push({
name,
positions,
ground: false,
...lineLook(style),
});
} else if (altitude === 'ground' && tessellate)
records.lines.push({ name, positions, ground: true, ...lineLook(style) });
else
records.lines.push({
name,
positions:
altitude === 'ground'
? positions.map(([lon, lat]) => [lon, lat, 0])
: positions,
ground: false,
...lineLook(style),
});
return;
}
if (kind === 'Polygon') {
const ringOf = (boundary) =>
coordinates(first(first(boundary, 'LinearRing'), 'coordinates'));
const outer = ringOf(first(geometry, 'outerBoundaryIs'));
if (!outer.length) return;
const holes = childrenNamed(geometry, 'innerBoundaryIs')
.flatMap((boundary) =>
childrenNamed(boundary, 'LinearRing').map((ring) =>
coordinates(first(ring, 'coordinates')),
),
)
.filter((ring) => ring.length);
const poly = style.poly ?? { outline: true, outlineColor: WHITE };
const raised = altitude !== 'ground';
if (raised && extrude)
records.undrawn.extruded = (records.undrawn.extruded ?? 0) + 1;
records.polygons.push({
name,
rings: [outer, ...holes],
raised,
fill: poly.fill === false ? null : (poly.color ?? WHITE),
outline:
poly.outline === false
? null
: {
color: style.line
? (style.line.color ?? WHITE)
: (poly.outlineColor ?? WHITE),
widthPx: style.line?.width ?? 1,
},
});
}
// A Model or a Track left by the sanitizer has nothing to draw.
}
function readGroundOverlay(el, records) {
const name = importFeatureName(textOf(el, 'name'));
const color = colorOf(el, 'color') ?? WHITE;
const quad = first(el, 'LatLonQuad');
if (quad) {
records.undrawn.latLonQuad = (records.undrawn.latLonQuad ?? 0) + 1;
return;
}
const box = first(el, 'LatLonBox');
if (!box) return;
// Cesium's negativePiToPi and latitude clamp.
const lon = (v = 0) =>
v >= -180 && v <= 180 ? v : ((((v + 180) % 360) + 360) % 360) - 180;
const lat = (v = 0) => Math.max(-90, Math.min(90, v));
const rectangle = {
west: lon(numberOf(box, 'west')),
south: lat(numberOf(box, 'south')),
east: lon(numberOf(box, 'east')),
north: lat(numberOf(box, 'north')),
};
if (!(rectangle.north > rectangle.south)) return;
const url = textOf(first(el, 'Icon'), 'href') || null;
records.overlays.push({
name,
url,
rectangle,
color,
rotation: numberOf(box, 'rotation') ?? 0,
});
}
/**
* Read a sanitized KML document into records, placemarks and ground
* overlays in document order.
* @param {Document} doc
* @returns {import('./geoImportRecords.js').ImportedRecords}
*/
export function readKmlRecords(doc) {
const records = emptyRecords();
const styles = sharedStyles(doc);
for (const el of doc.getElementsByTagName('*')) {
if (el.localName === 'GroundOverlay') {
readGroundOverlay(el, records);
continue;
}
if (el.localName !== 'Placemark') continue;
const name = importFeatureName(textOf(el, 'name'));
const style = cloneStyle(placemarkStyle(el, styles));
// Only a placemark's first geometry draws.
const geometry = [...(el.childNodes || [])].find(
(child) => child.nodeType === 1 && GEOMETRY.has(child.localName),
);
if (geometry) readGeometry(geometry, style, name, records);
}
return records;
}
+378
View File
@@ -0,0 +1,378 @@
// The KML reader against Cesium's KmlDataSource, the reference it replaces:
// the same sanitized documents read by both give the same pins, lines,
// polygons and overlays with the same styles. Cesium stays a test
// dependency here only.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
deduplicateKmlStyles,
loadGeoFile,
parseXml,
sanitizeKmlDocument,
} from './geoFileImport.js';
import { kmlColor, readKmlRecords } from './geoImportKml.js';
// happy-dom's getElementsByTagNameNS('*', name) finds nothing in a parsed
// XML document; KmlDataSource queries every element through it.
function byNamespace(namespace, localName) {
return [...this.getElementsByTagName('*')].filter(
(el) =>
(localName === '*' || el.localName === localName) &&
(namespace === '*' || el.namespaceURI === namespace),
);
}
const probe = new DOMParser().parseFromString('<a><b/></a>', 'application/xml');
for (const node of [probe, probe.documentElement])
for (let p = Object.getPrototypeOf(node); p; p = Object.getPrototypeOf(p))
if (Object.hasOwn(p, 'getElementsByTagNameNS')) {
p.getElementsByTagNameNS = byNamespace;
break;
}
const TRAP = 'gev-qa-trap.invalid';
const PNG =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
const kml = (body) => `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">
<Document>${body}</Document></kml>`;
// scripts/qa-geo-import.mjs's KML, with a packed icon and overlay as in its KMZ.
const QA = kml(`
<Style id="s"><IconStyle><Icon><href>${PNG}</href></Icon></IconStyle></Style>
<Style id="palette"><IconStyle><Icon><href>root://icons/palette-4.png</href><gx:x>32</gx:x><gx:y>64</gx:y></Icon></IconStyle></Style>
<NetworkLink><Link><href>https://${TRAP}/link.kml</href></Link></NetworkLink>
<Placemark><name>QA KML pin</name><styleUrl>#s</styleUrl>
<description>&lt;img src="https://${TRAP}/balloon.png"&gt;</description>
<TimeSpan><begin>2019-01-01T00:00:00Z</begin><end>2019-01-02T00:00:00Z</end></TimeSpan>
<Point><coordinates>-122.47,37.77,0</coordinates></Point></Placemark>
<Placemark><name>QA palette pin</name><styleUrl>#palette</styleUrl>
<Point><coordinates>-122.48,37.771,0</coordinates></Point></Placemark>
<Placemark><name>QA moving</name>
<gx:Track><when>2019-01-01T00:00:00Z</when><when>2019-01-01T01:00:00Z</when>
<gx:coord>-122.49 37.767 0</gx:coord><gx:coord>-122.475 37.772 0</gx:coord></gx:Track></Placemark>
<Placemark><name>QA split tracks</name><gx:MultiTrack>
<gx:Track><gx:coord>-122.50 37.767 0</gx:coord><gx:coord>-122.49 37.768 0</gx:coord></gx:Track>
<gx:Track><gx:coord>-122.47 37.771 0</gx:coord><gx:coord>-122.46 37.772 0</gx:coord></gx:Track>
</gx:MultiTrack></Placemark>
<Placemark><name>QA remote style</name><styleUrl>https://${TRAP}/styles.kml#x</styleUrl>
<LineString><coordinates>-122.50,37.766 -122.46,37.772</coordinates></LineString></Placemark>
<GroundOverlay><Icon><href>${PNG}</href></Icon>
<LatLonBox><north>37.77</north><south>37.76</south><east>-122.46</east><west>-122.47</west></LatLonBox></GroundOverlay>`);
// Styles and geometries the import keeps, each the way KML allows.
const STYLED = kml(`
<Style id="red"><LineStyle><color>ff0000ff</color><width>4</width></LineStyle>
<PolyStyle><color>7f00ff00</color></PolyStyle></Style>
<Style id="pin"><IconStyle><scale>1.5</scale><color>ff00ffff</color><heading>30</heading>
<Icon><href>${PNG}</href><gx:x>0</gx:x><gx:y>0</gx:y><gx:w>1</gx:w><gx:h>1</gx:h></Icon>
<hotSpot x="20" y="2" xunits="pixels" yunits="pixels"/></IconStyle></Style>
<Style id="frac"><IconStyle><hotSpot x="0.5" y="0" xunits="fraction" yunits="fraction"/></IconStyle></Style>
<Style id="inset"><IconStyle><scale>2</scale><hotSpot x="4" y="6" xunits="insetPixels" yunits="insetPixels"/></IconStyle></Style>
<Style id="none"><IconStyle><Icon></Icon></IconStyle></Style>
<Style id="hollow"><PolyStyle><fill>0</fill><outline>1</outline></PolyStyle><LineStyle><color>ffff0000</color><width>3</width></LineStyle></Style>
<StyleMap id="map"><Pair><key>normal</key><styleUrl>#red</styleUrl></Pair>
<Pair><key>highlight</key><styleUrl>#pin</styleUrl></Pair></StyleMap>
<StyleMap id="inlineMap"><Pair><key>normal</key><Style><LineStyle><color>ff00ff00</color></LineStyle></Style></Pair></StyleMap>
<Placemark><name>default pin</name><Point><coordinates>1,1</coordinates></Point></Placemark>
<Placemark><name>styled pin</name><styleUrl>#pin</styleUrl><Point><coordinates>1,2,50</coordinates></Point></Placemark>
<Placemark><name>fraction</name><styleUrl>#frac</styleUrl><Point><altitudeMode>absolute</altitudeMode><coordinates>1,3,500</coordinates></Point></Placemark>
<Placemark><name>inset</name><styleUrl>#inset</styleUrl><Point><altitudeMode>relativeToGround</altitudeMode><extrude>1</extrude><coordinates>1,4,80</coordinates></Point></Placemark>
<Placemark><name>hidden icon</name><styleUrl>#none</styleUrl><Point><coordinates>1,5</coordinates></Point></Placemark>
<Placemark><name>mapped line</name><styleUrl>#map</styleUrl><LineString><coordinates>0,0 1,1 2,1</coordinates></LineString></Placemark>
<Placemark><name>inline map line</name><styleUrl>#inlineMap</styleUrl><LineString><coordinates>0,0 1,0</coordinates></LineString></Placemark>
<Placemark><name>override</name><styleUrl>#red</styleUrl><Style><LineStyle><width>9</width></LineStyle></Style>
<LineString><coordinates>0,1 1,2</coordinates></LineString></Placemark>
<Placemark><name>air line</name><styleUrl>#red</styleUrl><LineString><altitudeMode>absolute</altitudeMode><coordinates>0,2,100 1,3,200</coordinates></LineString></Placemark>
<Placemark><name>relative line</name><LineString><tessellate>0</tessellate><altitudeMode>relativeToGround</altitudeMode><coordinates>0,3,10 1,4,20</coordinates></LineString></Placemark>
<Placemark><name>wall</name><styleUrl>#red</styleUrl><LineString><extrude>1</extrude><altitudeMode>absolute</altitudeMode><coordinates>0,4,100 1,5,100</coordinates></LineString></Placemark>
<Placemark><name>ring</name><LinearRing><coordinates>0,0 1,0 1,1 0,0</coordinates></LinearRing></Placemark>
<Placemark><name>filled</name><styleUrl>#red</styleUrl><Polygon>
<outerBoundaryIs><LinearRing><coordinates>0,0 2,0 2,2 0,2 0,0</coordinates></LinearRing></outerBoundaryIs>
<innerBoundaryIs><LinearRing><coordinates>0.5,0.5 1,0.5 1,1 0.5,0.5</coordinates></LinearRing></innerBoundaryIs></Polygon></Placemark>
<Placemark><name>hollow raised</name><styleUrl>#hollow</styleUrl><Polygon><altitudeMode>absolute</altitudeMode>
<outerBoundaryIs><LinearRing><coordinates>3,0,10 4,0,10 4,1,10 3,0,10</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
<Placemark><name>plain</name><Polygon><outerBoundaryIs><LinearRing><coordinates>5,0 6,0 6,1 5,0</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
<Placemark><name>multi</name><styleUrl>#red</styleUrl><MultiGeometry>
<Point><coordinates>7,7</coordinates></Point>
<LineString><coordinates>7,7 8,8</coordinates></LineString>
<MultiGeometry><Polygon><outerBoundaryIs><LinearRing><coordinates>7,0 8,0 8,1 7,0</coordinates></LinearRing></outerBoundaryIs></Polygon></MultiGeometry>
</MultiGeometry></Placemark>
<Folder><Placemark><name>in folder</name><styleUrl>#red</styleUrl><Point><coordinates>9,9</coordinates></Point></Placemark></Folder>
<GroundOverlay><color>80ffffff</color><Icon><href>${PNG}</href></Icon>
<LatLonBox><north>11</north><south>10</south><east>-179</east><west>179</west><rotation>15</rotation></LatLonBox></GroundOverlay>
<GroundOverlay><color>ff0000ff</color><LatLonBox><north>13</north><south>12</south><east>13</east><west>12</west></LatLonBox></GroundOverlay>
<Style id="a"><LineStyle><color>ff123456</color></LineStyle></Style>
<Style id="b"><LineStyle><color>ff123456</color></LineStyle></Style>
<Placemark><name>deduplicated a</name><styleUrl>#a</styleUrl><LineString><coordinates>3,3 4,4</coordinates></LineString></Placemark>
<Placemark><name>deduplicated b</name><styleUrl>#b</styleUrl><LineString><coordinates>4,4 5,5</coordinates></LineString></Placemark>`);
/** A document as the import prepares it for reading. */
function prepared(text) {
const doc = parseXml(text, 'KML');
sanitizeKmlDocument(doc);
deduplicateKmlStyles(doc);
return doc;
}
// To 1e-6, and -0 as 0.
const round = (v) =>
v === undefined ? undefined : Math.round(v * 1e6) / 1e6 + 0;
const rgba = (c) => c && [c.red, c.green, c.blue, c.alpha].map(round);
const lonLatH = (cartesian) => {
const c = Cesium.Cartographic.fromCartesian(cartesian);
return [
Cesium.Math.toDegrees(c.longitude),
Cesium.Math.toDegrees(c.latitude),
c.height,
].map((v) => Math.round(v * 1e4) / 1e4 + 0);
};
const roundPosition = (p) => p.map((v) => Math.round(v * 1e4) / 1e4 + 0);
const HEIGHT_MODE = {
[Cesium.HeightReference.CLAMP_TO_GROUND]: 'ground',
[Cesium.HeightReference.RELATIVE_TO_GROUND]: 'relative',
[Cesium.HeightReference.NONE]: 'absolute',
};
/** What Cesium's KmlDataSource draws of a document, as comparable data. */
async function cesiumReading(text) {
const previous = Cesium.PinBuilder.prototype.fromColor;
Cesium.PinBuilder.prototype.fromColor = (color, size) => ({
pin: rgba(color),
pinSize: size,
});
try {
const ds = await Cesium.KmlDataSource.load(prepared(text), {
clampToGround: true,
});
const now = Cesium.JulianDate.now();
const value = (property) => property?.getValue?.(now);
const out = { points: [], lines: [], polygons: [], overlays: [], walls: 0 };
for (const entity of ds.entities.values) {
const billboard = entity.billboard;
const image = value(billboard?.image);
if (billboard && entity.position && image) {
const offset = value(billboard.pixelOffset);
const rotation = value(billboard.rotation);
const region = value(billboard.imageSubRegion);
out.points.push({
name: entity.name,
position: lonLatH(value(entity.position)),
altitude: HEIGHT_MODE[value(billboard.heightReference)],
image: image.pin ? image : { url: image.url ?? String(image) },
scale: round(value(billboard.scale) ?? 1),
color: rgba(value(billboard.color)) ?? [1, 1, 1, 1],
offset: offset ? [offset.x, offset.y].map(round) : [0, 0],
rotation: round(rotation ?? 0),
subRegion: region
? [region.x, region.y, region.width, region.height]
: undefined,
size: [value(billboard.width), value(billboard.height)],
});
}
if (entity.polyline) {
const ground = value(entity.polyline.clampToGround) === true;
out.lines.push({
name: entity.name,
positions: value(entity.polyline.positions).map((p) => {
const [lon, lat, h] = lonLatH(p);
return ground ? [lon, lat] : [lon, lat, h];
}),
ground,
color: rgba(value(entity.polyline.material)?.color) ??
rgba(entity.polyline.material?.color?.getValue(now)) ?? [
1, 1, 1, 1,
],
width: value(entity.polyline.width) ?? 1,
});
}
if (entity.wall) out.walls += 1;
if (entity.polygon) {
const polygon = entity.polygon;
const hierarchy = value(polygon.hierarchy);
const raised = value(polygon.perPositionHeight) === true;
const ring = (positions) =>
positions.map((p) => {
const [lon, lat, h] = lonLatH(p);
return raised ? [lon, lat, h] : [lon, lat];
});
const fill = value(polygon.fill) ?? true;
const outline = value(polygon.outline) ?? false;
out.polygons.push({
name: entity.name,
rings: [
ring(hierarchy.positions),
...hierarchy.holes.map((h) => ring(h.positions)),
],
raised,
fill: fill
? (rgba(polygon.material?.color?.getValue(now)) ?? [1, 1, 1, 1])
: null,
outline: outline
? {
color: rgba(value(polygon.outlineColor)) ?? [1, 1, 1, 1],
widthPx: value(polygon.outlineWidth) ?? 1,
}
: null,
});
}
if (entity.rectangle) {
const r = value(entity.rectangle.coordinates);
const material = entity.rectangle.material;
const url = material?.image?.getValue(now);
out.overlays.push({
rectangle: [r.west, r.south, r.east, r.north].map((v) =>
round(Cesium.Math.toDegrees(v)),
),
url: url ? (url.url ?? String(url)) : null,
color: rgba(material?.color?.getValue(now)) ??
rgba(material?.getValue?.(now)?.color) ?? [1, 1, 1, 1],
rotation: round(
Cesium.Math.toDegrees(value(entity.rectangle.rotation) ?? 0),
),
});
}
}
return out;
} finally {
Cesium.PinBuilder.prototype.fromColor = previous;
}
}
/** The reader's records of a document, in the same comparable form. */
function ourReading(text) {
const records = readKmlRecords(prepared(text));
return {
points: records.points.map(({ name, position, altitude, icon }) => ({
name,
position: roundPosition(position),
altitude,
image: icon.image.pin
? { pin: icon.image.pin.map(round), pinSize: icon.image.pinSize }
: icon.image,
scale: round(icon.scale ?? 1),
color: (icon.color ?? [1, 1, 1, 1]).map(round),
offset: (icon.offsetPx ?? [0, 0]).map(round),
rotation: round(icon.rotation ?? 0),
subRegion: icon.subRegion,
size: [icon.widthPx, icon.heightPx],
})),
lines: records.lines.map(({ name, positions, ground, color, widthPx }) => ({
name,
positions: positions.map((p) =>
ground ? roundPosition(p).slice(0, 2) : roundPosition(p),
),
ground,
color: color.map(round),
width: widthPx,
})),
polygons: records.polygons.map(
({ name, rings, raised, fill, outline }) => ({
name,
rings: rings.map((ring) =>
ring.map((p) =>
raised ? roundPosition(p) : roundPosition(p).slice(0, 2),
),
),
raised,
fill: fill && fill.map(round),
outline: outline && {
color: outline.color.map(round),
widthPx: outline.widthPx,
},
}),
),
overlays: records.overlays.map(
({ rectangle: r, url, color, rotation }) => ({
rectangle: [r.west, r.south, r.east, r.north].map(round),
url,
// Cesium's transparent image material caps the alpha at 0.99, as the
// display's imagery alpha does.
color: (url
? [...color.slice(0, 3), Math.min(0.99, color[3])]
: color
).map(round),
rotation: round(rotation),
}),
),
walls: records.undrawn.wall ?? 0,
};
}
for (const [name, text] of [
['the QA file', QA],
['styles, altitudes, multigeometries and overlays', STYLED],
]) {
test(`KML records match Cesium's reading: ${name}`, async () => {
const cesium = await cesiumReading(text);
const ours = ourReading(text);
assert.deepEqual(ours.points, cesium.points);
// Cesium draws a wall's outline where the reader draws its top edge as
// a line; every other line is the same.
assert.deepEqual(
ours.lines.filter((line) => line.name !== 'wall'),
cesium.lines,
);
assert.equal(ours.walls, cesium.walls);
assert.deepEqual(ours.polygons, cesium.polygons);
assert.deepEqual(ours.overlays, cesium.overlays);
});
}
test('the QA file reads to pins, ground lines and a packed overlay, with nothing remote', () => {
const records = readKmlRecords(prepared(QA));
assert.deepEqual(
records.points.map(({ name, icon }) => [name, Boolean(icon.image.pin)]),
[
['QA KML pin', false],
// A blocked palette icon falls back to Cesium's yellow pin.
['QA palette pin', true],
],
);
assert.deepEqual(
records.lines.map(({ name, ground, positions }) => [
name,
ground,
positions.length,
]),
[
['QA moving', true, 2],
['QA split tracks', true, 2],
['QA split tracks', true, 2],
['QA remote style', true, 2],
],
);
assert.equal(records.overlays.length, 1);
assert.ok(!JSON.stringify(records).includes(TRAP));
// No time survives: a dated placemark draws for all time.
assert.ok(!/2019/.test(JSON.stringify(records)));
});
test('extruded polygons and quads are counted as undrawn', () => {
const records = readKmlRecords(
prepared(
kml(`<Placemark><Polygon><extrude>1</extrude><altitudeMode>absolute</altitudeMode>
<outerBoundaryIs><LinearRing><coordinates>0,0,9 1,0,9 1,1,9 0,0,9</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
<GroundOverlay><Icon><href>${PNG}</href></Icon><gx:LatLonQuad><coordinates>0,0 1,0 1,1 0,1</coordinates></gx:LatLonQuad></GroundOverlay>`),
),
);
assert.deepEqual(records.undrawn, { extruded: 1, latLonQuad: 1 });
assert.equal(records.polygons[0].raised, true);
});
test('KML colours are aabbggrr; a random colour stays within its channels', () => {
assert.deepEqual(kmlColor('7f00ff00'), [0, 1, 0, 127 / 255]);
assert.equal(kmlColor(' '), undefined);
const [red, green, blue, alpha] = kmlColor('ff0000ff', true);
assert.ok(red >= 0 && red <= 1 && green === 0 && blue === 0 && alpha === 1);
});
test('a KML file loads to records with its name, count and outside content removed', async () => {
const loaded = await loadGeoFile(new File([QA], 'remote-bits.kml'));
assert.equal(loaded.format, 'kml');
assert.equal(loaded.entityCount, 7);
assert.ok(loaded.removed > 0);
assert.equal(loaded.records.points[0].name, 'QA KML pin');
loaded.revoke();
});
+133 -122
View File
@@ -1,128 +1,139 @@
import * as Cesium from 'cesium';
// The images of imported points (UTILITIES ▸ Import), without Cesium: the
// map pin Cesium's PinBuilder drew for a GeoJSON point or a KML placemark
// without an icon, and each point's look composed onto one square sprite
// whose centre is the point (src/data/geoImportDisplay.js), so the pin's
// tip, a KML hot spot or an icon's sub-region lands where Cesium put it.
const STYLE = [
'image',
'scale',
'rotation',
'alignedAxis',
'horizontalOrigin',
'verticalOrigin',
'color',
'eyeOffset',
'pixelOffset',
'width',
'height',
'scaleByDistance',
'translucencyByDistance',
'pixelOffsetScaleByDistance',
'distanceDisplayCondition',
'sizeInMeters',
'disableDepthTestDistance',
'imageSubRegion',
];
/** Cesium.Color's CSS form of rgba in 0..1 (its float-to-byte conversion). */
export function cssOf([red, green, blue, alpha = 1]) {
const byte = (v) => (v === 1 ? 255 : (v * 256) | 0);
const rgb = `${byte(red)},${byte(green)},${byte(blue)}`;
return alpha === 1 ? `rgb(${rgb})` : `rgba(${rgb},${alpha})`;
}
/** Own static imported pins without a per-frame Entity visualizer walk. */
export function createImportedPins(dataSource, viewer) {
class ImportedBillboards extends Cesium.BillboardCollection {
update(frameState) {
// Height queries should find the map, never another imported marker.
// Ordinary click picks and rendered screenshots still include the pins.
if (frameState.passes.pick && frameState.passes.offscreen) return;
super.update(frameState);
}
}
/** Cesium.Color#brighten. */
const brighten = ([red, green, blue, alpha = 1], magnitude) => {
const keep = 1 - magnitude;
return [
1 - (1 - red) * keep,
1 - (1 - green) * keep,
1 - (1 - blue) * keep,
alpha,
];
};
/**
* Draw Cesium's map pin (PinBuilder's drawPin, from its Assets/Textures/
* pin.svg) of `color` (rgba in 0..1) into a `size` square at the context's
* origin.
*/
export function drawPin(context, color, size) {
context.save();
context.scale(size / 24, size / 24);
context.fillStyle = cssOf(color);
context.strokeStyle = cssOf(brighten(color, 0.6));
context.lineWidth = 0.846;
context.beginPath();
context.moveTo(6.72, 0.422);
context.lineTo(17.28, 0.422);
context.bezierCurveTo(18.553, 0.422, 19.577, 1.758, 19.577, 3.415);
context.lineTo(19.577, 10.973);
context.bezierCurveTo(19.577, 12.63, 18.553, 13.966, 17.282, 13.966);
context.lineTo(14.386, 14.008);
context.lineTo(11.826, 23.578);
context.lineTo(9.614, 14.008);
context.lineTo(6.719, 13.965);
context.bezierCurveTo(5.446, 13.983, 4.422, 12.629, 4.422, 10.972);
context.lineTo(4.422, 3.416);
context.bezierCurveTo(4.423, 1.76, 5.447, 0.423, 6.718, 0.423);
context.closePath();
context.fill();
context.stroke();
context.restore();
}
/**
* Where an icon draws around its point, in CSS pixels, as Cesium placed the
* billboard: `width` × `height` (the icon's own size when the record gives
* none) times `scale`, centred on the point (`origin: 'center'`) or standing
* on it (`'bottom'`), then moved by `offsetPx` (+y down). Returns the box's
* centre ([x, y] from the point) and size, and the side of the square
* sprite centred on the point that holds it.
* @param {object} icon An imported point's icon (src/data/geoImportRecords.js).
* @param {{ width: number, height: number }} [natural] The image's own size.
*/
export function iconBox(icon, natural) {
const pinSize = icon.image?.pin ? icon.image.pinSize : 0;
const ownWidth = icon.subRegion?.[2] || natural?.width || pinSize || 32;
const ownHeight = icon.subRegion?.[3] || natural?.height || pinSize || 32;
const scale = icon.scale ?? 1;
const width = (icon.widthPx ?? ownWidth) * scale;
const height = (icon.heightPx ?? ownHeight) * scale;
const [dx, dy] = icon.offsetPx ?? [0, 0];
const center = [dx, dy - (icon.origin === 'bottom' ? height / 2 : 0)];
const side =
2 *
Math.max(
Math.abs(center[0]) + width / 2,
Math.abs(center[1]) + height / 2,
0.5,
);
return { center, width, height, side };
}
const all = dataSource.entities.values.slice();
const now = Cesium.JulianDate.now();
const pins = all.filter((entity) => entity.billboard && entity.position);
if (!pins.length) return { mount() {}, sync() {}, destroy() {} };
const collection = new ImportedBillboards({ scene: viewer.scene });
const billboards = [];
try {
for (const entity of pins) {
const position = entity.position.getValue(now);
if (
!position ||
![position.x, position.y, position.z].every(Number.isFinite)
)
throw new Error('Invalid imported point position.');
const options = {
id: entity,
position,
show:
entity.isShowing && entity.billboard.show?.getValue(now) !== false,
heightReference: Cesium.HeightReference.NONE,
};
for (const key of STYLE) {
const value = entity.billboard[key]?.getValue(now);
if (value !== undefined) options[key] = value;
/**
* Compose one icon onto a square canvas centred on its point (see iconBox).
* `image` is the decoded image of a `url` icon (ignored for a pin);
* `ratio` canvas pixels per CSS pixel. Returns the canvas and the sprite's
* side in CSS pixels.
*/
export function composeIcon(icon, { image, createCanvas, ratio = 1 } = {}) {
const natural = image
? {
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
}
billboards.push(collection.add(options));
}
} catch (error) {
collection.destroy();
throw error;
: undefined;
const box = iconBox(icon, natural);
// At least one canvas pixel per icon pixel, at most 512 a side.
const k = Math.min(ratio, 512 / box.side);
const canvas = createCanvas();
canvas.width = Math.max(1, Math.ceil(box.side * k));
canvas.height = canvas.width;
const context = canvas.getContext('2d');
if (!context) throw new Error('Imported icons need a canvas');
const x = (box.side / 2 + box.center[0] - box.width / 2) * k;
const y = (box.side / 2 + box.center[1] - box.height / 2) * k;
if (icon.image?.pin) {
// PinBuilder draws the pin at its own size; the billboard then
// stretches that canvas over the box.
context.translate(x, y);
context.scale(
box.width / icon.image.pinSize,
box.height / icon.image.pinSize,
);
context.scale(k, k);
drawPin(context, icon.image.pin, icon.image.pinSize);
} else if (image) {
const [sx, sy, sw, sh] = icon.subRegion ?? [
0,
0,
natural.width,
natural.height,
];
// Cesium's imageSubRegion counts y up from the image's bottom.
const top = icon.subRegion ? natural.height - sy - sh : sy;
context.drawImage(
image,
sx,
top,
sw,
sh,
x,
y,
box.width * k,
box.height * k,
);
}
// Rebuild once before the source is added to DataSourceDisplay. Removing
// one entity at a time would itself make this transition quadratic.
dataSource.entities.suspendEvents();
try {
dataSource.entities.removeAll();
for (const entity of all)
if (!entity.billboard || !entity.position)
dataSource.entities.add(entity);
} finally {
dataSource.entities.resumeEvents();
}
Object.defineProperty(dataSource, 'importedEntities', {
value: all,
configurable: true,
});
Object.defineProperty(dataSource, 'importedBillboards', {
value: collection,
configurable: true,
});
let show = dataSource.show,
mounted = false,
destroyed = false;
Object.defineProperty(dataSource, 'show', {
configurable: true,
get: () => show,
set(value) {
show = Boolean(value);
dataSource.entities.show = show;
collection.show = show;
},
});
collection.show = show;
return {
mount() {
if (destroyed || mounted) return;
viewer.scene.primitives?.add(collection);
mounted = true;
},
sync() {
if (destroyed) return;
for (let i = 0; i < pins.length; i++) {
billboards[i].position = pins[i].position.getValue(now);
billboards[i].show =
pins[i].isShowing && pins[i].billboard.show?.getValue(now) !== false;
}
viewer.scene.requestRender?.();
},
destroy() {
if (destroyed) return;
destroyed = true;
if (viewer.trackedEntity && pins.includes(viewer.trackedEntity))
viewer.trackedEntity = undefined;
if (viewer.selectedEntity && pins.includes(viewer.selectedEntity))
viewer.selectedEntity = undefined;
if (mounted && !viewer.scene.primitives?.isDestroyed?.())
viewer.scene.primitives?.remove(collection);
if (!collection.isDestroyed()) collection.destroy();
all.length = 0;
pins.length = 0;
billboards.length = 0;
},
};
return { canvas, side: box.side };
}
+212
View File
@@ -0,0 +1,212 @@
// The parsed-file model of UTILITIES ▸ Import: what a GeoJSON, GPX, KML or
// KMZ file draws, as plain records, engine-neutral and without a Cesium
// DataSource. src/data/geoFileImport.js reads a file into it (GeoJSON and
// GPX here, KML and KMZ in src/data/geoImportKml.js), and
// src/data/geoImportDisplay.js draws it on any globe engine.
//
// Positions are [longitude, latitude, height] in degrees and metres above
// the ellipsoid; colours are rgba in 0..1. Every record has a short
// plain-text `name` (or undefined). The import keeps no descriptions,
// properties or times: a file's HTML never reaches the page, and a dated
// file never moves the app's clock (it draws as it is, for all time).
//
// @typedef {object} ImportedRecords
// @property {ImportedPoint[]} points
// @property {ImportedLine[]} lines
// @property {ImportedPolygon[]} polygons
// @property {ImportedOverlay[]} overlays
// @property {Object<string, number>} undrawn What the file has that no
// engine draws, by kind (`wall`, `extruded`, `latLonQuad`), counted.
//
// @typedef {object} ImportedPoint
// @property {number[]} position
// @property {'ground'|'relative'|'absolute'} altitude On the ground (the
// height is ignored), that high above it, or that high above the
// ellipsoid.
// @property {ImportedIcon} icon
//
// @typedef {object} ImportedIcon How Cesium drew the point's billboard:
// @property {{ pin: number[], pinSize: number } | { url: string }} image
// Cesium's map pin of that colour, drawn at pinSize px, or an image of the
// file's own (a data: or blob: URL; nothing remote).
// @property {number} [widthPx] Drawn width before `scale` (the image's own
// when omitted); `heightPx` likewise.
// @property {number} [heightPx]
// @property {number} [scale] 1 when omitted.
// @property {number[]} [color] Multiplies the image (white when omitted).
// @property {'center'|'bottom'} [origin] The point is the box's centre
// (the default) or the middle of its bottom edge.
// @property {number[]} [offsetPx] [x, y], +y down: the box moved by this.
// @property {number[]} [subRegion] [x, y, width, height] of the image in
// pixels, y up from its bottom (Cesium's imageSubRegion).
// @property {number[]} [scaleByDistance] [nearM, nearScale, farM, farScale].
// @property {number} [rotation] Radians counter-clockwise.
//
// @typedef {object} ImportedLine
// @property {number[][]} positions At least two.
// @property {boolean} ground Draped on the ground (heights ignored), else a
// line through the positions' heights.
// @property {number[]} color
// @property {number} widthPx
//
// @typedef {object} ImportedPolygon
// @property {number[][][]} rings [outer, ...holes], each closed or not.
// @property {boolean} raised Drawn at its positions' heights on Cesium
// (KML absolute and relative altitudes); else draped on the ground.
// @property {number[]|null} fill
// @property {{ color: number[], widthPx: number }|null} outline Cesium
// draws it only on a raised polygon (an outline on terrain is not drawn).
//
// @typedef {object} ImportedOverlay A KML ground overlay.
// @property {string|null} url Its image, or null for a plain colour.
// @property {{ west: number, south: number, east: number, north: number }} rectangle
// @property {number[]} color Multiplies the image; its alpha fades it.
// @property {number} rotation Degrees counter-clockwise (KML LatLonBox).
/** Longest plain-text name kept from a file's features. */
const MAX_IMPORT_NAME_LENGTH = 120;
/** A feature name as short plain text (no control characters), or undefined. */
export function importFeatureName(value) {
const text = String(value ?? '')
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!text) return undefined;
return text.length > MAX_IMPORT_NAME_LENGTH
? `${text.slice(0, MAX_IMPORT_NAME_LENGTH - 1)}…`
: text;
}
/** An empty set of records. */
export const emptyRecords = () => ({
points: [],
lines: [],
polygons: [],
overlays: [],
undrawn: {},
});
/** How many map objects the records hold. */
export const recordCount = (records) =>
records.points.length +
records.lines.length +
records.polygons.length +
records.overlays.length;
/** `#rrggbb` as rgba in 0..1, with Cesium.Color.fromCssColorString's bytes. */
export function rgbaOfHex(css, alpha = 1) {
const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(
String(css).trim(),
);
if (!match) throw new TypeError(`Unsupported colour: ${css}`);
return [
parseInt(match[1], 16) / 255,
parseInt(match[2], 16) / 255,
parseInt(match[3], 16) / 255,
alpha,
];
}
// The look Cesium's GeoJsonDataSource gave an import in the row's colour.
const GEOJSON_STROKE_PX = 3;
const GEOJSON_FILL_ALPHA = 0.25;
const GEOJSON_PIN_PX = 48;
/**
* The records of a checked GeoJSON object (src/data/geoFileImport.js has
* already cut it to names and ground positions), drawn as Cesium drew it
* in the row's colour `color` (`#rrggbb`): points as a 48 px map pin of
* that colour standing on the ground, lines as 3 px ground lines, polygons
* as a fill at 0.25 (the 3 px outline Cesium leaves out on terrain).
* Multipart geometries and geometry collections give one record per part.
* @param {object} geojson
* @param {string} color
* @returns {ImportedRecords}
*/
export function geoJsonRecords(geojson, color) {
const stroke = rgbaOfHex(color);
const fill = [...stroke.slice(0, 3), GEOJSON_FILL_ALPHA];
const records = emptyRecords();
const position = ([lon, lat, height = 0]) => [lon, lat, height];
const stack = [[geojson, undefined]];
while (stack.length) {
const [node, name] = stack.pop();
if (!node) continue;
if (node.type === 'FeatureCollection') {
for (let i = node.features.length - 1; i >= 0; i--)
stack.push([node.features[i], undefined]);
continue;
}
if (node.type === 'Feature') {
stack.push([node.geometry, node.properties?.name]);
continue;
}
if (node.type === 'GeometryCollection') {
for (let i = node.geometries.length - 1; i >= 0; i--)
stack.push([node.geometries[i], name]);
continue;
}
const parts = node.type.startsWith('Multi')
? node.coordinates
: [node.coordinates];
const kind = node.type.replace(/^Multi/, '');
for (const part of parts) {
if (kind === 'Point')
records.points.push({
name,
position: position(part),
altitude: 'ground',
icon: {
image: { pin: stroke, pinSize: GEOJSON_PIN_PX },
origin: 'bottom',
},
});
else if (kind === 'LineString')
records.lines.push({
name,
positions: part.map(position),
ground: true,
color: stroke,
widthPx: GEOJSON_STROKE_PX,
});
else if (kind === 'Polygon' && part[0]?.length)
records.polygons.push({
name,
rings: part.map((ring) => ring.map(position)),
raised: false,
fill,
outline: { color: stroke, widthPx: GEOJSON_STROKE_PX },
});
}
}
return records;
}
/**
* Every position the records draw, as [lon, lat, height], for framing:
* what lies on the ground at height 0, and each overlay's corners.
* @param {ImportedRecords} records
* @returns {number[][]}
*/
export function recordFootprint(records) {
const out = [];
const add = ([lon, lat, height = 0], grounded) => {
if (Number.isFinite(lon) && Number.isFinite(lat))
out.push([lon, lat, grounded || !Number.isFinite(height) ? 0 : height]);
};
for (const point of records.points)
add(point.position, point.altitude === 'ground');
for (const line of records.lines)
for (const position of line.positions) add(position, line.ground);
for (const polygon of records.polygons)
for (const ring of polygon.rings)
for (const position of ring) add(position, !polygon.raised);
for (const { rectangle: r } of records.overlays) {
const east = r.east < r.west ? r.east + 360 : r.east;
for (const lon of [r.west, east])
for (const lat of [r.south, r.north])
add([lon > 180 ? lon - 360 : lon, lat, 0], true);
}
return out;
}
+1 -1
View File
@@ -24,7 +24,7 @@
import {
worldToWindowCoordinates,
horizonOccluder,
} from './render/cesiumProjection.js';
} from '../globe/cesium/sceneProjection.js';
import * as geo from '../geo/index.js';
// A window position through the engine projector the scene carries (the
+1 -1
View File
@@ -1,6 +1,6 @@
# Sky background
Catalogue stars over a soft Milky Way, drawn by `src/sky/starrySky.js` in
Catalogue stars over a soft Milky Way, drawn by `src/globe/cesium/starrySky.js` in
place of Cesium's blocky Tycho-2 sky box.
| File | Content | Size |
+60 -120
View File
@@ -1,5 +1,4 @@
import * as Cesium from 'cesium';
import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js';
import { NO_IMAGERY_HOST } from '../maps/imageryHost.js';
/**
* @file Open-data raster overlays: OpenSeaMap sea marks and OpenSnowMap ski
@@ -16,10 +15,13 @@ import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js';
* - OpenInfraMap serves MapLibre vector tiles, not raster XYZ tiles. That
* needs a vector-tile rendering pipeline, not an imagery-layer drop-in.
*
* The overlays drape on whatever surface the active map stack offers (see
* `maps/imageryHost.js`): the globe on the OSM and Bing stacks, the Google
* Photorealistic tileset's own `imageryLayers` on Google 3D. Only a stack with
* neither leaves them hidden, and the row says so.
* The overlays drape through the globe engine on whatever surface the
* active map stack offers: `engine.imagery` (on Cesium the globe on the OSM
* and Bing stacks, the Google Photorealistic tileset's own imagery on Google
* 3D; the engine follows stack switches), or Navara's own raster overlay
* (`engine.maps.addOverlay`, per-tile levels on its terrain) where the
* engine has one. Only a surface with neither leaves them hidden, and the
* row says so.
* @module data/osmOverlays
*/
@@ -54,101 +56,66 @@ export function osmOverlayHiddenReason(enabled, hostKind) {
return enabled && hostKind === 'none' ? NO_IMAGERY_HOST : null;
}
// Over the heat rasters, under event imagery and the weather.
const OVERLAY_ORDER = 10;
const HOST_KIND = Object.freeze({ globe: 'globe', tiles: 'tileset' });
/**
* Create the layer for one overlay descriptor. Each application catalog gets
* its own instances; the Cesium factories are injectable so tests run without
* WebGL.
* its own instances.
*/
export function createOsmOverlayLayer(
overlay,
{
host = resolveImageryHost,
createProvider = (descriptor) =>
new Cesium.UrlTemplateImageryProvider({
url: descriptor.urlTemplate,
credit: descriptor.credit,
maximumLevel: descriptor.maximumLevel,
}),
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
} = {},
) {
export function createOsmOverlayLayer(overlay) {
if (!OSM_OVERLAYS.includes(overlay))
throw new TypeError(`Unknown open map overlay: ${overlay?.id}`);
let viewer = null;
// A globe engine without a Cesium viewer (Navara) takes the overlay
// through engine.maps.addOverlay; its removal while shown.
let engine = null;
let removeEngineOverlay = null;
let mapStack = null;
let unsubscribeMap = null;
let enabled = false;
let hostInfo = { collection: null, kind: 'none' };
/** The imagery layer this instance owns while enabled, and where it sits. */
/** The drape this instance owns while enabled: an imagery layer or a removal. */
let owned = null;
const usable = (collection) => collection && !collection.isDestroyed?.();
/** Where the drape lies now: 'globe', 'tileset' or 'none'. */
function hostKind() {
if (!owned) return 'none';
if (owned.remove) return 'globe';
return HOST_KIND[engine?.imagery?.surface?.()] ?? 'none';
}
function detach(destroy) {
function release() {
if (!owned) return;
const { collection, layer } = owned;
if (usable(collection) && collection.contains(layer))
collection.remove(layer, destroy);
else if (destroy && !layer.isDestroyed?.()) layer.destroy?.();
owned.collection = null;
if (destroy) owned = null;
const { layer, remove } = owned;
owned = null;
if (remove) remove();
else layer.destroy();
}
function resolveHost() {
return (
host({ viewer, tileset: mapStack?.getImageryHostTileset?.() }) || {
collection: null,
kind: 'none',
}
);
}
/** Create, move or drop the owned imagery layer to match the state. */
/** Create or drop the owned drape to match the state. */
function sync() {
if (!viewer) {
syncEngine();
if (!engine || !enabled) {
release();
return;
}
hostInfo = enabled ? resolveHost() : { collection: null, kind: 'none' };
if (!enabled) {
detach(true);
} else {
owned ||= {
layer: createImageryLayer(createProvider(overlay)),
collection: null,
};
if (owned.collection !== hostInfo.collection) {
detach(false);
if (usable(hostInfo.collection)) {
// Appended, so it draws above the map stack's base imagery.
hostInfo.collection.add(owned.layer);
owned.collection = hostInfo.collection;
}
}
if (owned) return;
const remove = engine.maps?.addOverlay?.({
url: overlay.urlTemplate,
maxZoom: overlay.maximumLevel,
credit: overlay.credit,
});
if (typeof remove === 'function') {
owned = { remove };
return;
}
viewer.scene?.requestRender?.();
}
function syncEngine() {
if (!engine) return;
if (enabled && !removeEngineOverlay)
removeEngineOverlay =
engine.maps?.addOverlay?.({
if (!engine.imagery) return;
owned = {
layer: engine.imagery.createLayer({
id: `osm-overlay-${overlay.id}`,
source: {
kind: 'tiles',
url: overlay.urlTemplate,
maxZoom: overlay.maximumLevel,
maxLevel: overlay.maximumLevel,
credit: overlay.credit,
}) ?? null;
else if (!enabled && removeEngineOverlay) {
removeEngineOverlay();
removeEngineOverlay = null;
}
hostInfo = removeEngineOverlay
? { collection: null, kind: 'globe' }
: { collection: null, kind: 'none' };
},
order: OVERLAY_ORDER,
}),
};
}
return {
@@ -157,33 +124,19 @@ export function createOsmOverlayLayer(
icon: overlay.icon,
source: overlay.source,
updateInterval: 0,
init(nextViewer, { engine: nextEngine = null } = {}) {
this.attachRenderer(nextViewer, { engine: nextEngine });
init(viewer, options) {
this.attachRenderer(viewer, options);
},
/** Drape on a globe: the Cesium viewer's imagery, or the engine's. */
attachRenderer(nextViewer, { engine: nextEngine = null } = {}) {
viewer = nextViewer ?? null;
engine = viewer ? null : nextEngine;
/** Drape on a globe engine's imagery. */
attachRenderer(_viewer, { engine: nextEngine = null } = {}) {
release();
engine = nextEngine;
sync();
},
/** Leave the globe; the enabled state stays for the next one. */
detachRenderer() {
detach(true);
removeEngineOverlay?.();
removeEngineOverlay = null;
viewer = null;
release();
engine = null;
hostInfo = { collection: null, kind: 'none' };
},
/** Follow map-stack switches so the drape moves with the surface. */
attachMapStackController(controller) {
unsubscribeMap?.();
unsubscribeMap = null;
mapStack = controller || null;
const off = controller?.subscribe?.(() => {
if (enabled) sync();
});
unsubscribeMap = typeof off === 'function' ? off : null;
},
enable() {
enabled = true;
@@ -197,32 +150,19 @@ export function createOsmOverlayLayer(
return true;
},
getStats() {
// Without a controller subscription the stats poll doubles as the
// map-stack watch.
if (enabled && viewer && !unsubscribeMap) {
const next = resolveHost();
if (next.collection !== hostInfo.collection) sync();
}
return {
count: enabled ? 1 : 0,
error: osmOverlayHiddenReason(enabled, hostInfo.kind),
error: osmOverlayHiddenReason(enabled, hostKind()),
};
},
/** Owned layer count and host kind, for tests and live gates. */
/** Owned drape count and host kind, for tests and live gates. */
diagnostics() {
return { host: hostInfo.kind, ownedCount: owned ? 1 : 0 };
return { host: hostKind(), ownedCount: owned ? 1 : 0 };
},
destroy() {
enabled = false;
detach(true);
removeEngineOverlay?.();
removeEngineOverlay = null;
release();
engine = null;
unsubscribeMap?.();
unsubscribeMap = null;
mapStack = null;
viewer = null;
hostInfo = { collection: null, kind: 'none' };
},
};
}
+46 -94
View File
@@ -7,67 +7,25 @@ import {
osmOverlayHiddenReason,
} from './osmOverlays.js';
import { NO_IMAGERY_HOST } from '../maps/imageryHost.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
function fakeCollection() {
const layers = [];
const removed = [];
return {
layers,
removed,
add(layer) {
layers.push(layer);
},
contains(layer) {
return layers.includes(layer);
},
remove(layer, destroy) {
const index = layers.indexOf(layer);
if (index < 0) return false;
layers.splice(index, 1);
removed.push({ layer, destroy });
if (destroy) layer.destroyed = true;
return true;
},
};
}
function fakeMapStack() {
const subscribers = new Set();
return {
tileset: null,
getImageryHostTileset() {
return this.tileset;
},
subscribe(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
},
emit() {
for (const fn of subscribers) fn();
},
subscribers,
};
}
/** A layer on the fake engine, whose imagery drapes where `surface()` says. */
function setup(overlay = OSM_OVERLAYS[0]) {
const globe = fakeCollection();
const viewer = {
imageryLayers: globe,
scene: { globe: { show: true }, requestRender() {} },
let surface = 'globe';
const fake = createFakeEngine({ kind: 'cesium' });
const engine = {
...fake,
imagery: { ...fake.imagery, surface: () => surface },
};
const layer = createOsmOverlayLayer(overlay);
layer.init(null, { engine });
return {
layer,
engine,
layers: () =>
engine.imagery.list().filter(({ id }) => id.startsWith('osm-overlay-')),
setSurface: (next) => (surface = next),
};
const created = [];
const layer = createOsmOverlayLayer(overlay, {
createProvider: (descriptor) => ({ descriptor }),
createImageryLayer: (provider) => {
const imagery = { provider, destroyed: false };
created.push(imagery);
return imagery;
},
});
const mapStack = fakeMapStack();
layer.init(viewer);
layer.attachMapStackController(mapStack);
return { layer, viewer, globe, created, mapStack };
}
test('OSM_OVERLAYS: the two shipped layers, each with a tile template and credit', () => {
@@ -104,66 +62,60 @@ test('osmOverlayHiddenReason: only when enabled with no imagery host', () => {
});
test('a layer owns no imagery until enabled and destroys it on disable', () => {
const { layer, globe, created } = setup(OSM_OVERLAYS[1]);
assert.equal(created.length, 0);
const { layer, engine, layers } = setup(OSM_OVERLAYS[1]);
assert.equal(layers().length, 0);
layer.enable();
assert.equal(globe.layers.length, 1);
assert.equal(globe.layers[0].provider.descriptor.id, 'ski-pistes');
assert.equal(layers().length, 1);
const [{ id }] = layers();
assert.equal(id, 'osm-overlay-ski-pistes');
const { source, options } = engine.fake.imageryLayer(id);
assert.equal(source.url, OSM_OVERLAYS[1].urlTemplate);
assert.equal(source.maxLevel, 18);
assert.equal(source.credit, OSM_OVERLAYS[1].credit);
assert.equal(options.order, 10);
assert.deepEqual(layer.getStats(), { count: 1, error: null });
layer.disable();
assert.equal(globe.layers.length, 0);
assert.equal(created[0].destroyed, true);
assert.equal(layers().length, 0);
assert.deepEqual(layer.diagnostics(), { host: 'none', ownedCount: 0 });
assert.deepEqual(layer.getStats(), { count: 0, error: null });
layer.enable();
assert.equal(created.length, 2, 're-enabling creates a fresh imagery layer');
assert.equal(layers().length, 1, 're-enabling creates a fresh layer');
});
test('the overlay follows a map-stack switch onto the Google 3D tileset and back', () => {
const { layer, viewer, globe, mapStack } = setup();
test('the overlay reports the surface the engine drapes it on', () => {
const { layer, setSurface } = setup();
layer.enable();
const [imagery] = globe.layers;
const tileset = { imageryLayers: fakeCollection() };
viewer.scene.globe.show = false;
mapStack.tileset = tileset;
mapStack.emit();
assert.equal(globe.layers.length, 0);
assert.deepEqual(tileset.imageryLayers.layers, [imagery]);
assert.equal(globe.removed[0].destroy, false);
assert.equal(layer.diagnostics().host, 'globe');
setSurface('tiles');
assert.equal(layer.diagnostics().host, 'tileset');
assert.equal(layer.getStats().error, null);
viewer.scene.globe.show = true;
mapStack.tileset = null;
mapStack.emit();
assert.deepEqual(globe.layers, [imagery]);
assert.equal(tileset.imageryLayers.layers.length, 0);
});
test('with no surface to drape on the row explains itself, and recovers when one appears', () => {
const { layer, viewer, globe, mapStack } = setup();
viewer.scene.globe.show = false;
const { layer, setSurface } = setup();
setSurface('none');
layer.enable();
assert.equal(globe.layers.length, 0);
assert.equal(layer.getStats().error, NO_IMAGERY_HOST);
viewer.scene.globe.show = true;
mapStack.emit();
assert.equal(globe.layers.length, 1);
setSurface('globe');
assert.equal(layer.getStats().error, null);
});
test('destroy removes owned imagery and unsubscribes from the map stack', () => {
const { layer, globe, created, mapStack } = setup();
test('destroy and a renderer detach remove the owned imagery', () => {
const { layer, layers, engine } = setup();
layer.enable();
layer.detachRenderer();
assert.equal(layers().length, 0);
layer.attachRenderer(null, { engine });
assert.equal(
layers().length,
1,
'the enabled state stays for the next globe',
);
layer.destroy();
assert.equal(globe.layers.length, 0);
assert.equal(created[0].destroyed, true);
assert.equal(mapStack.subscribers.size, 0);
assert.equal(layers().length, 0);
});
test('on Navara an overlay drapes through engine.maps and follows the globe', () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { trackingEventTypes } from './render/cesiumConstants.js';
import { trackingEventTypes } from '../globe/cesium/screenEvents.js';
import { isPointerFree } from './inputOwnership.js';
export const MAX_TRACKING_CLICK_TRAVEL_PX = 6;
+1 -1
View File
@@ -1 +1 @@
export { createTrail } from './render/cesiumTrailRenderer.js';
export { createTrail } from '../globe/cesium/trail.js';
@@ -39,7 +39,7 @@
* boost. Parking our fix on `enabled` would let that restore resurrect
* the broken shader mid-session; `renderable` is orthogonal to it.
*
* @module app/atmosphereCompat
* @module globe/cesium/atmosphereCompat
*/
/** Minimal vertex shader reproducing the exact out-param-to-varying bind. */
File renamed without changes.
File renamed without changes.
+7 -1
View File
@@ -36,10 +36,13 @@ import { createCesiumSurface } from './surface.js';
* lineFactories?: object,
* groundFactories?: object,
* imageryFactories?: object,
* createStage?: Function,
* createSonar?: Function,
* }} [options] `requestRender` defaults to the render governor; `appClock`
* to the page's app clock; the `create*`/`load*` options and the mover and
* line, ground and imagery factories (movers.js, lines.js, ground.js,
* imagery.js) are test seams.
* imagery.js) are test seams, `createStage` and `createSonar` too
* (render.js).
*
* `render.setContinuous` takes a hold on the app's singleton renderGovernor
* (src/renderGovernor.js), which governs the one viewer it was installed on;
@@ -107,6 +110,9 @@ export function createCesiumEngine(viewer, options = {}) {
kind: 'cesium',
// What only Cesium has, for code still written against the viewer.
extensions: Object.freeze({ cesium: Object.freeze({ viewer }) }),
// Cesium only, not contract: what the contact sonar holds (sonar.js),
// live; the app reads whether its command adapter runs here.
contactSonarDiagnostics: render.sonarDiagnostics,
host: {
container: viewer.container,
canvas,
+18 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { assertGlobeEngine } from '../contract.js';
import { assertGlobeEngine, engineCapabilities } from '../contract.js';
import {
_resetRenderGovernorForTest,
getRenderGovernorDiagnostics,
@@ -55,6 +55,23 @@ test('the engine satisfies the contract shape and changes nothing at creation',
assert.deepEqual(engine.host.size(), { width: 640, height: 600 });
});
test('the engine draws the visual styles, bloom and contact sonar, not the cloud cover', () => {
const viewer = createFakeViewer();
const engine = createCesiumEngine(viewer);
assert.deepEqual(engineCapabilities(engine), {
visualStyles: true,
bloom: true,
cloudCover: false,
contactSonar: true,
movers: true,
lines: true,
ground: true,
imagery: true,
});
assert.equal(engine.contactSonarDiagnostics().contacts, 0);
engine.destroy();
});
test('the render option reaches render.request', () => {
const viewer = createFakeViewer();
const reasons = [];
+22 -1
View File
@@ -48,6 +48,11 @@ export function createCesiumInput(
let handler = null;
let destroyed = false;
const groups = new Map();
// Camera-input suspensions held, and the enableInputs the last resume
// puts back: the controller's own from before the first, or what
// setCameraInputs asked for since.
let suspended = 0;
let resumeTo = true;
const delivery = createPickDelivery({
pick: (position) => picker?.pick(position),
drill: (position, limit) => picker?.drillPick(position, limit) ?? [],
@@ -84,7 +89,23 @@ export function createCesiumInput(
};
},
setCameraInputs(enabled) {
scene.screenSpaceCameraController.enableInputs = Boolean(enabled);
if (suspended) resumeTo = Boolean(enabled);
else scene.screenSpaceCameraController.enableInputs = Boolean(enabled);
},
// The controller's switch: whatever else turned it off counts too.
cameraInputsEnabled: () =>
scene.screenSpaceCameraController?.enableInputs !== false,
suspendCameraInputs() {
const controller = scene.screenSpaceCameraController;
if (suspended++ === 0) resumeTo = controller.enableInputs;
controller.enableInputs = false;
let resumed = false;
return () => {
if (resumed) return;
resumed = true;
if (--suspended === 0)
scene.screenSpaceCameraController.enableInputs = resumeTo;
};
},
};
return {
+26
View File
@@ -105,6 +105,32 @@ test('setCameraInputs toggles the camera controller', () => {
assert.equal(viewer.scene.screenSpaceCameraController.enableInputs, true);
});
test('suspendCameraInputs counts and the last resume restores enableInputs', () => {
const { viewer, input } = setup();
const controller = viewer.scene.screenSpaceCameraController;
assert.equal(input.cameraInputsEnabled(), true);
const outer = input.suspendCameraInputs();
const inner = input.suspendCameraInputs();
assert.equal(controller.enableInputs, false);
assert.equal(input.cameraInputsEnabled(), false);
inner();
inner();
assert.equal(controller.enableInputs, false, 'the outer one still holds');
outer();
assert.equal(controller.enableInputs, true);
assert.equal(input.cameraInputsEnabled(), true);
// Off before the first suspension: off again after the last.
controller.enableInputs = false;
input.suspendCameraInputs()();
assert.equal(controller.enableInputs, false);
// A choice made under a suspension applies at its resume.
const resume = input.suspendCameraInputs();
input.setCameraInputs(true);
assert.equal(controller.enableInputs, false);
resume();
assert.equal(controller.enableInputs, true);
});
test('destroy destroys the handler', () => {
const { handlers, input, destroy } = setup();
input.on('click', () => {});
@@ -3,7 +3,7 @@ import {
SENTINEL2_CREDIT,
SENTINEL2_MAX_ZOOM,
SENTINEL2_URL,
} from './sentinel2.js';
} from '../../maps/sentinel2.js';
// Attribution and service rights are documented in DATA_SOURCES.md.
export const ESRI_ATTRIBUTION_HTML =
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { createCesiumCredits } from '../globe/cesium/credits.js';
import { createMapCredits } from './credits.js';
import { createCesiumCredits } from './credits.js';
import { createMapCredits } from '../../maps/credits.js';
/**
* The scene changes a map source makes on a Cesium viewer: the base imagery
@@ -10,7 +10,7 @@ import { createMapCredits } from './credits.js';
* @param {Cesium.Viewer} viewer
* @param {{
* createImageryLayer?: (provider: object) => object,
* credits?: import('../globe/contract.js').GlobeEngine['credits'],
* credits?: import('../contract.js').GlobeEngine['credits'],
* }} [options] `credits`: the globe engine's credits (default: the viewer's
* credit display through the Cesium engine's credits).
*/
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMapScene } from './sceneApply.js';
import { createMapScene } from './mapScene.js';
function fixture() {
const layers = [];
@@ -91,7 +91,7 @@ test('removeBaseImagery, tilesets, globe and credits', () => {
});
test('the map scene shows its credit through a given credits capability', async () => {
const { createFakeEngine } = await import('../globe/testing/fakeEngine.js');
const { createFakeEngine } = await import('../testing/fakeEngine.js');
const engine = createFakeEngine();
const { viewer, credits } = fixture();
const scene = createMapScene(viewer, {
+3 -6
View File
@@ -5,12 +5,9 @@
import * as Cesium from 'cesium';
import { loadPhotorealisticTileset } from '../../maps/google3d.js';
import { createMapScene } from '../../maps/sceneApply.js';
import {
ESRI_ATTRIBUTION_HTML,
imageryFactoryFor,
} from '../../maps/imagery.js';
import { applyTerrain, terrainSourceFor } from '../../maps/terrain.js';
import { createMapScene } from './mapScene.js';
import { ESRI_ATTRIBUTION_HTML, imageryFactoryFor } from './mapImagery.js';
import { applyTerrain, terrainSourceFor } from './terrain.js';
const IMAGERY_KINDS = new Set(['osm', 'esri-imagery', 'sentinel2', 'ion']);
+1 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { MAP_STACKS } from '../../maps/catalog.js';
import { createDefaultMapSources } from '../../maps/defaultSources.js';
import { ESRI_ATTRIBUTION_HTML } from '../../maps/imagery.js';
import { ESRI_ATTRIBUTION_HTML } from './mapImagery.js';
import { createCesiumMaps } from './maps.js';
import { createFakeViewer } from './testing/fakeViewer.mjs';
+244
View File
@@ -0,0 +1,244 @@
// The app's full-frame effects on Cesium (GlobeRender's visualStyles,
// bloom and setSharpen), through the scene's post-process stages: one stage
// per visual style, each the style's own shader (src/styles), then the
// sharpening; the bloom is the scene's own stage, borrowed. Cesium draws its
// stages in the order they were added, so the six join the scene together,
// styles first, at the first style or sharpen call (creating the engine
// changes nothing), and each is enabled only while it draws. Anime fades the
// sky box's Milky Way with its intensity (starrySky.js). destroy() removes
// the stages and gives the bloom stage back as it was borrowed.
import { PostProcessStage } from 'cesium';
import {
BLOOM_INITIAL,
SHARPEN_INITIAL,
STYLE_INITIAL,
mergeBloom,
mergeSharpen,
mergeStyle,
} from '../contract.js';
import { retroShader } from '../../styles/retro.js';
import { nightVisionShader } from '../../styles/surveillance.js';
import { thermalShader } from '../../styles/thermal.js';
import { animeShader } from '../../styles/anime.js';
import { noirShader } from '../../styles/noir.js';
// The app's styles by their names, in their stage order
// (src/ui/visualPresets.js STYLES).
const STYLE_SHADERS = Object.freeze({
retro: retroShader,
surveillance: nightVisionShader,
thermal: thermalShader,
anime: animeShader,
noir: noirShader,
});
/** Intensity under which a style draws nothing (its stage is disabled). */
const STYLE_VISIBLE_INTENSITY = 0.001;
/** Strength at or under which the bloom draws nothing. */
const BLOOM_DEAD_ZONE = 0.06;
// The bloom stage's uniforms the engine sets, given back on destroy.
const BLOOM_UNIFORMS = [
'glowOnly',
'contrast',
'brightness',
'delta',
'sigma',
'stepSize',
];
/**
* GLSL fragment shader implementing an unsharp-mask sharpening filter.
* Samples a 3x3 neighborhood, computes box blur, then adds the
* difference (center - blur) scaled by `amount` for edge enhancement.
*/
export const SHARPEN_SHADER = /* glsl */ `
uniform sampler2D colorTexture;
uniform vec2 colorTextureDimensions;
uniform float amount;
in vec2 v_textureCoordinates;
void main() {
vec2 uv = v_textureCoordinates;
vec2 texel = 1.0 / colorTextureDimensions;
vec4 center = texture(colorTexture, uv);
vec4 blur = (
texture(colorTexture, uv + vec2(-texel.x, -texel.y)) +
texture(colorTexture, uv + vec2( 0.0, -texel.y)) +
texture(colorTexture, uv + vec2( texel.x, -texel.y)) +
texture(colorTexture, uv + vec2(-texel.x, 0.0)) +
center +
texture(colorTexture, uv + vec2( texel.x, 0.0)) +
texture(colorTexture, uv + vec2(-texel.x, texel.y)) +
texture(colorTexture, uv + vec2( 0.0, texel.y)) +
texture(colorTexture, uv + vec2( texel.x, texel.y))
) / 9.0;
vec4 sharpened = center + (center - blur) * amount;
out_FragColor = vec4(clamp(sharpened.rgb, 0.0, 1.0), center.a);
}
`;
/** A style's uniforms before it is first set: `time` if it animates, then its parameters' defaults. */
function styleDefaults(shader) {
const uniforms = {};
if (shader.fragmentShader.includes('uniform float time')) uniforms.time = 0;
for (const [name, meta] of Object.entries(shader.uniforms || {}))
uniforms[name] = meta.default;
return uniforms;
}
/**
* Cesium's bloom stage uniforms for a strength (0..1): above the dead zone
* the strength eases (smoothstep) into a lower contrast, a brighter offset
* and a wider blur, so more of the frame glows, further out.
*/
export function bloomUniforms(strength) {
const raw =
strength <= BLOOM_DEAD_ZONE
? 0
: (strength - BLOOM_DEAD_ZONE) / (1 - BLOOM_DEAD_ZONE);
const eased = raw * raw * (3 - 2 * raw);
return {
contrast: 255 - eased * 168,
brightness: -0.5 + eased * 0.36,
sigma: 0.28 + eased * 6.3,
delta: 0.2 + eased * 2.25,
stepSize: 1 + eased * 1.25,
};
}
/**
* @param {Cesium.Viewer} viewer
* @param {{ requestRender: (reason: string) => void,
* createStage?: (options: object) => object }} options `createStage` is
* a test seam (Cesium's PostProcessStage).
* @returns {{ render: object, destroy: () => void }} `render`: the
* GlobeRender members (visualStyles, setStyle, style, setBloom, bloom,
* setSharpen, sharpen).
*/
export function createCesiumPostStages(
viewer,
{ requestRender, createStage = (options) => new PostProcessStage(options) },
) {
const { scene } = viewer;
const styles = new Map(
Object.entries(STYLE_SHADERS).map(([name, shader]) => {
const uniforms = styleDefaults(shader);
return [
name,
{
shader,
names: Object.keys(uniforms),
state: { ...STYLE_INITIAL, uniforms },
stage: null,
},
];
}),
);
let sharpen = { ...SHARPEN_INITIAL };
let sharpenStage = null;
let bloom = { ...BLOOM_INITIAL };
let borrowed = null;
let destroyed = false;
// Every stage at once, in draw order: the styles, then the sharpening.
const ensureStages = () => {
if (sharpenStage || destroyed) return;
for (const [name, style] of styles) {
style.stage = createStage({
name: `godsEyeView_${name}`,
fragmentShader: style.shader.fragmentShader,
uniforms: { intensity: 0, ...style.state.uniforms },
});
style.stage.enabled = false;
scene.postProcessStages.add(style.stage);
}
sharpenStage = createStage({
name: 'godsEyeView_sharpen',
fragmentShader: SHARPEN_SHADER,
uniforms: { amount: sharpen.amount },
});
sharpenStage.enabled = false;
scene.postProcessStages.add(sharpenStage);
};
const knownStyle = (name) => {
const style = styles.get(name);
if (!style) throw new TypeError(`Unknown visual style: ${name}`);
return style;
};
const showStyle = (name, { stage, state }) => {
Object.assign(stage.uniforms, state.uniforms);
stage.uniforms.intensity = state.intensity;
stage.enabled = state.intensity > STYLE_VISIBLE_INTENSITY;
// Anime's bands turn the faint Milky Way into blotches, and depth
// cannot tell the sky from far geometry, so the glow fades with it.
if (name === 'anime' && scene.skyBox?.milkyWayVisibility !== undefined)
scene.skyBox.milkyWayVisibility = 1 - state.intensity;
};
// The scene's bloom stage, set up for the app's bloom the first time.
const bloomStage = () => {
const stage = scene.postProcessStages.bloom;
if (!borrowed) {
borrowed = {
enabled: stage.enabled,
uniforms: Object.fromEntries(
BLOOM_UNIFORMS.map((name) => [name, stage.uniforms[name]]),
),
};
stage.uniforms.glowOnly = false;
}
return stage;
};
const render = {
visualStyles: Object.freeze([...styles.keys()]),
setStyle(name, options) {
const style = knownStyle(name);
style.state = mergeStyle(style.state, options, style.names);
if (destroyed) return;
ensureStages();
showStyle(name, style);
requestRender('style-stage');
},
style(name) {
const { intensity, uniforms } = knownStyle(name).state;
return { intensity, uniforms: { ...uniforms } };
},
setBloom(options) {
bloom = mergeBloom(bloom, options);
if (destroyed) return;
const stage = bloomStage();
Object.assign(stage.uniforms, bloomUniforms(bloom.strength));
stage.enabled = bloom.enabled && bloom.strength > BLOOM_DEAD_ZONE;
requestRender('bloom');
},
bloom: () => ({ ...bloom }),
setSharpen(options) {
sharpen = mergeSharpen(sharpen, options);
if (destroyed) return;
ensureStages();
sharpenStage.uniforms.amount = sharpen.amount;
sharpenStage.enabled = sharpen.enabled;
requestRender('sharpen');
},
sharpen: () => ({ ...sharpen }),
};
return {
render,
destroy() {
if (destroyed) return;
destroyed = true;
const collection = scene.postProcessStages;
if (!collection || collection.isDestroyed?.()) return;
if (sharpenStage) {
for (const { stage } of styles.values()) collection.remove(stage);
collection.remove(sharpenStage);
}
if (borrowed) {
const stage = collection.bloom;
Object.assign(stage.uniforms, borrowed.uniforms);
stage.enabled = borrowed.enabled;
}
},
};
}
+149
View File
@@ -0,0 +1,149 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { bloomUniforms, createCesiumPostStages } from './postStages.js';
function setup() {
const stages = [];
const requests = [];
const bloom = {
enabled: false,
uniforms: {
glowOnly: true,
contrast: 128,
brightness: -0.3,
delta: 1,
sigma: 3.78,
stepSize: 5,
},
};
const original = structuredClone(bloom);
const scene = {
skyBox: { milkyWayVisibility: 1 },
postProcessStages: {
bloom,
add: (stage) => stages.push(stage),
remove: (stage) => {
const index = stages.indexOf(stage);
if (index >= 0) stages.splice(index, 1);
return index >= 0;
},
},
};
const effects = createCesiumPostStages(
{ scene },
{
requestRender: (reason) => requests.push(reason),
createStage: (options) => ({ ...options, enabled: true }),
},
);
const stage = (name) =>
stages.find((entry) => entry.name === `godsEyeView_${name}`);
return {
effects,
render: effects.render,
scene,
stages,
stage,
requests,
bloom,
original,
};
}
test('creation adds nothing; the first style or sharpen call adds all six, in draw order', () => {
const { render, stages } = setup();
assert.deepEqual(render.visualStyles, [
'retro',
'surveillance',
'thermal',
'anime',
'noir',
]);
render.bloom();
render.style('thermal');
assert.equal(stages.length, 0);
render.setSharpen({ amount: 1.5 });
assert.deepEqual(
stages.map(({ name }) => name),
[
'godsEyeView_retro',
'godsEyeView_surveillance',
'godsEyeView_thermal',
'godsEyeView_anime',
'godsEyeView_noir',
'godsEyeView_sharpen',
],
);
assert.ok(stages.every((stage) => !stage.enabled));
render.setStyle('noir', { intensity: 0.5 });
assert.equal(stages.length, 6);
});
test('a style draws above 0.001 with its uniforms; style() is a copy; unknown names throw', () => {
const { render, stage, requests } = setup();
assert.deepEqual(render.style('thermal'), {
intensity: 0,
uniforms: {
time: 0,
sensitivity: 0.75,
bloom: 0.65,
mode: 0,
pixelation: 1.5,
palette: 0,
},
});
render.setStyle('thermal', { intensity: 0.0005 });
assert.equal(stage('thermal').enabled, false);
render.setStyle('thermal', { intensity: 1, uniforms: { palette: 1 } });
assert.equal(stage('thermal').enabled, true);
assert.equal(stage('thermal').uniforms.intensity, 1);
assert.equal(stage('thermal').uniforms.palette, 1);
render.style('thermal').uniforms.palette = 0;
assert.equal(render.style('thermal').uniforms.palette, 1);
assert.ok(requests.includes('style-stage'));
assert.throws(() => render.setStyle('sepia', {}), TypeError);
assert.throws(
() => render.setStyle('noir', { uniforms: { palette: 1 } }),
TypeError,
);
assert.throws(() => render.setStyle('noir', { intensity: 2 }), TypeError);
});
test('Anime fades the Milky Way with its intensity; a sky without one is left alone', () => {
const { render, scene } = setup();
render.setStyle('anime', { intensity: 0.25 });
assert.equal(scene.skyBox.milkyWayVisibility, 0.75);
render.setStyle('thermal', { intensity: 1 });
assert.equal(scene.skyBox.milkyWayVisibility, 0.75);
render.setStyle('anime', { intensity: 0 });
assert.equal(scene.skyBox.milkyWayVisibility, 1);
scene.skyBox = {};
render.setStyle('anime', { intensity: 1 });
assert.equal('milkyWayVisibility' in scene.skyBox, false);
});
test('bloom eases the strength above its dead zone and gives the stage back on destroy', () => {
const { effects, render, bloom, original, stages } = setup();
render.setBloom({ enabled: true, strength: 0.05 });
assert.equal(bloom.enabled, false, 'the dead zone glows nothing');
assert.equal(bloom.uniforms.glowOnly, false);
render.setBloom({ strength: 1 });
assert.equal(bloom.enabled, true);
assert.deepEqual(bloom.uniforms, {
glowOnly: false,
...bloomUniforms(1),
});
assert.equal(bloom.uniforms.contrast, 87);
render.setBloom({ enabled: false });
assert.equal(bloom.enabled, false);
assert.deepEqual(render.bloom(), { enabled: false, strength: 1 });
render.setSharpen({ enabled: true });
effects.destroy();
effects.destroy();
assert.equal(stages.length, 0);
assert.deepEqual(bloom, original);
// Later calls keep the state only.
render.setStyle('noir', { intensity: 1 });
assert.equal(stages.length, 0);
assert.equal(render.style('noir').intensity, 1);
});
+57 -12
View File
@@ -1,6 +1,8 @@
// GlobeRender on the app's render governor, which owns Cesium's
// requestRenderMode and maximumRenderTimeChange; the engine only takes a
// hold under its own owner id.
// hold under its own owner id. The full-frame effects (visual styles,
// bloom, sharpening) are the scene's post stages (postStages.js), the
// contact sonar the scene's native draw commands (sonar.js).
import {
governorRequestRender,
@@ -8,16 +10,22 @@ import {
isRenderGovernorInstalled,
releaseContinuousRender,
} from '../../renderGovernor.js';
import { SHARPEN_INITIAL, mergeSharpen } from '../contract.js';
import { CONTACT_SONAR_INITIAL, mergeContactSonar } from '../contract.js';
import { createCesiumPostStages } from './postStages.js';
import { createCesiumSonar } from './sonar.js';
export const RENDER_OWNER = 'globe-engine';
/**
* @param {Cesium.Viewer} viewer
* @param {{ requestRender?: (reason: string) => void }} [options] Defaults to
* the render governor when it governs this viewer, else the scene itself
* (before the governor is installed, or on another viewer).
* @returns {{ render: import('../contract.js').GlobeRender, destroy: () => void }}
* @param {{ requestRender?: (reason: string) => void,
* createStage?: (options: object) => object,
* createSonar?: typeof createCesiumSonar }} [options] `requestRender`
* defaults to the render governor when it governs this viewer, else the
* scene itself (before the governor is installed, or on another viewer);
* `createStage` (postStages.js) and `createSonar` are test seams.
* @returns {{ render: import('../contract.js').GlobeRender,
* sonarDiagnostics: () => object, destroy: () => void }}
*/
export function createCesiumRender(
viewer,
@@ -26,10 +34,36 @@ export function createCesiumRender(
if (isRenderGovernorInstalled(viewer)) governorRequestRender(reason);
else viewer.scene.requestRender?.();
},
createStage,
createSonar = createCesiumSonar,
} = {},
) {
let held = false;
let sharpen = { ...SHARPEN_INITIAL };
const effects = createCesiumPostStages(viewer, {
requestRender,
...(createStage ? { createStage } : {}),
});
const sonar = createSonar(viewer);
let contactSonar = { ...CONTACT_SONAR_INITIAL };
// Whether Cesium is drawing a frame: a sonar change outside one asks for
// a frame, one inside it (the app's, every frame) draws in it.
let drawing = false;
let removeFrameWatch = null;
const watchFrames = () => {
const { scene } = viewer;
if (removeFrameWatch || !scene.preUpdate?.addEventListener) return;
const removers = [
scene.preUpdate.addEventListener(() => {
drawing = true;
}),
scene.postRender.addEventListener(() => {
drawing = false;
}),
];
removeFrameWatch = () => {
for (const remove of removers) remove();
};
};
const render = {
request(reason = RENDER_OWNER) {
requestRender(reason);
@@ -41,17 +75,28 @@ export function createCesiumRender(
},
// The scene's real mode: another owner's hold keeps it continuous.
isContinuous: () => viewer.scene.requestRenderMode === false,
// State only: in Cesium mode the app's own post stage sharpens
// (src/ui/visualEffects.js), and the scene stays as it was.
setSharpen(options) {
sharpen = mergeSharpen(sharpen, options);
...effects.render,
setContactSonar(options) {
const next = mergeContactSonar(contactSonar, options);
const changed = Object.keys(next).some(
(key) => next[key] !== contactSonar[key],
);
contactSonar = next;
if (!changed) return;
watchFrames();
sonar.set({ ...next });
if (!drawing) requestRender('cyber-sonar');
},
sharpen: () => ({ ...sharpen }),
contactSonar: () => ({ ...contactSonar }),
};
return {
render,
sonarDiagnostics: () => sonar.diagnostics(),
destroy() {
if (held) render.setContinuous(false);
removeFrameWatch?.();
sonar.destroy();
effects.destroy();
},
};
}
File renamed without changes.
File renamed without changes.
+214
View File
@@ -0,0 +1,214 @@
// The Cyber HUD sonar's contact treatment on Cesium (render.setContactSonar,
// the `contactSonar` capability), drawn from the state the app hands the
// engine each frame (src/cyberSonarEngine.js). Native dots, icons and
// glyphs get derived draw commands whose shaders apply the sweep
// (sonarCommands.js); models keep their reversible CPU colour treatment
// (src/cyberSonar.js applyCyberSonarPrimitive), without walking native
// records. Nothing joins the scene before the treatment is first on; turning
// it off gives the models their colours back at once.
import * as Cesium from 'cesium';
import {
applyCyberSonarPrimitive,
cyberSonarSampler,
} from '../../cyberSonar.js';
import {
CYBER_SONAR_CESIUM_VERSION,
createCyberSonarGpu,
} from './sonarCommands.js';
const alive = (value) => value && !value.isDestroyed?.();
export function visitCyberSonarModels(primitive, visit, visible = true) {
if (!alive(primitive)) return;
visible = visible && primitive.show !== false;
if (primitive instanceof Cesium.PrimitiveCollection) {
for (let i = 0; i < primitive.length; i++)
visitCyberSonarModels(primitive.get(i), visit, visible);
} else if (primitive instanceof Cesium.Model) {
visit(primitive, primitive, 'model', visible && primitive.ready);
}
}
/**
* The sonar on `viewer`'s scene. `set(state)` takes the merged
* ContactSonar (src/globe/contract.js) for the next drawn frame;
* `diagnostics()` is what the treatment holds, live: whether the command
* adapter runs on this Cesium (`supported`), the models it tinted last
* frame (`contacts`) and the native batches it drew (`gpu`: commands,
* visibleCommands, programs, error).
* @param {Cesium.Viewer} viewer
* @param {{ createGpu?: typeof createCyberSonarGpu }} [options] A test seam.
*/
export function createCesiumSonar(
viewer,
{ createGpu = createCyberSonarGpu } = {},
) {
const scene = viewer?.scene;
let state = null;
let treatment = null;
const stats = { contacts: 0, updateMs: 0, frames: 0, gpu: null };
// Read every frame (src/cyberSonarEngine.js): one live view, no copies.
const report = Object.freeze({
get supported() {
return (
stats.gpu?.supported ?? Cesium.VERSION === CYBER_SONAR_CESIUM_VERSION
);
},
get contacts() {
return stats.contacts;
},
get updateMs() {
return stats.updateMs;
},
get frames() {
return stats.frames;
},
get gpu() {
return stats.gpu;
},
});
function attach() {
if (
!scene?.postUpdate?.addEventListener ||
!scene?.preRender?.addEventListener
)
return null;
const tracked = new Map();
const matrix = new Cesium.Matrix4();
const position = new Cesium.Cartesian3();
let epoch = 0;
let sampler, width, height, active, count;
const gpu = createGpu(scene, () => state);
stats.gpu = gpu.stats;
const restore = (visual, entry) => {
if (!alive(entry.owner)) return;
for (const key of entry.keys)
applyCyberSonarPrimitive(visual, 1, false, undefined, key);
};
const restoreAll = () => {
for (const [visual, entry] of tracked) restore(visual, entry);
tracked.clear();
stats.contacts = 0;
stats.updateMs = 0;
};
const visit = (visual, owner, kind, visible) => {
let entry = tracked.get(visual);
if (!visible) {
if (entry) {
restore(visual, entry);
tracked.delete(visual);
}
return;
}
let p = visual.position;
if (kind === 'model')
p = Cesium.Matrix4.getTranslation(visual.modelMatrix, position);
else if (
p &&
owner.modelMatrix &&
!Cesium.Matrix4.equals(owner.modelMatrix, Cesium.Matrix4.IDENTITY)
)
p = Cesium.Matrix4.multiplyByPoint(owner.modelMatrix, p, position);
if (!p) return;
const w =
matrix[3] * p.x + matrix[7] * p.y + matrix[11] * p.z + matrix[15];
const x =
(matrix[0] * p.x + matrix[4] * p.y + matrix[8] * p.z + matrix[12]) / w;
const y =
(matrix[1] * p.x + matrix[5] * p.y + matrix[9] * p.z + matrix[13]) / w;
if (w <= 0 || Math.abs(x) > 1.1 || Math.abs(y) > 1.1) return;
if (!entry) {
entry = {
owner,
keys:
kind === 'label'
? ['fillColor', 'outlineColor', 'backgroundColor']
: kind === 'marker' && visual.outlineColor
? ['color', 'outlineColor']
: ['color'],
epoch,
};
tracked.set(visual, entry);
}
entry.epoch = epoch;
const factor = active
? sampler.at((x + 1) * width * 0.5, (1 - y) * height * 0.5)
: sampler.neutral;
for (const key of entry.keys) {
if (key === 'outlineColor' && visual.outlineWidth === 0) continue;
if (key === 'backgroundColor' && !visual.showBackground) continue;
applyCyberSonarPrimitive(
visual,
kind === 'label' ? sampler.label(factor) : factor,
true,
sampler,
key,
kind === 'model',
);
}
count++;
};
const update = () => {
if (!state?.enabled || !gpu.stats.supported) {
restoreAll();
return;
}
const started = performance.now();
width = scene.canvas.clientWidth;
height = scene.canvas.clientHeight;
sampler = cyberSonarSampler(width, height, state);
active = state.active;
Cesium.Matrix4.multiply(
scene.camera.frustum.projectionMatrix,
scene.camera.viewMatrix,
matrix,
);
epoch++;
count = 0;
visitCyberSonarModels(scene.primitives, visit);
for (const [visual, entry] of tracked)
if (entry.epoch !== epoch) {
restore(visual, entry);
tracked.delete(visual);
}
stats.contacts = count;
stats.updateMs = performance.now() - started;
stats.frames++;
};
let removeRender;
// Aircraft fleet writers run in preRender, while tracked models and
// entity visualizers update earlier. Compose AFTER all those owners and
// BEFORE Cesium uploads primitive attributes for this draw. Requeue at
// postUpdate so writers installed later (enable/re-enable/track) cannot
// overwrite sonar. This only reorders one callback; the contact walk
// runs once per actual draw.
const removeUpdate = scene.postUpdate.addEventListener(() => {
removeRender?.();
removeRender = scene.preRender.addEventListener(update);
});
return {
restoreAll,
destroy() {
removeUpdate();
removeRender?.();
gpu.destroy();
restoreAll();
},
};
}
return {
set(next) {
state = next;
if (next.enabled) treatment ??= attach();
else treatment?.restoreAll();
},
diagnostics: () => report,
destroy() {
treatment?.destroy();
treatment = null;
},
};
}
+219
View File
@@ -0,0 +1,219 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { CONTACT_SONAR_INITIAL } from '../contract.js';
import { createCesiumSonar, visitCyberSonarModels } from './sonar.js';
import { createCesiumRender } from './render.js';
const ON = Object.freeze({
...CONTACT_SONAR_INITIAL,
enabled: true,
active: true,
angleDeg: 90,
opacity: 40,
});
// A scene with its frame events, camera and the given primitives.
function sonarScene(primitives, extra = {}) {
let requests = 0;
const scene = {
primitives,
preUpdate: new Cesium.Event(),
postUpdate: new Cesium.Event(),
preRender: new Cesium.Event(),
postRender: new Cesium.Event(),
canvas: { clientWidth: 1000, clientHeight: 800 },
drawingBufferWidth: 1000,
drawingBufferHeight: 800,
camera: {
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
viewMatrix: Cesium.Matrix4.IDENTITY,
},
requestRender() {
requests++;
},
...extra,
};
const draw = () => {
scene.preUpdate.raiseEvent();
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
scene.postRender.raiseEvent();
};
return { scene, viewer: { scene }, draw, requests: () => requests };
}
// A model-shaped fixture exercises the actual scene walk without loading a
// glTF or allocating a WebGL context. Rendering remains the browser gate.
function fakeModel(color) {
return Object.create(Cesium.Model.prototype, {
ready: { value: true },
show: { value: true },
color: { value: color.clone(), writable: true },
modelMatrix: { value: Cesium.Matrix4.IDENTITY },
});
}
test('model walk never enumerates native contact or EntityCluster records', () => {
const root = new Cesium.PrimitiveCollection();
const points = root.add(new Cesium.PointPrimitiveCollection());
points.add();
points.get = () => {
throw new Error('must not enumerate contacts');
};
const cluster = root.add(new Cesium.EntityCluster());
Object.defineProperty(cluster, '_billboardCollection', {
get() {
throw new Error('must not access cluster internals');
},
configurable: true,
});
const seen = [];
visitCyberSonarModels(root, (...v) => seen.push(v));
assert.equal(seen.length, 0);
delete cluster._billboardCollection;
root.destroy();
});
test('nothing joins the scene until the treatment is first on', () => {
const primitives = new Cesium.PrimitiveCollection();
const original = primitives.update;
const { viewer, scene } = sonarScene(primitives);
const sonar = createCesiumSonar(viewer);
sonar.set({ ...CONTACT_SONAR_INITIAL });
assert.equal(primitives.update, original);
assert.equal(scene.postUpdate.numberOfListeners, 0);
sonar.set({ ...ON });
assert.notEqual(primitives.update, original);
assert.equal(scene.postUpdate.numberOfListeners, 1);
sonar.destroy();
sonar.destroy();
assert.equal(primitives.update, original);
assert.equal(scene.postUpdate.numberOfListeners, 0);
assert.equal(scene.preRender.numberOfListeners, 0);
primitives.destroy();
});
test('native owner colors remain untouched; models take the sweep and get theirs back', () => {
const primitives = new Cesium.PrimitiveCollection({
destroyPrimitives: false,
});
const points = primitives.add(new Cesium.PointPrimitiveCollection());
const point = points.add({ color: new Cesium.Color(0.3, 0.7, 0.5, 0.8) });
const nativeColor = new Cesium.Color(0.2, 0.4, 0.8, 1);
const model = fakeModel(nativeColor);
primitives.add(model);
const { viewer, draw } = sonarScene(primitives);
const sonar = createCesiumSonar(viewer);
try {
for (const active of [true, false, true]) {
for (const opacity of [35, 84, 100]) {
sonar.set({ ...ON, active, opacity });
point.color = new Cesium.Color(0.6, 0.4, 0.2, 0.7);
draw();
assert.equal(point.color.alpha, 0.7);
assert.equal(point.color.red, 0.6);
}
}
assert.notDeepEqual(model.color, nativeColor, 'the model is tinted');
assert.equal(sonar.diagnostics().contacts, 1);
// Off: the model's colour comes back at once, before any frame.
sonar.set({ ...ON, enabled: false });
assert.deepEqual(model.color, nativeColor);
assert.equal(sonar.diagnostics().contacts, 0);
} finally {
sonar.destroy();
points.destroy();
primitives.destroy();
}
});
test('a shader failure turns the treatment off and the next frame restores the models', () => {
const nativeColor = new Cesium.Color(0.2, 0.4, 0.8, 1);
const model = fakeModel(nativeColor);
const primitives = new Cesium.PrimitiveCollection({
destroyPrimitives: false,
});
primitives.add(model);
const points = new Cesium.PointPrimitiveCollection();
const nativeCommand = {
owner: points,
uniformMap: {},
shaderProgram: {
vertexShaderSource: new Cesium.ShaderSource({
sources: ['void main() {}'],
}),
},
};
primitives.update = (frame) => frame.commandList.push(nativeCommand);
const { viewer, scene } = sonarScene(primitives);
const sonar = createCesiumSonar(viewer);
try {
assert.equal(sonar.diagnostics().supported, true);
sonar.set({ ...ON, active: false });
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
assert.notDeepEqual(
model.color,
nativeColor,
'Cyber neutral treatment precedes shader compilation',
);
const frame = { commandList: [], passes: { render: true } };
primitives.update(frame);
assert.equal(sonar.diagnostics().supported, false);
assert.equal(frame.commandList[0], nativeCommand);
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
assert.deepEqual(
model.color,
nativeColor,
'the next draw restores native model color',
);
assert.equal(sonar.diagnostics().contacts, 0);
} finally {
sonar.destroy();
points.destroy();
primitives.destroy();
}
});
test('render.setContactSonar merges, hands the sonar a copy and asks for a frame only outside one', () => {
const primitives = new Cesium.PrimitiveCollection();
const { viewer, draw, scene } = sonarScene(primitives);
const reasons = [];
const states = [];
const { render, sonarDiagnostics, destroy } = createCesiumRender(viewer, {
requestRender: (reason) => reasons.push(reason),
createSonar: () => ({
set: (state) => states.push(state),
diagnostics: () => ({ supported: true }),
destroy() {},
}),
});
assert.deepEqual(render.contactSonar(), CONTACT_SONAR_INITIAL);
render.setContactSonar({ enabled: true, angleDeg: 10 });
assert.deepEqual(reasons, ['cyber-sonar']);
assert.deepEqual(states.at(-1), {
...CONTACT_SONAR_INITIAL,
enabled: true,
angleDeg: 10,
});
// The app sets it every frame, in preRender: no request then.
const off = scene.preRender.addEventListener(() =>
render.setContactSonar({ angleDeg: 20 }),
);
draw();
off();
assert.deepEqual(reasons, ['cyber-sonar']);
assert.equal(render.contactSonar().angleDeg, 20);
// An unchanged state is not handed on.
const handed = states.length;
render.setContactSonar({ angleDeg: 20 });
assert.equal(states.length, handed);
assert.throws(() => render.setContactSonar({ range: 0 }), TypeError);
assert.equal(sonarDiagnostics().supported, true);
destroy();
assert.equal(scene.preUpdate.numberOfListeners, 0);
assert.equal(scene.postRender.numberOfListeners, 0);
primitives.destroy();
});
@@ -1,3 +1,7 @@
// The Cyber HUD sonar on Cesium's native dots, icons and glyphs
// (render.setContactSonar, sonar.js): each drawn point or billboard batch
// gets a derived draw command whose shader applies the sweep.
import * as Cesium from 'cesium';
// Narrow, version-tested engine adapter. Cesium has no public custom shader
@@ -137,6 +141,7 @@ export function isCyberSonarCommandVisible(command, frameState, culling) {
/** Work scales with draw commands, not the number of contacts in a collection.
* Source programs, colors, pick IDs, render states and other scenes stay owned
* by Cesium. Only this scene's rendered command slice receives derived shaders.
* `readFrame` returns the sonar state to draw (the contract's ContactSonar).
*/
export function createCyberSonarGpu(scene, readFrame) {
const root = scene.primitives;
@@ -190,7 +195,7 @@ export function createCyberSonarGpu(scene, readFrame) {
}
viewport.x = scene.drawingBufferWidth;
viewport.y = scene.drawingBufferHeight;
uniforms.x = frame.angle;
uniforms.x = frame.angleDeg;
uniforms.y =
(Math.min(viewport.x * 0.4, viewport.y * 0.52) * frame.range) / 100;
uniforms.z = frame.opacity / 100;
@@ -7,7 +7,7 @@ import {
CYBER_SONAR_CESIUM_VERSION,
isCyberSonarCommandVisible,
syncCyberSonarCommand,
} from './cyberSonarGpu.js';
} from './sonarCommands.js';
test('stable commands reuse uniforms and derived pipelines while all native fields remain live', () => {
const native = new Cesium.DrawCommand({
@@ -197,7 +197,7 @@ test('unknown native shaders fail closed without losing native commands', () =>
() => ({
enabled: true,
active: true,
angle: 0,
angleDeg: 0,
range: 100,
opacity: 84,
sector: 24,
@@ -272,7 +272,7 @@ test('program swaps, layer removal and late shader failure release only adapter
() => ({
enabled,
active: true,
angle: 0,
angleDeg: 0,
range: 100,
opacity: 84,
sector: 24,
@@ -1,7 +1,7 @@
import * as Cesium from 'cesium';
import { loadBundledJson } from '../data/bundledJson.js';
import { governorRequestRender } from '../renderGovernor.js';
import { buildStarVertices, STAR_VERTEX_FLOATS } from './stars.js';
import { loadBundledJson } from '../../data/bundledJson.js';
import { governorRequestRender } from '../../renderGovernor.js';
import { buildStarVertices, STAR_VERTEX_FLOATS } from '../../sky/stars.js';
/**
* Space background in the `Scene#skyBox` slot: catalogue stars as point
@@ -18,32 +18,32 @@ export const MILKY_WAY_INTENSITY = 0.4;
export const SKY_ROTATION_EPSILON_SECONDS = 1;
const STAR_PACK_URL = new URL(
'../data/local_data/sky/bright_stars.json',
'../../data/local_data/sky/bright_stars.json',
import.meta.url,
);
const MILKY_WAY_FACE_URLS = Object.freeze({
positiveX: new URL(
'../data/local_data/sky/milkyway_px.webp',
'../../data/local_data/sky/milkyway_px.webp',
import.meta.url,
),
negativeX: new URL(
'../data/local_data/sky/milkyway_nx.webp',
'../../data/local_data/sky/milkyway_nx.webp',
import.meta.url,
),
positiveY: new URL(
'../data/local_data/sky/milkyway_py.webp',
'../../data/local_data/sky/milkyway_py.webp',
import.meta.url,
),
negativeY: new URL(
'../data/local_data/sky/milkyway_ny.webp',
'../../data/local_data/sky/milkyway_ny.webp',
import.meta.url,
),
positiveZ: new URL(
'../data/local_data/sky/milkyway_pz.webp',
'../../data/local_data/sky/milkyway_pz.webp',
import.meta.url,
),
negativeZ: new URL(
'../data/local_data/sky/milkyway_nz.webp',
'../../data/local_data/sky/milkyway_nz.webp',
import.meta.url,
),
});
@@ -149,7 +149,7 @@ export class StarrySky {
/** Matches `SkyBox#show`. */
this.show = true;
this.milkyWayIntensity = MILKY_WAY_INTENSITY;
/** 0..1 multiplier a style sets to hide the glow (see visualEffects.js). */
/** 0..1 multiplier a style sets to hide the glow (see postStages.js). */
this.milkyWayVisibility = 1;
this._requestRender = requestRender;
this._rotation = Cesium.Matrix3.clone(Cesium.Matrix3.IDENTITY);
File renamed without changes.
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { createTerrainRetryPolicy } from './terrainRetry.js';
import { createTerrainRetryPolicy } from '../../maps/terrainRetry.js';
/** Re:Earth / Mapterhorn ellipsoidal quantized mesh, CC BY 4.0. */
export const KEYLESS_TERRAIN_URL =
@@ -20,7 +20,7 @@
* carrying a pick id the layers' click handlers resolve.
*/
import * as Cesium from 'cesium';
import { registerPickOwner } from '../pickRegistry.js';
import { registerPickOwner } from '../../data/pickRegistry.js';
// Round 6: trail ENTITIES are pickable (the old Primitive had
// allowPicking:false). A trail hugs its aircraft, so an unclaimed pick would
@@ -1,10 +1,11 @@
import * as Cesium from 'cesium';
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
import { StarrySky } from '../sky/starrySky.js';
import { StarrySky } from './starrySky.js';
import { loadPhotorealisticTileset } from '../../maps/google3d.js';
import {
FREE_LOOK_MAX_HEIGHT_METERS,
globeOutOfFrame,
} from '../globeInView.js';
} from '../../globeInView.js';
// Dev convenience: expose the app's Cesium instance for console/preview probing
// (single shared module instance — avoids dual-Cesium state bugs when testing).
@@ -376,6 +377,15 @@ export function installGlobeInViewRecovery(
};
}
/**
* Google's photorealistic 3D tiles for the startup stack, on this Cesium
* (src/maps/google3d.js loadPhotorealisticTileset, which takes the SDK).
* @param {{ googleApiKey?: string, googleTokens?: object | null,
* cesiumToken?: string }} keys
*/
export const loadCesiumPhotorealTileset = (keys) =>
loadPhotorealisticTileset(Cesium, keys);
/** Cesium.Viewer options of the standard globe viewer. */
export function applicationViewerOptions(creditContainer) {
return {
@@ -417,7 +427,7 @@ export function createApplicationViewer({ container, creditContainer }) {
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
// Before any tile builds a draw command: Cesium's per-vertex model
// atmosphere fails to LINK on Apple's Metal backend and kills the
// render loop. See app/atmosphereCompat.js.
// render loop. See atmosphereCompat.js.
applyModelAtmosphereWorkaround(viewer.scene);
viewer.scene.globe.show = false;
viewer.scene.skyAtmosphere.show = true;
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { governorRequestRender } from '../renderGovernor.js';
import { governorRequestRender } from '../../renderGovernor.js';
/** Whole-Earth input limit; explicit camera destinations can go farther. */
export const MAXIMUM_ZOOM_DISTANCE_M = 40_000_000;
File renamed without changes.
+38 -17
View File
@@ -232,16 +232,16 @@
* (the maths of the app's Cesium sharpen stage, whose `amount` uniform the
* Sharpen slider sets in 0.1..2.1). Omitted fields keep their value; a
* non-boolean `enabled` or a negative or non-finite `amount` throws a
* TypeError. Off with amount 1 until set. The Cesium engine only keeps the
* state: in Cesium mode the app's own post stage sharpens and mirrors its
* state here (src/ui/visualEffects.js).
* TypeError. Off with amount 1 until set. Cesium draws it as a post stage
* after the styles' (src/globe/cesium/postStages.js); the app's Display
* controls drive it (src/ui/visualEffects.js).
* @property {() => { enabled: boolean, amount: number }} sharpen
* The sharpening last set (a copy).
* @property {readonly string[]} [visualStyles] Optional, with setStyle and
* style (capability `visualStyles`, see engineCapabilities): the
* full-frame styles the engine draws itself, by the app's style names
* (src/styles: retro, surveillance, thermal, anime, noir). Cesium has none:
* in Cesium mode the app's own post stages draw the styles
* (src/styles: retro, surveillance, thermal, anime, noir). Cesium draws
* each as its own post stage, Navara in one pass; the app drives them
* (src/ui/visualEffects.js).
* @property {(name: string, options: { intensity?: number, uniforms?: Record<string, number> }) => void} [setStyle]
* Shows one style at `intensity` (0..1, 0 draws nothing; a crossfade shows
@@ -259,8 +259,8 @@
* bloomStrengthFromIntensity); at or under 0.06 nothing glows, as with
* Cesium's bloom stage, whose easing the engine follows. Omitted fields
* keep their value; a non-boolean `enabled` or a strength outside 0..1
* throws a TypeError. Off at strength 0 until set. Cesium has none: in
* Cesium mode the app drives the scene's own bloom stage.
* throws a TypeError. Off at strength 0 until set. Cesium draws it with the
* scene's own bloom stage, which it gives back on destroy.
* @property {() => { enabled: boolean, strength: number }} [bloom] The bloom
* last set (a copy).
* @property {(options: { coverage?: number | null }) => void} [setCloudCover]
@@ -282,8 +282,9 @@
* off (`active` false) every contact takes the neutral factor. Positions
* and picks are unchanged. Omitted fields keep their value; a bad value
* throws a TypeError. Takes effect on the next drawn frame (a call outside
* a frame requests one). Off until set. Cesium has none: in Cesium mode
* src/cyberSonarScene.js adapts Cesium's own draw commands.
* a frame requests one). Off until set. Cesium adapts its native draw
* commands and tints models (src/globe/cesium/sonar.js); the app hands
* the state over every frame (src/cyberSonarEngine.js).
* @property {() => ContactSonar} [contactSonar] The sonar last set (a copy).
*
* @typedef {object} ContactSonar
@@ -297,6 +298,22 @@
*
* @typedef {{ intensity: number, uniforms: Record<string, number> }} StyleState
*
* @typedef {object} GlobeInput
* @property {(type: InputType, listener: (event: InputEvent) => void, options?: InputOptions) => () => void} on
* As in Cesium, a listener with a modifier fires only while that key is
* held and one without fires only while no modifier is held.
* @property {(enabled: boolean) => void} setCameraInputs Whether the user's
* drag, wheel and touch move the camera (Cesium's enableInputs). Under a
* suspension the choice is kept and applies at the last resume.
* @property {() => () => void} suspendCameraInputs Camera inputs off until
* the returned resume (idempotent): for an owner that holds the camera for
* a while (the cockpit, a gizmo or box drag, a flight a gesture would
* cancel). Suspensions count: inputs stay off until the last one resumes,
* which brings back the state from before the first (Cesium: the
* controller's enableInputs as it was), or what setCameraInputs set since.
* @property {() => boolean} cameraInputsEnabled Whether the user's gestures
* move the camera now: on, and under no suspension.
*
* @typedef {object} PickResult
* @property {any} id The picked entity or object id (a mover's or line's
* pickId); undefined for map tiles.
@@ -638,9 +655,7 @@
* @property {{ preUpdate: EngineEvent, preRender: EngineEvent, postRender: EngineEvent }} frames Listeners receive a timestamp in ms.
* @property {GlobeRender} render
* @property {GlobeSurface} surface
* @property {{ on: (type: InputType, listener: (event: InputEvent) => void, options?: { modifier?: InputModifier }) => () => void, setCameraInputs: (enabled: boolean) => void }} input
* As in Cesium, a listener with a modifier fires only while that key is
* held and one without fires only while no modifier is held.
* @property {GlobeInput} input
* @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits
* @property {GlobeMaps} maps
* @property {GlobeClock} clock
@@ -851,7 +866,12 @@ const SHAPE = {
clampToHeightSupported: isBoolean,
tilesLoaded: isFunction,
},
input: { on: isFunction, setCameraInputs: isFunction },
input: {
on: isFunction,
setCameraInputs: isFunction,
suspendCameraInputs: isFunction,
cameraInputsEnabled: isFunction,
},
credits: { addStatic: isFunction },
clock: { now: isFunction, frameTime: isFunction },
movers: { createSet: isFunction, list: isFunction },
@@ -911,10 +931,11 @@ const memberAt = (engine, path) =>
/**
* Which optional capabilities an engine has: { visualStyles, ... } as
* booleans. A caller that refuses a feature on an engine without a Cesium
* viewer (voice, say) asks here instead: Cesium draws these through the app's
* own stages and reports false. `movers`, `lines`, `ground` and `imagery`
* are required; they are reported too, true for every valid engine.
* booleans. A caller that refuses a feature on an engine without one
* (voice, say) asks here. Cesium and Navara draw the styles, bloom and
* contact sonar; only Navara has cloudCover. `movers`, `lines`, `ground`
* and `imagery` are required; they are reported too, true for every valid
* engine.
*/
export function engineCapabilities(engine) {
const whole = (members, prefix = '') =>
+1 -1
View File
@@ -3,7 +3,7 @@
// bloomStrengthFromIntensity, 0..1) as postprocessing BloomEffect settings,
// and the bloom port createNavaraEngine takes. bloom.js adds the effect.
//
// Cesium's bloom stage (src/ui/visualEffects.js applyBloomIntensity) eases
// Cesium's bloom stage (src/globe/cesium/postStages.js bloomUniforms) eases
// the strength above a 0.06 dead zone (smoothstep) into a lower contrast, a
// brighter offset and a wider blur: more of the frame glows, further out.
// The same eased value drives the threshold, softness, blur radius and
+6 -3
View File
@@ -139,10 +139,13 @@ export function createNavaraInput(
cameraInputs = Boolean(enabled);
sendCameraInputs();
},
/** Off under any suspension, else the setCameraInputs choice. */
cameraInputsEnabled: () => cameraInputs && suspended === 0,
/**
* Navara-only: Navara's spin, zoom and tilt off until the returned
* release (the follow camera takes drag and wheel itself); the app's
* setCameraInputs choice comes back after the last release.
* Navara's spin, zoom and tilt off until the returned release (the
* follow camera takes drag and wheel itself, as do the app's camera
* owners); the app's setCameraInputs choice comes back after the last
* release.
*/
suspendCameraInputs() {
suspended++;
+20
View File
@@ -134,6 +134,26 @@ test('setCameraInputs toggles Navara camera control and user input cancels fligh
]);
});
test('suspendCameraInputs counts and keeps the setCameraInputs choice for the last resume', () => {
const { input, cameraOptions } = setup();
const on = { enableSpin: true, enableZoom: true, enableTilt: true };
const off = { enableSpin: false, enableZoom: false, enableTilt: false };
assert.equal(input.cameraInputsEnabled(), true);
const outer = input.suspendCameraInputs();
const inner = input.suspendCameraInputs();
assert.equal(input.cameraInputsEnabled(), false);
inner();
inner();
assert.deepEqual(cameraOptions.at(-1), off);
input.setCameraInputs(false);
outer();
assert.deepEqual(cameraOptions.at(-1), off);
assert.equal(input.cameraInputsEnabled(), false);
input.setCameraInputs(true);
assert.deepEqual(cameraOptions.at(-1), on);
assert.equal(input.cameraInputsEnabled(), true);
});
test('destroy removes every DOM listener and later subscriptions are inert', () => {
const { canvas, input, destroy } = setup();
input.on('click', () => {});
+1 -1
View File
@@ -2,7 +2,7 @@
// ({ descriptor, credit } for imagery, { id } for terrain) or plain
// descriptions ({ kind, accessToken?, googleApiKey? }); this module builds
// Navara sources and layers from them. The URLs and credits repeat those of
// src/maps/imagery.js and src/maps/terrain.js, which import Cesium.
// src/globe/cesium/mapImagery.js and terrain.js, which import Cesium.
//
// - imagery: OSM and Esri as `raster-tile` layers; Cesium ion imagery (Bing)
// has no Navara path yet and rejects.
+5 -4
View File
@@ -3,10 +3,11 @@
// shaders (movers/moverMesh.js) from uniforms every overlay of a view
// shares. The rings and the sweep itself are the HUD's own (CSS,
// src/cyberSonar.js); this is the contact side, the counterpart of the
// Cesium command adapter (src/cyberSonarGpu.js), with the same maths: each
// contact's factor from its anchor's place on screen against the sweep (the
// sampler of src/cyberSonar.js createCyberSonarSampler), its colour pulled
// toward the scanner tint by that factor and its alpha scaled by it.
// Cesium command adapter (src/globe/cesium/sonarCommands.js), with the same
// maths: each contact's factor from its anchor's place on screen against
// the sweep (the sampler of src/cyberSonar.js createCyberSonarSampler), its
// colour pulled toward the scanner tint by that factor and its alpha scaled
// by it.
// Positions, picks and the instance data stay as the layers set them.
// Models keep their colours (Cesium tints them on the CPU).
+1 -1
View File
@@ -33,7 +33,7 @@ import {
} from 'three';
export const STYLES_EFFECT_KEY = 'gevStyles';
/** Intensity under which a style draws nothing (as VisualEffects disables a stage). */
/** Intensity under which a style draws nothing (as Cesium disables a stage). */
const STYLE_VISIBLE_INTENSITY = 0.001;
const VERTEX_SHADER = /* glsl */ `precision highp float;
+52
View File
@@ -1698,6 +1698,58 @@ export function createContractSuite(
engine.input.setCameraInputs(true);
},
],
[
'input.suspendCameraInputs: counted, the last resume restores the state before the first',
async () => {
const { input } = engine;
const before = input.cameraInputsEnabled();
ensure(
typeof before === 'boolean',
`cameraInputsEnabled() returned ${before}`,
);
try {
input.setCameraInputs(true);
ensure(
input.cameraInputsEnabled(),
'setCameraInputs(true) left them off',
);
const outer = input.suspendCameraInputs();
const inner = input.suspendCameraInputs();
ensure(
typeof outer === 'function' && typeof inner === 'function',
'suspendCameraInputs returned no resume',
);
ensure(!input.cameraInputsEnabled(), 'on under a suspension');
inner();
inner();
ensure(
!input.cameraInputsEnabled(),
'an inner resume (twice) let them on under the outer one',
);
outer();
ensure(input.cameraInputsEnabled(), 'off after the last resume');
input.setCameraInputs(false);
input.suspendCameraInputs()();
ensure(
!input.cameraInputsEnabled(),
'a resume turned on inputs that were off before the suspension',
);
const resume = input.suspendCameraInputs();
input.setCameraInputs(true);
ensure(
!input.cameraInputsEnabled(),
'setCameraInputs(true) lifted a suspension',
);
resume();
ensure(
input.cameraInputsEnabled(),
'the choice made under a suspension did not apply at its resume',
);
} finally {
input.setCameraInputs(before);
}
},
],
[
'credits.addStatic: returns an idempotent remover',
async () => {
+19 -1
View File
@@ -320,6 +320,9 @@ export function createFakeEngine(options = {}) {
let moving = false;
let lastFramePose = null;
let lastChangedPose = null;
// The setCameraInputs choice and the suspensions held over it.
let cameraInputChoice = true;
let cameraSuspensions = 0;
const fake = {
calls: [],
@@ -782,8 +785,23 @@ export function createFakeEngine(options = {}) {
},
setCameraInputs(enabled) {
record('input.setCameraInputs', [enabled]);
fake.cameraInputsEnabled = Boolean(enabled);
cameraInputChoice = Boolean(enabled);
fake.cameraInputsEnabled = cameraInputChoice && !cameraSuspensions;
},
suspendCameraInputs() {
record('input.suspendCameraInputs', []);
cameraSuspensions++;
fake.cameraInputsEnabled = false;
let resumed = false;
return () => {
if (resumed) return;
resumed = true;
record('input.resumeCameraInputs', []);
cameraSuspensions--;
fake.cameraInputsEnabled = cameraInputChoice && !cameraSuspensions;
};
},
cameraInputsEnabled: () => fake.cameraInputsEnabled,
},
credits: {
addStatic(html, creditOptions = {}) {
+8
View File
@@ -129,6 +129,14 @@ test('fake input dispatches to subscribers and off removes them', () => {
assert.throws(() => engine.input.on('wheel', () => {}), /Unknown input type/);
engine.input.setCameraInputs(false);
assert.equal(engine.fake.cameraInputsEnabled, false);
engine.input.setCameraInputs(true);
const resume = engine.input.suspendCameraInputs();
const inner = engine.input.suspendCameraInputs();
assert.equal(engine.input.cameraInputsEnabled(), false);
inner();
resume();
resume();
assert.equal(engine.input.cameraInputsEnabled(), true);
});
test('pick, sampleHeight and worldToWindow results can be scripted', () => {
-5
View File
@@ -1107,9 +1107,6 @@
"That GPX file has no <gpx> document.": "لا يحتوي ملف GPX هذا على مستند <gpx>.",
"That {format} file is not valid XML.": "ملف {format} هذا ليس XML صالحًا.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "يحتوي هذا الملف على صورة غير قابلة للقراءة أو تتجاوز حد 16 ميغابكسل.",
"That KML could not be read.": "تعذّرت قراءة ملف KML هذا.",
"That file has invalid point coordinates.": "يحتوي هذا الملف على إحداثيات نقاط غير صالحة.",
"Ground heights are not available for that file. Try again when the map has loaded.": "ارتفاعات الأرض غير متاحة لهذا الملف. أعد المحاولة بعد تحميل الخريطة.",
"That file took too long to read.": "استغرقت قراءة هذا الملف وقتًا طويلًا جدًا.",
"Nothing to import.": "لا يوجد ما يُستورد.",
"That file is larger than {size} MiB.": "حجم هذا الملف أكبر من {size} MiB.",
@@ -1118,8 +1115,6 @@
"That KMZ holds no KML document.": "لا يحتوي ملف KMZ هذا على مستند KML.",
"That {format} file could not be read.": "تعذّرت قراءة ملف {format} هذا.",
"That file has {count} map objects; the limit is {limit}.": "عدد عناصر الخريطة في هذا الملف {count}، والحد المسموح {limit}.",
"Ground heights could not be read. Try that file again.": "تعذّرت قراءة ارتفاعات الأرض. أعد استيراد الملف.",
"That file has invalid point graphics.": "يحتوي هذا الملف على رسومات نقاط غير صالحة.",
"Map tiles temporarily unavailable": "بلاطات الخريطة غير متاحة مؤقتًا",
"Mapped names temporarily unavailable": "أسماء المواقع المرسومة غير متاحة مؤقتًا",
"Military airfield": "مطار عسكري",
-5
View File
@@ -1075,9 +1075,6 @@
"That GPX file has no <gpx> document.": "Diese GPX-Datei hat kein <gpx>-Dokument.",
"That {format} file is not valid XML.": "Diese {format}-Datei ist kein gültiges XML.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "Diese Datei enthält ein unlesbares Bild oder überschreitet das Bildlimit von 16 Megapixeln.",
"That KML could not be read.": "Diese KML konnte nicht gelesen werden.",
"That file has invalid point coordinates.": "Diese Datei enthält ungültige Punktkoordinaten.",
"Ground heights are not available for that file. Try again when the map has loaded.": "Für diese Datei sind keine Geländehöhen verfügbar. Erneut versuchen, wenn die Karte geladen ist.",
"That file took too long to read.": "Das Lesen dieser Datei hat zu lange gedauert.",
"Nothing to import.": "Nichts zu importieren.",
"That file is larger than {size} MiB.": "Diese Datei ist größer als {size} MiB.",
@@ -1086,8 +1083,6 @@
"That KMZ holds no KML document.": "Diese KMZ enthält kein KML-Dokument.",
"That {format} file could not be read.": "Diese {format}-Datei konnte nicht gelesen werden.",
"That file has {count} map objects; the limit is {limit}.": "Diese Datei hat {count} Kartenobjekte; das Limit ist {limit}.",
"Ground heights could not be read. Try that file again.": "Geländehöhen konnten nicht gelesen werden. Die Datei erneut laden.",
"That file has invalid point graphics.": "Diese Datei enthält ungültige Punktgrafiken.",
"Map tiles temporarily unavailable": "Kartenkacheln vorübergehend nicht verfügbar",
"Mapped names temporarily unavailable": "Kartierte Namen vorübergehend nicht verfügbar",
"Military airfield": "Militärflugplatz",
-5
View File
@@ -2147,9 +2147,6 @@
"That GPX file has no <gpx> document.": "Ese archivo GPX no tiene documento <gpx>.",
"That {format} file is not valid XML.": "Ese archivo {format} no es un XML válido.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "Ese archivo tiene una imagen ilegible o supera el límite de imagen de 16 megapíxeles.",
"That KML could not be read.": "No se pudo leer ese KML.",
"That file has invalid point coordinates.": "Ese archivo tiene coordenadas de puntos no válidas.",
"Ground heights are not available for that file. Try again when the map has loaded.": "Las alturas del terreno no están disponibles para ese archivo. Vuelve a intentarlo cuando el mapa haya cargado.",
"That file took too long to read.": "La lectura de ese archivo tardó demasiado.",
"Nothing to import.": "No hay nada que importar.",
"That file is larger than {size} MiB.": "Ese archivo pesa más de {size} MiB.",
@@ -2158,8 +2155,6 @@
"That KMZ holds no KML document.": "Ese KMZ no contiene ningún documento KML.",
"That {format} file could not be read.": "No se pudo leer ese archivo {format}.",
"That file has {count} map objects; the limit is {limit}.": "Ese archivo tiene {count} objetos en el mapa; el límite es {limit}.",
"Ground heights could not be read. Try that file again.": "No se pudieron leer las alturas del terreno. Vuelve a intentarlo con ese archivo.",
"That file has invalid point graphics.": "Ese archivo tiene gráficos de puntos no válidos.",
"Low-accuracy score < 2%": "Índice de baja precisión < 2%",
"Low-accuracy score 2–10%": "Índice de baja precisión 2–10%",
"Low-accuracy score > 10%": "Índice de baja precisión > 10%",
-5
View File
@@ -225,9 +225,6 @@
"That GPX file has no <gpx> document.": "Ce fichier GPX n'a pas de document <gpx>.",
"That {format} file is not valid XML.": "Ce fichier {format} n'est pas un XML valide.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "Ce fichier contient une image illisible ou dépasse la limite de 16 mégapixels par image.",
"That KML could not be read.": "Impossible de lire ce KML.",
"That file has invalid point coordinates.": "Ce fichier contient des coordonnées de points non valides.",
"Ground heights are not available for that file. Try again when the map has loaded.": "Les altitudes du sol ne sont pas disponibles pour ce fichier. Réessayez une fois la carte chargée.",
"That file took too long to read.": "La lecture de ce fichier a pris trop de temps.",
"Nothing to import.": "Rien à importer.",
"That file is larger than {size} MiB.": "Ce fichier dépasse {size} Mio.",
@@ -236,8 +233,6 @@
"That KMZ holds no KML document.": "Ce KMZ ne contient aucun document KML.",
"That {format} file could not be read.": "Impossible de lire ce fichier {format}.",
"That file has {count} map objects; the limit is {limit}.": "Ce fichier contient {count} objets cartographiques ; la limite est de {limit}.",
"Ground heights could not be read. Try that file again.": "Impossible de lire les altitudes du sol. Réessayez avec ce fichier.",
"That file has invalid point graphics.": "Ce fichier contient des symboles de points non valides.",
"Overpass rate-limited": "Overpass : limite de requêtes atteinte",
"Overpass timed out": "Délai Overpass dépassé",
"Overpass could not complete the query": "Overpass n'a pas pu terminer la requête",
-5
View File
@@ -225,9 +225,6 @@
"That GPX file has no <gpx> document.": "Quel file GPX non ha un documento <gpx>.",
"That {format} file is not valid XML.": "Quel file {format} non è un XML valido.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "Quel file ha un'immagine illeggibile o supera il limite di 16 megapixel per immagine.",
"That KML could not be read.": "Impossibile leggere quel KML.",
"That file has invalid point coordinates.": "Quel file ha coordinate dei punti non valide.",
"Ground heights are not available for that file. Try again when the map has loaded.": "Le quote del terreno non sono disponibili per quel file. Riprova quando la mappa è caricata.",
"That file took too long to read.": "La lettura di quel file ha richiesto troppo tempo.",
"Nothing to import.": "Niente da importare.",
"That file is larger than {size} MiB.": "Quel file supera i {size} MiB.",
@@ -236,8 +233,6 @@
"That KMZ holds no KML document.": "Quel KMZ non contiene alcun documento KML.",
"That {format} file could not be read.": "Impossibile leggere quel file {format}.",
"That file has {count} map objects; the limit is {limit}.": "Quel file ha {count} oggetti sulla mappa; il limite è {limit}.",
"Ground heights could not be read. Try that file again.": "Impossibile leggere le quote del terreno. Riprova con quel file.",
"That file has invalid point graphics.": "Quel file ha simboli dei punti non validi.",
"Overpass rate-limited": "Overpass ha limitato le richieste",
"Overpass timed out": "Overpass è andato in timeout",
"Overpass could not complete the query": "Overpass non ha potuto completare la query",
-5
View File
@@ -219,9 +219,6 @@
"That GPX file has no <gpx> document.": "このGPXファイルには<gpx>ドキュメントがありません。",
"That {format} file is not valid XML.": "この{format}ファイルは有効なXMLではありません。",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "このファイルには読み取れない画像があるか、画像が1,600万画素の上限を超えています。",
"That KML could not be read.": "このKMLを読み取れませんでした。",
"That file has invalid point coordinates.": "このファイルには無効な点座標があります。",
"Ground heights are not available for that file. Try again when the map has loaded.": "このファイルの地表高を取得できません。地図の読み込み後にもう一度お試しください。",
"That file took too long to read.": "ファイルの読み取りに時間がかかりすぎました。",
"Nothing to import.": "インポートするものがありません。",
"That file is larger than {size} MiB.": "このファイルは{size} MiBを超えています。",
@@ -230,8 +227,6 @@
"That KMZ holds no KML document.": "このKMZにはKMLドキュメントが含まれていません。",
"That {format} file could not be read.": "この{format}ファイルを読み取れませんでした。",
"That file has {count} map objects; the limit is {limit}.": "このファイルの地図オブジェクトは{count}個です。上限は{limit}個です。",
"Ground heights could not be read. Try that file again.": "地表高を読み取れませんでした。もう一度このファイルをお試しください。",
"That file has invalid point graphics.": "このファイルには無効なポイントグラフィックがあります。",
"Overpass rate-limited": "Overpassがレート制限中",
"Overpass timed out": "Overpassがタイムアウトしました",
"Overpass could not complete the query": "Overpassがクエリを完了できませんでした",
-5
View File
@@ -225,9 +225,6 @@
"That GPX file has no <gpx> document.": "Esse arquivo GPX não tem documento <gpx>.",
"That {format} file is not valid XML.": "Esse arquivo {format} não é um XML válido.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "Esse arquivo tem uma imagem ilegível ou ultrapassa o limite de imagem de 16 megapixels.",
"That KML could not be read.": "Não foi possível ler esse KML.",
"That file has invalid point coordinates.": "Esse arquivo tem coordenadas de pontos inválidas.",
"Ground heights are not available for that file. Try again when the map has loaded.": "As altitudes do terreno não estão disponíveis para esse arquivo. Tente de novo quando o mapa tiver carregado.",
"That file took too long to read.": "A leitura desse arquivo demorou demais.",
"Nothing to import.": "Nada para importar.",
"That file is larger than {size} MiB.": "Esse arquivo tem mais de {size} MiB.",
@@ -236,8 +233,6 @@
"That KMZ holds no KML document.": "Esse KMZ não contém nenhum documento KML.",
"That {format} file could not be read.": "Não foi possível ler esse arquivo {format}.",
"That file has {count} map objects; the limit is {limit}.": "Esse arquivo tem {count} objetos no mapa; o limite é {limit}.",
"Ground heights could not be read. Try that file again.": "Não foi possível ler as altitudes do terreno. Tente esse arquivo de novo.",
"That file has invalid point graphics.": "Esse arquivo tem gráficos de pontos inválidos.",
"Overpass rate-limited": "Overpass: limite de requisições atingido",
"Overpass timed out": "Tempo esgotado no Overpass",
"Overpass could not complete the query": "Overpass não conseguiu concluir a consulta",
-5
View File
@@ -2181,9 +2181,6 @@
"That GPX file has no <gpx> document.": "В этом файле GPX нет документа <gpx>.",
"That {format} file is not valid XML.": "Этот файл {format} — некорректный XML.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "В файле есть нечитаемое изображение или изображение больше лимита в 16 мегапикселей.",
"That KML could not be read.": "Не удалось прочитать этот KML.",
"That file has invalid point coordinates.": "В файле некорректные координаты точек.",
"Ground heights are not available for that file. Try again when the map has loaded.": "Высоты рельефа для этого файла недоступны. Попробуйте ещё раз, когда карта загрузится.",
"That file took too long to read.": "Файл читался слишком долго.",
"Nothing to import.": "Нечего импортировать.",
"That file is larger than {size} MiB.": "Файл больше {size} МиБ.",
@@ -2192,8 +2189,6 @@
"That KMZ holds no KML document.": "В этом KMZ нет документа KML.",
"That {format} file could not be read.": "Не удалось прочитать этот файл {format}.",
"That file has {count} map objects; the limit is {limit}.": "Объектов на карте в файле: {count}; лимит: {limit}.",
"Ground heights could not be read. Try that file again.": "Не удалось получить высоты рельефа. Попробуйте импортировать файл ещё раз.",
"That file has invalid point graphics.": "В файле некорректное оформление точек.",
"Low-accuracy score < 2%": "Доля неточных < 2%",
"Low-accuracy score 2–10%": "Доля неточных 2–10%",
"Low-accuracy score > 10%": "Доля неточных > 10%",
-5
View File
@@ -228,9 +228,6 @@
"That GPX file has no <gpx> document.": "У цьому файлі GPX немає документа <gpx>.",
"That {format} file is not valid XML.": "Цей файл {format} — некоректний XML.",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "У файлі є непридатне для читання зображення або зображення, більше за ліміт 16 мегапікселів.",
"That KML could not be read.": "Не вдалося прочитати цей KML.",
"That file has invalid point coordinates.": "У файлі некоректні координати точок.",
"Ground heights are not available for that file. Try again when the map has loaded.": "Висоти рельєфу для цього файлу недоступні. Спробуйте ще раз, коли карта завантажиться.",
"That file took too long to read.": "Файл читався надто довго.",
"Nothing to import.": "Немає чого імпортувати.",
"That file is larger than {size} MiB.": "Файл більший за {size} МіБ.",
@@ -239,8 +236,6 @@
"That KMZ holds no KML document.": "У цьому KMZ немає документа KML.",
"That {format} file could not be read.": "Не вдалося прочитати цей файл {format}.",
"That file has {count} map objects; the limit is {limit}.": "Обʼєктів на карті у файлі: {count}; ліміт: {limit}.",
"Ground heights could not be read. Try that file again.": "Не вдалося отримати висоти рельєфу. Спробуйте імпортувати файл ще раз.",
"That file has invalid point graphics.": "У файлі некоректне оформлення точок.",
"Overpass rate-limited": "Overpass обмежує частоту запитів",
"Overpass timed out": "Минув час очікування Overpass",
"Overpass could not complete the query": "Overpass не зміг завершити запит",
-5
View File
@@ -219,9 +219,6 @@
"That GPX file has no <gpx> document.": "该 GPX 文件没有 <gpx> 文档。",
"That {format} file is not valid XML.": "该 {format} 文件不是有效的 XML。",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "该文件包含无法读取的图像,或图像超过 1600 万像素上限。",
"That KML could not be read.": "无法读取该 KML。",
"That file has invalid point coordinates.": "该文件的点坐标无效。",
"Ground heights are not available for that file. Try again when the map has loaded.": "无法获取该文件的地面高度。请在地图加载完成后重试。",
"That file took too long to read.": "读取该文件耗时过长。",
"Nothing to import.": "没有可导入的内容。",
"That file is larger than {size} MiB.": "该文件大于 {size} MiB。",
@@ -230,8 +227,6 @@
"That KMZ holds no KML document.": "该 KMZ 中没有 KML 文档。",
"That {format} file could not be read.": "无法读取该 {format} 文件。",
"That file has {count} map objects; the limit is {limit}.": "该文件有 {count} 个地图对象,上限为 {limit} 个。",
"Ground heights could not be read. Try that file again.": "无法读取地面高度。请重新导入该文件。",
"That file has invalid point graphics.": "该文件的点样式无效。",
"Overpass rate-limited": "Overpass 已被限流",
"Overpass timed out": "Overpass 超时",
"Overpass could not complete the query": "Overpass 无法完成查询",
-5
View File
@@ -219,9 +219,6 @@
"That GPX file has no <gpx> document.": "該 GPX 文件沒有 <gpx> 文檔。",
"That {format} file is not valid XML.": "該 {format} 文件不是有效的 XML。",
"That file has an unreadable image or exceeds the 16-megapixel image limit.": "該文件包含無法讀取的圖像,或圖像超過 1600 萬像素上限。",
"That KML could not be read.": "無法讀取該 KML。",
"That file has invalid point coordinates.": "該文件的點座標無效。",
"Ground heights are not available for that file. Try again when the map has loaded.": "無法獲取該文件的地面高度。請在地圖加載完成後重試。",
"That file took too long to read.": "讀取該文件耗時過長。",
"Nothing to import.": "沒有可導入的內容。",
"That file is larger than {size} MiB.": "該文件大於 {size} MiB。",
@@ -230,8 +227,6 @@
"That KMZ holds no KML document.": "該 KMZ 中沒有 KML 文檔。",
"That {format} file could not be read.": "無法讀取該 {format} 文件。",
"That file has {count} map objects; the limit is {limit}.": "該文件有 {count} 個地圖對象,上限為 {limit} 個。",
"Ground heights could not be read. Try that file again.": "無法讀取地面高度。請重新導入該文件。",
"That file has invalid point graphics.": "該文件的點樣式無效。",
"Overpass rate-limited": "Overpass 已被限流",
"Overpass timed out": "Overpass 超時",
"Overpass could not complete the query": "Overpass 無法完成查詢",
+2 -2
View File
@@ -682,8 +682,8 @@ function inputLifecycle(t, ring) {
const { state, rendering } = ring;
const loop = frameLoop(t, ring, { date: true });
state.enabled = true;
// The contract cannot say whether the camera controller takes input, so
// the display does not either; the lifecycle reads it when a display does.
// The display answers from engine.input.cameraInputsEnabled; here the
// test switches the controller's inputs itself.
const controller = { enableInputs: true };
state.display.cameraInputsEnabled = () => controller.enableInputs;
setCamera(ring, 3);
+2
View File
@@ -57,6 +57,8 @@ export function createAwarenessDisplay(engine, { navaraPulses } = {}) {
return engine.camera.following()?.native;
},
cameraHeight: () => engine.camera.height(),
// Off while another owner holds the camera (the cockpit, a gizmo drag).
cameraInputsEnabled: () => engine.input.cameraInputsEnabled(),
onFrame: (run) => engine.frames.preRender.addEventListener(run),
onFrameEnd: (run) => engine.frames.postRender.addEventListener(run),
frameContact: (center, radius, duration) =>
@@ -15,6 +15,8 @@ const HARNESSES = [
() => ({ name: 'the fake engine', engine: createFakeEngine() }),
() => {
const { engine, viewer, primitive } = createCesiumMoversEngine();
// A viewer's camera controller, whose inputs the engine switches.
viewer.scene.screenSpaceCameraController = { enableInputs: true };
return {
name: 'the Cesium engine',
cesium: true,
@@ -28,6 +30,23 @@ const HARNESSES = [
for (const makeHarness of HARNESSES) {
const { name } = makeHarness();
test(`the display reads whether the engine's camera inputs are on (${name})`, (t) => {
const h = makeHarness();
const display = createAwarenessDisplay(h.engine);
t.after(() => {
display.destroy();
h.engine.destroy();
});
assert.equal(display.cameraInputsEnabled(), true);
// The cockpit holds the camera through a suspension.
const resume = h.engine.input.suspendCameraInputs();
assert.equal(display.cameraInputsEnabled(), false);
resume();
assert.equal(display.cameraInputsEnabled(), true);
h.engine.input.setCameraInputs(false);
assert.equal(display.cameraInputsEnabled(), false);
});
test(`the display reads the engine's camera, frames and followed contact (${name})`, (t) => {
const h = makeHarness();
const display = createAwarenessDisplay(h.engine);
+3 -11
View File
@@ -593,17 +593,9 @@ export function createCctvDisplay(
gizmoRemovers.push(() => owner.removeEventListener(event, listener));
}
},
// The contract sets camera inputs; it cannot read them back, so the
// release turns them on again.
suspendCameraInputs() {
engine.input.setCameraInputs(false);
let released = false;
return () => {
if (released) return;
released = true;
engine.input.setCameraInputs(true);
};
},
// The engine counts suspensions: the release puts the camera inputs
// back as they were.
suspendCameraInputs: () => engine.input.suspendCameraInputs(),
destroy() {
if (destroyed) return;
destroyed = true;
+1 -2
View File
@@ -539,8 +539,7 @@ export function createInfrastructureDisplay(
function flyTo(lon, lat) {
// Camera input would cancel the flight.
engine.input.setCameraInputs(false);
const restore = () => engine.input.setCameraInputs(true);
const restore = engine.input.suspendCameraInputs();
engine.camera.flyTo({
destination: geo.cartesian3.fromDegrees(lon, lat, FLY_HEIGHT_M),
duration: FLY_SECONDS,
@@ -108,11 +108,16 @@ function watch(engine) {
};
const flights = [];
engine.camera.flyTo = (options) => flights.push(options);
// Camera inputs off at each suspension, on again at its resume.
const cameraInputs = [];
const setCameraInputs = engine.input.setCameraInputs.bind(engine.input);
engine.input.setCameraInputs = (enabled) => {
cameraInputs.push(enabled);
setCameraInputs(enabled);
const suspend = engine.input.suspendCameraInputs.bind(engine.input);
engine.input.suspendCameraInputs = () => {
cameraInputs.push(false);
const resume = suspend();
return () => {
cameraInputs.push(true);
resume();
};
};
const requests = [];
const request = engine.render.request.bind(engine.render);
+19 -9
View File
@@ -98,11 +98,18 @@ function domEvent(fields) {
/**
* Globe engine and document fakes for `initImageryBoxTool`. The canvas is
* 1000 × 500 px mapped linearly onto 0.2° near Austin; y < 40 px is sky.
* `suspend: true` gives the engine input a counted suspendCameraInputs
* (Navara's); without it the tool turns setCameraInputs off and on.
* The engine input counts camera-input suspensions over the app's
* setCameraInputs choice: `camera.inputs` is on while that choice is on
* and nothing suspends them.
*/
export function boxToolFakes({ suspend = false } = {}) {
const camera = { inputs: true, suspended: 0 };
export function boxToolFakes() {
const camera = {
choice: true,
suspended: 0,
get inputs() {
return camera.choice && camera.suspended === 0;
},
};
const captured = new Set();
const canvas = eventTarget({
getBoundingClientRect: () => ({ left: 0, top: 0 }),
@@ -112,16 +119,19 @@ export function boxToolFakes({ suspend = false } = {}) {
});
const input = {
setCameraInputs(enabled) {
camera.inputs = Boolean(enabled);
camera.choice = Boolean(enabled);
},
};
if (suspend)
input.suspendCameraInputs = () => {
cameraInputsEnabled: () => camera.inputs,
suspendCameraInputs() {
camera.suspended += 1;
let resumed = false;
return () => {
if (resumed) return;
resumed = true;
camera.suspended -= 1;
};
};
},
};
const engine = {
host: { canvas, size: () => ({ width: 1000, height: 500 }) },
input,
-401
View File
@@ -171,404 +171,3 @@ export function drawStorySource(engine, { view, NavaraColor }, data) {
},
};
}
// Graphics an imported file (UTILITIES ▸ Import) may carry that Navara does
// not draw; each one present is counted and reported.
const UNDRAWN_GRAPHICS = Object.freeze([
'label',
'model',
'rectangle',
'wall',
'ellipse',
'ellipsoid',
'corridor',
'box',
'cylinder',
'path',
'plane',
'polylineVolume',
'tileset',
]);
const DEGREES = 180 / Math.PI;
// Unique mover set ids for imported files.
let importSets = 0;
const css = (c) =>
'#' +
[c.red, c.green, c.blue]
.map((v) =>
Math.round(Math.max(0, Math.min(1, v)) * 255)
.toString(16)
.padStart(2, '0'),
)
.join('');
const lonLat = (position) => {
const c = position && geo.cartographic.fromCartesian(position);
return c && Number.isFinite(c.longitude) && Number.isFinite(c.latitude)
? [c.longitude * DEGREES, c.latitude * DEGREES]
: null;
};
const ringOf = (positions) => {
const ring = (positions ?? []).map(lonLat).filter(Boolean);
if (ring.length >= 3) {
const [first] = ring;
const last = ring[ring.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) ring.push([...first]);
}
return ring;
};
const colorOf = (material, fallback) => {
const value = val(material);
return value?.color ?? val(material?.color) ?? fallback;
};
/**
* The plan for an imported data source on Navara, as plain data: GeoJSON
* features grouped by paint (`fills` and `lines`: Map of style key →
* { color: '#rrggbb', opacity, width?, features }), point sprites (`points`)
* and billboards grouped by image (`images`: Map of image key →
* { image, sizePx, items }), and what is left out (`undrawn`: graphic kind →
* count; `drawn`: entities with something drawn).
*/
export function planImportedSource(dataSource, { sizes = new Map() } = {}) {
const fills = new Map();
const lines = new Map();
const points = [];
const images = new Map();
const undrawn = {};
let drawn = 0;
const group = (map, key, entry) => {
let target = map.get(key);
if (!target) map.set(key, (target = { ...entry, features: [] }));
return target;
};
for (const entity of dataSource.entities.values) {
if (val(entity.show) === false) continue;
let any = false;
const polygon = entity.polygon;
if (polygon && val(polygon.fill) !== false) {
const hierarchy = val(polygon.hierarchy);
const outer = ringOf(hierarchy?.positions);
if (outer.length >= 4) {
const holes = (hierarchy.holes ?? [])
.map((hole) => ringOf(hole?.positions))
.filter((ring) => ring.length >= 4);
const color = colorOf(polygon.material, null);
const fill = color ? css(color) : '#ffffff';
const opacity = color?.alpha ?? 1;
group(fills, `${fill}|${opacity.toFixed(3)}`, {
color: fill,
opacity,
}).features.push({
type: 'Feature',
properties: {},
geometry: { type: 'Polygon', coordinates: [outer, ...holes] },
});
any = true;
}
}
const polyline = entity.polyline;
if (polyline) {
const path = (val(polyline.positions) ?? []).map(lonLat).filter(Boolean);
if (path.length >= 2) {
const color = colorOf(polyline.material, null);
const stroke = color ? css(color) : '#ffffff';
const opacity = color?.alpha ?? 1;
const width = Number(val(polyline.width)) || 1;
group(lines, `${stroke}|${opacity.toFixed(3)}|${width}`, {
color: stroke,
opacity,
width,
}).features.push({
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: path },
});
any = true;
}
}
const position = val(entity.position);
if (position && entity.billboard && val(entity.billboard.image)) {
const raw = val(entity.billboard.image);
// A URL (a Cesium Resource's too), else a canvas; one sprite set per
// image (Cesium's pin builder hands every marker of a colour the same
// canvas).
const image =
typeof raw === 'string'
? raw
: typeof raw?.url === 'string'
? raw.url
: typeof raw?.getContext === 'function'
? raw
: typeof raw?.src === 'string'
? raw.src
: null;
if (!image) {
undrawn.billboard = (undrawn.billboard ?? 0) + 1;
continue;
}
const natural = sizes.get(image);
const width =
Number(val(entity.billboard.width)) ||
Number(image.width) ||
natural?.width ||
32;
const height =
Number(val(entity.billboard.height)) ||
Number(image.height) ||
natural?.height ||
width;
const entry = group(images, image, {
image,
sizePx: Math.max(width, height),
});
const tint = val(entity.billboard.color);
entry.features.push({
entity,
position,
scale: Number(val(entity.billboard.scale)) || 1,
color: tint ? rgba(tint) : [1, 1, 1, 1],
});
any = true;
} else if (position && entity.point) {
const p = entity.point;
const color = val(p.color);
const outline = val(p.outlineColor);
points.push({
entity,
position,
sizePx: Number(val(p.pixelSize)) || 1,
color: color ? rgba(color) : [1, 1, 1, 1],
outline: outline && Number(val(p.outlineWidth)) ? rgba(outline) : null,
});
any = true;
}
for (const kind of UNDRAWN_GRAPHICS) {
const graphic = entity[kind];
if (!graphic || val(graphic.show) === false) continue;
if (kind === 'label' && !val(graphic.text)) continue;
undrawn[kind] = (undrawn[kind] ?? 0) + 1;
}
if (any) drawn++;
}
return { fills, lines, points, images, undrawn, drawn };
}
/**
* Draw an imported file's data source (UTILITIES ▸ Import) on Navara: its
* polygons and lines as clamped GeoJSON vector layers (one per paint), its
* points as circle sprites and its billboards as sprites of their own image.
* Labels, models and the other graphics Navara has no drawing for are left
* out and counted in `report`. `setShow` follows the row's visibility;
* `refresh` re-reads the pins' positions after a ground refresh.
*/
export function drawImportedSource(
engine,
{ view },
dataSource,
{ sizes } = {},
) {
const plan = planImportedSource(dataSource, { sizes });
const layers = [];
const sets = [];
let shown = dataSource.show !== false;
const layerStyle = (kind, entry) =>
kind === 'polygon'
? {
polygon: {
color: Number.parseInt(entry.color.slice(1), 16),
show: shown,
clampToGround: true,
transparent: true,
opacity: entry.opacity,
},
}
: {
polyline: {
color: Number.parseInt(entry.color.slice(1), 16),
show: shown,
clampToGround: true,
width: entry.width,
transparent: true,
opacity: entry.opacity,
},
};
const id = (kind) => `geo-import-${kind}-${++importSets}`;
try {
for (const [kind, groups] of [
['polygon', plan.fills],
['polyline', plan.lines],
])
for (const entry of groups.values()) {
const source = view.addSource({
type: 'geojson',
data: { type: 'FeatureCollection', features: entry.features },
});
const record = { kind, entry, source, layer: null };
layers.push(record);
record.layer = view.addLayer({
type: 'vector',
source,
...layerStyle(kind, entry),
});
}
if (plan.points.length) {
const set = engine.movers.createSet({
id: id('points'),
kind: 'sprite',
capacity: plan.points.length * 2,
pickable: false,
depthTest: 'always',
sprite: { sizePx: 64, atlas: circleAtlas() },
});
const write = () => {
let at = 0;
for (const item of plan.points) {
const scale = item.sizePx / 58;
set.set(at++, {
position: item.position,
cell: 'fill',
color: item.color,
scale,
show: true,
});
if (item.outline)
set.set(at++, {
position: item.position,
cell: 'rim',
color: item.outline,
scale,
show: true,
});
}
return at;
};
sets.push({ set, count: write(), write });
}
for (const entry of plan.images.values()) {
const { image } = entry;
const set = engine.movers.createSet({
id: id('pins'),
kind: 'sprite',
capacity: entry.features.length,
pickable: false,
depthTest: 'always',
sprite: {
sizePx: entry.sizePx,
atlas: {
...(typeof image === 'string' ? { url: image } : { canvas: image }),
cells: { image: [0, 0, 1, 1] },
},
},
});
const write = () => {
entry.features.forEach((item, index) =>
set.set(index, {
position: item.position,
cell: 'image',
color: item.color,
scale: item.scale,
show: true,
}),
);
return entry.features.length;
};
sets.push({ set, count: write(), write });
}
for (const record of sets) {
record.set.setCount(shown ? record.count : 0);
record.set.commit();
}
} catch (error) {
release();
throw error;
}
engine.render.request('geo-import');
function release() {
for (const { layer, source } of layers.splice(0)) {
try {
layer?.delete();
source.delete();
} catch {
/* the view went first */
}
}
for (const { set } of sets.splice(0)) set.destroy();
}
const undrawn = Object.values(plan.undrawn).reduce((n, c) => n + c, 0);
return {
report: { drawn: plan.drawn, undrawn, kinds: { ...plan.undrawn } },
setShow(show) {
if (show === shown) return;
shown = show;
for (const { kind, entry, source, layer } of layers)
layer.update({ type: 'vector', source, ...layerStyle(kind, entry) });
for (const record of sets) {
record.set.setCount(shown ? record.count : 0);
record.set.commit();
}
engine.render.request('geo-import');
},
/** The pins' positions moved onto refreshed ground heights. */
refresh() {
const items = [
...plan.points,
...[...plan.images.values()].flatMap((entry) => entry.features),
];
for (const item of items)
item.position = val(item.entity.position) ?? item.position;
for (const record of sets) {
record.count = record.write();
record.set.setCount(shown ? record.count : 0);
record.set.commit();
}
engine.render.request('geo-import');
},
destroy() {
release();
engine.render.request('geo-import');
},
};
}
/**
* The natural sizes of an imported file's pin images (the file's own, as
* data: or blob: URLs), so each pin draws at the size Cesium gives it.
* Remote URLs are never loaded. Resolves with a Map of URL → { width, height }.
*/
export async function measureImportImages(
dataSource,
{ timeoutMs = 2000, createImage = () => new Image() } = {},
) {
const urls = new Set();
for (const entity of dataSource.entities.values) {
const image = val(entity.billboard?.image);
const url = typeof image === 'string' ? image : image?.url;
if (typeof url === 'string' && /^(data|blob):/.test(url)) urls.add(url);
}
const sizes = new Map();
await Promise.all(
[...urls].map(async (url) => {
const image = createImage();
image.src = url;
let timer;
try {
await Promise.race([
image.decode(),
new Promise((_, reject) => {
timer = setTimeout(reject, timeoutMs);
}),
]);
if (image.naturalWidth > 0)
sizes.set(url, {
width: image.naturalWidth,
height: image.naturalHeight,
});
} catch {
/* drawn at the default size */
} finally {
clearTimeout(timer);
}
}),
);
return sizes;
}
@@ -1,192 +0,0 @@
import '../../../testSupport/registerDom.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import {
drawImportedSource,
measureImportImages,
planImportedSource,
} from './navaraStory.js';
// happy-dom has no 2D canvas; the circle atlas only needs to draw.
globalThis.HTMLCanvasElement.prototype.getContext = () => ({
beginPath() {},
arc() {},
fill() {},
stroke() {},
});
const degrees = (...pairs) => Cesium.Cartesian3.fromDegreesArray(pairs.flat());
/** A file's data source as Cesium's loaders build one. */
function importedSource() {
const source = new Cesium.CustomDataSource('file');
const amber = Cesium.Color.fromCssColorString('#ffb547');
source.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
degrees([0, 0], [1, 0], [1, 1], [0, 1]),
[
new Cesium.PolygonHierarchy(
degrees([0.2, 0.2], [0.4, 0.2], [0.4, 0.4]),
),
],
),
material: amber.withAlpha(0.25),
},
});
source.entities.add({
polyline: { positions: degrees([0, 0], [2, 2]), width: 3, material: amber },
});
source.entities.add({
polyline: { positions: degrees([1, 0], [2, 1]), width: 3, material: amber },
});
source.entities.add({
position: Cesium.Cartesian3.fromDegrees(0.5, 0.5),
point: { pixelSize: 10, color: Cesium.Color.RED },
});
// Two pins sharing one image, one with a label Navara cannot draw.
for (const lon of [3, 4])
source.entities.add({
position: Cesium.Cartesian3.fromDegrees(lon, 0),
billboard: { image: 'data:image/png;base64,AAAA', scale: 0.5 },
...(lon === 3 ? { label: { text: 'Pin' } } : {}),
});
source.entities.add({
position: Cesium.Cartesian3.fromDegrees(5, 0),
model: { uri: 'model.glb' },
});
source.entities.add({
show: false,
position: Cesium.Cartesian3.fromDegrees(6, 0),
point: { pixelSize: 4 },
});
return source;
}
function fakeView() {
const sources = [];
const layers = [];
return {
sources,
layers,
view: {
addSource(config) {
const source = {
config,
deleted: false,
delete: () => (source.deleted = true),
};
sources.push(source);
return source;
},
addLayer(config) {
const layer = {
config,
updates: [],
deleted: false,
update: (next) => layer.updates.push(next),
delete: () => (layer.deleted = true),
};
layers.push(layer);
return layer;
},
},
};
}
test('an imported file plans fills, lines, points and pins, and counts what Navara leaves out', () => {
const plan = planImportedSource(importedSource());
assert.equal(plan.fills.size, 1);
const [fill] = plan.fills.values();
assert.equal(fill.color, '#ffb547');
assert.ok(Math.abs(fill.opacity - 0.25) < 1e-6);
const [polygon] = fill.features;
assert.equal(polygon.geometry.coordinates.length, 2, 'outer ring and hole');
assert.deepEqual(
polygon.geometry.coordinates[0][0].map((v) => Math.round(v)),
polygon.geometry.coordinates[0].at(-1).map((v) => Math.round(v)),
'rings close',
);
assert.equal(plan.lines.size, 1, 'lines of one paint share a group');
assert.equal([...plan.lines.values()][0].features.length, 2);
assert.equal(plan.points.length, 1);
assert.equal(plan.images.size, 1, 'pins sharing an image share a set');
assert.equal([...plan.images.values()][0].features.length, 2);
assert.deepEqual(plan.undrawn, { label: 1, model: 1 });
assert.equal(plan.drawn, 6, 'the hidden point is not drawn');
});
test('the Navara drawing builds vector layers and sprite sets, follows the row and lets go', () => {
const engine = createFakeEngine({ kind: 'navara' });
const { view, sources, layers } = fakeView();
const drawing = drawImportedSource(engine, { view }, importedSource());
assert.deepEqual(drawing.report, {
drawn: 6,
undrawn: 2,
kinds: { label: 1, model: 1 },
});
assert.equal(sources.length, 2);
assert.deepEqual(
layers.map((layer) =>
Object.keys(layer.config).filter((k) => k !== 'type' && k !== 'source'),
),
[['polygon'], ['polyline']],
);
assert.equal(layers[0].config.polygon.clampToGround, true);
assert.equal(layers[0].config.polygon.color, 0xffb547);
assert.equal(layers[1].config.polyline.width, 3);
const sets = engine.movers.list();
assert.deepEqual(
sets.map(({ count }) => count).sort(),
[1, 2],
'one point (no outline) and two pins',
);
drawing.setShow(false);
assert.equal(layers[0].updates.at(-1).polygon.show, false);
assert.ok(engine.movers.list().every(({ count }) => count === 0));
drawing.setShow(true);
assert.deepEqual(
engine.movers
.list()
.map(({ count }) => count)
.sort(),
[1, 2],
);
drawing.refresh();
drawing.destroy();
assert.ok(sources.every((s) => s.deleted) && layers.every((l) => l.deleted));
assert.equal(engine.movers.list().length, 0);
});
test('pin images are measured from the file itself, never fetched from elsewhere', async () => {
const source = new Cesium.CustomDataSource('file');
for (const image of [
'data:image/png;base64,AAAA',
'blob:http://127.0.0.1/abc',
'https://example.invalid/icon.png',
])
source.entities.add({
position: Cesium.Cartesian3.fromDegrees(0, 0),
billboard: { image },
});
const loaded = [];
const sizes = await measureImportImages(source, {
createImage: () => {
const image = {
naturalWidth: 12,
naturalHeight: 20,
decode: async () => loaded.push(image.src),
};
return image;
},
});
assert.deepEqual(loaded.sort(), [
'blob:http://127.0.0.1/abc',
'data:image/png;base64,AAAA',
]);
const plan = planImportedSource(source, { sizes });
assert.equal(plan.images.get('data:image/png;base64,AAAA').sizePx, 20);
assert.equal(plan.images.get('https://example.invalid/icon.png').sizePx, 32);
});
+1 -2
View File
@@ -573,8 +573,7 @@ function createTrail(engine, { id }) {
* Where a click lands in the image before a sensor style bent it: the
* surveillance lens's barrel distortion and the sensors' pixelation grid
* (src/styles/surveillance.js, thermal.js), read from the engine's own
* style state (render.style). Cesium has none: there clicks pick where they
* land.
* style state (render.style), on every engine that draws the styles.
*/
function styleClickPosition(engine, style, position) {
if (style !== 'thermal' && style !== 'surveillance') return position;
@@ -764,6 +764,55 @@ test('sensor styles drawn by the engine move a click to the pixel the shader sho
engine.destroy();
});
test("on the Cesium engine the surveillance lens and FLIR's pixel grid move a click too", () => {
const h = cesiumHarness();
// The scene's post stages, which draw the styles.
h.viewer.scene.postProcessStages = {
bloom: { enabled: false, uniforms: {} },
add() {},
remove() {},
};
Object.assign(h.viewer.scene.canvas, {
clientWidth: 1920,
clientHeight: 1080,
width: 1920,
height: 1080,
});
const state = { _stylePreset: 'surveillance', _vehicles: new Map() };
const display = createTransitDisplay(h.engine, {
state,
services: {},
drawAtlas: transitTestAtlas,
});
display.attach();
const seen = [];
display.bindClick(
({ x, y }) => seen.push([x, y]),
() => true,
);
h.click({ x: 1440, y: 540 });
assert.deepEqual(seen.at(-1), [1440, 540], 'no style, no bend');
h.engine.render.setStyle('surveillance', {
intensity: 1,
uniforms: { pixelation: 1 },
});
h.click({ x: 1440, y: 540 });
near(seen.at(-1)[0], 1472, 'barrel distortion');
near(seen.at(-1)[1], 540, 'on the axis');
near(h.picked().at(-1).x, 1472, 'the engine picks where the lens drew');
state._stylePreset = 'thermal';
h.engine.render.setStyle('surveillance', { intensity: 0 });
h.engine.render.setStyle('thermal', {
intensity: 1,
uniforms: { pixelation: 1.5 },
});
h.click({ x: 100.9, y: 200.1 });
near(seen.at(-1)[0], 100.5, 'pixel grid x');
near(seen.at(-1)[1], 201, 'pixel grid y');
display.destroy();
h.engine.destroy();
});
test('terrain answers the floor unless photoreal tiles hide the globe', async () => {
const engine = createFakeEngine();
const display = createTransitDisplay(engine, {
+8 -5
View File
@@ -1,7 +1,6 @@
import { indexMapSources } from './registry.js';
import * as Cesium from 'cesium';
import { createMapScene } from './sceneApply.js';
import { applyTerrain } from './terrain.js';
import { createMapScene } from '../globe/cesium/mapScene.js';
import { applyTerrain } from '../globe/cesium/terrain.js';
import { acquireImageryComparison } from './imageryComparison.js';
/**
@@ -52,7 +51,8 @@ export class MapSourceController {
requestRender = engine
? () => engine.render.request('map-stack')
: () => viewer?.scene?.requestRender?.(),
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
// A test seam: Cesium's ImageryLayer by default (mapScene.js).
createImageryLayer,
},
) {
this.viewer = viewer;
@@ -69,7 +69,10 @@ export class MapSourceController {
this._prebuilt = engine?.kind === 'cesium';
this._scene = engine
? null
: createMapScene(viewer, { createImageryLayer });
: createMapScene(
viewer,
createImageryLayer ? { createImageryLayer } : {},
);
/** Engine mode: the provider last handed to engine.maps as base imagery. */
this._baseProvider = null;
this._abort = new AbortController();
+5 -2
View File
@@ -1,8 +1,11 @@
import { MAP_STACKS } from './catalog.js';
import { photorealUnavailableReason } from './availability.js';
import { keySetupRequirementEnvVars } from '../keySetupCore.mjs';
import { imageryFactoryFor, ESRI_ATTRIBUTION_HTML } from './imagery.js';
import { terrainSourceFor } from './terrain.js';
import {
imageryFactoryFor,
ESRI_ATTRIBUTION_HTML,
} from '../globe/cesium/mapImagery.js';
import { terrainSourceFor } from '../globe/cesium/terrain.js';
import { t } from '../i18n/index.js';
const NAVARA_ION_IMAGERY_REASON =
+2 -2
View File
@@ -6,13 +6,13 @@ import {
createIonImagery,
createOsmImagery,
imageryFactoryFor,
} from './imagery.js';
} from '../globe/cesium/mapImagery.js';
import {
applyTerrain,
createWorldTerrain,
createKeylessTerrain,
terrainSourceFor,
} from './terrain.js';
} from '../globe/cesium/terrain.js';
import { createDefaultMapSources } from './defaultSources.js';
test('Esri uses Re:Earth without keys and preserves ion terrain when configured', async () => {
+1 -1
View File
@@ -12,7 +12,7 @@ import {
import {
KEYLESS_TERRAIN_URL,
createKeylessTerrainResource,
} from './terrain.js';
} from '../globe/cesium/terrain.js';
const START = 1_700_000_000_000;
+17 -5
View File
@@ -63,8 +63,15 @@ export function fakeCctvHarness() {
engine.fake.script('pick', () => results[0]);
engine.fake.script('drillPick', () => results);
},
// Camera inputs off at each suspension, on again at its resume.
cameraInputs: () =>
engine.fake.callsOf('input.setCameraInputs').map(({ args }) => args[0]),
engine.fake.calls
.filter(({ method }) =>
['input.suspendCameraInputs', 'input.resumeCameraInputs'].includes(
method,
),
)
.map(({ method }) => method === 'input.resumeCameraInputs'),
lineState: (id) => {
const line = engine.fake.line(id);
return line && { ...line.options, color: line.color, shown: line.shown };
@@ -103,11 +110,16 @@ export function cesiumCctvHarness() {
canvas.style = {};
canvas.ownerDocument = { defaultView: new EventTarget() };
viewer.scene.screenSpaceCameraController = { enableInputs: true };
// Camera inputs off at each suspension, on again at its resume.
const inputs = [];
const setCameraInputs = engine.input.setCameraInputs;
engine.input.setCameraInputs = (enabled) => {
inputs.push(enabled);
setCameraInputs(enabled);
const suspend = engine.input.suspendCameraInputs;
engine.input.suspendCameraInputs = () => {
inputs.push(false);
const resume = suspend();
return () => {
inputs.push(true);
resume();
};
};
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(0, 0, VIEW.height),
+5 -1
View File
@@ -99,7 +99,10 @@ export function createFakeGroundHarness(options = {}) {
* `realFills`, Cesium's own GroundPrimitive draws the fills (it builds in
* Node; GroundPolylinePrimitive does not).
*/
export function createCesiumGroundHarness({ realFills = false } = {}) {
export function createCesiumGroundHarness({
realFills = false,
engineOptions = {},
} = {}) {
const handlers = createFakeEventHandlers();
const primitive = (type) => (options) => {
const value = {
@@ -117,6 +120,7 @@ export function createCesiumGroundHarness({ realFills = false } = {}) {
};
const { engine, viewer } = createCesiumMoversEngine({
engineOptions: {
...engineOptions,
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
groundFactories: {
+1 -1
View File
@@ -67,7 +67,7 @@ test('disposing an active Cockpit releases the supplied render owner without ret
services: { releaseContinuousRender: (reason) => released.push(reason) },
});
const ridden = env.cockpit.aircraft();
env.engine.input.setCameraInputs(false);
owner.host.inputs(false);
ridden.show = false;
Object.assign(owner, { active: true, trackedEntity: ridden });
owner.dispose();
+9 -1
View File
@@ -10,6 +10,7 @@ import { routeViewerDoubleClick } from '../globe/cesium/doubleClick.js';
* the Cockpit's goes first; Navara has none to route around.
*/
export function cockpitHost(engine, { viewer = null } = {}) {
let resumeInputs = null;
const aircraft = () => {
const target = engine.camera.following()?.native;
return target?.resumeAircraftFollow ? target : null;
@@ -27,7 +28,14 @@ export function cockpitHost(engine, { viewer = null } = {}) {
cartographic: () => engine.camera.pose().cartographic,
terrainHeight: (place) => engine.surface.terrainHeight(place),
surface: engine,
inputs: (enabled) => engine.input.setCameraInputs(enabled),
// The cockpit holds the camera through a suspension of the user's
// camera inputs: leaving it puts back whatever they were.
inputs(enabled) {
if (enabled) {
resumeInputs?.();
resumeInputs = null;
} else resumeInputs ??= engine.input.suspendCameraInputs();
},
canvasPosition: (world) => engine.projector.worldToWindow(world) ?? null,
routeDoubleClick: (handle) =>
viewer
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more