Cut duplicated and dead code across the engines, the layers and the QA scripts

- src/geo drops the ported debug argument checks and the tests that
  bitExact already covers; package boundaries accept a directory entry,
  so src/geo/ is listed once per group.
- Cesium no longer carries movers and lines nobody draws with: they are
  optional engine capabilities. The fake engine and the Cesium camera use
  the shared cameraMath; unused contract tables, clock.ticked, the
  'finest' surface detail and Navara click statistics are gone.
- ALPR keeps one overlay core and one presentation model for both
  engines, bikeshare one station bookkeeping, the Navara ground effects
  one definition helper; image atlases, view centres, mover banks and
  colours are shared.
- The legacy switch route, the clock offset and rate controls and other
  unused exports are removed.
- The acceptance runs the Navara draw, geo import and scope checks;
  scenarios use the harness switch and page helpers; the main chunk
  check keeps the size budget only and runs once in CI.
This commit is contained in:
German Khokhlov committed 2026-11-05 13:32:39 +04:00
1 parent c502404e1d
commit 7abf74518f
272 files changed
+4828 -10213

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@ const LINE_ID = 'gev-draw-preview';
const DOTS_ID = 'gev-draw-vertices';
/** '#rrggbb' → rgba in 0..1. */
export function cssRgba(css, alpha = 1) {
function cssRgba(css, alpha = 1) {
const hex = Number.parseInt(String(css).slice(1), 16);
return [
((hex >> 16) & 255) / 255,
-11
View File
@@ -68,14 +68,3 @@ export function catalogControlServices(catalog) {
}),
);
}
/**
* Layer roles for controls on a page without data layers: one empty object
* per role, so the shell's optional calls
* (`layer.method?.()`) do nothing.
*/
export function absentLayerControlServices() {
return Object.fromEntries(
Object.keys(CONTROL_LAYER_IDS).map((role) => [role, {}]),
);
}
+1 -39
View File
@@ -1,10 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
absentLayerControlServices,
createLayerCatalog,
catalogControlServices,
} from './catalog.js';
import { createLayerCatalog, catalogControlServices } from './catalog.js';
import { createApplicationData } from './data.js';
const metadata = (layers) =>
@@ -158,40 +154,6 @@ test('controls bind catalog instances rather than similarly named defaults', ()
);
});
test('without data layers the controls get one empty stand-in per layer role', () => {
const ids = [
'traffic',
'flights',
'military',
'satellites',
'cctv',
'street-level',
'radio',
'bikeshare',
'transit',
'ais-live-vessels',
'military-awareness',
'military-installations',
'rocket-launches',
'local-adsb',
];
const layers = ids.map((id) => ({ id }));
const roles = Object.keys(
catalogControlServices(createLayerCatalog(layers, metadata(layers))),
);
const absent = absentLayerControlServices();
assert.deepEqual(Object.keys(absent), roles);
for (const role of roles) {
assert.deepEqual(Object.keys(absent[role]), [], role);
assert.equal(absent[role].subscribe, undefined);
}
assert.notEqual(absent.flightsLayer, absent.trafficLayer);
assert.notEqual(
absentLayerControlServices().flightsLayer,
absent.flightsLayer,
);
});
test('data setup without a viewer registers an empty catalog and no sonar scene', async (t) => {
const previous = globalThis.document;
globalThis.document = { getElementById: () => null };
+9 -9
View File
@@ -6,17 +6,17 @@ import { createAirQualityCesiumRenderer } from '../../layers/airQuality/render/c
import { createFirmsCesiumRenderer } from '../../layers/firms/render/cesium.js';
import { createEarthquakesCesiumRenderer } from '../../layers/earthquakes/render/cesium.js';
import { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesium.js';
// Which engine draws which layer: the layer renderer registry (plan, "layer
// renderer interface") and the layer availability API built on it.
// Which engine draws which layer: the layer renderer registry and the layer
// availability API built on it.
//
// A renderer factory `(engine, options) => renderer` draws one layer on one
// engine kind (`render/cesium.js`, `render/navara.js` of a layer). Cesium
// layers still draw themselves until their render/cesium.js is extracted
// (the *a tasks): they are registered as CESIUM_BUILTIN. Cesium entries are
// registered here, statically (main chunk); Navara factories are registered
// by the Navara mode after its import() (src/app/navaraMode.js).
// layers without a render/cesium.js draw themselves: they are registered as
// CESIUM_BUILTIN. Cesium entries are registered here, statically (main
// chunk); Navara factories are registered by the Navara mode after its
// import() (src/app/navaraMode.js).
//
// ## Layer availability (stable API, used by phase 4's layer list)
// ## Layer availability (stable API, used by the layer list)
//
// import { createLayerAvailability } from './app/layers/renderers.js';
// const availability = createLayerAvailability(engine.kind);
@@ -28,8 +28,8 @@ import { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesiu
// The object is frozen; available() and unavailable() return fresh arrays.
// It reads the registry when called, so a Navara factory registered later
// (after the Navara mode loads) shows up. `name` is the layer's display name
// (src/app/layers/directory.js). Phase 4 injects it as its `availability`
// provider (`{ engine, unavailable() }` is the part it reads).
// (src/app/layers/directory.js). The layer list takes it as its
// `availability` provider (`{ engine, unavailable() }` is the part it reads).
import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js';
+2 -2
View File
@@ -14,7 +14,7 @@
// drawn(id) what the layer draws per object (glyph, model),
// as plain data, or null (layers with qaDrawn)
// following() { label, native } of engine.camera.following()
// movers() engine.movers.list()
// movers() engine.movers.list() ([] without movers: Cesium)
// framesDrawn() frames drawn since the probe started
const plain = (position) =>
@@ -83,7 +83,7 @@ export function createQaProbe({ engine, dataManager, availability }) {
native: handle.native?.id ?? null,
};
},
movers: () => engine.movers.list(),
movers: () => engine.movers?.list() ?? [],
framesDrawn: () => frames,
destroy: () => removeFrames(),
};
+5 -5
View File
@@ -217,11 +217,11 @@ async function createRenderer({
googleTileset: tileset,
cesiumToken,
initialStack: tileset ? 'photoreal' : 'esri-imagery',
// Task 5 (height-datum fix): rebroadcast stack changes as a window
// CustomEvent so data layers (CCTV per-regime ground resolution) can
// react without coupling MapStackController to layer modules. Fires on
// 'switching'/'ready'/'error'; listeners derive the surface regime from
// live scene state, so intermediate emissions are harmless.
// Rebroadcast stack changes as a window CustomEvent so data layers (CCTV
// per-regime ground resolution) can react without coupling
// MapStackController to layer modules. Fires on 'switching'/'ready'/
// 'error'; listeners derive the surface regime from live scene state, so
// intermediate emissions are harmless.
onChange: (state) => {
window.dispatchEvent(
new CustomEvent('gev:map-stack-changed', { detail: state }),
-9
View File
@@ -9,13 +9,6 @@
// a release that runs, in reverse, when the part detaches (before its
// detachScene).
const STATES = Object.freeze([
'detached',
'attaching',
'attached',
'detaching',
]);
export class SceneSwitchingError extends Error {
constructor(message = 'The globe is switching engines') {
super(message);
@@ -208,5 +201,3 @@ export function createSceneHost({ onError = null } = {}) {
},
});
}
export const SCENE_HOST_STATES = STATES;
+1 -1
View File
@@ -327,7 +327,7 @@ function attachViewerTools(
);
// Install the explicit scope mask used by the DISPLAY controls.
installScopeMask(viewer);
installScopeMask(engine);
defer(() => destroyScopeMask());
// The follow camera recomputes the tracked target's dead-reckon position
+1 -1
View File
@@ -9,7 +9,7 @@ export const PITCH_CLAMP_MIN_HEIGHT_M = 300_000;
// with the first Cesium globe, after this module (src/app/loadCesium.js).
const RADIANS_PER_DEGREE = Math.PI / 180;
/** Look this far below the Earth's limb, so the globe stays on screen. */
export const LIMB_MARGIN_RAD = 8 * RADIANS_PER_DEGREE;
const LIMB_MARGIN_RAD = 8 * RADIANS_PER_DEGREE;
const PITCH_EASE = 0.35;
const PITCH_SNAP_RAD = 0.1 * RADIANS_PER_DEGREE;
const HEIGHT_EPSILON_M = 0.0001;
+1 -1
View File
@@ -1,6 +1,6 @@
// Deterministic Launch Library 2 records for phase-3 mission/replay acceptance.
import { SATELLITE_FIXTURE_EPOCH } from './satelliteFixture.js';
export const LAUNCH_FIXTURE_EPOCH = SATELLITE_FIXTURE_EPOCH;
const LAUNCH_FIXTURE_EPOCH = SATELLITE_FIXTURE_EPOCH;
export function launchFixture() {
const definitions = [
[
+1 -3
View File
@@ -1,4 +1,4 @@
// Deterministic TLE catalogs for phase 3 browser QA; no live feed requests.
// Deterministic TLE catalogs for browser QA; no live feed requests.
import { W1_FROZEN_MS } from './w1Feeds.js';
const checksum = (line) => {
@@ -30,8 +30,6 @@ const GROUPS = {
starlink: [50000, 6000, 53, 15.2, 'QA STARLINK'],
};
export const SATELLITE_FIXTURE_EPOCH = W1_FROZEN_MS;
export const SATELLITE_FIXTURE_CORE_COUNT = 839;
export const SATELLITE_FIXTURE_DENSE_COUNT = 6839;
export function satelliteFixture(group) {
const spec = GROUPS[group];
if (!spec) throw new TypeError('Unknown fixture group');
+1
View File
@@ -57,6 +57,7 @@ function fakeViewer({ cameraHeightM, surfaceM, canvas = null }) {
const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM);
const viewer = {
flights,
canvas,
scene: {
canvas,
sampleHeightSupported: true,
+1 -1
View File
@@ -400,7 +400,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
assert.doesNotMatch(source, /camera\.flyTo/);
}
// Vessels' click rule is a function the engine's selection dispatcher
// calls (phase 3, 13a): the same body, out of the old LEFT_CLICK handler.
// calls: the same body, out of the old LEFT_CLICK handler.
const vesselClick = body(
vessels,
/function handleClick\(click\) \{([\s\S]*?)\n \}/,
-14
View File
@@ -2370,17 +2370,3 @@ export function flyRoute(
};
return typeof runNavigation === 'function' ? runNavigation(start) : start();
}
// QA (development builds only; production builds drop it): the verbs and
// their diagnostics, so a browser scenario can drive them on an engine whose
// voice tools do not offer them yet.
if (import.meta.env?.DEV && typeof window !== 'undefined')
window.__gevCameraVerbs = Object.freeze({
cameraVerbsSupported,
flyRoute,
getActiveCameraMotion,
initCameraVerbs,
interruptCameraMotion,
moveCamera,
releaseCameraVerbs,
});
+1 -3
View File
@@ -261,10 +261,8 @@ const TRACKED_RASTER_PX = 192;
* Default size serves the fleet; pass `aircraftIcon(kind, TRACKED_ICON_PX)`
* (re-exported below) for the tracked billboard. */
export const TRACKED_ICON_PX = TRACKED_RASTER_PX;
/** The fleet raster size (aircraftIcon's default). */
export const FLEET_ICON_PX = FLEET_RASTER_PX;
/** Every silhouette kind aircraftIcon draws (an engine's sprite atlas). */
export const AIRCRAFT_ICON_KINDS = Object.freeze(Object.keys(BODIES));
const AIRCRAFT_ICON_KINDS = Object.freeze(Object.keys(BODIES));
export function aircraftIcon(kind, px = FLEET_RASTER_PX) {
const k = BODIES[kind] ? kind : 'airliner';
const key = `${k}@${px}`;
+2 -2
View File
@@ -2,8 +2,8 @@ import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
/**
* Arrival corrections belong to one globe engine and yield to its next camera
* owner. Callers may name the engine or (layers until phase 1c) the viewer it
* is registered for; both reach the same correction.
* owner. Callers may name the engine or (layers that still hold one) the
* viewer it is registered for; both reach the same correction.
*/
const arrivals = new WeakMap();
+1 -1
View File
@@ -29,7 +29,7 @@ for (const layer of LAYERS) {
test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => {
// The aircraft renderer loads through its `loadModel`, Cesium.Model.fromGltfAsync
// unless a test injects a loader (phase 3).
// unless a test injects a loader.
assert.match(source, /loadModel = \(options\) => Cesium\.Model\.fromGltfAsync\(options\)/);
const calls = [...source.matchAll(/(?:Cesium\.Model\.fromGltfAsync|\bloadModel)\(\{([\s\S]*?)\}\)/g)];
assert.ok(calls.length >= 3, `expected fleet, tracked, and preload model calls; found ${calls.length}`);
+2 -2
View File
@@ -34,7 +34,7 @@ export const OPENMAPTILES_CREDIT = {
key: 'openfreemap',
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
};
export const GOOGLE_PLACES_CREDIT = {
const GOOGLE_PLACES_CREDIT = {
key: 'google-maps-places',
html: 'Google Maps Places: <a href="https://developers.google.com/maps/documentation/places/web-service/policies" target="_blank" rel="noopener">Google Maps</a>',
};
@@ -45,7 +45,7 @@ const viewerCredits = new WeakMap();
/**
* Credits of a globe engine, or of a Cesium viewer's credit display for
* callers that still hold a viewer (layers until phase 1c); null without one.
* callers that still hold a viewer (Cesium layers); null without one.
* @returns {{ addStatic: Function, request: () => void } | null}
*/
function creditsOf(target) {
+1 -1
View File
@@ -143,7 +143,7 @@ test('every scene click handler consults ownership before it picks', () => {
'src/layers/submarineCables/interaction.js',
'viewer.scene.pick(click.position)',
],
// Vessels take the engine dispatcher's pick (phase 3, 13a): the guard
// Vessels take the engine dispatcher's pick: the guard
// comes before the click rule reads it.
['src/layers/vessels/selection.js', 'resolvePickId(picked)'],
];
+1 -1
View File
@@ -140,7 +140,7 @@ test('sampler on a globe engine: samples engine.surface and latches against the
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 137.0);
const [call] = engine.fake.callsOf('surface.sampleHeightDetailed');
assert.ok(Math.abs(Cesium.Math.toDegrees(call.args[0].latitude) - cell.lat) < 1e-9);
assert.deepEqual(call.args[1], { exclude: ['own'], lod: 'visible' });
assert.deepEqual(call.args[1], { exclude: ['own'] });
});
test('sampler: a coarse tile answer is a miss; a fine one latches', async () => {
+1 -2
View File
@@ -1,7 +1,6 @@
// Native constants shared by the W1 adapters; keep the SDK import here.
// Read on call: the production build's Cesium global loads with the first
// Cesium globe, after this module has loaded on a Navara start.
import { Axis, ScreenSpaceEventType } from 'cesium';
import { ScreenSpaceEventType } from 'cesium';
export const modelAxis = () => Axis;
export const trackingEventTypes = () => ScreenSpaceEventType;
+3 -3
View File
@@ -1,6 +1,6 @@
import { nearbyContact } from './pickRegistry.js';
// One selection dispatcher for the layers that have moved onto the globe
// engine (phase 3 Task 4): one click subscription with picks
// One selection dispatcher for the layers that draw through the globe
// engine: one click subscription with picks
// (engine.input.on('click', …, { pick: true, drill: 8 })), the press
// gesture and one Escape listener, instead of a ScreenSpaceEventHandler
// per layer. Owners register as with pickRegistry (registerPickOwner, so
@@ -38,7 +38,7 @@ import {
isTrackingSelectionGesture,
} from './trackingClickGesture.js';
export const SELECTION_DRILL = 8;
const SELECTION_DRILL = 8;
/**
* @param {import('../globe/contract.js').GlobeEngine} engine
+1 -1
View File
@@ -109,7 +109,7 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r
assert.deepEqual(calls, [viewer, viewer, viewer]);
const firmsLayer = createFirmsHeatmapLayer({ id: 'firms', name: 'FIRMS' });
// Flights draws through its renderer (phase 3): enable asks the fleet to
// Flights draws through its renderer: enable asks the fleet to
// restore its order, which the Cesium aircraft renderer wires to the shared
// restorer under the flights' sprite slot.
assert.match(flightsLayer.enable.toString(), /fleet\.restoreOrderOnEnable\(\)/);
+1 -1
View File
@@ -114,7 +114,7 @@ test('civilian and military click handlers apply duration only at the deselect b
readLayerSource(new URL('./flights.js', import.meta.url)),
readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
];
// Flights and military moved to the selection dispatcher (phase 3), whose
// Flights and military use the selection dispatcher, whose
// source applies the same two rules: the selection gesture before a
// select, the full click classifier before the empty-space deselect, which
// each layer answers with a user-origin clear.
+7 -9
View File
@@ -1,12 +1,15 @@
// Seeded, deterministic bit-for-bit comparison of src/geo against Cesium.
// Seeded, deterministic bit-for-bit comparison of src/geo against Cesium's
// release build (Build/Cesium, debug checks stripped, as production ships it).
// Every number is compared with Object.is (so -0 and NaN count), a throw on one
// side must be a throw on the other, and `result` aliasing is exercised the
// same way on both sides.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createRequire } from 'node:module';
import * as geo from './index.js';
const Cesium = createRequire(import.meta.url)('cesium/Build/Cesium/index.cjs');
const {
cartesian3: c3,
cartographic,
@@ -108,7 +111,7 @@ const cesium = (v) => new C3(v.x, v.y, v.z);
test('vector operations, including -0 inputs and aliased results, equal Cesium bit for bit', () => {
const { scalar, vector } = inputs(1);
// Directionless vectors first: normalize must throw on both sides.
// Directionless vectors first: normalize returns NaN on both sides.
const DEGENERATE = [
{ x: 0, y: 0, z: 0 },
{ x: -0, y: 0, z: -0 },
@@ -397,12 +400,7 @@ test('matrices and quaternions, including aliased results and every fromRotation
][i % 4];
const angle =
i % 8 < 4 ? Math.PI - 0.5 * random() : (random() - 0.5) * 2 * Math.PI;
let m;
try {
m = rotation(axis, angle);
} catch {
continue; // a zero axis: Cesium throws, nothing to compare
}
const m = rotation(axis, angle);
const trace = m[0] + m[4] + m[8];
branches.add(
trace > 0
-7
View File
@@ -1,8 +1,6 @@
// Ported from CesiumJS 1.138 (Apache-2.0) — BoundingSphere.js subset.
// Exact mission framing: Ritter/box fit and the union of two spheres.
import * as Cartesian3 from './cartesian3.js';
import { Check } from './check.js';
import { DEBUG } from './debug.js';
const defined = (value) => value !== null && value !== undefined;
export function create(center = Cartesian3.ZERO, radius = 0) {
return { center: Cartesian3.clone(center), radius };
@@ -184,11 +182,6 @@ export function fromPoints(positions, result) {
return result;
}
export function union(left, right, result) {
if (DEBUG) {
Check.typeOf.object('left', left);
Check.typeOf.object('right', right);
}
if (!defined(result)) {
result = create();
}
+1 -11
View File
@@ -1,17 +1,12 @@
// Ported from CesiumJS 1.138 (Apache-2.0) — Cartesian2.js
//
// 2D vectors (screen coordinates) as plain-data {x, y} objects. With DEBUG on
// (development and tests; see debug.js) invalid arguments throw the same
// DeveloperError as Cesium's debug checks.
// 2D vectors (screen coordinates) as plain-data {x, y} objects.
//
// Geo values are not Object.prototype objects, so src/app/stateChannel.js
// copyState and src/director/documentFields.js jsonTree reject them: copy
// the fields into a literal before putting one into product state or a
// scene document.
import { DEBUG } from './debug.js';
import { Check } from './check.js';
// Values are instances of a module-private constructor rather than object
// literals: every `{x, y}` literal in the process shares one V8 hidden
// class, and code elsewhere that builds such literals would make geo's hot
@@ -38,11 +33,6 @@ export function clone(cartesian, result) {
/** The distance between two points. */
export function distance(left, right) {
if (DEBUG) {
Check.typeOf.object('left', left);
Check.typeOf.object('right', right);
}
const x = left.x - right.x;
const y = left.y - right.y;
return Math.sqrt(x * x + y * y);
+3 -144
View File
@@ -3,19 +3,14 @@
// 3D vectors as plain-data {x, y, z} objects. Arithmetic takes a required `result`
// (as Cesium does) that may be one of the inputs; creators take an optional
// one. The operation order matches Cesium so the outputs are bit-identical.
// With DEBUG on (development and tests; see debug.js) invalid arguments throw
// the same DeveloperError as Cesium's debug checks, and `normalize` throws on a
// zero or non-finite vector; production builds skip the checks and return NaN
// components there, as release Cesium does.
// As in release Cesium, arguments are not checked and `normalize` returns NaN
// components for a zero or non-finite vector.
//
// Geo values are not Object.prototype objects, so src/app/stateChannel.js
// copyState and src/director/documentFields.js jsonTree reject them: copy
// the fields into a literal before putting one into product state or a
// scene document.
import { DEBUG } from './debug.js';
import { Check } from './check.js';
import { DeveloperError } from './developerError.js';
import { equalsEpsilon as scalarEqualsEpsilon, toRadians } from './math.js';
// Values are instances of a module-private constructor rather than object
@@ -59,10 +54,6 @@ export const UNIT_Y = Object.freeze(create(0.0, 1.0, 0.0));
export const UNIT_Z = Object.freeze(create(0.0, 0.0, 1.0));
export function magnitudeSquared(cartesian) {
if (DEBUG) {
Check.typeOf.object('cartesian', cartesian);
}
return (
cartesian.x * cartesian.x +
cartesian.y * cartesian.y +
@@ -77,60 +68,29 @@ export function magnitude(cartesian) {
const distanceScratch = create();
export function distance(left, right) {
if (DEBUG) {
Check.typeOf.object('left', left);
Check.typeOf.object('right', right);
}
subtract(left, right, distanceScratch);
return magnitude(distanceScratch);
}
export function distanceSquared(left, right) {
if (DEBUG) {
Check.typeOf.object('left', left);
Check.typeOf.object('right', right);
}
subtract(left, right, distanceScratch);
return magnitudeSquared(distanceScratch);
}
/** The unit vector; with DEBUG on, throws when the vector has no direction (zero or non-finite). */
/** The unit vector; NaN components when the vector has no direction (zero or non-finite). */
export function normalize(cartesian, result) {
if (DEBUG) {
Check.typeOf.object('cartesian', cartesian);
Check.typeOf.object('result', result);
}
const length = magnitude(cartesian);
result.x = cartesian.x / length;
result.y = cartesian.y / length;
result.z = cartesian.z / length;
if (DEBUG) {
if (isNaN(result.x) || isNaN(result.y) || isNaN(result.z)) {
throw new DeveloperError('normalized result is not a number');
}
}
return result;
}
export function dot(left, right) {
if (DEBUG) {
Check.typeOf.object('left', left);
Check.typeOf.object('right', right);
}
return left.x * right.x + left.y * right.y + left.z * right.z;
}
export function multiplyComponents(left, right, result) {
if (DEBUG) {
Check.typeOf.object('left', left);
Check.typeOf.object('right', right);
Check.typeOf.object('result', result);
}
result.x = left.x * right.x;
result.y = left.y * right.y;
result.z = left.z * right.z;
@@ -138,12 +98,6 @@ export function multiplyComponents(left, right, result) {
}
export function add(left, right, result) {
if (DEBUG) {
Check.typeOf.object('left', left);
Check.typeOf.object('right', right);
Check.typeOf.object('result', result);
}
result.x = left.x + right.x;
result.y = left.y + right.y;
result.z = left.z + right.z;
@@ -151,12 +105,6 @@ export function add(left, right, result) {
}
export function subtract(left, right, result) {
if (DEBUG) {
Check.typeOf.object('left', left);
Check.typeOf.object('right', right);
Check.typeOf.object('result', result);
}
result.x = left.x - right.x;
result.y = left.y - right.y;
result.z = left.z - right.z;
@@ -164,12 +112,6 @@ export function subtract(left, right, result) {
}
export function multiplyByScalar(cartesian, scalar, result) {
if (DEBUG) {
Check.typeOf.object('cartesian', cartesian);
Check.typeOf.number('scalar', scalar);
Check.typeOf.object('result', result);
}
result.x = cartesian.x * scalar;
result.y = cartesian.y * scalar;
result.z = cartesian.z * scalar;
@@ -177,12 +119,6 @@ export function multiplyByScalar(cartesian, scalar, result) {
}
export function divideByScalar(cartesian, scalar, result) {
if (DEBUG) {
Check.typeOf.object('cartesian', cartesian);
Check.typeOf.number('scalar', scalar);
Check.typeOf.object('result', result);
}
result.x = cartesian.x / scalar;
result.y = cartesian.y / scalar;
result.z = cartesian.z / scalar;
@@ -190,11 +126,6 @@ export function divideByScalar(cartesian, scalar, result) {
}
export function negate(cartesian, result) {
if (DEBUG) {
Check.typeOf.object('cartesian', cartesian);
Check.typeOf.object('result', result);
}
result.x = -cartesian.x;
result.y = -cartesian.y;
result.z = -cartesian.z;
@@ -204,25 +135,12 @@ export function negate(cartesian, result) {
const lerpScratch = create();
export function lerp(start, end, t, result) {
if (DEBUG) {
Check.typeOf.object('start', start);
Check.typeOf.object('end', end);
Check.typeOf.number('t', t);
Check.typeOf.object('result', result);
}
multiplyByScalar(end, t, lerpScratch);
result = multiplyByScalar(start, 1.0 - t, result);
return add(lerpScratch, result, result);
}
export function cross(left, right, result) {
if (DEBUG) {
Check.typeOf.object('left', left);
Check.typeOf.object('right', right);
Check.typeOf.object('result', result);
}
const leftX = left.x;
const leftY = left.y;
const leftZ = left.z;
@@ -241,12 +159,6 @@ export function cross(left, right, result) {
}
export function midpoint(left, right, result) {
if (DEBUG) {
Check.typeOf.object('left', left);
Check.typeOf.object('right', right);
Check.typeOf.object('result', result);
}
result.x = (left.x + right.x) * 0.5;
result.y = (left.y + right.y) * 0.5;
result.z = (left.z + right.z) * 0.5;
@@ -292,11 +204,6 @@ let scratchK = create();
/** The Earth-fixed position of a geodetic one in radians on the ellipsoid (WGS84 by default). */
export function fromRadians(longitude, latitude, height, ellipsoid, result) {
if (DEBUG) {
Check.typeOf.number('longitude', longitude);
Check.typeOf.number('latitude', latitude);
}
height = height ?? 0.0;
const radiiSquared =
ellipsoid == null ? WGS84_RADII_SQUARED : ellipsoid.radiiSquared;
@@ -318,11 +225,6 @@ export function fromRadians(longitude, latitude, height, ellipsoid, result) {
/** The Earth-fixed position of a geodetic one in degrees on the ellipsoid (WGS84 by default). */
export function fromDegrees(longitude, latitude, height, ellipsoid, result) {
if (DEBUG) {
Check.typeOf.number('longitude', longitude);
Check.typeOf.number('latitude', latitude);
}
longitude = toRadians(longitude);
latitude = toRadians(latitude);
return fromRadians(longitude, latitude, height, ellipsoid, result);
@@ -330,15 +232,6 @@ export function fromDegrees(longitude, latitude, height, ellipsoid, result) {
/** Positions from a flat [lon, lat, lon, lat, …] list in degrees, at height 0. */
export function fromDegreesArray(coordinates, ellipsoid, result) {
if (DEBUG) {
Check.defined('coordinates', coordinates);
if (coordinates.length < 2 || coordinates.length % 2 !== 0) {
throw new DeveloperError(
'the number of coordinates must be a multiple of 2 and at least 2',
);
}
}
const length = coordinates.length;
if (result == null) {
result = new Array(length / 2);
@@ -358,43 +251,9 @@ export function fromDegreesArray(coordinates, ellipsoid, result) {
return result;
}
/** Positions from a flat [lon, lat, height, …] list in degrees and metres. */
export function fromDegreesArrayHeights(coordinates, ellipsoid, result) {
if (DEBUG) {
Check.defined('coordinates', coordinates);
if (coordinates.length < 3 || coordinates.length % 3 !== 0) {
throw new DeveloperError(
'the number of coordinates must be a multiple of 3 and at least 3',
);
}
}
const length = coordinates.length;
if (result == null) {
result = new Array(length / 3);
} else {
result.length = length / 3;
}
for (let i = 0; i < length; i += 3) {
const index = i / 3;
result[index] = fromDegrees(
coordinates[i],
coordinates[i + 1],
coordinates[i + 2],
ellipsoid,
result[index],
);
}
return result;
}
const angleBetweenScratch = create();
const angleBetweenScratch2 = create();
export function angleBetween(left, right) {
if (DEBUG) {
Check.typeOf.object('left', left);
Check.typeOf.object('right', right);
}
normalize(left, angleBetweenScratch);
normalize(right, angleBetweenScratch2);
const cosine = dot(angleBetweenScratch, angleBetweenScratch2);
+2 -90
View File
@@ -21,78 +21,16 @@ const same = (ours, theirs, label) =>
label,
);
test('binary vector ops equal Cesium bit for bit', () => {
for (const a of SAMPLES)
for (const b of SAMPLES) {
for (const op of ['add', 'subtract', 'cross', 'multiplyComponents']) {
same(
c3[op](asPlain(a), asPlain(b), c3.create()),
C[op](asCesium(a), asCesium(b), new C()),
`${op}`,
);
}
assert.equal(
c3.dot(asPlain(a), asPlain(b)),
C.dot(asCesium(a), asCesium(b)),
);
assert.equal(
c3.distance(asPlain(a), asPlain(b)),
C.distance(asCesium(a), asCesium(b)),
);
assert.equal(
c3.distanceSquared(asPlain(a), asPlain(b)),
C.distanceSquared(asCesium(a), asCesium(b)),
);
same(
c3.lerp(asPlain(a), asPlain(b), 0.3, c3.create()),
C.lerp(asCesium(a), asCesium(b), 0.3, new C()),
'lerp',
);
same(
c3.midpoint(asPlain(a), asPlain(b), c3.create()),
C.midpoint(asCesium(a), asCesium(b), new C()),
'midpoint',
);
assert.equal(
c3.equalsEpsilon(asPlain(a), asPlain(b), 1e-7),
C.equalsEpsilon(asCesium(a), asCesium(b), 1e-7),
);
}
});
test('unary ops and scalars equal Cesium', () => {
test('magnitudeSquared equals Cesium', () => {
for (const a of SAMPLES) {
assert.equal(c3.magnitude(asPlain(a)), C.magnitude(asCesium(a)));
assert.equal(
c3.magnitudeSquared(asPlain(a)),
C.magnitudeSquared(asCesium(a)),
);
same(
c3.negate(asPlain(a), c3.create()),
C.negate(asCesium(a), new C()),
'negate',
);
same(
c3.multiplyByScalar(asPlain(a), 2.5, c3.create()),
C.multiplyByScalar(asCesium(a), 2.5, new C()),
'mul',
);
same(
c3.divideByScalar(asPlain(a), 4, c3.create()),
C.divideByScalar(asCesium(a), 4, new C()),
'div',
);
if (C.magnitude(asCesium(a)) !== 0) {
same(
c3.normalize(asPlain(a), c3.create()),
C.normalize(asCesium(a), new C()),
'normalize',
);
}
}
});
test('geodetic constructors equal Cesium', () => {
test('fromRadians and fromDegreesArray equal Cesium', () => {
for (const [lon, lat, h] of [
[0, 0, 0],
[-73.9857, 40.7484, 381],
@@ -100,11 +38,6 @@ test('geodetic constructors equal Cesium', () => {
[180, -90, 1e4],
[12.5, 89.999999, 1],
]) {
same(
c3.fromDegrees(lon, lat, h),
C.fromDegrees(lon, lat, h),
`fromDegrees ${lon},${lat}`,
);
const lr = [Cesium.Math.toRadians(lon), Cesium.Math.toRadians(lat), h];
same(c3.fromRadians(...lr), C.fromRadians(...lr), 'fromRadians');
}
@@ -112,10 +45,6 @@ test('geodetic constructors equal Cesium', () => {
c3.fromDegreesArray(flat).forEach((p, i) =>
same(p, C.fromDegreesArray(flat)[i], `array ${i}`),
);
const heights = [-74, 40.7, 10, -73.9, 40.8, 20];
c3.fromDegreesArrayHeights(heights).forEach((p, i) =>
same(p, C.fromDegreesArrayHeights(heights)[i], `heights ${i}`),
);
});
test('result aliasing works like Cesium and creators reuse result', () => {
@@ -138,20 +67,3 @@ test('plain results are accepted by Cesium functions (duck typing)', () => {
),
);
});
test('normalize throws on a zero or NaN vector, as Cesium does when run from source', () => {
for (const [x, y, z] of [
[0, 0, 0],
[-0, 0, -0],
[Number.NaN, 1, 2],
]) {
assert.throws(
() => C.normalize(new C(x, y, z), new C()),
/normalized result is not a number/,
);
assert.throws(
() => c3.normalize({ x, y, z }, c3.create()),
/normalized result is not a number/,
);
}
});
+1 -19
View File
@@ -1,17 +1,13 @@
// Ported from CesiumJS 1.138 (Apache-2.0) — Cartographic.js
//
// Geodetic positions as plain-data {longitude, latitude, height} objects (radians and
// metres). Every function takes an optional `result` and returns it. With
// DEBUG on (development and tests; see debug.js) invalid arguments throw the
// same DeveloperError as Cesium's debug checks.
// metres). Every function takes an optional `result` and returns it.
//
// Geo values are not Object.prototype objects, so src/app/stateChannel.js
// copyState and src/director/documentFields.js jsonTree reject them: copy
// the fields into a literal before putting one into product state or a
// scene document.
import { DEBUG } from './debug.js';
import { Check } from './check.js';
import { toRadians } from './math.js';
import * as cartesian3 from './cartesian3.js';
import { scaleToGeodeticSurface } from './scaleToGeodeticSurface.js';
@@ -51,11 +47,6 @@ const WGS84_CENTER_TOLERANCE_SQUARED = 0.1;
/** A geodetic position from radians. */
export function fromRadians(longitude, latitude, height, result) {
if (DEBUG) {
Check.typeOf.number('longitude', longitude);
Check.typeOf.number('latitude', latitude);
}
height = height ?? 0.0;
if (result == null) return create(longitude, latitude, height);
result.longitude = longitude;
@@ -66,11 +57,6 @@ export function fromRadians(longitude, latitude, height, result) {
/** A geodetic position from degrees; the result is in radians. */
export function fromDegrees(longitude, latitude, height, result) {
if (DEBUG) {
Check.typeOf.number('longitude', longitude);
Check.typeOf.number('latitude', latitude);
}
longitude = toRadians(longitude);
latitude = toRadians(latitude);
return fromRadians(longitude, latitude, height, result);
@@ -133,10 +119,6 @@ export function fromCartesian(cartesian, ellipsoid, result) {
* where Cesium uses Ellipsoid.default; gev never changes it).
*/
export function toCartesian(cartographic, ellipsoid, result) {
if (DEBUG) {
Check.defined('cartographic', cartographic);
}
return cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
-29
View File
@@ -4,12 +4,6 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as cartographic from './cartographic.js';
const close = (a, b, tol, label) =>
assert.ok(
Math.abs(a - b) <= tol * Math.max(1, Math.abs(b)),
`${label}: ${a} vs ${b}`,
);
test('fromDegrees and fromRadians equal Cesium', () => {
for (const [lon, lat, h] of [
[0, 0, 0],
@@ -30,29 +24,6 @@ test('fromDegrees and fromRadians equal Cesium', () => {
}
});
test('fromCartesian and toCartesian equal Cesium, including the centre case', () => {
for (const [x, y, z] of [
[1334762.2, -4654869.1, 4137287.9],
[6378137, 0, 0],
[0.01, 0, 0],
]) {
const ours = cartographic.fromCartesian({ x, y, z });
const theirs = Cesium.Cartographic.fromCartesian(
new Cesium.Cartesian3(x, y, z),
);
if (theirs === undefined) {
assert.equal(ours, undefined);
continue;
}
close(ours.longitude, theirs.longitude, 1e-12, 'lon');
close(ours.latitude, theirs.latitude, 1e-12, 'lat');
close(ours.height, theirs.height, 1e-9, 'h');
const back = cartographic.toCartesian(ours);
const cBack = Cesium.Cartographic.toCartesian(theirs);
for (const k of ['x', 'y', 'z']) close(back[k], cBack[k], 1e-12, k);
}
});
test('result objects are reused', () => {
const out = cartographic.create();
assert.equal(cartographic.fromDegrees(1, 2, 3, out), out);
-61
View File
@@ -1,61 +0,0 @@
// Ported from CesiumJS 1.138 (Apache-2.0) — Check.js
//
// Cesium's argument checks with the same conditions and message text. Only the
// checks src/geo needs are ported. Callers run them inside `if (DEBUG)` blocks
// (see debug.js), as Cesium runs them inside its debug pragmas, so production
// builds drop the calls and this module with them.
import { DeveloperError } from './developerError.js';
const undefinedMessage = (name) =>
`${name} is required, actual value was undefined`;
const typeMessage = (actual, expected, name) =>
`Expected ${name} to be typeof ${expected}, actual typeof was ${actual}`;
/** Throws unless `test` is neither undefined nor null. */
function defined(name, test) {
if (test === undefined || test === null) {
throw new DeveloperError(undefinedMessage(name));
}
}
/** Throws unless `typeof test` is 'number' (NaN passes, as in Cesium). */
function number(name, test) {
if (typeof test !== 'number') {
throw new DeveloperError(typeMessage(typeof test, 'number', name));
}
}
// Attached in a function marked pure, not by top-level assignments, so that a
// production build, where no caller runs a check, drops this module entirely.
function withBounds(check) {
check.lessThanOrEquals = function (name, test, limit) {
check(name, test);
if (test > limit) {
throw new DeveloperError(
`Expected ${name} to be less than or equal to ${limit}, actual value was ${test}`,
);
}
};
check.greaterThanOrEquals = function (name, test, limit) {
check(name, test);
if (test < limit) {
throw new DeveloperError(
`Expected ${name} to be greater than or equal to ${limit}, actual value was ${test}`,
);
}
};
return check;
}
/** Throws unless `typeof test` is 'object' (null passes, as in Cesium). */
function object(name, test) {
if (typeof test !== 'object') {
throw new DeveloperError(typeMessage(typeof test, 'object', name));
}
}
export const Check = /* @__PURE__ */ Object.freeze({
defined,
typeOf: Object.freeze({ number: /* @__PURE__ */ withBounds(number), object }),
});
-486
View File
@@ -1,486 +0,0 @@
// Argument-check parity with Cesium. gev runs Cesium from source, where its
// debug checks are live, so for any input both sides must throw an error with
// the same name and message, or neither may throw. The inputs are seeded and
// deterministic: each case builds its arguments twice from the same seed, once
// for geo and once for Cesium, so no object is shared between the two sides.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from './index.js';
import { DeveloperError } from './developerError.js';
const TRIALS = 150;
function generator(seed) {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Invalid values every argument may receive. NaN is a number to Check.typeOf.number
// and null an object to Check.typeOf.object, so both reach the arithmetic.
const INVALID = [undefined, null, Number.NaN, 'abc', 5, true, {}];
const vectorValues = { x: 1.5, y: -2, z: 3 };
const VALID = {
num: (side, random) => [0, -0, 0.7, -2.5, 1e6][Math.floor(random() * 5)],
index3: (side, random) => Math.floor(random() * 5) - 1,
index4: (side, random) => Math.floor(random() * 6) - 1,
nonNegative: (side, random) => [0, 3, 6378137, -1][Math.floor(random() * 4)],
vec: (side) =>
side === 'geo' ? { ...vectorValues } : new Cesium.Cartesian3(1.5, -2, 3),
zeroVec: (side) =>
side === 'geo' ? { x: 0, y: 0, z: 0 } : new Cesium.Cartesian3(),
vec4: (side) =>
side === 'geo' ? { x: 0, y: 0, z: 0, w: 0 } : new Cesium.Cartesian4(),
carto: (side) =>
side === 'geo'
? { longitude: 0.3, latitude: -0.2, height: 10 }
: new Cesium.Cartographic(0.3, -0.2, 10),
partialCarto: (side) =>
side === 'geo'
? { longitude: 0.3, latitude: -0.2 }
: { longitude: 0.3, latitude: -0.2 },
quat: (side) =>
side === 'geo'
? { x: 0.1, y: 0.2, z: 0.3, w: 0.9 }
: new Cesium.Quaternion(0.1, 0.2, 0.3, 0.9),
mat3: (side) =>
side === 'geo'
? geo.matrix3.fromRotationZ(0.4)
: Cesium.Matrix3.fromRotationZ(0.4, new Cesium.Matrix3()),
mat4: (side) =>
side === 'geo'
? geo.transforms.eastNorthUpToFixedFrame({ x: 6378137, y: 1, z: 2 })
: Cesium.Transforms.eastNorthUpToFixedFrame(
new Cesium.Cartesian3(6378137, 1, 2),
),
hpr: (side) =>
side === 'geo'
? { heading: 0.3, pitch: -0.2, roll: 0.1 }
: new Cesium.HeadingPitchRoll(0.3, -0.2, 0.1),
partialHpr: () => ({ heading: 0.3 }),
ellipsoid: (side) =>
side === 'geo' ? geo.ellipsoid.WGS84 : Cesium.Ellipsoid.WGS84,
coords2: (side, random) =>
[[1, 2], [1, 2, 3], [], [1, 2, 3, 4]][Math.floor(random() * 4)],
coords3: (side, random) =>
[[1, 2, 3], [1, 2], [], [1, 2, 3, 4, 5, 6], [1, 2, 'h']][
Math.floor(random() * 5)
],
nanVec: (side) =>
side === 'geo'
? { x: 1, y: Number.NaN, z: 2 }
: new Cesium.Cartesian3(1, Number.NaN, 2),
partialVec: () => ({ x: 1, y: 2 }),
stringHeight: () => '5',
};
/** One argument: usually a valid value of its kind, sometimes an invalid one. */
function argument(kinds, side, random) {
if (random() < 0.4) return INVALID[Math.floor(random() * INVALID.length)];
const kind = kinds[Math.floor(random() * kinds.length)];
return VALID[kind](side, random);
}
const W = Cesium.Ellipsoid.WGS84;
const C3 = Cesium.Cartesian3;
const V = ['vec', 'zeroVec', 'nanVec', 'partialVec'];
const N = ['num'];
const R = ['vec'];
// [name, geo function, Cesium function, argument kinds per position]
const CASES = [
['math.toRadians', geo.math.toRadians, Cesium.Math.toRadians, [N]],
['math.toDegrees', geo.math.toDegrees, Cesium.Math.toDegrees, [N]],
['math.clamp', geo.math.clamp, Cesium.Math.clamp, [N, N, N]],
['math.lerp', geo.math.lerp, Cesium.Math.lerp, [N, N, N]],
['math.mod', geo.math.mod, Cesium.Math.mod, [N, ['num', 'nonNegative']]],
['math.zeroToTwoPi', geo.math.zeroToTwoPi, Cesium.Math.zeroToTwoPi, [N]],
[
'math.negativePiToPi',
geo.math.negativePiToPi,
Cesium.Math.negativePiToPi,
[N],
],
[
'math.equalsEpsilon',
geo.math.equalsEpsilon,
Cesium.Math.equalsEpsilon,
[N, N, N, N],
],
['cartesian2.clone', geo.cartesian2.clone, Cesium.Cartesian2.clone, [R, R]],
[
'cartesian2.distance',
geo.cartesian2.distance,
Cesium.Cartesian2.distance,
[R, R],
],
['cartesian3.clone', geo.cartesian3.clone, C3.clone, [V, R]],
[
'cartesian3.fromElements',
geo.cartesian3.fromElements,
C3.fromElements,
[N, N, N, R],
],
['cartesian3.magnitude', geo.cartesian3.magnitude, C3.magnitude, [V]],
[
'cartesian3.magnitudeSquared',
geo.cartesian3.magnitudeSquared,
C3.magnitudeSquared,
[V],
],
['cartesian3.distance', geo.cartesian3.distance, C3.distance, [V, V]],
[
'cartesian3.distanceSquared',
geo.cartesian3.distanceSquared,
C3.distanceSquared,
[V, V],
],
['cartesian3.normalize', geo.cartesian3.normalize, C3.normalize, [V, R]],
['cartesian3.dot', geo.cartesian3.dot, C3.dot, [V, V]],
[
'cartesian3.multiplyComponents',
geo.cartesian3.multiplyComponents,
C3.multiplyComponents,
[V, V, R],
],
['cartesian3.add', geo.cartesian3.add, C3.add, [V, V, R]],
['cartesian3.subtract', geo.cartesian3.subtract, C3.subtract, [V, V, R]],
[
'cartesian3.multiplyByScalar',
geo.cartesian3.multiplyByScalar,
C3.multiplyByScalar,
[V, N, R],
],
[
'cartesian3.divideByScalar',
geo.cartesian3.divideByScalar,
C3.divideByScalar,
[V, N, R],
],
['cartesian3.negate', geo.cartesian3.negate, C3.negate, [V, R]],
['cartesian3.lerp', geo.cartesian3.lerp, C3.lerp, [V, V, N, R]],
['cartesian3.cross', geo.cartesian3.cross, C3.cross, [V, V, R]],
['cartesian3.midpoint', geo.cartesian3.midpoint, C3.midpoint, [V, V, R]],
['cartesian3.equals', geo.cartesian3.equals, C3.equals, [V, V]],
[
'cartesian3.equalsEpsilon',
geo.cartesian3.equalsEpsilon,
C3.equalsEpsilon,
[V, V, N, N],
],
[
'cartesian3.fromRadians',
geo.cartesian3.fromRadians,
C3.fromRadians,
[N, N, ['num', 'stringHeight'], ['ellipsoid'], R],
],
[
'cartesian3.fromDegrees',
geo.cartesian3.fromDegrees,
C3.fromDegrees,
[N, N, ['num', 'stringHeight'], ['ellipsoid'], R],
],
[
'cartesian3.fromDegreesArray',
geo.cartesian3.fromDegreesArray,
C3.fromDegreesArray,
[['coords2'], ['ellipsoid']],
],
[
'cartesian3.fromDegreesArrayHeights',
geo.cartesian3.fromDegreesArrayHeights,
C3.fromDegreesArrayHeights,
[['coords3'], ['ellipsoid']],
],
[
'cartographic.fromRadians',
geo.cartographic.fromRadians,
Cesium.Cartographic.fromRadians,
[N, N, N, ['carto']],
],
[
'cartographic.fromDegrees',
geo.cartographic.fromDegrees,
Cesium.Cartographic.fromDegrees,
[N, N, N, ['carto']],
],
[
'cartographic.fromCartesian',
geo.cartographic.fromCartesian,
Cesium.Cartographic.fromCartesian,
[V, ['ellipsoid'], ['carto']],
],
[
'cartographic.toCartesian',
geo.cartographic.toCartesian,
Cesium.Cartographic.toCartesian,
[['carto', 'partialCarto'], ['ellipsoid'], R],
],
[
'cartographic.clone',
geo.cartographic.clone,
Cesium.Cartographic.clone,
[['carto'], ['carto']],
],
[
'ellipsoid.create',
geo.ellipsoid.create,
(x, y, z) => new Cesium.Ellipsoid(x, y, z),
[['nonNegative'], ['nonNegative'], ['nonNegative']],
],
[
'WGS84.geodeticSurfaceNormal',
geo.ellipsoid.WGS84.geodeticSurfaceNormal,
(c, r) => W.geodeticSurfaceNormal(c, r),
[V, R],
],
[
'WGS84.geodeticSurfaceNormalCartographic',
geo.ellipsoid.WGS84.geodeticSurfaceNormalCartographic,
(c, r) => W.geodeticSurfaceNormalCartographic(c, r),
[['carto', 'partialCarto'], R],
],
[
'WGS84.scaleToGeodeticSurface',
geo.ellipsoid.WGS84.scaleToGeodeticSurface,
(c, r) => W.scaleToGeodeticSurface(c, r),
[V, R],
],
[
'WGS84.cartographicToCartesian',
geo.ellipsoid.WGS84.cartographicToCartesian,
(c, r) => W.cartographicToCartesian(c, r),
[['carto', 'partialCarto'], R],
],
[
'WGS84.cartesianToCartographic',
geo.ellipsoid.WGS84.cartesianToCartographic,
(c, r) => W.cartesianToCartographic(c, r),
[V, ['carto']],
],
[
'matrix3.fromQuaternion',
geo.matrix3.fromQuaternion,
Cesium.Matrix3.fromQuaternion,
[['quat'], ['mat3']],
],
[
'matrix3.fromRotationZ',
geo.matrix3.fromRotationZ,
Cesium.Matrix3.fromRotationZ,
[N, ['mat3']],
],
[
'matrix3.multiplyByVector',
geo.matrix3.multiplyByVector,
Cesium.Matrix3.multiplyByVector,
[['mat3'], V, R],
],
[
'matrix3.getColumn',
geo.matrix3.getColumn,
Cesium.Matrix3.getColumn,
[['mat3'], ['index3'], R],
],
[
'matrix4.clone',
geo.matrix4.clone,
Cesium.Matrix4.clone,
[['mat4'], ['mat4']],
],
[
'matrix4.equals',
geo.matrix4.equals,
Cesium.Matrix4.equals,
[['mat4'], ['mat4']],
],
[
'matrix4.multiply',
geo.matrix4.multiply,
Cesium.Matrix4.multiply,
[['mat4'], ['mat4'], ['mat4']],
],
[
'matrix4.multiplyTransformation',
geo.matrix4.multiplyTransformation,
Cesium.Matrix4.multiplyTransformation,
[['mat4'], ['mat4'], ['mat4']],
],
[
'matrix4.multiplyByPoint',
geo.matrix4.multiplyByPoint,
Cesium.Matrix4.multiplyByPoint,
[['mat4'], V, R],
],
[
'matrix4.multiplyByPointAsVector',
geo.matrix4.multiplyByPointAsVector,
Cesium.Matrix4.multiplyByPointAsVector,
[['mat4'], V, R],
],
[
'matrix4.getTranslation',
geo.matrix4.getTranslation,
Cesium.Matrix4.getTranslation,
[['mat4'], R],
],
[
'matrix4.getMatrix3',
geo.matrix4.getMatrix3,
Cesium.Matrix4.getMatrix3,
[['mat4'], ['mat3']],
],
[
'matrix4.getColumn',
geo.matrix4.getColumn,
Cesium.Matrix4.getColumn,
[['mat4'], ['index4'], ['vec4']],
],
[
'matrix4.inverseTransformation',
geo.matrix4.inverseTransformation,
Cesium.Matrix4.inverseTransformation,
[['mat4'], ['mat4']],
],
[
'matrix4.fromRotationTranslation',
geo.matrix4.fromRotationTranslation,
Cesium.Matrix4.fromRotationTranslation,
[['mat3'], R, ['mat4']],
],
[
'matrix4.fromUniformScale',
geo.matrix4.fromUniformScale,
Cesium.Matrix4.fromUniformScale,
[N, ['mat4']],
],
[
'quaternion.fromAxisAngle',
geo.quaternion.fromAxisAngle,
Cesium.Quaternion.fromAxisAngle,
[V, N, ['quat']],
],
[
'quaternion.fromRotationMatrix',
geo.quaternion.fromRotationMatrix,
Cesium.Quaternion.fromRotationMatrix,
[['mat3'], ['quat']],
],
[
'transforms.eastNorthUpToFixedFrame',
geo.transforms.eastNorthUpToFixedFrame,
Cesium.Transforms.eastNorthUpToFixedFrame,
[V, ['ellipsoid'], ['mat4']],
],
[
'transforms.headingPitchRollToFixedFrame',
geo.transforms.headingPitchRollToFixedFrame,
Cesium.Transforms.headingPitchRollToFixedFrame,
[V, ['hpr', 'partialHpr'], ['ellipsoid'], [], ['mat4']],
],
];
function outcome(fn, args) {
try {
fn(...args);
return 'returned';
} catch (error) {
return `${error?.name}: ${error?.message}`;
}
}
test('every ported function throws exactly where Cesium does, with the same name and message', () => {
let thrown = 0;
let checked = 0;
const mismatches = [];
CASES.forEach(([name, ours, theirs, kinds], caseIndex) => {
for (let trial = 0; trial < TRIALS; trial++) {
const seed = caseIndex * 100003 + trial;
// Trial 0 passes no arguments at all; the rest draw from the pools.
const build = (side) => {
if (trial === 0) return [];
const random = generator(seed);
return kinds.map((k) =>
k.length ? argument(k, side, random) : undefined,
);
};
const a = outcome(ours, build('geo'));
const b = outcome(theirs, build('cesium'));
if (a !== b)
mismatches.push(`${name} (seed ${seed}): geo ${a} | Cesium ${b}`);
checked++;
if (b !== 'returned') thrown++;
}
});
assert.deepEqual(mismatches, []);
// The pools must actually reach the checks.
assert.ok(thrown > checked / 4, `only ${thrown} of ${checked} cases threw`);
});
test('the checks throw a DeveloperError with Cesium message text', () => {
const cases = [
[() => geo.math.toRadians(undefined), 'degrees is required.'],
[() => geo.math.mod(5, 0), 'divisor cannot be 0.'],
[
() => geo.math.clamp('1', 0, 1),
'Expected value to be typeof number, actual typeof was string',
],
[
() => geo.cartesian3.fromDegrees(undefined, 1),
'Expected longitude to be typeof number, actual typeof was undefined',
],
[
() => geo.cartesian3.add({ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }),
'Expected result to be typeof object, actual typeof was undefined',
],
[
() => geo.cartesian3.fromDegreesArray([1]),
'the number of coordinates must be a multiple of 2 and at least 2',
],
[
() => geo.cartesian3.fromDegreesArray(),
'coordinates is required, actual value was undefined',
],
[
() => geo.ellipsoid.create(-1, 1, 1),
'Expected x to be greater than or equal to 0, actual value was -1',
],
[
() => geo.matrix3.getColumn(geo.matrix3.create(), 3, {}),
'Expected index to be less than or equal to 2, actual value was 3',
],
[
() =>
geo.transforms.eastNorthUpToFixedFrame({ x: 1, y: Number.NaN, z: 0 }),
'origin has a NaN component',
],
[
() => geo.cartographic.fromCartesian({ x: 1, y: 2, z: 3 }, {}),
'oneOverRadii is required.',
],
];
for (const [fn, message] of cases) {
assert.throws(fn, (error) => {
assert.ok(error instanceof DeveloperError);
assert.equal(error.name, 'DeveloperError');
assert.equal(error.message, message);
return true;
});
}
// NaN is typeof number, so Check.typeOf.number lets it through (as in Cesium).
assert.ok(Number.isNaN(geo.math.clamp(Number.NaN, 0, 1)));
assert.ok(Number.isNaN(Cesium.Math.clamp(Number.NaN, 0, 1)));
});
-12
View File
@@ -1,12 +0,0 @@
// Whether src/geo runs Cesium's debug argument checks.
//
// Cesium wraps its argument checks in `//>>includeStart('debug')` pragmas: they
// are live in its Source modules (Node tests, the Vite dev server) and stripped
// from Build/Cesium, which vite-plugin-cesium copies into production builds.
// src/geo does the same: every Check call and every debug throw sits in an
// `if (DEBUG)` block. DEBUG is true in Node (no import.meta.env) and in Vite
// dev; `vite build` folds it to false and the minifier drops the blocks, so a
// production build returns what release Cesium returns (NaN, not a throw, for
// a degenerate input).
export const DEBUG = import.meta.env?.PROD !== true;
-13
View File
@@ -1,13 +0,0 @@
// Ported from CesiumJS 1.138 (Apache-2.0) — DeveloperError.js
//
// The error Cesium throws for invalid arguments. Its debug argument checks are
// live in Cesium's Source modules (Node tests, the Vite dev server) and
// stripped from production builds; src/geo throws the same name and message
// wherever Cesium does while DEBUG is on (see debug.js).
export class DeveloperError extends Error {
constructor(message) {
super(message);
this.name = 'DeveloperError';
}
}
+1 -22
View File
@@ -3,14 +3,9 @@
// A quadratic surface x²/a² + y²/b² + z²/c² = 1 as a plain, frozen object.
// Every method takes an optional `result` and returns it (a new object when it
// is absent); `result` may be the input. The algorithms and the operation order
// match Cesium so the outputs are bit-identical. With DEBUG on (development
// and tests; see debug.js) invalid arguments throw the same DeveloperError as
// Cesium's debug checks.
// match Cesium so the outputs are bit-identical.
import { DEBUG } from './debug.js';
import { Check } from './check.js';
import { create as createCartographic } from './cartographic.js';
import { DeveloperError } from './developerError.js';
import { EPSILON14 } from './math.js';
import { scaleToGeodeticSurface } from './scaleToGeodeticSurface.js';
import {
@@ -39,11 +34,6 @@ export function create(x, y, z) {
x = x ?? 0.0;
y = y ?? 0.0;
z = z ?? 0.0;
if (DEBUG) {
Check.typeOf.number.greaterThanOrEquals('x', x, 0.0);
Check.typeOf.number.greaterThanOrEquals('y', y, 0.0);
Check.typeOf.number.greaterThanOrEquals('z', z, 0.0);
}
const radii = Object.freeze(vec(x, y, z));
const radiiSquared = Object.freeze(vec(x * x, y * y, z * z));
@@ -68,10 +58,6 @@ export function create(x, y, z) {
/** The normal to the ellipsoid surface at a geodetic position. */
function geodeticSurfaceNormalCartographic(cartographic, result) {
if (DEBUG) {
Check.typeOf.object('cartographic', cartographic);
}
const longitude = cartographic.longitude;
const latitude = cartographic.latitude;
const cosLatitude = Math.cos(latitude);
@@ -89,13 +75,6 @@ export function create(x, y, z) {
/** The normal to the ellipsoid surface at a position, or `undefined` at the centre. */
function geodeticSurfaceNormal(cartesian, result) {
if (DEBUG) {
Check.typeOf.object('cartesian', cartesian);
if (isNaN(cartesian.x) || isNaN(cartesian.y) || isNaN(cartesian.z)) {
throw new DeveloperError('cartesian has a NaN component');
}
}
if (equalsEpsilon(cartesian, ZERO, EPSILON14)) {
return undefined;
}
-45
View File
@@ -12,18 +12,6 @@ const close = (a, b, tol, label) =>
const closeVec = (a, b, tol, label) => {
for (const k of ['x', 'y', 'z']) close(a[k], b[k], tol, `${label}.${k}`);
};
const POINTS = [
[6378137, 0, 0],
[0, 6378137, 0],
[0, 0, 6356752.314245],
[1334762.2, -4654869.1, 4137287.9],
[-2694045, -4293642, 3857878],
[1e3, 1e3, 1e3],
[0.01, 0.01, 0.01],
[7e6, -2e6, 1e6],
[4510000, 4510000, 1],
[1, 1, 6400000],
];
test('WGS84 constants equal Cesium', () => {
const c = Cesium.Ellipsoid.WGS84;
@@ -40,39 +28,6 @@ test('WGS84 constants equal Cesium', () => {
assert.equal(WGS84.maximumRadius, c.maximumRadius);
});
test('surface normals, scaling and conversions equal Cesium', () => {
const c = Cesium.Ellipsoid.WGS84;
for (const [x, y, z] of POINTS) {
const p = { x, y, z };
const cp = new Cesium.Cartesian3(x, y, z);
closeVec(
WGS84.geodeticSurfaceNormal(p),
c.geodeticSurfaceNormal(cp),
1e-12,
'normal',
);
const ours = WGS84.scaleToGeodeticSurface(p);
const theirs = c.scaleToGeodeticSurface(cp);
if (theirs === undefined) assert.equal(ours, undefined);
else closeVec(ours, theirs, 1e-12, 'scale');
const carto = WGS84.cartesianToCartographic(p);
const cCarto = c.cartesianToCartographic(cp);
if (cCarto === undefined) {
assert.equal(carto, undefined);
} else {
close(carto.longitude, cCarto.longitude, 1e-12, 'lon');
close(carto.latitude, cCarto.latitude, 1e-12, 'lat');
close(carto.height, cCarto.height, 1e-9, 'height');
closeVec(
WGS84.cartographicToCartesian(carto),
c.cartographicToCartesian(cCarto),
1e-12,
'roundtrip',
);
}
}
});
test('result parameters are filled in place and may alias the input', () => {
const p = { x: 1334762.2, y: -4654869.1, z: 4137287.9 };
const out = { x: 0, y: 0, z: 0 };
-48
View File
@@ -1,11 +1,8 @@
// Ported from CesiumJS 1.138 (Apache-2.0) — EllipsoidGeodesic.js.
// Vincenty inverse and surface interpolation, preserving operation order.
import * as Cartesian3 from './cartesian3.js';
import * as Cartographic from './cartographic.js';
import * as Ellipsoid from './ellipsoid.js';
import * as CesiumMath from './math.js';
import { Check } from './check.js';
import { DEBUG } from './debug.js';
const defined = (value) => value !== null && value !== undefined;
function setConstants(ellipsoidGeodesic) {
const uSquared = ellipsoidGeodesic._uSquared;
@@ -230,29 +227,7 @@ function vincentyInverseFormula(
ellipsoidGeodesic._uSquared = uSquared;
}
const scratchCart1 = Cartesian3.create();
const scratchCart2 = Cartesian3.create();
function computeProperties(ellipsoidGeodesic, start, end, ellipsoid) {
const firstCartesian = Cartesian3.normalize(
ellipsoid.cartographicToCartesian(start, scratchCart2),
scratchCart1,
);
const lastCartesian = Cartesian3.normalize(
ellipsoid.cartographicToCartesian(end, scratchCart2),
scratchCart2,
);
if (DEBUG) {
Check.typeOf.number.greaterThanOrEquals(
'value',
Math.abs(
Math.abs(Cartesian3.angleBetween(firstCartesian, lastCartesian)) -
Math.PI,
),
0.0125,
);
}
vincentyInverseFormula(
ellipsoidGeodesic,
ellipsoid.maximumRadius,
@@ -322,10 +297,6 @@ Object.defineProperties(EllipsoidGeodesic.prototype, {
*/
surfaceDistance: {
get: function () {
if (DEBUG) {
Check.defined('distance', this._distance);
}
return this._distance;
},
},
@@ -362,10 +333,6 @@ Object.defineProperties(EllipsoidGeodesic.prototype, {
*/
startHeading: {
get: function () {
if (DEBUG) {
Check.defined('distance', this._distance);
}
return this._startHeading;
},
},
@@ -378,10 +345,6 @@ Object.defineProperties(EllipsoidGeodesic.prototype, {
*/
endHeading: {
get: function () {
if (DEBUG) {
Check.defined('distance', this._distance);
}
return this._endHeading;
},
},
@@ -394,11 +357,6 @@ Object.defineProperties(EllipsoidGeodesic.prototype, {
* @param {Cartographic} end The final planetodetic point on the path.
*/
EllipsoidGeodesic.prototype.setEndPoints = function (start, end) {
if (DEBUG) {
Check.defined('start', start);
Check.defined('end', end);
}
computeProperties(this, start, end, this._ellipsoid);
};
@@ -425,17 +383,11 @@ EllipsoidGeodesic.prototype.interpolateUsingFraction = function (
* @param {number} distance The distance from the initial point to the point of interest along the geodesic
* @param {Cartographic} [result] The object in which to store the result.
* @returns {Cartographic} The location of the point along the geodesic.
*
* @exception {DeveloperError} start and end must be set before calling function interpolateUsingSurfaceDistance
*/
EllipsoidGeodesic.prototype.interpolateUsingSurfaceDistance = function (
distance,
result,
) {
if (DEBUG) {
Check.defined('distance', this._distance);
}
const constants = this._constants;
const s = constants.distanceRatio + distance / constants.b;
-14
View File
@@ -66,17 +66,3 @@ test('endpoint reset, result reuse, custom sphere and dateline/polar paths retai
);
}
});
test('unset and near-antipodal geodesics reject the same debug inputs', () => {
assert.throws(() => geo.ellipsoidGeodesic.create().surfaceDistance, {
name: 'DeveloperError',
});
const a = geo.cartographic.fromDegrees(0, 0),
b = geo.cartographic.fromDegrees(180, 0);
assert.throws(() => geo.ellipsoidGeodesic.create(a, b), {
name: 'DeveloperError',
});
assert.throws(
() => geo.ellipsoidGeodesic.create().setEndPoints(undefined, b),
{ name: 'DeveloperError' },
);
});
+1 -54
View File
@@ -1,15 +1,8 @@
// Ported from CesiumJS 1.138 (Apache-2.0) — Math.js
//
// Engine-neutral angle maths. Formulas and constants match Cesium.Math bit for
// bit. With DEBUG on (development and tests; see debug.js) invalid arguments
// throw the same DeveloperError as Cesium's debug checks; production builds
// skip the checks, as release Cesium does.
// bit.
import { DEBUG } from './debug.js';
import { Check } from './check.js';
import { DeveloperError } from './developerError.js';
export const EPSILON7 = 0.0000001;
export const EPSILON10 = 0.0000000001;
export const EPSILON12 = 0.000000000001;
export const EPSILON14 = 0.00000000000001;
@@ -20,32 +13,16 @@ export const DEGREES_PER_RADIAN = 180.0 / Math.PI;
/** Converts degrees to radians. */
export function toRadians(degrees) {
if (DEBUG) {
if (degrees == null) {
throw new DeveloperError('degrees is required.');
}
}
return degrees * RADIANS_PER_DEGREE;
}
/** Converts radians to degrees. */
export function toDegrees(radians) {
if (DEBUG) {
if (radians == null) {
throw new DeveloperError('radians is required.');
}
}
return radians * DEGREES_PER_RADIAN;
}
/** Constrains a value to lie between two values. */
export function clamp(value, min, max) {
if (DEBUG) {
Check.typeOf.number('value', value);
Check.typeOf.number('min', min);
Check.typeOf.number('max', max);
}
return value < min ? min : value > max ? max : value;
}
@@ -56,17 +33,6 @@ export function lerp(p, q, time) {
/** The modulo operation that also works for negative dividends. */
export function mod(m, n) {
if (DEBUG) {
if (m == null) {
throw new DeveloperError('m is required.');
}
if (n == null) {
throw new DeveloperError('n is required.');
}
if (n === 0.0) {
throw new DeveloperError('divisor cannot be 0.');
}
}
if (Math.sign(m) === Math.sign(n) && Math.abs(m) < Math.abs(n)) {
// Early exit if the input does not need to be modded. This avoids
// unnecessary math which could introduce floating point error.
@@ -77,11 +43,6 @@ export function mod(m, n) {
/** Produces an angle in the range 0 <= angle <= 2Pi equivalent to the provided angle. */
export function zeroToTwoPi(angle) {
if (DEBUG) {
if (angle == null) {
throw new DeveloperError('angle is required.');
}
}
if (angle >= 0 && angle <= TWO_PI) {
// Early exit if the input is already inside the range. This avoids
// unnecessary math which could introduce floating point error.
@@ -96,11 +57,6 @@ export function zeroToTwoPi(angle) {
/** Produces an angle in the range -Pi <= angle <= Pi equivalent to the provided angle. */
export function negativePiToPi(angle) {
if (DEBUG) {
if (angle == null) {
throw new DeveloperError('angle is required.');
}
}
if (angle >= -Math.PI && angle <= Math.PI) {
// Early exit if the input is already inside the range. This avoids
// unnecessary math which could introduce floating point error.
@@ -114,15 +70,6 @@ export function negativePiToPi(angle) {
* (the absolute one defaults to the relative one).
*/
export function equalsEpsilon(left, right, relativeEpsilon, absoluteEpsilon) {
if (DEBUG) {
if (left == null) {
throw new DeveloperError('left is required.');
}
if (right == null) {
throw new DeveloperError('right is required.');
}
}
relativeEpsilon = relativeEpsilon ?? 0.0;
absoluteEpsilon = absoluteEpsilon ?? relativeEpsilon;
const absDiff = Math.abs(left - right);
-1
View File
@@ -31,7 +31,6 @@ test('constants equal Cesium.Math', () => {
for (const name of [
'PI_OVER_TWO',
'TWO_PI',
'EPSILON7',
'EPSILON10',
'EPSILON12',
'EPSILON14',
+2 -73
View File
@@ -4,13 +4,10 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as m3 from './matrix3.js';
import * as m4 from './matrix4.js';
import * as quat from './quaternion.js';
const arr = (m, n) => Array.from({ length: n }, (_, i) => m[i]);
const sameArr = (ours, theirs, n, label) =>
assert.deepEqual(arr(ours, n), arr(theirs, n), label);
const close = (a, b, tol, label) =>
assert.ok(Math.abs(a - b) <= tol, `${label}: ${a} vs ${b}`);
const T = Cesium.Matrix4.fromTranslationQuaternionRotationScale(
new Cesium.Cartesian3(10, -20, 30),
@@ -29,41 +26,7 @@ const U = Cesium.Matrix4.fromRotationTranslation(
);
const plain4 = (m) => arr(m, 16);
test('matrix4 products, points and inverses equal Cesium', () => {
sameArr(
m4.multiply(plain4(T), plain4(U), m4.create()),
Cesium.Matrix4.multiply(T, U, new Cesium.Matrix4()),
16,
'multiply',
);
sameArr(
m4.multiplyTransformation(plain4(T), plain4(U), m4.create()),
Cesium.Matrix4.multiplyTransformation(T, U, new Cesium.Matrix4()),
16,
'multiplyTransformation',
);
const p = new Cesium.Cartesian3(1.5, -2, 3);
const ours = m4.multiplyByPoint(
plain4(T),
{ x: 1.5, y: -2, z: 3 },
{ x: 0, y: 0, z: 0 },
);
const theirs = Cesium.Matrix4.multiplyByPoint(T, p, new Cesium.Cartesian3());
assert.deepEqual({ ...ours }, { x: theirs.x, y: theirs.y, z: theirs.z });
const v = m4.multiplyByPointAsVector(
plain4(T),
{ x: 1.5, y: -2, z: 3 },
{ x: 0, y: 0, z: 0 },
);
const cv = Cesium.Matrix4.multiplyByPointAsVector(
T,
p,
new Cesium.Cartesian3(),
);
assert.deepEqual({ ...v }, { x: cv.x, y: cv.y, z: cv.z });
const inv = m4.inverseTransformation(plain4(T), m4.create());
const cinv = Cesium.Matrix4.inverseTransformation(T, new Cesium.Matrix4());
for (let i = 0; i < 16; i++) close(inv[i], cinv[i], 1e-12, `inv ${i}`);
test('matrix4 getters, equals and IDENTITY equal Cesium', () => {
const t = m4.getTranslation(plain4(T), { x: 0, y: 0, z: 0 });
assert.deepEqual({ ...t }, { x: 10, y: -20, z: 30 });
sameArr(
@@ -74,49 +37,15 @@ test('matrix4 products, points and inverses equal Cesium', () => {
);
assert.ok(m4.equals(m4.clone(plain4(T)), plain4(T)));
assert.ok(Object.isFrozen(m4.IDENTITY));
sameArr(
m4.fromUniformScale(2.5, m4.create()),
Cesium.Matrix4.fromUniformScale(2.5, new Cesium.Matrix4()),
16,
'scale',
);
});
test('matrix3, quaternion and rotation-translation equal Cesium', () => {
test('matrix3 and rotation-translation equal Cesium', () => {
sameArr(
m3.fromRotationZ(1.1, m3.create()),
Cesium.Matrix3.fromRotationZ(1.1, new Cesium.Matrix3()),
9,
'rotZ',
);
const axis = {
x: 0.2672612419124244,
y: 0.5345224838248488,
z: 0.8017837257372732,
};
const q = quat.fromAxisAngle(axis, 0.7, quat.create());
const cq = Cesium.Quaternion.fromAxisAngle(
new Cesium.Cartesian3(axis.x, axis.y, axis.z),
0.7,
new Cesium.Quaternion(),
);
assert.deepEqual({ ...q }, { x: cq.x, y: cq.y, z: cq.z, w: cq.w });
sameArr(
m3.fromQuaternion(q, m3.create()),
Cesium.Matrix3.fromQuaternion(cq, new Cesium.Matrix3()),
9,
'fromQuaternion',
);
const back = quat.fromRotationMatrix(
m3.fromQuaternion(q, m3.create()),
quat.create(),
);
const cback = Cesium.Quaternion.fromRotationMatrix(
Cesium.Matrix3.fromQuaternion(cq, new Cesium.Matrix3()),
new Cesium.Quaternion(),
);
for (const k of ['x', 'y', 'z', 'w'])
close(back[k], cback[k], 1e-15, `q.${k}`);
const mv = m3.multiplyByVector(
m3.fromRotationZ(1.1, m3.create()),
{ x: 1, y: 2, z: 3 },
+1 -27
View File
@@ -2,12 +2,7 @@
//
// 3x3 matrices as plain arrays of 9 numbers in Cesium's column-major index
// order (m[column * 3 + row]), so Cesium accepts them wherever it reads
// m[0]..m[8]. Only the subset gev uses is ported. With DEBUG on (development
// and tests; see debug.js) invalid arguments throw the same DeveloperError as
// Cesium's debug checks.
import { DEBUG } from './debug.js';
import { Check } from './check.js';
// m[0]..m[8]. Only the subset gev uses is ported.
/** A matrix of zeros (Cesium's `new Matrix3()`). */
export function create() {
@@ -16,10 +11,6 @@ export function create() {
/** The rotation matrix of a unit quaternion. */
export function fromQuaternion(quaternion, result) {
if (DEBUG) {
Check.typeOf.object('quaternion', quaternion);
}
const x2 = quaternion.x * quaternion.x;
const xy = quaternion.x * quaternion.y;
const xz = quaternion.x * quaternion.z;
@@ -58,10 +49,6 @@ export function fromQuaternion(quaternion, result) {
/** A rotation by `angle` radians around the z-axis. */
export function fromRotationZ(angle, result) {
if (DEBUG) {
Check.typeOf.number('angle', angle);
}
const cosAngle = Math.cos(angle);
const sinAngle = Math.sin(angle);
@@ -80,12 +67,6 @@ export function fromRotationZ(angle, result) {
/** The product of a matrix and a column vector; `result` is required. */
export function multiplyByVector(matrix, cartesian, result) {
if (DEBUG) {
Check.typeOf.object('matrix', matrix);
Check.typeOf.object('cartesian', cartesian);
Check.typeOf.object('result', result);
}
const vX = cartesian.x;
const vY = cartesian.y;
const vZ = cartesian.z;
@@ -102,13 +83,6 @@ export function multiplyByVector(matrix, cartesian, result) {
/** The column at `index` (0..2) as {x, y, z}; `result` is required. */
export function getColumn(matrix, index, result) {
if (DEBUG) {
Check.typeOf.object('matrix', matrix);
Check.typeOf.number.greaterThanOrEquals('index', index, 0);
Check.typeOf.number.lessThanOrEquals('index', index, 2);
Check.typeOf.object('result', result);
}
const startIndex = index * 3;
const x = matrix[startIndex];
const y = matrix[startIndex + 1];
+1 -81
View File
@@ -4,12 +4,8 @@
// order (m[column * 4 + row]), so Cesium accepts them wherever it reads
// m[0]..m[15] (e.g. the Model.modelMatrix setter clones by index). Products
// and getters take a required `result` that may be an input; constructors
// take an optional one. Only the subset gev uses is ported. With DEBUG on
// (development and tests; see debug.js) invalid arguments throw the same
// DeveloperError as Cesium's debug checks.
// take an optional one. Only the subset gev uses is ported.
import { DEBUG } from './debug.js';
import { Check } from './check.js';
import { ZERO } from './cartesian3.js';
/** A matrix of zeros (Cesium's `new Matrix4()`). */
@@ -65,12 +61,6 @@ export function equals(left, right) {
/** The product of two matrices. */
export function multiply(left, right, result) {
if (DEBUG) {
Check.typeOf.object('left', left);
Check.typeOf.object('right', right);
Check.typeOf.object('result', result);
}
const left0 = left[0];
const left1 = left[1];
const left2 = left[2];
@@ -162,12 +152,6 @@ export function multiply(left, right, result) {
/** The product of two affine transformations (bottom rows assumed [0, 0, 0, 1]). */
export function multiplyTransformation(left, right, result) {
if (DEBUG) {
Check.typeOf.object('left', left);
Check.typeOf.object('right', right);
Check.typeOf.object('result', result);
}
const left0 = left[0];
const left1 = left[1];
const left2 = left[2];
@@ -234,12 +218,6 @@ export function multiplyTransformation(left, right, result) {
/** Transforms a point (w = 1). */
export function multiplyByPoint(matrix, cartesian, result) {
if (DEBUG) {
Check.typeOf.object('matrix', matrix);
Check.typeOf.object('cartesian', cartesian);
Check.typeOf.object('result', result);
}
const vX = cartesian.x;
const vY = cartesian.y;
const vZ = cartesian.z;
@@ -256,12 +234,6 @@ export function multiplyByPoint(matrix, cartesian, result) {
/** Transforms a direction (w = 0), ignoring the translation. */
export function multiplyByPointAsVector(matrix, cartesian, result) {
if (DEBUG) {
Check.typeOf.object('matrix', matrix);
Check.typeOf.object('cartesian', cartesian);
Check.typeOf.object('result', result);
}
const vX = cartesian.x;
const vY = cartesian.y;
const vZ = cartesian.z;
@@ -278,11 +250,6 @@ export function multiplyByPointAsVector(matrix, cartesian, result) {
/** The translation part as {x, y, z}. */
export function getTranslation(matrix, result) {
if (DEBUG) {
Check.typeOf.object('matrix', matrix);
Check.typeOf.object('result', result);
}
result.x = matrix[12];
result.y = matrix[13];
result.z = matrix[14];
@@ -291,11 +258,6 @@ export function getTranslation(matrix, result) {
/** The upper-left 3x3 part. */
export function getMatrix3(matrix, result) {
if (DEBUG) {
Check.typeOf.object('matrix', matrix);
Check.typeOf.object('result', result);
}
result[0] = matrix[0];
result[1] = matrix[1];
result[2] = matrix[2];
@@ -310,13 +272,6 @@ export function getMatrix3(matrix, result) {
/** The column at `index` (0..3) as {x, y, z, w}. */
export function getColumn(matrix, index, result) {
if (DEBUG) {
Check.typeOf.object('matrix', matrix);
Check.typeOf.number.greaterThanOrEquals('index', index, 0);
Check.typeOf.number.lessThanOrEquals('index', index, 3);
Check.typeOf.object('result', result);
}
const startIndex = index * 4;
const x = matrix[startIndex];
const y = matrix[startIndex + 1];
@@ -332,11 +287,6 @@ export function getColumn(matrix, index, result) {
/** The inverse of a rotation-plus-translation matrix (no scale or shear). */
export function inverseTransformation(matrix, result) {
if (DEBUG) {
Check.typeOf.object('matrix', matrix);
Check.typeOf.object('result', result);
}
// This function is an optimized version of the below 4 lines.
// const rT = Matrix3.transpose(Matrix4.getMatrix3(matrix));
// const rTN = Matrix3.negate(rT);
@@ -382,10 +332,6 @@ export function inverseTransformation(matrix, result) {
/** A transformation from a 3x3 rotation and a translation (zero when absent). */
export function fromRotationTranslation(rotation, translation, result) {
if (DEBUG) {
Check.typeOf.object('rotation', rotation);
}
translation = translation ?? ZERO;
if (result == null) result = create();
result[0] = rotation[0];
@@ -406,29 +352,3 @@ export function fromRotationTranslation(rotation, translation, result) {
result[15] = 1.0;
return result;
}
/** A uniform scale. */
export function fromUniformScale(scale, result) {
if (DEBUG) {
Check.typeOf.number('scale', scale);
}
if (result == null) result = create();
result[0] = scale;
result[1] = 0.0;
result[2] = 0.0;
result[3] = 0.0;
result[4] = 0.0;
result[5] = scale;
result[6] = 0.0;
result[7] = 0.0;
result[8] = 0.0;
result[9] = 0.0;
result[10] = scale;
result[11] = 0.0;
result[12] = 0.0;
result[13] = 0.0;
result[14] = 0.0;
result[15] = 1.0;
return result;
}
+1 -14
View File
@@ -1,16 +1,12 @@
// Ported from CesiumJS 1.138 (Apache-2.0) — Quaternion.js
//
// Rotations as plain-data {x, y, z, w} objects. Only the subset gev uses is ported.
// With DEBUG on (development and tests; see debug.js) invalid arguments throw
// the same DeveloperError as Cesium's debug checks.
//
// Geo values are not Object.prototype objects, so src/app/stateChannel.js
// copyState and src/director/documentFields.js jsonTree reject them: copy
// the fields into a literal before putting one into product state or a
// scene document.
import { DEBUG } from './debug.js';
import { Check } from './check.js';
import { normalize, create as createCartesian3 } from './cartesian3.js';
// Values are instances of a module-private constructor rather than object
@@ -32,13 +28,8 @@ export function create(x, y, z, w) {
let fromAxisAngleScratch = createCartesian3();
/** The rotation by `angle` radians around `axis` (normalised here; with DEBUG on, a zero axis throws). */
/** The rotation by `angle` radians around `axis` (normalised here; a zero axis gives NaN). */
export function fromAxisAngle(axis, angle, result) {
if (DEBUG) {
Check.typeOf.object('axis', axis);
Check.typeOf.number('angle', angle);
}
const halfAngle = angle / 2.0;
const s = Math.sin(halfAngle);
fromAxisAngleScratch = normalize(axis, fromAxisAngleScratch);
@@ -63,10 +54,6 @@ const elementIndex = (column, row) => column * 3 + row;
/** The rotation of a 3x3 rotation matrix. */
export function fromRotationMatrix(matrix, result) {
if (DEBUG) {
Check.typeOf.object('matrix', matrix);
}
let root;
let x;
let y;
+9 -53
View File
@@ -1,36 +1,15 @@
// Release parity: with DEBUG off, src/geo returns what release Cesium returns.
// Release parity: src/geo returns what release Cesium returns.
//
// Production builds fold geo's DEBUG to false (see debug.js) and ship Cesium's
// Build/Cesium, whose debug checks are stripped. So for a degenerate input that
// throws in development (a zero vector to normalize, a NaN origin, a divisor
// of 0, an index out of range) both must now return the same values, NaN for
// NaN and -0 for -0, or fail with the same error. This file loads a copy of
// src/geo whose debug.js says `DEBUG = false`, and the stripped Cesium build.
import { test, after } from 'node:test';
// Production builds ship Cesium's Build/Cesium, whose debug checks are
// stripped, and src/geo has no debug checks either. So for a degenerate input
// (a zero vector to normalize, a NaN origin, a divisor of 0, an index out of
// range) both must return the same values, NaN for NaN and -0 for -0, or fail
// with the same error.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import {
cpSync,
mkdtempSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import * as geo from './index.js';
const here = path.dirname(fileURLToPath(import.meta.url));
const copy = mkdtempSync(path.join(tmpdir(), 'geo-release-'));
for (const name of readdirSync(here)) {
if (name.endsWith('.js'))
cpSync(path.join(here, name), path.join(copy, name));
}
writeFileSync(path.join(copy, 'debug.js'), 'export const DEBUG = false;\n');
after(() => rmSync(copy, { recursive: true, force: true }));
const geo = await import(pathToFileURL(path.join(copy, 'index.js')).href);
const { DEBUG } = await import(pathToFileURL(path.join(copy, 'debug.js')).href);
const Cesium = createRequire(import.meta.url)('cesium/Build/Cesium/index.cjs');
const W = Cesium.Ellipsoid.WGS84;
@@ -185,18 +164,6 @@ const CASES = [
C3.fromDegreesArray,
[list(1, 2, 3)],
],
[
'cartesian3.fromDegreesArrayHeights(4)',
geo.cartesian3.fromDegreesArrayHeights,
C3.fromDegreesArrayHeights,
[list(1, 2, 3, 4)],
],
[
'cartesian3.fromDegreesArrayHeights(str)',
geo.cartesian3.fromDegreesArrayHeights,
C3.fromDegreesArrayHeights,
[list(1, 2, 'h')],
],
[
'cartographic.fromRadians(NaN)',
@@ -314,12 +281,6 @@ const CASES = [
Cesium.Matrix4.multiplyByPoint,
[nanMat4, NAN, R],
],
[
'matrix4.fromUniformScale(str)',
geo.matrix4.fromUniformScale,
Cesium.Matrix4.fromUniformScale,
[value('2'), mat4],
],
[
'quaternion.fromAxisAngle(zero)',
@@ -403,11 +364,6 @@ function outcome(fn, args) {
}
}
test('the copy under test has its debug checks off', () => {
assert.equal(DEBUG, false);
assert.ok(!('normalized result is not a number' in Cesium));
});
for (const [name, geoFn, cesiumFn, builders] of CASES) {
test(`release ${name} matches Build/Cesium`, () => {
const g = outcome(
@@ -432,7 +388,7 @@ for (const [name, geoFn, cesiumFn, builders] of CASES) {
});
}
test('release geo returns NaN where development geo throws', () => {
test('geo returns NaN for degenerate inputs, as release Cesium does', () => {
const r = geo.cartesian3.normalize(
geo.cartesian3.create(),
geo.cartesian3.create(),
-17
View File
@@ -4,8 +4,6 @@
// Internal to src/geo (not re-exported by index.js): used by ellipsoid.js and,
// as in Cesium, directly by cartographic.fromCartesian.
import { DEBUG } from './debug.js';
import { DeveloperError } from './developerError.js';
import { EPSILON12 } from './math.js';
import {
clone,
@@ -28,21 +26,6 @@ export function scaleToGeodeticSurface(
centerToleranceSquared,
result,
) {
if (DEBUG) {
if (cartesian == null) {
throw new DeveloperError('cartesian is required.');
}
if (oneOverRadii == null) {
throw new DeveloperError('oneOverRadii is required.');
}
if (oneOverRadiiSquared == null) {
throw new DeveloperError('oneOverRadiiSquared is required.');
}
if (centerToleranceSquared == null) {
throw new DeveloperError('centerToleranceSquared is required.');
}
}
const positionX = cartesian.x;
const positionY = cartesian.y;
const positionZ = cartesian.z;
-20
View File
@@ -3,12 +3,7 @@
// Local east-north-up frames and heading-pitch-roll orientations as 4x4
// column-major matrices (see matrix4.js). Outputs match Cesium bit for bit,
// including the degenerate frames at the Earth's centre and at the poles.
// With DEBUG on (development and tests; see debug.js) invalid arguments throw
// the same DeveloperError as Cesium's debug checks.
import { DEBUG } from './debug.js';
import { Check } from './check.js';
import { DeveloperError } from './developerError.js';
import {
EPSILON14,
TWO_PI,
@@ -52,15 +47,6 @@ function unpack(array, result) {
* `Transforms.localFrameToFixedFrameGenerator('east', 'north')`).
*/
export function eastNorthUpToFixedFrame(origin, ellipsoid, result) {
if (DEBUG) {
if (origin == null) {
throw new DeveloperError('origin is required.');
}
if (isNaN(origin.x) || isNaN(origin.y) || isNaN(origin.z)) {
throw new DeveloperError('origin has a NaN component');
}
}
if (result == null) result = createMatrix4();
const east = scratchEast;
@@ -228,12 +214,6 @@ export function headingPitchRollToFixedFrame(
fixedFrameTransform,
result,
) {
// The private helpers below skip Cesium's checks on their internal
// arguments (scratch objects and constants), which can never fail.
if (DEBUG) {
Check.typeOf.object('HeadingPitchRoll', headingPitchRoll);
}
fixedFrameTransform = fixedFrameTransform ?? eastNorthUpToFixedFrame;
const hprQuaternion = quaternionFromHeadingPitchRoll(
headingPitchRoll,
-57
View File
@@ -4,63 +4,6 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as transforms from './transforms.js';
const close = (a, b, tol, label) =>
assert.ok(Math.abs(a - b) <= tol, `${label}: ${a} vs ${b}`);
const ORIGINS = [
[-73.9857, 40.7484, 381],
[0, 0, 0],
[179.9, -45, 10],
[10, 89.9999999, 0],
[10, -90, 0],
[0, 90, 0],
];
test('eastNorthUpToFixedFrame equals Cesium, including the poles', () => {
for (const [lon, lat, h] of ORIGINS) {
const origin = Cesium.Cartesian3.fromDegrees(lon, lat, h);
const ours = transforms.eastNorthUpToFixedFrame({
x: origin.x,
y: origin.y,
z: origin.z,
});
const theirs = Cesium.Transforms.eastNorthUpToFixedFrame(origin);
for (let i = 0; i < 16; i++)
close(
ours[i],
theirs[i],
1e-9 * Math.max(1, Math.abs(theirs[i])),
`${lon},${lat} [${i}]`,
);
}
});
test('headingPitchRollToFixedFrame equals Cesium', () => {
for (const [lon, lat, h] of ORIGINS.slice(0, 3)) {
const origin = Cesium.Cartesian3.fromDegrees(lon, lat, h);
for (const [hd, p, r] of [
[0, 0, 0],
[0.7, -0.2, 0.1],
[-2.5, 1.2, -0.4],
]) {
const ours = transforms.headingPitchRollToFixedFrame(
{ x: origin.x, y: origin.y, z: origin.z },
{ heading: hd, pitch: p, roll: r },
);
const theirs = Cesium.Transforms.headingPitchRollToFixedFrame(
origin,
new Cesium.HeadingPitchRoll(hd, p, r),
);
for (let i = 0; i < 16; i++)
close(
ours[i],
theirs[i],
1e-9 * Math.max(1, Math.abs(theirs[i])),
`hpr [${i}]`,
);
}
}
});
test('headingPitchRollToFixedFrame keeps Cesium argument order (result is the fifth argument)', () => {
const origin = Cesium.Cartesian3.fromDegrees(-73.9857, 40.7484, 381);
const hpr = new Cesium.HeadingPitchRoll(0.7, -0.2, 0.1);
@@ -1,13 +1,12 @@
// Camera math for the Navara engine, on src/geo only: Cesium's heading/pitch/
// roll convention, the perspective projection and picks of a camera given as
// { position, direction, up, fovy, width, height } (Earth-fixed metres, unit
// vectors, vertical fov in radians, CSS pixels), Cesium's view rectangle and
// horizon test, and the Navara setCamera pose (degrees). Ported from the S4
// spike (src/bench/cameraConvention.js) and the contract's fake engine; the
// projection matches what Navara draws because the engine keeps Navara's
// camera on the same pose and fov.
// Camera math for the Navara and fake engines, on src/geo only: Cesium's
// heading/pitch/roll convention, the perspective projection and picks of a
// camera given as { position, direction, up, fovy, width, height }
// (Earth-fixed metres, unit vectors, vertical fov in radians, CSS pixels),
// Cesium's view rectangle and horizon test, and the Navara setCamera pose
// (degrees). The projection matches what Navara draws because the engine
// keeps Navara's camera on the same pose and fov.
import * as geo from '../../geo/index.js';
import * as geo from '../geo/index.js';
const { cartesian2, cartesian3, cartographic, math, matrix4 } = geo;
const WGS84 = geo.ellipsoid.WGS84;
@@ -109,7 +108,7 @@ export function headingPitchRoll(
/**
* The Navara setCamera/flyTo pose for a camera pose: the eye's longitude,
* latitude and ellipsoid height, and the same heading/pitch/roll in degrees
* (Navara 0.2.1 uses Cesium's convention, S4), heading in [0, 360) and roll
* (Navara 0.2.1 uses Cesium's convention), heading in [0, 360) and roll
* in (−180, 180].
*/
export function navaraPose(position, direction, up) {
@@ -206,14 +205,16 @@ export function pickRay(camera, screen) {
return { origin: copy3(camera.position), direction: unit(direction) };
}
/** Nearest intersection of a ray with the WGS84 ellipsoid, if any. */
export function intersectEllipsoid(origin, direction) {
const o = [origin.x / RADII.x, origin.y / RADII.y, origin.z / RADII.z];
const d = [
direction.x / RADII.x,
direction.y / RADII.y,
direction.z / RADII.z,
];
/**
* Nearest intersection of a ray with the WGS84 ellipsoid raised by `height`
* metres (each radius grown by it), if any.
*/
export function intersectEllipsoid(origin, direction, height = 0) {
const rx = RADII.x + height;
const ry = RADII.y + height;
const rz = RADII.z + height;
const o = [origin.x / rx, origin.y / ry, origin.z / rz];
const d = [direction.x / rx, direction.y / ry, direction.z / rz];
const a = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
const b = 2 * (o[0] * d[0] + o[1] * d[1] + o[2] * d[2]);
const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - 1;
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../geo/index.js';
import * as geo from '../geo/index.js';
import {
cesiumFovy,
headingPitchRoll,
@@ -194,8 +194,8 @@ test('horizonVisible: the nadir is visible and the antipode hidden', () => {
assert.equal(horizonVisible(eye, cartesian3.fromDegrees(-70, 10, 2e7)), true);
});
test("viewRectangle matches Cesium's computeViewRectangle (S4 reference poses)", () => {
// S4 measured these on Cesium at 1280×720 with Cesium's fov.
test("viewRectangle matches Cesium's computeViewRectangle (reference poses)", () => {
// Measured on Cesium at 1280×720 with Cesium's fov.
const deg = (rectangle) =>
['west', 'south', 'east', 'north'].map((side) =>
math.toDegrees(rectangle[side]),
+30 -70
View File
@@ -3,24 +3,17 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import { createEngineEvent } from '../contract.js';
import { wrapAngle } from '../cameraMath.js';
import { bridgeEvent } from './events.js';
import { toCesium2, toCesium3, toGeo3 } from './convert.js';
import { createCesiumFollow } from './follow.js';
const { math } = geo;
const easings = () => ({
cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT,
sinusoidalInOut: Cesium.EasingFunction.SINUSOIDAL_IN_OUT,
linear: Cesium.EasingFunction.LINEAR_NONE,
});
// Cesium reports heading and roll in [0, 2π]; the contract wants [0, 2π).
function wrapAngle(angle) {
const value = math.zeroToTwoPi(angle);
return value < 1e-12 || math.TWO_PI - value < 1e-12 ? 0 : value;
}
function cesiumOrientation(orientation) {
if (!orientation) return undefined;
if (orientation.direction)
@@ -65,70 +58,38 @@ const COMMANDS = Object.freeze([
/**
* camera.commanded: raised before any COMMANDS call on the Cesium camera and
* when the viewer's tracked entity changes. The hooks exist only while the
* event has listeners and then restore each method exactly (unless something
* newer replaced it), so an idle engine leaves the camera untouched.
* event has listeners (bridgeEvent) and then restore each method exactly
* (unless something newer replaced it), so an idle engine leaves the camera
* untouched.
*/
function commandedEvent(viewer, cesium) {
const event = createEngineEvent();
let unhook = null;
const hook = () => {
const removers = [];
for (const name of COMMANDS) {
const original = cesium[name];
if (typeof original !== 'function') continue;
const descriptor = Object.getOwnPropertyDescriptor(cesium, name);
const wrapped = function (...args) {
event.raiseEvent();
return original.apply(this, args);
return bridgeEvent({
addEventListener(raise) {
const removers = [];
for (const name of COMMANDS) {
const original = cesium[name];
if (typeof original !== 'function') continue;
const descriptor = Object.getOwnPropertyDescriptor(cesium, name);
const wrapped = function (...args) {
raise();
return original.apply(this, args);
};
cesium[name] = wrapped;
removers.push(() => {
if (cesium[name] !== wrapped) return;
if (descriptor) Object.defineProperty(cesium, name, descriptor);
else delete cesium[name];
});
}
const removeTracking = viewer.trackedEntityChanged?.addEventListener(() =>
raise(),
);
if (removeTracking) removers.push(removeTracking);
return () => {
for (const remove of removers) remove();
};
cesium[name] = wrapped;
removers.push(() => {
if (cesium[name] !== wrapped) return;
if (descriptor) Object.defineProperty(cesium, name, descriptor);
else delete cesium[name];
});
}
const removeTracking = viewer.trackedEntityChanged?.addEventListener(() =>
event.raiseEvent(),
);
if (removeTracking) removers.push(removeTracking);
return () => {
for (const remove of removers) remove();
};
};
let detached = false;
const sync = () => {
const wanted = !detached && event.numberOfListeners > 0;
if (wanted === (unhook !== null)) return;
if (wanted) unhook = hook();
else {
const remove = unhook;
unhook = null;
remove();
}
};
const add = event.addEventListener;
const remove = event.removeEventListener;
event.addEventListener = (listener) => {
const removeListener = add(listener);
sync();
return () => {
removeListener();
sync();
};
};
event.removeEventListener = (listener) => {
const removed = remove(listener);
sync();
return removed;
};
return {
event,
detach() {
detached = true;
sync();
},
};
});
}
/**
@@ -136,8 +97,7 @@ function commandedEvent(viewer, cesium) {
* @returns {{ camera: import('../contract.js').GlobeCamera, destroy: () => void }}
*/
export function createCesiumCamera(viewer) {
// Viewer.camera is scene.camera; the public alias is what shell callers read.
const cesium = viewer.camera ?? viewer.scene.camera;
const cesium = viewer.camera;
const bridges = {
changed: bridgeEvent(cesium.changed),
moveStart: bridgeEvent(cesium.moveStart),
+3 -10
View File
@@ -15,7 +15,6 @@
// preUpdate instead, attached on first use so creating the engine adds no
// scene listener.
import { createEngineEvent } from '../contract.js';
import { appClock as pageClock } from '../../time/clock.js';
/**
@@ -28,21 +27,16 @@ export function createCesiumClock(viewer, { appClock = pageClock } = {}) {
// A viewer-shaped object without a clock (tests, a bare scene): the
// scene's preUpdate opens the frame instead.
const tickSource = viewer.clock?.onTick ?? viewer.scene.preUpdate;
const ticked = createEngineEvent();
let removeTick = null;
let destroyed = false;
const onTick = () => ticked.raiseValue(appClock.beginFrame());
const onTick = () => {
appClock.beginFrame();
};
const attach = () => {
if (!removeTick && !destroyed)
removeTick = tickSource.addEventListener(onTick);
};
if (viewer.clock?.onTick) attach();
const add = ticked.addEventListener;
ticked.addEventListener = (listener) => {
const remove = add(listener);
attach();
return remove;
};
return {
clock: {
now: () => appClock.now(),
@@ -50,7 +44,6 @@ export function createCesiumClock(viewer, { appClock = pageClock } = {}) {
attach();
return appClock.frameTime();
},
ticked,
},
destroy() {
destroyed = true;
+12 -9
View File
@@ -1,7 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { isEngineEvent } from '../contract.js';
import { createAppClock } from '../../time/clock.js';
import { createCesiumClock } from './clock.js';
import { createFakeViewer } from './testing/fakeViewer.mjs';
@@ -32,7 +31,6 @@ test('the clock reads the app clock and latches on the viewer clock from creatio
const viewer = createFakeViewer();
const wall = manualAppClock();
const { clock } = createCesiumClock(viewer, { appClock: wall.appClock });
assert.ok(isEngineEvent(clock.ticked));
// Attached at creation, so it runs before onTick listeners added later
// (cameraVerbs installs its own after the engine exists).
assert.equal(viewer.clock.onTick.numberOfListeners, 1);
@@ -51,7 +49,9 @@ test('frameTime is latched on the viewer clock tick, before preUpdate', () => {
const wall = manualAppClock();
const { clock } = createCesiumClock(viewer, { appClock: wall.appClock });
const seen = [];
clock.ticked.addEventListener((time) => seen.push(['ticked', time]));
viewer.clock.onTick.addEventListener(() =>
seen.push(['tick', clock.frameTime()]),
);
// A preUpdate listener the app added before the engine existed still
// reads this frame's time: the latch is on the tick, not on preUpdate.
viewer.scene.preUpdate.addEventListener(() =>
@@ -66,10 +66,10 @@ test('frameTime is latched on the viewer clock tick, before preUpdate', () => {
wall.advance(16);
cesiumFrame(viewer, { draw: false });
assert.deepEqual(seen, [
['ticked', t0],
['tick', t0],
['preUpdate', t0],
['preRender', t0],
['ticked', t0 + 19],
['tick', t0 + 19],
['preUpdate', t0 + 19],
]);
});
@@ -99,11 +99,14 @@ test('a viewer without a clock latches on preUpdate', () => {
const { clock, destroy } = createCesiumClock(viewer, {
appClock: wall.appClock,
});
const seen = [];
clock.ticked.addEventListener((time) => seen.push(time));
// Attached on first use: creating the clock adds no scene listener.
assert.equal(viewer.scene.preUpdate.numberOfListeners, 0);
const before = clock.frameTime();
assert.equal(viewer.scene.preUpdate.numberOfListeners, 1);
wall.advance(7);
viewer.scene.preUpdate.raiseEvent(viewer.scene);
assert.deepEqual(seen, [wall.appClock.now()]);
wall.advance(2);
assert.equal(clock.frameTime(), before + 7);
destroy();
assert.equal(viewer.scene.preUpdate.numberOfListeners, 0);
});
@@ -116,7 +119,7 @@ test('the viewer clock itself is left alone', () => {
const { clock } = createCesiumClock(viewer, {
appClock: manualAppClock().appClock,
});
clock.ticked.addEventListener(() => {});
clock.frameTime();
cesiumFrame(viewer);
assert.equal(viewer.clock.shouldAnimate, false);
assert.equal(viewer.clock.multiplier, 1);
+1 -1
View File
@@ -18,6 +18,6 @@ export const toCesium3 = (value) =>
/**
* A new Cesium.Cartesian2 for a window position. Always a new instance: Cesium
* caches pickPosition by windowPosition.toString() (phase 1a rule 9).
* caches pickPosition by windowPosition.toString().
*/
export const toCesium2 = (value) => new Cesium.Cartesian2(value.x, value.y);
+4 -32
View File
@@ -10,9 +10,7 @@ import { createCesiumClock } from './clock.js';
import { createCesiumCredits } from './credits.js';
import { createCesiumFrames } from './frames.js';
import { createCesiumInput } from './input.js';
import { createCesiumLines } from './lines.js';
import { createCesiumMaps } from './maps.js';
import { createCesiumMovers } from './movers.js';
import { createCesiumPicker } from './picker.js';
import { createCesiumProjector } from './projector.js';
import { createCesiumRender } from './render.js';
@@ -37,44 +35,18 @@ import { createCesiumSurface } from './surface.js';
*/
export function createCesiumEngine(viewer, options = {}) {
if (!viewer?.scene) throw new TypeError('createCesiumEngine needs a viewer');
// Viewer.canvas is scene.canvas; the engine reads the public alias.
const canvas = viewer.canvas ?? viewer.scene.canvas;
const { canvas } = viewer;
const render = createCesiumRender(viewer, options);
const credits = createCesiumCredits(viewer);
// Picks name the mover set or line they hit (movers and lines exist
// below; the picker asks them when it picks).
let lines = null;
let movers = null;
let projectorPart = null;
const picker = createCesiumPicker(viewer, {
identify: (picked) =>
movers?.identify(picked) ?? lines?.identify(picked) ?? null,
// A mover instance behind the globe is not picked (billboards drawn
// without a depth test reach Cesium's GPU pick through it).
visible: ({ owner, instance }) => {
const position = movers?.instancePosition(owner, instance);
return position ? projectorPart.projector.horizonVisible(position) : true;
},
});
const picker = createCesiumPicker(viewer);
const parts = {
camera: createCesiumCamera(viewer),
picker,
projector: (projectorPart = createCesiumProjector(viewer)),
projector: createCesiumProjector(viewer),
frames: createCesiumFrames(viewer),
clock: createCesiumClock(viewer, options),
lines: (lines = createCesiumLines(viewer, {
requestRender: (reason) => render.render.request(reason),
...(options.lineFactories ? { factories: options.lineFactories } : {}),
})),
movers: (movers = createCesiumMovers(viewer, {
requestRender: (reason) => render.render.request(reason),
...(options.moverFactories ? { factories: options.moverFactories } : {}),
})),
render,
// Mover sets in a surface `exclude` are hidden for the sample.
surface: createCesiumSurface(viewer, {
engineExclude: (entry) => movers?.primitiveOf(entry) ?? null,
}),
surface: createCesiumSurface(viewer),
input: createCesiumInput(viewer, { ...options, picker: picker.picker }),
credits,
maps: createCesiumMaps(viewer, {
+1 -47
View File
@@ -146,57 +146,11 @@ test('the engine clock is the app clock given, latched on the viewer clock tick'
const viewer = createFakeViewer();
const engine = createCesiumEngine(viewer, { appClock });
assert.equal(engine.clock.now(), 1_000_000);
const seen = [];
engine.clock.ticked.addEventListener((time) => seen.push(time));
assert.equal(viewer.clock.onTick.numberOfListeners, 1);
wall += 16;
viewer.clock.onTick.raiseEvent(viewer.clock);
assert.deepEqual(seen, [1_000_016]);
wall += 5;
assert.equal(engine.clock.frameTime(), 1_000_016);
engine.destroy();
assert.equal(viewer.clock.onTick.numberOfListeners, 0);
});
test('picks leave out mover instances behind the horizon and the follow proxy', async () => {
const geo = await import('../../geo/index.js');
const viewer = createFakeViewer();
const engine = createCesiumEngine(viewer, { requestRender: () => {} });
try {
const set = engine.movers.createSet({
id: 'horizon-points',
pickable: true,
kind: 'point',
capacity: 2,
point: { sizePx: 8 },
});
// The fake camera is at x = 7e6 (over 0°, 0°): 0°E is in front, 180° behind.
set.set(0, { position: geo.cartesian3.fromDegrees(0, 1, 0) });
set.set(1, { position: geo.cartesian3.fromDegrees(180, 1, 0) });
set.setCount(2);
set.commit();
const [front, back] = viewer.primitiveList.flatMap((root) =>
collectPoints(root),
);
viewer.scene.drillPick = () => [
{ primitive: back, id: 'back' },
{ primitive: front, id: 'front' },
];
assert.deepEqual(
engine.picker.drillPick({ x: 1, y: 1 }).map(({ id }) => id),
['front'],
);
} finally {
engine.destroy();
}
});
// The PointPrimitives under a primitive collection tree.
function collectPoints(collection) {
const out = [];
for (let i = 0; i < (collection.length ?? 0); i++) {
const item = collection.get(i);
if (item instanceof Cesium.PointPrimitive) out.push(item);
else if (item?.length !== undefined) out.push(...collectPoints(item));
}
return out;
}
+2 -2
View File
@@ -1,5 +1,5 @@
// The bridge for shell callers that still hold only a Cesium viewer (layers
// until phase 1c): the engine registered for that viewer. The app registers
// The bridge for shell callers that hold only a Cesium viewer: the engine
// registered for that viewer. The app registers
// its scene engine (src/app/globeEngine.js) and unregisters it before
// destroying it. Nothing is ever built here, so no engine can outlive its
// viewer without a destroy path: an unregistered or torn-down viewer has none.
-225
View File
@@ -1,225 +0,0 @@
// engine.lines on Cesium: each line is one Primitive of a PolylineGeometry
// (built synchronously, rebuilt when its positions change) with a
// PolylineMaterialAppearance, inside one engine-owned PrimitiveCollection
// added to the scene with the first line. depthFailAlpha adds Cesium's
// depthFailAppearance (the same material, its alpha scaled) for the part
// the scene hides; dashes in pixels are PolylineDash; the tactical dash is
// the launches' selected-orbit pattern along the whole line.
import * as Cesium from 'cesium';
import { createLineRegistry } from '../lines.js';
const TACTICAL_TYPE = 'GevLineTactical';
let tacticalRegistered = false;
// The launches' GevMissionOrbitTactical shader (layers/launches/
// orbitRendering.js), under the engine's own type until Task 15a moves
// launches onto engine.lines.
function registerTactical() {
if (tacticalRegistered) return;
new Cesium.Material({
fabric: {
type: TACTICAL_TYPE,
uniforms: { color: Cesium.Color.CYAN, groupCount: 4, dashCount: 100 },
source: `
czm_material czm_getMaterial(czm_materialInput materialInput) {
czm_material material = czm_getDefaultMaterial(materialInput);
float groupPosition = fract(materialInput.st.s * groupCount);
float markPosition = groupPosition * (dashCount + 1.0);
float markIndex = floor(markPosition);
float localPosition = fract(markPosition);
float centerDistance = abs(localPosition - 0.5);
float edge = max(fwidth(localPosition) * 1.35, 0.012);
float dashAlong = 1.0 - smoothstep(0.27 - edge, 0.27 + edge, centerDistance);
float dashAcross = 1.0 - smoothstep(0.12, 0.24, abs(materialInput.st.t - 0.5));
float dash = dashAlong * dashAcross;
float dotAlong = (localPosition - 0.5) / 0.32;
float dotAcross = (materialInput.st.t - 0.5) / 0.5;
float dot = 1.0 - smoothstep(0.78, 1.0, length(vec2(dotAlong, dotAcross)));
float isDot = 1.0 - step(0.5, markIndex);
float visible = mix(dash, dot, isDot);
material.diffuse = color.rgb;
material.emission = color.rgb * mix(0.07, 0.65, isDot);
material.alpha = color.a * visible * mix(0.58, 1.0, isDot);
return material;
}`,
},
});
tacticalRegistered = true;
}
/** PolylineDash's 16-bit pattern: the dash first, then the gap. */
export function dashPattern(lengthPx, gapPx) {
const on = Math.max(
1,
Math.min(16, Math.round((16 * lengthPx) / (lengthPx + gapPx))),
);
return (0xffff << (16 - on)) & 0xffff;
}
const toColor = ([r, g, b, a], alphaScale = 1) =>
new Cesium.Color(r, g, b, a * alphaScale);
/** The Cesium material type and uniforms of a line (pure, for tests). */
export function materialSpec(options, color, alphaScale = 1) {
if (options.arrow)
return {
type: 'PolylineArrow',
uniforms: { color: toColor(color, alphaScale) },
};
const { dash } = options;
if (dash?.kind === 'pixels')
return {
type: 'PolylineDash',
uniforms: {
color: toColor(color, alphaScale),
gapColor: Cesium.Color.TRANSPARENT,
dashLength: dash.lengthPx + dash.gapPx,
dashPattern: dashPattern(dash.lengthPx, dash.gapPx),
},
};
if (dash?.kind === 'tactical')
return {
type: TACTICAL_TYPE,
uniforms: {
color: toColor(color, alphaScale),
groupCount: dash.groups,
dashCount: dash.dashes,
},
};
return { type: 'Color', uniforms: { color: toColor(color, alphaScale) } };
}
function cesiumMaterial({ type, uniforms }) {
if (type === TACTICAL_TYPE) registerTactical();
return Cesium.Material.fromType(type, uniforms);
}
const DEFAULT_FACTORIES = {
primitive: (options) => new Cesium.Primitive(options),
appearance: (spec) =>
new Cesium.PolylineMaterialAppearance({ material: cesiumMaterial(spec) }),
};
/**
* @param {Cesium.Viewer} viewer
* @param {{ requestRender?: (reason: string) => void, factories?: object }} [options]
* @returns {{ lines: import('../contract.js').GlobeLines, destroy: () => void }}
*/
export function createCesiumLines(
viewer,
{ requestRender = () => viewer.scene.requestRender(), factories = {} } = {},
) {
const make = { ...DEFAULT_FACTORIES, ...factories };
const { scene } = viewer;
let root = null;
const rootCollection = () => {
if (!root) {
root = new Cesium.PrimitiveCollection({ destroyPrimitives: true });
scene.primitives.add(root);
}
return root;
};
const registry = createLineRegistry((options) => {
rootCollection();
let color = options.color;
let modelMatrix = options.modelMatrix
? Cesium.Matrix4.fromArray(options.modelMatrix)
: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY);
let shown = true;
let primitive = null;
let appearance = null;
let depthFail = null;
const drop = () => {
if (primitive) root.remove(primitive);
primitive = null;
};
return {
setPositions(flat) {
drop();
if (flat.length < 6) {
requestRender('lines');
return;
}
const positions = [];
for (let index = 0; index < flat.length; index += 3)
positions.push(
new Cesium.Cartesian3(
flat[index],
flat[index + 1],
flat[index + 2],
),
);
appearance = make.appearance(materialSpec(options, color));
depthFail =
options.depthFailAlpha > 0
? make.appearance(
materialSpec(options, color, options.depthFailAlpha),
)
: undefined;
primitive = make.primitive({
geometryInstances: new Cesium.GeometryInstance({
geometry: new Cesium.PolylineGeometry({
positions,
width: options.widthPx,
vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT,
arcType:
options.arc === 'geodesic'
? Cesium.ArcType.GEODESIC
: Cesium.ArcType.NONE,
}),
id: options.pickId,
}),
appearance,
depthFailAppearance: depthFail,
modelMatrix: Cesium.Matrix4.clone(modelMatrix),
show: shown,
asynchronous: false,
});
primitive.gevLine = options.id;
root.add(primitive);
requestRender('lines');
},
setColor(rgba) {
color = rgba;
if (appearance) appearance.material.uniforms.color = toColor(rgba);
if (depthFail)
depthFail.material.uniforms.color = toColor(
rgba,
options.depthFailAlpha,
);
requestRender('lines');
},
setModelMatrix(matrix) {
modelMatrix = Cesium.Matrix4.fromArray(matrix);
if (primitive)
primitive.modelMatrix = Cesium.Matrix4.clone(modelMatrix);
requestRender('lines');
},
show(visible) {
shown = visible;
if (primitive) primitive.show = visible;
requestRender('lines');
},
destroy() {
drop();
requestRender('lines');
},
};
});
return {
lines: registry.lines,
/** The line a Cesium pick result belongs to, or null. */
identify(picked) {
const owner = picked?.primitive?.gevLine;
return owner !== undefined && registry.live.has(owner) ? { owner } : null;
},
destroy() {
registry.destroyAll();
if (root) {
scene.primitives.remove(root);
root = null;
}
},
};
}
-165
View File
@@ -1,165 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createCesiumLines, dashPattern } from './lines.js';
import { createFakeViewer } from './testing/fakeViewer.mjs';
function fakePrimitives() {
const made = [];
return {
made,
factories: {
primitive: (options) => {
const primitive = {
...options,
isDestroyed: () => false,
destroy() {
primitive.destroyed = true;
},
};
made.push(primitive);
return primitive;
},
appearance: (spec) => ({
material: { type: spec.type, uniforms: { ...spec.uniforms } },
}),
},
};
}
const points = [
{ x: 6_378_137, y: 0, z: 0 },
{ x: 6_378_137, y: 1000, z: 0 },
{ x: 6_378_137, y: 2000, z: 1000 },
];
test('a dash in pixels becomes PolylineDash with a 16-bit pattern', () => {
assert.equal(dashPattern(16, 16), 0xff00);
assert.equal(dashPattern(12, 4), 0xfff0);
assert.equal(dashPattern(1, 0), 0xffff);
});
test('positions build one polyline primitive; a change rebuilds it', () => {
const viewer = createFakeViewer();
const { factories, made } = fakePrimitives();
const renders = [];
const { lines, destroy } = createCesiumLines(viewer, {
factories,
requestRender: (reason) => renders.push(reason),
});
assert.equal(viewer.scene.primitives.length, 0, 'nothing at creation');
const line = lines.createLine({
id: 'trail',
widthPx: 3,
color: [1, 0, 0, 1],
depthFailAlpha: 0.4,
arc: 'geodesic',
pickId: 'trail-1',
});
assert.equal(viewer.scene.primitives.length, 1);
assert.equal(made.length, 0, 'no primitive without positions');
line.setPositions(points);
assert.equal(made.length, 1);
const [first] = made;
const geometry = first.geometryInstances.geometry;
assert.equal(geometry._width, 3);
assert.equal(geometry._arcType, Cesium.ArcType.GEODESIC);
assert.equal(geometry._positions.length, 3);
assert.equal(first.geometryInstances.id, 'trail-1');
assert.equal(first.asynchronous, false);
assert.ok(first.depthFailAppearance, 'the dimmer copy behind the scene');
assert.ok(
Cesium.Color.equals(
first.depthFailAppearance.material.uniforms.color,
new Cesium.Color(1, 0, 0, 0.4),
),
);
line.setPositions(points.slice(0, 2));
assert.equal(made.length, 2);
assert.equal(first.destroyed, true, 'the old primitive goes');
line.setColor([0, 1, 0, 1]);
assert.ok(
Cesium.Color.equals(
made[1].appearance.material.uniforms.color,
new Cesium.Color(0, 1, 0, 1),
),
);
const turn = Cesium.Matrix4.fromRotation(Cesium.Matrix3.fromRotationZ(0.5));
line.setModelMatrix(Cesium.Matrix4.toArray(turn));
assert.ok(Cesium.Matrix4.equals(made[1].modelMatrix, turn));
line.show(false);
assert.equal(made[1].show, false);
line.setPositions(points);
assert.equal(made[2].show, false, 'a rebuild keeps show and matrix');
assert.ok(Cesium.Matrix4.equals(made[2].modelMatrix, turn));
assert.ok(renders.length >= 4);
line.destroy();
assert.equal(made[2].destroyed, true);
destroy();
assert.equal(viewer.scene.primitives.length, 0);
});
test('dash kinds pick their materials', () => {
const viewer = createFakeViewer();
const { factories, made } = fakePrimitives();
const { lines } = createCesiumLines(viewer, { factories });
lines
.createLine({
id: 'd',
widthPx: 2,
color: [1, 1, 1, 1],
dash: { lengthPx: 12, gapPx: 4 },
})
.setPositions(points);
lines
.createLine({ id: 't', widthPx: 3, color: [0, 1, 1, 1], dash: 'tactical' })
.setPositions(points);
lines
.createLine({ id: 's', widthPx: 1, color: [1, 1, 1, 1] })
.setPositions(points);
assert.equal(made[0].appearance.material.type, 'PolylineDash');
assert.equal(made[0].appearance.material.uniforms.dashLength, 16);
assert.equal(made[0].appearance.material.uniforms.dashPattern, 0xfff0);
assert.equal(made[1].appearance.material.type, 'GevLineTactical');
assert.equal(made[1].appearance.material.uniforms.groupCount, 4);
assert.equal(made[2].appearance.material.type, 'Color');
assert.equal(made[2].depthFailAppearance, undefined);
});
test('a pick of a line primitive names the line', () => {
const viewer = createFakeViewer();
const { factories, made } = fakePrimitives();
const { lines, identify } = createCesiumLines(viewer, { factories });
lines
.createLine({
id: 'orbit-25544',
widthPx: 2,
color: [1, 1, 1, 1],
pickId: 'o',
})
.setPositions(points);
assert.deepEqual(identify({ primitive: made[0], id: 'o' }), {
owner: 'orbit-25544',
});
assert.equal(identify({ primitive: {}, id: 'o' }), null);
});
test('arrow lines retain the native arrow material in both depth passes', () => {
const { factories, made } = fakePrimitives();
const { lines, destroy } = createCesiumLines(createFakeViewer(), {
factories,
});
lines
.createLine({
id: 'arrow',
widthPx: 8,
color: [1, 0, 0, 1],
arrow: true,
depthFailAlpha: 0.45,
})
.setPositions(points);
assert.equal(made[0].appearance.material.type, 'PolylineArrow');
assert.equal(made[0].depthFailAppearance.material.type, 'PolylineArrow');
assert.equal(made[0].depthFailAppearance.material.uniforms.color.alpha, 0.45);
destroy();
});
-365
View File
@@ -1,365 +0,0 @@
// engine.movers on Cesium: one primitive per set inside one engine-owned
// PrimitiveCollection (added to the scene with the first set, in `order`):
// sprites are a BillboardCollection (screen-aligned, rotation in screen
// space, an image per atlas cell), points a PointPrimitiveCollection,
// models one Model per instance in a PrimitiveCollection. commit() writes
// the changed instances and requests a render; nothing else renders.
import * as Cesium from 'cesium';
import { createMoverRegistry } from '../movers.js';
let scratchPosition;
let scratchColor;
let scratchQuaternion;
let scratchRotation;
// Model.modelMatrix and Model.color copy what they are given.
let scratchModelMatrix;
/** The atlas cells as canvases, by key (a DOM canvas or image source). */
async function cropAtlasCells(atlas, keys) {
let image = atlas.canvas;
if (!image) {
image = new Image();
image.crossOrigin = 'anonymous';
image.src = atlas.url;
await image.decode();
}
const width = image.width;
const height = image.height;
const cells = new Map();
for (const key of keys) {
const [u0, v0, u1, v1] = atlas.cells[key];
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round((u1 - u0) * width));
canvas.height = Math.max(1, Math.round((v1 - v0) * height));
canvas
.getContext('2d')
.drawImage(
image,
u0 * width,
v0 * height,
canvas.width,
canvas.height,
0,
0,
canvas.width,
canvas.height,
);
cells.set(key, canvas);
}
return cells;
}
const DEFAULT_FACTORIES = {
billboards: (scene) => new Cesium.BillboardCollection({ scene }),
points: () => new Cesium.PointPrimitiveCollection(),
models: () => new Cesium.PrimitiveCollection({ destroyPrimitives: true }),
loadModel: (options) => Cesium.Model.fromGltfAsync(options),
cropCells: cropAtlasCells,
};
const depthTestDistance = (depthTest) =>
depthTest === 'always'
? Number.POSITIVE_INFINITY
: depthTest === 'normal'
? 0
: depthTest.disableWithinM;
const nearFar = (value) =>
value ? new Cesium.NearFarScalar(...value) : undefined;
function readPosition(store, index) {
const { positions } = store;
return Cesium.Cartesian3.fromElements(
positions[index * 3],
positions[index * 3 + 1],
positions[index * 3 + 2],
scratchPosition,
);
}
function readColor(store, index) {
const { colors } = store;
scratchColor.red = colors[index * 4];
scratchColor.green = colors[index * 4 + 1];
scratchColor.blue = colors[index * 4 + 2];
scratchColor.alpha = colors[index * 4 + 3];
return scratchColor;
}
function spritePart(scene, options, store, factories, requestRender, owners) {
const collection = factories.billboards(scene);
const { sprite, depthTest, id } = options;
const style = {
width: sprite.sizePx,
height: sprite.sizePx,
scaleByDistance: nearFar(sprite.scaleByDistance),
disableDepthTestDistance: depthTestDistance(depthTest),
};
const billboards = [];
const shownCell = [];
let cells = null;
let destroyed = false;
const apply = (from, to) => {
for (let index = from; index < to; index++) {
let billboard = billboards[index];
if (index >= store.count) {
if (billboard) billboard.show = false;
continue;
}
if (!billboard) {
billboard = collection.add({ ...style });
billboards[index] = billboard;
owners.set(billboard, { owner: id, instance: index });
shownCell[index] = -1;
}
billboard.position = readPosition(store, index);
billboard.rotation = store.rotations[index];
billboard.scale = store.scales[index];
billboard.color = readColor(store, index);
billboard.show = store.shows[index] === 1;
billboard.id = store.pickIds[index];
const cell = store.cells[index];
if (shownCell[index] !== cell) {
const key = store.cellKeys[cell];
billboard.setImage(`${id}:${key}`, cells.get(key));
shownCell[index] = cell;
}
}
};
// Instances wait for the atlas; then everything drawn so far is applied.
factories
.cropCells(sprite.atlas, store.cellKeys)
.then((ready) => {
if (destroyed) return;
cells = ready;
apply(0, Math.max(store.count, billboards.length));
requestRender('movers');
})
.catch((error) => console.warn(`[movers] ${id}: atlas failed`, error));
return {
primitive: collection,
ready: () => cells !== null,
commit(range) {
if (cells && range) apply(range.from, range.to);
requestRender('movers');
},
destroy() {
destroyed = true;
billboards.length = 0;
shownCell.length = 0;
cells = null;
},
};
}
function pointPart(options, store, factories, requestRender, owners) {
const collection = factories.points();
const { point, depthTest } = options;
const style = {
outlineWidth: point.outlinePx,
outlineColor: new Cesium.Color(...point.outlineColor),
scaleByDistance: nearFar(point.scaleByDistance),
translucencyByDistance: nearFar(point.translucencyByDistance),
disableDepthTestDistance: depthTestDistance(depthTest),
};
const points = [];
return {
primitive: collection,
commit(range) {
if (range)
for (let index = range.from; index < range.to; index++) {
let item = points[index];
if (index >= store.count) {
if (item) item.show = false;
continue;
}
if (!item) {
item = collection.add({ ...style });
points[index] = item;
owners.set(item, { owner: options.id, instance: index });
}
item.position = readPosition(store, index);
item.pixelSize = point.sizePx * store.scales[index];
item.color = readColor(store, index);
item.show = store.shows[index] === 1;
item.id = store.pickIds[index];
}
requestRender('movers');
},
destroy() {
points.length = 0;
},
};
}
function modelPart(options, store, factories, requestRender, owners) {
const collection = factories.models();
const { model } = options;
const blendMode =
model.colorBlend?.mode === 'replace'
? Cesium.ColorBlendMode.REPLACE
: Cesium.ColorBlendMode.MIX;
const models = [];
const loading = [];
let destroyed = false;
const place = (item, index) => {
const { orientations } = store;
scratchQuaternion.x = orientations[index * 4];
scratchQuaternion.y = orientations[index * 4 + 1];
scratchQuaternion.z = orientations[index * 4 + 2];
scratchQuaternion.w = orientations[index * 4 + 3];
item.modelMatrix = Cesium.Matrix4.fromRotationTranslation(
Cesium.Matrix3.fromQuaternion(scratchQuaternion, scratchRotation),
readPosition(store, index),
scratchModelMatrix,
);
item.scale = store.scales[index];
item.show = index < store.count && store.shows[index] === 1;
item.id = store.pickIds[index];
if (model.colorBlend) item.color = readColor(store, index);
};
const ensure = (index) => {
if (models[index] || loading[index]) return;
loading[index] = true;
factories
.loadModel({
url: model.url,
minimumPixelSize: model.minimumPixelSize,
maximumScale: Number.isFinite(model.maximumScale)
? model.maximumScale
: undefined,
colorBlendMode: blendMode,
colorBlendAmount: model.colorBlend?.amount ?? 0.5,
...(model.unlit
? {
customShader: new Cesium.CustomShader({
lightingModel: Cesium.LightingModel.UNLIT,
}),
}
: {}),
})
.then((item) => {
if (destroyed) {
item.destroy?.();
return;
}
models[index] = item;
owners.set(item, { owner: options.id, instance: index });
place(item, index);
collection.add(item);
requestRender('movers');
})
.catch((error) =>
console.warn(`[movers] ${options.id}: model failed`, error),
);
};
return {
primitive: collection,
ready() {
for (let index = 0; index < store.count; index++)
if (!models[index]?.ready) return false;
return true;
},
commit(range) {
if (range)
for (let index = range.from; index < range.to; index++) {
if (index < store.count) ensure(index);
if (models[index]) place(models[index], index);
}
requestRender('movers');
},
destroy() {
destroyed = true;
models.length = 0;
loading.length = 0;
},
};
}
/**
* @param {Cesium.Viewer} viewer
* @param {{ requestRender?: (reason: string) => void, factories?: object }} [options]
* `factories` (collections, model loading, atlas cropping) is a test seam.
* @returns {{ movers: import('../contract.js').GlobeMovers, destroy: () => void }}
*/
export function createCesiumMovers(
viewer,
{ requestRender = () => viewer.scene.requestRender(), factories = {} } = {},
) {
scratchPosition ??= new Cesium.Cartesian3();
scratchColor ??= new Cesium.Color();
scratchQuaternion ??= new Cesium.Quaternion();
scratchRotation ??= new Cesium.Matrix3();
scratchModelMatrix ??= new Cesium.Matrix4();
const make = { ...DEFAULT_FACTORIES, ...factories };
const { scene } = viewer;
let root = null;
const ordered = [];
// Billboard, point or model → its set and instance, for picks.
const owners = new WeakMap();
const rootCollection = () => {
if (!root) {
root = new Cesium.PrimitiveCollection({ destroyPrimitives: true });
scene.primitives.add(root);
}
return root;
};
const registry = createMoverRegistry((options, store) => {
const part =
options.kind === 'sprite'
? spritePart(scene, options, store, make, requestRender, owners)
: options.kind === 'point'
? pointPart(options, store, make, requestRender, owners)
: modelPart(options, store, make, requestRender, owners);
const { primitive } = part;
primitive.gevMoverSet = options.id;
// Later (higher) order draws later: after every set of lower or equal order.
const at = ordered.filter((entry) => entry.order <= options.order).length;
ordered.splice(at, 0, { order: options.order, primitive });
rootCollection().add(primitive, at);
return {
primitive,
ready: part.ready,
commit: () => part.commit(store.dirty()),
destroy() {
part.destroy();
ordered.splice(
ordered.findIndex((entry) => entry.primitive === primitive),
1,
);
root.remove(primitive);
requestRender('movers');
},
};
});
return {
movers: registry.movers,
/** Where an instance of a set is (a geo Cartesian3), or null. */
instancePosition(owner, instance) {
const entry = registry.sets.get(owner);
if (!entry || !(instance < entry.store.count)) return null;
const view = entry.store.positionsView();
const at = 3 * instance;
return { x: view[at], y: view[at + 1], z: view[at + 2] };
},
/** The primitive drawing a live mover set of this engine, or null. */
primitiveOf(set) {
const entry = set?.id ? registry.sets.get(set.id) : null;
return entry && entry.set === set ? entry.part.primitive : null;
},
/** The set and instance a Cesium pick result belongs to, or null. */
identify(picked) {
const known = picked?.primitive ? owners.get(picked.primitive) : null;
const entry = known && registry.sets.get(known.owner);
return entry ? { ...known, pickable: entry.options.pickable } : null;
},
destroy() {
registry.destroyAll();
if (root) {
scene.primitives.remove(root);
root = null;
}
},
};
}
-248
View File
@@ -1,248 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createCesiumMovers } from './movers.js';
import { createFakeViewer } from './testing/fakeViewer.mjs';
// Collections that keep what the engine gives them (Cesium's own need a
// WebGL scene to add billboards).
function fakeFactories() {
const made = [];
const collection = (kind) => {
const items = [];
const value = {
kind,
items,
show: true,
add(options) {
// Cesium's billboards and points copy positions and colours.
let position;
let color;
const item = {
...options,
get position() {
return position;
},
set position(value) {
position = Cesium.Cartesian3.clone(value);
},
get color() {
return color;
},
set color(value) {
color = Cesium.Color.clone(value);
},
images: [],
setImage(id, image) {
this.images.push(id);
this.image = image;
},
};
items.push(item);
return item;
},
remove(item) {
items.splice(items.indexOf(item), 1);
return true;
},
get length() {
return items.length;
},
isDestroyed: () => false,
destroy() {
value.destroyed = true;
},
};
made.push(value);
return value;
};
return {
made,
factories: {
billboards: () => collection('billboards'),
points: () => collection('points'),
models: () => collection('models'),
loadModel: async (options) => ({ ...options, loaded: true }),
cropCells: async (atlas, keys) =>
new Map(keys.map((key) => [key, { cell: key }])),
},
};
}
const atlas = {
canvas: { width: 64, height: 32 },
cells: { jet: [0, 0, 0.5, 1], heli: [0.5, 0, 1, 1] },
};
const at = (x) => ({ x, y: 0, z: 6_400_000 });
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('creating the movers changes nothing; the first set adds one root collection', () => {
const viewer = createFakeViewer();
const { factories } = fakeFactories();
const { movers, destroy } = createCesiumMovers(viewer, { factories });
assert.equal(viewer.scene.primitives.length, 0);
movers.createSet({
id: 'a',
kind: 'point',
capacity: 2,
point: { sizePx: 4 },
});
assert.equal(viewer.scene.primitives.length, 1);
destroy();
assert.equal(viewer.scene.primitives.length, 0);
});
test('sprites become billboards with the set style once the atlas cells are ready', async () => {
const viewer = createFakeViewer();
const { factories, made } = fakeFactories();
const requests = [];
const { movers } = createCesiumMovers(viewer, {
factories,
requestRender: (reason) => requests.push(reason),
});
const set = movers.createSet({
id: 'flights',
kind: 'sprite',
capacity: 3,
depthTest: 'always',
sprite: { atlas, sizePx: 20, scaleByDistance: [1e3, 1, 1e6, 0.5] },
});
set.set(0, { position: at(1), rotation: 0.25, cell: 'heli', pickId: 'x1' });
set.set(1, { position: at(2), color: [1, 0, 0, 0.5], scale: 2, show: false });
set.setCount(2);
set.commit();
const billboards = made.find(
(collection) => collection.kind === 'billboards',
);
assert.equal(billboards.items.length, 0, 'waits for the atlas');
await settle();
assert.equal(billboards.items.length, 2);
const [first, second] = billboards.items;
assert.ok(first.position instanceof Cesium.Cartesian3);
assert.equal(first.position.x, 1);
assert.equal(first.width, 20);
assert.equal(first.height, 20);
assert.equal(first.rotation, 0.25);
assert.equal(first.id, 'x1');
assert.equal(first.disableDepthTestDistance, Number.POSITIVE_INFINITY);
assert.ok(first.scaleByDistance instanceof Cesium.NearFarScalar);
assert.deepEqual(first.images, ['flights:heli']);
assert.equal(first.show, true);
assert.equal(second.show, false);
assert.equal(second.scale, 2);
assert.ok(Cesium.Color.equals(second.color, new Cesium.Color(1, 0, 0, 0.5)));
assert.ok(requests.length >= 1);
// Lowering the count hides the instances past it.
set.setCount(1);
set.commit();
assert.equal(second.show, false);
set.set(0, { cell: 'heli' });
set.commit();
assert.deepEqual(
first.images,
['flights:heli'],
'the same cell is not re-sent',
);
});
test('points take the size, outline and depth test of the set', () => {
const viewer = createFakeViewer();
const { factories, made } = fakeFactories();
const { movers } = createCesiumMovers(viewer, { factories });
const set = movers.createSet({
id: 'sats',
kind: 'point',
capacity: 2,
depthTest: { disableWithinM: 5000 },
point: { sizePx: 4, outlinePx: 1 },
});
set.set(0, { position: at(3), scale: 1.5, color: [0, 1, 0, 1] });
set.setCount(1);
set.commit();
const [point] = made.find((c) => c.kind === 'points').items;
assert.equal(point.pixelSize, 6);
assert.equal(point.outlineWidth, 1);
assert.equal(point.disableDepthTestDistance, 5000);
assert.ok(Cesium.Color.equals(point.color, new Cesium.Color(0, 1, 0, 1)));
});
test('sets are ordered in the root by their order', () => {
const viewer = createFakeViewer();
const { factories } = fakeFactories();
const { movers } = createCesiumMovers(viewer, { factories });
const make = (id, order) =>
movers.createSet({
id,
kind: 'point',
capacity: 1,
order,
point: { sizePx: 2 },
});
make('top', 5);
make('bottom', -1);
make('middle', 0);
const root = viewer.scene.primitives.get(0);
assert.deepEqual(
[0, 1, 2].map((index) => root.get(index).gevMoverSet),
['bottom', 'middle', 'top'],
);
});
test('models load per instance and follow position and orientation', async () => {
const viewer = createFakeViewer();
const { factories, made } = fakeFactories();
const { movers } = createCesiumMovers(viewer, { factories });
const set = movers.createSet({
id: 'models',
kind: 'model',
capacity: 2,
model: {
url: '/models/airplane.glb',
minimumPixelSize: 24,
colorBlend: { mode: 'mix', amount: 0.4 },
},
});
set.set(0, {
position: at(4),
orientation: { x: 0, y: 0, z: 0, w: 1 },
color: [1, 0.5, 0, 1],
});
set.setCount(1);
set.commit();
await settle();
const models = made.find((c) => c.kind === 'models');
assert.equal(models.items.length, 1);
const [model] = models.items;
assert.equal(model.url, '/models/airplane.glb');
assert.equal(model.minimumPixelSize, 24);
assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.MIX);
assert.equal(model.colorBlendAmount, 0.4);
assert.equal(model.modelMatrix[12], 4);
set.destroy();
assert.equal(models.destroyed, true);
});
test('a pick of a billboard or point names its set and instance', async () => {
const viewer = createFakeViewer();
const { factories, made } = fakeFactories();
const { movers, identify } = createCesiumMovers(viewer, { factories });
const set = movers.createSet({
id: 'sats',
kind: 'point',
capacity: 3,
point: { sizePx: 4 },
});
set.set(0, { position: at(1) });
set.set(1, { position: at(2), pickId: 25544 });
set.setCount(2);
set.commit();
const [, second] = made.find((c) => c.kind === 'points').items;
assert.deepEqual(identify({ primitive: second, id: 25544 }), {
owner: 'sats',
instance: 1,
pickable: false,
});
assert.equal(identify({ primitive: {}, id: 'x' }), null);
set.destroy();
assert.equal(identify({ primitive: second, id: 25544 }), null);
});
+4 -31
View File
@@ -6,44 +6,18 @@ import { toCesium2, toGeo3 } from './convert.js';
/**
* @param {Cesium.Viewer} viewer
* @param {{
* identify?: (picked: object) => ({ owner: string, instance?: number, pickable?: boolean } | null),
* visible?: (known: { owner: string, instance?: number }) => boolean,
* }} [options]
* `identify` names the mover set or line (and instance) a pick belongs to;
* `visible` says whether that instance is in front of the horizon
* (Cesium's GPU pick sees billboards drawn without a depth test through
* the globe).
* @returns {{ picker: object, destroy: () => void }}
*/
export function createCesiumPicker(
viewer,
{ identify = () => null, visible = () => true } = {},
) {
export function createCesiumPicker(viewer) {
const normalise = (picked) => {
if (picked == null) return undefined;
// The follow camera's proxy entity (follow.js) is not an object.
if (picked.id?.gevFollowProxy) return undefined;
const known = identify(picked);
// A mover set created with pickable: false is not picked (Cesium's GPU
// pick sees every primitive; Navara's only the pickable ones), nor an
// instance behind the horizon.
if (known?.pickable === false) return undefined;
if (known && known.instance !== undefined && !visible(known))
return undefined;
return {
id: picked.id,
primitive: picked.primitive,
content: picked.content,
object: picked,
...(known
? {
owner: known.owner,
...(known.instance !== undefined
? { instance: known.instance }
: {}),
}
: {}),
};
};
const { scene } = viewer;
@@ -58,8 +32,8 @@ export function createCesiumPicker(
if (top == null) return undefined;
const picked = normalise(top);
if (picked) return picked;
// The top result is filtered: what lies under it, the first with an
// id (an object), else the first kept (map tiles).
// The top result is the follow proxy: what lies under it, the first
// with an id (an object), else the first kept (map tiles).
const under = drill(screen, undefined, width, height);
return under.find((entry) => entry.id !== undefined) ?? under[0];
},
@@ -81,8 +55,7 @@ export function createCesiumPicker(
pickGlobe(screen) {
if (!scene.globe) return undefined;
try {
const camera = viewer.camera ?? scene.camera;
const ray = camera.getPickRay(toCesium2(screen));
const ray = viewer.camera.getPickRay(toCesium2(screen));
return ray ? toGeo3(scene.globe.pick(ray, scene)) : undefined;
} catch {
return undefined;
+7 -81
View File
@@ -114,97 +114,23 @@ test('pickGlobe asks the globe along the camera pick ray and returns geo', () =>
assert.equal(picker.pickGlobe(geo.cartesian2.create(1, 1)), undefined);
});
test('picks of movers and lines name their owner and instance', () => {
test('a follow-proxy top pick falls through to the first pick under it with an id', () => {
const viewer = createFakeViewer();
const billboard = { id: 'ae01ce' };
viewer.scene.pick = () => ({ primitive: billboard, id: 'ae01ce' });
viewer.scene.drillPick = () => [
{ primitive: billboard, id: 'ae01ce' },
{ primitive: {}, id: 'other' },
];
const { picker } = createCesiumPicker(viewer, {
identify: (raw) =>
raw.primitive === billboard ? { owner: 'flights', instance: 7 } : null,
});
const picked = picker.pick({ x: 1, y: 2 });
assert.equal(picked.id, 'ae01ce');
assert.equal(picked.owner, 'flights');
assert.equal(picked.instance, 7);
const drilled = picker.drillPick({ x: 1, y: 2 }, 5);
assert.equal(drilled[0].owner, 'flights');
assert.equal(drilled[1].owner, undefined);
});
test('picks of a mover set made with pickable: false are dropped', () => {
const viewer = createFakeViewer();
const quiet = { id: 'q' };
viewer.scene.pick = () => ({ primitive: quiet, id: 'q' });
viewer.scene.drillPick = () => [
{ primitive: quiet, id: 'q' },
{ primitive: {}, id: 'other' },
];
const { picker } = createCesiumPicker(viewer, {
identify: (raw) =>
raw.primitive === quiet
? { owner: 'quiet', instance: 0, pickable: false }
: null,
});
// The pick falls through to what is under the quiet set.
assert.equal(picker.pick({ x: 1, y: 2 }).id, 'other');
assert.deepEqual(
picker.drillPick({ x: 1, y: 2 }, 5).map(({ id }) => id),
['other'],
);
});
test('a filtered top pick falls through to the first pick under it with an id', () => {
const viewer = createFakeViewer();
const quiet = { primitive: 'quiet-billboard', id: 'q' };
const proxy = { primitive: 'proxy-point', id: { gevFollowProxy: true } };
const tile = { primitive: 'tileset', content: {} };
const flight = { primitive: 'flight-billboard', id: 'ae01ce' };
const { picker } = createCesiumPicker(viewer, {
identify: (picked) =>
picked === quiet
? { owner: 'quiet', instance: 0, pickable: false }
: picked === flight
? { owner: 'flights', instance: 3, pickable: true }
: null,
});
viewer.scene.pick = () => quiet;
viewer.scene.drillPick = () => [quiet, proxy, tile, flight];
const picked = picker.pick(geo.cartesian2.create(1, 1));
assert.equal(picked.id, 'ae01ce');
assert.equal(picked.owner, 'flights');
const { picker } = createCesiumPicker(viewer);
viewer.scene.pick = () => proxy;
viewer.scene.drillPick = () => [proxy, tile, flight];
assert.equal(picker.pick(geo.cartesian2.create(1, 1)).id, 'ae01ce');
assert.deepEqual(
picker.drillPick(geo.cartesian2.create(1, 1)).map((p) => p.primitive),
['tileset', 'flight-billboard'],
'the follow proxy and pickable: false sets are never picked',
'the follow proxy is never picked',
);
// Nothing under it with an id: the first kept result (a tile).
viewer.scene.drillPick = () => [quiet, tile];
viewer.scene.drillPick = () => [proxy, tile];
assert.equal(picker.pick(geo.cartesian2.create(1, 1)).primitive, 'tileset');
viewer.scene.pick = () => proxy;
viewer.scene.drillPick = () => [proxy];
assert.equal(picker.pick(geo.cartesian2.create(1, 1)), undefined);
});
test('drills leave out mover instances behind the horizon', () => {
const viewer = createFakeViewer();
const near = { primitive: 'near', id: 'n' };
const far = { primitive: 'far', id: 'f' };
const { picker } = createCesiumPicker(viewer, {
identify: (picked) => ({
owner: 'flights',
instance: picked === near ? 0 : 1,
}),
visible: (known) => known.instance === 0,
});
viewer.scene.drillPick = () => [far, near];
assert.deepEqual(
picker.drillPick(geo.cartesian2.create(1, 1)).map((p) => p.id),
['n'],
);
viewer.scene.pick = () => far;
assert.equal(picker.pick(geo.cartesian2.create(1, 1)).id, 'n');
});
+1 -3
View File
@@ -15,9 +15,7 @@ export function createCesiumProjector(
worldToWindowCoordinates = Cesium.SceneTransforms.worldToWindowCoordinates,
} = {},
) {
const { scene } = viewer;
// Viewer.camera is scene.camera; the engine reads the public alias.
const camera = viewer.camera ?? scene.camera;
const { scene, camera } = viewer;
const scratchWindow = new Cesium.Cartesian2();
const scratchMatrix = new Cesium.Matrix4();
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
+5 -43
View File
@@ -1,11 +1,5 @@
// GlobeSurface on a Cesium scene: the rendered-surface queries that
// services/groundSnap and services/meshFloorSampler make.
//
// `exclude` takes the engine's own mover sets (engine.movers) as well as
// Cesium objects: Cesium's sampleHeight matches excluded objects by the
// picked primitive, which for a set is each billboard, point or model, so a
// set is hidden for the sample instead (synchronous: nothing draws in
// between) and shown again after it.
import { firstVisibleTileset } from '../../services/groundSnap.js';
import { normalizeSurfaceOptions } from '../surfaceDetail.js';
@@ -15,46 +9,16 @@ const finite = (value) => (Number.isFinite(value) ? value : undefined);
/**
* @param {Cesium.Viewer} viewer
* @param {{ engineExclude?: (entry: any) => ({ show: boolean } | null) }} [options]
* `engineExclude` maps an engine object (a mover set) to the Cesium
* primitive that draws it, or null for a Cesium object.
* @returns {{ surface: import('../contract.js').GlobeSurface, destroy: () => void }}
*/
export function createCesiumSurface(
viewer,
{ engineExclude = () => null } = {},
) {
export function createCesiumSurface(viewer) {
const { scene } = viewer;
// Runs `query(nativeExclude)` with the engine objects in `exclude` hidden.
function excluding(exclude, query) {
if (!exclude?.length) return query(exclude);
let native = exclude;
const hidden = [];
for (const entry of exclude) {
const primitive = engineExclude(entry);
if (!primitive) continue;
if (native === exclude)
native = exclude.filter((other) => !engineExclude(other));
if (primitive.show) {
primitive.show = false;
hidden.push(primitive);
}
}
try {
return query(native);
} finally {
for (const primitive of hidden) primitive.show = true;
}
}
const sampleHeight = (cartographic, exclude) => {
if (!scene.sampleHeightSupported) return undefined;
try {
// Throws when unsupported (no depth textures); that is a miss.
return excluding(exclude, (native) =>
finite(scene.sampleHeight(cartographic, native)),
);
return finite(scene.sampleHeight(cartographic, exclude));
} catch {
return undefined;
}
@@ -64,8 +28,8 @@ export function createCesiumSurface(
const surface = {
sampleHeight,
// Cesium samples the tiles it has loaded (lod 'finest' cannot do better
// synchronously) and does not say which tile answered.
// Cesium samples the tiles it has loaded and does not say which tile
// answered.
sampleHeightDetailed(cartographic, options) {
const { exclude } = normalizeSurfaceOptions(options);
const height = sampleHeight(cartographic, exclude);
@@ -85,9 +49,7 @@ export function createCesiumSurface(
clampToHeight(position, exclude) {
if (!scene.clampToHeightSupported) return undefined;
try {
return excluding(exclude, (native) =>
toGeo3(scene.clampToHeight(position, native)),
);
return toGeo3(scene.clampToHeight(position, exclude));
} catch {
return undefined;
}
+2 -25
View File
@@ -93,35 +93,12 @@ test('sampleHeightDetailed: tiles from sampleHeight, else terrain; no tile size'
source: 'tiles',
});
scene.sampleHeight = () => undefined;
assert.deepEqual(surface.sampleHeightDetailed(place, { lod: 'finest' }), {
assert.deepEqual(surface.sampleHeightDetailed(place), {
height: 12.5,
source: 'terrain',
});
assert.throws(
() => surface.sampleHeightDetailed(place, { lod: 2 }),
() => surface.sampleHeightDetailed(place, { exclude: 'x' }),
TypeError,
);
});
test('an engine mover set in exclude is hidden for the sample, native objects pass', () => {
const viewer = createFakeViewer();
const collection = { show: true };
const set = { id: 'flights' };
const { surface } = createCesiumSurface(viewer, {
engineExclude: (entry) => (entry === set ? collection : null),
});
const shownDuring = [];
viewer.scene.sampleHeight = (position, excluded) => {
shownDuring.push(collection.show, excluded);
return 30;
};
const native = { primitive: 1 };
const place = geo.cartographic.fromDegrees(20, 10, 0);
assert.equal(surface.sampleHeight(place, [set, native]), 30);
assert.deepEqual(shownDuring, [false, [native]]);
assert.equal(collection.show, true, 'shown again after the sample');
shownDuring.length = 0;
surface.sampleHeightDetailed(place, { exclude: [set] });
assert.deepEqual(shownDuring, [false, []]);
assert.equal(collection.show, true);
});
+35 -62
View File
@@ -189,12 +189,12 @@
* @typedef {object} GlobeSurface
* @property {(cartographic: Cartographic, exclude?: any[]) => (number | undefined)} sampleHeight
* Height of the rendered scene (tiles, models) under a point.
* @property {(cartographic: Cartographic, options?: { exclude?: any[], lod?: 'visible' | 'finest' }) => (SurfaceHeight | undefined)} sampleHeightDetailed
* @property {(cartographic: Cartographic, options?: { exclude?: any[] }) => (SurfaceHeight | undefined)} sampleHeightDetailed
* sampleHeight with the answer's quality (src/globe/surfaceDetail.js):
* lod 'visible' (default) answers from the tiles drawn now, 'finest' from
* the finest loaded ones (Navara; Cesium samples what it has loaded).
* Where no tile answers, the terrain. `exclude` takes this engine's mover
* sets as well as engine objects.
* from the tiles drawn now (Navara: where they miss, the finest loaded
* ones; Cesium samples what it has loaded). Where no tile answers, the
* terrain. `exclude` takes this engine's mover sets as well as engine
* objects.
* @property {boolean} supported Whether sampleHeight works.
* @property {(cartographic: Cartographic) => (number | undefined)} terrainHeight
* Height of the terrain/globe surface only (Cesium's globe.getHeight).
@@ -203,7 +203,6 @@
* The position moved onto the rendered scene along the geodetic normal.
* @property {boolean} clampToHeightSupported
* @property {() => boolean} tilesLoaded
* @property {boolean} [raycastSupported] Optional visible-tile obstruction probe.
* @property {(ray: { origin: Cartesian3, direction: Cartesian3, far: number, exclude?: any[] }) => ({ position: Cartesian3, distance: number, object: any } | undefined)} [raycast]
* Nearest visible tile triangle within finite positive `far` metres. Direction
* is normalized by the adapter. Excluded roots/meshes and hidden objects do
@@ -339,28 +338,23 @@
* epoch: the one time source for layers and the engine's own time-driven
* drawing (Navara's sun). By default it is the wall clock (Date.now); QA
* and tests may freeze, offset or speed it up on the app clock: now()
* follows at once, frameTime() and ticked from the engine's next frame, and
* in request-render mode nothing draws until a render is requested, so a
* QA helper that moves the clock calls render.request() after it.
* Not on this clock yet (each moves when its layer or code moves):
* follows at once, frameTime() from the engine's next frame, and in
* request-render mode nothing draws until a render is requested, so a QA
* helper that moves the clock calls render.request() after it.
* Not on this clock:
* - Cesium's viewer.clock: the app never animates it (shouldAnimate false,
* its time frozen at boot); Cesium's sun and lighting and every entity
* property read at viewer.clock.currentTime stay on that time;
* - wall-clock throttles and feed receipt stamps everywhere (they measure
* real elapsed time, not scene time).
* Launch replay, satellite propagation and orbit GMST now use this clock.
* - wall-clock throttles and feed receipt stamps (they measure real
* elapsed time, not scene time).
* @property {() => number} now The current app time.
* @property {() => number} frameTime The app time latched at the start of the
* engine's current frame: every reader in one frame gets the same value; it
* does not decrease from frame to frame while the app clock runs forward.
* Before the first frame, the current time.
* @property {EngineEvent} ticked Raised once per engine frame, with the
* frame time, before that frame's preUpdate (and so before preRender). A
* frame is one turn of the engine's update loop, also when it draws
* nothing: Cesium raises it on the viewer clock's tick, right before
* scene.render (which raises preUpdate on every animation frame, drawn or
* not); Navara on the view's preUpdate, which it emits on every turn of
* its loop.
* Before the first frame, the current time. A frame is one turn of the
* engine's update loop, also when it draws nothing; its time is latched
* before its preUpdate (Cesium: on the viewer clock's tick, right before
* scene.render; Navara: on the view's preUpdate).
*
* @typedef {object} MoverSetOptions
* A set of moving objects drawn as one batch (src/globe/movers.js checks
@@ -383,14 +377,14 @@
* @property {{ sizePx: number, outlinePx?: number, outlineColor?: number[], scaleByDistance?: number[], translucencyByDistance?: number[] }} [point]
* A filled disc of sizePx CSS px diameter plus outlinePx on each side;
* outlineColor is rgba in 0..1 (opaque black by default).
* @property {{ url: string, minimumPixelSize?: number, maximumScale?: number,
* @property {{ url: string, minimumPixelSize?: number,
* colorBlend?: { mode: 'mix'|'replace', amount: number }, unlit?: boolean }} [model]
* A glTF drawn per instance in metres, at least minimumPixelSize px.
* @property {boolean} [pickable]
* @property {boolean} [sharpen] Sprites/points: include the glyph in the
* sharpen/AA stage after tone mapping, for translucent fields such as FIRMS.
* Navara defaults to the later display stage used by W1; Cesium retains its
* native billboard stage. Does not enable sharpening or change its amount.
* sharpen/AA stage after tone mapping, for translucent fields such as FIRMS
* (by default Navara draws them in the later display stage). Does not
* enable sharpening or change its amount.
*
* @typedef {object} MoverFields
* One instance; omitted fields keep their value (defaults: white, scale 1,
@@ -413,10 +407,8 @@
* @property {() => number} count
* @property {() => void} commit Uploads what changed (one buffer write) and
* requests a render; nothing else draws a frame.
* @property {() => Float64Array} positionsView x, y, z of the drawn
* instances (a view on the set's own array; read-only).
* @property {() => boolean} ready Whether what the set draws has loaded: a
* model set's glTF (Cesium: every instance's model), a sprite set's atlas.
* model set's glTF, a sprite set's atlas.
* A layer keeps its fallback visual until then.
* @property {() => void} destroy
*
@@ -475,8 +467,11 @@
* @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits
* @property {GlobeMaps} maps
* @property {GlobeClock} clock
* @property {GlobeMovers} movers
* @property {GlobeLines} lines
* @property {GlobeMovers} [movers] Optional, with lines (capabilities
* `movers` and `lines`): the engine draws the layers' moving objects and
* polylines (Navara). Cesium has none: in Cesium mode the layers draw
* their own Cesium primitives.
* @property {GlobeLines} [lines]
* @property {() => void} destroy
* @property {() => boolean} isDestroyed True once destroy() has run.
*/
@@ -491,7 +486,7 @@ export const INPUT_TYPES = Object.freeze([
]);
export const MODIFIERS = Object.freeze(['ctrl', 'shift', 'alt']);
/** Input types whose listeners may ask for picks. */
export const PICK_INPUT_TYPES = Object.freeze(['click', 'doubleClick', 'move']);
const PICK_INPUT_TYPES = Object.freeze(['click', 'doubleClick', 'move']);
/**
* input.on options checked and defaulted: { modifier, pick, drill } (drill
@@ -512,11 +507,6 @@ export function normalizeInputOptions(type, options = {}) {
if (drill && type === 'move') throw new TypeError('A move cannot drill');
return { modifier, pick: pick || drill > 0, drill };
}
export const FLIGHT_EASINGS = Object.freeze([
'cubicInOut',
'sinusoidalInOut',
'linear',
]);
const ENGINE_EVENT = Symbol('gev.globe.engineEvent');
@@ -681,9 +671,7 @@ const SHAPE = {
},
input: { on: isFunction, setCameraInputs: isFunction },
credits: { addStatic: isFunction },
clock: { now: isFunction, frameTime: isFunction, ticked: isEngineEvent },
movers: { createSet: isFunction, list: isFunction },
lines: { createLine: isFunction, list: isFunction },
clock: { now: isFunction, frameTime: isFunction },
maps: {
setBaseImagery: isFunction,
setTerrain: isFunction,
@@ -720,21 +708,16 @@ const OPTIONAL_SHAPE = {
'render.setContactSonar': isFunction,
'render.contactSonar': isFunction,
},
movers: {
'movers.createSet': isFunction,
'movers.list': isFunction,
},
lines: {
'lines.createLine': isFunction,
'lines.list': isFunction,
},
};
/** The optional capability flags, e.g. 'visualStyles'. */
export const ENGINE_CAPABILITIES = Object.freeze(Object.keys(OPTIONAL_SHAPE));
/** The member paths of each optional capability. */
export const OPTIONAL_ENGINE_MEMBERS = Object.freeze(
Object.fromEntries(
Object.entries(OPTIONAL_SHAPE).map(([flag, members]) => [
flag,
Object.freeze(Object.keys(members)),
]),
),
);
const memberAt = (engine, path) =>
path.split('.').reduce((value, key) => value?.[key], engine);
@@ -756,16 +739,6 @@ export function engineCapabilities(engine) {
);
}
/** The capability paths the contract requires, e.g. 'camera.flyTo'. */
export const GLOBE_ENGINE_MEMBERS = Object.freeze([
'kind',
...Object.entries(SHAPE).flatMap(([capability, members]) =>
Object.keys(members).map((member) => `${capability}.${member}`),
),
'destroy',
'isDestroyed',
]);
/**
* Whether a value is a globe engine rather than, say, a Cesium viewer (a
* cheap structural test; assertGlobeEngine checks every member).
+21 -108
View File
@@ -2,14 +2,11 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
ENGINE_KINDS,
GLOBE_ENGINE_MEMBERS,
INPUT_TYPES,
MODIFIERS,
BLOOM_INITIAL,
CLOUD_COVER_INITIAL,
CONTACT_SONAR_INITIAL,
ENGINE_CAPABILITIES,
OPTIONAL_ENGINE_MEMBERS,
SHARPEN_INITIAL,
assertGlobeEngine,
createEngineEvent,
@@ -198,42 +195,6 @@ test('assertGlobeEngine accepts a complete engine and names every missing capabi
assert.throws(() => assertGlobeEngine(null), TypeError);
});
test('the contract carries what the shell infrastructure needs', () => {
for (const member of [
'camera.livePosition',
'camera.height',
'maps.releasePhotorealTiles',
'maps.current',
])
assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member);
const engine = createFakeEngine();
const broken = {
...engine,
camera: { ...engine.camera, livePosition: undefined, height: 1 },
maps: { ...engine.maps, releasePhotorealTiles: undefined },
};
assert.throws(
() => assertGlobeEngine(broken),
/camera\.livePosition.*camera\.height.*maps\.releasePhotorealTiles/,
);
});
test('the contract carries what shell navigation needs', () => {
for (const member of ['camera.commanded', 'picker.pickGlobe', 'isDestroyed'])
assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member);
const engine = createFakeEngine();
const broken = {
...engine,
camera: { ...engine.camera, commanded: {} },
picker: { ...engine.picker, pickGlobe: undefined },
isDestroyed: false,
};
assert.throws(
() => assertGlobeEngine(broken),
/camera\.commanded.*picker\.pickGlobe.*isDestroyed/,
);
});
test('the fake engine passes the engine contract suite', async (t) => {
const engine = createFakeEngine();
const checks = createContractSuite(engine, {
@@ -314,9 +275,7 @@ test('raiseValue raises exactly one argument with the event semantics', () => {
assert.equal(event.numberOfListeners, 1);
});
test('the contract carries the sharpening and checks its values', () => {
for (const member of ['render.setSharpen', 'render.sharpen'])
assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member);
test('mergeSharpen keeps omitted fields and rejects bad values', () => {
assert.deepEqual(SHARPEN_INITIAL, { enabled: false, amount: 1 });
const on = mergeSharpen(SHARPEN_INITIAL, { enabled: true });
assert.deepEqual(on, { enabled: true, amount: 1 });
@@ -334,68 +293,34 @@ test('the contract carries the sharpening and checks its values', () => {
assert.throws(() => mergeSharpen(on, bad), TypeError);
});
test('the contract carries the app clock', () => {
for (const member of ['clock.now', 'clock.frameTime', 'clock.ticked'])
assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member);
test('movers and lines are optional capabilities, whole or absent', () => {
const engine = createFakeEngine();
assert.equal(assertGlobeEngine(engine), engine);
const broken = {
...engine,
clock: { ...engine.clock, frameTime: 1, ticked: {} },
};
assert.equal(engineCapabilities(engine).movers, true);
assert.equal(engineCapabilities(engine).lines, true);
const without = { ...engine };
delete without.movers;
delete without.lines;
assert.equal(assertGlobeEngine(without), without);
assert.equal(engineCapabilities(without).movers, false);
const half = { ...engine, movers: { createSet: 1 } };
assert.throws(
() => assertGlobeEngine(broken),
/clock\.frameTime.*clock\.ticked/,
);
const missing = { ...engine };
delete missing.clock;
assert.throws(() => assertGlobeEngine(missing), /\bclock\b/);
});
test('the contract carries instanced movers', () => {
for (const member of ['movers.createSet', 'movers.list'])
assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member);
const engine = createFakeEngine();
const broken = { ...engine, movers: { createSet: 1 } };
assert.throws(
() => assertGlobeEngine(broken),
() => assertGlobeEngine(half),
/movers\.createSet.*movers\.list/,
);
});
test('the contract carries dynamic lines', () => {
for (const member of ['lines.createLine', 'lines.list'])
assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member);
const engine = createFakeEngine();
assert.equal(assertGlobeEngine(engine), engine);
const broken = { ...engine, lines: {} };
assert.throws(
() => assertGlobeEngine(broken),
/lines\.createLine.*lines\.list/,
);
});
test('the contract carries the follow camera', () => {
for (const member of [
'camera.follow',
'camera.following',
'camera.followChanged',
])
assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member);
const engine = createFakeEngine();
const broken = { ...engine, camera: { ...engine.camera, followChanged: {} } };
assert.throws(() => assertGlobeEngine(broken), /camera\.followChanged/);
// Without them the suite leaves out the checks that draw movers and lines.
const names = (target) =>
createContractSuite(target, { waitFrames: async () => {} }).map(
({ name }) => name,
);
assert.ok(names(engine).some((name) => name.startsWith('movers:')));
const plain = names(without);
assert.ok(!plain.some((name) => /^(movers|lines|colours):/.test(name)));
assert.ok(plain.some((name) => name.startsWith('camera.')));
});
test('engineCapabilities names the optional capabilities an engine has whole', () => {
assert.ok(ENGINE_CAPABILITIES.includes('visualStyles'));
assert.deepEqual(OPTIONAL_ENGINE_MEMBERS.visualStyles, [
'render.visualStyles',
'render.setStyle',
'render.style',
]);
const plain = createFakeEngine();
for (const flag of ENGINE_CAPABILITIES)
for (const flag of ['visualStyles', 'bloom', 'cloudCover', 'contactSonar'])
assert.equal(engineCapabilities(plain)[flag], false, flag);
const styled = createFakeEngine({
visualStyles: { thermal: ['palette', 'time'] },
@@ -457,11 +382,6 @@ test('a fake engine with visual styles passes the optional style check', async (
});
test('the bloom capability: whole or absent, merged and checked', async (t) => {
assert.ok(ENGINE_CAPABILITIES.includes('bloom'));
assert.deepEqual(OPTIONAL_ENGINE_MEMBERS.bloom, [
'render.setBloom',
'render.bloom',
]);
const engine = createFakeEngine({ bloom: true });
assert.equal(engineCapabilities(engine).bloom, true);
assert.equal(engineCapabilities(engine).visualStyles, false);
@@ -482,7 +402,6 @@ test('the bloom capability: whole or absent, merged and checked', async (t) => {
});
test('the cloudCover capability: whole or absent, null for the engine own', async (t) => {
assert.ok(ENGINE_CAPABILITIES.includes('cloudCover'));
const engine = createFakeEngine({ cloudCover: true });
assert.equal(engineCapabilities(engine).cloudCover, true);
assert.equal(engineCapabilities(createFakeEngine()).cloudCover, false);
@@ -506,12 +425,6 @@ test('the cloudCover capability: whole or absent, null for the engine own', asyn
});
test('the contactSonar capability: whole or absent, merged and checked', async (t) => {
assert.deepEqual(ENGINE_CAPABILITIES, [
'visualStyles',
'bloom',
'cloudCover',
'contactSonar',
]);
const engine = createFakeEngine({ contactSonar: true });
assert.equal(engineCapabilities(engine).contactSonar, true);
assert.deepEqual(engine.render.contactSonar(), CONTACT_SONAR_INITIAL);
+2 -2
View File
@@ -16,9 +16,9 @@ const scratchB = geo.cartesian3.create();
const scratchScreen = { x: 0, y: 0 };
const scratchScreenB = { x: 0, y: 0 };
export const PICK_RADIUS_PX = 3;
const PICK_RADIUS_PX = 3;
/** A line is picked within 8 px of its drawn segments (thin trails too). */
export const LINE_PICK_PX = 8;
const LINE_PICK_PX = 8;
/** Distance from point p to segment ab (2D). */
export function distanceToSegment(p, a, b) {
+1 -6
View File
@@ -193,12 +193,7 @@ export function orbitOffset(
}
/** The ENU offset scaled by `factor`, never inside `minRange`. */
export function zoomOffset(
offset,
factor,
minRange,
result = cartesian3.create(),
) {
function zoomOffset(offset, factor, minRange, result = cartesian3.create()) {
const range = cartesian3.magnitude(offset);
if (!(range > 0) || !(factor > 0)) return cartesian3.clone(offset, result);
const next = Math.max(minRange, range * factor);
+1 -1
View File
@@ -23,7 +23,7 @@ function fail(message) {
*/
export const TACTICAL_DASH = Object.freeze({ groups: 4, dashes: 100 });
export const LINE_ARCS = Object.freeze(['none', 'geodesic']);
const LINE_ARCS = Object.freeze(['none', 'geodesic']);
const isColor = (value) =>
(Array.isArray(value) || ArrayBuffer.isView(value)) &&
+6 -9
View File
@@ -5,7 +5,7 @@
// metres), colours rgba in 0..1, rotations radians. No engine imports.
export const MOVER_KINDS = Object.freeze(['sprite', 'point', 'model']);
export const MOVER_DEPTH_TESTS = Object.freeze(['normal', 'always']);
const MOVER_DEPTH_TESTS = Object.freeze(['normal', 'always']);
const isFiniteNumber = (value) =>
typeof value === 'number' && Number.isFinite(value);
@@ -146,10 +146,6 @@ export function normalizeMoverSetOptions(options) {
'model.minimumPixelSize',
0,
),
maximumScale:
model.maximumScale === undefined
? Infinity
: positive(model.maximumScale, 'model.maximumScale'),
colorBlend: blend
? { mode: blend.mode, amount: blend.amount ?? 0.5 }
: null,
@@ -305,8 +301,10 @@ export function createMoverStore(options) {
});
this.clean();
},
/** Indices changed since clean(), as [from, to), or null. */
/** The changed range (one object, reused: read it at once), or null. */
/**
* Indices changed since clean(), as [from, to) (one object, reused: read
* it at once), or null.
*/
dirty() {
if (dirtyFrom === Infinity) return null;
range.from = dirtyFrom;
@@ -350,14 +348,13 @@ export function createMoverSet(
commit();
store.clean();
},
positionsView: () => store.positionsView(),
// Whether what it draws has loaded (a model's glTF, an atlas image).
ready: () => !destroyed && ready() === true,
destroy() {
if (destroyed) return;
destroyed = true;
destroy();
store.release?.();
store.release();
},
isDestroyed: () => destroyed,
};
+2 -3
View File
@@ -43,7 +43,6 @@ test('set options are normalised with the contract defaults', () => {
model: { url: '/models/airplane.glb' },
});
assert.equal(model.model.minimumPixelSize, 0);
assert.equal(model.model.maximumScale, Infinity);
assert.equal(model.model.colorBlend, null);
});
@@ -177,7 +176,7 @@ test('the set facade forwards to the store and an engine commit, once destroyed
set.set(0, { position: { x: 1, y: 2, z: 3 } });
set.setCount(1);
assert.equal(set.count(), 1);
assert.deepEqual([...set.positionsView()], [1, 2, 3]);
assert.deepEqual([...store.positionsView()], [1, 2, 3]);
set.commit();
assert.deepEqual(calls, [['commit', { from: 0, to: 1 }]]);
assert.equal(store.dirty(), null, 'commit cleans the store');
@@ -205,7 +204,7 @@ test('a destroyed set releases its instance arrays', () => {
assert.equal(store.count, 0);
assert.equal(store.positions.length, 0);
assert.equal(store.pickIds.length, 0);
assert.equal(set.positionsView().length, 0);
assert.equal(store.positionsView().length, 0);
assert.equal(store.dirty(), null);
});
+1 -1
View File
@@ -11,7 +11,7 @@ import { createBloomPort } from './bloomSettings.js';
import { pipelinePassNames } from './internals.js';
import { withPassDisposal } from './frameDescriptor.js';
export const BLOOM_EFFECT_KEY = 'gevBloom';
const BLOOM_EFFECT_KEY = 'gevBloom';
function apply(effect, settings) {
effect.luminanceMaterial.threshold = settings.luminanceThreshold;
+1 -1
View File
@@ -30,7 +30,7 @@ import {
sphereRange,
viewProjection,
viewRectangle,
} from './cameraMath.js';
} from '../cameraMath.js';
const { cartesian3, cartographic, math, matrix4 } = geo;
const WGS84 = geo.ellipsoid.WGS84;
+1 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../geo/index.js';
import { isEngineEvent } from '../contract.js';
import { orientationVectors } from './cameraMath.js';
import { orientationVectors } from '../cameraMath.js';
import { createCameraModel } from './cameraModel.js';
const { cartesian2, cartesian3, math, matrix4 } = geo;
+1 -1
View File
@@ -1,4 +1,4 @@
// Navara 0.2.1 leak fix (phase 3 review): Navara's transparent pass keeps a
// Navara 0.2.1 leak fix: Navara's transparent pass keeps a
// copy of each scene light and refreshes it every frame with
// `copy.copy(light, true)`. For the sun's cascaded shadow lights
// (@navaramap/three-csm CascadedDirectionalLights) that copy clones every
+1 -4
View File
@@ -5,7 +5,6 @@
// contract's preUpdate; every listener of the frame then reads that time.
// The look (look.js) reads the same clock for the sun.
import { createEngineEvent } from '../contract.js';
import { appClock as pageClock } from '../../time/clock.js';
/**
@@ -14,15 +13,13 @@ import { appClock as pageClock } from '../../time/clock.js';
* @returns {{ clock: import('../contract.js').GlobeClock, beginFrame: () => void }}
*/
export function createNavaraClock({ appClock = pageClock } = {}) {
const ticked = createEngineEvent();
return {
clock: {
now: () => appClock.now(),
frameTime: () => appClock.frameTime(),
ticked,
},
beginFrame() {
ticked.raiseValue(appClock.beginFrame());
appClock.beginFrame();
},
};
}
+1 -6
View File
@@ -1,21 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { isEngineEvent } from '../contract.js';
import { createAppClock } from '../../time/clock.js';
import { createNavaraClock } from './clock.js';
test('beginFrame latches the app clock and raises ticked with the frame time', () => {
test('beginFrame latches the app clock', () => {
let wall = 10_000;
const appClock = createAppClock({ now: () => wall });
const { clock, beginFrame } = createNavaraClock({ appClock });
assert.ok(isEngineEvent(clock.ticked));
const seen = [];
clock.ticked.addEventListener((time) => seen.push(time));
assert.equal(clock.frameTime(), 10_000);
wall = 10_016;
beginFrame();
wall = 10_020;
assert.deepEqual(seen, [10_016]);
assert.equal(clock.frameTime(), 10_016);
assert.equal(clock.now(), 10_020);
});
+4 -32
View File
@@ -20,7 +20,7 @@ import {
mergeSharpen,
mergeStyle,
} from '../contract.js';
import { cesiumFovy } from './cameraMath.js';
import { cesiumFovy, intersectEllipsoid } from '../cameraMath.js';
import { createCameraModel } from './cameraModel.js';
import { createNavaraClock } from './clock.js';
import { createNavaraCredits } from './credits.js';
@@ -62,31 +62,6 @@ function readCamera(raw) {
};
}
// Nearest hit of a ray with the WGS84 ellipsoid raised by `height` metres
// (each radius grown by it), or undefined.
function rayRaisedEllipsoid({ origin, direction }, height) {
const radii = geo.ellipsoid.WGS84.radii;
const rx = radii.x + height;
const ry = radii.y + height;
const rz = radii.z + height;
const o = [origin.x / rx, origin.y / ry, origin.z / rz];
const d = [direction.x / rx, direction.y / ry, direction.z / rz];
const a = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
const b = 2 * (o[0] * d[0] + o[1] * d[1] + o[2] * d[2]);
const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - 1;
const discriminant = b * b - 4 * a * c;
if (discriminant < 0) return undefined;
const root = Math.sqrt(discriminant);
const near = (-b - root) / (2 * a);
const t = near > 0 ? near : (-b + root) / (2 * a);
if (!(t > 0)) return undefined;
return cartesian3.add(
origin,
cartesian3.multiplyByScalar(direction, t, cartesian3.create()),
cartesian3.create(),
);
}
/**
* @param {object} view An initialised Navara ThreeView.
* @param {{
@@ -156,7 +131,7 @@ export function createNavaraEngine(
read: () => readCamera(view.camera.raw),
fly: (pose, options) => view.flyTo(pose, options),
// Navara 0.2.1 has no cancel call: any camera change interrupts a
// flight, and an empty setCamera changes nothing else (S4).
// flight, and an empty setCamera changes nothing else.
stop: () => view.setCamera({}),
size,
requestFrame: () => view.forceUpdate(),
@@ -170,8 +145,7 @@ export function createNavaraEngine(
};
const engineClock = createNavaraClock(appClock ? { appClock } : {});
// camera.follow (src/globe/follow.js): Navara's own cameraFollow takes
// no user rotation or zoom (phase 3 Task 5 probe), so the engine steps
// its own follow each frame.
// no user rotation or zoom, so the engine steps its own follow each frame.
const followStep = createEngineEvent();
const follow = createFollowController({
camera: model.camera,
@@ -259,8 +233,6 @@ export function createNavaraEngine(
// Clicks with { pick } get Navara's GPU pick of the gesture instead.
pick: (screen) => picking.pick(screen),
drillPick: (screen, limit) => picking.drillPick(screen, limit),
// Navara-only diagnostics: how clicks with { pick } were answered.
clickStats: () => picking.clickStats(),
pickPosition(screen) {
const rect = canvas.getBoundingClientRect();
const hit = view.pickDepthPosition(
@@ -282,7 +254,7 @@ export function createNavaraEngine(
geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84),
);
if (!Number.isFinite(height)) break;
const next = rayRaisedEllipsoid(ray, height);
const next = intersectEllipsoid(ray.origin, ray.direction, height);
if (!next) break;
const moved = cartesian3.distance(next, hit);
hit = next;
+2 -4
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../geo/index.js';
import { assertGlobeEngine, engineCapabilities } from '../contract.js';
import { cesiumFovy, orientationVectors } from './cameraMath.js';
import { cesiumFovy, orientationVectors } from '../cameraMath.js';
import { createNavaraEngine } from './createNavaraEngine.js';
const { cartesian2, cartesian3, math } = geo;
@@ -138,7 +138,6 @@ test('the engine clock is the app clock, latched on the view preUpdate before fr
const engine = createNavaraEngine(view, { hooks, appClock });
assert.equal(engine.clock.now(), 2_000_000);
const seen = [];
engine.clock.ticked.addEventListener((time) => seen.push(['ticked', time]));
for (const name of ['preUpdate', 'preRender', 'postRender'])
engine.frames[name].addEventListener(() =>
seen.push([name, engine.clock.frameTime()]),
@@ -149,7 +148,6 @@ test('the engine clock is the app clock, latched on the view preUpdate before fr
view.emit('preRender', 2);
view.emit('postRender', 3);
assert.deepEqual(seen, [
['ticked', 2_000_016],
['preUpdate', 2_000_016],
['preRender', 2_000_016],
['postRender', 2_000_016],
@@ -158,7 +156,7 @@ test('the engine clock is the app clock, latched on the view preUpdate before fr
engine.destroy();
wall += 16;
view.emit('preUpdate', 4);
assert.equal(seen.length, 4, 'no tick after destroy');
assert.equal(seen.length, 3, 'no frame after destroy');
});
test('render requests are forceUpdate and continuous mode is Navara animation', () => {
+8 -20
View File
@@ -44,7 +44,7 @@ import {
} from './viewOptions.js';
// Navara 0.2.1 has no preserveDrawingBuffer option but takes a canvas whose
// WebGL2 context is already created (S4); three then uses that context.
// WebGL2 context is already created; three then uses that context.
function preservedCanvas(container) {
const canvas = document.createElement('canvas');
Object.assign(canvas.style, {
@@ -107,24 +107,13 @@ function groundInView(engine) {
/**
* @param {{
* container: HTMLElement,
* effects?: 'minimal' | 'photoreal' | 'cinematic',
* search?: string,
* pixelRatio?: number,
* appClock?: import('../../time/clock.js').AppClock,
* }} options `effects` names the look (cinematic by default); `search`, the
* page's query string, picks it from `?look=` instead (the app's way).
* `pixelRatio` only for measurements; the product uses the device's.
* `appClock` (the page's by default) is the engine clock's time and so the
* sun's: the look reads the engine clock's frame time.
* }} options `search`, the page's query string, picks the look from
* `?look=` (cinematic by default). The sun follows the engine clock (the
* page's app clock): the look reads its frame time.
* @returns {Promise<{ view: object, engine: import('../contract.js').GlobeEngine, destroy: () => void }>}
*/
export async function createNavaraGlobe({
container,
effects,
search,
pixelRatio,
appClock,
}) {
export async function createNavaraGlobe({ container, search }) {
if (!container) throw new TypeError('createNavaraGlobe needs a container');
const canvas = preservedCanvas(container);
let view;
@@ -136,13 +125,13 @@ export async function createNavaraGlobe({
// Parked for a switch back: its render loop is off (setRenderLoop).
let parked = false;
try {
view = new ThreeView(navaraViewOptions({ container, canvas, pixelRatio }));
view = new ThreeView(navaraViewOptions({ container, canvas }));
const plugin = new DefaultPlugin();
view.addPlugin(plugin);
await view.init();
const automaticLandscape =
!effects && new URLSearchParams(search).get('sun') !== 'live';
const plan = navaraScenePlan(effects ?? resolveNavaraLook(search), {
new URLSearchParams(search).get('sun') !== 'live';
const plan = navaraScenePlan(resolveNavaraLook(search), {
mobile: Boolean(view.isMobileOptimized?.()),
});
// The sun follows the engine clock (one time source for layers and the
@@ -158,7 +147,6 @@ export async function createNavaraGlobe({
engine = createNavaraEngine(view, {
hooks: navaraHooks(view),
container,
...(appClock ? { appClock } : {}),
sharpen: createNavaraSharpen(view),
bloom: createNavaraBloom(view),
clouds: createNavaraCloudCover(look),
+1 -1
View File
@@ -4,7 +4,7 @@
// canvas's top-left corner, and a listener with a modifier fires only while
// that key is held (shift over ctrl over alt), one without only while none is.
//
// Camera inputs map to Navara's controller flags (view.camera.options, S4).
// Camera inputs map to Navara's controller flags (view.camera.options).
// Navara 0.2.1 lets a flyTo run on under user input, where Cesium cancels it;
// `onCameraInput` reports user camera input so the engine can do the same.
+8 -8
View File
@@ -1,8 +1,8 @@
// Every private Navara member the engine relies on, in one place. Written
// against @navaramap/three 0.2.1 (package.json pins it exactly; the test in
// internals.test.mjs fails if the two drift apart): re-check each access below
// before moving to another Navara version. Each names the parity spike that
// found it and the public API that would replace it upstream.
// before moving to another Navara version. Each names the public API that
// would replace it upstream.
import { ModelMesh } from '@navaramap/three';
import { patchCascadedLightCopy } from './cascadedLightCopy.js';
@@ -21,7 +21,7 @@ export function setRenderLoopEnabled(view, enabled) {
let restoreRelease = null;
/**
* S2: keep 3D-tile positions and indices on the CPU after upload so the
* Keeps 3D-tile positions and indices on the CPU after upload so the
* surface ray can hit them. Private: ModelMesh.prototype._releaseGeometryArrays,
* which frees every attribute array once the tile is on the GPU. Patched on
* the prototype, for all views; idempotent. Upstream ask: an option to retain
@@ -56,7 +56,7 @@ export function retainTileGeometry() {
}
/**
* S2: the loaded 3D-tile roots (ModelMesh objects). Private: view._scenes.mrt,
* The loaded 3D-tile roots (ModelMesh objects). Private: view._scenes.mrt,
* the scene Navara renders tiles into. Upstream ask: as for
* retainTileGeometry.
*/
@@ -67,7 +67,7 @@ export function tileRoots(view) {
}
/**
* S2/S5: whether Navara has stopped updating (tiles loaded, camera still).
* Whether Navara has stopped updating (tiles loaded, camera still).
* Private: view._isIdle, which the public `idle` event reports only as an
* edge. Upstream ask: a public isIdle/tilesLoaded getter.
*/
@@ -88,7 +88,7 @@ export function isIdle(view) {
* throws in the dev server and warns once in a build. Upstream ask: a sky
* luminance or atmosphere exposure that leaves unlit content alone.
*/
export const LUMINANCE_UNIFORMS = Object.freeze([
const LUMINANCE_UNIFORMS = Object.freeze([
'SUN_SPECTRAL_RADIANCE_TO_LUMINANCE',
'SKY_SPECTRAL_RADIANCE_TO_LUMINANCE',
]);
@@ -141,7 +141,7 @@ export function forEachLuminanceUniform(handles, visit) {
}
/**
* Phase 3 review (leak): the sun's cascaded shadow lights are re-cloned by
* A leak: the sun's cascaded shadow lights are re-cloned by
* the transparent pass every frame (cascadedLightCopy.js). Finds the
* CascadedDirectionalLights class among the view's scene lights (private:
* view._scenes.light) and patches its copy to update in place. Returns the
@@ -189,7 +189,7 @@ export const navaraHooks = (view) => ({
});
/**
* Stage 6 (QA diagnostics only): the names of the passes in the view's
* QA diagnostics only: the names of the passes in the view's
* post-processing pipeline, in draw order, or null when the path is gone.
* Private: ctx.renderPassOrchestrator.passMap.list ({ name, pass }).
* Upstream ask: a public read of the pipeline order.
+1 -1
View File
@@ -242,7 +242,7 @@ export function buildNavaraLook(
}
/** Navara's own stars intensity, for a look that does not set one. */
export const NAVARA_STARS_INTENSITY = 10;
const NAVARA_STARS_INTENSITY = 10;
const IRRADIANCE_ON = Object.freeze({
aerialPerspective: Object.freeze({ irradiance: true }),
+4 -4
View File
@@ -9,9 +9,9 @@
// - terrain: the keyless Re:Earth quantized mesh, Cesium World Terrain through
// the ion endpoint with an explicit token (Navara sends it as a Bearer
// token), or none (the ellipsoid). Navara 0.2.1 panics on a quantized-mesh
// URL without the {z}/{x}/{y}.terrain template (S2), so URLs always carry it.
// - photoreal: Google 3D tiles with a browser key only in this phase (the
// server proxy of S3 comes later), with Google's logo and the tiles' own
// URL without the {z}/{x}/{y}.terrain template, so URLs always carry it.
// - photoreal: Google 3D tiles with a browser key only (no server proxy),
// with Google's logo and the tiles' own
// copyright lines (creditLayerId) on screen. Showing them hides the base
// imagery, as MapSourceController removes it; clearing them does not bring
// it back until setBaseImagery. Hidden, not deleted: deleting the last
@@ -42,7 +42,7 @@ const ION_WORLD_TERRAIN_ENDPOINT =
const GOOGLE_TILES_ROOT = 'https://tile.googleapis.com/v1/3dtiles/root.json';
// The mark Cesium shows for Google tiles (GoogleMaps.getDefaultCredit).
const GOOGLE_LOGO = 'https://assets.ion.cesium.com/google-credit.png';
// Cesium3DTileset's default maximumScreenSpaceError, as the S1–S5 benches use.
// Cesium3DTileset's default maximumScreenSpaceError.
const PHOTOREAL_MAX_SSE = 16;
const kindOf = (source) => (source?.descriptor ?? source)?.kind;
+1 -1
View File
@@ -7,7 +7,7 @@
// PickableInstancedMeshWrapper so Navara's pick pass sees each instance;
// pickIds.js turns its batch ids back into instances. commit() uploads and
// asks for one frame (view.forceUpdate); per-frame work runs only on frames
// the view draws anyway (S5), so an idle view stays idle.
// the view draws anyway, so an idle view stays idle.
import { withFrameUpdates } from '../frameDescriptor.js';
import {
+5 -6
View File
@@ -1,10 +1,10 @@
// The three side of a Navara model set: the glTF loaded once, one
// InstancedMesh per mesh in it (all instances share the model's meshes),
// each instance placed by position, orientation and scale relative to an
// origin near the camera. Cesium's minimumPixelSize/maximumScale are kept on
// the CPU once per drawn frame (models are few: tracked and proximity
// aircraft), and the instance colour is mixed into the surface colour
// (colorBlend), or the model drawn unlit.
// origin near the camera. Cesium's minimumPixelSize is kept on the CPU once
// per drawn frame (models are few: tracked and proximity aircraft), and the
// instance colour is mixed into the surface colour (colorBlend), or the model
// drawn unlit.
import {
Box3,
@@ -25,7 +25,7 @@ import { createModelTint } from './modelTint.js';
* besides the sun. Navara's sky light alone left it grey-brown at dawn.
* The instance colour is applied after lighting, independently of this share.
*/
export const MODEL_AMBIENT = 0.5;
const MODEL_AMBIENT = 0.5;
/**
* The distance Cesium sizes a model's minimumPixelSize at (Camera
@@ -159,7 +159,6 @@ export function createModelMover(
const pixels = (2 * radius * scale) / (footprint * distance);
if (pixels < model.minimumPixelSize)
scale *= model.minimumPixelSize / pixels;
scale = Math.min(scale, model.maximumScale);
drawnPx[index] = (pixels * scale) / scales[index];
}
scratchTranslation.set(
+1 -1
View File
@@ -23,7 +23,7 @@ export function atlasUvRects(cells, keys) {
}
/** The camera distance from the origin past which the origin moves. */
export const recentreDistance = (cameraHeight) =>
const recentreDistance = (cameraHeight) =>
Math.max(1_000, 0.1 * Math.abs(cameraHeight));
/**
+3 -2
View File
@@ -10,7 +10,8 @@
// depth; their lit display colour is resolved here so HDR effects and MSAA
// cannot spread a tint into neighbouring pixels.
// FIRMS opts into a separate native-stage overlay before sharpening/AA.
// Only that stage owns an encoded-RGBA8 blend target; W1 stays in place.
// Only that stage owns an encoded-RGBA8 blend target; this overlay stays in
// place.
import { withFrameUpdates, withPassDisposal } from './frameDescriptor.js';
import { EffectDesc, Pass as NavaraPass } from '@navaramap/three';
@@ -18,7 +19,7 @@ import { GevOverlayPass } from './overlayPass.js';
import { sonarUniformsFor } from './sonar.js';
import { RGBADepthPacking, Scene } from 'three';
export const OVERLAY_EFFECT_KEY = 'gevOverlay';
const OVERLAY_EFFECT_KEY = 'gevOverlay';
const PRE_SHARPEN_KEY = 'gevPreSharpenOverlay';
const registeredViews = new WeakMap();
+1 -1
View File
@@ -220,7 +220,7 @@ test('empty display overlay does not swap an unwritten frame buffer', () => {
h.pass.dispose();
});
test('ordinary W1 overlays keep the direct pass without a display copy or target', () => {
test('ordinary overlays keep the direct pass without a display copy or target', () => {
const h = harness(),
input = { width: 1280, height: 800, samples: 4, texture: {} };
h.overlay.displayBlend = false;
+3 -21
View File
@@ -52,13 +52,6 @@ export function createNavaraPicking({
// Clicks waiting for Navara's GPU answer, oldest first.
const waiting = [];
// Diagnostics: how clicks were answered and how long it took (ms).
const stats = { gpu: 0, timeout: 0, stale: 0, latencies: [] };
const answered = (entry, how) => {
stats[how]++;
stats.latencies.push(now() - entry.startedAt);
if (stats.latencies.length > 200) stats.latencies.shift();
};
// Navara 0.2.1 answers clicks in quick succession (within a frame or so)
// with the first click's pick: a GPU answer whose instance is not drawn
// near the click is stale, and the CPU pick answers instead.
@@ -70,7 +63,7 @@ export function createNavaraPicking({
// the CPU sees must be drawn under the click (its pick radius, no slack)
// and be the one the CPU finds on top. A stale answer from the previous
// gesture (the followed aircraft at the centre) lay within twice its
// radius of a neighbour's click and won (10b/11b flake). A model hit is
// radius of a neighbour's click and won. A model hit is
// checked against its projected position within its drawn radius
// (pickRadiusPx); the CPU's top hit (drawn order first) is not asked of
// it, as a model is drawn depth-tested among the glyphs.
@@ -83,10 +76,7 @@ export function createNavaraPicking({
(top !== undefined &&
!(top.owner === hit.set && top.instance === hit.instance) &&
(!isModelSet(hit.set) || top.instance === undefined));
if (stale) {
stats.stale++;
return drillPick(at, 1)[0];
}
if (stale) return drillPick(at, 1)[0];
}
return hit
? { id: hit.pickId, owner: hit.set, instance: hit.instance }
@@ -103,7 +93,6 @@ export function createNavaraPicking({
return;
}
cancel(next.timer);
answered(next, 'gpu');
next.resolve(answerFrom(resolveInfo(info), next.at));
};
view.on('featureClick', onFeatureClick);
@@ -119,29 +108,22 @@ export function createNavaraPicking({
clickPick(at) {
const gesture = early;
early = null;
if (gesture && now() - gesture.at <= EARLY_ANSWER_MS) {
const entry = { startedAt: gesture.at };
answered(entry, 'gpu');
if (gesture && now() - gesture.at <= EARLY_ANSWER_MS)
return Promise.resolve(answerFrom(gesture.hit, at));
}
return new Promise((resolve) => {
const entry = {
at: { x: at.x, y: at.y },
resolve,
timer: null,
startedAt: now(),
};
entry.timer = later(() => {
const index = waiting.indexOf(entry);
if (index >= 0) waiting.splice(index, 1);
answered(entry, 'timeout');
resolve(drillPick(entry.at, 1)[0]);
}, timeoutMs);
waiting.push(entry);
});
},
/** How clicks were answered: GPU picks, timeouts, recent latencies (ms). */
clickStats: () => ({ ...stats, latencies: [...stats.latencies] }),
destroy() {
meshPickers.clear();
view.off('featureClick', onFeatureClick);
-33
View File
@@ -162,36 +162,6 @@ test('without a GPU answer in time the click falls back to the CPU pick', async
assert.equal(view.handlers.size, 0);
});
test('click stats count GPU answers and timeouts with their latency', async () => {
const view = fakeView();
const clock = timers();
let time = 0;
const picking = createNavaraPicking({
view,
projector,
camera,
movers: () => [],
lines: () => [],
resolveBatch: () => null,
now: () => time,
...clock,
});
const first = picking.clickPick({ x: 0, y: 0 });
time = 30;
view.emit('featureClick', null);
await first;
const second = picking.clickPick({ x: 0, y: 0 });
time = 280;
clock.pending[0].fn();
await second;
assert.deepEqual(picking.clickStats(), {
gpu: 1,
timeout: 1,
stale: 0,
latencies: [30, 250],
});
});
test("Navara's answer may come before the engine's click of the same gesture", async () => {
const view = fakeView();
const clock = timers();
@@ -247,7 +217,6 @@ test('a GPU answer naming an instance drawn far from the click is stale', async
const answer = picking.clickPick({ x: 10, y: 10 });
view.emit('featureClick', { batchId: 2 });
assert.equal((await answer).id, 'drawn-here');
assert.equal(picking.clickStats().stale, 1);
});
test('a thin line is picked within 8 px of its segments', () => {
@@ -328,7 +297,6 @@ test('a stale GPU answer for a neighbour of the click never wins over the instan
view.emit('featureClick', { batchId: 1 }); // stale: A again
assert.equal((await onB).id, 'b', `gesture ${gesture}: b, not the stale a`);
}
assert.equal(picking.clickStats().stale, 20);
});
function modelSet(id, rows, radiusPx) {
@@ -406,7 +374,6 @@ test('a model hit is checked against its projected position within its drawn rad
const outside = picking.clickPick({ x: 160, y: 100 });
view.emit('featureClick', { batchId: 1 });
assert.equal(await outside, undefined);
assert.equal(picking.clickStats().stale, 1);
// The CPU pick sees the model at its drawn radius, not the 24 px minimum.
assert.equal(picking.drillPick({ x: 121, y: 100 })[0]?.id, 'jet');
assert.equal(picking.drillPick({ x: 125, y: 100 }).length, 0);
+8 -9
View File
@@ -4,15 +4,16 @@
// center + (center − blur) × amount, clamped to [0, 1].
//
// After tone mapping and native-stage fields such as FIRMS, before AA and
// the ordinary W1 display overlay. Native SHARPEN_SHADER filters encoded
// display RGB. Navara buffers stay linear, so use the exact sRGB transfer
// before the box mask and its inverse afterward. A gamma-2.2 approximation
// changes the colors of small, high-contrast glows even at the same amount.
// the ordinary display overlay (overlay.js). Native SHARPEN_SHADER filters
// encoded display RGB. Navara buffers stay linear, so use the exact sRGB
// transfer before the box mask and its inverse afterward. A gamma-2.2
// approximation changes the colors of small, high-contrast glows even at the
// same amount.
//
// The sky is left alone: Navara's clouds are dithered (a fine grain at the
// `low` quality the look uses), and the mask turned that grain into visible
// noise. A pixel whose scene depth (the MRT pass's packed depth buffer, as
// the S1 drape reads it) is the far plane is sky or cloud; it keeps its
// the drape reads it) is the far plane is sky or cloud; it keeps its
// colour, and costs one texture read instead of nine.
//
// Its own pass: a convolution reads the pass input, so merged into the tone
@@ -25,7 +26,7 @@ import { Effect as NavaraEffect, EffectDesc } from '@navaramap/three';
import { Effect, EffectAttribute } from 'postprocessing';
import { RGBADepthPacking, Uniform } from 'three';
export const SHARPEN_EFFECT_KEY = 'gevSharpen';
const SHARPEN_EFFECT_KEY = 'gevSharpen';
const fragmentShader = /* glsl */ `
uniform float amount;
@@ -87,9 +88,7 @@ class UnsharpMaskEffect extends Effect {
}
/** config.gevSharpen: { amount } (Cesium's `amount` uniform). */
export class SharpenEffectDesc extends withPassDisposal(
withFrameUpdates(EffectDesc),
) {
class SharpenEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
static key = SHARPEN_EFFECT_KEY;
static insertAfter = ['toneMapping'];
static insertBefore = ['smaa', 'fxaa', 'gevOverlay', 'final'];
+1 -1
View File
@@ -34,7 +34,7 @@ import {
export const STYLES_EFFECT_KEY = 'gevStyles';
/** Intensity under which a style draws nothing (as VisualEffects disables a stage). */
export const STYLE_VISIBLE_INTENSITY = 0.001;
const STYLE_VISIBLE_INTENSITY = 0.001;
const VERTEX_SHADER = /* glsl */ `precision highp float;
in vec3 position;
+21 -27
View File
@@ -1,4 +1,4 @@
// GlobeSurface on Navara (S2): the height of the rendered scene is a CPU ray
// GlobeSurface on Navara: the height of the rendered scene is a CPU ray
// straight down the ellipsoid normal against the 3D-tile meshes Navara shows.
// Where the shown tiles do not reach (outside the frustum), the loaded
// tiles Navara hides answer, the finest first. Tile positions are float32
@@ -187,15 +187,14 @@ const NO_EXCLUDE = new Set();
// the top one among them. Tiles the ray passes are tried finest first, and
// coarser ones stop being tried once a hit sets the limit, so the coarse
// parents' triangles are never walked.
// With `includeShown` (lod 'finest') the shown tiles take part too.
const hiddenTiles = [];
function finestHiddenHit(roots, exclude, ray, includeShown = false) {
function finestHiddenHit(roots, exclude, ray) {
worldRay.origin.set(ray.origin.x, ray.origin.y, ray.origin.z);
worldRay.direction.set(ray.direction.x, ray.direction.y, ray.direction.z);
hiddenTiles.length = 0;
for (let i = 0; i < roots.length; i++) {
const root = roots[i];
if ((root.visible && !includeShown) || exclude.has(root)) continue;
if (root.visible || exclude.has(root)) continue;
const extent = tileExtent(root);
if (extent && worldRay.intersectsSphere(extent)) hiddenTiles.push(root);
}
@@ -257,33 +256,29 @@ export function createNavaraSurface(view, hooks) {
return height === undefined ? undefined : { height, source: 'terrain' };
};
const sampleHeightDetailed = (place, options) => {
const { exclude, lod } = normalizeSurfaceOptions(options);
const { exclude } = normalizeSurfaceOptions(options);
if (!supported) return terrainDetail(place);
const ray = surfaceRay(place);
const roots = hooks.tileRoots();
const excluded = exclude.length ? new Set(exclude) : NO_EXCLUDE;
let hit = { distance: Infinity, root: null };
if (lod === 'visible') {
// The tiles on screen first; where they do not reach (the ground
// under a camera looking ahead is outside the frustum), the hidden
// ones.
shownMeshes.length = 0;
shownRoots.length = 0;
for (let i = 0; i < roots.length; i++)
collectMeshes(
roots[i],
excluded,
true,
shownMeshes,
shownRoots,
roots[i],
);
hit = nearestHit(shownMeshes, shownRoots, ray);
shownMeshes.length = 0;
shownRoots.length = 0;
}
// The tiles on screen first; where they do not reach (the ground under
// a camera looking ahead is outside the frustum), the hidden ones.
shownMeshes.length = 0;
shownRoots.length = 0;
for (let i = 0; i < roots.length; i++)
collectMeshes(
roots[i],
excluded,
true,
shownMeshes,
shownRoots,
roots[i],
);
let hit = nearestHit(shownMeshes, shownRoots, ray);
shownMeshes.length = 0;
shownRoots.length = 0;
if (!Number.isFinite(hit.distance))
hit = finestHiddenHit(roots, excluded, ray, lod === 'finest');
hit = finestHiddenHit(roots, excluded, ray);
const height = heightAlongRay(ray, hit.distance);
if (height === undefined) return terrainDetail(place);
return { height, source: 'tiles', tileSizeM: tileSizeOf(hit.root) };
@@ -342,7 +337,6 @@ export function createNavaraSurface(view, hooks) {
return {
surface: {
raycast,
raycastSupported: supported,
sampleHeight,
sampleHeightDetailed,
supported,
+1 -4
View File
@@ -230,15 +230,12 @@ test('sampleHeightDetailed names the source and the size of the answering tile',
assert.equal(shown.source, 'tiles');
near(shown.height, 43, 0.05, 'the shown tile');
near(shown.tileSizeM, 600 * Math.SQRT2, 1, 'its size');
const finest = surface.sampleHeightDetailed(place, { lod: 'finest' });
near(finest.height, 40, 0.05, 'the finest loaded tile');
near(finest.tileSizeM, 200 * Math.SQRT2, 1, 'its size');
assert.deepEqual(
surface.sampleHeightDetailed(place, { exclude: [coarse.tile, fine.tile] }),
{ height: 7, source: 'terrain' },
);
assert.throws(
() => surface.sampleHeightDetailed(place, { lod: 'best' }),
() => surface.sampleHeightDetailed(place, { exclude: 'x' }),
TypeError,
);
const bare = setup({ tiles: [] }).surface;
-1
View File
@@ -97,7 +97,6 @@ test('invalid and unsupported surface rays never invent terrain intersections',
undefined,
);
const missing = fixture(t, { supported: false });
assert.equal(missing.surface.raycastSupported, false);
assert.equal(missing.surface.raycast(missing.ray), undefined);
assert.equal(missing.terrainQueries(), 0);
});
+9 -11
View File
@@ -1,11 +1,10 @@
// How the product builds its Navara view (S4, S5): render on demand (the
// engine's render.setContinuous turns animation on), MSAA 4 as Cesium's
// msaaSamples, the device pixel ratio unless a measurement pins one, a canvas
// whose WebGL2 context keeps its drawing buffer (Cesium's
// contextOptions.webgl.preserveDrawingBuffer, for captures), and a look:
// `cinematic` by default (navaraScenePlan below), Navara's own `photoreal`
// scene (aerial perspective, tone mapping and SMAA, about 5 ms a frame, S5)
// with `?look=photoreal`, and `minimal` (sky, stars, sky light and sun
// How the product builds its Navara view: render on demand (the engine's
// render.setContinuous turns animation on), MSAA 4 as Cesium's msaaSamples,
// the device pixel ratio, a canvas whose WebGL2 context keeps its drawing
// buffer (Cesium's contextOptions.webgl.preserveDrawingBuffer, for captures),
// and a look: `cinematic` by default (navaraScenePlan below), Navara's own
// `photoreal` scene (aerial perspective, tone mapping and SMAA, about 5 ms a
// frame) with `?look=photoreal`, and `minimal` (sky, stars, sky light and sun
// without post effects) for benches.
/** WebGL2 attributes Navara 0.2.1 asks for, plus preserveDrawingBuffer. */
@@ -21,14 +20,13 @@ export const NAVARA_CONTEXT_ATTRIBUTES = Object.freeze({
const MSAA_SAMPLES = 4;
/** ThreeView options; `pixelRatio` only when a measurement pins it. */
export function navaraViewOptions({ container, canvas, pixelRatio } = {}) {
/** ThreeView options. */
export function navaraViewOptions({ container, canvas } = {}) {
return {
container,
...(canvas ? { canvas } : {}),
animation: false,
multisampling: MSAA_SAMPLES,
...(pixelRatio !== undefined ? { pixelRatio } : {}),
};
}
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff. Show more