Draw radio, transit and CCTV through one display each

- Radio: stations, clusters, the selection and its brackets are point
  and sprite sets; clusters come from the layer's engine-neutral screen
  clusterer with Cesium's EntityCluster rules, and clicks go through the
  selection dispatcher. One style module holds the sizes and colours.
- Transit: vehicles are two sprite sets from an up-front atlas, rotated
  with Cesium's projected-rotation maths. The selected vehicle's trail is
  engine.lines in two lanes and 29 age bands, so trails are no longer
  draped over terrain until the contract has ground corridors. The
  Cesium contact trail renderer and its re-export go.
- CCTV: markers, wedges, the plane outline and the calibration gizmo
  (rings, arrows, handles, drags through engine.input) draw on the
  contract. The textured monitor plane and the viewshed volume, which no
  primitive expresses yet, sit behind one seam, render/meshes.js, with a
  Cesium backend and a Navara backend the Navara mode passes in.

Known gaps against Cesium: transit trails are not draped, and the
surveillance-lens click correction runs only where the engine reports
its style stage (Navara).

Coupling: layer renderer lines fall from 2638/8735 to 1636/6953
(Cesium/Navara), ScreenSpaceEventHandler files from 15 to 10, `viewer`
lines from 2014 to 1901.
This commit is contained in:
German Khokhlov committed 2026-11-07 04:29:50 +04:00
1 parent fc75e245a6
commit dc1144b947
68 files changed
+6216 -5326

No files matched your search

+20 -24
View File
@@ -207,8 +207,12 @@ one application, and only the globe-bound parts move to the next engine.
`render/navara*.js`, or through one `render/display.js` that runs on every
engine over the globe contract: the aircraft family (flights, military and
local ADS-B) through `src/layers/aircraft/render/display.js`, and AIS
vessels, satellites, Space Missions, ALPR, bikeshare, OSM Places and
Wildlife Sightings through their own `render/display.js`. Cesium
vessels, satellites, Space Missions, ALPR, bikeshare, OSM Places,
Wildlife Sightings, radio, transit and CCTV through their own
`render/display.js`. CCTV's monitor plane and viewshed volume, which the
contract has no primitive for yet, draw behind one seam,
`src/layers/cctv/render/meshes.js` (`render/cesiumMeshes.js` on Cesium;
`render/navaraMeshes.js`, which the Navara mode loads and passes in). Cesium
renderers are listed in `src/app/layers/renderers.js`
(`CESIUM_LAYER_RENDERERS`), Navara ones are loaded by
`registerNavaraLayerRenderers` in `src/app/navaraMode.js`; both register the
@@ -4944,22 +4948,14 @@ zero-duration journey or immediately relocate a visible vehicle.
**Selected history.** Selecting one vehicle draws its available recent path,
clipped to the displayed time. The browser retains up to 15 minutes and 128
fixes per vehicle, under a global 32 MiB history budget. Capacity pressure may
shorten that history. Only the selected vehicle has a trail: completed segments
are batched primitives, and a small moving head ends at the marker. Trail
geometry is bounded to 640 prepared vertices, prioritizing the active corridor
and shortening the oldest geometry first. Active and future marker corridors
retain their own 640-vertex budget; long report intervals use coarser subdivisions
than 25 m instead of dropping an admitted vehicle. Supported scenes use ground polylines
classified against both terrain and 3D tiles, including after map-stack changes,
with per-segment age colours. History changes rebuild the body. The head is one
short draped corridor, clipped to displayed time by a material uniform; only
subdivision crossings rebuild it. Ordinary frames upload no head geometry.
Without ground-polyline support, ordinary primitives retain a mode-coloured
0.55-alpha depth-fail path, including the head, without dimming it to near-black.
Instance attribute updates write back the same typed array returned by Cesium;
its getters return copies, so reading a second time discards a visibility or colour edit.
Normal instance colours follow the age ramp once per display second or style change. Subdivision crossings update only changed visibility
attributes. Deselecting releases selection geometry; marker paths retain only
shorten that history. Only the selected vehicle has a trail, drawn with engine.lines on every
engine: completed segments draw in two lanes (a 5 px dark edge under a 3 px
mode colour), one line per lane for each run of joined segments in one of 29
age bands, re-banded once per displayed second or style change; the live head
runs from its segment's start to the marker and is rewritten only when the
marker moves. Where the scene hides the trail, the colour lane draws at up to
0.55 alpha. Trails are not draped yet (the contract's ground corridor will
drape them), so history appears once its floors resolve. Trail geometry is bounded to 640 prepared vertices, prioritizing the active corridor and shortening the oldest geometry first. Active and future marker corridors retain their own 640-vertex budget; long report intervals use coarser subdivisions than 25 m instead of dropping an admitted vehicle. Deselecting releases selection geometry; marker paths retain only
active and future corridors.
MBTA history is also retained in the running proxy: up to 15 minutes and 128
@@ -5079,7 +5075,7 @@ heap growth ≤24/48 MiB. The 3,000-vehicle CPU budget is 4 ms (recorded browser
measurement 3.4 ms); the 800-vehicle budget stays at 1 ms (measured 0.9 ms).
Transit-authored frame allocation is ≤8 KiB/frame at both fleets, attributed
by source stack to `src/layers/transit/**`, `src/data/contactPlayback.js`,
`src/data/contactTrailRenderer.js` and `src/data/transit*.js`. Whole-frame
`src/data/transit*.js`. Whole-frame
allocation and its three largest allocators are reported without gating:
Cesium billboard buffer uploads and the shared DETECT painter are outside
this layer's allocation budget. **Follow-up: pool shared DETECT placement and
@@ -5161,17 +5157,17 @@ height still choose their own unit.
At global scale, ambient Radio cluster badges are hard-opacity shared-host
entries: count/category updates and identity replacement do not run keyhole or
enter/exit ramps. Shared collision, viewport rejection, allocation, and horizon
culling can still remove an invalid placement, and Cesium still owns the cluster
point geometry and picks. Their 50,000 km line-of-sight range covers the
culling can still remove an invalid placement, and the radio display's cluster
points (screen clusters with Cesium's EntityCluster rules) carry the picks. Their 50,000 km line-of-sight range covers the
supported full-globe camera above 24,000 km, including farther horizon clusters.
Unclustered visible stations publish nearest-first ambient labels through that
same host, capped at 16 labels globally, 32 at intermediate zoom, and 48 nearby;
cluster and singleton candidates still share the Radio source's 64-entry ambient
cohort. Cesium continues to own each 13 px station point, horizon visibility,
and direct/nearby picking. Native points and shared-host text use one 50,000 km
cohort. The display draws each 13 px station point, horizon visibility,
and direct/nearby picking. Points and shared-host text use one 50,000 km
interaction limit, so a painted singleton or cluster label always retains a
pickable point at its anchor throughout the supported high-global view. No
Radio entity uses native Cesium label text.
Radio point uses engine label text.
Delivery constraint: PR #10 is stack-only and unsafe standalone. PR #11 owns
the required Radio lifecycle/authority repair, so PR #10 must not merge or ship
+2
View File
@@ -35,6 +35,8 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
// render/navara*.js. The list grows with each layer moved onto renderers.
const RENDERED_LAYER_DIRS = [
'src/layers/alpr/',
'src/layers/cctv/',
'src/layers/transit/',
'src/layers/airQuality/',
'src/layers/gnss/',
'src/layers/perimeters/',
+8 -8
View File
@@ -1,16 +1,16 @@
{
"metrics": {
"cesiumLayerRendererLines": 2638,
"navaraLayerRendererLines": 8735,
"cesiumLayerRendererLines": 1636,
"navaraLayerRendererLines": 6953,
"cesiumImportFiles": 65,
"viewerLines": 2014,
"navaraKindChecks": 15,
"viewerLines": 1901,
"navaraKindChecks": 12,
"engineSniffCalls": 14,
"engineExtensionReads": 26,
"groundEffectPasses": 8,
"groundEffectPasses": 7,
"cesiumSdkLoads": 6,
"screenSpaceHandlerFiles": 15,
"engineNameLines": 141
"screenSpaceHandlerFiles": 10,
"engineNameLines": 133
},
"measuredAt": "after the vessels, satellites, launches, ALPR, bikeshare and OSM Places displays, 2026-11-07"
"measuredAt": "after the radio, transit and CCTV displays; one engine.extensions read is CCTV's meshes seam, 2026-11-07"
}
+17 -24
View File
@@ -813,8 +813,6 @@
"src/data/transitIcons.js",
"src/data/cameraSensitivity.js",
"src/data/contactPlayback.js",
"src/data/contactTrailRenderer.js",
"src/data/contactTrailRenderer.test.mjs",
"src/layers/transit/trails.js",
"src/data/contactPlayback.test.mjs",
"src/layers/transit/movement.js",
@@ -1247,26 +1245,13 @@
"src/layers/bikeshare/presentation.test.mjs",
"src/layers/bikeshare/selectionModel.js",
"src/layers/cctv/projectionScheduling.test.mjs",
"src/layers/cctv/gizmo.test.mjs",
"src/layers/cctv/render/cesiumGizmo.js",
"src/layers/cctv/render/navara.js",
"src/layers/cctv/render/navara.test.mjs",
"src/layers/cctv/render/navaraMeshes.js",
"src/layers/cctv/render/navaraMeshes.test.mjs",
"src/layers/cctv/render/navaraGizmo.js",
"src/layers/cctv/render/navaraGizmo.test.mjs",
"src/layers/cctv/render/navaraLine.js",
"src/layers/cctv/render/cesium.js",
"src/layers/cctv/render/cesiumViewshed.js",
"src/layers/cctv/render/cesium.test.mjs",
"src/layers/cctv/lifecycle.test.mjs",
"src/layers/cctv/projectionMedia.test.mjs",
"src/layers/cctv/ingestion.test.mjs",
"scripts/fixtures/w2/alpr.mjs",
"scripts/fixtures/w2/cctv.mjs",
"src/layers/radio/render/cesium.js",
"src/layers/radio/render/navara.js",
"src/layers/radio/render/navara.test.mjs",
"src/layers/radio/screenClusters.js",
"src/layers/radio/screenClusters.test.mjs",
"src/maps/sceneApply.js",
@@ -1292,13 +1277,6 @@
"src/globe/cesium/testing/cameraViewer.mjs",
"src/app/globeEngine.js",
"src/app/globeEngine.test.mjs",
"src/layers/transit/render/cesium.js",
"src/layers/transit/render/cesium.test.mjs",
"src/layers/transit/render/navara.js",
"src/layers/transit/render/navaraView.js",
"src/layers/transit/render/navaraSprites.js",
"src/layers/transit/render/navaraTrail.js",
"src/layers/transit/render/navara.test.mjs",
"scripts/fixtures/w2/transit.mjs",
"src/layers/localAdsb/render/display.js",
"src/layers/localAdsb/renderer.test.mjs",
@@ -1412,7 +1390,6 @@
"src/layers/launches/lifecycleSnapshot.test.mjs",
"src/data/render/cesiumProjection.js",
"src/data/render/cesiumTrailRenderer.js",
"src/data/render/cesiumContactTrailRenderer.js",
"src/layers/flights/trackingRefresh.test.mjs",
"scripts/qa-heading-navara.mjs",
"src/layers/aircraft/qaDrawn.test.mjs",
@@ -1616,5 +1593,21 @@
"src/layers/vessels/render/display.test.mjs",
"src/testSupport/vesselViewerRenderer.mjs",
"scripts/qa-wildlife-switch.mjs",
"scripts/fixtures/wildlife.mjs"
"scripts/fixtures/wildlife.mjs",
"src/layers/cctv/render/cesiumMeshes.js",
"src/layers/cctv/render/display.js",
"src/layers/cctv/render/display.test.mjs",
"src/layers/cctv/render/displayGizmo.test.mjs",
"src/layers/cctv/render/meshes.js",
"src/layers/cctv/render/meshes.test.mjs",
"src/layers/cctv/render/style.js",
"src/layers/radio/render/display.js",
"src/layers/radio/render/display.test.mjs",
"src/layers/radio/render/style.js",
"src/layers/transit/render/display.js",
"src/layers/transit/render/display.test.mjs",
"src/layers/transit/render/style.js",
"src/layers/transit/render/testAtlas.mjs",
"src/testSupport/cctvEngines.mjs",
"src/testSupport/radioTestEngine.mjs"
]
File diff suppressed because it is too large. Load diff
+17 -16
View File
@@ -1,11 +1,7 @@
import {
createCctvCesiumRenderer,
bindCctvWorldClickGesture,
} from '../../layers/cctv/render/cesium.js';
import { bindCctvWorldClickGesture } from '../../layers/cctv/render/display.js';
import { createCctvLayer } from '../../layers/cctv/index.js';
import { rendererFor } from './renderers.js';
import * as credits from '../../data/dataCredits.js';
import * as sprites from '../../data/spriteOrder.js';
import * as activation from '../../cctvFocusRequest.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { CITY_POIS } from '../../cityPois.js';
@@ -13,14 +9,16 @@ import * as picking from '../../data/pickRegistry.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */
/**
* Construct one layer using the application scene owners and a supplied
* source; it draws with the engine's renderer from the registry
* (render/display.js on every engine).
*/
export function createApplicationCctv({
surface,
source,
rendererFactory = ({ engine, ...context }) =>
engine?.kind === 'navara'
? rendererFor('cctv', engine, context)
: createCctvCesiumRenderer(context),
rendererFor('cctv', engine, context),
}) {
const { terrain, groundFloor: ground, meshFloor: mesh } = surface;
const layer = createCctvLayer({
@@ -28,7 +26,6 @@ export function createApplicationCctv({
rendererFactory,
services: {
credits,
sprites,
activation,
overlays,
locations: { CITY_POIS },
@@ -43,13 +40,17 @@ export function createApplicationCctv({
return {
...layer,
bindCctvWorldClickGesture,
// Legacy native helper; production navigation uses the initialized owner.
focusCctvRecord(viewer, record, duration) {
const renderer = viewer
? createCctvCesiumRenderer({ services: {}, state: {} })
// Legacy helper; production navigation uses the initialized owner.
focusCctvRecord(engine, record, duration) {
const display = engine
? rendererFor('cctv', engine, { services: {}, state: {} })
: null;
renderer?.attach(viewer);
return layer.focusCctvRecord(renderer, record, duration);
display?.attach();
try {
return layer.focusCctvRecord(display, record, duration);
} finally {
display?.destroy();
}
},
};
}
+6 -5
View File
@@ -1,4 +1,3 @@
import { createRadioCesiumRenderer } from '../../layers/radio/render/cesium.js';
import { createRadioLayer } from '../../layers/radio/index.js';
import { rendererFor } from './renderers.js';
import * as picking from '../../data/pickRegistry.js';
@@ -6,14 +5,16 @@ import * as overlays from '../../overlays/worldOverlay.js';
import * as globe from '../../celestialRing.js';
import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */
/**
* Construct one layer using the application scene owners and a supplied
* source; it draws with the engine's renderer from the registry
* (render/display.js on every engine).
*/
export function createApplicationRadio({
surface,
source,
rendererFactory = ({ engine, ...context }) =>
engine?.kind === 'navara'
? rendererFor('radio', engine, context)
: createRadioCesiumRenderer(context),
rendererFor('radio', engine, context),
}) {
const { groundFloor: ground } = surface;
return createRadioLayer({
+6
View File
@@ -40,6 +40,9 @@ import { createLaunchesDisplay } from '../../layers/launches/render/display.js';
import { createOsmPlacesDisplay } from '../../layers/osmPlaces/render/display.js';
import { createAlprDisplay } from '../../layers/alpr/render/display.js';
import { createBikeshareDisplay } from '../../layers/bikeshare/render/display.js';
import { createRadioDisplay } from '../../layers/radio/render/display.js';
import { createTransitDisplay } from '../../layers/transit/render/display.js';
import { createCctvDisplay } from '../../layers/cctv/render/display.js';
const ENGINE_KINDS = new Set(['cesium', 'navara']);
@@ -109,6 +112,8 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({
'osm-places': createOsmPlacesDisplay,
'alpr-cameras': createAlprDisplay,
bikeshare: createBikeshareDisplay,
radio: createRadioDisplay,
transit: createTransitDisplay,
'local-firms': createFirmsCesiumRenderer,
'rocket-launches': createLaunchesDisplay,
satellites: createSatellitesDisplay,
@@ -116,6 +121,7 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({
military: createMilitaryDisplay,
'local-adsb': createLocalAdsbDisplay,
'ais-live-vessels': createVesselsDisplay,
cctv: createCctvDisplay,
});
// The other application layers still draw themselves on Cesium.
+5
View File
@@ -68,6 +68,8 @@ test('the layers with extracted renderers', () => {
'osm-places',
'alpr-cameras',
'bikeshare',
'radio',
'transit',
'local-firms',
'rocket-launches',
'satellites',
@@ -75,6 +77,7 @@ test('the layers with extracted renderers', () => {
'military',
'local-adsb',
'ais-live-vessels',
'cctv',
]);
});
@@ -89,6 +92,8 @@ test('the layers drawn through one display get the same factory on every engine'
'osm-places': ['osmPlaces', 'createOsmPlacesDisplay'],
'alpr-cameras': ['alpr', 'createAlprDisplay'],
bikeshare: ['bikeshare', 'createBikeshareDisplay'],
radio: ['radio', 'createRadioDisplay'],
transit: ['transit', 'createTransitDisplay'],
};
// The Navara mode registers the same modules' factories.
const navaraMode = readFileSync(
+3 -7
View File
@@ -1,8 +1,6 @@
import { createTransitLayer } from '../../layers/transit/index.js';
import { createTransitCesiumRenderer } from '../../layers/transit/render/cesium.js';
import { rendererFor } from './renderers.js';
import * as render from '../../renderGovernor.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import {
@@ -19,7 +17,8 @@ import {
* from `surface.meshFloor`, so a mesh floor the application resolves for a
* cell is the floor transit draws on, and a transit-only session warms its
* own mesh floors. An earlier cut imported the legacy module-level singleton
* instead and could not see either.
* instead and could not see either. It draws with the engine's renderer from
* the registry (render/display.js on every engine).
*
* @param {{surface: object}} options
* @returns {object}
@@ -28,9 +27,7 @@ export function createApplicationTransit({
surface,
source,
rendererFactory = ({ engine, ...context }) =>
engine?.kind === 'navara'
? rendererFor('transit', engine, context)
: createTransitCesiumRenderer({ ...context, engine }),
rendererFor('transit', engine, context),
}) {
if (!surface?.groundFloor || !surface?.meshFloor)
throw new TypeError('Transit needs the application surface services');
@@ -40,7 +37,6 @@ export function createApplicationTransit({
rendererFactory,
services: {
render,
sprites,
picking,
overlays,
credits: { registerDynamicCredit, transitFeedCredit },
+12 -15
View File
@@ -103,29 +103,26 @@ export async function registerNavaraLayerRenderers() {
...engine.extensions.navara,
});
},
transit: async () => {
const { createTransitNavaraRenderer } =
await import('../layers/transit/render/navara.js');
return (engine, options) =>
createTransitNavaraRenderer(engine, {
...options,
...engine.extensions.navara,
});
},
transit: async () =>
(await import('../layers/transit/render/display.js'))
.createTransitDisplay,
// CCTV's display, with the Navara backend of its plane and volume.
cctv: async () => {
const { createCctvNavaraRenderer } =
await import('../layers/cctv/render/navara.js');
const [{ createCctvDisplay }, { createCctvNavaraMeshes }] =
await Promise.all([
import('../layers/cctv/render/display.js'),
import('../layers/cctv/render/navaraMeshes.js'),
]);
return (engine, options) =>
createCctvNavaraRenderer(engine, {
createCctvDisplay(engine, {
...options,
...engine.extensions.navara,
navaraMeshes: createCctvNavaraMeshes,
});
},
'alpr-cameras': async () =>
(await import('../layers/alpr/render/display.js')).createAlprDisplay,
radio: async () =>
(await import('../layers/radio/render/navara.js'))
.createRadioNavaraRenderer,
(await import('../layers/radio/render/display.js')).createRadioDisplay,
bikeshare: async () =>
(await import('../layers/bikeshare/render/display.js'))
.createBikeshareDisplay,
+96 -136
View File
@@ -27,6 +27,9 @@ function componentFunctionSource(fn) {
}
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../testSupport/cesiumMoversEngine.mjs';
import cctvLayer, {
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
_createCctvProjectionPlaneForTest,
@@ -291,29 +294,12 @@ test('default COVERAGE refresh materializes the active and visible camera frustu
coverageEntities: [],
projection: null,
}));
const inserted = [];
const viewer = {
entities: {
add(entity) {
inserted.push(entity);
return entity;
},
getById(id) {
return inserted.find((entity) => entity.id === id);
},
remove(entity) {
const index = inserted.indexOf(entity);
if (index < 0) return false;
inserted.splice(index, 1);
return true;
},
},
};
const engine = createFakeEngine();
const activeRecord = records.at(-1);
try {
_setCctvCoverageStateForTest({
viewer,
engine,
records,
activeCameraId: activeRecord.camera.id,
});
@@ -323,7 +309,7 @@ test('default COVERAGE refresh materializes the active and visible camera frustu
(record) => record.coverageEntities.length > 0,
);
assert.equal(
inserted.length,
engine.lines.list().length,
14 * 5,
'default COVERAGE ON builds the 14-camera visible cohort',
);
@@ -345,7 +331,7 @@ test('default COVERAGE refresh materializes the active and visible camera frustu
projection: { planeEntity: { show: false } },
};
_setCctvCoverageStateForTest({
viewer,
engine,
records: [projectionOnly],
activeCameraId: projectionOnly.camera.id,
coverageMode: 'off',
@@ -372,13 +358,13 @@ test('camera icons are violet for video feeds, cyan for stills, amber when activ
const idleVideo = record('idle-hls', 'hls');
const idleStill = record('idle-image', 'image');
const activeVideo = record('active-hls', 'hls');
const viewer = { entities: { add: (entity) => entity, getById: () => undefined } };
const engine = createFakeEngine();
try {
// Disabled keeps the active video camera from opening a real projection;
// icon colors are assigned either way.
_setCctvCoverageStateForTest({
viewer,
engine,
records: [idleVideo, idleStill, activeVideo],
activeCameraId: activeVideo.camera.id,
enabled: false,
@@ -751,7 +737,7 @@ test('real active monitor plane owns one protected host label and no native labe
setVisible: (...args) => calls.push(['visible', ...args]),
clearSource: (...args) => calls.push(['clear', ...args]),
};
const viewer = { entities: new Cesium.EntityCollection() };
const { engine, viewer } = createCesiumMoversEngine();
const record = {
camera: {
...UNCLAMPED_CAMERA,
@@ -764,9 +750,9 @@ test('real active monitor plane owns one protected host label and no native labe
};
_setCctvOverlayHostForTest(overlayHost);
try {
const runtime = _createCctvProjectionPlaneForTest(viewer, record);
const runtime = _createCctvProjectionPlaneForTest(engine, record);
_setCctvCoverageStateForTest({
viewer,
engine,
records: [record],
activeCameraId: record.camera.id,
enabled: true,
@@ -777,13 +763,11 @@ test('real active monitor plane owns one protected host label and no native labe
assert.ok(
runtime.planeEntity,
'runtime guard requires a real monitor-plane entity',
);
assert.equal(runtime.planeEntity.label, undefined);
assert.ok(
runtime.planeEntity.plane,
'monitor plane geometry remains native',
'runtime guard requires a real monitor plane',
);
const planeEntity = viewer.entities.getById(runtime.planeEntity.id);
assert.equal(planeEntity.label, undefined);
assert.ok(planeEntity.plane, 'monitor plane geometry remains native');
assert.ok(
viewer.entities.values.every((entity) => entity.label === undefined),
);
@@ -821,7 +805,7 @@ test('real active monitor plane owns one protected host label and no native labe
);
_setCctvCoverageStateForTest({
viewer,
engine,
records: [record],
activeCameraId: null,
enabled: false,
@@ -830,6 +814,7 @@ test('real active monitor plane owns one protected host label and no native labe
});
refreshCoverageStyles();
assert.equal(runtime.planeEntity.show, false);
assert.equal(planeEntity.show, false);
assert.deepEqual(calls.slice(-2), [
['clear', 'cctv-projection'],
['visible', 'cctv-projection', false],
@@ -837,6 +822,7 @@ test('real active monitor plane owns one protected host label and no native labe
} finally {
_setCctvCoverageStateForTest({ enabled: false });
_setCctvOverlayHostForTest();
engine.destroy();
}
});
@@ -886,16 +872,19 @@ test('CCTV disable→enable defers the active-camera re-probe until its next act
);
});
test('CCTV focus distinguishes tracking ownership from a missing active camera', () => {
let flyCalls = 0;
const viewer = {
trackedEntity: { id: 'tracked-flight' },
camera: {
flyToBoundingSphere() {
flyCalls += 1;
},
},
/** A globe engine whose sphere flights are counted, following `following`. */
function focusEngine(following = null) {
const engine = createFakeEngine();
engine.camera.following = () => following;
engine.flights = 0;
engine.camera.flyToSphere = () => {
engine.flights += 1;
};
return engine;
}
test('CCTV focus distinguishes tracking ownership from a missing active camera', () => {
const engine = focusEngine({ name: 'tracked-flight' });
const record = {
camera: { rangeM: 210, headingDeg: 41 },
position: { x: 1, y: 2, z: 3 },
@@ -904,49 +893,38 @@ test('CCTV focus distinguishes tracking ownership from a missing active camera',
console.debug = () => {};
try {
assert.equal(
focusCctvRecord(viewer, record, 1.9),
focusCctvRecord(engine, record, 1.9),
CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW,
);
} finally {
console.debug = originalDebug;
}
assert.equal(flyCalls, 0);
assert.equal(engine.flights, 0);
assert.equal(
focusCctvRecord(viewer, null, 1.9),
focusCctvRecord(engine, null, 1.9),
CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA,
);
assert.equal(
focusCctvRecord(null, record, 1.9),
CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA,
);
});
test('CCTV focus reports when the camera flight starts', () => {
let flyCalls = 0;
const viewer = {
trackedEntity: null,
camera: {
flyToBoundingSphere() {
flyCalls += 1;
},
},
};
const engine = focusEngine();
const record = {
camera: { rangeM: 210, headingDeg: 41 },
position: { x: 1, y: 2, z: 3 },
};
assert.equal(focusCctvRecord(viewer, record, 1.9), CCTV_FOCUS_RESULT.FOCUSED);
assert.equal(flyCalls, 1);
assert.equal(focusCctvRecord(engine, record, 1.9), CCTV_FOCUS_RESULT.FOCUSED);
assert.equal(engine.flights, 1);
assert.equal(engine.movers.list().length + engine.lines.list().length, 0);
});
test('CCTV focus refuses camera flights while cockpit owns the view', () => {
const originalDocument = globalThis.document;
let flyCalls = 0;
const viewer = {
trackedEntity: null,
camera: {
flyToBoundingSphere() {
flyCalls += 1;
},
},
};
const engine = focusEngine();
const record = {
camera: { rangeM: 210, headingDeg: 41 },
position: { x: 1, y: 2, z: 3 },
@@ -959,14 +937,14 @@ test('CCTV focus refuses camera flights while cockpit owns the view', () => {
try {
assert.equal(
focusCctvRecord(viewer, record, 1.9),
focusCctvRecord(engine, record, 1.9),
CCTV_FOCUS_RESULT.COCKPIT_ACTIVE,
);
} finally {
console.debug = originalDebug;
globalThis.document = originalDocument;
}
assert.equal(flyCalls, 0);
assert.equal(engine.flights, 0);
});
// Both initial mount and live engine switches install the same click handler.
@@ -1026,33 +1004,21 @@ test('CCTV repeated in-world clicks dispatch focus only for the one real activat
// ─── Empty-space deselection and stable null-active state ──────────────────
function makeDeselectViewer() {
return {
entities: new Cesium.EntityCollection(),
isDestroyed: () => false,
trackedEntity: { id: 'sibling-track-owner' },
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 4_000),
positionCartographic: Cesium.Cartographic.fromDegrees(
-97.7431,
30.2672,
4_000,
),
heading: 0.4,
pitch: -0.7,
roll: 0.1,
transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
flyToBoundingSphere() {
this.flyCalls = (this.flyCalls || 0) + 1;
},
},
scene: {
canvas: { clientWidth: 1200, clientHeight: 800 },
cartesianToCanvasCoordinates: () => undefined,
primitives: { add: (primitive) => primitive, remove: () => true },
requestRender() {},
},
};
/** A globe engine over Austin whose camera a sibling layer follows. */
function makeDeselectEngine() {
const engine = createFakeEngine({
longitude: toRad(-97.7431),
latitude: toRad(30.2672),
altitude: 4_000,
width: 1200,
height: 800,
});
engine.fake.script('worldToWindow', undefined);
engine.camera.follow({
position: () => geo.cartesian3.fromDegrees(-97.7431, 30.2672, 0),
viewFrom: { x: 0, y: -300, z: 300 },
});
return engine;
}
function makeDeselectRecord(id, index = 0) {
@@ -1143,30 +1109,26 @@ test('CCTV empty-click gate excludes every identified scene object, ADJUST, and
});
test('CCTV camera extraction requires layer ownership, not a colliding sibling ID', () => {
const billboard = { id: 'shared-id' };
const ownedCoverageEntity = {
id: 'cctv-shared-id-cap',
properties: { cctvCameraId: 'shared-id' },
};
const record = {
...makeDeselectRecord('shared-id'),
billboard,
coverageEntities: [ownedCoverageEntity],
};
const engine = createFakeEngine();
const record = makeDeselectRecord('shared-id');
_setCctvCoverageStateForTest({
engine,
records: [record],
activeCameraId: 'shared-id',
});
try {
refreshCoverageStyles();
const cap = record.coverageEntities.find(
(line) => line._coverageRole === 'cap',
);
assert.equal(cap.id, 'cctv-shared-id-cap');
assert.equal(
_extractPickedCameraIdForTest({ id: 'shared-id', primitive: billboard }),
_extractPickedCameraIdForTest({ id: 'shared-id', owner: 'cctv' }),
'shared-id',
"a pick of this layer's marker set activates its camera",
);
assert.equal(
_extractPickedCameraIdForTest({
id: 'shared-id',
primitive: { id: 'shared-id' },
}),
_extractPickedCameraIdForTest({ id: 'shared-id', owner: 'sibling' }),
null,
'a sibling primitive with the same canonical ID cannot activate CCTV',
);
@@ -1176,9 +1138,14 @@ test('CCTV camera extraction requires layer ownership, not a colliding sibling I
'a sibling Entity with the same canonical ID cannot activate CCTV',
);
assert.equal(
_extractPickedCameraIdForTest({ id: ownedCoverageEntity }),
_extractPickedCameraIdForTest({ id: cap.id, owner: cap.id }),
'shared-id',
'an exact stored CCTV coverage Entity retains activation ownership',
'an exact stored CCTV coverage line retains activation ownership',
);
assert.equal(
_extractPickedCameraIdForTest({ id: cap.id, owner: 'sibling-line' }),
null,
'a sibling line cannot borrow the coverage pick id',
);
assert.equal(
_extractPickedCameraIdForTest({
@@ -1196,7 +1163,7 @@ test('CCTV camera extraction requires layer ownership, not a colliding sibling I
});
test('CCTV deselect publishes one null state and leaves the complete camera pose unchanged', () => {
const viewer = makeDeselectViewer();
const engine = makeDeselectEngine();
const record = {
...makeDeselectRecord('deselect-me'),
probeClampRangeM: 150,
@@ -1209,9 +1176,9 @@ test('CCTV deselect publishes one null state and leaves the complete camera pose
hitTest: () => null,
});
try {
const runtime = _createCctvProjectionPlaneForTest(viewer, record);
const runtime = _createCctvProjectionPlaneForTest(engine, record);
_setCctvCoverageStateForTest({
viewer,
engine,
records: [record],
activeCameraId: record.camera.id,
enabled: true,
@@ -1223,17 +1190,10 @@ test('CCTV deselect publishes one null state and leaves the complete camera pose
publications.push(state),
);
const baselinePublications = publications.length;
const pose = {
positionWC: Cesium.Cartesian3.clone(viewer.camera.positionWC),
positionCartographic: Cesium.Cartographic.clone(
viewer.camera.positionCartographic,
),
heading: viewer.camera.heading,
pitch: viewer.camera.pitch,
roll: viewer.camera.roll,
transform: Cesium.Matrix4.clone(viewer.camera.transform),
trackedEntity: viewer.trackedEntity,
};
const pose = engine.camera.pose();
const transform = engine.camera.transform();
const following = engine.camera.following();
engine.fake.resetCalls();
assert.equal(deactivateActiveCamera(), true);
assert.equal(
@@ -1248,17 +1208,17 @@ test('CCTV deselect publishes one null state and leaves the complete camera pose
assert.equal(runtime.planeEntity.show, false);
assert.equal(record.probeClampRangeM, null);
assert.equal(record.activationDone, false);
assert.deepEqual(viewer.camera.positionWC, pose.positionWC);
assert.deepEqual(engine.camera.pose(), pose);
assert.deepEqual(engine.camera.transform(), transform);
assert.equal(engine.camera.following(), following);
assert.ok(following, 'the sibling still follows its target');
assert.deepEqual(
viewer.camera.positionCartographic,
pose.positionCartographic,
engine.fake.calls
.map(({ method }) => method)
.filter((method) => method.startsWith('camera.')),
[],
'no camera command',
);
assert.equal(viewer.camera.heading, pose.heading);
assert.equal(viewer.camera.pitch, pose.pitch);
assert.equal(viewer.camera.roll, pose.roll);
assert.deepEqual(viewer.camera.transform, pose.transform);
assert.equal(viewer.trackedEntity, pose.trackedEntity);
assert.equal(viewer.camera.flyCalls || 0, 0);
unsubscribe();
} finally {
_setCctvOverlayHostForTest();
@@ -1267,7 +1227,7 @@ test('CCTV deselect publishes one null state and leaves the complete camera pose
});
test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay honest', () => {
const viewer = makeDeselectViewer();
const engine = makeDeselectEngine();
const records = Array.from({ length: 3 }, (_, index) =>
makeDeselectRecord(`cam-${index}`, index),
);
@@ -1278,7 +1238,7 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
});
try {
_setCctvCoverageStateForTest({
viewer,
engine,
records,
activeCameraId: records[0].camera.id,
enabled: true,
+23 -26
View File
@@ -5,6 +5,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import cctvLayer, {
_setCctvCoverageStateForTest,
_viewRecordIdsForTest,
@@ -48,22 +49,18 @@ function recordsAt(
return { records, screenByPosition };
}
function viewerAt(heightM, screenByPosition, shiftX = 0) {
return {
isDestroyed: () => false,
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, heightM),
positionCartographic: { latitude: 0, longitude: 0, height: heightM },
frustum: { fovy: Math.PI / 3 },
},
scene: {
canvas: { clientWidth: WIDTH, clientHeight: HEIGHT },
cartesianToCanvasCoordinates: (position) => {
const screen = screenByPosition.get(position);
return screen && { x: screen.x + shiftX, y: screen.y };
},
},
};
/** A globe engine over lon 0, lat 0 that draws each record where `screenByPosition` says. */
function engineAt(heightM, screenByPosition, shiftX = 0) {
const engine = createFakeEngine({
width: WIDTH,
height: HEIGHT,
altitude: heightM,
});
engine.fake.script('worldToWindow', (position) => {
const screen = screenByPosition.get(position);
return screen && { x: screen.x + shiftX, y: screen.y };
});
return engine;
}
function shownIds(records) {
@@ -84,7 +81,7 @@ const CLUSTER = [
test('zoomed out, cameras sharing a ground cell show one icon', () => {
const { records, screenByPosition } = recordsAt(CLUSTER);
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
engine: engineAt(20_000, screenByPosition),
records,
});
assert.equal(
@@ -104,7 +101,7 @@ test('a pan keeps the same winners, and an unchanged view keeps its list', () =>
y: 300,
}));
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
engine: engineAt(20_000, screenByPosition),
records,
});
refreshHorizonCulling();
@@ -112,7 +109,7 @@ test('a pan keeps the same winners, and an unchanged view keeps its list', () =>
const list = _viewRecordIdsForTest();
for (const shiftX of [3, 11, 20, 37]) {
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition, shiftX),
engine: engineAt(20_000, screenByPosition, shiftX),
records,
});
refreshHorizonCulling();
@@ -126,7 +123,7 @@ test('a small zoom within one cell size keeps the same winners', () => {
const { records, screenByPosition } = recordsAt(CLUSTER);
const shownAt = (heightM) => {
_setCctvCoverageStateForTest({
viewer: viewerAt(heightM, screenByPosition),
engine: engineAt(heightM, screenByPosition),
records,
});
refreshHorizonCulling();
@@ -142,7 +139,7 @@ test('a healthy camera wins its cell over an unknown one', () => {
// Whichever camera the id order would pick, the healthy one must win.
for (const id of ['cam-0', 'cam-1']) {
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
engine: engineAt(20_000, screenByPosition),
records,
healthById: new Map([[id, { status: 'ok' }]]),
});
@@ -155,7 +152,7 @@ test('the active camera always shows, even in a crowded cell', () => {
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 3));
for (const active of records) {
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
engine: engineAt(20_000, screenByPosition),
records,
activeCameraId: active.camera.id,
});
@@ -175,7 +172,7 @@ test('close up, every camera near the screen shows and far-off ones hide', () =>
index === 2 ? { x: WIDTH * 3, y: HEIGHT / 2 } : { x: 100, y: 100 },
);
_setCctvCoverageStateForTest({
viewer: viewerAt(300, screenByPosition),
engine: engineAt(300, screenByPosition),
records,
});
refreshHorizonCulling();
@@ -185,7 +182,7 @@ test('close up, every camera near the screen shows and far-off ones hide', () =>
test('a disabled layer skips the view pass', () => {
const { records, screenByPosition } = recordsAt(CLUSTER);
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
engine: engineAt(20_000, screenByPosition),
records,
enabled: false,
});
@@ -196,7 +193,7 @@ test('a disabled layer skips the view pass', () => {
test('UI state builds the full camera list only when it is read', () => {
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
_setCctvCoverageStateForTest({
viewer: viewerAt(300, screenByPosition),
engine: engineAt(300, screenByPosition),
records,
});
refreshHorizonCulling();
@@ -236,7 +233,7 @@ test('the camera picker lists the view, with the active camera first', () => {
(index) => (index === 1 ? { x: WIDTH * 3, y: 0 } : { x: 100, y: 100 }),
);
_setCctvCoverageStateForTest({
viewer: viewerAt(300, screenByPosition),
engine: engineAt(300, screenByPosition),
records,
activeCameraId: 'cam-1',
});
-4
View File
@@ -1,4 +0,0 @@
export {
createContactTrailRenderer,
trailAlpha,
} from './render/cesiumContactTrailRenderer.js';
-357
View File
@@ -1,357 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { readFileSync } from 'node:fs';
import { TRAIL_VERTEX_LIMIT } from '../layers/transit/trails.js';
import {
createContactTrailRenderer,
trailAlpha,
} from './contactTrailRenderer.js';
function fixture(t, ground = false) {
for (const key of [
'HTMLCanvasElement',
'HTMLImageElement',
'ImageBitmap',
'OffscreenCanvas',
]) {
const prior = globalThis[key];
globalThis[key] = class {};
t.after(() => {
if (prior === undefined) delete globalThis[key];
else globalThis[key] = prior;
});
}
const width = Cesium.ContextLimits._maximumAliasedLineWidth;
Cesium.ContextLimits._maximumAliasedLineWidth = 1;
t.after(() => {
Cesium.ContextLimits._maximumAliasedLineWidth = width;
});
const primitives = new Cesium.PrimitiveCollection();
t.after(() => primitives.destroy());
const groundPrimitives = new Cesium.PrimitiveCollection();
t.after(() => groundPrimitives.destroy());
return {
primitives,
groundPrimitives,
frameState: { context: { depthTexture: ground } },
globe: { show: false },
};
}
test('body clips future subdivisions, head ends at exact marker, and frames retain geometry', (t) => {
const scene = fixture(t),
renderer = createContactTrailRenderer(scene);
const positions = [0, 1, 2].map((i) =>
Cesium.Cartesian3.fromDegrees(-71, 42 + i * 0.001, 11.5),
);
renderer.replaceHistory({
revision: 1,
segments: [
{
fromSeq: 0,
toSeq: 1,
fromT: 0,
toT: 10000,
positions: positions.slice(0, 2),
},
{
fromSeq: 0,
toSeq: 1,
fromT: 10000,
toT: 20000,
positions: positions.slice(1, 3),
},
],
});
const { body } = renderer.diagnostics();
assert.equal(body.allowPicking, false);
// The constructor defers this check until shader preparation. Run the real
// Cesium validator without WebGL, including a missing-attribute control.
const source = body.depthFailAppearance.vertexShaderSource;
body._batchTableAttributeIndices = { color: 0 };
assert.throws(
() => Cesium.Primitive._updateColorAttribute(body, source, true),
/depthFailColor/,
);
for (const instance of body.geometryInstances) {
assert.deepEqual(
instance.attributes.depthFailColor.value,
Cesium.ColorGeometryInstanceAttribute.toValue(
Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55),
),
);
}
body._batchTableAttributeIndices = { color: 0, depthFailColor: 1 };
assert.doesNotThrow(() =>
Cesium.Primitive._updateColorAttribute(body, source, true),
);
const attributes = new Map(
body.geometryInstances.map((instance) => [
instance.id,
{
show: new Uint8Array([0]),
color: new Uint8Array(4),
depthFailColor: new Uint8Array(4),
},
]),
);
// Simulate Cesium's completed upload; exercise public attribute writes.
body._ready = true;
body.getGeometryInstanceAttributes = (id) => {
const stored = attributes.get(id),
accessor = {};
for (const name of ['show', 'color', 'depthFailColor'])
Object.defineProperty(accessor, name, {
get: () => stored[name].slice(),
set: (value) => {
stored[name] = value.slice();
},
});
return accessor;
};
const marker = Cesium.Cartesian3.lerp(
positions[1],
positions[2],
0.5,
new Cesium.Cartesian3(),
);
const sample = { displayT: 15000, fromSeq: 0, toSeq: 1, fraction: 0.75 };
for (let i = 0; i < 120; i++) renderer.setDisplaySample(sample, marker);
assert.deepEqual(
[...attributes.values()].map((a) => a.show[0]),
[1, 1, 0, 0],
);
for (const a of attributes.values())
assert.deepEqual(
a.depthFailColor,
Cesium.ColorGeometryInstanceAttribute.toValue(
Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55),
),
);
const result = renderer.diagnostics();
assert.equal(result.body, body);
assert.equal(result.rebuilds, 1);
assert.equal(result.headRebuilds, 1, '120 frames retain head geometry');
assert.equal(result.head.material.uniforms.fraction, 0.5);
assert.equal(result.head.width, 3);
assert.equal(result.backing.width, 5);
assert.ok(Cesium.Cartesian3.equals(result.head.positions.at(-1), marker));
assert.equal(result.head.positions.length, 2);
renderer.setDisplaySample(
{ ...sample, displayT: 5000, fraction: 0.25 },
positions[0],
);
assert.ok(
[...attributes.values()].every((a) => a.show[0] === 0),
'rewind hides future geometry again',
);
renderer.destroy();
assert.equal(scene.primitives.length, 0);
});
test('trail age curve uses the specified four alpha anchors', () => {
assert.equal(trailAlpha(0), 0.8);
assert.equal(trailAlpha(120000), 0.45);
assert.equal(trailAlpha(600000), 0.2);
assert.equal(trailAlpha(900000), 0.08);
assert.ok(Math.abs(trailAlpha(60000) - 0.625) < 1e-10);
});
test('ground body and clipped head stay inside rebuild and frame geometry budgets', async () => {
Cesium.ApproximateTerrainHeights._terrainHeights = JSON.parse(
readFileSync(
new URL(
import.meta
.resolve('@cesium/engine/Source/Assets/approximateTerrainHeights.json'),
),
),
);
const positions = [
Cesium.Cartesian3.fromDegrees(-97.7431, 30.267),
Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672),
];
const start = performance.now();
const instances = async (count) =>
Promise.all(
Array.from(
{ length: count },
async (_, i) =>
new Cesium.GeometryInstance({
id: i,
geometry: await Cesium.GroundPolylineGeometry.createGeometry(
new Cesium.GroundPolylineGeometry({
positions,
width: i % 2 ? 3 : 5,
granularity: 0,
}),
),
}),
),
);
const pipelineBytes = async (count) => {
// Match GroundPolylinePrimitive's internal Primitive options and the
// application's morph-capable scene, including batch IDs and encoding.
const result = Cesium.PrimitivePipeline.combineGeometry({
instances: await instances(count),
projection: new Cesium.GeographicProjection(),
ellipsoid: Cesium.Ellipsoid.WGS84,
modelMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
elementIndexUintSupported: true,
scene3DOnly: false,
vertexCacheOptimize: false,
compressVertices: false,
createPickOffsets: false,
});
return result.geometries.reduce(
(sum, geometry) =>
sum +
Object.values(geometry.attributes).reduce(
(n, a) => n + (a?.values?.byteLength || 0),
0,
) +
geometry.indices.byteLength,
0,
);
};
const bytes = await pipelineBytes(1);
const bodyInstances = 2 * (TRAIL_VERTEX_LIMIT - 1);
const bodyBytes = await pipelineBytes(bodyInstances);
assert.equal(bytes, 1576, 'measure final encoded and batched geometry');
assert.equal(bodyBytes, 2014128);
const instanceReserve = 64 * bodyInstances;
assert.ok(bodyBytes + instanceReserve <= 2 * 1024 * 1024);
assert.ok(
bytes <= 2048,
'subdivision crossings stay below 2 KiB of head geometry',
);
console.log(
`trail final pipeline: ${bytes} bytes/head crossing, 0 bytes/ordinary head frame, ${bodyBytes} bytes/body (${bodyInstances} instances), ${instanceReserve} bytes instance reserve; geometry CPU ${(performance.now() - start).toFixed(3)} ms`,
);
});
test('supported scenes drape body and clipped head on 3D tiles and retain head geometry', (t) => {
const scene = fixture(t, true),
renderer = createContactTrailRenderer(scene);
const positions = [
Cesium.Cartesian3.fromDegrees(-97.7431, 30.267),
Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672),
];
renderer.replaceHistory({
revision: 1,
segments: [{ fromSeq: 0, toSeq: 1, fromT: 0, toT: 20000, positions }],
});
const first = renderer.diagnostics();
assert.ok(first.body instanceof Cesium.GroundPolylinePrimitive);
assert.equal(first.body.classificationType, Cesium.ClassificationType.BOTH);
assert.ok(scene.groundPrimitives.contains(first.body));
for (const instance of first.body.geometryInstances)
assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry);
for (let i = 1; i <= 120; i++)
renderer.setDisplaySample(
{ displayT: i * 100, fromSeq: 0, toSeq: 1, fraction: i / 200 },
positions[1],
);
const result = renderer.diagnostics();
assert.equal(result.body, first.body);
assert.equal(result.headRebuilds, 1);
assert.ok(result.headPrimitive instanceof Cesium.GroundPolylinePrimitive);
assert.equal(result.head.material.uniforms.fraction, 0.6);
assert.match(result.head.material.shaderSource, /discard/);
for (const stack of ['Google', 'OSM', 'Bing', 'Google']) {
scene.globe.show = stack !== 'Google';
renderer.setDisplaySample(
{ displayT: 12000, fromSeq: 0, toSeq: 1, fraction: 0.6 },
positions[1],
);
const retained = renderer.diagnostics();
assert.equal(retained.body, result.body, `${stack}: retained history`);
assert.equal(retained.headPrimitive, result.headPrimitive);
for (const primitive of [retained.body, retained.headPrimitive]) {
assert.equal(
primitive.classificationType,
Cesium.ClassificationType.BOTH,
stack,
);
assert.equal(primitive.show, true, stack);
}
}
renderer.setVisible(false);
assert.equal(result.body.show, false);
assert.equal(result.headPrimitive.show, false);
renderer.destroy();
assert.equal(scene.groundPrimitives.length, 0);
assert.equal(scene.primitives.length, 0);
});
test('subdivision crossings only write the changed show range and age colors once per second', (t) => {
const renderer = createContactTrailRenderer(fixture(t));
const positions = [
Cesium.Cartesian3.fromDegrees(-71, 42),
Cesium.Cartesian3.fromDegrees(-71, 42.001),
];
renderer.replaceHistory({
revision: 1,
segments: Array.from({ length: 100 }, (_, i) => ({
fromSeq: 0,
toSeq: 1,
fromT: i * 500,
toT: (i + 1) * 500,
positions,
})),
});
const { body } = renderer.diagnostics();
const counts = { show: 0, color: 0, depthFailColor: 0 };
const attributes = new Map(
body.geometryInstances.map((instance) => {
const result = {};
for (const name of Object.keys(counts)) {
let value = instance.attributes[name].value;
Object.defineProperty(result, name, {
get: () => value,
set(next) {
value = next;
counts[name]++;
},
});
}
return [instance.id, result];
}),
);
body._ready = true;
body.getGeometryInstanceAttributes = (id) => attributes.get(id);
const display = (displayT) =>
renderer.setDisplaySample(
{ displayT, fromSeq: 0, toSeq: 1, fraction: 0.1 },
positions[0],
);
const reset = () => {
for (const name of Object.keys(counts)) counts[name] = 0;
};
display(100);
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
reset();
display(600);
assert.deepEqual(counts, { show: 2, color: 0, depthFailColor: 0 });
reset();
display(100);
assert.deepEqual(counts, { show: 2, color: 0, depthFailColor: 0 });
assert.ok(
[...attributes.values()].every((a) => a.show[0] === 0),
'backward seek hides the completed range',
);
reset();
display(1100);
assert.deepEqual(counts, { show: 4, color: 200, depthFailColor: 200 });
reset();
display(1200);
assert.deepEqual(counts, { show: 0, color: 0, depthFailColor: 0 });
renderer.setStyle('#FF4538');
display(1200);
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
for (const a of attributes.values())
assert.deepEqual(
a.depthFailColor,
Cesium.ColorGeometryInstanceAttribute.toValue(
Cesium.Color.fromCssColorString('#FF4538').withAlpha(0.55),
),
);
});
+28 -46
View File
@@ -52,6 +52,7 @@ import {
stationMatchesRadioCategory,
} from './radio.js';
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
import { radioSelectedMarker, radioTestEngine } from '../testSupport/radioTestEngine.mjs';
const stations = [
{ id: 'news', tags: ['News', 'Weather Radio', 'air traffic'] },
@@ -393,13 +394,11 @@ test('destroy and re-init reset Radio audio ownership, volume, filter, and telem
const viewer = {
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
dataSources: { add() {}, remove() {} },
entities: { add(entity) { return entity; }, remove() {} },
};
radioLayer.destroy();
try {
radioLayer.init(viewer);
radioLayer.init(viewer, { engine: radioTestEngine() });
radioLayer.enable();
radioLayer.setLifecyclePresentation({
lifecycleState: 'enabled', enabled: true, uncertain: false,
@@ -424,7 +423,7 @@ test('destroy and re-init reset Radio audio ownership, volume, filter, and telem
assert.equal(getRadioUIState().volume, 0.8);
assert.equal(getRadioUIState().effectiveVolume, 0.8);
radioLayer.init(viewer);
radioLayer.init(viewer, { engine: radioTestEngine() });
assert.equal(audioInstances.length, 2, 're-init creates a clean Audio session');
const nextState = getRadioUIState();
for (const eventName of ['playing', 'pause', 'waiting', 'error']) {
@@ -512,13 +511,11 @@ test('late media errors after replacement or Pause cannot mutate the active stat
const viewer = {
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
dataSources: { add() {}, remove() {} },
entities: { add(entity) { return entity; }, remove() {} },
};
radioLayer.destroy();
try {
radioLayer.init(viewer);
radioLayer.init(viewer, { engine: radioTestEngine() });
radioLayer.enable();
await radioLayer.update();
radioLayer.setLifecyclePresentation({
@@ -643,12 +640,10 @@ test('unusable directory responses preserve warm client state atomically', async
const viewer = {
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
dataSources: { add() {}, remove() {} },
entities: { add(entity) { return entity; }, remove() {} },
};
radioLayer.destroy();
try {
radioLayer.init(viewer);
radioLayer.init(viewer, { engine: radioTestEngine() });
globalThis.fetch = async () => new Response(JSON.stringify({
stations: [station],
stale: false,
@@ -762,7 +757,7 @@ test('unusable directory responses preserve warm client state atomically', async
assert.equal(getRadioUIState().stale, true);
assert.equal(getRadioUIState().rejectedStationCount, 10_000);
radioLayer.destroy();
radioLayer.init(viewer);
radioLayer.init(viewer, { engine: radioTestEngine() });
assert.equal(getRadioUIState().rejectedStationCount, 0);
assert.equal(getRadioUIState().degraded, false);
} finally {
@@ -776,8 +771,6 @@ test('tuner drag keeps one immutable catalog resolution through refresh and rele
const viewer = {
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
dataSources: { add() {}, remove() {} },
entities: { add(entity) { return entity; }, remove() {} },
};
const stationA = {
id: '00000000-0000-4000-8000-000000000041',
@@ -820,7 +813,7 @@ test('tuner drag keeps one immutable catalog resolution through refresh and rele
radioLayer.destroy();
try {
radioLayer.init(viewer);
radioLayer.init(viewer, { engine: radioTestEngine() });
radioLayer.enable();
await publish(stationA, 1);
assert.equal(radioLayer.beginTuning(), true);
@@ -927,9 +920,12 @@ test('failed exact tuner release cannot consume a stale playback fallback', asyn
test('tuner cancellation restores the frozen start marker after catalog removal', async () => {
const originalFetch = globalThis.fetch;
const originalAudio = globalThis.Audio;
const selectedEntities = new Map();
const engine = radioTestEngine();
const selectedMarker = (id) => {
const marker = radioSelectedMarker(engine);
return marker?.id === `radio:selected:${id}` ? marker : undefined;
};
let playCalls = 0;
let flyToCalls = 0;
globalThis.Audio = class FakeAudio {
constructor() {
this.volume = 0.8;
@@ -944,17 +940,11 @@ test('tuner cancellation restores the frozen start marker after catalog removal'
const viewer = {
camera: {
positionWC: { x: 7_000_000, y: 0, z: 0 },
flyTo() { flyToCalls += 1; },
},
scene: {
canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} },
requestRender() {},
},
dataSources: { add() {}, remove() {} },
entities: {
add(entity) { selectedEntities.set(entity.id, entity); return entity; },
remove(entity) { return selectedEntities.delete(entity?.id); },
},
};
const stationA = {
id: '00000000-0000-4000-8000-000000000051',
@@ -994,7 +984,7 @@ test('tuner cancellation restores the frozen start marker after catalog removal'
radioLayer.destroy();
try {
radioLayer.init(viewer);
radioLayer.init(viewer, { engine });
radioLayer.enable();
radioLayer.setLifecyclePresentation({
lifecycleState: 'enabled', enabled: true, uncertain: false,
@@ -1004,7 +994,7 @@ test('tuner cancellation restores the frozen start marker after catalog removal'
await publish([stationA, stationB], nextGeneration);
nextGeneration += 1;
assert.equal(radioLayer.selectStation(stationA.id, { autoplay: false, focus: false }), true);
const startMarker = selectedEntities.get(`radio:selected:${stationA.id}`);
const startMarker = selectedMarker(stationA.id);
assert.ok(startMarker);
assert.equal(radioLayer.beginTuning(), true);
assert.equal(radioLayer.previewTuningStation(stationB.id, { rotate: false }), true);
@@ -1012,14 +1002,14 @@ test('tuner cancellation restores the frozen start marker after catalog removal'
nextGeneration += 1;
assert.equal(getRadioUIState().selected, null);
assert.equal(radioLayer.cancelTuning(), true);
const restoredMarker = selectedEntities.get(`radio:selected:${stationA.id}`);
const restoredMarker = selectedMarker(stationA.id);
assert.ok(restoredMarker, 'the frozen start marker remains visible after cancellation');
assert.deepEqual(restoredMarker.position, startMarker.position);
assert.equal(getRadioUIState().tuningRestoredStationId, stationA.id);
assert.equal(getRadioUIState().tuningActive, false);
assert.equal(getRadioUIState().selected, null, 'stale marker never becomes playback authority');
assert.equal(playCalls, 0);
assert.equal(flyToCalls, 0);
assert.equal(engine.fake.callsOf('camera.flyTo').length, 0);
return radioLayer.getAcceptedCatalogSnapshot();
};
@@ -1028,21 +1018,21 @@ test('tuner cancellation restores the frozen start marker after catalog removal'
assert.equal(radioLayer.setFilter(sameFilter), true);
assert.equal(getRadioUIState().filter, sameFilter);
assert.equal(getRadioUIState().tuningRestoredStationId, null);
assert.equal(selectedEntities.has(`radio:selected:${stationA.id}`), false);
assert.equal(Boolean(selectedMarker(stationA.id)), false);
assert.equal(radioLayer.getAcceptedCatalogSnapshot(), sameFilterSnapshot);
assert.equal(getRadioUIState().selected, null);
assert.equal(playCalls, 0);
assert.equal(flyToCalls, 0);
assert.equal(engine.fake.callsOf('camera.flyTo').length, 0);
const changedFilterSnapshot = await restoreRemovedStartMarker();
assert.equal(radioLayer.setFilter('news'), true);
assert.equal(getRadioUIState().filter, 'news');
assert.equal(getRadioUIState().tuningRestoredStationId, null);
assert.equal(selectedEntities.has(`radio:selected:${stationA.id}`), false);
assert.equal(Boolean(selectedMarker(stationA.id)), false);
assert.equal(radioLayer.getAcceptedCatalogSnapshot(), changedFilterSnapshot);
assert.equal(getRadioUIState().selected, null);
assert.equal(playCalls, 0);
assert.equal(flyToCalls, 0);
assert.equal(engine.fake.callsOf('camera.flyTo').length, 0);
const equalGenerationSnapshot = await restoreRemovedStartMarker();
await publish([stationB], equalGenerationSnapshot.generation);
@@ -1051,14 +1041,14 @@ test('tuner cancellation restores the frozen start marker after catalog removal'
null,
'the next accepted equal-generation refresh clears the presentation-only marker',
);
assert.equal(selectedEntities.has(`radio:selected:${stationA.id}`), false);
assert.equal(Boolean(selectedMarker(stationA.id)), false);
assert.equal(radioLayer.getAcceptedCatalogSnapshot(), equalGenerationSnapshot);
assert.equal(playCalls, 0);
assert.equal(flyToCalls, 0);
assert.equal(engine.fake.callsOf('camera.flyTo').length, 0);
assert.equal(radioLayer.selectStation(stationB.id, { autoplay: false, focus: false }), true);
assert.equal(getRadioUIState().tuningRestoredStationId, null);
assert.equal(selectedEntities.has(`radio:selected:${stationA.id}`), false);
assert.equal(Boolean(selectedMarker(stationA.id)), false);
} finally {
globalThis.fetch = originalFetch;
radioLayer.destroy();
@@ -1096,14 +1086,12 @@ test('Radio public tuner, filter, and resume mutations require certain enabled l
const viewer = {
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
dataSources: { add() {}, remove() {} },
entities: { add(entity) { return entity; }, remove() {} },
};
viewer.scene.requestRender = () => {};
radioLayer.destroy();
try {
radioLayer.init(viewer);
radioLayer.init(viewer, { engine: radioTestEngine() });
radioLayer.enable();
await radioLayer.update();
radioLayer.setLifecyclePresentation({
@@ -1149,8 +1137,6 @@ test('tuner refuses a degraded fallback that has no accepted catalog generation'
const viewer = {
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
dataSources: { add() {}, remove() {} },
entities: { add(entity) { return entity; }, remove() {} },
};
const station = {
id: '00000000-0000-4000-8000-000000000043',
@@ -1178,7 +1164,7 @@ test('tuner refuses a degraded fallback that has no accepted catalog generation'
acceptedGeneration: null,
updatedAt: new Date().toISOString(),
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
radioLayer.init(viewer);
radioLayer.init(viewer, { engine: radioTestEngine() });
radioLayer.enable();
await radioLayer.update();
@@ -1217,8 +1203,6 @@ test('accepted snapshots allowlist station fields and never regress generation i
const viewer = {
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
dataSources: { add() {}, remove() {} },
entities: { add(entity) { return entity; }, remove() {} },
};
const serve = (stations, acceptedGeneration, catalogInstance = 'qa-instance-a') => {
globalThis.fetch = async () => new Response(JSON.stringify({
@@ -1233,7 +1217,7 @@ test('accepted snapshots allowlist station fields and never regress generation i
radioLayer.destroy();
try {
radioLayer.init(viewer);
radioLayer.init(viewer, { engine: radioTestEngine() });
serve([station], 1);
radioLayer.enable();
await radioLayer.update();
@@ -1318,8 +1302,6 @@ test('update abort, destroy, and re-init invalidate the prior session and reset
const viewer = {
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
dataSources: { add() {}, remove() {} },
entities: { add(entity) { return entity; }, remove() {} },
};
const lateStation = {
id: '00000000-0000-4000-8000-000000000099',
@@ -1344,7 +1326,7 @@ test('update abort, destroy, and re-init invalidate the prior session and reset
requestSignal = options.signal;
return new Promise((resolve) => { resolveFetch = resolve; });
};
radioLayer.init(viewer);
radioLayer.init(viewer, { engine: radioTestEngine() });
radioLayer.enable();
const pending = radioLayer.update();
assert.equal(getRadioUIState().loading, true);
@@ -1353,7 +1335,7 @@ test('update abort, destroy, and re-init invalidate the prior session and reset
assert.equal(getRadioUIState().loading, false);
radioLayer.destroy();
radioLayer.init(viewer);
radioLayer.init(viewer, { engine: radioTestEngine() });
const cleanState = getRadioUIState();
assert.deepEqual({
enabled: cleanState.enabled,
+2 -3
View File
@@ -19,6 +19,7 @@ import {
} from './radio.js';
import { RADIO_REJECTED_STATION_COUNT_MAX as SERVER_REJECTED_MAX } from '../../server/providers/radio/constants.js';
import { RADIO_REJECTED_STATION_COUNT_MAX as CLIENT_REJECTED_MAX } from '../layers/radio/policy.js';
import { radioTestEngine } from '../testSupport/radioTestEngine.mjs';
const UUID = '12345678-1234-4234-8234-123456789abc';
const publicLookup = async () => [{ address: '93.184.216.34', family: 4 }];
@@ -760,8 +761,6 @@ test('server structural rejections reach the client diagnostic without discardin
const viewer = {
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
dataSources: { add() {}, remove() {} },
entities: { add(entity) { return entity; }, remove() {} },
};
radioLayer.destroy();
try {
@@ -769,7 +768,7 @@ test('server structural rejections reach the client diagnostic without discardin
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
radioLayer.init(viewer);
radioLayer.init(viewer, { engine: radioTestEngine() });
radioLayer.enable();
await radioLayer.update();
const state = getRadioUIState();
@@ -1,309 +0,0 @@
import * as Cesium from 'cesium';
const ALPHA_STOPS = [
[0, 0.8],
[120000, 0.45],
[600000, 0.2],
[900000, 0.08],
];
export function trailAlpha(ageMs) {
const stops = ALPHA_STOPS;
for (let i = 1; i < stops.length; i++) {
if (ageMs === stops[i][0]) return stops[i][1];
if (ageMs < stops[i][0]) {
const [a, x] = stops[i - 1],
[b, y] = stops[i];
return x + ((y - x) * Math.max(0, ageMs - a)) / (b - a);
}
}
return 0.08;
}
/** One static history batch and one frequently updated head. Never entities. */
export function createContactTrailRenderer(scene) {
const ground =
!!scene.frameState?.context &&
Cesium.GroundPolylinePrimitive.isSupported(scene);
const collection = ground ? scene.groundPrimitives : scene.primitives;
// One 25 m corridor is clipped in material coordinates. Only subdivision
// crossings build geometry; frame updates write a scalar, never vertex buffers.
const headMaterial = (alpha) =>
new Cesium.Material({
fabric: {
type: 'TransitTrailHead',
uniforms: {
color: new Cesium.Color(0.37, 0.94, 0.54, alpha),
fraction: 0,
backing: true,
},
source: `czm_material czm_getMaterial(czm_materialInput materialInput) {
if (materialInput.st.s > fraction) discard;
czm_material m = czm_getDefaultMaterial(materialInput);
bool edge = abs(materialInput.st.t - 0.5) > 0.3;
m.diffuse = czm_gammaCorrect(vec4(edge && backing ? vec3(0.02, 0.03, 0.05) : color.rgb, 1.0)).rgb;
m.alpha = color.a;
return m;
}`,
},
translucent: true,
});
const material = headMaterial(0.8),
depthMaterial = headMaterial(0.55);
depthMaterial.uniforms.backing = false;
const head = { positions: [], show: false, width: 3, material };
const backing = { positions: [], show: false, width: 5 };
let headPrimitive = null,
headRebuilds = 0;
function geometry(positions, width, material = false) {
return ground
? new Cesium.GroundPolylineGeometry({ positions, width, granularity: 0 })
: new Cesium.PolylineGeometry({
positions,
width,
arcType: Cesium.ArcType.NONE,
vertexFormat: material
? Cesium.PolylineMaterialAppearance.VERTEX_FORMAT
: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
});
}
function primitive(instances, appearance, depthFailAppearance) {
return ground
? new Cesium.GroundPolylinePrimitive({
geometryInstances: instances,
appearance,
classificationType: Cesium.ClassificationType.BOTH,
allowPicking: false,
asynchronous: true,
show: visible,
})
: new Cesium.Primitive({
geometryInstances: instances,
appearance,
depthFailAppearance,
allowPicking: false,
asynchronous: false,
show: visible,
});
}
let body = null,
segments = [],
revision = -1,
visible = true,
style = '#5EF08A';
let lastSecond = -1,
completed = -1,
active = null;
const styleColor = Cesium.Color.fromCssColorString(style);
const attributeIds = [];
const color = new Cesium.Color(),
endpoint = new Cesium.Cartesian3();
const headPositions = [new Cesium.Cartesian3(), endpoint];
let rebuilds = 0;
function tint(alpha, dark = false) {
if (dark) {
color.red = 0.02;
color.green = 0.03;
color.blue = 0.05;
} else Cesium.Color.clone(styleColor, color);
color.alpha = alpha;
return color;
}
function replaceHistory(next) {
if (next.revision === revision) return;
revision = next.revision;
segments = next.segments;
completed = -1;
lastSecond = -1;
active = null;
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = null;
head.show = backing.show = false;
if (body) collection.remove(body);
body = null;
const instances = [];
attributeIds.length = 0;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
attributeIds.push([`${i}:0`, `${i}:1`]);
if (segment.positions.length < 2) continue;
for (let lane = 0; lane < 2; lane++) {
instances.push(
new Cesium.GeometryInstance({
id: `${i}:${lane}`,
geometry: geometry(segment.positions, lane ? 3 : 5),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
tint(0.8, !lane),
),
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
tint(0.55),
),
show: new Cesium.ShowGeometryInstanceAttribute(false),
},
}),
);
}
}
if (instances.length) {
body = collection.add(
primitive(
instances,
new Cesium.PolylineColorAppearance({ translucent: true }),
new Cesium.PolylineColorAppearance({ translucent: true }),
),
);
rebuilds++;
}
}
function setDisplaySample(sample, renderedPosition) {
if (!sample || !renderedPosition) {
head.show = false;
backing.show = false;
if (headPrimitive) headPrimitive.show = false;
return;
}
const second = Math.floor(sample.displayT / 1000);
let lo = 0,
hi = segments.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (segments[mid].toT <= sample.displayT) lo = mid + 1;
else hi = mid;
}
const done = lo - 1;
const candidate = segments[lo];
const segment =
candidate &&
candidate.fromT <= sample.displayT &&
candidate.fromSeq === sample.fromSeq &&
candidate.toSeq === sample.toSeq
? candidate
: null;
if (body?.ready && done !== completed) {
for (
let i = Math.min(done, completed) + 1;
i <= Math.max(done, completed);
i++
) {
for (let lane = 0; lane < 2; lane++) {
const attributes = body.getGeometryInstanceAttributes(
attributeIds[i][lane],
);
if (!attributes) continue;
// Cesium getters return copies. Write back the array we changed.
const show = attributes.show;
show[0] = i <= done ? 1 : 0;
attributes.show = show;
}
}
completed = done;
}
if (body?.ready && second !== lastSecond) {
for (let i = 0; i < segments.length; i++) {
const alpha = trailAlpha(sample.displayT - segments[i].toT);
for (let lane = 0; lane < 2; lane++) {
const attributes = body.getGeometryInstanceAttributes(
attributeIds[i][lane],
);
if (!attributes) continue;
const rgba = attributes.color;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(alpha, !lane),
rgba,
);
attributes.color = rgba;
if (!ground) {
const depthRgba = attributes.depthFailColor;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(0.55),
depthRgba,
);
attributes.depthFailColor = depthRgba;
}
}
}
lastSecond = second;
}
const show = visible && !!segment && sample.fraction < 1;
head.show = show;
backing.show = show;
if (headPrimitive) headPrimitive.show = show;
if (!show) return;
if (active !== segment) {
active = segment;
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = collection.add(
primitive(
new Cesium.GeometryInstance({
geometry: geometry(segment.positions, 5, true),
}),
new Cesium.PolylineMaterialAppearance({
material,
translucent: true,
}),
new Cesium.PolylineMaterialAppearance({
material: depthMaterial,
translucent: true,
}),
),
);
headRebuilds++;
headPositions[0] = segment.positions[0];
headPositions[1] = endpoint;
}
Cesium.Cartesian3.clone(renderedPosition, endpoint);
head.positions = headPositions;
backing.positions = headPositions;
material.uniforms.fraction = Math.max(
0,
Math.min(
1,
(sample.displayT - segment.fromT) / (segment.toT - segment.fromT),
),
);
depthMaterial.uniforms.fraction = material.uniforms.fraction;
Cesium.Color.clone(tint(0.8), material.uniforms.color);
Cesium.Color.clone(tint(0.55), depthMaterial.uniforms.color);
}
return {
replaceHistory,
setDisplaySample,
setStyle(next) {
if (next === style) return;
style = next;
Cesium.Color.fromCssColorString(style, styleColor);
lastSecond = -1;
},
setVisible(next) {
visible = next;
if (headPrimitive) headPrimitive.show = next && head.show;
if (body) body.show = next;
},
destroy() {
if (body) collection.remove(body);
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = null;
material.destroy();
depthMaterial.destroy();
body = null;
segments = [];
},
diagnostics() {
return {
revision,
rebuilds,
segments: segments.length,
body,
head,
backing,
headPrimitive,
headStrategy: ground
? 'draped corridor with material clip'
: 'depth-fail corridor with material clip',
ground,
headRebuilds,
entitiesAdded: 0,
};
},
};
}
+9 -6
View File
@@ -9,7 +9,8 @@ import * as annotations from '../annotations/worldAnnotationRenderer.js';
import { createDirectionsLayer } from '../layers/directions/index.js';
import { createDirectionsSource } from '../layers/directions/source.js';
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
import { createCctvCesiumRenderer } from '../layers/cctv/render/cesium.js';
import { createCctvDisplay } from '../layers/cctv/render/display.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import * as input from './inputOwnership.js';
import { createOverpassAlprSource } from '../layers/alpr/source.js';
@@ -83,15 +84,17 @@ test('Directions introduces the shared OSM credit when the requested route draws
});
test('Warendorf webcam display introduces the shared OSM credit', (t) => {
const v = viewer(t), empty = new Proxy({}, { get: () => noop });
const renderer = createCctvCesiumRenderer({ state: {}, services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty } });
renderer.attach(v);
const engine = createFakeEngine(), empty = new Proxy({}, { get: () => noop });
const renderer = createCctvDisplay(engine, { state: {}, services: { credits: { showOsmCredit, hideOsmCredit } } });
renderer.attach();
t.after(() => renderer.destroy());
const lifecycle = createCctvLifecycle({ state: { _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost },
services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty },
parts: { renderer, selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} });
lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable();
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
lifecycle.methods.enable();
assert.ok(engine.fake.credits.some((credit) => credit.onScreen && credit.html.includes('>© OpenStreetMap<')));
lifecycle.methods.disable();
assert.equal(engine.fake.credits.filter((credit) => credit.onScreen).length, 0);
});
test('optional Overpass ALPR adapter uses the shared contributor wording', () => {
+3 -4
View File
@@ -1,7 +1,6 @@
import { createTransitLayer as createLayer } from '../layers/transit/index.js';
import { createTransitCesiumRenderer } from '../layers/transit/render/cesium.js';
import { createTransitDisplay } from '../layers/transit/render/display.js';
import * as render from '../renderGovernor.js';
import * as sprites from './spriteOrder.js';
import * as picking from './pickRegistry.js';
import * as overlays from '../overlays/worldOverlay.js';
import { registerDynamicCredit, transitFeedCredit } from './dataCredits.js';
@@ -16,7 +15,8 @@ import { sampleMeshFloorCells } from './meshFloorSampler.js';
export function createTransitLayer(options) {
return createLayer({
rendererFactory: createTransitCesiumRenderer,
rendererFactory: ({ engine, ...context }) =>
createTransitDisplay(engine, context),
...options,
});
}
@@ -24,7 +24,6 @@ export function createTransitLayer(options) {
const layer = createTransitLayer({
services: {
render,
sprites,
picking,
overlays,
credits: { registerDynamicCredit, transitFeedCredit },
-233
View File
@@ -1,233 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createCalibrationGizmo } from '../../data/cctvGizmo.js';
import { createCesiumCctvGizmoRenderer } from './render/cesiumGizmo.js';
function setup(t, initialInputs = true) {
const actions = new Map();
const entities = new Cesium.EntityCollection();
const controller = { enableInputs: initialInputs };
const canvas = new EventTarget();
canvas.style = {};
canvas.ownerDocument = { defaultView: new EventTarget() };
let picked = null;
let destroys = 0;
const viewer = {
entities,
scene: {
canvas,
screenSpaceCameraController: controller,
pick: () => picked,
drillPick: () => [],
},
camera: {
getPickRay: ({ x }) => ({
origin: new Cesium.Cartesian3(6378237, x, 0),
direction: new Cesium.Cartesian3(-1, 0, 0),
}),
},
};
const renderer = createCesiumCctvGizmoRenderer(viewer, {
createEventHandler: () => ({
setInputAction: (listener, type) => actions.set(type, listener),
destroy() {
destroys++;
actions.clear();
},
}),
});
const p = (x, y, z) => new Cesium.Cartesian3(x, y, z);
const record = {
camera: {
headingDeg: 0,
pitchDeg: 0,
rangeM: 100,
calibration: {
headingDeg: 0,
pitchDeg: 0,
offsetEastM: 0,
offsetNorthM: 0,
heightM: 0,
rangeScale: 1,
fovDeg: 0,
},
basePose: { rangeM: 100, fovDeg: 60 },
},
frustumGeometry: { rangeM: 100 },
frustumPositions: {
mount: p(6378137, 0, 0),
capCenter: p(6378137, 0, 100),
tl: p(6378150, -20, 100),
tr: p(6378150, 20, 100),
bl: p(6378124, -20, 100),
br: p(6378124, 20, 100),
},
};
let active = record;
const patches = [],
ends = [];
const gizmo = createCalibrationGizmo({
renderer,
getActiveRecord: () => active,
applyPatch: (patch, pinned) => patches.push({ patch, pinned }),
endPatch: (pinned) => ends.push(pinned),
});
t.after(() => gizmo.destroy());
gizmo.setEnabled(true);
const event = (type, x = 10) =>
actions.get(Cesium.ScreenSpaceEventType[type])?.({
position: { x, y: 0 },
endPosition: { x, y: 0 },
});
return {
gizmo,
renderer,
record,
entities,
controller,
patches,
ends,
canvas,
event,
choose: (entity = entities.getById('cctv-gizmo-move-east')) => {
picked = { id: entity };
},
switchCamera: () => {
active = { ...record };
},
destroys: () => destroys,
};
}
for (const initial of [false, true]) {
for (const ending of ['release', 'disable', 'destroy', 'selection']) {
test(`gizmo ${ending} restores initial camera input ${initial} and ends only the pinned record`, (t) => {
const h = setup(t, initial);
h.choose();
h.event('LEFT_DOWN');
assert.equal(h.gizmo.isDragging(), true);
assert.equal(h.controller.enableInputs, false);
if (ending === 'selection') {
h.switchCamera();
h.event('MOUSE_MOVE', 25);
} else {
h.event('MOUSE_MOVE', 25);
assert.equal(h.patches.length, 1);
assert.equal(h.patches[0].pinned, h.record);
assert.ok(Math.abs(h.patches[0].patch.offsetEastM - 15) < 1e-8);
if (ending === 'release') h.event('LEFT_UP');
if (ending === 'disable') h.gizmo.setEnabled(false);
if (ending === 'destroy') h.gizmo.destroy();
}
assert.equal(h.controller.enableInputs, initial);
assert.deepEqual(h.ends, [h.record]);
assert.equal(h.gizmo.isDragging(), false);
h.event('LEFT_UP');
assert.equal(h.ends.length, 1);
if (ending === 'selection') assert.equal(h.patches.length, 0);
});
}
}
test('native gizmo requires the exact owned entity, not a sibling with its ID', (t) => {
const h = setup(t);
const own = h.entities.getById('cctv-gizmo-move-east');
h.choose({ id: own.id });
h.event('LEFT_DOWN');
assert.equal(h.gizmo.isDragging(), false);
h.choose();
h.event('LEFT_DOWN');
assert.equal(h.gizmo.isDragging(), true);
});
test('gizmo builds eight owned handles with pinned native geometry and releases only those', (t) => {
const h = setup(t);
const now = Cesium.JulianDate.now();
assert.equal(h.entities.values.length, 8);
for (const entity of h.entities.values) assert.equal(entity.show, true);
const heading = h.entities
.getById('cctv-gizmo-ring-heading')
.polyline.positions.getValue(now);
assert.equal(heading.length, 97);
assert.ok(
Math.abs(
Cesium.Cartesian3.distance(heading[0], h.record.frustumPositions.mount) -
12,
) < 1e-8,
);
const east = h.entities.getById('cctv-gizmo-move-east');
assert.equal(east.polyline.width.getValue(now), 12);
assert.equal(east.polyline.material.getType(now), 'PolylineArrow');
assert.ok(Math.abs(east.position.getValue(now).y - 19.2) < 1e-8);
const sibling = h.entities.add({ id: 'foreign' });
h.gizmo.destroy();
h.gizmo.destroy();
assert.deepEqual(h.entities.values, [sibling]);
assert.equal(h.destroys(), 1);
});
for (const event of ['pointercancel', 'blur']) {
test(`gizmo ${event} ends a pinned drag and releases the camera`, (t) => {
const h = setup(t);
h.choose();
h.event('LEFT_DOWN');
const target =
event === 'blur' ? h.canvas.ownerDocument.defaultView : h.canvas;
target.dispatchEvent(new Event(event));
assert.equal(h.gizmo.isDragging(), false);
assert.equal(h.controller.enableInputs, true);
assert.deepEqual(h.ends, [h.record]);
h.gizmo.destroy();
target.dispatchEvent(new Event(event));
assert.equal(h.ends.length, 1);
});
}
test('a duplicate down cannot overwrite the original camera-input state', (t) => {
const h = setup(t);
h.choose();
h.event('LEFT_DOWN');
h.event('LEFT_DOWN');
h.event('LEFT_UP');
assert.equal(h.controller.enableInputs, true);
assert.deepEqual(h.ends, [h.record]);
});
test('native gizmo never deletes an unrelated entity with a reserved handle ID', (t) => {
const h = setup(t);
const id = 'cctv-gizmo-move-east';
const own = h.entities.getById(id);
h.entities.remove(own);
const sibling = h.entities.add({ id });
assert.throws(
() => h.renderer.createPoint('move-east', Cesium.Color.RED, 10),
/owned|already|exists/i,
);
assert.equal(h.entities.getById(id), sibling);
h.choose(own);
h.event('LEFT_DOWN');
assert.equal(h.gizmo.isDragging(), false);
h.gizmo.destroy();
assert.equal(h.entities.getById(id), sibling);
});
test('a handle construction failure releases already-created resources', () => {
const handles = new Set();
let destroys = 0;
const renderer = {
createLine(part) {
if (part === 'move-east') throw new Error('ID already owned');
const handle = { part };
handles.add(handle);
return handle;
},
destroy() {
destroys++;
handles.clear();
},
};
assert.throws(() => createCalibrationGizmo({ renderer }), /already owned/);
assert.equal(handles.size, 0);
assert.equal(destroys, 1);
});
-364
View File
@@ -1,364 +0,0 @@
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { horizonOccluder } from '../../../data/iconOrientation.js';
import { createFrustumVolumePrimitive } from './cesiumViewshed.js';
import {
bindTrackingClickGesture,
isTrackingClickGesture,
} from '../../../data/trackingClickGesture.js';
import { createCesiumCctvGizmoRenderer } from './cesiumGizmo.js';
import {
CAMERA_ICON,
IDLE_CAMERA_COLOR,
IDLE_COVERAGE_COLOR,
PLANE_OUTLINE_COLOR,
} from '../policy.js';
/** Native CCTV resources and scene operations owned by one viewer. */
export function createCctvCesiumRenderer({ services, state }) {
let viewer = null;
let billboards = null;
let activeTileset = null;
const entities = new Set();
const volumes = new Set();
const listeners = new Set();
const gizmos = new Set();
const color = (value) => (value ? Cesium.Color.clone(value) : undefined);
function ownListener(remove) {
const release = () => {
if (!listeners.delete(release)) return;
remove();
};
listeners.add(release);
return release;
}
function addEntity(options) {
const entity = viewer.entities.add(options);
entities.add(entity);
return entity;
}
function removeEntity(entity) {
if (!entities.delete(entity)) return;
if (viewer.entities.getById(entity.id) === entity)
viewer.entities.remove(entity);
}
function tilesReady() {
if (!activeTileset || activeTileset.isDestroyed?.()) {
activeTileset = null;
const primitives = viewer?.scene?.primitives;
if (primitives && typeof primitives.get === 'function') {
for (let i = 0; i < primitives.length; i++) {
const p = primitives.get(i);
if (p instanceof Cesium.Cesium3DTileset && !p.isDestroyed?.()) {
activeTileset = p;
break;
}
}
}
}
if (!activeTileset) return true;
return activeTileset.show !== false && activeTileset.tilesLoaded === true;
}
function extractPickedCameraId(picked) {
if (!picked) return null;
const entity = picked.id?.properties
? picked.id
: picked.primitive?.id?.properties
? picked.primitive.id
: null;
const maybeProp = entity?.properties?.cctvCameraId;
if (maybeProp) {
const value =
typeof maybeProp.getValue === 'function'
? maybeProp.getValue(Cesium.JulianDate.now())
: maybeProp;
const record =
typeof value === 'string' ? state._recordById.get(value) : null;
const ownsCoverageEntity = Boolean(
record?.coverageEntities?.includes(entity),
);
const ownsProjectionEntity =
record?.projection?.planeEntity === entity ||
state._projectionEntities.some(
(runtime) =>
runtime?.cameraId === value && runtime.planeEntity === entity,
);
if (record && (ownsCoverageEntity || ownsProjectionEntity)) return value;
}
// Camera billboard IDs are intentionally the upstream camera ID, so a
// sibling may legitimately use the same string. A bare ID match is not
// ownership proof: require this layer's billboard collection or the exact
// billboard stored on the record.
const directId =
typeof picked.id === 'string'
? picked.id
: typeof picked.primitive?.id === 'string'
? picked.primitive.id
: null;
const record = directId === null ? null : state._recordById.get(directId);
if (!record) return null;
return picked.primitive === billboards ||
picked.primitive === record.billboard
? directId
: null;
}
return {
extractPickedCameraId,
attach(owner) {
if (viewer && viewer !== owner)
throw new Error('CCTV renderer already has an owner');
viewer = owner;
activeTileset = null;
},
get ready() {
return Boolean(viewer && !viewer.isDestroyed?.());
},
cameraCartographic: () => viewer?.camera?.positionCartographic,
following: () => Boolean(viewer?.trackedEntity),
globeVisible: () => viewer?.scene?.globe?.show,
tilesReady,
project: (position, result) =>
viewer.scene.cartesianToCanvasCoordinates(position, result),
distance: (position) =>
geo.cartesian3.distance(viewer.camera.positionWC, position),
horizonTest() {
const occluder = horizonOccluder(viewer.camera);
return (position) => occluder.isPointVisible(position);
},
viewport() {
const canvas = viewer.scene.canvas;
return {
width: canvas.clientWidth || canvas.width || 0,
height: canvas.clientHeight || canvas.height || 0,
};
},
request: () => viewer?.scene?.requestRender?.(),
pick: (screen) => viewer.scene.pick(screen),
onMoveEnd: (fn) => ownListener(viewer.camera.moveEnd.addEventListener(fn)),
onMoveStart: (fn) =>
ownListener(viewer.camera.moveStart.addEventListener(fn)),
bindClick(onClick, options = {}) {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
bindTrackingClickGesture(
handler,
(event, gesture) => {
if (isTrackingClickGesture(gesture)) onClick(event);
},
options,
);
return ownListener(() => handler.destroy());
},
createGizmoRenderer() {
const gizmo = createCesiumCctvGizmoRenderer(viewer);
gizmos.add(gizmo);
return gizmo;
},
createMarkers() {
if (billboards) throw new Error('CCTV markers already exist');
billboards = new Cesium.BillboardCollection();
viewer.scene.primitives.add(billboards);
services.sprites.registerSpriteCollection('cctv', billboards);
return billboards;
},
createMarker(camera, position) {
return billboards.add({
id: camera.id,
image: CAMERA_ICON,
position,
color: color(IDLE_CAMERA_COLOR),
width: 24,
height: 24,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
scaleByDistance: new Cesium.NearFarScalar(350, 1.25, 4_000_000, 0.42),
});
},
setMarkersVisible(value) {
if (billboards) billboards.show = value;
},
restoreOrder: () => services.sprites.restoreSpriteOrder(viewer),
showCredit: () => services.credits?.showOsmCredit?.(viewer, 'cctv'),
hideCredit: () => services.credits?.hideOsmCredit?.(viewer, 'cctv'),
sampleMeshFloorCells: (points, options) =>
services.mesh.sampleMeshFloorCells(viewer?.scene, points, options),
obstructionRay(origin, direction, exclude, far) {
if (!viewer?.scene?.pickFromRay) return undefined;
const hit = viewer.scene.pickFromRay(
new Cesium.Ray(origin, direction),
[billboards, ...exclude].filter(Boolean),
);
return hit?.position &&
geo.cartesian3.distance(origin, hit.position) <= far
? hit
: undefined;
},
createPlaneMaterial(image) {
return new Cesium.ImageMaterialProperty({
image,
transparent: true,
color: Cesium.Color.WHITE.withAlpha(0.95),
});
},
createTestPlaneMaterial: () =>
new Cesium.ColorMaterialProperty(Cesium.Color.WHITE),
setPlaneImage(runtime, image) {
runtime.planeMaterial.image = image;
},
createPlane(record, runtime, geometry, positions, orientation) {
return addEntity({
id: `cctv-${record.camera.id}-plane`,
properties: { cctvCameraId: record.camera.id },
show: false,
position: positions.capCenter,
orientation: Cesium.Quaternion.clone(orientation),
plane: {
plane: new Cesium.Plane(Cesium.Cartesian3.UNIT_Z, 0.0),
dimensions: new Cesium.Cartesian2(
geometry.halfW * 2,
geometry.halfH * 2,
),
material: runtime.planeMaterial,
outline: true,
outlineColor: color(PLANE_OUTLINE_COLOR),
},
});
},
updatePlane(runtime, geometry, positions, orientation) {
const entity = runtime.planeEntity;
entity.position = positions.capCenter;
entity.orientation = Cesium.Quaternion.clone(orientation);
if (entity.plane)
entity.plane.dimensions = new Cesium.Cartesian2(
geometry.halfW * 2,
geometry.halfH * 2,
);
},
setPlaneVisible(runtime, visible) {
if (runtime.planeEntity) runtime.planeEntity.show = visible;
},
removePlane: (runtime) => removeEntity(runtime.planeEntity),
createCoverage(cameraId, positions) {
const created = [];
try {
for (const [role, points] of [
['ray-tl', [positions.mount, positions.tl]],
['ray-tr', [positions.mount, positions.tr]],
['ray-br', [positions.mount, positions.br]],
['ray-bl', [positions.mount, positions.bl]],
[
'cap',
[
positions.tl,
positions.tr,
positions.br,
positions.bl,
positions.tl,
],
],
]) {
const entity = addEntity({
id: `cctv-${cameraId}-${role}`,
properties: { cctvCameraId: cameraId },
polyline: {
positions: points,
width: 1.2,
material: color(IDLE_COVERAGE_COLOR),
},
});
entity._coverageRole = role === 'cap' ? 'cap' : 'edge';
created.push(entity);
}
return created;
} catch (error) {
for (const entity of created) removeEntity(entity);
throw error;
}
},
updateCoverage(record, positions) {
const lines = record.coverageEntities;
if (!lines || lines.length < 5) return;
for (const [i, role] of ['tl', 'tr', 'br', 'bl'].entries())
lines[i].polyline.positions = [positions.mount, positions[role]];
lines[4].polyline.positions = [
positions.tl,
positions.tr,
positions.br,
positions.bl,
positions.tl,
];
},
removeCoverageEntity: removeEntity,
styleLine(entity, { visible, width, tint, dashed, depthFail }) {
entity.show = visible;
if (!entity.polyline) return;
entity.polyline.width = width;
entity.polyline.material = dashed
? new Cesium.PolylineDashMaterialProperty({ color: color(tint) })
: color(tint);
entity.polyline.depthFailMaterial = color(depthFail);
},
hideCoverage(record) {
for (const entity of record.coverageEntities || []) entity.show = false;
},
removeCoverage(record) {
for (const entity of record.coverageEntities || []) removeEntity(entity);
},
createVolume(record, tint) {
const primitive = createFrustumVolumePrimitive(
record.frustumPositions,
color(tint),
);
primitive._gevViewshed = record.camera.id;
viewer.scene.primitives.add(primitive);
volumes.add(primitive);
return primitive;
},
removeVolume(primitive) {
if (volumes.delete(primitive)) viewer.scene.primitives.remove(primitive);
},
focus(record, duration) {
viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
record.position,
Math.max(40, record.camera.rangeM * 0.36),
),
{
offset: new Cesium.HeadingPitchRange(
geo.math.toRadians(record.camera.headingDeg),
geo.math.toRadians(-22),
Math.max(280, record.camera.rangeM * 1.18),
),
duration: Math.max(0.2, duration || 0),
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
},
);
},
destroy() {
if (!viewer) return;
services.credits?.hideOsmCredit?.(viewer, 'cctv');
for (const remove of listeners) remove();
for (const gizmo of gizmos) gizmo.destroy();
gizmos.clear();
for (const entity of entities) removeEntity(entity);
for (const volume of volumes) viewer.scene.primitives.remove(volume);
volumes.clear();
if (billboards) {
services.sprites.unregisterSpriteCollection?.('cctv', billboards);
viewer.scene.primitives.remove(billboards);
}
viewer = billboards = activeTileset = null;
},
};
}
export function bindCctvWorldClickGesture(handler, onClick, options = {}) {
bindTrackingClickGesture(
handler,
(click, gesture) => {
if (!isTrackingClickGesture(gesture)) return;
onClick(click);
},
options,
);
}
-158
View File
@@ -1,158 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import * as Cesium from 'cesium';
import { createCctvCesiumRenderer } from './cesium.js';
import { viewshedColors } from '../../../data/cctvViewshed.js';
function viewer() {
return {
entities: new Cesium.EntityCollection(),
scene: { primitives: new Cesium.PrimitiveCollection() },
camera: { moveStart: new Cesium.Event(), moveEnd: new Cesium.Event() },
isDestroyed: () => false,
};
}
function setup(t) {
const owner = viewer();
const sprites = [];
const state = { _recordById: new Map(), _projectionEntities: [] };
const renderer = createCctvCesiumRenderer({
state,
services: {
sprites: {
registerSpriteCollection: (...args) => sprites.push(['add', ...args]),
unregisterSpriteCollection: (...args) =>
sprites.push(['remove', ...args]),
},
},
});
renderer.attach(owner);
t.after(() => renderer.destroy());
const p = (x, y, z) => new Cesium.Cartesian3(x, y, z);
const positions = {
mount: p(6378137, 0, 10),
tl: p(6378137, 100, 40),
tr: p(6378137, 140, 40),
br: p(6378137, 140, 0),
bl: p(6378137, 100, 0),
capCenter: p(6378137, 120, 20),
};
return { owner, renderer, sprites, positions, state };
}
test('native CCTV releases only its original viewer resources and listeners', (t) => {
const h = setup(t);
const foreignViewer = viewer();
const foreign = foreignViewer.entities.add({ id: 'cctv-a-cap' });
const neighbor = h.owner.entities.add({ id: 'neighbor' });
const markers = h.renderer.createMarkers();
h.renderer.createMarker({ id: 'a' }, h.positions.mount);
h.renderer.createCoverage('a', h.positions);
const record = { camera: { id: 'a' }, frustumPositions: h.positions };
const volume = h.renderer.createVolume(record, viewshedColors(30).fill);
assert.equal(volume.allowPicking, false);
assert.equal(volume.asynchronous, false);
let moves = 0;
h.renderer.onMoveEnd(() => moves++);
h.owner.camera.moveEnd.raiseEvent();
assert.equal(moves, 1);
assert.throws(() => h.renderer.attach(foreignViewer), /already has an owner/);
h.renderer.destroy(foreignViewer);
h.renderer.destroy();
h.owner.camera.moveEnd.raiseEvent();
assert.equal(moves, 1);
assert.deepEqual(h.owner.entities.values, [neighbor]);
assert.equal(h.owner.scene.primitives.length, 0);
assert.equal(foreignViewer.entities.getById(foreign.id), foreign);
assert.deepEqual(h.sprites, [
['add', 'cctv', markers],
['remove', 'cctv', markers],
]);
});
test('coverage construction rolls back partial entities and can retry', (t) => {
const h = setup(t);
const collision = h.owner.entities.add({ id: 'cctv-a-ray-br' });
assert.throws(() => h.renderer.createCoverage('a', h.positions));
assert.deepEqual(h.owner.entities.values, [collision]);
h.owner.entities.remove(collision);
const lines = h.renderer.createCoverage('a', h.positions);
assert.equal(lines.length, 5);
assert.deepEqual(
lines.map((line) => line._coverageRole),
['edge', 'edge', 'edge', 'edge', 'cap'],
);
});
test('replaced native entity identities survive CCTV teardown', (t) => {
const h = setup(t);
const [line] = h.renderer.createCoverage('a', h.positions);
h.owner.entities.remove(line);
const replacement = h.owner.entities.add({ id: line.id });
h.renderer.destroy();
assert.deepEqual(h.owner.entities.values, [replacement]);
});
test('native materials convert portable colours and keep depth-fail and dash policy', (t) => {
const h = setup(t);
const [line] = h.renderer.createCoverage('a', h.positions);
const tint = viewshedColors(210).line;
h.renderer.styleLine(line, {
visible: true,
width: 1.8,
tint,
dashed: true,
depthFail: tint.withAlpha(0.18),
});
const now = Cesium.JulianDate.now();
assert.equal(line.polyline.material.getType(now), 'PolylineDash');
assert.ok(
Cesium.Color.equals(line.polyline.material.getValue(now).color, tint),
);
assert.equal(line.polyline.depthFailMaterial.getValue(now).color.alpha, 0.18);
h.renderer.styleLine(line, {
visible: false,
width: 0.9,
tint,
dashed: false,
});
assert.equal(line.show, false);
assert.equal(line.polyline.material.getType(now), 'Color');
assert.equal(line.polyline.depthFailMaterial, undefined);
});
test('obstruction queries include the owned marker set and enforce finite range', (t) => {
const h = setup(t);
const markers = h.renderer.createMarkers();
const exclude = [{}];
let query;
h.owner.scene.pickFromRay = (ray, exclusions) => {
query = { ray, exclusions };
return {
position: Cesium.Cartesian3.add(
ray.origin,
new Cesium.Cartesian3(0, 20, 0),
new Cesium.Cartesian3(),
),
};
};
assert.equal(
h.renderer.obstructionRay(
h.positions.mount,
Cesium.Cartesian3.UNIT_Y,
exclude,
10,
),
undefined,
);
assert.ok(
h.renderer.obstructionRay(
h.positions.mount,
Cesium.Cartesian3.UNIT_Y,
exclude,
30,
),
);
assert.deepEqual(query.exclusions, [markers, ...exclude]);
assert.ok(query.ray instanceof Cesium.Ray);
});
-193
View File
@@ -1,193 +0,0 @@
import * as Cesium from 'cesium';
import { GIZMO_ID_PREFIX } from '../../../data/cctvGizmo.js';
/** Native handle resources and input, owned by exactly one viewer. */
export function createCesiumCctvGizmoRenderer(
viewer,
{
createEventHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas),
} = {},
) {
const scene = viewer.scene;
const handler = createEventHandler(scene.canvas);
const entities = new Map();
const removeCancelListeners = [];
let destroyed = false;
/** Adds (or re-adds) a gizmo entity, clearing any stale duplicate id. */
function addEntity(part, options) {
const id = `${GIZMO_ID_PREFIX}${part}`;
const stale = viewer.entities.getById(id);
if (stale && stale !== entities.get(part))
throw new Error(`CCTV handle ID is already owned: ${id}`);
if (stale) viewer.entities.remove(stale);
const entity = viewer.entities.add({ id, show: false, ...options });
entities.set(part, entity);
return entity;
}
function createLine(part, color, width, arrow = false) {
color = Cesium.Color.clone(color);
return addEntity(part, {
...(arrow
? {
position: Cesium.Cartesian3.ZERO,
point: {
pixelSize: 14,
color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
}
: {}),
polyline: {
positions: [],
width,
material: arrow
? new Cesium.PolylineArrowMaterialProperty(color)
: color.withAlpha(0.9),
// Standard gizmo convention: parts behind geometry stay clearly
// visible, just dimmed (a street-level mount buries half the heading
// ring in sloped photogrammetry tiles — smoke-tested 2026-07-05).
depthFailMaterial: color.withAlpha(0.45),
},
});
}
function createPoint(part, color, pixelSize) {
color = Cesium.Color.clone(color);
return addEntity(part, {
position: Cesium.Cartesian3.ZERO,
point: {
pixelSize,
color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
}
/** Extracts a gizmo part name from a pick result, or null. */
function gizmoPartFrom(picked) {
const id = picked?.id?.id ?? picked?.id;
if (typeof id !== 'string' || !id.startsWith(GIZMO_ID_PREFIX)) return null;
const part = id.slice(GIZMO_ID_PREFIX.length);
const entity = entities.get(part);
return entity &&
viewer.entities.getById(id) === entity &&
(picked.id === entity || picked.primitive?.id === entity)
? part
: null;
}
function pickGizmoPart(windowPosition) {
// Gizmo primitives render depth-test-free and should be the topmost pick.
// Take the cheap single-result path first: a full drillPick can stall for
// tens of seconds under software GL even when its first result is already
// the gizmo. Retain drillPick for uncommon overlap/fallback cases.
try {
const picked = scene.pick(windowPosition, 14, 14);
const part = gizmoPartFrom(picked);
if (part && entities.get(part)?.show) return part;
} catch (err) {
if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
console.debug('[CCTV:gizmo] pick threw:', err?.message || err);
}
}
let results = [];
try {
results = scene.drillPick(windowPosition, 6, 14, 14) || [];
} catch (err) {
if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
console.debug('[CCTV:gizmo] drillPick threw:', err?.message || err);
}
return null;
}
if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
console.debug(
'[CCTV:gizmo] drillPick @',
windowPosition?.x,
windowPosition?.y,
'→',
JSON.stringify(
results.map((r) =>
String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name),
),
),
);
}
for (const picked of results) {
const part = gizmoPartFrom(picked);
if (part && entities.get(part)?.show) return part;
}
return null;
}
function pickRay(windowPosition) {
try {
return viewer.camera.getPickRay(windowPosition) || null;
} catch {
return null;
}
}
function setCursor(value) {
if (scene.canvas?.style) scene.canvas.style.cursor = value;
}
return {
createLine,
createPoint,
pickPart: pickGizmoPart,
pickRay,
setCursor,
on(type, listener) {
if (type === 'cancel') {
for (const [target, event] of [
[scene.canvas, 'pointercancel'],
[scene.canvas.ownerDocument?.defaultView, 'blur'],
]) {
if (!target?.addEventListener) continue;
target.addEventListener(event, listener);
removeCancelListeners.push(() =>
target.removeEventListener(event, listener),
);
}
return;
}
handler.setInputAction(
listener,
{
down: Cesium.ScreenSpaceEventType.LEFT_DOWN,
move: Cesium.ScreenSpaceEventType.MOUSE_MOVE,
up: Cesium.ScreenSpaceEventType.LEFT_UP,
}[type],
);
},
suspendCameraInputs() {
const controller = scene.screenSpaceCameraController;
const previous = controller?.enableInputs;
if (controller) controller.enableInputs = false;
let released = false;
return () => {
if (released) return;
released = true;
if (controller) controller.enableInputs = previous;
};
},
destroy() {
if (destroyed) return;
destroyed = true;
handler.destroy();
for (const remove of removeCancelListeners) remove();
removeCancelListeners.length = 0;
for (const entity of entities.values())
if (viewer.entities.getById(entity.id) === entity)
viewer.entities.remove(entity);
entities.clear();
setCursor('');
},
};
}
+133
View File
@@ -0,0 +1,133 @@
// The CCTV meshes on Cesium (behind render/meshes.js): the monitor plane is
// a plane Entity with an image material, the viewshed volume a synchronous
// Primitive of six triangles in a flat translucent colour, both faces drawn,
// never picked. The activation probe's ray pick lives here too, while the
// Cesium engine has no surface.raycast.
import * as Cesium from 'cesium';
import { frustumVolumeGeometryData } from '../../../data/cctvViewshed.js';
import { PLANE_IMAGE_ALPHA } from './style.js';
const toColor = (tint) => new Cesium.Color(...tint.rgba());
/** The frustum volume's Primitive over positions { mount, tl, tr, br, bl }. */
export function createFrustumVolumePrimitive(positions, tint) {
const { positions: flat, indices } = frustumVolumeGeometryData(positions);
const geometry = new Cesium.Geometry({
attributes: {
position: new Cesium.GeometryAttribute({
componentDatatype: Cesium.ComponentDatatype.DOUBLE,
componentsPerAttribute: 3,
values: flat,
}),
},
indices,
primitiveType: Cesium.PrimitiveType.TRIANGLES,
boundingSphere: Cesium.BoundingSphere.fromVertices(Array.from(flat)),
});
return new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
geometry,
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(toColor(tint)),
},
}),
appearance: new Cesium.PerInstanceColorAppearance({
flat: true,
translucent: true,
// The camera is routinely inside or behind a cone.
renderState: { cull: { enabled: false } },
}),
asynchronous: false,
// Clicks fall through to the markers, wedges and plane.
allowPicking: false,
});
}
/**
* @param {{ viewer: Cesium.Viewer }} cesium The engine's Cesium extension.
*/
export function createCctvCesiumMeshes({ viewer: { entities, scene } }) {
const planes = new Map();
const volumes = new Set();
const dimensions = (shape) =>
new Cesium.Cartesian2(shape.halfW * 2, shape.halfH * 2);
return {
createMaterial(image) {
const property = image
? new Cesium.ImageMaterialProperty({
image,
transparent: true,
color: Cesium.Color.WHITE.withAlpha(PLANE_IMAGE_ALPHA),
})
: new Cesium.ColorMaterialProperty(Cesium.Color.WHITE);
return {
property,
setImage(next) {
property.image = next;
},
dispose() {},
};
},
createPlane(cameraId, material, shape, positions, orientation) {
const entity = entities.add({
id: `cctv-${cameraId}-plane`,
show: false,
position: positions.capCenter,
orientation: Cesium.Quaternion.clone(orientation),
plane: {
plane: new Cesium.Plane(Cesium.Cartesian3.UNIT_Z, 0.0),
dimensions: dimensions(shape),
material: material.property,
},
});
const plane = {
entity,
get show() {
return entity.show;
},
set show(value) {
entity.show = value;
},
destroy() {
if (!planes.delete(entity)) return;
if (entities.getById(entity.id) === entity) entities.remove(entity);
},
};
planes.set(entity, plane);
return plane;
},
updatePlane({ entity }, shape, positions, orientation) {
entity.position = positions.capCenter;
entity.orientation = Cesium.Quaternion.clone(orientation);
if (entity.plane) entity.plane.dimensions = dimensions(shape);
},
createVolume(positions, tint) {
const primitive = createFrustumVolumePrimitive(positions, tint);
scene.primitives.add(primitive);
volumes.add(primitive);
return {
primitive,
destroy() {
if (volumes.delete(primitive)) scene.primitives.remove(primitive);
},
};
},
// The engine's pick result keeps Cesium's own id: the plane Entity.
planeFromPick: (picked) => planes.get(picked.id) ?? null,
drawnBy: (native) => native.entity ?? native.primitive,
obstructionRay(origin, direction, far, exclude) {
if (!scene.pickFromRay) return undefined;
const hit = scene.pickFromRay(new Cesium.Ray(origin, direction), exclude);
return hit?.position &&
Cesium.Cartesian3.distance(origin, hit.position) <= far
? hit
: undefined;
},
destroy() {
for (const plane of [...planes.values()]) plane.destroy();
for (const primitive of volumes) scene.primitives.remove(primitive);
volumes.clear();
},
};
}
-45
View File
@@ -1,45 +0,0 @@
import * as Cesium from 'cesium';
import { frustumVolumeGeometryData } from '../../../data/cctvViewshed.js';
/**
* Builds the translucent frustum-volume primitive for one camera.
* Synchronous (6 triangles — cheaper than the async geometry pipeline's
* bookkeeping), unlit flat color, both faces visible (the viewer is routinely
* inside or behind a cone), never pickable (clicks fall through to the
* billboard/wireframe/plane, whose pick semantics are established).
* @param {Object} positions - frustumCartesians shape (see frustumVolumeGeometryData).
* @param {Cesium.Color} color - Per-camera fill color (already alpha'd).
* @returns {Cesium.Primitive}
*/
export function createFrustumVolumePrimitive(positions, color) {
const { positions: flat, indices } = frustumVolumeGeometryData(positions);
const geometry = new Cesium.Geometry({
attributes: {
position: new Cesium.GeometryAttribute({
componentDatatype: Cesium.ComponentDatatype.DOUBLE,
componentsPerAttribute: 3,
values: flat,
}),
},
indices,
primitiveType: Cesium.PrimitiveType.TRIANGLES,
boundingSphere: Cesium.BoundingSphere.fromVertices(Array.from(flat)),
});
return new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
geometry,
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(color),
},
}),
appearance: new Cesium.PerInstanceColorAppearance({
flat: true,
translucent: true,
renderState: {
cull: { enabled: false },
},
}),
asynchronous: false,
allowPicking: false,
});
}
+922
View File
@@ -0,0 +1,922 @@
// The CCTV cameras on the globe contract (engine.movers, engine.lines,
// engine.input, engine.picker, engine.camera, engine.projector,
// engine.surface, engine.credits), the same on every engine:
// - the camera markers, one sprite set (24 px, shrinking with distance,
// always on top) of plain handles the layer writes; the writes are
// committed once per frame and before every pick;
// - the coverage wedges (four corner rays and the far rectangle) and the
// monitor plane's outline as engine lines, rebuilt when their width, dash
// or depth fail changes;
// - the calibration gizmo: rings and arrow shafts as lines, arrow tips and
// handles in one point set, drags through engine.input;
// - the monitor plane and the viewshed volume behind render/meshes.js, the
// engine-specific part the contract has no primitive for yet.
// The layer (../index.js) keeps the records, the policy and the style
// choices (../rendering.js, ./style.js).
import * as geo from '../../../geo/index.js';
import {
bindTrackingClickGesture,
isTrackingClickGesture,
} from '../../../data/trackingClickGesture.js';
import { GIZMO_ID_PREFIX } from '../../../data/cctvGizmo.js';
import { CAMERA_ICON, IDLE_CAMERA_COLOR } from '../policy.js';
import { createCctvMeshes } from './meshes.js';
import {
COVERAGE_DASH,
COVERAGE_INITIAL,
GIZMO_ARROW_TIP_PX,
GIZMO_DEPTH_FAIL_ALPHA,
GIZMO_DRILL_LIMIT,
GIZMO_LINE_ALPHA,
GIZMO_ORDER,
GIZMO_PICK_PX,
GIZMO_POINT,
LINE_ARC,
MARKER_ORDER,
MARKER_SCALE_BY_DISTANCE,
MARKER_SET_ID,
MARKER_SIZE_PX,
PLANE_OUTLINE,
depthFailAlpha,
} from './style.js';
const GIZMO_POINT_SET_ID = `${GIZMO_ID_PREFIX}points`;
const GIZMO_POINT_CAPACITY = 16;
// The tracking click gesture's handler types, as engine.input types.
const CLICK_EVENT_TYPES = Object.freeze({
LEFT_DOWN: 'down',
MOUSE_MOVE: 'move',
LEFT_UP: 'up',
LEFT_CLICK: 'click',
});
const CORNERS = ['tl', 'tr', 'br', 'bl'];
const capRing = (p) => [p.tl, p.tr, p.br, p.bl, p.tl];
const sameDash = (a, b) =>
a === b || (a && b && a.lengthPx === b.lengthPx && a.gapPx === b.gapPx);
const sameColor = (a, b) =>
a.red === b.red &&
a.green === b.green &&
a.blue === b.blue &&
a.alpha === b.alpha;
const gizmoDebug = () =>
typeof window !== 'undefined' && window.__gevGizmoDebug;
const scratchRotation = geo.matrix3.create();
const scratchCorner = geo.cartesian3.create();
/** The plane's outline: its corners from the centre, size and orientation. */
export function planeOutline(shape, positions, orientation) {
const rotation = geo.matrix3.fromQuaternion(orientation, scratchRotation);
const corner = (x, y) =>
geo.cartesian3.add(
geo.matrix3.multiplyByVector(
rotation,
geo.cartesian3.fromElements(
x * shape.halfW,
y * shape.halfH,
0,
scratchCorner,
),
geo.cartesian3.create(),
),
positions.capCenter,
geo.cartesian3.create(),
);
const tl = corner(-1, 1);
return [tl, corner(1, 1), corner(1, -1), corner(-1, -1), tl];
}
/** The tracking click gesture over engine.input (the CCTV world click). */
export function bindCctvWorldClickGesture(handler, onClick, options = {}) {
bindTrackingClickGesture(
handler,
(click, gesture) => {
if (!isTrackingClickGesture(gesture)) return;
onClick(click);
},
options,
);
}
/**
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {{ state: object, services: object, navaraMeshes?: Function }} options
* The layer's state and services; `navaraMeshes` is the Navara meshes
* backend (render/navaraMeshes.js), which the Navara mode passes in.
*/
export function createCctvDisplay(
engine,
{ state, services = {}, navaraMeshes } = {},
) {
if (!engine) throw new TypeError('The CCTV display needs a globe engine');
const meshes = createCctvMeshes(engine, { navaraMeshes });
const markers = [];
const markerHandles = new WeakSet();
const changedMarkers = new Set();
const lines = new Map();
const planes = new Set();
const planeByMesh = new Map();
const gizmos = new Set();
const removers = new Set();
let markerSet = null,
capacity = 0,
markersVisible = true,
dirty = false,
initialized = false,
destroyed = false;
const request = () => engine.render.request('cctv');
const ownListener = (remove) => {
const release = () => {
if (removers.delete(release)) remove();
};
removers.add(release);
return release;
};
// ── Markers ─────────────────────────────────────────────────────────────
// What changed since the last sync: one marker, or every one (null).
const markDirty = (marker = null) => {
if (destroyed) return;
if (marker) changedMarkers.add(marker);
else for (const each of markers) changedMarkers.add(each);
if (dirty) return;
dirty = true;
request();
};
function syncMarkers() {
if (!dirty || !markerSet || destroyed) return;
dirty = false;
markerSet.setCount(markers.length);
for (const marker of changedMarkers)
markerSet.set(marker.index, {
position: marker.position,
show: markersVisible && marker.show,
scale: marker.scale,
color: marker.color.rgba(),
cell: 'camera',
pickId: marker.id,
});
changedMarkers.clear();
markerSet.commit();
}
function allocateMarkers(count) {
if (markerSet && capacity >= count) return;
markerSet?.destroy();
capacity = Math.max(128, count, capacity * 2);
markerSet = engine.movers.createSet({
id: MARKER_SET_ID,
kind: 'sprite',
capacity,
order: MARKER_ORDER,
pickable: true,
depthTest: 'always',
sharpen: true,
sprite: {
sizePx: MARKER_SIZE_PX,
scaleByDistance: [...MARKER_SCALE_BY_DISTANCE],
atlas: { url: CAMERA_ICON, cells: { camera: [0, 0, 1, 1] } },
},
});
markDirty();
}
/** A marker handle: billboard-like fields the layer reads and writes. */
function createMarkerHandle(camera, position) {
const marker = {
id: camera.id,
index: markers.length,
width: MARKER_SIZE_PX,
height: MARKER_SIZE_PX,
};
const fields = {
position: [geo.cartesian3.clone(position), geo.cartesian3.equals],
color: [IDLE_CAMERA_COLOR, sameColor],
scale: [1, Object.is],
show: [true, Object.is],
};
for (const [key, [initial, equal]] of Object.entries(fields)) {
let value = initial;
Object.defineProperty(marker, key, {
enumerable: true,
get: () => value,
set(next) {
if (equal(next, value)) return;
// A caller cannot move a marker by mutating what it passed.
value = key === 'position' ? geo.cartesian3.clone(next) : next;
markDirty(marker);
},
});
}
markerHandles.add(marker);
return marker;
}
const ownsMarker = (handle) => markerHandles.has(handle);
// ── Lines ───────────────────────────────────────────────────────────────
/**
* A line handle with an entity-like face (`show`, `polyline.positions`):
* the engine line is rebuilt when its width, dash or depth fail changes.
*/
function createLineHandle(id, style, positions, options = {}) {
const {
pickId = id,
shown = true,
order = 0,
arrow = false,
extra = {},
} = options;
let current = {
widthPx: style.widthPx,
color: style.color,
depthFailAlpha: style.depthFailAlpha ?? 0,
dash: style.dash ?? null,
};
let points = positions;
let visible = shown;
let line = null;
let destroyed = false;
const build = () => {
line?.destroy();
line = null;
line = engine.lines.createLine({
id,
pickId,
widthPx: current.widthPx,
color: current.color.rgba(),
depthFailAlpha: current.depthFailAlpha,
dash: current.dash,
arc: LINE_ARC,
order,
...(arrow ? { arrow: true } : {}),
});
line.setPositions(points);
line.show(visible);
};
build();
const handle = {
id,
pickId,
...extra,
get line() {
return line;
},
get show() {
return !destroyed && visible;
},
set show(value) {
if (destroyed || visible === Boolean(value)) return;
visible = Boolean(value);
line.show(visible);
},
polyline: {
get positions() {
return points;
},
set positions(value) {
if (destroyed) return;
points = value;
line.setPositions(value);
},
},
/** { visible, widthPx, color, depthFail?, dash? } (./style.js). */
style(next) {
if (destroyed) return;
const wanted = {
widthPx: next.widthPx,
color: next.color,
depthFailAlpha: depthFailAlpha(next.color, next.depthFail),
dash: next.dash ?? null,
};
const rebuild =
wanted.widthPx !== current.widthPx ||
wanted.depthFailAlpha !== current.depthFailAlpha ||
!sameDash(wanted.dash, current.dash);
const recolor = !sameColor(wanted.color, current.color);
current = wanted;
visible = Boolean(next.visible);
if (rebuild) build();
else {
if (recolor) line.setColor(current.color.rgba());
line.show(visible);
}
},
destroy() {
if (destroyed) return;
destroyed = true;
lines.delete(id);
line.destroy();
},
};
lines.set(id, handle);
return handle;
}
/** Whether a pick result is this live, shown line. */
const lineFromPick = (picked) => {
const handle = lines.get(picked?.owner);
return handle && picked.id === handle.pickId && handle.show ? handle : null;
};
// ── Planes ──────────────────────────────────────────────────────────────
function cameraOwnsPlane(cameraId, plane) {
const record = state._recordById?.get(cameraId);
if (!record) return false;
return (
record.projection?.planeEntity === plane ||
(state._projectionEntities ?? []).some(
(runtime) =>
runtime?.cameraId === cameraId && runtime.planeEntity === plane,
)
);
}
/** The engine objects one of the layer's handles draws with. */
function drawnObjects(handle) {
if (!handle) return [];
if (ownsMarker(handle)) return markerSet ? [markerSet] : [];
if (lines.get(handle.id) === handle) return [handle.line];
if (planes.has(handle))
return [handle.outline.line, meshes.drawnBy(handle.mesh)].filter(Boolean);
return [meshes.drawnBy(handle)].filter(Boolean);
}
/** What the layer's exclusion lists name, as the engine's own objects. */
const engineObjects = (handles = []) => [
...new Set(handles.flatMap(drawnObjects)),
];
/**
* The same for a ray pick that skips objects by pick id (Cesium's): every
* marker, as the whole set was, and the listed lines, planes and volumes.
*/
function rayExclusions(handles = []) {
const exclude = markers.map((marker) => marker.id);
for (const handle of handles) {
if (!handle || ownsMarker(handle)) continue;
if (lines.get(handle.id) === handle) exclude.push(handle.pickId);
else if (planes.has(handle))
exclude.push(
handle.outline.pickId,
...[meshes.drawnBy(handle.mesh)].filter(Boolean),
);
else exclude.push(...[meshes.drawnBy(handle)].filter(Boolean));
}
return exclude;
}
// ── Gizmo ───────────────────────────────────────────────────────────────
function createGizmoRenderer() {
const handles = new Map();
const slots = [];
const gizmoRemovers = [];
let points = null;
let destroyed = false;
const canvas = () => engine.host.canvas;
const setCursor = (value) => {
const style = canvas()?.style;
if (style) style.cursor = value;
};
const assertNew = (part) => {
if (destroyed) throw new Error('CCTV gizmo is destroyed');
if (handles.has(part))
throw new Error(
`CCTV handle ID is already owned: ${GIZMO_ID_PREFIX}${part}`,
);
};
const writePoint = (slot) => {
if (destroyed || !points) return;
points.set(slot.index, {
position: slot.position,
color: slot.color.rgba(),
scale: slot.pixelSize / GIZMO_POINT.sizePx,
show: slot.visible,
pickId: slot.pickId,
});
points.commit();
};
/** A point in the gizmo's set: `position`, `show`, `point.pixelSize`. */
function addPoint(part, color, pixelSize) {
if (slots.length >= GIZMO_POINT_CAPACITY)
throw new Error('CCTV gizmo has no room for another handle');
points ??= engine.movers.createSet({
id: GIZMO_POINT_SET_ID,
kind: 'point',
capacity: GIZMO_POINT_CAPACITY,
order: GIZMO_ORDER,
pickable: true,
depthTest: 'always',
point: {
sizePx: GIZMO_POINT.sizePx,
outlinePx: GIZMO_POINT.outlinePx,
outlineColor: [...GIZMO_POINT.outlineColor],
},
});
const slot = {
index: slots.length,
pickId: `${GIZMO_ID_PREFIX}${part}`,
position: geo.cartesian3.create(),
color,
pixelSize,
visible: false,
};
slots.push(slot);
points.setCount(slots.length);
writePoint(slot);
return slot;
}
/** Gives a handle its point's `position` and `point.pixelSize`. */
const withPoint = (handle, slot) =>
Object.defineProperties(handle, {
position: {
enumerable: true,
get: () => slot.position,
set(value) {
slot.position = geo.cartesian3.clone(value);
writePoint(slot);
},
},
point: {
enumerable: true,
value: {
get pixelSize() {
return slot.pixelSize;
},
set pixelSize(value) {
if (value === slot.pixelSize) return;
slot.pixelSize = value;
writePoint(slot);
},
},
},
});
const setPointShown = (slot, value) => {
if (slot.visible === Boolean(value)) return;
slot.visible = Boolean(value);
writePoint(slot);
};
function partFrom(picked) {
const id = picked?.id;
if (typeof id !== 'string' || !id.startsWith(GIZMO_ID_PREFIX))
return null;
const part = id.slice(GIZMO_ID_PREFIX.length);
const handle = handles.get(part);
return handle?.owns(picked.owner) && handle.show ? part : null;
}
return {
createLine(part, color, width, arrow = false) {
assertNew(part);
const tint = arrow ? color : color.withAlpha(GIZMO_LINE_ALPHA);
const line = createLineHandle(
`${GIZMO_ID_PREFIX}${part}`,
{
widthPx: width,
color: tint,
depthFailAlpha: depthFailAlpha(
tint,
color.withAlpha(GIZMO_DEPTH_FAIL_ALPHA),
),
},
[],
{ shown: false, order: GIZMO_ORDER, arrow },
);
if (!arrow) {
const handle = {
id: line.id,
get show() {
return line.show;
},
set show(value) {
line.show = value;
},
polyline: line.polyline,
owns: (owner) => owner === line.id,
destroy: () => line.destroy(),
};
handles.set(part, handle);
return handle;
}
let slot;
try {
slot = addPoint(part, color, GIZMO_ARROW_TIP_PX);
} catch (error) {
line.destroy();
throw error;
}
const handle = withPoint(
{
id: line.id,
get show() {
return line.show;
},
set show(value) {
line.show = value;
setPointShown(slot, value);
},
polyline: line.polyline,
owns: (owner) => owner === line.id || owner === GIZMO_POINT_SET_ID,
destroy: () => line.destroy(),
},
slot,
);
handles.set(part, handle);
return handle;
},
createPoint(part, color, pixelSize) {
assertNew(part);
const slot = addPoint(part, color, pixelSize);
const handle = withPoint(
{
id: slot.pickId,
get show() {
return slot.visible;
},
set show(value) {
setPointShown(slot, value);
},
owns: (owner) => owner === GIZMO_POINT_SET_ID,
destroy() {},
},
slot,
);
handles.set(part, handle);
return handle;
},
// Handles draw over the scene: the single pick usually finds one,
// and a drill covers what overlaps them.
pickPart(position) {
try {
const part = partFrom(
engine.picker.pick(position, GIZMO_PICK_PX, GIZMO_PICK_PX),
);
if (part) return part;
} catch (error) {
if (gizmoDebug())
console.debug('[CCTV:gizmo] pick threw:', error?.message || error);
}
let results = [];
try {
results =
engine.picker.drillPick(
position,
GIZMO_DRILL_LIMIT,
GIZMO_PICK_PX,
GIZMO_PICK_PX,
) || [];
} catch (error) {
if (gizmoDebug())
console.debug(
'[CCTV:gizmo] drillPick threw:',
error?.message || error,
);
return null;
}
if (gizmoDebug())
console.debug(
'[CCTV:gizmo] drillPick @',
position?.x,
position?.y,
'→',
JSON.stringify(results.map((result) => String(result?.id))),
);
for (const picked of results) {
const part = partFrom(picked);
if (part) return part;
}
return null;
},
pickRay(position) {
try {
return engine.camera.pickRay(position) || null;
} catch {
return null;
}
},
setCursor,
on(type, listener) {
if (destroyed) return;
if (type !== 'cancel') {
gizmoRemovers.push(engine.input.on(type, listener));
return;
}
const target = canvas();
for (const [owner, event] of [
[target, 'pointercancel'],
[target?.ownerDocument?.defaultView, 'blur'],
]) {
if (!owner?.addEventListener) continue;
owner.addEventListener(event, listener);
gizmoRemovers.push(() => owner.removeEventListener(event, listener));
}
},
// The contract sets camera inputs; it cannot read them back, so the
// release turns them on again.
suspendCameraInputs() {
engine.input.setCameraInputs(false);
let released = false;
return () => {
if (released) return;
released = true;
engine.input.setCameraInputs(true);
};
},
destroy() {
if (destroyed) return;
destroyed = true;
gizmos.delete(this);
for (const remove of gizmoRemovers.splice(0)) remove();
for (const handle of handles.values()) handle.destroy();
handles.clear();
points?.destroy();
points = null;
setCursor('');
},
};
}
const removeFrame = engine.frames.preRender.addEventListener(syncMarkers);
let credit = false;
return {
attach() {
if (destroyed) throw new Error('CCTV display is destroyed');
initialized = true;
},
get ready() {
return initialized && !destroyed && !engine.isDestroyed();
},
cameraCartographic: () => engine.camera.pose().cartographic,
following: () => Boolean(engine.camera.following()),
globeVisible: () => !engine.maps.photorealActive(),
// Without shown photoreal tiles there is nothing to wait on.
tilesReady: () =>
!engine.maps.photorealActive() || engine.surface.tilesLoaded(),
project: (position, result) =>
engine.projector.worldToWindow(position, result),
distance: (position) =>
geo.cartesian3.distance(engine.camera.livePosition, position),
horizonTest: () => (position) => engine.projector.horizonVisible(position),
viewport: () => engine.host.size(),
request,
pick(position) {
syncMarkers();
return engine.picker.pick(position);
},
/**
* The camera a pick result belongs to: its marker in this layer's set,
* a shown wedge line or plane the camera's record owns; null for
* anything else, a sibling with the same id included.
*/
extractPickedCameraId(picked) {
if (!picked) return null;
if (picked.owner === MARKER_SET_ID)
return typeof picked.id === 'string' &&
state._recordById?.has(picked.id)
? picked.id
: null;
const line = lineFromPick(picked);
if (line?.cameraId !== undefined) {
const record = state._recordById?.get(line.cameraId);
return record?.coverageEntities?.includes(line) ? line.cameraId : null;
}
const plane =
line?.plane ?? planeByMesh.get(meshes.planeFromPick(picked));
if (plane?.show && cameraOwnsPlane(plane.cameraId, plane))
return plane.cameraId;
return null;
},
onMoveEnd: (fn) => ownListener(engine.camera.moveEnd.addEventListener(fn)),
onMoveStart: (fn) =>
ownListener(engine.camera.moveStart.addEventListener(fn)),
bindClick(onClick, options = {}) {
const removes = [];
bindCctvWorldClickGesture(
{
setInputAction(fn, type) {
removes.push(engine.input.on(type, fn));
},
},
onClick,
{ ...options, eventTypes: CLICK_EVENT_TYPES },
);
return ownListener(() => removes.forEach((remove) => remove()));
},
createGizmoRenderer() {
const gizmo = createGizmoRenderer();
gizmos.add(gizmo);
return gizmo;
},
createMarkers() {
if (markerSet) throw new Error('CCTV markers already exist');
allocateMarkers(1);
return markerSet;
},
createMarker(camera, position) {
const marker = createMarkerHandle(camera, position);
markers.push(marker);
allocateMarkers(markers.length);
markDirty(marker);
return marker;
},
setMarkersVisible(value) {
if (markersVisible === Boolean(value)) return;
markersVisible = Boolean(value);
markDirty();
},
restoreOrder: syncMarkers,
showCredit() {
if (credit) return;
credit = true;
services.credits?.showOsmCredit?.(engine, 'cctv');
},
hideCredit() {
if (!credit) return;
credit = false;
services.credits?.hideOsmCredit?.(engine, 'cctv');
},
sampleMeshFloorCells(points, options = {}) {
syncMarkers();
return services.mesh.sampleMeshFloorCells(engine, points, {
...options,
excludeObjects: engineObjects(options.excludeObjects),
});
},
/** The world (tiles) along the activation probe, within `far` metres. */
obstructionRay(origin, direction, exclude, far) {
syncMarkers();
if (typeof engine.surface.raycast === 'function')
return engine.surface.raycast({
origin,
direction,
far,
exclude: [markerSet, ...engineObjects(exclude)].filter(Boolean),
});
return meshes.obstructionRay(
origin,
direction,
far,
rayExclusions(exclude),
);
},
createPlaneMaterial: (image) => meshes.createMaterial(image),
createTestPlaneMaterial: () => meshes.createMaterial(null),
setPlaneImage(runtime, image) {
runtime.planeMaterial?.setImage(image);
},
createPlane(record, runtime, geometry, positions, orientation) {
const cameraId = record.camera.id;
const mesh = meshes.createPlane(
cameraId,
runtime.planeMaterial,
geometry,
positions,
orientation,
);
let outline;
try {
outline = createLineHandle(
`cctv-${cameraId}-plane-outline`,
PLANE_OUTLINE,
planeOutline(geometry, positions, orientation),
{ pickId: mesh.id, shown: false },
);
} catch (error) {
mesh.destroy();
throw error;
}
const plane = {
id: mesh.id,
cameraId,
mesh,
outline,
get show() {
return mesh.show;
},
set show(value) {
mesh.show = value;
outline.show = mesh.show;
},
};
outline.plane = plane;
planes.add(plane);
planeByMesh.set(mesh, plane);
return plane;
},
updatePlane(runtime, geometry, positions, orientation) {
const plane = runtime.planeEntity;
if (!planes.has(plane)) return;
meshes.updatePlane(plane.mesh, geometry, positions, orientation);
plane.outline.polyline.positions = planeOutline(
geometry,
positions,
orientation,
);
},
setPlaneVisible(runtime, visible) {
if (runtime.planeEntity) runtime.planeEntity.show = visible;
},
removePlane(runtime) {
const plane = runtime?.planeEntity;
if (planes.delete(plane)) {
planeByMesh.delete(plane.mesh);
plane.mesh.destroy();
plane.outline.destroy();
}
runtime?.planeMaterial?.dispose();
},
createCoverage(cameraId, positions) {
const created = [];
try {
for (const [role, points] of [
...CORNERS.map((corner) => [
`ray-${corner}`,
[positions.mount, positions[corner]],
]),
['cap', capRing(positions)],
])
created.push(
createLineHandle(
`cctv-${cameraId}-${role}`,
COVERAGE_INITIAL,
points,
{
extra: {
cameraId,
_coverageRole: role === 'cap' ? 'cap' : 'edge',
},
},
),
);
return created;
} catch (error) {
for (const line of created) line.destroy();
throw error;
}
},
updateCoverage(record, positions) {
const coverage = record.coverageEntities;
if (!coverage || coverage.length < 5) return;
CORNERS.forEach((corner, index) => {
coverage[index].polyline.positions = [
positions.mount,
positions[corner],
];
});
coverage[4].polyline.positions = capRing(positions);
},
removeCoverageEntity(handle) {
if (handle && lines.get(handle.id) === handle) handle.destroy();
},
/** The layer's coverage style ({ visible, width, tint, dashed, depthFail? }). */
styleLine(handle, { visible, width, tint, dashed, depthFail }) {
if (lines.get(handle?.id) !== handle) return;
handle.style({
visible,
widthPx: width,
color: tint,
depthFail,
dash: dashed ? COVERAGE_DASH : null,
});
},
hideCoverage(record) {
for (const handle of record.coverageEntities || []) handle.show = false;
},
removeCoverage(record) {
for (const handle of record.coverageEntities || [])
if (lines.get(handle?.id) === handle) handle.destroy();
},
createVolume: (record, tint) =>
meshes.createVolume(record.frustumPositions, tint),
removeVolume: (volume) => volume?.destroy(),
focus(record, duration) {
engine.camera.flyToSphere(
{
center: record.position,
radius: Math.max(40, record.camera.rangeM * 0.36),
},
{
heading: geo.math.toRadians(record.camera.headingDeg),
pitch: geo.math.toRadians(-22),
range: Math.max(280, record.camera.rangeM * 1.18),
duration: Math.max(0.2, duration || 0),
easing: 'cubicInOut',
},
);
},
destroy() {
if (destroyed) return;
this.hideCredit();
destroyed = true;
initialized = false;
removeFrame();
for (const remove of [...removers]) remove();
for (const gizmo of [...gizmos]) gizmo.destroy();
for (const handle of [...lines.values()]) handle.destroy();
meshes.destroy();
planes.clear();
planeByMesh.clear();
markerSet?.destroy();
markerSet = null;
markers.length = 0;
},
};
}
+648
View File
@@ -0,0 +1,648 @@
// The CCTV display on the engine contract, on the fake engine and on the
// Cesium engine (Cesium's own collections, primitives and entities over a
// stub scene: they draw nothing but hold what the engine writes): the
// marker set and its batched writes, the coverage wedge lines and their
// styles, picks that need this layer's own objects, the monitor plane and
// its outline, the viewshed volume, the activation probe's ray, the world
// click, the credit, focus and teardown.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { viewshedColors } from '../../../data/cctvViewshed.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { CCTV_HARNESSES } from '../../../testSupport/cctvEngines.mjs';
import {
ACTIVE_CAMERA_COLOR,
IDLE_CAMERA_COLOR,
IDLE_COVERAGE_CENTER_MUTED,
IDLE_COVERAGE_COLOR,
IDLE_COVERAGE_EDGE_MUTED,
PLANE_OUTLINE_COLOR,
} from '../policy.js';
import { createCctvDisplay, planeOutline } from './display.js';
const p = (x, y, z) => geo.cartesian3.fromElements(x, y, z);
const POSITIONS = {
mount: p(6378137, 0, 10),
tl: p(6378137, 100, 40),
tr: p(6378137, 140, 40),
br: p(6378137, 140, 0),
bl: p(6378137, 100, 0),
capCenter: p(6378137, 120, 20),
};
const SHAPE = { halfW: 20, halfH: 11.25 };
const near = (actual, expected, label, tolerance = 1e-6) =>
assert.ok(
Math.abs(actual - expected) < tolerance,
`${label}: ${actual} vs ${expected}`,
);
const sameRgba = (actual, color, label) =>
color
.rgba()
.forEach((value, index) =>
near(actual[index], value, `${label}[${index}]`),
);
function setup(makeHarness, t, services = {}) {
const harness = makeHarness();
const state = { _recordById: new Map(), _projectionEntities: [] };
const display = createCctvDisplay(harness.engine, { state, services });
display.attach();
t.after(() => {
display.destroy();
harness.destroy();
});
return { ...harness, display, state };
}
const recordFor = (state, id, extra = {}) => {
const record = { camera: { id }, coverageEntities: [], ...extra };
state._recordById.set(id, record);
return record;
};
test('the display needs a globe engine', () => {
assert.throws(() => createCctvDisplay(null, { state: {} }), TypeError);
});
for (const makeHarness of CCTV_HARNESSES) {
const { name } = makeHarness();
test(`markers are one always-on-top sprite set written once a frame (${name})`, async (t) => {
const h = setup(makeHarness, t);
h.display.createMarkers();
assert.throws(() => h.display.createMarkers(), /already exist/);
const position = p(6378137, 10, 20);
const marker = h.display.createMarker({ id: 'a' }, position);
const other = h.display.createMarker({ id: 'b' }, POSITIONS.mount);
assert.equal(marker.width, 24);
assert.equal(marker.color, IDLE_CAMERA_COLOR);
position.y = 99;
assert.equal(marker.position.y, 10, 'the marker keeps its own copy');
const [listed] = h.engine.movers.list();
assert.deepEqual(
{ id: listed.id, kind: listed.kind, count: listed.count },
{ id: 'cctv', kind: 'sprite', count: 0 },
'nothing is written before the frame',
);
h.frame();
assert.equal(h.engine.movers.list()[0].count, 2);
marker.color = ACTIVE_CAMERA_COLOR;
marker.scale = 1.25;
h.display.setMarkersVisible(false);
h.display.restoreOrder();
await h.settle();
if (h.cesium) {
const billboards = h.primitive('cctv');
const billboard = billboards.get(0);
assert.equal(billboard.width, 24);
assert.equal(billboard.height, 24);
assert.equal(
billboard.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.deepEqual(
[
billboard.scaleByDistance.near,
billboard.scaleByDistance.nearValue,
billboard.scaleByDistance.far,
billboard.scaleByDistance.farValue,
],
[350, 1.25, 4_000_000, 0.42],
);
assert.equal(billboard.id, 'a');
assert.equal(billboard.scale, 1.25);
near(billboard.color.alpha, ACTIVE_CAMERA_COLOR.alpha, 'alpha');
assert.equal(billboard.show, false);
assert.equal(billboards.get(1).show, false);
} else {
const set = h.engine.fake.moverSet('cctv');
assert.equal(set.options.order, 0, 'the bottom of the contact sprites');
assert.equal(set.options.depthTest, 'always');
assert.equal(set.options.pickable, true);
assert.equal(set.options.sprite.sizePx, 24);
assert.deepEqual(
[...set.options.sprite.scaleByDistance],
[350, 1.25, 4_000_000, 0.42],
);
assert.equal(set.store.scales[0], 1.25);
assert.equal(set.store.shows[0], 0);
assert.equal(set.store.pickIds[1], 'b');
const commits = set.commits;
marker.color = marker.color.withAlpha(marker.color.alpha);
marker.position = { ...marker.position };
other.show = true;
h.frame();
assert.equal(
h.engine.fake.moverSet('cctv').commits,
commits,
'unchanged writes commit nothing',
);
}
});
test(`coverage is five lines that roll back a partial build (${name})`, (t) => {
const h = setup(makeHarness, t);
const foreign = h.engine.lines.createLine({
id: 'cctv-a-ray-br',
widthPx: 1,
color: [1, 1, 1, 1],
});
assert.throws(() => h.display.createCoverage('a', POSITIONS), /exists/);
assert.deepEqual(
h.engine.lines.list().map(({ id }) => id),
['cctv-a-ray-br'],
);
foreign.destroy();
const lines = h.display.createCoverage('a', POSITIONS);
assert.deepEqual(
lines.map((line) => [line.id, line._coverageRole, line.cameraId]),
[
['cctv-a-ray-tl', 'edge', 'a'],
['cctv-a-ray-tr', 'edge', 'a'],
['cctv-a-ray-br', 'edge', 'a'],
['cctv-a-ray-bl', 'edge', 'a'],
['cctv-a-cap', 'cap', 'a'],
],
);
assert.ok(
lines.every((line) => line.show),
'shown as created',
);
assert.deepEqual(lines[4].polyline.positions, [
POSITIONS.tl,
POSITIONS.tr,
POSITIONS.br,
POSITIONS.bl,
POSITIONS.tl,
]);
const moved = { ...POSITIONS, tl: p(6378137, 90, 45) };
h.display.updateCoverage({ coverageEntities: lines }, moved);
assert.deepEqual(lines[0].polyline.positions, [moved.mount, moved.tl]);
assert.equal(lines[4].polyline.positions[0], moved.tl);
if (h.cesium) {
const drawn = h.primitive('cctv-a-cap');
assert.equal(
drawn.geometryInstances.geometry._arcType,
Cesium.ArcType.GEODESIC,
);
assert.equal(drawn.geometryInstances.geometry._width, 1.2);
} else {
const line = h.lineState('cctv-a-cap');
assert.equal(line.widthPx, 1.2);
assert.equal(line.arc, 'geodesic');
assert.equal(line.pickId, 'cctv-a-cap');
sameRgba(line.color, IDLE_COVERAGE_COLOR, 'idle colour');
}
h.display.hideCoverage({ coverageEntities: lines });
assert.ok(lines.every((line) => !line.show));
h.display.removeCoverageEntity(lines[0]);
h.display.removeCoverage({ coverageEntities: lines });
assert.equal(h.engine.lines.list().length, 0);
});
test(`coverage styles keep Cesium's widths, dash and depth fail (${name})`, (t) => {
const h = setup(makeHarness, t);
const [edge, , , , cap] = h.display.createCoverage('a', POSITIONS);
const hue = viewshedColors(210);
// The layer's styles (../rendering.js) for an active, viewshed-hued
// estimated bearing behind the plane, then idle ones.
const style = (line, input) =>
h.display.styleLine(line, { visible: true, dashed: false, ...input });
style(edge, {
width: 1.8,
tint: hue.lineActive,
dashed: true,
depthFail: hue.line.withAlpha(0.18),
});
if (h.cesium) {
const drawn = h.primitive(edge.id);
assert.equal(drawn.appearance.material.type, 'PolylineDash');
near(
drawn.appearance.material.uniforms.color.alpha,
hue.lineActive.alpha,
'line alpha',
);
near(
drawn.depthFailAppearance.material.uniforms.color.alpha,
0.18,
'depth-fail alpha',
);
assert.equal(drawn.geometryInstances.geometry._width, 1.8);
} else {
const line = h.lineState(edge.id);
assert.deepEqual(line.dash, { kind: 'pixels', lengthPx: 8, gapPx: 8 });
assert.equal(line.widthPx, 1.8);
near(line.depthFailAlpha, 0.18 / hue.lineActive.alpha, 'depth fail');
sameRgba(line.color, hue.lineActive, 'active hue');
}
style(cap, { visible: false, width: 1, tint: IDLE_COVERAGE_CENTER_MUTED });
assert.equal(cap.show, false);
style(edge, { width: 0.9, tint: IDLE_COVERAGE_EDGE_MUTED });
if (h.cesium) {
const drawn = h.primitive(edge.id);
assert.equal(drawn.appearance.material.type, 'Color');
assert.equal(drawn.depthFailAppearance, undefined);
assert.equal(drawn.geometryInstances.geometry._width, 0.9);
} else {
const line = h.lineState(edge.id);
assert.equal(line.dash, null);
assert.equal(line.depthFailAlpha, 0);
assert.equal(line.widthPx, 0.9);
h.engine.fake.resetCalls();
const before = h.engine.fake.line(edge.id);
style(edge, { width: 0.9, tint: hue.line.withAlpha(0.6) });
assert.equal(
h.engine.fake.line(edge.id),
before,
'a colour change keeps the line',
);
sameRgba(h.lineState(edge.id).color, hue.line.withAlpha(0.6), 'idle hue');
}
});
test(`picks need this layer's own markers, lines and planes (${name})`, async (t) => {
const h = setup(makeHarness, t);
const record = recordFor(h.state, 'a');
h.display.createMarkers();
record.billboard = h.display.createMarker({ id: 'a' }, POSITIONS.mount);
h.frame();
await h.settle();
h.scriptPicks({ set: 'cctv', index: 0 });
assert.equal(
h.display.extractPickedCameraId(h.display.pick({ x: 1, y: 1 })),
'a',
);
assert.equal(
h.display.extractPickedCameraId({ id: 'a', owner: 'sibling' }),
null,
'a sibling with the camera id is not this layer',
);
record.coverageEntities = h.display.createCoverage('a', POSITIONS);
h.scriptPicks({ line: 'cctv-a-cap' });
assert.equal(
h.display.extractPickedCameraId(h.display.pick({ x: 1, y: 1 })),
'a',
);
record.coverageEntities[4].show = false;
assert.equal(
h.display.extractPickedCameraId({
id: 'cctv-a-cap',
owner: 'cctv-a-cap',
}),
null,
'a hidden line is not picked',
);
record.coverageEntities[4].show = true;
const stray = h.display.createCoverage('b', POSITIONS);
assert.equal(
h.display.extractPickedCameraId({ id: stray[0].id, owner: stray[0].id }),
null,
'a line no record owns is not a camera',
);
h.display.removeCoverageEntity(record.coverageEntities[4]);
assert.equal(
h.display.extractPickedCameraId({
id: 'cctv-a-cap',
owner: 'cctv-a-cap',
}),
null,
);
assert.equal(h.display.extractPickedCameraId(undefined), null);
});
test(`the monitor plane, its outline and material (${name})`, (t) => {
const h = setup(makeHarness, t);
const record = recordFor(h.state, 'a');
const orientation = geo.quaternion.fromAxisAngle(
geo.cartesian3.normalize(p(1, 2, 3), geo.cartesian3.create()),
0.75,
geo.quaternion.create(),
);
const runtime = { cameraId: 'a', planeMaterial: null };
runtime.planeMaterial = h.display.createPlaneMaterial({ width: 8 });
const plane = h.display.createPlane(
{ camera: { id: 'a' } },
runtime,
SHAPE,
POSITIONS,
orientation,
);
runtime.planeEntity = plane;
assert.equal(plane.show, false);
assert.equal(plane.outline.show, false);
h.display.setPlaneVisible(runtime, true);
assert.equal(plane.outline.show, true);
const corners = plane.outline.polyline.positions;
const rotation = geo.matrix3.fromQuaternion(
orientation,
geo.matrix3.create(),
);
for (const [index, [x, y]] of [
[-1, 1],
[1, 1],
[1, -1],
[-1, -1],
[-1, 1],
].entries()) {
const expected = geo.cartesian3.add(
geo.matrix3.multiplyByVector(
rotation,
p(x * SHAPE.halfW, y * SHAPE.halfH, 0),
geo.cartesian3.create(),
),
POSITIONS.capCenter,
geo.cartesian3.create(),
);
near(geo.cartesian3.distance(corners[index], expected), 0, 'corner');
}
if (h.cesium) {
const entity = h.viewer.entities.getById('cctv-a-plane');
const now = Cesium.JulianDate.now();
assert.equal(entity.show, true);
assert.deepEqual(
entity.plane.dimensions.getValue(now),
new Cesium.Cartesian2(40, 22.5),
);
assert.equal(entity.plane.outline, undefined, 'the outline is a line');
assert.equal(entity.plane.material.color.getValue(now).alpha, 0.95);
assert.equal(entity.plane.material.transparent.getValue(now), true);
h.scriptPicks({ native: { id: entity, primitive: {} } });
assert.equal(
h.display.extractPickedCameraId(h.display.pick({ x: 1, y: 1 })),
null,
'a plane no record owns is not a camera',
);
record.projection = runtime;
assert.equal(
h.display.extractPickedCameraId(h.display.pick({ x: 1, y: 1 })),
'a',
);
} else {
const outline = h.lineState('cctv-a-plane-outline');
assert.equal(outline.widthPx, 1);
assert.equal(outline.pickId, 'cctv-a-plane');
sameRgba(outline.color, PLANE_OUTLINE_COLOR, 'outline colour');
record.projection = runtime;
}
h.scriptPicks({ line: 'cctv-a-plane-outline' });
assert.equal(
h.display.extractPickedCameraId(h.display.pick({ x: 1, y: 1 })),
'a',
'the outline picks its camera',
);
const moved = { ...POSITIONS, capCenter: p(6378140, 120, 20) };
h.display.updatePlane(runtime, SHAPE, moved, orientation);
near(
plane.outline.polyline.positions[0].x - corners[0].x,
3,
'the outline moves with the plane',
);
const material = runtime.planeMaterial;
h.display.removePlane(runtime);
assert.equal(material.disposed, true);
assert.equal(h.engine.lines.list().length, 0);
if (h.cesium) assert.equal(h.viewer.entities.values.length, 0);
});
test(`the viewshed volume and the activation probe's ray (${name})`, (t) => {
const mesh = [];
const h = setup(makeHarness, t, {
mesh: { sampleMeshFloorCells: (...args) => mesh.push(args) },
});
const record = recordFor(h.state, 'a', { frustumPositions: POSITIONS });
const markerSet = h.display.createMarkers();
record.billboard = h.display.createMarker({ id: 'a' }, POSITIONS.mount);
h.display.createMarker({ id: 'b' }, POSITIONS.tl);
record.coverageEntities = h.display.createCoverage('a', POSITIONS);
const volume = h.display.createVolume(record, viewshedColors(30).fill);
h.display.sampleMeshFloorCells([{ lat: 0, lon: 0 }], {
excludeObjects: [...record.coverageEntities, record.billboard],
});
const [[target, , { excludeObjects }]] = mesh;
assert.equal(target, h.engine);
assert.ok(excludeObjects.includes(markerSet), 'the marker set');
assert.ok(
record.coverageEntities.every(({ line }) =>
excludeObjects.includes(line),
),
'the engine lines',
);
assert.equal(excludeObjects.length, 6);
const direction = p(0, 1, 0);
if (h.cesium) {
const primitive = volume.native.primitive;
assert.equal(primitive.allowPicking, false);
assert.equal(primitive.asynchronous, false);
assert.ok(h.viewer.scene.primitives.contains(primitive));
let query;
h.viewer.scene.pickFromRay = (ray, exclusions) => {
query = { ray, exclusions };
return {
position: Cesium.Cartesian3.add(
ray.origin,
new Cesium.Cartesian3(0, 20, 0),
new Cesium.Cartesian3(),
),
};
};
const exclude = [...record.coverageEntities, volume];
assert.equal(
h.display.obstructionRay(POSITIONS.mount, direction, exclude, 10),
undefined,
'a hit past the range is no obstruction',
);
assert.ok(
h.display.obstructionRay(POSITIONS.mount, direction, exclude, 30),
);
assert.ok(query.ray instanceof Cesium.Ray);
assert.deepEqual(
query.exclusions.slice(0, 2),
['a', 'b'],
'every marker',
);
assert.ok(query.exclusions.includes('cctv-a-ray-tl'));
assert.ok(query.exclusions.includes(primitive));
h.display.removeVolume(volume);
assert.ok(!h.viewer.scene.primitives.contains(primitive));
} else {
assert.equal(
h.display.obstructionRay(POSITIONS.mount, direction, [], 30),
undefined,
'no ray pick on an engine without one',
);
let ray;
h.engine.surface.raycast = (query) => {
ray = query;
return { position: POSITIONS.tl, distance: 5 };
};
assert.ok(
h.display.obstructionRay(
POSITIONS.mount,
direction,
record.coverageEntities,
30,
),
);
assert.equal(ray.far, 30);
assert.equal(ray.exclude.length, 6);
assert.equal(volume.positions, POSITIONS);
}
});
test(`a clean click reaches the layer; a drag does not (${name})`, (t) => {
const h = setup(makeHarness, t);
const clicks = [];
const moves = [];
const remove = h.display.bindClick((click) => clicks.push(click.position), {
onMouseMove: (movement) => moves.push(movement.endPosition),
});
const press = (to) => {
h.fire('down', { position: { x: 10, y: 10 } });
h.fire('move', { startPosition: { x: 10, y: 10 }, endPosition: to });
h.fire('up', { position: to });
h.fire('click', { position: to });
};
press({ x: 12, y: 11 });
press({ x: 60, y: 10 });
assert.equal(clicks.length, 1);
assert.deepEqual({ ...clicks[0] }, { x: 12, y: 11 });
assert.equal(moves.length, 2);
remove();
press({ x: 12, y: 11 });
assert.equal(clicks.length, 1);
});
test(`focus, credit and teardown (${name})`, (t) => {
const credits = [];
const h = setup(makeHarness, t, {
credits: {
showOsmCredit: (...args) => credits.push(['show', ...args]),
hideOsmCredit: (...args) => credits.push(['hide', ...args]),
},
});
let flight;
h.engine.camera.flyToSphere = (sphere, options) => {
flight = { sphere, options };
};
h.display.focus(
{ position: POSITIONS.mount, camera: { headingDeg: 84, rangeM: 126 } },
0.1,
);
assert.deepEqual(flight, {
sphere: { center: POSITIONS.mount, radius: 45.36 },
options: {
heading: (84 * Math.PI) / 180,
pitch: (-22 * Math.PI) / 180,
range: 280,
duration: 0.2,
easing: 'cubicInOut',
},
});
h.display.showCredit();
h.display.showCredit();
assert.deepEqual(credits, [['show', h.engine, 'cctv']]);
let moves = 0;
h.display.onMoveEnd(() => moves++);
h.engine.camera.moveEnd.raiseEvent();
assert.equal(moves, 1);
h.display.createMarkers();
h.display.createMarker({ id: 'a' }, POSITIONS.mount);
h.display.createCoverage('a', POSITIONS);
const runtime = { planeMaterial: h.display.createPlaneMaterial(null) };
runtime.planeEntity = h.display.createPlane(
{ camera: { id: 'a' } },
runtime,
SHAPE,
POSITIONS,
{ x: 0, y: 0, z: 0, w: 1 },
);
h.display.createVolume(
{ frustumPositions: POSITIONS },
viewshedColors(1).fill,
);
h.display.createGizmoRenderer().createPoint('range', IDLE_CAMERA_COLOR, 10);
assert.equal(h.display.ready, true);
h.display.destroy();
h.display.destroy();
assert.equal(h.display.ready, false);
h.engine.camera.moveEnd.raiseEvent();
assert.equal(moves, 1);
assert.deepEqual(credits.at(-1), ['hide', h.engine, 'cctv']);
assert.equal(h.engine.lines.list().length, 0);
assert.equal(h.engine.movers.list().length, 0);
if (h.cesium) {
assert.equal(h.viewer.entities.values.length, 0);
const drawn = [];
for (let i = 0; i < h.viewer.scene.primitives.length; i++)
drawn.push(h.viewer.scene.primitives.get(i));
assert.ok(
drawn.every((primitive) => !(primitive instanceof Cesium.Primitive)),
'the volume is gone',
);
}
});
}
test('the plane outline closes on its first corner', () => {
const corners = planeOutline(
{ halfW: 2, halfH: 1 },
{ capCenter: p(10, 0, 0) },
{ x: 0, y: 0, z: 0, w: 1 },
);
assert.equal(corners.length, 5);
assert.equal(corners[0], corners[4]);
assert.deepEqual({ ...corners[0] }, { x: 8, y: 1, z: 0 });
assert.deepEqual({ ...corners[2] }, { x: 12, y: -1, z: 0 });
});
test('on Navara the display draws its plane through the backend it is given', async (t) => {
const { createCctvNavaraMeshes } = await import('./navaraMeshes.js');
const engine = createFakeEngine({ kind: 'navara' });
const descriptors = new Map();
const view = {
registerMesh: (key, value) => descriptors.set(key, value),
addMesh(config) {
const key = Object.keys(config)[0];
const desc = new (descriptors.get(key))(view, {}, config);
desc.createMesh();
return { delete: () => desc.onDestroy() };
},
};
class MeshDesc {
onDestroy() {}
}
const pickers = new Set();
engine.picker.registerMeshPicker = (pick) => {
pickers.add(pick);
return () => pickers.delete(pick);
};
engine.extensions = { navara: { view, MeshDesc } };
const state = { _recordById: new Map(), _projectionEntities: [] };
const display = createCctvDisplay(engine, {
state,
navaraMeshes: createCctvNavaraMeshes,
});
t.after(() => engine.destroy());
display.attach();
const record = recordFor(state, 'a');
const runtime = { planeMaterial: display.createPlaneMaterial(null) };
runtime.planeEntity = display.createPlane(
{ camera: { id: 'a' } },
runtime,
SHAPE,
POSITIONS,
{ x: 0, y: 0, z: 0, w: 1 },
);
record.projection = runtime;
assert.equal(pickers.size, 1);
const picked = { id: 'cctv-a-plane', owner: 'cctv-a-plane' };
assert.equal(display.extractPickedCameraId(picked), null, 'hidden');
display.setPlaneVisible(runtime, true);
assert.equal(runtime.planeEntity.mesh.native.group.visible, true);
assert.equal(display.extractPickedCameraId(picked), 'a');
display.destroy();
assert.equal(pickers.size, 0);
});
@@ -0,0 +1,369 @@
// The CCTV calibration gizmo drawn by the display, on the fake engine and
// on the Cesium engine: drags through engine.input that patch only the
// pinned record and hand the camera back, picks that need the gizmo's own
// handles, the handles' geometry, hover sizes, cancellation and teardown.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../../geo/index.js';
import { createCalibrationGizmo } from '../../../data/cctvGizmo.js';
import { CCTV_HARNESSES } from '../../../testSupport/cctvEngines.mjs';
import { IDLE_CAMERA_COLOR } from '../policy.js';
import { createCctvDisplay } from './display.js';
const p = (x, y, z) => geo.cartesian3.fromElements(x, y, z);
const near = (actual, expected, label) =>
assert.ok(Math.abs(actual - expected) < 1e-5, `${label}: ${actual}`);
function setup(makeHarness, t) {
const harness = makeHarness();
const display = createCctvDisplay(harness.engine, { state: {} });
display.attach();
// A fixed ray per screen x: along -X from 100 m over the mount.
harness.engine.camera.pickRay = ({ x }) => ({
origin: p(6378237, x, 0),
direction: p(-1, 0, 0),
});
const renderer = display.createGizmoRenderer();
const record = {
camera: {
headingDeg: 0,
pitchDeg: 0,
rangeM: 100,
calibration: {
headingDeg: 0,
pitchDeg: 0,
offsetEastM: 0,
offsetNorthM: 0,
heightM: 0,
rangeScale: 1,
fovDeg: 0,
},
basePose: { rangeM: 100, fovDeg: 60 },
},
frustumGeometry: { rangeM: 100 },
frustumPositions: {
mount: p(6378137, 0, 0),
capCenter: p(6378137, 0, 100),
tl: p(6378150, -20, 100),
tr: p(6378150, 20, 100),
bl: p(6378124, -20, 100),
br: p(6378124, 20, 100),
},
};
let active = record;
const patches = [];
const ends = [];
const gizmo = createCalibrationGizmo({
renderer,
getActiveRecord: () => active,
applyPatch: (patch, pinned) => patches.push({ patch, pinned }),
endPatch: (pinned) => ends.push(pinned),
});
t.after(() => {
gizmo.destroy();
display.destroy();
harness.destroy();
});
gizmo.setEnabled(true);
const event = (type, x = 10) =>
harness.fire(type, {
position: { x, y: 0 },
startPosition: { x, y: 0 },
endPosition: { x, y: 0 },
});
return {
...harness,
display,
gizmo,
renderer,
record,
patches,
ends,
event,
choose: (target = { line: 'cctv-gizmo-move-east' }) =>
harness.scriptPicks(target),
switchCamera: () => {
active = { ...record };
},
};
}
for (const makeHarness of CCTV_HARNESSES) {
const { name } = makeHarness();
for (const ending of ['release', 'disable', 'destroy', 'selection']) {
test(`a gizmo ${ending} hands the camera back and ends only the pinned record (${name})`, (t) => {
const h = setup(makeHarness, t);
h.choose();
h.event('down');
assert.equal(h.gizmo.isDragging(), true);
assert.deepEqual(h.cameraInputs(), [false]);
if (ending === 'selection') {
h.switchCamera();
h.event('move', 25);
} else {
h.event('move', 25);
assert.equal(h.patches.length, 1);
assert.equal(h.patches[0].pinned, h.record);
assert.ok(Math.abs(h.patches[0].patch.offsetEastM - 15) < 1e-8);
if (ending === 'release') h.event('up');
if (ending === 'disable') h.gizmo.setEnabled(false);
if (ending === 'destroy') h.gizmo.destroy();
}
assert.deepEqual(h.cameraInputs(), [false, true]);
assert.deepEqual(h.ends, [h.record]);
assert.equal(h.gizmo.isDragging(), false);
h.event('up');
assert.equal(h.ends.length, 1);
if (ending === 'selection') assert.equal(h.patches.length, 0);
});
}
test(`the gizmo needs its own handle, not a sibling with its id (${name})`, (t) => {
const h = setup(makeHarness, t);
const sibling = h.engine.lines.createLine({
id: 'sibling',
pickId: 'cctv-gizmo-move-east',
widthPx: 1,
color: [1, 1, 1, 1],
});
sibling.setPositions([p(6378137, 0, 0), p(6378137, 1, 0)]);
h.choose({ line: 'sibling' });
h.event('down');
assert.equal(h.gizmo.isDragging(), false);
h.choose({ set: 'cctv-gizmo-points', index: 0 });
h.event('down');
assert.equal(h.gizmo.isDragging(), true, 'an arrow tip drags its arrow');
sibling.destroy();
});
test(`the gizmo draws eight handles and releases only those (${name})`, (t) => {
const h = setup(makeHarness, t);
const foreign = h.engine.lines.createLine({
id: 'foreign',
widthPx: 1,
color: [1, 1, 1, 1],
});
const lines = h.engine.lines.list().filter(({ id }) => id !== 'foreign');
assert.deepEqual(
lines.map(({ id }) => id),
[
'cctv-gizmo-ring-heading',
'cctv-gizmo-ring-pitch',
'cctv-gizmo-move-east',
'cctv-gizmo-move-north',
'cctv-gizmo-move-up',
],
);
assert.ok(lines.every(({ shown }) => shown));
assert.equal(lines[0].points, 97);
const [points] = h.engine.movers.list();
assert.deepEqual(
{ id: points.id, kind: points.kind, count: points.count },
{ id: 'cctv-gizmo-points', kind: 'point', count: 6 },
);
if (h.cesium) {
const ring = h.primitive('cctv-gizmo-ring-heading');
const first = ring.geometryInstances.geometry._positions[0];
assert.ok(
Math.abs(
geo.cartesian3.distance(first, h.record.frustumPositions.mount) - 12,
) < 1e-6,
);
const east = h.primitive('cctv-gizmo-move-east');
assert.equal(east.geometryInstances.geometry._width, 12);
assert.equal(east.appearance.material.type, 'PolylineArrow');
assert.ok(
Math.abs(
east.depthFailAppearance.material.uniforms.color.alpha - 0.45,
) < 1e-6,
);
const heading = h.primitive('cctv-gizmo-ring-heading');
assert.ok(
Math.abs(heading.appearance.material.uniforms.color.alpha - 0.9) < 1e-6,
);
assert.ok(
Math.abs(
heading.depthFailAppearance.material.uniforms.color.alpha - 0.45,
) < 1e-6,
);
const tips = h.primitive('cctv-gizmo-points');
const tip = tips.get(0);
near(tip.pixelSize, 14, 'tip size');
assert.equal(tip.outlineWidth, 2);
assert.ok(Math.abs(tip.outlineColor.alpha - 0.7) < 1e-6);
assert.equal(tip.disableDepthTestDistance, Number.POSITIVE_INFINITY);
assert.ok(Math.abs(tip.position.y - 19.2) < 1e-6);
near(tips.get(3).pixelSize, 13, 'range handle size');
} else {
const ring = h.engine.fake.line('cctv-gizmo-ring-heading');
assert.ok(
Math.abs(
geo.cartesian3.distance(
p(...ring.positions.slice(0, 3)),
h.record.frustumPositions.mount,
) - 12,
) < 1e-6,
);
const east = h.lineState('cctv-gizmo-move-east');
assert.equal(east.widthPx, 12);
assert.equal(east.arrow, true);
assert.ok(Math.abs(east.depthFailAlpha - 0.45) < 1e-6);
const heading = h.lineState('cctv-gizmo-ring-heading');
assert.ok(Math.abs(heading.color[3] - 0.9) < 1e-6);
assert.ok(Math.abs(heading.depthFailAlpha - 0.5) < 1e-6);
const set = h.engine.fake.moverSet('cctv-gizmo-points');
assert.equal(set.options.depthTest, 'always');
assert.equal(set.options.order, 100);
assert.deepEqual(set.options.point.outlineColor, [0, 0, 0, 0.7]);
assert.equal(set.options.point.outlinePx, 2);
assert.equal(set.store.pickIds[0], 'cctv-gizmo-move-east');
assert.ok(Math.abs(set.store.positions[1] - 19.2) < 1e-6);
near(set.store.scales[0] * set.options.point.sizePx, 14, 'tip size');
near(set.store.scales[3] * set.options.point.sizePx, 13, 'range size');
}
h.gizmo.destroy();
h.gizmo.destroy();
assert.deepEqual(
h.engine.lines.list().map(({ id }) => id),
['foreign'],
);
assert.equal(h.engine.movers.list().length, 0);
assert.equal(h.canvas.style.cursor, '');
foreign.destroy();
});
test(`hovering a handle grows it and shows the grab cursor (${name})`, (t) => {
const h = setup(makeHarness, t);
h.choose({ set: 'cctv-gizmo-points', index: 3 });
h.event('move');
assert.equal(h.canvas.style.cursor, 'grab');
const size = (index) =>
h.cesium
? h.primitive('cctv-gizmo-points').get(index).pixelSize
: h.engine.fake.moverSet('cctv-gizmo-points').store.scales[index] * 10;
near(size(3), 17, 'the hovered range handle');
near(size(0), 10, 'an arrow tip takes the handle size');
near(size(4), 10, 'a field-of-view handle');
});
for (const cancel of ['pointercancel', 'blur']) {
test(`a ${cancel} ends a pinned drag and releases the camera (${name})`, (t) => {
const h = setup(makeHarness, t);
h.choose();
h.event('down');
const target =
cancel === 'blur' ? h.canvas.ownerDocument.defaultView : h.canvas;
target.dispatchEvent(new Event(cancel));
assert.equal(h.gizmo.isDragging(), false);
assert.deepEqual(h.cameraInputs(), [false, true]);
assert.deepEqual(h.ends, [h.record]);
h.gizmo.destroy();
target.dispatchEvent(new Event(cancel));
assert.equal(h.ends.length, 1);
});
}
test(`a second down cannot start another drag (${name})`, (t) => {
const h = setup(makeHarness, t);
h.choose();
h.event('down');
h.event('down');
h.event('up');
assert.deepEqual(h.cameraInputs(), [false, true]);
assert.deepEqual(h.ends, [h.record]);
});
test(`a handle id another owner holds stops the gizmo cleanly (${name})`, (t) => {
const h = setup(makeHarness, t);
assert.throws(
() => h.renderer.createPoint('move-east', IDLE_CAMERA_COLOR, 10),
/already owned/,
);
h.gizmo.destroy();
const sibling = h.engine.lines.createLine({
id: 'cctv-gizmo-move-east',
widthPx: 1,
color: [1, 1, 1, 1],
});
assert.throws(
() =>
createCalibrationGizmo({
renderer: h.display.createGizmoRenderer(),
getActiveRecord: () => null,
applyPatch() {},
endPatch() {},
}),
/already exists/,
);
assert.deepEqual(
h.engine.lines.list().map(({ id }) => id),
['cctv-gizmo-move-east'],
);
assert.equal(h.engine.movers.list().length, 0);
sibling.destroy();
});
test(`a destroyed gizmo drops its input and listeners (${name})`, (t) => {
const h = setup(makeHarness, t);
let cancels = 0;
let moves = 0;
h.renderer.on('cancel', () => cancels++);
h.renderer.on('move', () => moves++);
h.canvas.dispatchEvent(new Event('pointercancel'));
h.canvas.ownerDocument.defaultView.dispatchEvent(new Event('blur'));
h.event('move');
assert.equal(cancels, 2);
assert.equal(moves, 1);
h.renderer.setCursor('grabbing');
h.gizmo.destroy();
h.canvas.dispatchEvent(new Event('pointercancel'));
h.event('move');
assert.equal(cancels, 2);
assert.equal(moves, 1);
assert.equal(h.canvas.style.cursor, '');
assert.throws(
() => h.renderer.createPoint('new', IDLE_CAMERA_COLOR, 10),
/destroyed/,
);
});
}
test("an arrow's shaft goes when its tip cannot be drawn", (t) => {
const [makeHarness] = CCTV_HARNESSES;
const harness = makeHarness();
t.after(() => harness.destroy());
const display = createCctvDisplay(harness.engine, { state: {} });
harness.engine.movers.createSet = () => {
throw new Error('allocation failed');
};
const renderer = display.createGizmoRenderer();
const color = { withAlpha: () => color, rgba: () => [1, 1, 1, 1], alpha: 1 };
assert.throws(
() => renderer.createLine('east', color, 8, true),
/allocation failed/,
);
assert.equal(harness.engine.lines.list().length, 0);
display.destroy();
});
test('a handle construction failure releases already-created resources', () => {
const handles = new Set();
let destroys = 0;
const renderer = {
createLine(part) {
if (part === 'move-east') throw new Error('ID already owned');
const handle = { part };
handles.add(handle);
return handle;
},
destroy() {
destroys++;
handles.clear();
},
};
assert.throws(() => createCalibrationGizmo({ renderer }), /already owned/);
assert.equal(handles.size, 0);
assert.equal(destroys, 1);
});
+171
View File
@@ -0,0 +1,171 @@
// The CCTV meshes the globe contract has no primitive for yet, behind one
// seam: the monitor plane (a quad textured with a live canvas or video) and
// the viewshed volume (the frustum pyramid in a flat translucent colour,
// both faces, not pickable). Movers draw a glTF per instance at a uniform
// scale and lines draw strips, so neither carries a texture that changes
// every frame or a pyramid whose corners move independently.
//
// This is the only CCTV module that reads engine.extensions. Each engine
// draws through its own backend: render/cesiumMeshes.js (Cesium entities and
// primitives) and render/navaraMeshes.js (three meshes in Navara's view),
// which the Navara mode loads and passes in, as Navara modules reach the app
// only through its import(). The handles here keep what the layer asked
// for, so an engine with no backend (the fake engine) draws nothing and
// still answers. The plane's outline is an engine line, drawn by the display.
import { createCctvCesiumMeshes } from './cesiumMeshes.js';
/**
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {{ navaraMeshes?: (engine: object, navara: object) => object }} [options]
* The Navara backend's factory (render/navaraMeshes.js).
*/
export function createCctvMeshes(engine, { navaraMeshes } = {}) {
const cesium = engine.extensions?.cesium;
const navara = engine.extensions?.navara;
const materials = new Set();
const planes = new Set();
const volumes = new Set();
const planeByNative = new Map();
const backend = cesium
? createCctvCesiumMeshes(cesium)
: navara && navaraMeshes
? navaraMeshes(engine, navara)
: null;
let destroyed = false;
const alive = () => {
if (destroyed) throw new Error('CCTV meshes are destroyed');
};
return {
/** A plane material showing `image` (a canvas or video), or plain white. */
createMaterial(image) {
alive();
const material = {
image: image ?? null,
native: null,
disposed: false,
setImage(next) {
if (material.disposed) return;
material.image = next;
material.native?.setImage(next);
},
dispose() {
if (material.disposed) return;
material.disposed = true;
materials.delete(material);
material.native?.dispose();
material.native = null;
},
};
material.native = backend?.createMaterial(material.image) ?? null;
materials.add(material);
return material;
},
/**
* The monitor plane, hidden: `shape` { halfW, halfH } metres, centred on
* positions.capCenter, its local +Z along `orientation`'s normal.
*/
createPlane(cameraId, material, shape, positions, orientation) {
alive();
if (!materials.has(material))
throw new Error('CCTV plane material is not owned');
const plane = {
id: `cctv-${cameraId}-plane`,
cameraId,
material,
shape,
positions,
orientation,
visible: false,
native: null,
destroyed: false,
get show() {
return !plane.destroyed && plane.visible;
},
set show(value) {
if (plane.destroyed) return;
plane.visible = Boolean(value);
if (plane.native) plane.native.show = plane.visible;
},
destroy() {
if (plane.destroyed) return;
plane.destroyed = true;
planes.delete(plane);
if (plane.native) {
planeByNative.delete(plane.native);
plane.native.destroy();
plane.native = null;
}
},
};
if (backend) {
plane.native = backend.createPlane(
cameraId,
material.native,
shape,
positions,
orientation,
);
planeByNative.set(plane.native, plane);
}
planes.add(plane);
return plane;
},
updatePlane(plane, shape, positions, orientation) {
if (!planes.has(plane)) return;
Object.assign(plane, { shape, positions, orientation });
if (plane.native)
backend.updatePlane(plane.native, shape, positions, orientation);
},
/** The viewshed volume over positions { mount, tl, tr, br, bl }. */
createVolume(positions, tint) {
alive();
const volume = {
positions,
tint,
native: null,
destroyed: false,
destroy() {
if (volume.destroyed) return;
volume.destroyed = true;
volumes.delete(volume);
volume.native?.destroy();
volume.native = null;
},
};
volume.native = backend?.createVolume(positions, tint) ?? null;
volumes.add(volume);
return volume;
},
/** The live, shown plane an engine pick result names, or null. */
planeFromPick(picked) {
if (!backend || !picked) return null;
const plane = planeByNative.get(backend.planeFromPick(picked));
return plane?.show ? plane : null;
},
/** What draws a plane or volume, for an engine `exclude` list. */
drawnBy(handle) {
return backend && handle?.native
? backend.drawnBy(handle.native)
: undefined;
},
/**
* The first scene object along a ray within `far` metres, skipping
* `exclude` (pick ids and drawn objects), on an engine without
* surface.raycast; undefined where the backend has no ray pick.
*/
obstructionRay(origin, direction, far, exclude) {
return backend?.obstructionRay?.(origin, direction, far, exclude);
},
destroy() {
if (destroyed) return;
destroyed = true;
for (const plane of [...planes]) plane.destroy();
for (const volume of [...volumes]) volume.destroy();
for (const material of [...materials]) material.dispose();
backend?.destroy();
},
};
}
+124
View File
@@ -0,0 +1,124 @@
// The CCTV meshes seam: what an engine without a backend keeps, and the
// Navara backend the Navara mode passes in.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { viewshedColors } from '../../../data/cctvViewshed.js';
import { createCctvMeshes } from './meshes.js';
const shape = { halfW: 8, halfH: 4.5 };
const positions = {
mount: { x: 6378137, y: 0, z: 0 },
tl: { x: 6378147, y: -1, z: 1 },
tr: { x: 6378147, y: 1, z: 1 },
br: { x: 6378147, y: 1, z: -1 },
bl: { x: 6378147, y: -1, z: -1 },
capCenter: { x: 6378147, y: 0, z: 0 },
};
const identity = { x: 0, y: 0, z: 0, w: 1 };
/** A backend that records what it is asked to draw. */
function recordingBackend(calls) {
const native = (kind, args) => {
const object = {
kind,
args,
show: false,
destroy: () => calls.push(['destroy', kind]),
};
calls.push(['create', kind, object]);
return object;
};
return {
createMaterial: (image) => ({
...native('material', [image]),
setImage: (image) => calls.push(['image', image]),
dispose: () => calls.push(['dispose', 'material']),
}),
createPlane: (...args) => native('plane', args),
updatePlane: (plane, ...args) => calls.push(['update', ...args]),
createVolume: (...args) => native('volume', args),
planeFromPick: (picked) => picked.native,
drawnBy: (object) => object,
destroy: () => calls.push(['destroy', 'backend']),
};
}
test('an engine without a backend keeps the handles and draws nothing', () => {
const engine = createFakeEngine();
const meshes = createCctvMeshes(engine);
const material = meshes.createMaterial(null);
const plane = meshes.createPlane('a', material, shape, positions, identity);
assert.equal(plane.id, 'cctv-a-plane');
assert.equal(plane.show, false);
plane.show = true;
assert.equal(plane.show, true);
assert.equal(meshes.planeFromPick({ id: plane.id }), null);
assert.equal(meshes.drawnBy(plane), undefined);
assert.equal(
meshes.obstructionRay(positions.mount, positions.tl, 10, []),
undefined,
);
assert.throws(
() => meshes.createPlane('b', { image: null }, shape, positions, identity),
/not owned/,
);
meshes.destroy();
assert.equal(plane.show, false);
assert.equal(material.disposed, true);
assert.throws(() => meshes.createMaterial(null), /destroyed/);
});
test('the Navara backend the Navara mode passes in draws the meshes', () => {
const engine = createFakeEngine({ kind: 'navara' });
const navara = { view: {}, MeshDesc: class {} };
engine.extensions = { navara };
const calls = [];
let given;
const meshes = createCctvMeshes(engine, {
navaraMeshes(target, extension) {
given = { target, extension };
return recordingBackend(calls);
},
});
assert.equal(given.target, engine);
assert.equal(given.extension, navara);
const material = meshes.createMaterial('canvas');
material.setImage('video');
const plane = meshes.createPlane('a', material, shape, positions, identity);
const volume = meshes.createVolume(positions, viewshedColors(4).fill);
const created = calls.filter(([type]) => type === 'create');
assert.deepEqual(
created.map(([, kind]) => kind),
['material', 'plane', 'volume'],
);
assert.deepEqual(calls[1], ['image', 'video']);
const [, , drawnPlane] = created[1];
assert.equal(drawnPlane.args[1], material.native, 'its drawn material');
plane.show = true;
assert.equal(drawnPlane.show, true);
assert.equal(meshes.planeFromPick({ native: drawnPlane }), plane);
plane.show = false;
assert.equal(meshes.planeFromPick({ native: drawnPlane }), null);
meshes.updatePlane(plane, shape, positions, identity);
assert.deepEqual(calls.at(-1), ['update', shape, positions, identity]);
assert.equal(meshes.drawnBy(volume), created[2][2]);
meshes.destroy();
meshes.destroy();
assert.deepEqual(calls.slice(-4), [
['destroy', 'plane'],
['destroy', 'volume'],
['dispose', 'material'],
['destroy', 'backend'],
]);
});
test('a Navara engine without the backend draws nothing', () => {
const engine = createFakeEngine({ kind: 'navara' });
engine.extensions = { navara: {} };
const meshes = createCctvMeshes(engine);
const volume = meshes.createVolume(positions, viewshedColors(4).fill);
assert.equal(meshes.drawnBy(volume), undefined);
meshes.destroy();
});
-338
View File
@@ -1,338 +0,0 @@
import * as geo from '../../../geo/index.js';
import {
bindTrackingClickGesture,
isTrackingClickGesture,
} from '../../../data/trackingClickGesture.js';
import { createCctvNavaraMeshes } from './navaraMeshes.js';
import { createCctvNavaraLine } from './navaraLine.js';
import { createNavaraCctvGizmoRenderer } from './navaraGizmo.js';
import {
CAMERA_ICON,
IDLE_CAMERA_COLOR,
IDLE_COVERAGE_COLOR,
} from '../policy.js';
export function createCctvNavaraRenderer(
engine,
{ state, services, view, MeshDesc },
) {
const meshes = createCctvNavaraMeshes(engine, { view, MeshDesc });
const markers = [];
const coverage = new Set();
const planes = new Set();
const gizmos = new Set();
const removers = new Set();
let initialized = false,
destroyed = false,
visible = true,
dirty = false;
let set = null,
capacity = 0,
credit = null;
const request = () => engine.render.request('cctv');
const markDirty = () => {
if (dirty || destroyed) return;
dirty = true;
request();
};
function syncMarkers() {
if (!dirty || !set || destroyed) return;
dirty = false;
set.setCount(markers.length);
markers.forEach((marker, index) =>
set.set(index, {
position: marker.position,
show: visible && marker.show,
scale: marker.scale,
color: marker.color.rgba(),
cell: 'camera',
pickId: marker.id,
}),
);
set.commit();
}
function allocateMarkers(count) {
if (set && capacity >= count) return;
set?.destroy();
capacity = Math.max(128, count, capacity * 2);
set = engine.movers.createSet({
id: 'cctv',
kind: 'sprite',
capacity,
order: 50,
pickable: true,
depthTest: 'always',
sharpen: true,
sprite: {
sizePx: 24,
scaleByDistance: [350, 1.25, 4_000_000, 0.42],
atlas: { url: CAMERA_ICON, cells: { camera: [0, 0, 1, 1] } },
},
});
markDirty();
}
const ownListener = (remove) => {
const release = () => {
if (removers.delete(release)) remove();
};
removers.add(release);
return release;
};
function pickedCameraId(picked) {
if (!picked) return null;
if (picked.owner === 'cctv' && state._recordById.has(picked.id))
return picked.id;
for (const entity of [...coverage, ...planes])
if (picked.id === entity.id && picked.owner === entity.id && entity.show)
return entity.cameraId;
return null;
}
const removeMeshPicker = engine.picker.registerMeshPicker((at) => {
const hit = meshes.pickPlane(engine.camera.pickRay(at));
if (!hit) return [];
// The rendered depth includes terrain, tiles and foreground models.
const surface = engine.picker.pickPosition(at);
if (
surface &&
geo.cartesian3.distance(engine.camera.livePosition, surface) + 0.1 <
hit.distance
)
return [];
return [
{
onTop: 0,
order: 0,
depth: hit.distance,
result: { id: hit.plane.id, owner: hit.plane.id },
},
];
});
const removeFrame = engine.frames.preRender.addEventListener(syncMarkers);
const removePlane = (runtime) => {
const plane = runtime?.planeEntity;
if (planes.delete(plane)) plane.destroy();
runtime?.planeMaterial?.dispose();
};
const points = (p) => [p.tl, p.tr, p.br, p.bl, p.tl];
return {
attach() {
if (destroyed) throw new Error('CCTV renderer is destroyed');
initialized = true;
},
get ready() {
return initialized && !destroyed && !engine.isDestroyed();
},
cameraCartographic: () => engine.camera.pose().cartographic,
following: () => Boolean(engine.camera.following()),
globeVisible: () => !engine.maps.photorealActive(),
tilesReady: () => engine.surface.tilesLoaded(),
project: (position, result) =>
engine.projector.worldToWindow(position, result),
distance: (position) =>
geo.cartesian3.distance(engine.camera.livePosition, position),
horizonTest: () => (position) => engine.projector.horizonVisible(position),
viewport: () => engine.host.size(),
request,
pick(position) {
syncMarkers();
return engine.picker.pick(position);
},
extractPickedCameraId: pickedCameraId,
onMoveEnd: (fn) => ownListener(engine.camera.moveEnd.addEventListener(fn)),
onMoveStart: (fn) =>
ownListener(engine.camera.moveStart.addEventListener(fn)),
bindClick(onClick, options = {}) {
const removes = [];
bindTrackingClickGesture(
{
setInputAction(fn, type) {
removes.push(engine.input.on(type, fn));
},
},
(event, gesture) => {
if (isTrackingClickGesture(gesture)) onClick(event);
},
{
...options,
eventTypes: {
LEFT_DOWN: 'down',
MOUSE_MOVE: 'move',
LEFT_UP: 'up',
LEFT_CLICK: 'click',
},
},
);
return ownListener(() => removes.forEach((remove) => remove()));
},
createGizmoRenderer() {
const gizmo = createNavaraCctvGizmoRenderer(engine);
gizmos.add(gizmo);
return gizmo;
},
createMarkers() {
allocateMarkers(1);
return set;
},
createMarker(camera, position) {
const marker = { id: camera.id };
for (const [key, initial] of Object.entries({
position,
color: IDLE_CAMERA_COLOR,
scale: 1,
show: true,
})) {
const copy = (item) => (key === 'position' ? { ...item } : item);
const equal = (a, b) =>
key === 'position'
? a.x === b.x && a.y === b.y && a.z === b.z
: key === 'color'
? a.red === b.red &&
a.green === b.green &&
a.blue === b.blue &&
a.alpha === b.alpha
: a === b;
let value = copy(initial);
Object.defineProperty(marker, key, {
enumerable: true,
get: () => value,
set(next) {
if (!equal(next, value)) {
value = copy(next);
markDirty();
}
},
});
}
markers.push(marker);
allocateMarkers(markers.length);
markDirty();
return marker;
},
setMarkersVisible(value) {
if (visible !== Boolean(value)) {
visible = Boolean(value);
markDirty();
}
},
restoreOrder: syncMarkers,
showCredit() {
credit ??= engine.credits.addStatic(
'© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors',
);
},
hideCredit() {
credit?.();
credit = null;
},
sampleMeshFloorCells: (p, options) =>
services.mesh.sampleMeshFloorCells(engine, p, options),
obstructionRay: (origin, direction, exclude, far) =>
engine.surface.raycast({ origin, direction, exclude, far }),
createPlaneMaterial: meshes.createMaterial,
createTestPlaneMaterial: () => meshes.createMaterial(null),
setPlaneImage: (runtime, image) => runtime.planeMaterial.setImage(image),
createPlane(record, runtime, geometry, p, orientation) {
const plane = meshes.createPlane(
record.camera.id,
runtime.planeMaterial,
geometry,
p,
orientation,
);
planes.add(plane);
return plane;
},
updatePlane: (runtime, geometry, p, orientation) =>
meshes.updatePlane(runtime.planeEntity, geometry, p, orientation),
setPlaneVisible(runtime, value) {
if (runtime.planeEntity) runtime.planeEntity.show = value;
},
removePlane,
createCoverage(cameraId, p) {
const created = [];
try {
for (const [role, positions] of [
['ray-tl', [p.mount, p.tl]],
['ray-tr', [p.mount, p.tr]],
['ray-br', [p.mount, p.br]],
['ray-bl', [p.mount, p.bl]],
['cap', points(p)],
]) {
const line = createCctvNavaraLine(
engine,
`cctv-${cameraId}-${role}`,
{ width: 1.2, tint: IDLE_COVERAGE_COLOR },
positions,
);
line.cameraId = cameraId;
line._coverageRole = role === 'cap' ? 'cap' : 'edge';
coverage.add(line);
created.push(line);
}
return created;
} catch (error) {
for (const line of created) {
coverage.delete(line);
line.destroy();
}
throw error;
}
},
updateCoverage(record, p) {
const lines = record.coverageEntities;
if (!lines || lines.length < 5) return;
['tl', 'tr', 'br', 'bl'].forEach((role, i) => {
lines[i].polyline.positions = [p.mount, p[role]];
});
lines[4].polyline.positions = points(p);
},
removeCoverageEntity(entity) {
if (coverage.delete(entity)) entity.destroy();
},
styleLine: (entity, style) => entity.style(style),
hideCoverage(record) {
for (const entity of record.coverageEntities || []) entity.show = false;
},
removeCoverage(record) {
for (const entity of record.coverageEntities || [])
if (coverage.delete(entity)) entity.destroy();
},
createVolume: (record, tint) =>
meshes.createVolume(record.frustumPositions, tint),
removeVolume: (volume) => volume?.destroy(),
focus(record, duration) {
engine.camera.flyToSphere(
{
center: record.position,
radius: Math.max(40, record.camera.rangeM * 0.36),
},
{
heading: geo.math.toRadians(record.camera.headingDeg),
pitch: geo.math.toRadians(-22),
range: Math.max(280, record.camera.rangeM * 1.18),
duration: Math.max(0.2, duration || 0),
easing: 'cubicInOut',
},
);
},
destroy() {
if (destroyed) return;
destroyed = true;
initialized = false;
removeFrame();
removeMeshPicker();
for (const remove of [...removers]) remove();
for (const gizmo of gizmos) gizmo.destroy();
for (const line of coverage) line.destroy();
meshes.destroy();
set?.destroy();
set = null;
credit?.();
credit = null;
markers.length = 0;
coverage.clear();
planes.clear();
gizmos.clear();
},
};
}
-119
View File
@@ -1,119 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCctvNavaraRenderer } from './navara.js';
function fixture(t) {
const engine = createFakeEngine({ kind: 'navara' });
const pickers = new Set();
engine.picker.registerMeshPicker = (pick) => {
pickers.add(pick);
return () => pickers.delete(pick);
};
const descriptors = new Map();
const view = {
registerMesh: (key, value) => descriptors.set(key, value),
addMesh(config) {
const key = Object.keys(config)[0];
const desc = new (descriptors.get(key))(view, {}, config);
desc.createMesh();
return { delete: () => desc.onDestroy() };
},
};
class MeshDesc {
onDestroy() {}
}
const state = { _recordById: new Map() };
const renderer = createCctvNavaraRenderer(engine, {
state,
services: {},
view,
MeshDesc,
});
renderer.attach();
t.after(() => renderer.destroy());
return { engine, renderer, state, pickers };
}
test('CCTV focus uses the engine sphere-flight fields and native range/pitch', (t) => {
const { engine, renderer } = fixture(t);
let flight;
engine.camera.flyToSphere = (sphere, options) => {
flight = { sphere, options };
};
const position = { x: 100, y: 200, z: 300 };
renderer.focus({ position, camera: { headingDeg: 84, rangeM: 126 } }, 0.2);
assert.deepEqual(flight, {
sphere: { center: position, radius: 45.36 },
options: {
heading: (84 * Math.PI) / 180,
pitch: (-22 * Math.PI) / 180,
range: 280,
duration: 0.2,
easing: 'cubicInOut',
},
});
});
test('CCTV markers own position snapshots and batch mutations without idle writes', (t) => {
const { engine, renderer } = fixture(t);
renderer.createMarkers();
const position = { x: 6378137, y: 10, z: 20 };
const marker = renderer.createMarker({ id: 'camera' }, position);
renderer.restoreOrder();
const store = engine.fake.moverSet('cctv').store;
position.y = 35;
marker.position = position;
engine.fake.frame();
assert.equal(store.positions[1], 35);
position.y = 42;
assert.equal(
marker.position.y,
35,
'a caller cannot mutate the retained position',
);
engine.fake.resetCalls();
marker.position = { ...marker.position };
marker.color = marker.color.withAlpha(marker.color.alpha);
renderer.restoreOrder();
assert.equal(engine.fake.callsOf('render.request').length, 0);
renderer.setMarkersVisible(false);
renderer.restoreOrder();
assert.equal(store.shows[0], 0);
});
test('CCTV picking requires actual owned primitives, and destroy removes its picker', (t) => {
const { engine, renderer, state, pickers } = fixture(t);
renderer.createMarkers();
const position = { x: 6378137, y: 10, z: 20 };
renderer.createMarker({ id: 'a' }, position);
state._recordById.set('a', {});
assert.equal(
renderer.extractPickedCameraId({ id: 'a', owner: 'sibling' }),
null,
);
assert.equal(renderer.extractPickedCameraId({ id: 'a', owner: 'cctv' }), 'a');
const p = {
mount: position,
tl: position,
tr: position,
br: position,
bl: position,
};
const lines = renderer.createCoverage('a', p);
lines[0].show = true;
assert.equal(
renderer.extractPickedCameraId({ id: lines[0].id, owner: lines[0].id }),
'a',
);
renderer.removeCoverageEntity(lines[0]);
assert.equal(
renderer.extractPickedCameraId({ id: lines[0].id, owner: lines[0].id }),
null,
);
renderer.destroy();
renderer.destroy();
assert.equal(pickers.size, 0);
assert.equal(engine.lines.list().length, 0);
assert.equal(engine.movers.list().length, 0);
});
-150
View File
@@ -1,150 +0,0 @@
import { GIZMO_ID_PREFIX } from '../../../data/cctvGizmo.js';
import { createCctvNavaraLine } from './navaraLine.js';
export function createNavaraCctvGizmoRenderer(engine) {
const entities = new Map();
const removers = [];
let destroyed = false;
const pointSets = new Set();
const makePoint = (part, color, size) => {
const id = `${GIZMO_ID_PREFIX}${part}`;
const set = engine.movers.createSet({
id,
kind: 'point',
capacity: 1,
pickable: true,
depthTest: 'always',
order: 100,
point: { sizePx: size, outlinePx: 2, outlineColor: [0, 0, 0, 0.7] },
});
pointSets.add(set);
let position = { x: 0, y: 0, z: 0 };
let visible = false;
let pixelSize = size;
const sync = () => {
if (destroyed) return;
set.setCount(1);
set.set(0, {
position,
show: visible,
scale: pixelSize / size,
color: color.rgba(),
pickId: id,
});
set.commit();
};
return {
id,
get position() {
return position;
},
set position(value) {
position = value;
sync();
},
get show() {
return visible;
},
set show(value) {
if (visible !== Boolean(value)) {
visible = Boolean(value);
sync();
}
},
point: {
get pixelSize() {
return pixelSize;
},
set pixelSize(value) {
if (value !== pixelSize) {
pixelSize = value;
sync();
}
},
},
};
};
const add = (part, entity) => {
entities.set(part, entity);
return entity;
};
const assertNew = (part) => {
if (destroyed) throw new Error('CCTV gizmo is destroyed');
if (entities.has(part))
throw new Error(`CCTV handle is already owned: ${part}`);
};
return {
createPoint(part, color, size) {
assertNew(part);
return add(part, makePoint(part, color, size));
},
createLine(part, color, width, arrow = false) {
assertNew(part);
const line = createCctvNavaraLine(engine, `${GIZMO_ID_PREFIX}${part}`, {
tint: color.withAlpha(arrow ? 1 : 0.9),
depthFail: color.withAlpha(0.45),
width,
arrow,
order: 100,
});
if (!arrow) return add(part, line);
let point;
try {
point = makePoint(part, color, 14);
} catch (error) {
line.destroy();
throw error;
}
const visibility = Object.getOwnPropertyDescriptor(point, 'show');
Object.defineProperty(point, 'show', {
get: visibility.get,
set(value) {
visibility.set(value);
line.show = value;
},
});
point.polyline = line.polyline;
point.destroy = () => line.destroy();
return add(part, point);
},
pickPart(position) {
for (const hit of engine.picker.drillPick(position, 6)) {
if (typeof hit?.id !== 'string' || !hit.id.startsWith(GIZMO_ID_PREFIX))
continue;
const part = hit.id.slice(GIZMO_ID_PREFIX.length);
if (hit.owner === hit.id && entities.get(part)?.show) return part;
}
return null;
},
pickRay: (position) => engine.camera.pickRay(position),
setCursor(value) {
if (engine.host.canvas.style) engine.host.canvas.style.cursor = value;
},
suspendCameraInputs: () => engine.input.suspendCameraInputs(),
on(type, listener) {
if (type !== 'cancel') {
removers.push(engine.input.on(type, listener));
return;
}
const canvas = engine.host.canvas;
for (const [target, event] of [
[canvas, 'pointercancel'],
[canvas.ownerDocument?.defaultView, 'blur'],
]) {
if (!target?.addEventListener) continue;
target.addEventListener(event, listener);
removers.push(() => target.removeEventListener(event, listener));
}
},
destroy() {
if (destroyed) return;
destroyed = true;
for (const remove of removers.splice(0)) remove();
for (const entity of entities.values()) entity.destroy?.();
for (const set of pointSets) set.destroy();
entities.clear();
pointSets.clear();
if (engine.host.canvas.style) engine.host.canvas.style.cursor = '';
},
};
}
@@ -1,61 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { viewshedColors } from '../../../data/cctvViewshed.js';
import { createNavaraCctvGizmoRenderer } from './navaraGizmo.js';
const color = viewshedColors(30).fill;
test('Navara gizmo releases arrow resources, input and cancellation listeners', () => {
const engine = createFakeEngine({ kind: 'navara' });
const canvas = new EventTarget();
const window = new EventTarget();
canvas.style = {};
canvas.ownerDocument = { defaultView: window };
engine.host.canvas = canvas;
const renderer = createNavaraCctvGizmoRenderer(engine);
renderer.createLine('east', color, 8, true);
renderer.createLine('heading', color, 3);
renderer.createPoint('range', color, 10);
let cancels = 0;
let moves = 0;
renderer.on('cancel', () => cancels++);
renderer.on('move', () => moves++);
canvas.dispatchEvent(new Event('pointercancel'));
window.dispatchEvent(new Event('blur'));
engine.fake.input('move', { position: { x: 1, y: 1 } });
assert.equal(cancels, 2);
assert.equal(moves, 1);
assert.throws(
() => renderer.createLine('east', color, 8, true),
/already owned/,
);
assert.equal(engine.lines.list().length, 2);
assert.equal(engine.movers.list().length, 2);
renderer.setCursor('grabbing');
renderer.destroy();
renderer.destroy();
canvas.dispatchEvent(new Event('pointercancel'));
window.dispatchEvent(new Event('blur'));
engine.fake.input('move', { position: { x: 1, y: 1 } });
assert.equal(cancels, 2);
assert.equal(moves, 1);
assert.equal(engine.lines.list().length, 0);
assert.equal(engine.movers.list().length, 0);
assert.equal(canvas.style.cursor, '');
assert.throws(() => renderer.createPoint('new', color, 10), /destroyed/);
});
test('Navara gizmo rolls back the arrow shaft if its tip cannot be allocated', () => {
const engine = createFakeEngine({ kind: 'navara' });
engine.movers.createSet = () => {
throw new Error('allocation failed');
};
const renderer = createNavaraCctvGizmoRenderer(engine);
assert.throws(
() => renderer.createLine('east', color, 8, true),
/allocation failed/,
);
assert.equal(engine.lines.list().length, 0);
renderer.destroy();
});
-68
View File
@@ -1,68 +0,0 @@
/** Mutable CCTV line backed by the existing engine ribbon renderer. */
export function createCctvNavaraLine(engine, id, initial, points = []) {
let style = { ...initial };
let positions = points;
let visible = false;
let destroyed = false;
let line = null;
const build = () => {
line?.destroy();
line = engine.lines.createLine({
id,
pickId: id,
widthPx: style.width,
color: style.tint.rgba(),
depthFailAlpha: style.depthFail
? style.depthFail.alpha / Math.max(style.tint.alpha, 1e-6)
: 0,
dash: style.dashed ? { lengthPx: 8, gapPx: 8 } : null,
arrow: Boolean(style.arrow),
order: style.order ?? 0,
});
line.setPositions(positions);
line.show(visible);
};
build();
return {
id,
get show() {
return !destroyed && visible;
},
set show(value) {
if (destroyed || visible === Boolean(value)) return;
visible = Boolean(value);
line.show(visible);
},
polyline: {
get positions() {
return positions;
},
set positions(value) {
if (destroyed) return;
positions = value;
line.setPositions(value);
},
},
style(next) {
if (destroyed) return;
const depth = (s) =>
s.depthFail ? s.depthFail.alpha / Math.max(s.tint.alpha, 1e-6) : 0;
const rebuild =
next.width !== style.width ||
Boolean(next.dashed) !== Boolean(style.dashed) ||
depth(next) !== depth(style);
const colorChanged = next.tint
.rgba()
.some((v, i) => v !== style.tint.rgba()[i]);
style = { ...style, ...next };
if (rebuild) build();
else if (colorChanged) line.setColor(style.tint.rgba());
this.show = next.visible;
},
destroy() {
if (destroyed) return;
destroyed = true;
line.destroy();
},
};
}
+71 -67
View File
@@ -1,3 +1,8 @@
// The CCTV meshes on Navara (behind render/meshes.js, loaded through
// import()): the monitor plane and the viewshed volume as three meshes in
// Navara's view, depth-tested against the scene, and a mesh picker for the
// plane's own triangles.
import {
BufferAttribute,
BufferGeometry,
@@ -13,8 +18,9 @@ import {
Vector3,
VideoTexture,
} from 'three';
import * as geo from '../../../geo/index.js';
import { frustumVolumeGeometryData } from '../../../data/cctvViewshed.js';
import { PLANE_OUTLINE_COLOR } from '../policy.js';
import { PLANE_IMAGE_ALPHA } from './style.js';
const KIND = 'gevCctvMesh';
const registered = new WeakSet();
@@ -34,7 +40,11 @@ function placeGeometry(mesh, positions, indices, origin) {
mesh.matrixWorldNeedsUpdate = true;
}
/** CCTV owns these resources; media playback remains in shared projection. */
/**
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {{ view: object, MeshDesc: Function }} navara The engine's Navara
* extension.
*/
export function createCctvNavaraMeshes(engine, { view, MeshDesc }) {
if (!view?.addMesh || !MeshDesc)
throw new TypeError('CCTV requires Navara MeshDesc support');
@@ -79,7 +89,7 @@ export function createCctvNavaraMeshes(engine, { view, MeshDesc }) {
const material = new MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.95,
opacity: PLANE_IMAGE_ALPHA,
side: DoubleSide,
depthWrite: false,
toneMapped: false,
@@ -178,9 +188,9 @@ export function createCctvNavaraMeshes(engine, { view, MeshDesc }) {
}
}
function updatePlane(plane, geometry, positions, orientation) {
function updatePlane(plane, shape, positions, orientation) {
if (!planes.has(plane)) return;
// Match the native plane's quaternion and dimensions, including its UVs.
// The native plane's quaternion and dimensions, including its UVs.
const q = new Quaternion(
orientation.x,
orientation.y,
@@ -194,25 +204,55 @@ export function createCctvNavaraMeshes(engine, { view, MeshDesc }) {
[-1, -1],
[1, -1],
]) {
const p = new Vector3(x * geometry.halfW, y * geometry.halfH, 0)
const p = new Vector3(x * shape.halfW, y * shape.halfH, 0)
.applyQuaternion(q)
.add(positions.capCenter);
flat.push(p.x, p.y, p.z);
}
placeGeometry(plane.mesh, flat, [0, 2, 1, 2, 3, 1], positions.capCenter);
plane.outline.setPositions(
[0, 1, 3, 2, 0].map((i) => ({
x: flat[i * 3],
y: flat[i * 3 + 1],
z: flat[i * 3 + 2],
})),
);
request();
}
/** The nearest shown plane's triangles along a ray, from either side. */
function pickPlane(ray) {
if (destroyed || !ray) return undefined;
raycaster.ray.origin.copy(ray.origin);
raycaster.ray.direction.copy(ray.direction).normalize();
let nearest;
for (const plane of planes) {
if (!plane.show) continue;
plane.group.updateWorldMatrix(true, true);
const hit = raycaster.intersectObject(plane.mesh, false)[0];
if (hit && (!nearest || hit.distance < nearest.distance))
nearest = { ...hit, plane };
}
return nearest;
}
const removeMeshPicker = engine.picker.registerMeshPicker((at) => {
const hit = pickPlane(engine.camera.pickRay(at));
if (!hit) return [];
// The rendered depth includes terrain, tiles and foreground models.
const surface = engine.picker.pickPosition(at);
if (
surface &&
geo.cartesian3.distance(engine.camera.livePosition, surface) + 0.1 <
hit.distance
)
return [];
return [
{
onTop: 0,
order: 0,
depth: hit.distance,
result: { id: hit.plane.id, owner: hit.plane.id },
},
];
});
return {
createMaterial,
createPlane(cameraId, material, geometry, positions, orientation) {
createPlane(cameraId, material, shape, positions, orientation) {
alive();
if (!materials.has(material) || material.disposed)
throw new Error('CCTV plane material is not owned');
@@ -221,45 +261,15 @@ export function createCctvNavaraMeshes(engine, { view, MeshDesc }) {
'uv',
new BufferAttribute(new Float32Array([0, 1, 1, 1, 0, 0, 1, 0]), 2),
);
let outline;
let plane;
let transferred = false;
try {
outline = engine.lines.createLine({
id: `cctv-${cameraId}-plane-outline`,
widthPx: 1,
color: PLANE_OUTLINE_COLOR.rgba(),
});
transferred = true;
plane = ownMesh(mesh, () => {
outline.destroy();
material.dispose();
planes.delete(plane);
});
plane.id = `cctv-${cameraId}-plane`;
plane.cameraId = cameraId;
plane.outline = outline;
const visibility = Object.getOwnPropertyDescriptor(plane, 'show');
Object.defineProperty(plane, 'show', {
get: visibility.get,
set(value) {
visibility.set(value);
if (planes.has(plane)) outline.show(plane.show);
},
});
planes.add(plane);
updatePlane(plane, geometry, positions, orientation);
plane.show = false;
return plane;
} catch (error) {
plane?.destroy();
if (!transferred) {
mesh.geometry.dispose();
outline?.destroy();
material.dispose();
}
throw error;
}
// A failed descriptor disposes the geometry; the material stays its own.
let plane = null;
plane = ownMesh(mesh, () => planes.delete(plane));
plane.id = `cctv-${cameraId}-plane`;
plane.cameraId = cameraId;
planes.add(plane);
updatePlane(plane, shape, positions, orientation);
plane.show = false;
return plane;
},
updatePlane,
createVolume(positions, tint) {
@@ -277,24 +287,18 @@ export function createCctvNavaraMeshes(engine, { view, MeshDesc }) {
placeGeometry(mesh, data.positions, data.indices, positions.mount);
return ownMesh(mesh, () => material.dispose());
},
/** Actual plane triangles, not a projected rectangle or marker proxy. */
pickPlane(ray) {
if (destroyed || !ray) return undefined;
raycaster.ray.origin.copy(ray.origin);
raycaster.ray.direction.copy(ray.direction).normalize();
let nearest;
for (const plane of planes) {
if (!plane.show) continue;
plane.group.updateWorldMatrix(true, true);
const hit = raycaster.intersectObject(plane.mesh, false)[0];
if (hit && (!nearest || hit.distance < nearest.distance))
nearest = { ...hit, plane };
}
return nearest;
pickPlane,
// The mesh picker's result names the plane by its id.
planeFromPick(picked) {
for (const plane of planes)
if (picked.id === plane.id && picked.owner === plane.id) return plane;
return null;
},
drawnBy: (native) => native.group,
destroy() {
if (destroyed) return;
destroyed = true;
removeMeshPicker();
for (const resource of [...owned]) resource.destroy();
for (const material of [...materials]) material.dispose();
},
+45 -17
View File
@@ -9,7 +9,6 @@ function harness() {
const scene = new Group();
const kinds = new Map();
const handles = new Set();
const lines = new Set();
let requests = 0;
let fail = false;
let failedGeometryDisposals = 0;
@@ -43,27 +42,25 @@ function harness() {
return handle;
},
};
const pickers = new Set();
const engine = {
render: { request: () => requests++ },
lines: {
createLine(options) {
const line = {
options,
setPositions: (value) => (line.positions = value),
show: (value) => (line.visible = value),
destroy: () => lines.delete(line),
};
lines.add(line);
return line;
camera: { livePosition: { x: 0, y: 0, z: 0 }, pickRay: () => null },
picker: {
pickPosition: () => undefined,
registerMeshPicker(pick) {
pickers.add(pick);
return () => pickers.delete(pick);
},
},
};
const owner = createCctvNavaraMeshes(engine, { view, MeshDesc });
return {
owner,
engine,
scene,
handles,
lines,
pickers,
requests: () => requests,
failedGeometryDisposals: () => failedGeometryDisposals,
fail: () => (fail = true),
@@ -111,9 +108,9 @@ test('Navara CCTV plane preserves native rotated geometry, UVs and metre precisi
assert.equal(uv.getY(i), (y + 1) / 2);
}
assert.equal(plane.show, false);
assert.equal(plane.outline.visible, false);
assert.equal(plane.id, 'cctv-a-plane');
plane.show = true;
assert.equal(plane.outline.visible, true);
assert.equal(plane.group.visible, true);
assert.equal(material.material.opacity, 0.95);
assert.equal(material.material.depthWrite, false);
});
@@ -188,12 +185,13 @@ test('Navara CCTV releases replaced video textures and ignores retired image upd
const plane = h.owner.createPlane('a', material, shape, positions, identity);
plane.destroy();
plane.destroy();
assert.equal(finalDisposed, 0, 'the plane leaves its material to its owner');
material.dispose();
const requests = h.requests();
material.setImage(video);
assert.equal(h.requests(), requests);
assert.equal(finalDisposed, 1);
assert.equal(h.handles.size, 0);
assert.equal(h.lines.size, 0);
h.owner.destroy();
h.owner.destroy();
assert.equal(finalDisposed, 1);
@@ -209,11 +207,11 @@ test('Navara CCTV rolls back a failed descriptor and rejects new resources after
() => h.owner.createPlane('a', material, shape, positions, identity),
/descriptor failed/,
);
assert.equal(releases, 1);
assert.equal(releases, 0, 'the material stays with its owner');
assert.equal(h.failedGeometryDisposals(), 1);
assert.equal(h.handles.size, 0);
assert.equal(h.lines.size, 0);
h.owner.destroy();
assert.equal(releases, 1);
assert.throws(() => h.owner.createMaterial(canvas()), /destroyed/);
});
@@ -244,3 +242,33 @@ test('Navara CCTV volume preserves native world vertices and releases geometry/m
assert.equal(materialReleases, 1);
assert.equal(h.handles.size, 0);
});
test('Navara CCTV plane picks go through a mesh picker that defers to nearer depth', () => {
const h = harness();
const plane = h.owner.createPlane(
'a',
h.owner.createMaterial(canvas()),
shape,
positions,
identity,
);
plane.show = true;
const [pick] = h.pickers;
h.engine.camera.livePosition = { ...positions.capCenter, z: 5800 };
h.engine.camera.pickRay = () => ({
origin: { ...positions.capCenter, z: 5800 },
direction: { x: 0, y: 0, z: -1 },
});
const [hit] = pick({ x: 1, y: 1 });
assert.deepEqual(hit.result, { id: 'cctv-a-plane', owner: 'cctv-a-plane' });
assert.equal(hit.depth, 122);
assert.equal(h.owner.planeFromPick(hit.result), plane);
assert.equal(
h.owner.planeFromPick({ id: 'cctv-a-plane', owner: 'sibling' }),
null,
);
h.engine.picker.pickPosition = () => ({ ...positions.capCenter, z: 5750 });
assert.deepEqual(pick({ x: 1, y: 1 }), [], 'a wall in front hides it');
h.owner.destroy();
assert.equal(h.pickers.size, 0);
});
+59
View File
@@ -0,0 +1,59 @@
// The CCTV display's look on the globe contract: the camera markers, the
// coverage lines as created, the monitor plane's outline and material and
// the calibration gizmo's handles, in contract terms (CSS pixels, a
// depth-fail alpha factor). Values are Cesium's, the reference look; the
// per-camera coverage styles are the layer's (../rendering.js). No engine
// imports.
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
import { IDLE_COVERAGE_COLOR, PLANE_OUTLINE_COLOR } from '../policy.js';
/** The camera markers: one sprite set, the bottom of the contact sprites. */
export const MARKER_SET_ID = 'cctv';
export const MARKER_SIZE_PX = 24;
export const MARKER_SCALE_BY_DISTANCE = Object.freeze([
350, 1.25, 4_000_000, 0.42,
]);
export const MARKER_ORDER = SPRITE_LAYER_ORDER.indexOf('cctv');
/** A coverage line as created, before the first style pass. */
export const COVERAGE_INITIAL = Object.freeze({
widthPx: 1.2,
color: IDLE_COVERAGE_COLOR,
});
// Cesium's PolylineDashMaterialProperty defaults: 16 px, half dash.
export const COVERAGE_DASH = Object.freeze({ lengthPx: 8, gapPx: 8 });
// Entity polylines follow the ellipsoid (Cesium's default arc type).
export const LINE_ARC = 'geodesic';
/** The monitor plane: its image at 95 % and a 1 px outline. */
export const PLANE_IMAGE_ALPHA = 0.95;
export const PLANE_OUTLINE = Object.freeze({
widthPx: 1,
color: PLANE_OUTLINE_COLOR,
});
/** The calibration gizmo, drawn over the markers. */
export const GIZMO_ORDER = 100;
export const GIZMO_LINE_ALPHA = 0.9;
// Parts behind geometry stay visible, dimmed: a street-level mount buries
// half the heading ring in sloped photogrammetry tiles.
export const GIZMO_DEPTH_FAIL_ALPHA = 0.45;
export const GIZMO_ARROW_TIP_PX = 14;
export const GIZMO_POINT = Object.freeze({
sizePx: 10,
outlinePx: 2,
outlineColor: Object.freeze([0, 0, 0, 0.7]),
});
// Pick boxes around the pointer, as Cesium's gizmo picked.
export const GIZMO_PICK_PX = 14;
export const GIZMO_DRILL_LIMIT = 6;
/**
* A depth-fail colour as the contract's alpha factor of the line colour (the
* contract keeps the line's hue where the scene hides it).
*/
export const depthFailAlpha = (tint, depthFail) =>
depthFail
? Math.min(1, Math.max(0, depthFail.alpha / Math.max(tint.alpha, 1e-6)))
: 0;
+2 -2
View File
@@ -337,8 +337,8 @@ export function createRendering({
record.billboard.color = color;
const scale = isActive ? 1.25 : 1.0;
if (record.billboard.scale !== scale) record.billboard.scale = scale;
// disableDepthTestDistance stays POSITIVE_INFINITY for every billboard
// (set at creation) — see the field-test far-zoom submerge fix there.
// Every marker draws over the scene (the marker set's depth test is
// 'always') — see the field-test far-zoom submerge fix there.
}
if (
+10 -17
View File
@@ -1,7 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCctvSource, createCctvLayer } from './index.js';
import { createCctvCesiumRenderer } from './render/cesium.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { createCctvDisplay } from './render/display.js';
const camera = {
id: 'pack/camera ?x',
@@ -111,7 +112,6 @@ test('camera construction is inert and destruction cancels a pending catalog and
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
},
sprites: { registerSpriteCollection: noop },
activation: {},
locations: {},
picking: { unregisterPickOwner: noop },
@@ -132,24 +132,17 @@ test('camera construction is inert and destruction cancels a pending catalog and
});
},
};
const a = createCctvLayer({
services,
source,
rendererFactory: createCctvCesiumRenderer,
});
const b = createCctvLayer({
services,
source,
rendererFactory: createCctvCesiumRenderer,
});
const rendererFactory = ({ engine, ...context }) =>
createCctvDisplay(engine, context);
const a = createCctvLayer({ services, source, rendererFactory });
const b = createCctvLayer({ services, source, rendererFactory });
assert.equal(listeners.size, 0);
const viewer = {
scene: { primitives: { add: (value) => value, remove: () => true } },
};
const initializing = a.init(viewer);
const engine = createFakeEngine();
const initializing = a.init(null, { engine });
assert.equal(listeners.size, 1);
assert.equal(signal.aborted, false);
a.destroy(viewer);
a.destroy();
assert.equal(engine.movers.list().length, 0, 'the markers go with it');
assert.equal(listeners.size, 0);
assert.equal(signal.aborted, true);
resolveCatalog({ sources: [] });
+14 -11
View File
@@ -7,18 +7,21 @@ export function createTesting({
source,
rendererFactory,
}) {
let testViewer;
function useRenderer(viewer) {
if (parts.renderer && testViewer === viewer) return;
let testEngine;
function useRenderer(engine) {
if (parts.renderer && testEngine === engine) return;
parts.renderer?.destroy();
parts.renderer = null;
testEngine = engine;
if (!engine) return;
parts.renderer = rendererFactory({
state: layerState,
services,
parts,
source,
engine,
});
parts.renderer.attach(viewer);
testViewer = viewer;
parts.renderer.attach();
}
/** Test seam: republishes host entries through the real push path, so tests
@@ -39,13 +42,13 @@ export function createTesting({
/**
* Create the production monitor-plane/host-label pair without media setup.
* @param {Object} viewer Cesium viewer seam.
* @param {Object} engine Globe engine.
* @param {Object} record CCTV runtime record.
* @returns {Object} Projection runtime.
*/
function _createCctvProjectionPlaneForTest(viewer, record) {
useRenderer(viewer);
function _createCctvProjectionPlaneForTest(engine, record) {
useRenderer(engine);
const geometry =
record.frustumGeometry ||
parts.geometry.computeFrustumGeometry(
@@ -93,7 +96,7 @@ export function createTesting({
* Primes the minimum module state needed to exercise the production coverage
* refresh path in unit tests.
* @param {Object} [options={}] Test state values.
* @param {Object|null} [options.viewer] Viewer-like entity owner.
* @param {Object|null} [options.engine] Globe engine the display draws on.
* @param {Object[]} [options.records] Seeded CCTV records.
* @param {string|null} [options.activeCameraId] Active record id.
* @param {boolean} [options.enabled=true] Layer enabled state.
@@ -104,7 +107,7 @@ export function createTesting({
*/
function _setCctvCoverageStateForTest({
viewer = null,
engine = null,
records = [],
activeCameraId = null,
enabled = true,
@@ -112,7 +115,7 @@ export function createTesting({
showProjection = false,
healthById = new Map(),
} = {}) {
useRenderer(viewer);
useRenderer(engine);
layerState._records = Array.isArray(records) ? records : [];
layerState._recordById = new Map(
layerState._records
+1 -2
View File
@@ -311,8 +311,7 @@ test('a failed engine switch, its rollback and the retry re-follow the tracked a
destination: geo.cartesian3.fromDegrees(-97.5, 30.3, 400_000),
});
made.viewer.trackedEntityChanged.addEventListener((entity) => {
if (entity && !entity.gevRestoring)
navigations.push(entity.gevTrackedId);
if (entity && !entity.gevRestoring) navigations.push(entity.gevTrackedId);
});
return made;
};
+2
View File
@@ -37,6 +37,8 @@ export function createLifecycle({
source,
engine: options.engine,
});
if (!parts.renderer)
throw new TypeError('Radio has no renderer on this engine');
parts.renderer.init(viewer, options);
if (layerState._enabled) {
parts.rendering.drawStations(layerState._stations);
-313
View File
@@ -1,313 +0,0 @@
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { horizonOccluder } from '../../../data/iconOrientation.js';
import {
RADIO_PREFIX,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
} from '../policy.js';
/** Native scene resources stay owned by the viewer passed to init. */
export function createRadioCesiumRenderer({
state: layerState,
services,
parts,
}) {
let viewer = null;
let dataSource = null;
let selectedEntity = null;
let removeClusterListener = null;
let removeClick = null;
function installClusterStyling() {
if (!dataSource || removeClusterListener) return;
const clustering = dataSource.clustering;
clustering.enabled = true;
clustering.pixelRange = 42;
clustering.minimumClusterSize = 3;
clustering.clusterPoints = true;
clustering.clusterLabels = false;
clustering.clusterBillboards = false;
removeClusterListener = clustering.clusterEvent.addEventListener(
(clusteredEntities, cluster) => {
const clusteredStations = [];
for (const entity of clusteredEntities) {
const stationId = String(entity.id || '').slice(RADIO_PREFIX.length);
const station = layerState._renderById.get(stationId)?.station;
if (station) clusteredStations.push(station);
}
const clusterCategory = parts.model.radioClusterCategoryId(
clusteredStations,
layerState._filter,
);
const clusterColor = Cesium.Color.fromCssColorString(
parts.model.radioCategoryColor(clusterCategory),
);
// Cesium assigns the entity-id array only to the generated cluster label.
// Mirror it to the visible point so clicking either part of the callout
// resolves the first station and remains a direct playback gesture.
cluster.point.id = clusteredEntities;
cluster.billboard.id = clusteredEntities;
cluster.label.show = false;
cluster.label.text = '';
cluster.point.show = true;
cluster.point.pixelSize = Math.min(
26,
12 + Math.log2(clusteredEntities.length) * 1.6,
);
cluster.point.color = clusterColor.withAlpha(0.9);
cluster.point.outlineColor = Cesium.Color.BLACK;
cluster.point.outlineWidth = 2;
cluster.point.disableDepthTestDistance = Number.POSITIVE_INFINITY;
cluster.point.distanceDisplayCondition =
new Cesium.DistanceDisplayCondition(
0,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
);
parts.rendering.scheduleRadioOverlayPublish();
},
);
}
const renderer = {
get ready() {
return Boolean(viewer && dataSource);
},
init(owner) {
if (viewer) throw new Error('Radio renderer is already initialized');
viewer = owner;
dataSource = new Cesium.CustomDataSource('Radio stations');
viewer.dataSources.add(dataSource);
installClusterStyling();
dataSource.show = false;
},
setVisible(visible) {
if (dataSource) dataSource.show = visible;
},
markerPosition(station, lift) {
const floor = services.ground.cachedGroundFloor(station.lat, station.lon);
return Cesium.Cartesian3.fromDegrees(
station.lon,
station.lat,
(Number.isFinite(floor) ? floor : 0) + lift,
);
},
clearStations() {
dataSource?.entities.removeAll();
},
addStation(station, position, markerColor) {
const entity = dataSource.entities.add({
id: `${RADIO_PREFIX}${station.id}`,
position,
point: {
pixelSize: 13,
color: Cesium.Color.fromCssColorString(markerColor).withAlpha(0.86),
outlineColor: Cesium.Color.fromCssColorString('#071b25'),
outlineWidth: 1,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
scaleByDistance: new Cesium.NearFarScalar(
100_000,
1.15,
12_000_000,
1,
),
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
0,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
),
},
});
return { station, position, entity, visible: true };
},
setStationVisible(record, visible) {
record.entity.show = visible;
},
clearSelected() {
if (selectedEntity && viewer) viewer.entities.remove(selectedEntity);
selectedEntity = null;
},
addSelected(station, position, selectionColor, bracketImage) {
selectedEntity = viewer.entities.add({
id: `${RADIO_PREFIX}selected:${station.id}`,
position: position,
point: {
pixelSize: 14,
color: Cesium.Color.fromCssColorString(selectionColor),
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
0,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
),
},
billboard: {
image: bracketImage,
width: 40,
height: 40,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
0,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
),
scaleByDistance: new Cesium.NearFarScalar(
100_000,
1,
12_000_000,
0.72,
),
},
});
},
setSelectedVisible(visible) {
if (!selectedEntity) return false;
const changed = selectedEntity.show !== visible;
selectedEntity.show = visible;
return changed;
},
clusters() {
const points = dataSource?.clustering?._clusterPointCollection;
const clusters = [];
for (let index = 0; index < (points?.length || 0); index++) {
const point = points.get(index);
if (!point?.show || !point.position || !Array.isArray(point.id))
continue;
clusters.push({
position: point.position,
stationIds: point.id.map((entity) =>
String(entity?.id || '').slice(RADIO_PREFIX.length),
),
});
}
return clusters;
},
invalidateClusters() {
if (!dataSource?.clustering.enabled) return;
// Entity show changes alone do not invalidate native cluster primitives.
const enabled = dataSource.clustering.clusterPoints;
dataSource.clustering.clusterPoints = !enabled;
dataSource.clustering.clusterPoints = enabled;
viewer.scene?.requestRender?.();
},
cameraPosition() {
return viewer?.camera?.positionWC;
},
cameraHeight() {
return viewer?.camera?.positionCartographic?.height;
},
horizonVisible(position) {
return horizonOccluder(viewer.camera).isPointVisible(position);
},
pick(position) {
return viewer?.scene.pick(position);
},
drillPick(position, limit) {
return viewer?.scene.drillPick?.(position, limit) || [];
},
installClick(callback) {
services.picking.registerPickOwner('radio', (id) =>
id.startsWith(RADIO_PREFIX),
);
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction(
(event) => callback(event.position),
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
let removed = false;
removeClick = () => {
if (!removed) {
handler.destroy();
services.picking.unregisterPickOwner('radio');
}
removed = true;
};
return removeClick;
},
cameraState() {
const camera = viewer?.camera;
return camera
? {
height: camera.positionCartographic?.height,
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
}
: null;
},
navigationAllowed() {
return Boolean(viewer?.camera) && !viewer.trackedEntity;
},
recenterView() {
const camera = viewer?.camera;
const canvas = viewer?.scene?.canvas;
const width = canvas?.clientWidth || canvas?.width;
const height = canvas?.clientHeight || canvas?.height;
if (!camera?.positionCartographic || !(width > 0) || !(height > 0))
return null;
const geometry = services.globe.projectEarthDiscToViewport(
viewer,
width,
height,
layerState._radioEarthScreenCenter,
layerState._radioEarthToCenter,
);
return {
width,
height,
cartographic: camera.positionCartographic,
position: camera.positionWC,
direction: camera.directionWC,
fovy: camera.frustum?.fovy,
geometry,
toCenter: layerState._radioEarthToCenter,
};
},
flyTo({ easing, ...options }) {
viewer.camera.flyTo({
...options,
...(easing === 'cubicInOut'
? { easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT }
: {}),
});
},
cancelFlight() {
viewer?.camera.cancelFlight();
},
viewportAnchor() {
const camera = viewer?.camera;
if (!camera) return null;
const canvas = viewer.scene?.canvas;
const position =
canvas &&
camera.pickEllipsoid?.(
geo.cartesian2.create(
canvas.clientWidth / 2,
canvas.clientHeight / 2,
),
viewer.scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
);
const cartographic = position
? geo.cartographic.fromCartesian(position)
: camera.positionCartographic;
return cartographic
? {
lat: geo.math.toDegrees(cartographic.latitude),
lon: geo.math.toDegrees(cartographic.longitude),
altitudeM: Number(camera.positionCartographic?.height),
}
: null;
},
destroy() {
removeClick?.();
removeClick = null;
renderer.clearSelected();
removeClusterListener?.();
removeClusterListener = null;
if (dataSource && viewer) viewer.dataSources.remove(dataSource, true);
dataSource = null;
viewer = null;
},
};
return renderer;
}
@@ -1,3 +1,15 @@
// The radio stations on the globe contract (engine.movers, engine.camera,
// engine.projector, engine.picker), the same on every engine:
// - stations: one point set; a station shows while it is visible to the
// layer (category filter and horizon), not clustered and within
// RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M of the camera;
// - clusters: ../screenClusters.js groups the visible stations on screen
// (Cesium's point EntityCluster rules: 42 px range, three or more) after
// each camera change, one point per cluster in its own set;
// - selection: an opaque point and a corner-bracket sprite over the rest;
// - clicks: an own-rule owner of the selection dispatcher; the layer picks.
// Media, labels and policy stay in the layer.
import * as geo from '../../../geo/index.js';
import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
import { rgbaFromCss } from '../../shared/color.js';
@@ -10,9 +22,36 @@ import {
RADIO_TUNER_DIRECTORY_LIMIT,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
} from '../policy.js';
import {
BRACKET_ORDER,
BRACKET_SET_ID,
BRACKET_SPRITE,
CLUSTER_ALPHA,
CLUSTER_ORDER,
CLUSTER_POINT,
CLUSTER_SET_ID,
SELECTED_ORDER,
SELECTED_POINT,
SELECTED_SET_ID,
STATION_ALPHA,
STATION_ORDER,
STATION_POINT,
STATION_SET_ID,
clusterPixelSize,
} from './style.js';
/** Point batches and a single reusable selection bracket; no media ownership. */
export function createRadioNavaraRenderer(engine, { state, services, parts }) {
// Camera changes below this fraction keep the clusters.
const RECLUSTER_FRACTION = 0.05;
const rgba = (css, alpha = 1) => rgbaFromCss(css).withAlpha(alpha).rgba();
/**
* Registry factory (src/app/layers/renderers.js): `(engine, context)`.
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {{ state: object, services: object, parts: object }} context
*/
export function createRadioDisplay(engine, { state, services, parts }) {
if (!engine) throw new TypeError('The radio display needs a globe engine');
let initialized = false;
let visible = false;
let sets = [];
@@ -29,16 +68,15 @@ export function createRadioNavaraRenderer(engine, { state, services, parts }) {
removeClick = null;
const request = () => engine.render.request('radio-render');
const rgba = (css, alpha = 1) => rgbaFromCss(css).withAlpha(alpha).rgba();
const inRange = (position) =>
geo.cartesian3.distance(engine.camera.livePosition, position) <=
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M;
const pointSet = (id, capacity, order, point) =>
const pointSet = (id, capacity, order, { outlineCss, ...point }) =>
engine.movers.createSet({
id,
capacity,
order,
point,
point: outlineCss ? { ...point, outlineColor: rgba(outlineCss) } : point,
kind: 'point',
pickable: true,
depthTest: 'always',
@@ -87,10 +125,10 @@ export function createRadioNavaraRenderer(engine, { state, services, parts }) {
);
grouped.set(i, {
position: cluster.position,
color: rgba(parts.model.radioCategoryColor(category), 0.9),
scale: Math.min(26, 12 + Math.log2(stations.length) * 1.6) / 12,
color: rgba(parts.model.radioCategoryColor(category), CLUSTER_ALPHA),
scale: clusterPixelSize(stations.length) / CLUSTER_POINT.sizePx,
show: visible && inRange(cluster.position),
// The native cluster click resolves its first actual station too.
// A click on a cluster resolves its first station.
pickId: `${RADIO_PREFIX}${cluster.stationIds[0]}`,
});
});
@@ -116,6 +154,7 @@ export function createRadioNavaraRenderer(engine, { state, services, parts }) {
syncSelection();
parts.rendering.scheduleRadioOverlayPublish();
};
// Clusters after a camera change; the distance cut whenever the camera moved.
const frame = () => {
if (!visible) return;
if (dirty) rebuild();
@@ -138,34 +177,31 @@ export function createRadioNavaraRenderer(engine, { state, services, parts }) {
},
init() {
if (initialized) throw new Error('Radio renderer is already initialized');
normal = pointSet('radio-stations', RADIO_TUNER_DIRECTORY_LIMIT, 40, {
sizePx: 13,
outlinePx: 1,
outlineColor: rgba('#071b25'),
scaleByDistance: [100_000, 1.15, 12_000_000, 1],
});
grouped = pointSet('radio-clusters', RADIO_TUNER_DIRECTORY_LIMIT, 41, {
sizePx: 12,
outlinePx: 2,
outlineColor: [0, 0, 0, 1],
});
selected = pointSet('radio-selected', 1, 42, {
sizePx: 14,
outlinePx: 2,
outlineColor: [0, 0, 0, 1],
});
normal = pointSet(
STATION_SET_ID,
RADIO_TUNER_DIRECTORY_LIMIT,
STATION_ORDER,
STATION_POINT,
);
grouped = pointSet(
CLUSTER_SET_ID,
RADIO_TUNER_DIRECTORY_LIMIT,
CLUSTER_ORDER,
CLUSTER_POINT,
);
selected = pointSet(SELECTED_SET_ID, 1, SELECTED_ORDER, SELECTED_POINT);
// White brackets, tinted by the selection's colour.
const image = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(parts.labels.radioSelectionBracketSvg('#ffffff'))}`;
bracket = engine.movers.createSet({
id: 'radio-bracket',
id: BRACKET_SET_ID,
kind: 'sprite',
capacity: 1,
order: 43,
order: BRACKET_ORDER,
pickable: true,
depthTest: 'always',
sharpen: true,
sprite: {
sizePx: 40,
scaleByDistance: [100_000, 1, 12_000_000, 0.72],
...BRACKET_SPRITE,
atlas: { url: image, cells: { bracket: [0, 0, 1, 1] } },
},
});
@@ -177,7 +213,7 @@ export function createRadioNavaraRenderer(engine, { state, services, parts }) {
initialized = true;
removeFrame = engine.frames.preRender.addEventListener(frame);
removeCamera = engine.camera.changed.addEventListener((amount) => {
if (Number.isFinite(amount) && amount < 0.05) return;
if (Number.isFinite(amount) && amount < RECLUSTER_FRACTION) return;
dirty = true;
if (visible) request();
});
@@ -206,8 +242,8 @@ export function createRadioNavaraRenderer(engine, { state, services, parts }) {
},
clearStations() {
pointOrder.clear(state._renderById.values());
// Native EntityCluster retains its previous zoom clusters across a
// directory refresh; candidates are re-evaluated against the new rows.
// The previous clusters stay for the next zoom-in rule; their members
// are re-evaluated against the new rows.
singletonIds.clear();
dirty = true;
if (initialized) {
@@ -219,7 +255,12 @@ export function createRadioNavaraRenderer(engine, { state, services, parts }) {
if (state._renderById.size >= RADIO_TUNER_DIRECTORY_LIMIT)
throw new RangeError('Radio directory exceeds its point budget');
dirty = true;
return { station, position, color: rgba(color, 0.86), visible: true };
return {
station,
position,
color: rgba(color, STATION_ALPHA),
visible: true,
};
},
setStationVisible(record, value) {
if (record.renderVisible !== value) {
@@ -266,7 +307,7 @@ export function createRadioNavaraRenderer(engine, { state, services, parts }) {
},
installClick(callback) {
removeClick = selectionDispatcherFor(engine).register('radio', {
owns: (id) => id.startsWith(RADIO_PREFIX),
owns: (id) => String(id).startsWith(RADIO_PREFIX),
onClick: ({ position }) => callback(position),
});
return removeClick;
@@ -287,34 +328,21 @@ export function createRadioNavaraRenderer(engine, { state, services, parts }) {
const pose = engine.camera.pose();
const { width, height } = engine.host.size();
if (!(width > 0) || !(height > 0)) return null;
const toCenter = geo.cartesian3.negate(
pose.position,
state._radioEarthToCenter,
);
const radius = services.globe.earthDiscScreenRadius(
geo.cartesian3.magnitude(pose.position),
const geometry = services.globe.projectEarthDiscToViewport(
{
camera: {
positionWC: pose.position,
directionWC: pose.direction,
frustum: { fovy: pose.fovy },
},
scene: {},
},
width,
height,
pose.fovy,
state._radioEarthScreenCenter,
state._radioEarthToCenter,
engine.projector,
);
const center =
geo.cartesian3.dot(pose.direction, toCenter) > 0
? engine.projector.worldToWindow(
geo.cartesian3.ZERO,
state._radioEarthScreenCenter,
)
: null;
const keyhole = services.globe.getKeyholeGeometry(width, height);
const geometry =
center && radius
? {
earthCenterX: center.x,
earthCenterY: center.y,
earthRadius: radius,
keyholeCenterX: keyhole.centerX,
keyholeCenterY: keyhole.centerY,
keyholeRadius: keyhole.radius,
}
: null;
return {
width,
height,
@@ -322,8 +350,8 @@ export function createRadioNavaraRenderer(engine, { state, services, parts }) {
position: pose.position,
direction: pose.direction,
fovy: pose.fovy,
toCenter,
geometry,
toCenter: state._radioEarthToCenter,
};
},
flyTo(options) {
+452
View File
@@ -0,0 +1,452 @@
// The radio display on the engine contract, on the fake engine and on the
// Cesium engine (real Cesium point and billboard collections over a stub
// scene: they draw nothing but hold what the engine writes): station,
// cluster and selection styles, screen clustering, the direct click that
// plays, the distance cut, follow ownership, the Earth disc for recentering
// and what enable/disable cycles and teardown leave.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createFakeEventHandlers } from '../../../globe/cesium/testing/fakeViewer.mjs';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { projectEarthDiscToViewport } from '../../../celestialRing.js';
import { rgbaFromCss } from '../../shared/color.js';
import { createRadioLayer } from '../index.js';
import { RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M } from '../policy.js';
import { createRadioDisplay } from './display.js';
const rows = Array.from({ length: 4 }, (_, i) => ({
id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
name: `Station ${i}`,
lat: 0,
lon: i === 3 ? 10 : i * 0.001,
streamUrl: `https://audio.example.test/${i}.mp3`,
homepage: null,
tags: ['news'],
languages: ['English'],
state: '',
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: 'MP3',
bitrate: 128,
}));
const body = (stations = rows, generation = 1) => ({
stations,
updatedAt: new Date().toISOString(),
stale: false,
degraded: false,
acceptedGeneration: generation,
catalogInstance: 'fixture',
});
const noop = () => {};
const SETS = [
'radio-stations',
'radio-clusters',
'radio-selected',
'radio-bracket',
];
const NEWS = '#44adff';
const near = (actual, expected, label, tolerance = 1e-6) =>
assert.ok(
Math.abs(actual - expected) < tolerance,
`${label}: ${actual} vs ${expected}`,
);
function fakeHarness() {
const engine = createFakeEngine();
return {
name: 'the fake engine',
engine,
frame: () => engine.fake.frame(),
/** A click whose pick is `picked` (a mover's pick result). */
click(picked) {
engine.fake.script('pick', picked);
engine.fake.input('click', { position: { x: 100, y: 100 } });
},
instance(id, index) {
const { store, options } = engine.fake.moverSet(id);
return {
shown: index < store.count && store.shows[index] === 1,
color: [...store.colors.slice(index * 4, index * 4 + 4)],
pixelSize:
(options.point ?? options.sprite).sizePx * store.scales[index],
id: store.pickIds[index],
};
},
pick: (id, index) => ({
id: engine.fake.moverSet(id).store.pickIds[index],
owner: id,
instance: index,
}),
};
}
function cesiumHarness() {
const handlers = createFakeEventHandlers();
const { engine, viewer, frame, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
},
});
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
const count = (id) => engine.movers.list().find((set) => set.id === id).count;
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
frame,
primitive,
click(picked) {
native = picked
? { primitive: primitive(picked.owner).get(picked.instance) }
: undefined;
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(100, 100),
});
},
instance(id, index) {
const item = primitive(id).get(index);
return {
shown: index < count(id) && item.show,
color: [
item.color.red,
item.color.green,
item.color.blue,
item.color.alpha,
],
pixelSize: item.pixelSize ?? item.width * item.scale,
id: item.id,
native: item,
};
},
pick: (id, index) => ({ owner: id, instance: index }),
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
function fixture(t, makeHarness) {
const previous = globalThis.Audio;
const plays = [];
globalThis.Audio = class {
volume = 0.8;
addEventListener() {}
pause() {}
removeAttribute() {}
load() {}
play() {
plays.push(this.src);
return Promise.resolve();
}
};
t.after(() => {
globalThis.Audio = previous;
});
const h = makeHarness();
const at = (height) =>
h.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(0, 0, height),
});
at(1_000_000);
let response = body();
let context;
const entries = new Map();
const listeners = {
preRender: h.engine.frames.preRender.numberOfListeners,
changed: h.engine.camera.changed.numberOfListeners,
};
const layer = createRadioLayer({
source: { getDirectory: async () => response, recordClick: async () => {} },
services: {
ground: { cachedGroundFloor: () => 20, warmGroundFloor: noop },
globe: {
projectEarthDiscToViewport,
getKeyholeGeometry: () => ({ radius: 500 }),
},
picking: {
registerPickOwner: noop,
unregisterPickOwner: noop,
resolvePickId: (pick) => pick?.id,
isOwnedByOtherLayer: () => false,
},
render: { governorRequestRender: noop },
overlays: {
clearOverlaySource: (id) => entries.delete(id),
setOverlaySourceVisible: noop,
setOverlayEntries: (id, value) => entries.set(id, value),
},
},
rendererFactory(value) {
context = value;
return createRadioDisplay(h.engine, value);
},
});
layer.init(null, { engine: h.engine });
t.after(() => {
layer.destroy();
h.engine.destroy();
});
return {
...h,
layer,
plays,
entries,
at,
listeners,
get context() {
return context;
},
respond(value) {
response = value;
},
count: (id) => h.engine.movers.list().find((set) => set.id === id)?.count,
async settle() {
h.frame();
await new Promise((resolve) => setTimeout(resolve, 1));
},
};
}
test('the display needs a globe engine', () => {
assert.throws(() => createRadioDisplay(null, {}), TypeError);
});
for (const makeHarness of HARNESSES) {
const { name } = makeHarness();
test(`stations, clusters and the selection keep their styles; a click plays (${name})`, async (t) => {
const f = fixture(t, makeHarness);
f.layer.enable();
await f.layer.update();
await f.settle();
assert.equal(f.plays.length, 0, 'enable must not autoplay');
assert.equal(f.count('radio-stations'), 4);
assert.equal(f.count('radio-clusters'), 1);
assert.deepEqual(
[0, 1, 2, 3].map((i) => f.instance('radio-stations', i).shown),
[false, false, false, true],
'three stations within 42 px cluster; the far one stays single',
);
const station = f.instance('radio-stations', 3);
rgbaFromCss(NEWS)
.withAlpha(0.86)
.rgba()
.forEach((value, i) =>
near(station.color[i], value, `station colour ${i}`),
);
near(station.pixelSize, 13, 'station size');
const cluster = f.instance('radio-clusters', 0);
near(
cluster.pixelSize,
Math.min(26, 12 + Math.log2(3) * 1.6),
'cluster size',
);
rgbaFromCss(NEWS)
.withAlpha(0.9)
.rgba()
.forEach((value, i) =>
near(cluster.color[i], value, `cluster colour ${i}`),
);
if (f.cesium) {
const point = station.native;
assert.ok(
f.primitive('radio-stations') instanceof
Cesium.PointPrimitiveCollection,
);
assert.equal(point.outlineWidth, 1);
assert.ok(
point.outlineColor.equalsEpsilon(
Cesium.Color.fromCssColorString('#071b25'),
1e-6,
),
);
assert.ok(
Cesium.NearFarScalar.equals(
point.scaleByDistance,
new Cesium.NearFarScalar(100_000, 1.15, 12_000_000, 1),
),
);
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY);
assert.equal(cluster.native.outlineWidth, 2);
assert.equal(
cluster.native.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
} else {
const { options } = f.engine.fake.moverSet('radio-stations');
assert.equal(options.point.sizePx, 13);
assert.equal(options.point.outlinePx, 1);
assert.deepEqual(
options.point.scaleByDistance,
[100_000, 1.15, 12_000_000, 1],
);
assert.equal(options.depthTest, 'always');
assert.equal(options.sharpen, true);
assert.equal(options.pickable, true);
assert.equal(
f.engine.fake.moverSet('radio-clusters').options.point.outlinePx,
2,
);
}
const clusterId = f.context.parts.renderer.clusters()[0].stationIds[0];
f.click(f.pick('radio-clusters', 0));
assert.equal(
f.plays.length,
1,
'play is called inside the direct input callback',
);
assert.equal(
f.layer.getUIState().selected.id,
clusterId,
'a cluster click selects its first station',
);
assert.equal(f.count('radio-selected'), 1);
assert.equal(f.count('radio-bracket'), 1);
const selected = f.instance('radio-selected', 0);
near(selected.pixelSize, 14, 'selected size');
near(selected.color[3], 1, 'the selection is opaque');
const bracket = f.instance('radio-bracket', 0);
near(bracket.pixelSize, 40, 'bracket size');
assert.equal(bracket.id, `radio:selected:${clusterId}`);
if (f.cesium) {
assert.equal(selected.native.outlineWidth, 2);
assert.equal(selected.native.scaleByDistance, undefined);
assert.ok(
Cesium.NearFarScalar.equals(
bracket.native.scaleByDistance,
new Cesium.NearFarScalar(100_000, 1, 12_000_000, 0.72),
),
);
assert.equal(
bracket.native.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
} else {
const { options } = f.engine.fake.moverSet('radio-selected');
assert.equal(options.point.sizePx, 14);
assert.equal(options.point.scaleByDistance, null);
assert.deepEqual(
f.engine.fake.moverSet('radio-bracket').options.sprite.scaleByDistance,
[100_000, 1, 12_000_000, 0.72],
);
}
f.layer.setFilter('talk');
await f.settle();
assert.equal(f.layer.getUIState().selected.id, clusterId);
assert.equal(f.count('radio-clusters'), 0);
assert.equal(f.count('radio-selected'), 1);
});
test(`a rejected budget keeps the catalog and repeated lifecycles free every set (${name})`, async (t) => {
const f = fixture(t, makeHarness);
f.layer.enable();
await f.layer.update();
await f.settle();
const accepted = f.layer.getAcceptedCatalogSnapshot();
f.respond(body(Array(751).fill(rows[0]), 2));
await f.layer.update();
assert.equal(f.layer.getAcceptedCatalogSnapshot(), accepted);
assert.equal(f.layer.getStats().count, 4);
assert.match(f.layer.getStats().error, /previous station catalog/);
for (let i = 0; i < 10; i++) {
f.layer.disable();
await f.settle();
for (const id of SETS) assert.equal(f.count(id), 0, id);
assert.equal(f.context.state._clickHandler, null);
assert.equal(f.context.state._horizonTimer, null);
f.layer.enable();
f.layer.enable();
await f.settle();
assert.equal(
f.engine.frames.preRender.numberOfListeners,
f.listeners.preRender + 1,
);
assert.equal(
f.engine.camera.changed.numberOfListeners,
f.listeners.changed + 1,
);
}
f.layer.destroy();
f.layer.destroy();
assert.deepEqual(f.engine.movers.list(), []);
assert.equal(
f.engine.frames.preRender.numberOfListeners,
f.listeners.preRender,
);
assert.equal(
f.engine.camera.changed.numberOfListeners,
f.listeners.changed,
);
assert.equal(f.plays.length, 0);
});
test(`navigation yields to a follow and points hide beyond the interaction distance (${name})`, async (t) => {
const f = fixture(t, makeHarness);
f.layer.enable();
await f.layer.update();
await f.settle();
let flights = 0;
const flyTo = f.engine.camera.flyTo;
f.engine.camera.flyTo = (...args) => {
flights++;
return flyTo.apply(f.engine.camera, args);
};
t.after(() => {
f.engine.camera.flyTo = flyTo;
});
const follow = f.engine.camera.follow({
position: () => geo.cartesian3.fromDegrees(0, 0, 0),
viewFrom: { x: 100, y: 0, z: 0 },
});
f.layer.selectStation(rows[0].id, { focus: true, autoplay: false });
assert.equal(flights, 0, 'a followed camera is not flown');
follow.stop();
f.at(RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M + 10_000_000);
await f.settle();
for (const id of SETS)
for (let i = 0; i < f.count(id); i++)
assert.equal(f.instance(id, i).shown, false, `${id} ${i}`);
f.at(1_000_000);
await f.settle();
assert.equal(f.instance('radio-stations', 3).shown, true, 'back in range');
});
test(`recentering reads the Earth disc through the engine's projector (${name})`, (t) => {
const f = fixture(t, makeHarness);
f.at(20_000_000);
const view = f.context.parts.renderer.recenterView();
const { width, height } = f.engine.host.size();
assert.equal(view.width, width);
assert.equal(view.height, height);
near(view.cartographic.height, 20_000_000, 'camera height', 1e-3);
const pose = f.engine.camera.pose();
const expected = projectEarthDiscToViewport(
{
camera: {
positionWC: pose.position,
directionWC: pose.direction,
frustum: { fovy: pose.fovy },
},
scene: {},
},
width,
height,
undefined,
undefined,
f.engine.projector,
);
for (const key of ['earthCenterX', 'earthCenterY', 'earthRadius'])
near(view.geometry[key], expected[key], key, 1e-6);
near(view.geometry.earthCenterX, width / 2, 'the globe is centred', 1e-3);
near(view.toCenter.x, -pose.position.x, 'toCenter', 1e-3);
});
}
-205
View File
@@ -1,205 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createRadioLayer } from '../index.js';
import { createRadioNavaraRenderer } from './navara.js';
const rows = Array.from({ length: 4 }, (_, i) => ({
id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
name: `Station ${i}`,
lat: 0,
lon: i === 3 ? 10 : i * 0.001,
streamUrl: `https://audio.example.test/${i}.mp3`,
homepage: null,
tags: ['news'],
languages: ['English'],
state: '',
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: 'MP3',
bitrate: 128,
}));
const body = (stations = rows, generation = 1) => ({
stations,
updatedAt: new Date().toISOString(),
stale: false,
degraded: false,
acceptedGeneration: generation,
catalogInstance: 'fixture',
});
const noop = () => {};
function fixture(t) {
const previous = globalThis.Audio;
const plays = [];
globalThis.Audio = class {
volume = 0.8;
addEventListener() {}
pause() {}
removeAttribute() {}
load() {}
play() {
plays.push(this.src);
return Promise.resolve();
}
};
t.after(() => {
globalThis.Audio = previous;
});
const engine = createFakeEngine({ kind: 'navara' });
engine.fake.setPose({ longitude: 0, latitude: 0, height: 1_000_000 });
let response = body();
let context;
const entries = new Map();
const layer = createRadioLayer({
source: { getDirectory: async () => response, recordClick: async () => {} },
services: {
ground: { cachedGroundFloor: () => 20, warmGroundFloor: noop },
globe: {
earthDiscScreenRadius: () => null,
getKeyholeGeometry: () => ({ radius: 500 }),
},
picking: {
registerPickOwner: noop,
unregisterPickOwner: noop,
resolvePickId: (pick) => pick?.id,
isOwnedByOtherLayer: () => false,
},
render: { governorRequestRender: noop },
overlays: {
clearOverlaySource: (id) => entries.delete(id),
setOverlaySourceVisible: noop,
setOverlayEntries: (id, value) => entries.set(id, value),
},
},
rendererFactory(value) {
context = value;
return createRadioNavaraRenderer(engine, value);
},
});
layer.init(null, { engine });
t.after(() => layer.destroy());
return {
engine,
layer,
plays,
entries,
context,
respond(value) {
response = value;
},
async settle() {
engine.fake.frame();
await new Promise((resolve) => setTimeout(resolve, 1));
},
};
}
test('Navara radio preserves station clusters, direct gesture audio and selected style', async (t) => {
const f = fixture(t);
f.layer.enable();
await f.layer.update();
await f.settle();
assert.equal(f.plays.length, 0, 'enable must not autoplay');
const normal = f.engine.fake.moverSet('radio-stations');
const cluster = f.engine.fake.moverSet('radio-clusters');
assert.equal(normal.store.count, 4);
assert.equal(cluster.store.count, 1);
assert.equal(normal.options.point.sizePx, 13);
assert.equal(normal.options.point.outlinePx, 1);
assert.equal(cluster.options.point.outlinePx, 2);
assert.equal(normal.options.sharpen, true);
assert.deepEqual(
Array.from(normal.store.shows.slice(0, normal.store.count)),
[0, 0, 0, 1],
);
const id = cluster.store.pickIds[0];
assert.ok(id.startsWith('radio:'));
f.engine.fake.script('pick', { id });
f.engine.fake.input('click', { position: { x: 100, y: 100 } });
assert.equal(
f.plays.length,
1,
'play is called inside the direct input callback',
);
assert.equal(f.layer.getUIState().selected.id, id.slice(6));
const selected = f.engine.fake.moverSet('radio-selected');
const bracket = f.engine.fake.moverSet('radio-bracket');
assert.equal(selected.store.count, 1);
assert.equal(selected.options.point.sizePx, 14);
assert.equal(selected.options.point.scaleByDistance, null);
assert.equal(bracket.options.sprite.sizePx, 40);
assert.equal(bracket.store.pickIds[0], `radio:selected:${id.slice(6)}`);
f.layer.setFilter('talk');
await f.settle();
assert.equal(f.layer.getUIState().selected.id, id.slice(6));
assert.equal(cluster.store.count, 0);
assert.equal(selected.store.count, 1);
});
test('Navara radio budget rejection preserves the accepted catalog and repeated lifecycle frees every batch', async (t) => {
const f = fixture(t);
f.layer.enable();
await f.layer.update();
await f.settle();
const accepted = f.layer.getAcceptedCatalogSnapshot();
f.respond(body(Array(751).fill(rows[0]), 2));
await f.layer.update();
assert.equal(f.layer.getAcceptedCatalogSnapshot(), accepted);
assert.equal(f.layer.getStats().count, 4);
assert.match(f.layer.getStats().error, /previous station catalog/);
for (let i = 0; i < 10; i++) {
f.layer.disable();
await f.settle();
for (const name of [
'radio-stations',
'radio-clusters',
'radio-selected',
'radio-bracket',
])
assert.equal(f.engine.fake.moverSet(name).store.count, 0);
assert.equal(f.context.state._clickHandler, null);
assert.equal(f.context.state._horizonTimer, null);
f.layer.enable();
f.layer.enable();
await f.settle();
assert.equal(f.engine.frames.preRender.numberOfListeners, 1);
assert.equal(f.engine.camera.changed.numberOfListeners, 1);
}
f.layer.destroy();
f.layer.destroy();
assert.deepEqual(f.engine.movers.list(), []);
assert.equal(f.engine.frames.preRender.numberOfListeners, 0);
assert.equal(f.engine.camera.changed.numberOfListeners, 0);
assert.equal(f.plays.length, 0);
});
test('Navara radio navigation yields to follow ownership and hides points outside the native distance range', async (t) => {
const f = fixture(t);
f.layer.enable();
await f.layer.update();
await f.settle();
const follow = f.engine.camera.follow({
position: () => ({ x: 6378140, y: 0, z: 0 }),
viewFrom: { x: 100, y: 0, z: 0 },
});
const before = f.engine.fake.callsOf('camera.flyTo').length;
f.layer.selectStation(rows[0].id, { focus: true, autoplay: false });
assert.equal(f.engine.fake.callsOf('camera.flyTo').length, before);
follow.stop();
f.engine.fake.setPose({ longitude: 0, latitude: 0, height: 60_000_000 });
await f.settle();
for (const name of [
'radio-stations',
'radio-clusters',
'radio-selected',
'radio-bracket',
]) {
const { store } = f.engine.fake.moverSet(name);
assert.ok(
Array.from(store.shows.slice(0, store.count)).every(
(value) => value === 0,
),
);
}
});
+50
View File
@@ -0,0 +1,50 @@
// How the radio stations draw (engine.movers point and sprite sets). Every
// set draws over the scene and hides beyond
// RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M from the camera.
export const STATION_SET_ID = 'radio-stations';
export const CLUSTER_SET_ID = 'radio-clusters';
export const SELECTED_SET_ID = 'radio-selected';
export const BRACKET_SET_ID = 'radio-bracket';
export const STATION_ORDER = 40;
export const CLUSTER_ORDER = 41;
export const SELECTED_ORDER = 42;
export const BRACKET_ORDER = 43;
/** A station's disc, its category colour at STATION_ALPHA. */
export const STATION_POINT = Object.freeze({
sizePx: 13,
outlinePx: 1,
outlineCss: '#071b25',
scaleByDistance: Object.freeze([100_000, 1.15, 12_000_000, 1]),
});
export const STATION_ALPHA = 0.86;
/**
* A cluster's disc: its dominant category's colour at CLUSTER_ALPHA,
* clusterPixelSize(count) across (sizePx times the point's scale).
*/
export const CLUSTER_POINT = Object.freeze({
sizePx: 12,
outlinePx: 2,
outlineColor: Object.freeze([0, 0, 0, 1]),
});
export const CLUSTER_ALPHA = 0.9;
/** The diameter of a cluster of `count` stations, in CSS pixels. */
export const clusterPixelSize = (count) =>
Math.min(26, 12 + Math.log2(count) * 1.6);
/** The selected station's opaque disc. */
export const SELECTED_POINT = Object.freeze({
sizePx: 14,
outlinePx: 2,
outlineColor: Object.freeze([0, 0, 0, 1]),
});
/** The four corner brackets around the selection, tinted by its colour. */
export const BRACKET_SPRITE = Object.freeze({
sizePx: 40,
scaleByDistance: Object.freeze([100_000, 1, 12_000_000, 0.72]),
});
+1 -2
View File
@@ -37,10 +37,9 @@ export function createRendering({
const color = parts.model.radioCategoryColor(
parts.model.radioStationCategoryId(station),
);
const bracket = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(parts.labels.radioSelectionBracketSvg(color))}`;
const position = markerPosition(station, SELECTED_LIFT_M);
layerState._selectedPosition = position;
parts.renderer.addSelected(station, position, color, bracket);
parts.renderer.addSelected(station, position, color);
scheduleRadioOverlayPublish();
}
+4 -7
View File
@@ -15,11 +15,10 @@ import { TRANSIT_POLL_MS } from './policy.js';
/**
* Construct one Transit layer with its own scene state and supplied services.
*
* Nothing here is module-global: the viewer, the point collection, the vehicle
* Nothing here is module-global: the renderer, the marker sets, the vehicle
* and height maps, the in-flight requests, the manager handle and the overlay
* host all live on the instance this returns, and `destroy()` releases every
* shared registration it took (sprite collection, pick owner, render hold,
* camera sensitivity).
* shared registration it took (pick owner, render hold, camera sensitivity).
*
* @param {{services: object}} options
* @returns {object} The data-layer module the manager registers.
@@ -29,10 +28,8 @@ export function createTransitLayer({
source = createTransitSource(),
rendererFactory,
}) {
if (!services?.overlays || !services?.render || !services?.sprites) {
throw new TypeError(
'A transit layer needs overlay, render and sprite services',
);
if (!services?.overlays || !services?.render) {
throw new TypeError('A transit layer needs overlay and render services');
}
if (
typeof source?.requestSnapshot !== 'function' ||
+6 -6
View File
@@ -4,9 +4,9 @@ import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js';
/**
* Init / enable / disable / update / destroy for one Transit layer instance.
* Everything acquired here is released here — primitives, listeners, the sprite
* registration, the pick owner, the render hold, and the camera sensitivity
* this layer borrowed.
* Everything acquired here is released here — the renderer, listeners, the
* pick owner, the render hold, and the camera sensitivity this layer
* borrowed.
* @param {object} context
* @returns {object}
*/
@@ -111,7 +111,7 @@ export function createLifecycle({
source: 'GTFS-RT',
/**
* Create the point collection. Called once at bootstrap.
* Create the marker sets. Called once at bootstrap.
* @param {Cesium.Viewer} viewer
*/
init(viewer, options) {
@@ -130,6 +130,8 @@ export function createLifecycle({
source,
engine: options.engine,
});
if (!renderer)
throw new TypeError('Transit has no renderer on this engine');
state._viewer = viewer;
parts.renderer = renderer;
try {
@@ -154,7 +156,6 @@ export function createLifecycle({
this.bindScene(viewer);
if (!state._restoringViewport) this.repaint();
}
parts.renderer.restoreOrder();
bindStyleEvents();
console.log(
`[Data:Transit] Initialized with ${TRANSIT_ENABLED_FEEDS.length} GTFS-RT feeds`,
@@ -196,7 +197,6 @@ export function createLifecycle({
);
}
parts.viewport.runProximityCheck();
parts.renderer.restoreOrder();
},
repaint() {
+242 -280
View File
@@ -2,7 +2,9 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createTransitLayer } from './index.js';
import { createTransitCesiumRenderer } from './render/cesium.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { createTransitDisplay } from './render/display.js';
import { transitTestAtlas } from './render/testAtlas.mjs';
import {
FEED_EVICT_AFTER_MS,
FEED_STALE_AFTER_MS,
@@ -88,7 +90,7 @@ function vehicle(id, lat, lon, atSeconds, extra = {}) {
}
/**
* A Transit layer wired to a stub scene and a scripted proxy.
* A Transit layer drawn on the fake globe engine, with a scripted proxy.
*
* The render governor is the real one, so "does this layer keep the render loop
* awake?" is answered by the same code the app runs.
@@ -160,6 +162,7 @@ function harness(
east: at.lon + 0.1,
north: at.lat + 0.1,
},
engineOptions = {},
} = {},
) {
const viewRect = { ...view };
@@ -167,25 +170,9 @@ function harness(
apis: ['Date', 'setTimeout'],
now: 1_789_000_000_000,
});
for (const name of [
'HTMLCanvasElement',
'HTMLImageElement',
'ImageBitmap',
'OffscreenCanvas',
]) {
const prior = globalThis[name];
globalThis[name] = class {};
t.after(() => {
if (prior === undefined) delete globalThis[name];
else globalThis[name] = prior;
});
}
t.mock.method(performance, 'now', () => Date.now() - 1_789_000_000_000);
const originalFetch = globalThis.fetch;
const originalDocument = globalThis.document;
const primitives = [];
const sprites = new Map();
const pickOwners = new Map();
const overlaySources = new Map();
const credits = [];
@@ -201,47 +188,39 @@ function harness(
const originalWindow = globalThis.window;
globalThis.window = new EventTarget();
const preRender = new Cesium.Event();
const cameraChanged = new Cesium.Event();
// The globe: a fake engine over `at`, looking straight down from
// `altitude`, whose view rectangle is `view` (lookAt moves it).
const engine = createFakeEngine(engineOptions);
engine.fake.setPose({
longitude: Cesium.Math.toRadians(at.lon),
latitude: Cesium.Math.toRadians(at.lat),
height: altitude,
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
});
let viewRectangle = () =>
Cesium.Rectangle.fromDegrees(
viewRect.west,
viewRect.south,
viewRect.east,
viewRect.north,
);
engine.fake.script('viewRectangle', () => viewRectangle());
// No terrain answers unless a test gives one (photoreal-style floors).
let terrain = () => undefined;
engine.fake.script('terrainHeight', (cartographic) => terrain(cartographic));
let frameTime = 0;
// The render governor's scene: requestRenderMode is what it switches.
const viewer = {
camera: {
changed: cameraChanged,
percentageChanged: 0.5,
positionCartographic: {
height: altitude,
latitude: Cesium.Math.toRadians(at.lat),
longitude: Cesium.Math.toRadians(at.lon),
},
computeViewRectangle() {
return Cesium.Rectangle.fromDegrees(
viewRect.west,
viewRect.south,
viewRect.east,
viewRect.north,
);
},
},
camera: engine.camera,
scene: {
canvas: { addEventListener() {}, removeEventListener() {} },
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
pick: () => undefined,
requestRenderMode: false,
maximumRenderTimeChange: 0,
requestRender() {},
preRender,
primitives: {
add(primitive) {
primitives.push(primitive);
return primitive;
},
remove(primitive) {
const index = primitives.indexOf(primitive);
if (index >= 0) primitives.splice(index, 1);
return index >= 0;
},
contains: (primitive) => primitives.includes(primitive),
raiseToTop() {},
},
// For the layers in a test that still bind a viewer's scene.
preRender: engine.frames.preRender,
postRender: engine.frames.postRender,
},
};
@@ -263,14 +242,21 @@ function harness(
const ground = groundStub(floorAt);
/** Every call the floor cycle makes to the rendered-mesh sampler. */
const meshCalls = [];
let display = null;
const layer = createTransitLayer({
rendererFactory: createTransitCesiumRenderer,
rendererFactory: (context) => {
display = createTransitDisplay(engine, {
...context,
drawAtlas: transitTestAtlas,
});
return display;
},
source,
services: {
ground,
mesh: {
sampleMeshFloorCells(scene, points, options) {
meshCalls.push({ scene, points: [...points], options });
sampleMeshFloorCells(target, points, options) {
meshCalls.push({ target, points: [...points], options });
},
},
render: {
@@ -278,12 +264,6 @@ function harness(
holdContinuousRender,
releaseContinuousRender,
},
sprites: {
registerSpriteCollection: (id, collection) =>
sprites.set(id, collection),
unregisterSpriteCollection: (id) => sprites.delete(id),
restoreSpriteOrder() {},
},
picking: {
registerPickOwner: (id, predicate) => pickOwners.set(id, predicate),
unregisterPickOwner: (id) => pickOwners.delete(id),
@@ -294,7 +274,7 @@ function harness(
clearOverlaySource: (id) => overlaySources.delete(id),
},
credits: {
registerDynamicCredit: (_viewer, credit) => credits.push(credit?.key),
registerDynamicCredit: (_engine, credit) => credits.push(credit?.key),
transitFeedCredit: (feed) => ({
key: `transit-${feed.id}`,
html: feed.name,
@@ -304,7 +284,7 @@ function harness(
});
installRenderGovernor(viewer);
layer.init(viewer);
layer.init(viewer, { engine });
t.after(() => {
try {
@@ -312,6 +292,7 @@ function harness(
} catch {
/* a test may already have destroyed it */
}
engine.destroy();
uninstallRenderGovernor(viewer);
_resetRenderGovernorForTest();
globalThis.fetch = originalFetch;
@@ -321,11 +302,31 @@ function harness(
else globalThis.window = originalWindow;
});
const frame = () => {
frameTime += 16;
engine.fake.emit('frames.preUpdate', frameTime);
engine.fake.emit('frames.preRender', frameTime);
};
const markerSets = () => {
const state = layer._transitStateForTest();
return new Map(
[
['transit', state._markers],
['transit-motion', state._animatedMarkers],
].filter(([, collection]) => collection),
);
};
return {
layer,
viewer,
primitives,
sprites,
engine,
get display() {
return display;
},
/** The marker collections by sprite-order id, while the layer has them. */
get sprites() {
return markerSets();
},
pickOwners,
overlaySources,
credits,
@@ -338,9 +339,7 @@ function harness(
},
state: () => layer._transitStateForTest(),
vehicles: () => [...layer._transitStateForTest()._vehicles.values()],
frame() {
preRender.raiseEvent();
},
frame,
advance(ms) {
t.mock.timers.tick(ms);
},
@@ -355,11 +354,11 @@ function harness(
const until = Date.now() + ms;
while (Date.now() + step <= until) {
t.mock.timers.tick(step);
preRender.raiseEvent();
frame();
}
if (Date.now() < until) {
t.mock.timers.tick(until - Date.now());
preRender.raiseEvent();
frame();
}
},
holds: () => getRenderGovernorDiagnostics().holds,
@@ -381,7 +380,7 @@ function harness(
// a test about how long a sweep waits.
parts.rendering.refreshVisibility();
t.mock.timers.tick(16);
preRender.raiseEvent();
frame();
}
},
/** What the style manager dispatches when the user picks a preset. */
@@ -396,10 +395,47 @@ function harness(
new CustomEvent('gev:vision-change', { detail: { style, cockpit } }),
);
},
/** Move the camera's view rectangle, the way a real camera move would. */
/**
* Pan the camera over the centre of `next` at its height and report
* `next` as its view rectangle, the way a real camera move would.
*/
lookAt(next) {
Object.assign(viewRect, next);
cameraChanged.raiseEvent();
const height = engine.camera.height();
engine.fake.setPose({
longitude: Cesium.Math.toRadians((viewRect.west + viewRect.east) / 2),
latitude: Cesium.Math.toRadians((viewRect.south + viewRect.north) / 2),
height,
});
engine.fake.emit('camera.changed', 1);
},
/** The view rectangle the camera reports (undefined: none). */
setViewRectangle(next) {
viewRectangle = next;
},
/** The rendered terrain under a point (a globe with terrain shown). */
setTerrain(heightAt) {
terrain = (cartographic) =>
heightAt(
Cesium.Math.toDegrees(cartographic.latitude),
Cesium.Math.toDegrees(cartographic.longitude),
);
},
/** Places the camera (degrees and metres; angles in radians). */
aim({ lat, lon, height, heading = 0, pitch = -Math.PI / 2, roll = 0 }) {
engine.fake.setPose({
longitude: Cesium.Math.toRadians(lon),
latitude: Cesium.Math.toRadians(lat),
height,
heading,
pitch,
roll,
});
},
/** A click at `position` whose pick is `picked`. */
click(position, picked) {
engine.fake.script('pick', picked);
engine.fake.input('click', { position });
},
};
}
@@ -910,26 +946,32 @@ test('destroy releases every shared registration this layer took', async (t) =>
}));
app.layer.enable(app.viewer);
await app.layer.update();
await app.display.whenReady();
assert.ok(app.sprites.has('transit'));
assert.ok(app.pickOwners.has('transit'));
assert.equal(app.primitives.length, 2);
assert.deepEqual(
app.engine.movers.list().map(({ id, count }) => [id, count]),
[['transit-1', 1]],
'the stationary fleet is one sprite set',
);
app.layer.destroy(app.viewer);
assert.equal(
app.sprites.has('transit'),
false,
'the sprite collection is unregistered',
'the marker collections are released',
);
assert.equal(
app.pickOwners.has('transit'),
false,
'the pick owner is released',
);
assert.equal(
app.primitives.length,
0,
'the primitive collection leaves the scene',
assert.deepEqual(
app.engine.movers.list(),
[],
'the sprite sets leave the scene',
);
assert.deepEqual(app.engine.lines.list(), []);
assert.equal(app.holds().includes('transit'), false);
assert.equal(app.state()._viewer, null);
assert.equal(app.state()._dataManager, null);
@@ -938,7 +980,8 @@ test('destroy releases every shared registration this layer took', async (t) =>
test('two layers built from the factory share no state', async (t) => {
const app = harness(t);
const second = createTransitLayer({
rendererFactory: createTransitCesiumRenderer,
rendererFactory: ({ engine, ...context }) =>
createTransitDisplay(engine, { ...context, drawAtlas: transitTestAtlas }),
services: {
ground: groundStub(() => 12),
render: {
@@ -946,11 +989,6 @@ test('two layers built from the factory share no state', async (t) => {
holdContinuousRender,
releaseContinuousRender,
},
sprites: {
registerSpriteCollection() {},
unregisterSpriteCollection() {},
restoreSpriteOrder() {},
},
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
overlays: {
setOverlayEntries() {},
@@ -2307,16 +2345,16 @@ test('the floor cycle samples the rendered mesh for the cells it warms, with its
'the sampler was asked after the floor resolved',
);
const call = app.meshCalls[0];
assert.equal(call.scene, app.viewer.scene);
assert.equal(call.target, app.engine);
assert.deepEqual(
call.points.map((p) => p.lat).sort(),
[42.36, 42.37],
'the in-view cells, and not the one off screen',
);
assert.equal(
call.options.excludeObjects.length,
2,
'both in-view billboards excluded',
assert.deepEqual(
call.options.excludeObjects.map((set) => set.count()),
[3],
'the sprite set holding the fleet is excluded',
);
assert.ok(
Math.abs(call.options.viewerLat - BOSTON.lat) < 1e-6,
@@ -2673,7 +2711,8 @@ test('a long selected history shortens its oldest geometry before sacrificing th
});
test('sparse rail reports preserve a bounded marker corridor with and without selection', async (t) => {
const app = harness(t);
// High enough that the 16 km run south of the camera stays in view.
const app = harness(t, { altitude: 50_000 });
const parts = app.layer._transitPartsForTest();
const { seek } = await import('../../data/contactPlayback.js');
let latitude = 42.36 - 16000 / 111320;
@@ -2731,7 +2770,7 @@ test('sparse rail reports preserve a bounded marker corridor with and without se
test('missing rectangle still rejects the far side, and frustum rejection wins', async (t) => {
const app = harness(t, { altitude: 100_000 });
app.viewer.camera.computeViewRectangle = () => undefined;
app.setViewRectangle(() => undefined);
app.serve('mbta', () => ({
status: 200,
body: snapshot(
@@ -2752,11 +2791,8 @@ test('missing rectangle still rejects the far side, and frustum rejection wins',
[...app.state()._visible].map((e) => e.record.id),
['near'],
);
app.viewer.camera.frustum = {
computeCullingVolume: () => ({
computeVisibility: () => Cesium.Intersect.OUTSIDE,
}),
};
// The camera turns to the sky: the frustum holds no vehicle.
app.aim({ ...BOSTON, height: 100_000, pitch: 0.5 });
app.advance(250);
app.layer._transitPartsForTest().rendering.refreshVisibility();
assert.equal(app.state()._visible.size, 0);
@@ -2827,7 +2863,11 @@ test('every styling path retains the mode palette and exact unpadded size', asyn
const entry = app.vehicles()[0],
parts = app.layer._transitPartsForTest();
const check = (hex, display) => {
assert.equal(entry.marker.color.toCssHexString().toUpperCase(), hex);
const { red, green, blue, alpha } = entry.marker.color;
assert.equal(
new Cesium.Color(red, green, blue, alpha).toCssHexString().toUpperCase(),
hex,
);
const svg = Buffer.from(
entry.marker.image.split(',')[1],
'base64',
@@ -2877,11 +2917,8 @@ test('visible motion is recullable without a camera event and releases its hold
await app.layer.update();
app.layer._loadTransitFleetForTest(1, BOSTON, 1);
assert.equal(app.state()._moving.size, 1);
app.viewer.camera.frustum = {
computeCullingVolume: () => ({
computeVisibility: () => Cesium.Intersect.OUTSIDE,
}),
};
// The camera turns to the sky: the frustum holds no vehicle.
app.aim({ ...BOSTON, height: 30000, pitch: 0.5 });
app.layer._transitPartsForTest().rendering.maintainPresentation();
app.advance(250);
assert.equal(app.state()._moving.size, 0);
@@ -3052,11 +3089,6 @@ test('tab visibility synchronizes playback before the returning frame and remove
for (const bounds of ['null', 'stale']) {
test(`browser visibility starts an injected fleet with ${bounds} bounds and an unsampled hidden marker`, async (t) => {
const app = harness(t);
app.viewer.camera.frustum = {
computeCullingVolume: () => ({
computeVisibility: () => Cesium.Intersect.INSIDE,
}),
};
app.layer.enable(app.viewer);
const loaded = app.layer._loadTransitFleetForTest(4, BOSTON, 70, [
'Red',
@@ -3089,8 +3121,7 @@ for (const bounds of ['null', 'stale']) {
test('keyless loaded terrain resolves a pending surface without needing a frame first', async (t) => {
const app = harness(t, { floorAt: () => undefined });
let floor;
app.viewer.scene.globe.show = true;
app.viewer.scene.globe.getHeight = () => floor;
app.setTerrain(() => floor);
app.serve('mbta', () => ({
status: 200,
body: snapshot(
@@ -3117,7 +3148,6 @@ test('keyless loaded terrain resolves a pending surface without needing a frame
test('scripted keyless fleet keeps its known floor through selection and floor rereads', async (t) => {
const app = harness(t, { floorAt: () => undefined });
app.viewer.scene.globe.show = true;
app.layer.enable(app.viewer);
app.layer._loadTransitFleetForTest(4, BOSTON, 70);
const parts = app.layer._transitPartsForTest(),
@@ -3134,27 +3164,11 @@ test('scripted keyless fleet keeps its known floor through selection and floor r
for (const count of [800, 3000]) {
test(`real perspective frustum admits all ${count} moving budget fixtures`, async (t) => {
const altitude = count === 800 ? 600 : 30000;
const app = harness(t, { altitude, floorAt: () => undefined });
const camera = app.viewer.camera;
camera.positionWC = Cesium.Cartesian3.fromDegrees(
BOSTON.lon,
BOSTON.lat,
// Straight down over Boston, 60° across a 1920 × 1080 view.
const app = harness(t, {
altitude,
);
camera.directionWC = Cesium.Cartesian3.negate(
Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
camera.positionWC,
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(camera.positionWC);
camera.upWC = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3());
camera.frustum = new Cesium.PerspectiveFrustum({
fov: Math.PI / 3,
aspectRatio: 1920 / 1080,
near: 1,
far: 100000,
floorAt: () => undefined,
engineOptions: { width: 1920, height: 1080 },
});
app.layer.enable(app.viewer);
const loaded = app.layer._loadTransitFleetForTest(
@@ -3178,23 +3192,20 @@ test('click handler selects a migrated scripted billboard and prepares a valid d
app.layer._loadTransitFleetForTest(4, BOSTON, 70);
const entry = app.vehicles()[0];
assert.equal(entry.markerCollection, app.state()._animatedMarkers);
app.viewer.scene.pick = () => ({ primitive: entry.marker, id: entry.key });
app
.state()
._clickHandler.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK)({
position: new Cesium.Cartesian2(500, 400),
});
assert.equal(app.state()._selectedKey, entry.key);
const body = app.layer._transitPartsForTest().trails.diagnostics().body;
assert.ok(body.geometryInstances.length > 0);
body._batchTableAttributeIndices = { color: 0, depthFailColor: 1 };
assert.doesNotThrow(() =>
Cesium.Primitive._updateColorAttribute(
body,
body.depthFailAppearance.vertexShaderSource,
true,
),
await app.display.whenReady();
app.click(
{ x: 500, y: 400 },
{ id: entry.key, owner: 'transit-motion-1', instance: 0 },
);
assert.equal(app.state()._selectedKey, entry.key);
const { body } = app.layer._transitPartsForTest().trails.diagnostics();
assert.ok(body.lines > 0);
app.frame();
// Where the scene hides the trail, its colour lane still draws, dimmed.
const lanes = app.engine.lines
.list()
.map(({ id }) => app.engine.fake.line(id));
assert.ok(lanes.some((line) => line.options.depthFailAlpha > 0));
assert.ok(entry.trailSegments.length > 0);
app.run(1000, 16);
assert.equal(entry.marker.show, true);
@@ -3242,14 +3253,14 @@ test('hot path reuses sample outputs and reads camera altitude once per frame',
const out = new Cesium.Cartesian3();
assert.equal(parts.trails.samplePosition(entry, entry.sample, out), out);
const sample = entry.sample;
const carto = h.viewer.camera.positionCartographic;
const pose = h.engine.camera.pose;
let reads = 0;
Object.defineProperty(h.viewer.camera, 'positionCartographic', {
configurable: true,
get() {
reads++;
return carto;
},
h.engine.camera.pose = () => {
reads++;
return pose();
};
t.after(() => {
h.engine.camera.pose = pose;
});
h.advance(17);
h.frame();
@@ -3265,7 +3276,8 @@ test('hot path reuses sample outputs and reads camera altitude once per frame',
});
test('scripted straight and changing delay preserve ordinary-frame speed through polls', async (t) => {
const h = harness(t, { altitude: 600 });
// A wide view keeps the 1.6 km run north of the camera on screen.
const h = harness(t, { altitude: 600, engineOptions: { fovy: 2.6 } });
const start = Date.now();
const modes = ['straight', 'gap', 'dup'];
h.serve('mbta', () => {
@@ -3390,7 +3402,7 @@ test('selected anchor rejects nonfinite marker coordinates before sampling and d
assert.equal(card.position(), null);
});
test('preset clicks preserve both pick id paths through moving and stationary collection migration', async (t) => {
test('preset clicks select through moving and stationary collection migration', async (t) => {
const app = harness(t, { floorAt: () => undefined });
app.layer.enable(app.viewer);
app.layer._loadTransitFleetForTest(4, BOSTON, 70);
@@ -3409,13 +3421,11 @@ test('preset clicks preserve both pick id paths through moving and stationary co
animated ? app.state()._animatedMarkers : app.state()._markers,
);
assert.equal(entry.marker.id, entry.key);
for (const pick of [{ id: entry.key }, { primitive: entry.marker }]) {
app.viewer.scene.pick = () => pick;
app
.state()
._clickHandler.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK)(
{ position: new Cesium.Cartesian2(500, 400) },
);
for (const owner of [
undefined,
animated ? 'transit-motion-1' : 'transit-1',
]) {
app.click({ x: 500, y: 400 }, { id: entry.key, owner, instance: 0 });
assert.equal(app.state()._selectedKey, entry.key);
parts.selection.clearSelection();
}
@@ -3442,36 +3452,42 @@ test('QA backwards seek reconstructs the pruned fixture paths before allocation
});
test('sensor clicks map displayed pixels into the pre-shader pick buffer', async (t) => {
const app = harness(t, { floorAt: () => undefined });
// An engine that draws the sensor styles itself (render.style).
const app = harness(t, {
floorAt: () => undefined,
engineOptions: {
width: 1920,
height: 1080,
canvas: { width: 1920, height: 1080 },
visualStyles: {
surveillance: ['pixelation'],
thermal: ['pixelation'],
},
},
});
app.layer.enable(app.viewer);
app.layer._loadTransitFleetForTest(4, BOSTON, 70);
const entry = app.vehicles()[0],
scene = app.viewer.scene;
const entry = app.vehicles()[0];
let actual;
scene.pick = (p) => {
const pick = (p) => {
actual = { x: p.x, y: p.y };
return { id: entry.key };
};
scene.canvas = {
width: 1920,
height: 1080,
clientWidth: 1920,
clientHeight: 1080,
};
const uniforms = { intensity: 1, pixelation: 1 };
scene.postProcessStages = {
getStageByName: () => ({ enabled: true, uniforms }),
};
const click = app
.state()
._clickHandler.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
const click = (position) => app.click(position, pick);
app.engine.render.setStyle('surveillance', {
intensity: 1,
uniforms: { pixelation: 1 },
});
app.layer._transitPartsForTest().rendering.setStylePreset('surveillance');
click({ position: new Cesium.Cartesian2(1440, 540) });
click({ x: 1440, y: 540 });
assert.ok(Math.abs(actual.x - 1472) < 1e-6);
assert.equal(actual.y, 540);
app.layer._transitPartsForTest().rendering.setStylePreset('thermal');
uniforms.pixelation = 1.5;
click({ position: new Cesium.Cartesian2(100.9, 200.1) });
app.engine.render.setStyle('thermal', {
intensity: 1,
uniforms: { pixelation: 1.5 },
});
click({ x: 100.9, y: 200.1 });
assert.ok(Math.abs(actual.x - 100.5) < 1e-6);
assert.ok(Math.abs(actual.y - 201) < 1e-6);
assert.equal(app.state()._selectedKey, entry.key);
@@ -3618,7 +3634,6 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve
},
},
).lifecycle.methods;
app.viewer.camera.moveEnd = new Cesium.Event();
const layers = { traffic, bikeshare: bike, transit: app.layer };
const orders = [
['traffic', 'bikeshare', 'transit'],
@@ -3661,54 +3676,27 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve
});
test('moving and stationary transit stay adjacent above CCTV and Bikeshare during migration', async (t) => {
const {
registerSpriteCollection,
unregisterSpriteCollection,
restoreSpriteOrder,
} = await import('../../data/spriteOrder.js');
const { SPRITE_LAYER_ORDER } = await import('../../data/spriteOrder.js');
const app = harness(t);
app.layer.enable(app.viewer);
app.layer._loadTransitFleetForTest(1, BOSTON, 70);
const cctv = {},
bike = {},
flights = {};
app.primitives.push(cctv, bike, flights);
app.viewer.scene.primitives.raiseToTop = (c) => {
app.primitives.splice(app.primitives.indexOf(c), 1);
app.primitives.push(c);
};
const collections = new Map([
...app.sprites,
['cctv', cctv],
['bikeshare', bike],
['flights', flights],
]);
for (const [id, collection] of collections)
registerSpriteCollection(id, collection);
t.after(() => {
for (const [id, c] of collections) unregisterSpriteCollection(id, c);
});
await app.display.whenReady();
const entry = app.vehicles()[0],
parts = app.layer._transitPartsForTest();
const order = (id) => app.engine.fake.moverSet(id)?.options.order;
for (const moving of [true, false, true]) {
entry.sample.phase = moving ? 'playing' : 'held';
entry.sample.segmentSpeedMps = moving ? 1 : 0;
entry.sample.segmentCourseDeg = NaN;
parts.rendering.schedulePlayback(entry);
restoreSpriteOrder(app.viewer);
const active = app.primitives.indexOf(entry.markerCollection);
assert.ok(active > app.primitives.indexOf(cctv));
assert.ok(active > app.primitives.indexOf(bike));
assert.ok(active < app.primitives.indexOf(flights));
assert.equal(
Math.abs(
app.primitives.indexOf(app.state()._markers) -
app.primitives.indexOf(app.state()._animatedMarkers),
),
1,
);
const active = order(moving ? 'transit-motion-1' : 'transit-1');
assert.ok(active > SPRITE_LAYER_ORDER.indexOf('cctv'));
assert.ok(active > SPRITE_LAYER_ORDER.indexOf('bikeshare'));
assert.ok(active < SPRITE_LAYER_ORDER.indexOf('flights'));
assert.equal(entry.marker.id, entry.key);
}
// Both sets now exist, the moving one directly above the stationary one.
assert.equal(order('transit-motion-1') - order('transit-1'), 1);
});
test('transit polls the supplied source and cancels it when disabled', async (t) => {
@@ -3788,8 +3776,7 @@ for (const jump of ['setView', 'flyTo']) {
test(`${jump} recovers a cold hidden vehicle within one visibility interval`, async (t) => {
const app = harness(t, { floorAt: () => undefined });
let floor;
app.viewer.scene.globe.show = true;
app.viewer.scene.globe.getHeight = () => floor;
app.setTerrain(() => floor);
app.serve('mbta', () => ({
status: 200,
body: snapshot('mbta', 'MBTA', [
@@ -3805,11 +3792,15 @@ for (const jump of ['setView', 'flyTo']) {
// timer or preRender is allowed to bootstrap the visibility recovery.
clearTimeout(app.state()._floorTimer);
app.state()._floorTimer = null;
app.viewer.camera[jump] = () => {
floor = 12;
app.viewer.camera.changed.raiseEvent();
};
app.viewer.camera[jump]();
floor = 12;
const destination = Cesium.Cartesian3.fromDegrees(
BOSTON.lon,
BOSTON.lat,
4_000,
);
if (jump === 'setView') app.engine.camera.setView({ destination });
else app.engine.camera.flyTo({ destination, duration: 0 });
app.engine.fake.emit('camera.changed', 1);
app.advance(250);
assert.equal(entry.marker.show, true);
assert.equal(entry.heightPending, false);
@@ -3841,7 +3832,7 @@ test('selected history is prepared while its head is hidden and remains shown ou
app.state()._vehicles.set(entry.key, entry);
});
test('selected draped history prepares geometry before surface heights resolve', async (t) => {
test('selected history waits for its surface heights: lines are not draped', async (t) => {
const app = harness(t, { floorAt: () => undefined });
app.layer.enable(app.viewer);
app.layer._loadTransitFleetForTest(1, BOSTON);
@@ -3850,20 +3841,12 @@ test('selected draped history prepares geometry before surface heights resolve',
delete entry.qaFloorM;
entry.marker.show = false;
entry.heightPending = true;
// Exercise the real GroundPolylinePrimitive branch, without a WebGL context.
const width = Cesium.ContextLimits._maximumAliasedLineWidth;
Cesium.ContextLimits._maximumAliasedLineWidth = 1;
t.after(() => {
Cesium.ContextLimits._maximumAliasedLineWidth = width;
});
app.viewer.scene.frameState = { context: { depthTexture: true } };
app.viewer.scene.groundPrimitives = app.viewer.scene.primitives;
parts.selection.selectVehicle(entry.key);
const trail = parts.trails.diagnostics();
assert.equal(trail.ground, true);
assert.ok(trail.body instanceof Cesium.GroundPolylinePrimitive);
assert.ok(trail.segments > 0);
assert.equal(trail.body.show, true);
assert.equal(trail.ground, false);
assert.equal(trail.segments, 0, 'no history before its floors resolve');
assert.equal(trail.body, null);
assert.deepEqual(app.engine.lines.list(), []);
assert.equal(
entry.displayPaths.size,
0,
@@ -3877,43 +3860,19 @@ test('parked transit reprojects held and reported courses at the bounded camera
const { createRendering } = await import('./rendering.js');
t.mock.timers.enable({ apis: ['Date'], now: 1000 });
const position = Cesium.Cartesian3.fromDegrees(0, 0, 20);
const cameraPosition = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
const scene = {
frameState: { mode: Cesium.SceneMode.SCENE3D },
canvas: { clientWidth: 1280, clientHeight: 800 },
camera: {
positionWC: cameraPosition,
heading: 0,
const engine = createFakeEngine({ width: 1280, height: 800 });
t.after(() => engine.destroy());
// Straight down from 1 km; each pose is a new frame.
const pose = (heading) => {
engine.fake.setPose({
longitude: 0,
latitude: 0,
height: 1000,
heading,
pitch: -Math.PI / 2,
roll: 0,
frustum: new Cesium.PerspectiveFrustum({
fov: Math.PI / 3,
aspectRatio: 1.6,
near: 1,
far: 1e7,
}),
},
};
const pose = (heading) => {
const camera = scene.camera;
camera.heading = heading;
camera.rightWC = new Cesium.Cartesian3(
0,
Math.cos(heading),
-Math.sin(heading),
);
camera.upWC = new Cesium.Cartesian3(
0,
Math.sin(heading),
Math.cos(heading),
);
camera.viewMatrix = Cesium.Matrix4.computeView(
cameraPosition,
new Cesium.Cartesian3(-1, 0, 0),
camera.upWC,
camera.rightWC,
new Cesium.Matrix4(),
);
});
engine.fake.emit('frames.preUpdate', 0);
};
pose(0);
const make = (key, courseDeg, bearing) => ({
@@ -3928,7 +3887,6 @@ test('parked transit reprojects held and reported courses at the bounded camera
const unknown = make('unknown', null, null);
const hidden = make('hidden', 90, 90);
const state = {
_viewer: { scene },
_enabled: true,
_vehicles: new Map(
[held, reportedOnly, unknown, hidden].map((e) => [e.key, e]),
@@ -3942,8 +3900,12 @@ test('parked transit reprojects held and reported courses at the bounded camera
_rotationDirty: false,
};
const noop = () => {};
const renderer = createTransitCesiumRenderer({ state, services: {} });
renderer.attach(state._viewer);
const renderer = createTransitDisplay(engine, {
state,
services: {},
drawAtlas: transitTestAtlas,
});
renderer.attach();
t.after(() => renderer.destroy());
const rendering = createRendering({
state,
-216
View File
@@ -1,216 +0,0 @@
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
cameraPoseSignature,
perspectiveProjectedRotation,
} from '../../../data/iconOrientation.js';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
} from '../../../data/cameraSensitivity.js';
import { createContactTrailRenderer } from '../../../data/render/cesiumContactTrailRenderer.js';
import { CAMERA_PERCENTAGE_CHANGED } from '../policy.js';
/** Native resource owner; feed/playback/selection policy stays in the layer. */
export function createTransitCesiumRenderer({ state, services, engine }) {
let viewer = null;
const banks = [];
const removers = new Set();
const terrainPoint = new Cesium.Cartographic();
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
const sphere = new Cesium.BoundingSphere(undefined, 5);
const cameraPosition = geo.cartesian3.create();
const own = (remove) => {
const release = () => {
if (removers.delete(release)) remove();
};
removers.add(release);
return release;
};
return {
attach(host) {
viewer = host;
},
get ready() {
return Boolean(viewer);
},
createMarkers() {
try {
for (const id of ['transit', 'transit-motion']) {
const collection = new Cesium.BillboardCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
banks.push({ id, collection });
viewer.scene.primitives.add(collection);
services.sprites.registerSpriteCollection(id, collection);
collection.show = false;
}
return banks.map(({ collection }) => collection);
} catch (error) {
this.destroy();
throw error;
}
},
restoreOrder: () => services.sprites.restoreSpriteOrder(viewer),
cameraCartographic: () => viewer?.camera?.positionCartographic,
viewRectangle: () =>
viewer?.camera?.computeViewRectangle?.(viewer.scene.globe?.ellipsoid),
cameraSignature: () =>
viewer?.scene?.camera ? cameraPoseSignature(viewer.scene.camera) : null,
rotation: (position, course, previous) =>
perspectiveProjectedRotation(viewer.scene, position, course, previous),
visibilityTester() {
const camera = viewer?.camera;
if (!camera) return null;
const position =
camera.positionWC ||
geo.ellipsoid.WGS84.cartographicToCartesian(
camera.positionCartographic,
cameraPosition,
);
occluder.cameraPosition = position;
const frustum = camera.frustum?.computeCullingVolume(
position,
camera.directionWC,
camera.upWC,
);
const planes = frustum?.planes;
return {
hasFrustum: Boolean(frustum),
test(point) {
sphere.center = point;
let inFrustum = true;
if (planes) {
for (const p of planes) {
if (
p.x * point.x + p.y * point.y + p.z * point.z + p.w <
-sphere.radius
) {
inFrustum = false;
break;
}
}
} else if (frustum)
inFrustum =
frustum.computeVisibility(sphere) !== Cesium.Intersect.OUTSIDE;
return { horizon: occluder.isPointVisible(point), inFrustum };
},
};
},
claimCamera() {
claimCameraSensitivity(
engine ?? viewer?.camera,
'transit',
CAMERA_PERCENTAGE_CHANGED,
);
},
releaseCamera() {
releaseCameraSensitivity(engine ?? viewer?.camera, 'transit');
},
onCameraChanged: (fn) => own(viewer.camera.changed.addEventListener(fn)),
onPreRender: (fn) => own(viewer.scene.preRender.addEventListener(fn)),
onPostRender: (fn) => own(viewer.scene.postRender.addEventListener(fn)),
terrainHeight(lat, lon) {
const globe = viewer?.scene?.globe;
if (globe?.show !== true) return undefined;
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
const rendered = globe.getHeight?.(terrainPoint);
return viewer?.terrainProvider instanceof Cesium.EllipsoidTerrainProvider
? 0
: rendered;
},
sampleMeshFloorCells: (points, options) =>
services.mesh?.sampleMeshFloorCells?.(viewer?.scene, points, options),
addCredit(feed) {
if (viewer)
services.credits.registerDynamicCredit(
viewer,
services.credits.transitFeedCredit(feed),
);
},
createTrailRenderer: () => createContactTrailRenderer(viewer.scene),
bindClick(listener, pointerFree) {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((click) => {
if (!pointerFree()) return;
const scene = viewer.scene;
const position = Cesium.Cartesian2.clone(click.position);
const style = state._stylePreset;
const stage = scene.postProcessStages?.getStageByName?.(
`godsEyeView_${style}`,
);
if (
stage?.enabled &&
(style === 'thermal' || style === 'surveillance')
) {
const intensity = stage.uniforms.intensity;
const canvas = scene.canvas,
w = canvas.clientWidth,
h = canvas.clientHeight;
let u = position.x / w,
v = 1 - position.y / h;
if (style === 'surveillance') {
const x = u * 2 - 1,
y = v * 2 - 1,
r2 = x * x + y * y;
const d = 1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
u = (x * d + 1) / 2;
v = (y * d + 1) / 2;
}
const grid = 1 + (stage.uniforms.pixelation - 1) * intensity;
u +=
((Math.floor((u * canvas.width) / grid) * grid) / canvas.width -
u) *
intensity;
v +=
((Math.floor((v * canvas.height) / grid) * grid) / canvas.height -
v) *
intensity;
position.x = u * w;
position.y = (1 - v) * h;
}
const picked = scene.pick(position);
listener({
id: typeof picked?.id === 'string' ? picked.id : null,
primitiveId:
typeof picked?.primitive?.id === 'string'
? picked.primitive.id
: null,
x: position.x,
y: position.y,
collection:
picked?.primitive?._billboardCollection === state._animatedMarkers
? 'animated'
: picked?.primitive?._billboardCollection === state._markers
? 'stationary'
: null,
});
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
const remove = own(() => handler.destroy());
return {
destroy: remove,
getInputAction: (type) => handler.getInputAction(type),
};
},
frameProbe() {
return {
canvas: viewer.scene.canvas,
gl: viewer.scene.context._gl,
uploads: banks.map(({ collection }) => ({
target: collection,
method: 'update',
})),
};
},
destroy() {
for (const remove of [...removers]) remove();
this.releaseCamera();
for (const { id, collection } of banks.splice(0)) {
services.sprites.unregisterSpriteCollection(id, collection);
viewer?.scene?.primitives?.remove(collection);
if (!collection.isDestroyed()) collection.destroy();
}
viewer = null;
},
};
}
-58
View File
@@ -1,58 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createTransitCesiumRenderer } from './cesium.js';
test('failed native bank registration releases both acquired collections', () => {
const acquired = [],
registered = new Map();
const primitives = new Cesium.PrimitiveCollection();
const renderer = createTransitCesiumRenderer({
state: {},
services: {
sprites: {
registerSpriteCollection(id, collection) {
acquired.push(collection);
registered.set(id, collection);
if (id === 'transit-motion') throw new Error('registration failed');
},
unregisterSpriteCollection: (id) => registered.delete(id),
},
},
});
renderer.attach({ scene: { primitives } });
assert.throws(() => renderer.createMarkers(), /registration failed/);
assert.equal(primitives.length, 0);
assert.equal(registered.size, 0);
assert.equal(acquired.length, 2);
assert.ok(acquired.every((collection) => collection.isDestroyed()));
renderer.destroy();
primitives.destroy();
});
test('failed scene insertion also destroys the uninserted collection', (t) => {
let acquired;
t.mock.method(Cesium.BillboardCollection.prototype, 'destroy', function () {
acquired = this;
this.isDestroyed = () => true;
});
const renderer = createTransitCesiumRenderer({
state: {},
services: { sprites: { unregisterSpriteCollection() {} } },
});
renderer.attach({
scene: {
primitives: {
add() {
throw new Error('insertion failed');
},
remove() {
return false;
},
},
},
});
assert.throws(() => renderer.createMarkers(), /insertion failed/);
assert.ok(acquired?.isDestroyed());
renderer.destroy();
});
+778
View File
@@ -0,0 +1,778 @@
// The transit vehicles on the globe contract, the same on every engine:
// - markers: two engine.movers sprite sets (the stationary fleet and the
// vehicles mid-glide, so a glide never rewrites the fleet), drawn over the
// scene and turned on screen; one atlas holds every glyph the layer draws
// (data/transitIcons.js). The layer writes Billboard-shaped handles
// (position, image, width, color, rotation, show); the writes of a frame
// are committed once, at the end of the frame listener that made them (in
// a microtask for writes outside a frame);
// - the selected vehicle's trail: engine.lines, its completed history in
// alpha bands by age and its live head up to the marker;
// - the camera, surface and credits through engine.camera, engine.surface
// and engine.credits; clicks through engine.input and engine.picker.
// Feed, playback and selection policy stay in the layer.
import * as geo from '../../../geo/index.js';
import { Rgba, rgbaFromCss } from '../../shared/color.js';
import { createMoverBank } from '../../shared/render/moverBank.js';
import { createEngineLayerView } from '../../shared/render/engineView.js';
import {
decodeImages,
packImageAtlas,
} from '../../shared/render/imageAtlas.js';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
} from '../../../data/cameraSensitivity.js';
import {
cameraPoseSignature,
perspectiveProjectedRotation,
} from '../../../data/iconOrientation.js';
import { TRANSIT_ICON_KINDS, transitIcon } from '../../../data/transitIcons.js';
import { CAMERA_PERCENTAGE_CHANGED } from '../policy.js';
import {
MARKER_SET_IDS,
SPRITE_SIZE_PX,
TRAIL_BACKING_RGB,
TRAIL_BACKING_WIDTH_PX,
TRAIL_DEFAULT_CSS,
TRAIL_HEAD_ALPHA,
TRAIL_OCCLUDED_ALPHA,
TRAIL_WIDTH_PX,
markerOrder,
trailAlpha,
trailBand,
trailBandAlpha,
} from './style.js';
const LAYER_ID = 'transit';
// The frustum test keeps a vehicle this close outside the view (metres).
const VIEW_MARGIN_M = 5;
// Consecutive trail segments closer than this (squared metres) join.
const JOIN_DISTANCE_SQ = 1;
/** Every glyph the markers draw: each mode, look and size. */
export function transitAtlasIcons() {
return [
...new Set(
TRANSIT_ICON_KINDS.flatMap((mode) =>
['normal', 'thermal', 'retro'].flatMap((style) =>
[48, 96].map((px) => transitIcon(mode, px, { style })),
),
),
),
];
}
/** Draws the atlas (browser): each glyph at its own size, keyed by its URL. */
async function drawTransitAtlas() {
const urls = transitAtlasIcons();
const images = await decodeImages(urls);
const cell = Math.max(...images.map((image) => image.naturalWidth)) + 4;
return packImageAtlas(
images.map((image, i) => ({
key: urls[i],
image,
width: image.naturalWidth,
height: image.naturalHeight,
})),
{ cell, pad: 2 },
);
}
/**
* The two marker sets as Billboard collections: `collections` hold
* Billboard-shaped handles; `inFrame(run)` commits what `run` wrote once.
*/
function createMarkerBanks(engine, { drawAtlas }) {
let destroyed = false,
atlas = null,
queued = false,
inFrame = 0;
const pending = new Set();
const owners = [];
const api = {
flush() {
queued = false;
if (destroyed) return;
for (const bank of pending) bank.commit();
pending.clear();
},
inFrame(run) {
inFrame++;
try {
return run();
} finally {
if (--inFrame === 0) api.flush();
}
},
/** The live mover sets (what a surface sample leaves out). */
sets: () => owners.map((owner) => owner.set()).filter(Boolean),
destroy() {
if (destroyed) return;
destroyed = true;
for (const owner of owners) owner.destroy();
pending.clear();
atlas = null;
},
};
const schedule = (bank) => {
pending.add(bank);
if (!queued && !inFrame && !destroyed) {
queued = true;
queueMicrotask(() => api.flush());
}
};
function collection(id) {
const handles = new Set();
let bank = null,
visible = false;
const rgba = [1, 1, 1, 1];
const colorValues = (color) => {
rgba[0] = color.red;
rgba[1] = color.green;
rgba[2] = color.blue;
rgba[3] = color.alpha;
return rgba;
};
const fields = {
position: undefined,
rotation: undefined,
scale: undefined,
color: undefined,
show: undefined,
cell: undefined,
};
const update = (handle, key, value) => {
if (!bank || destroyed || !handles.has(handle)) return;
fields[key] = value;
bank.update(handle, fields);
fields[key] = undefined;
};
const ensure = () => {
if (bank || !atlas || destroyed) return;
bank = createMoverBank(engine, {
options: (capacity, generation) => ({
id: `${id}-${generation}`,
kind: 'sprite',
capacity,
order: markerOrder(id),
depthTest: 'always',
pickable: true,
sprite: { atlas, sizePx: SPRITE_SIZE_PX },
}),
write: (h, set, index) =>
set.set(index, {
position: h.position,
rotation: h.rotation,
scale: h.width / SPRITE_SIZE_PX,
color: colorValues(h.color),
cell: h.image,
show: h.show,
pickId: h.id,
}),
onCommit: schedule,
});
bank.setVisible(visible);
for (const handle of handles) bank.add(handle);
};
owners.push({
ensure,
set: () => bank?.set ?? null,
destroy() {
handles.clear();
bank?.destroy();
bank = null;
},
});
return {
get show() {
return visible;
},
set show(value) {
const next = Boolean(value);
if (destroyed || next === visible) return;
visible = next;
bank?.setVisible(next);
},
get length() {
return handles.size;
},
get: (index) => bank?.handles[index] ?? [...handles][index],
contains: (handle) => handles.has(handle),
add(spec) {
if (destroyed) throw new Error('Transit markers are destroyed');
const position = geo.cartesian3.clone(spec.position);
let image = spec.image,
show = spec.show !== false,
rotation = spec.rotation ?? 0,
width = spec.width ?? SPRITE_SIZE_PX,
height = spec.height ?? width;
let color = new Rgba(
spec.color?.red,
spec.color?.green,
spec.color?.blue,
spec.color?.alpha,
);
const handle = {
id: spec.id,
gevBank: null,
gevIndex: -1,
alignedAxis: geo.cartesian3.ZERO,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
scaleByDistance: spec.scaleByDistance,
get position() {
return position;
},
set position(value) {
if (destroyed || geo.cartesian3.equals(position, value)) return;
geo.cartesian3.clone(value, position);
update(handle, 'position', position);
},
get image() {
return image;
},
set image(value) {
if (destroyed || image === value) return;
image = value;
update(handle, 'cell', image);
},
get show() {
return show;
},
set show(value) {
const next = Boolean(value);
if (destroyed || show === next) return;
show = next;
update(handle, 'show', show);
},
get rotation() {
return rotation;
},
set rotation(value) {
if (destroyed || rotation === value) return;
rotation = value;
update(handle, 'rotation', rotation);
},
get width() {
return width;
},
set width(value) {
if (destroyed || width === value) return;
width = value;
update(handle, 'scale', width / SPRITE_SIZE_PX);
},
// Sprites are square: the width sets the size.
get height() {
return height;
},
set height(value) {
height = value;
},
get color() {
return color;
},
set color(value) {
if (destroyed || color.equals(value)) return;
color = new Rgba(value.red, value.green, value.blue, value.alpha);
update(handle, 'color', colorValues(color));
},
};
handles.add(handle);
ensure();
bank?.add(handle);
return handle;
},
remove(handle) {
if (!handles.delete(handle)) return false;
bank?.remove(handle);
return true;
},
removeAll() {
for (const handle of handles) bank?.remove(handle);
handles.clear();
},
};
}
api.collections = MARKER_SET_IDS.map(collection);
api.ready = Promise.resolve()
.then(drawAtlas)
.then((drawn) => {
if (destroyed) return;
atlas = drawn;
for (const owner of owners) owner.ensure();
api.flush();
});
// An early decode failure is reported even before a caller awaits ready.
api.ready.catch(() => {
if (!destroyed) console.error('Transit sprite atlas failed');
});
return api;
}
const samePosition = (a, b) =>
!!a &&
!!b &&
(a === b || geo.cartesian3.distanceSquared(a, b) < JOIN_DISTANCE_SQ);
/**
* The selected vehicle's trail on engine.lines. The history is the
* layer's prepared segments ({ fromT, toT, fromSeq, toSeq, positions: [a,
* b] }, in time order); the segments the display clock has passed draw in
* two lanes (a dark edge under the colour), one line per lane for each run
* of joined segments in one age band, re-banded once per displayed second.
* The head is the segment being driven, from its start to the marker.
*/
function createTrail(engine, { id }) {
let seq = 0;
let segments = [],
runs = [],
revision = -1,
rebuilds = 0,
headRebuilds = 0,
visible = true,
destroyed = false;
let rgb = rgbaFromCss(TRAIL_DEFAULT_CSS).rgba().slice(0, 3);
let css = TRAIL_DEFAULT_CSS;
let completed = -1,
lastSecond = -1,
active = null;
// Body lines by run and band: { backing, line, from, to, band }.
let groups = new Map();
let body = { show: true, ready: true, lines: 0 };
const head = { show: false, positions: [], width: TRAIL_WIDTH_PX };
const backing = {
show: false,
positions: [],
width: TRAIL_BACKING_WIDTH_PX,
};
const lineOf = (widthPx, color, depthFailAlpha, order) => {
const line = engine.lines.createLine({
id: `${id}-${++seq}`,
widthPx,
color,
depthFailAlpha,
order,
});
line.show(visible);
return line;
};
const laneColor = (alpha) => [...rgb, alpha];
const backingColor = (alpha) => [...TRAIL_BACKING_RGB, alpha];
// Where the scene hides the trail, its colour at TRAIL_OCCLUDED_ALPHA.
const occluded = (alpha) => Math.min(1, TRAIL_OCCLUDED_ALPHA / alpha);
let headLines = null;
const ensureHead = () =>
(headLines ??= {
backing: lineOf(
TRAIL_BACKING_WIDTH_PX,
backingColor(TRAIL_HEAD_ALPHA),
0,
2,
),
line: lineOf(
TRAIL_WIDTH_PX,
laneColor(TRAIL_HEAD_ALPHA),
occluded(TRAIL_HEAD_ALPHA),
3,
),
drawn: [],
});
const dropGroup = (group) => {
group.backing.destroy();
group.line.destroy();
};
const clearBody = () => {
for (const group of groups.values()) dropGroup(group);
groups = new Map();
body.lines = 0;
};
// The positions of segments [from, to] as one line.
const runPositions = (from, to) => {
const positions = [segments[from].positions[0]];
for (let i = from; i <= to; i++) positions.push(segments[i].positions[1]);
return positions;
};
// Each run's completed segments by band (a run's alpha only falls with
// age, so a band is one span of it); a line keeps its run and band and is
// rewritten only when its span changes.
function band(displayT) {
const reusable = groups;
groups = new Map();
let start = 0;
while (start <= completed) {
const run = runs[start];
const level = trailBand(trailAlpha(displayT - segments[start].toT));
let end = start;
while (
end + 1 <= completed &&
runs[end + 1] === run &&
trailBand(trailAlpha(displayT - segments[end + 1].toT)) === level
)
end++;
const key = `${run}:${level}`;
let group = reusable.get(key);
reusable.delete(key);
if (!group) {
const alpha = trailBandAlpha(level);
group = {
level,
backing: lineOf(TRAIL_BACKING_WIDTH_PX, backingColor(alpha), 0, 0),
line: lineOf(TRAIL_WIDTH_PX, laneColor(alpha), occluded(alpha), 1),
from: -1,
to: -1,
};
}
if (group.from !== start || group.to !== end) {
const positions = runPositions(start, end);
group.backing.setPositions(positions);
group.line.setPositions(positions);
group.from = start;
group.to = end;
}
groups.set(key, group);
start = end + 1;
}
for (const group of reusable.values()) dropGroup(group);
body.lines = groups.size * 2;
}
function hideHead() {
head.show = backing.show = false;
if (headLines) {
headLines.backing.show(false);
headLines.line.show(false);
}
}
return {
replaceHistory(next) {
if (destroyed || next.revision === revision) return;
revision = next.revision;
segments = next.segments;
runs = [];
let run = 0;
for (let i = 0; i < segments.length; i++) {
if (
i > 0 &&
!samePosition(segments[i - 1].positions[1], segments[i].positions[0])
)
run++;
runs.push(run);
}
completed = -1;
lastSecond = -1;
active = null;
clearBody();
hideHead();
body = { show: visible, ready: true, lines: 0 };
if (segments.length) rebuilds++;
engine.render.request('transit-trail-history');
},
setDisplaySample(sample, renderedPosition) {
if (destroyed) return;
if (!sample || !renderedPosition) {
hideHead();
return;
}
const second = Math.floor(sample.displayT / 1000);
let lo = 0,
hi = segments.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (segments[mid].toT <= sample.displayT) lo = mid + 1;
else hi = mid;
}
const done = lo - 1;
const candidate = segments[lo];
const segment =
candidate &&
candidate.fromT <= sample.displayT &&
candidate.fromSeq === sample.fromSeq &&
candidate.toSeq === sample.toSeq
? candidate
: null;
if (done !== completed || second !== lastSecond) {
completed = done;
lastSecond = second;
band(sample.displayT);
}
const show = visible && !!segment && sample.fraction < 1;
head.show = backing.show = show;
if (!show) {
hideHead();
return;
}
active = segment;
const lines = ensureHead();
lines.backing.show(true);
lines.line.show(true);
const positions = [segment.positions[0], renderedPosition];
head.positions = backing.positions = positions;
if (
lines.drawn.length !== 2 ||
!geo.cartesian3.equals(lines.drawn[0], positions[0]) ||
!geo.cartesian3.equals(lines.drawn[1], positions[1])
) {
lines.drawn = positions.map((p) => geo.cartesian3.clone(p));
lines.backing.setPositions(lines.drawn);
lines.line.setPositions(lines.drawn);
headRebuilds++;
}
},
setStyle(next) {
if (destroyed || next === css) return;
css = next;
rgb = rgbaFromCss(css).rgba().slice(0, 3);
for (const group of groups.values())
group.line.setColor(laneColor(trailBandAlpha(group.level)));
headLines?.line.setColor(laneColor(TRAIL_HEAD_ALPHA));
engine.render.request('transit-trail-style');
},
setVisible(next) {
if (destroyed || visible === next) return;
visible = next;
body.show = next;
for (const group of groups.values()) {
group.backing.show(next);
group.line.show(next);
}
const headShown = next && head.show;
headLines?.backing.show(headShown);
headLines?.line.show(headShown);
engine.render.request('transit-trail-visible');
},
destroy() {
if (destroyed) return;
destroyed = true;
clearBody();
if (headLines) {
headLines.backing.destroy();
headLines.line.destroy();
headLines = null;
}
segments = [];
engine.render.request('transit-trail-destroy');
},
diagnostics: () => ({
revision,
rebuilds,
segments: segments.length,
body: segments.length ? body : null,
head,
backing,
headPrimitive: null,
active,
ground: false,
headStrategy: 'engine lines: age bands and a head to the marker',
headRebuilds,
entitiesAdded: 0,
}),
};
}
/**
* Where a click lands in the image before a sensor style bent it: the
* surveillance lens's barrel distortion and the sensors' pixelation grid
* (src/styles/surveillance.js, thermal.js), read from the engine's own
* style state (render.style). Cesium has none: there clicks pick where they
* land.
*/
function styleClickPosition(engine, style, position) {
if (style !== 'thermal' && style !== 'surveillance') return position;
const styles = engine.render.visualStyles;
if (!styles?.includes(style)) return position;
const { intensity = 0, uniforms = {} } = engine.render.style(style) ?? {};
if (!(intensity > 0)) return position;
const { width: w, height: h } = engine.host.size();
const canvas = engine.host.canvas;
const bufferWidth = canvas?.width || w,
bufferHeight = canvas?.height || h;
let u = position.x / w,
v = 1 - position.y / h;
if (style === 'surveillance') {
const x = u * 2 - 1,
y = v * 2 - 1,
r2 = x * x + y * y;
const d = 1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
u = (x * d + 1) / 2;
v = (y * d + 1) / 2;
}
const grid = 1 + ((uniforms.pixelation ?? 1) - 1) * intensity;
u +=
((Math.floor((u * bufferWidth) / grid) * grid) / bufferWidth - u) *
intensity;
v +=
((Math.floor((v * bufferHeight) / grid) * grid) / bufferHeight - v) *
intensity;
return { x: u * w, y: (1 - v) * h };
}
/**
* Registry factory (src/app/layers/renderers.js): `(engine, context)`.
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {{ state: object, services: object,
* drawAtlas?: () => Promise<{ canvas: object, cells: object }> }} context
* `drawAtlas` is a test seam: the glyph atlas.
*/
export function createTransitDisplay(
engine,
{ state, services, drawAtlas = drawTransitAtlas } = {},
) {
if (!engine) throw new TypeError('The transit display needs a globe engine');
let destroyed = false,
attached = false,
markers = null;
const removers = new Set(),
trails = new Set();
const wrap = (run) => (markers ? markers.inFrame(run) : run());
const layerView = createEngineLayerView(engine, {
layerId: LAYER_ID,
wrap,
isDestroyed: () => destroyed,
});
const { view, freshCamera } = layerView;
const own = (remove) => {
const release = () => {
if (removers.delete(release)) remove();
};
removers.add(release);
return release;
};
const listen = (event, fn) =>
own(event.addEventListener((...args) => wrap(() => fn(...args))));
const scratchCartographic = geo.cartographic.create();
return {
attach() {
attached = true;
},
get ready() {
return attached && !destroyed;
},
createMarkers() {
markers = createMarkerBanks(engine, { drawAtlas });
return markers.collections;
},
/** The atlas is drawn (tests, QA). */
whenReady: () => markers?.ready,
cameraCartographic: () => freshCamera().positionCartographic,
viewRectangle: () => engine.camera.viewRectangle(),
cameraSignature: () => cameraPoseSignature(freshCamera()),
rotation: (position, course, previous) =>
perspectiveProjectedRotation(view.scene, position, course, previous),
// A pose of its own: the sweep runs off the frame path, after any turn.
visibilityTester() {
const pose = engine.camera.pose();
const { width, height } = engine.host.size();
const tanY = Math.tan(pose.fovy / 2);
const tanX =
tanY * (pose.aspectRatio ?? (height > 0 ? width / height : 1));
// The perpendicular margin to each side plane, along the view axis.
const xMargin = VIEW_MARGIN_M * Math.hypot(1, tanX),
yMargin = VIEW_MARGIN_M * Math.hypot(1, tanY);
const delta = geo.cartesian3.create();
return {
hasFrustum: true,
test(point) {
geo.cartesian3.subtract(point, pose.position, delta);
const z = geo.cartesian3.dot(delta, pose.direction);
return {
horizon: engine.projector.horizonVisible(point),
inFrustum:
z >= -VIEW_MARGIN_M &&
Math.abs(geo.cartesian3.dot(delta, pose.right)) <=
z * tanX + xMargin &&
Math.abs(geo.cartesian3.dot(delta, pose.up)) <=
z * tanY + yMargin,
};
},
};
},
claimCamera: () =>
claimCameraSensitivity(engine, LAYER_ID, CAMERA_PERCENTAGE_CHANGED),
releaseCamera: () => releaseCameraSensitivity(engine, LAYER_ID),
onCameraChanged: (fn) => listen(engine.camera.changed, fn),
onPreRender: (fn) => listen(engine.frames.preRender, fn),
onPostRender: (fn) => own(engine.frames.postRender.addEventListener(fn)),
terrainHeight(lat, lon) {
// Photoreal tiles hide the globe: the floor comes from the mesh
// samples and the coarse floors instead.
if (engine.maps.photorealActive()) return undefined;
if (!engine.surface.terrainHeightSupported) return undefined;
return engine.surface.terrainHeight(
geo.cartographic.fromDegrees(lon, lat, 0, scratchCartographic),
);
},
// The engine's surface leaves out mover sets, not single sprites: a
// probe never lands on a vehicle of either set.
sampleMeshFloorCells: (points, options) =>
services.mesh?.sampleMeshFloorCells?.(engine, points, {
...options,
excludeObjects: markers?.sets() ?? [],
}),
addCredit(feed) {
services.credits.registerDynamicCredit(
engine,
services.credits.transitFeedCredit(feed),
);
},
createTrailRenderer() {
const trail = createTrail(engine, { id: `${LAYER_ID}-trail` });
trails.add(trail);
const destroy = trail.destroy;
trail.destroy = () => {
trails.delete(trail);
destroy();
};
return trail;
},
bindClick(listener, pointerFree) {
const remove = own(
engine.input.on('click', (event) => {
if (!pointerFree()) return;
const position = styleClickPosition(
engine,
state._stylePreset,
event.position,
);
const picked = engine.picker.pick(position);
const id = typeof picked?.id === 'string' ? picked.id : null;
const entry = id === null ? null : state._vehicles.get(id);
listener({
id,
primitiveId: null,
x: position.x,
y: position.y,
collection: entry?.marker
? entry.markerCollection === state._animatedMarkers
? 'animated'
: 'stationary'
: null,
});
}),
);
return { destroy: remove };
},
frameProbe() {
// The contract has no per-set upload hook: every scene upload counts,
// an upper bound (never an unavailable count read as zero).
const canvas = engine.host.canvas;
return {
canvas,
gl: canvas.getContext?.('webgl2'),
uploads: [],
allUploads: true,
uploadScope: 'scene-upper-bound',
};
},
destroy() {
if (destroyed) return;
destroyed = true;
for (const remove of [...removers]) remove();
releaseCameraSensitivity(engine, LAYER_ID);
for (const trail of [...trails]) trail.destroy();
markers?.destroy();
markers = null;
layerView.destroy();
attached = false;
},
};
}
+783
View File
@@ -0,0 +1,783 @@
// The transit display on the engine contract, on the fake engine and on the
// Cesium engine (real Cesium billboard collections and polylines over a stub
// scene: they draw nothing but hold what the engine writes): the two marker
// sets and their Billboard-shaped handles, one commit per frame, the glyph
// atlas, on-screen course rotation, the view test, the surface and credits,
// clicks (sensor styles included), the selected trail's history bands and
// head, and teardown.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createFakeEventHandlers } from '../../../globe/cesium/testing/fakeViewer.mjs';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { perspectiveProjectedRotation } from '../../../data/iconOrientation.js';
import { cameraSensitivityClaims } from '../../../data/cameraSensitivity.js';
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
import { transitIcon } from '../../../data/transitIcons.js';
import { rgbaFromCss } from '../../shared/color.js';
import { createTransitDisplay, transitAtlasIcons } from './display.js';
import { TRAIL_BANDS, trailAlpha, trailBand, trailBandAlpha } from './style.js';
import { transitAtlasLayout, transitTestAtlas } from './testAtlas.mjs';
const AUSTIN = { lat: 30.2672, lon: -97.7431 };
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
const near = (actual, expected, label, tolerance = 1e-6) =>
assert.ok(
Math.abs(actual - expected) < tolerance,
`${label}: ${actual} vs ${expected}`,
);
function fakeHarness(options = {}) {
const engine = createFakeEngine(options.engine);
return {
name: 'the fake engine',
engine,
frame: () => engine.fake.frame(),
commits: (id) => engine.fake.moverSet(id).commits,
sprite(id, index) {
const { store, options: set } = engine.fake.moverSet(id);
return {
shown: index < store.count && store.shows[index] === 1,
sizePx: set.sprite.sizePx * store.scales[index],
color: [...store.colors.slice(index * 4, index * 4 + 4)],
rotation: store.rotations[index],
cell: store.cellKeys[store.cells[index]],
id: store.pickIds[index],
};
},
line: (id) => {
const line = engine.fake.line(id);
return {
color: [...line.color],
widthPx: line.options.widthPx,
depthFailAlpha: line.options.depthFailAlpha,
shown: line.shown,
points: line.positions.length / 3,
};
},
click(position, picked) {
engine.fake.script('pick', picked);
engine.fake.input('click', { position });
},
};
}
function cesiumHarness() {
const handlers = createFakeEventHandlers();
const { engine, viewer, frame, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
},
});
let native;
let picked = [];
viewer.scene.pick = (position) => {
picked.push({ x: position.x, y: position.y });
return native;
};
viewer.scene.drillPick = () => (native ? [native] : []);
const count = (id) => engine.movers.list().find((set) => set.id === id).count;
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
frame,
primitive,
picked: () => picked,
sprite(id, index) {
const billboard = primitive(id).get(index);
return {
shown: index < count(id) && billboard.show,
sizePx: billboard.width * billboard.scale,
color: [
billboard.color.red,
billboard.color.green,
billboard.color.blue,
billboard.color.alpha,
],
rotation: billboard.rotation,
id: billboard.id,
native: billboard,
};
},
line(id) {
const { points, shown } = engine.lines.list().find((l) => l.id === id);
const line = primitive(id);
const color = line?.appearance.material.uniforms.color;
return {
color: color && [color.red, color.green, color.blue, color.alpha],
widthPx: line?.geometryInstances.geometry._width,
depthFailAlpha: line?.depthFailAppearance
? line.depthFailAppearance.material.uniforms.color.alpha / color.alpha
: 0,
shown,
points,
};
},
click(position, pick) {
native = pick
? { primitive: primitive(pick.owner).get(pick.instance) }
: undefined;
picked = [];
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(position.x, position.y),
});
},
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
function setup(
makeHarness,
{ vehicles = new Map(), credits = [], mesh = [] } = {},
) {
const h = makeHarness();
h.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 2000),
});
const state = {
_stylePreset: 'normal',
_vehicles: vehicles,
_markers: null,
_animatedMarkers: null,
};
const display = createTransitDisplay(h.engine, {
state,
services: {
credits: {
registerDynamicCredit: (target, credit) =>
credits.push([target, credit]),
transitFeedCredit: (feed) => ({
key: `transit-${feed.id}`,
html: feed.name,
}),
},
mesh: {
sampleMeshFloorCells: (...args) => mesh.push(args),
},
},
drawAtlas: transitTestAtlas,
});
display.attach();
[state._markers, state._animatedMarkers] = display.createMarkers();
return { ...h, display, state, credits, mesh };
}
const spec = (id, overrides = {}) => ({
id,
position: geo.cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 5),
image: transitIcon('bus'),
width: 20,
height: 20,
color: rgbaFromCss('#5EF08A'),
rotation: 0,
show: true,
...overrides,
});
test('the display needs a globe engine', () => {
assert.throws(() => createTransitDisplay(null, {}), TypeError);
});
test('the atlas holds every glyph the markers draw, once each', () => {
const icons = transitAtlasIcons();
assert.equal(new Set(icons).size, icons.length);
const { cells } = transitAtlasLayout();
for (const mode of ['bus', 'tram', 'subway', 'rail', 'ferry', 'unknown'])
for (const style of [
'normal',
'thermal',
'surveillance',
'noir',
'retro',
'anime',
])
for (const px of [undefined, 96])
assert.ok(
cells[transitIcon(mode, px, { style })],
`${mode} ${style} ${px}`,
);
});
test('the age bands keep the trail alpha anchors', () => {
assert.equal(trailAlpha(0), 0.8);
assert.equal(trailAlpha(120000), 0.45);
assert.equal(trailAlpha(600000), 0.2);
assert.equal(trailAlpha(900000), 0.08);
assert.ok(Math.abs(trailAlpha(60000) - 0.625) < 1e-10);
assert.equal(trailBandAlpha(trailBand(0.8)), 0.8);
assert.equal(trailBandAlpha(trailBand(0.08)), 0.08);
for (let age = 0; age <= 1_000_000; age += 1000)
assert.ok(
Math.abs(trailBandAlpha(trailBand(trailAlpha(age))) - trailAlpha(age)) <=
0.72 / TRAIL_BANDS / 2 + 1e-12,
`age ${age}`,
);
});
test('a late atlas cannot resurrect destroyed markers', async () => {
let resolve;
const engine = createFakeEngine();
const display = createTransitDisplay(engine, {
state: {},
services: {},
drawAtlas: () =>
new Promise((done) => {
resolve = done;
}),
});
display.attach();
const [stationary] = display.createMarkers();
stationary.add(spec('late'));
await Promise.resolve();
display.destroy();
resolve(await transitTestAtlas());
await settle();
assert.deepEqual(engine.movers.list(), []);
});
for (const makeHarness of HARNESSES) {
const { name } = makeHarness();
test(`markers are two over-the-scene sprite sets written once per frame (${name})`, async () => {
const f = setup(makeHarness);
await f.display.whenReady();
const [stationary, moving] = [f.state._markers, f.state._animatedMarkers];
stationary.show = moving.show = true;
for (let i = 0; i < 50; i++) stationary.add(spec(`static-${i}`));
const source = spec('moving');
const marker = moving.add(source);
await settle();
f.frame();
await settle();
const sets = f.engine.movers.list();
assert.deepEqual(
sets.map(({ id, count }) => [id, count]),
[
['transit-1', 50],
['transit-motion-1', 1],
],
);
source.position.x += 99;
assert.notEqual(
marker.position.x,
source.position.x,
'creation copies coordinates',
);
const glyph = f.sprite('transit-motion-1', 0);
assert.equal(glyph.shown, true);
assert.equal(glyph.id, 'moving', 'a pick returns the vehicle key');
near(glyph.sizePx, 20, 'size');
rgbaFromCss('#5EF08A')
.rgba()
.forEach((value, i) => near(glyph.color[i], value, `colour ${i}`));
if (f.cesium) {
assert.equal(
glyph.native.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.ok(f.primitive('transit-1') instanceof Cesium.BillboardCollection);
} else {
const { options } = f.engine.fake.moverSet('transit-1');
assert.equal(options.depthTest, 'always');
assert.equal(options.pickable, true);
assert.equal(options.sprite.sizePx, 20);
assert.equal(options.order, SPRITE_LAYER_ORDER.indexOf('transit'));
assert.equal(
f.engine.fake.moverSet('transit-motion-1').options.order,
SPRITE_LAYER_ORDER.indexOf('transit-motion'),
);
assert.equal(glyph.cell, transitIcon('bus'));
}
// A frame's writes are one commit of the set they touch.
const before = f.cesium
? null
: [f.commits('transit-1'), f.commits('transit-motion-1')];
let wrote = false;
const remove = f.display.onPreRender(() => {
if (wrote) return;
wrote = true;
marker.position = geo.cartesian3.fromDegrees(
AUSTIN.lon,
AUSTIN.lat + 0.001,
5,
);
marker.rotation = 0.3;
marker.width = 30;
marker.color = rgbaFromCss('#FF4538');
});
f.frame();
remove();
const moved = f.sprite('transit-motion-1', 0);
near(moved.rotation, 0.3, 'rotation');
near(moved.sizePx, 30, 'the width sets the size');
rgbaFromCss('#FF4538')
.rgba()
.forEach((value, i) => near(moved.color[i], value, `new colour ${i}`));
if (before) {
assert.equal(
f.commits('transit-1'),
before[0],
'the fleet is not rewritten',
);
assert.equal(f.commits('transit-motion-1'), before[1] + 1);
// Equal writes are idle.
f.display.onPreRender(() => {
marker.rotation = 0.3;
marker.width = 30;
marker.show = true;
marker.color = marker.color.clone();
});
f.frame();
assert.equal(f.commits('transit-motion-1'), before[1] + 1);
}
// Moving between sets keeps the vehicle's key.
const migrated = stationary.add({
...spec('moving'),
position: marker.position,
});
moving.remove(marker);
await settle();
assert.equal(migrated.id, 'moving');
assert.equal(
f.engine.movers.list().find(({ id }) => id === 'transit-motion-1').count,
0,
);
stationary.show = false;
await settle();
assert.equal(
f.engine.movers.list().find(({ id }) => id === 'transit-1').count,
0,
);
f.display.destroy();
f.display.destroy();
assert.deepEqual(f.engine.movers.list(), []);
f.engine.destroy();
});
test(`course rotation is the shared perspective projection (${name})`, () => {
const f = setup(makeHarness);
const position = geo.cartesian3.fromDegrees(-97.7401, 30.2682, 10);
for (const height of [600, 20000, 1000000])
for (const heading of [0, 1.2, 3.1]) {
f.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(
AUSTIN.lon,
AUSTIN.lat,
height,
),
orientation: { heading, pitch: -0.8, roll: 0 },
});
f.frame();
for (const course of [0, 90, 180, 270]) {
const rotation = f.display.rotation(position, course, 0);
assert.ok(Number.isFinite(rotation));
if (f.cesium)
near(
rotation,
perspectiveProjectedRotation(f.viewer.scene, position, course, 0),
`${height} m, heading ${heading}, course ${course}`,
1e-9,
);
}
}
assert.equal(f.display.rotation(position, NaN, 0.4), 0.4);
f.display.destroy();
f.engine.destroy();
});
test(`the view test keeps a five-metre margin and the horizon (${name})`, () => {
const f = setup(makeHarness);
f.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 2000),
});
f.frame();
const tester = f.display.visibilityTester();
assert.equal(tester.hasFrustum, true);
const below = tester.test(
geo.cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0),
);
assert.deepEqual(below, { horizon: true, inFrustum: true });
const behind = tester.test(
geo.cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 2100),
);
assert.equal(behind.inFrustum, false, 'above the camera');
const far = tester.test(
geo.cartesian3.fromDegrees(AUSTIN.lon + 90, AUSTIN.lat, 0),
);
assert.equal(far.horizon, false, 'over the horizon');
// Just outside the side plane, inside the margin.
const pose = f.engine.camera.pose();
const { width, height } = f.engine.host.size();
const tanX = Math.tan(pose.fovy / 2) * (pose.aspectRatio ?? width / height);
const ground = geo.cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0);
const depth = geo.cartesian3.distance(pose.position, ground);
const edge = (offset) =>
geo.cartesian3.add(
ground,
geo.cartesian3.multiplyByScalar(
pose.right,
depth * tanX + offset,
geo.cartesian3.create(),
),
geo.cartesian3.create(),
);
assert.equal(tester.test(edge(4)).inFrustum, true);
assert.equal(tester.test(edge(12)).inFrustum, false);
f.display.destroy();
f.engine.destroy();
});
test(`clicks pick where they land and name the vehicle's set (${name})`, async () => {
const vehicles = new Map();
const f = setup(makeHarness, { vehicles });
await f.display.whenReady();
f.state._markers.show = f.state._animatedMarkers.show = true;
const marker = f.state._animatedMarkers.add(spec('mbta:1'));
vehicles.set('mbta:1', {
marker,
markerCollection: f.state._animatedMarkers,
});
await settle();
const clicks = [];
const binding = f.display.bindClick(
(picked) => clicks.push(picked),
() => true,
);
f.click(
{ x: 40, y: 50 },
{ id: 'mbta:1', owner: 'transit-motion-1', instance: 0 },
);
assert.deepEqual(clicks.at(-1), {
id: 'mbta:1',
primitiveId: null,
x: 40,
y: 50,
collection: 'animated',
});
if (f.cesium) assert.deepEqual(f.picked()[0], { x: 40, y: 50 });
vehicles.get('mbta:1').markerCollection = f.state._markers;
f.click(
{ x: 40, y: 50 },
{ id: 'mbta:1', owner: 'transit-motion-1', instance: 0 },
);
assert.equal(clicks.at(-1).collection, 'stationary');
f.click({ x: 40, y: 50 }, undefined);
assert.deepEqual(clicks.at(-1), {
id: null,
primitiveId: null,
x: 40,
y: 50,
collection: null,
});
// A tool that owns the pointer takes the click.
const owned = f.display.bindClick(
(picked) => clicks.push(picked),
() => false,
);
const count = clicks.length;
owned.destroy();
binding.destroy();
f.click({ x: 40, y: 50 }, undefined);
assert.equal(clicks.length, count, 'removed handlers hear nothing');
f.display.destroy();
f.engine.destroy();
});
test(`camera, surface, credits and teardown go through the engine (${name})`, () => {
const f = setup(makeHarness);
const listeners = {
changed: f.engine.camera.changed.numberOfListeners,
preRender: f.engine.frames.preRender.numberOfListeners,
postRender: f.engine.frames.postRender.numberOfListeners,
};
f.display.claimCamera();
assert.equal(f.engine.camera.percentageChanged, 0.05);
assert.ok(cameraSensitivityClaims(f.engine).includes('transit'));
const heard = [];
f.display.onCameraChanged(() => heard.push('changed'));
f.display.onPreRender(() => heard.push('pre'));
f.display.onPostRender(() => heard.push('post'));
f.frame();
assert.deepEqual(heard, ['pre', 'post']);
near(f.display.cameraCartographic().height, 2000, 'camera height', 1e-3);
assert.ok(f.display.viewRectangle());
assert.match(f.display.cameraSignature(), /^-?\d+:-?\d+:-?\d+:/);
// The mesh sampler gets the engine and leaves the marker sets out.
f.display.sampleMeshFloorCells([{ lat: 1, lon: 2 }], { viewerLat: 1 });
assert.equal(f.mesh[0][0], f.engine);
assert.deepEqual(f.mesh[0][2].excludeObjects, []);
assert.equal(f.mesh[0][2].viewerLat, 1);
f.display.addCredit({ id: 'mbta', name: 'MBTA' });
assert.equal(f.credits[0][0], f.engine);
assert.equal(f.credits[0][1].key, 'transit-mbta');
f.display.destroy();
assert.equal(f.engine.camera.changed.numberOfListeners, listeners.changed);
assert.equal(
f.engine.frames.preRender.numberOfListeners,
listeners.preRender,
);
assert.equal(
f.engine.frames.postRender.numberOfListeners,
listeners.postRender,
);
assert.equal(cameraSensitivityClaims(f.engine).includes('transit'), false);
assert.equal(f.display.ready, false);
f.engine.destroy();
});
test(`the selected trail draws its passed history in age bands and its head to the marker (${name})`, () => {
const f = setup(makeHarness);
const trail = f.display.createTrailRenderer();
const positions = [0, 1, 2].map((i) =>
geo.cartesian3.fromDegrees(-71, 42 + i * 0.001, 11.5),
);
trail.replaceHistory({
revision: 1,
segments: [
{
fromSeq: 0,
toSeq: 1,
fromT: 0,
toT: 10000,
positions: positions.slice(0, 2),
},
{
fromSeq: 0,
toSeq: 1,
fromT: 10000,
toT: 20000,
positions: positions.slice(1, 3),
},
],
});
assert.deepEqual(
f.engine.lines.list(),
[],
'nothing before the clock passes a segment',
);
const marker = geo.cartesian3.lerp(
positions[1],
positions[2],
0.5,
geo.cartesian3.create(),
);
const sample = { displayT: 15000, fromSeq: 0, toSeq: 1, fraction: 0.75 };
for (let i = 0; i < 120; i++) trail.setDisplaySample(sample, marker);
const lines = f.engine.lines.list();
// The first segment (two lanes) and the head (two lanes).
assert.equal(lines.length, 4);
const lanes = lines.map(({ id }) => ({ id, ...f.line(id) }));
const [bodyBacking, bodyLane, headBacking, headLane] = lanes;
assert.equal(bodyBacking.points, 2);
assert.equal(bodyLane.points, 2);
near(bodyBacking.color[0], 0.02, 'dark edge');
near(bodyLane.color[0], rgbaFromCss('#5EF08A').rgba()[0], 'style colour');
// Five seconds old: the newest band.
near(
bodyLane.color[3],
trailBandAlpha(trailBand(trailAlpha(5000))),
'age alpha',
);
near(headLane.color[3], 0.8, 'head alpha');
near(headLane.depthFailAlpha, 0.55 / 0.8, 'hidden head at 0.55');
near(
bodyLane.depthFailAlpha * bodyLane.color[3],
0.55,
'hidden body at 0.55',
1e-3,
);
assert.equal(
headBacking.depthFailAlpha,
0,
'the dark edge does not draw hidden',
);
if (!f.cesium) {
assert.equal(bodyBacking.widthPx, 5);
assert.equal(bodyLane.widthPx, 3);
}
const result = trail.diagnostics();
assert.equal(result.rebuilds, 1);
assert.equal(
result.headRebuilds,
1,
'120 frames at one marker write the head once',
);
assert.equal(result.head.width, 3);
assert.equal(result.backing.width, 5);
assert.equal(result.head.positions.length, 2);
assert.ok(geo.cartesian3.equals(result.head.positions.at(-1), marker));
assert.equal(result.ground, false);
// Rewind: the passed segment is future again.
trail.setDisplaySample(
{ ...sample, displayT: 5000, fraction: 0.25 },
positions[0],
);
assert.equal(f.engine.lines.list().length, 2, 'only the head');
// Past the end the head goes too; long after, both segments share the
// oldest band.
trail.setDisplaySample(
{ ...sample, displayT: 2_000_000, fraction: 1, fromSeq: 1, toSeq: 1 },
positions[2],
);
const done = f.engine.lines.list();
assert.equal(
done.filter((line) => line.shown).length,
2,
'the whole history, no head',
);
assert.equal(
done.find((line) => line.shown).points,
3,
'joined segments are one line',
);
trail.setVisible(false);
assert.ok(f.engine.lines.list().every((line) => !line.shown));
trail.setVisible(true);
trail.destroy();
trail.destroy();
assert.deepEqual(f.engine.lines.list(), []);
f.display.destroy();
f.engine.destroy();
});
test(`history bands age once per displayed second and restyle in place (${name})`, () => {
const f = setup(makeHarness);
const trail = f.display.createTrailRenderer();
const step = 0.0002;
const at = (i) => geo.cartesian3.fromDegrees(-71, 42 + i * step, 10);
// 100 joined half-second segments, then a gap, then 10 more.
const segments = [];
for (let i = 0; i < 110; i++) {
const from = i < 100 ? i : i + 5;
segments.push({
fromSeq: 0,
toSeq: 1,
fromT: i * 500,
toT: (i + 1) * 500,
positions: [at(from), at(from + 1)],
});
}
trail.replaceHistory({ revision: 1, segments });
const shownLines = () => f.engine.lines.list().filter((line) => line.shown);
// Ten minutes on, the passed history spans several bands; across the gap
// it is two runs.
const display = (displayT) =>
trail.setDisplaySample(
{ displayT, fromSeq: 9, toSeq: 9, fraction: 1 },
at(0),
);
display(55000 + 120000);
const bands = new Set();
let points = 0;
for (const { id, points: count } of shownLines()) {
const { color } = f.line(id);
if (color[0] > 0.1) {
bands.add(color[3]);
points += count;
}
}
assert.ok(bands.size >= 1);
assert.equal(points, 101 + 11 + (bands.size - 1) * 1, 'each band one span');
const listed = f.engine.lines.list().map(({ id }) => id);
// Within the same second nothing is rewritten.
display(55000 + 120400);
assert.deepEqual(
f.engine.lines.list().map(({ id }) => id),
listed,
);
// A new style recolours the colour lanes, not the dark edges.
trail.setStyle('#FF4538');
const red = rgbaFromCss('#FF4538').rgba();
for (const { id } of shownLines()) {
const { color } = f.line(id);
if (color[0] > 0.1) near(color[0], red[0], 'restyled');
else near(color[0], 0.02, 'dark edge kept');
}
// Old history reaches the floor band and merges per run.
display(55000 + 2_000_000);
const old = shownLines();
assert.equal(old.length, 4, 'two runs, two lanes each');
for (const { id } of old) near(f.line(id).color[3], 0.08, 'oldest alpha');
trail.destroy();
f.display.destroy();
f.engine.destroy();
});
}
test('sensor styles drawn by the engine move a click to the pixel the shader showed', () => {
const engine = createFakeEngine({
width: 1920,
height: 1080,
canvas: { width: 1920, height: 1080 },
visualStyles: { surveillance: ['pixelation'], thermal: ['pixelation'] },
});
const state = { _stylePreset: 'surveillance', _vehicles: new Map() };
const display = createTransitDisplay(engine, {
state,
services: {},
drawAtlas: transitTestAtlas,
});
display.attach();
const seen = [];
display.bindClick(
({ x, y }) => seen.push([x, y]),
() => true,
);
const click = (x, y) => engine.fake.input('click', { position: { x, y } });
click(1440, 540);
assert.deepEqual(
seen.at(-1),
[1440, 540],
'a style at intensity 0 bends nothing',
);
engine.render.setStyle('surveillance', {
intensity: 1,
uniforms: { pixelation: 1 },
});
click(1440, 540);
near(seen.at(-1)[0], 1472, 'barrel distortion');
near(seen.at(-1)[1], 540, 'on the axis');
state._stylePreset = 'thermal';
engine.render.setStyle('thermal', {
intensity: 1,
uniforms: { pixelation: 1.5 },
});
click(100.9, 200.1);
near(seen.at(-1)[0], 100.5, 'pixel grid x');
near(seen.at(-1)[1], 201, 'pixel grid y');
state._stylePreset = 'noir';
click(100.9, 200.1);
assert.deepEqual(
seen.at(-1),
[100.9, 200.1],
'other styles do not bend clicks',
);
display.destroy();
engine.destroy();
});
test('terrain answers the floor unless photoreal tiles hide the globe', async () => {
const engine = createFakeEngine();
const display = createTransitDisplay(engine, {
state: {},
services: {},
drawAtlas: transitTestAtlas,
});
engine.fake.script('terrainHeight', (cartographic) => {
near(geo.math.toDegrees(cartographic.latitude), AUSTIN.lat, 'latitude');
return 151;
});
assert.equal(display.terrainHeight(AUSTIN.lat, AUSTIN.lon), 151);
await engine.maps.setPhotorealTiles({ kind: 'photoreal' });
assert.equal(display.terrainHeight(AUSTIN.lat, AUSTIN.lon), undefined);
display.destroy();
engine.destroy();
});
-135
View File
@@ -1,135 +0,0 @@
import * as geo from '../../../geo/index.js';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
} from '../../../data/cameraSensitivity.js';
import { CAMERA_PERCENTAGE_CHANGED } from '../policy.js';
import { createTransitSpriteBanks } from './navaraSprites.js';
import { createTransitNavaraView } from './navaraView.js';
import { createTransitNavaraTrail } from './navaraTrail.js';
/** Navara resources only; ingestion, playback and selection stay shared. */
export function createTransitNavaraRenderer(
engine,
{ state, services, drawAtlas, ...native } = {},
) {
let destroyed = false,
attached = false,
sprites = null;
const removers = new Set(),
credits = new Map(),
trails = new Set();
const view = createTransitNavaraView(engine);
const own = (remove) => {
const release = () => {
if (removers.delete(release)) remove();
};
removers.add(release);
return release;
};
return {
attach() {
attached = true;
},
get ready() {
return attached && !destroyed;
},
createMarkers() {
sprites = createTransitSpriteBanks(engine, { drawAtlas });
return sprites.collections;
},
whenReady: () => sprites?.ready,
restoreOrder() {},
cameraCartographic: view.cameraCartographic,
cameraSignature: view.cameraSignature,
rotation: view.rotation,
visibilityTester: view.visibilityTester,
viewRectangle: () => engine.camera.viewRectangle(),
claimCamera: () =>
claimCameraSensitivity(
engine.camera,
'transit',
CAMERA_PERCENTAGE_CHANGED,
),
releaseCamera: () => releaseCameraSensitivity(engine.camera, 'transit'),
onCameraChanged: (fn) => own(engine.camera.changed.addEventListener(fn)),
onPreRender: (fn) =>
own(
engine.frames.preRender.addEventListener((...args) => {
view.refresh();
sprites.inFrame(() => fn(...args));
}),
),
onPostRender: (fn) => own(engine.frames.postRender.addEventListener(fn)),
terrainHeight(lat, lon) {
// Mesh sampling keeps the application's existing budgets and cache.
if (engine.maps.current().photoreal) return undefined;
return engine.surface.terrainHeight(
geo.cartographic.fromDegrees(lon, lat),
);
},
sampleMeshFloorCells: (points, options) =>
services.mesh?.sampleMeshFloorCells?.(engine, points, options),
addCredit(feed) {
if (credits.has(feed.id)) return;
credits.set(
feed.id,
engine.credits.addStatic(services.credits.transitFeedCredit(feed).html),
);
},
createTrailRenderer() {
const trail = createTransitNavaraTrail(engine, native);
trails.add(trail);
const destroy = trail.destroy;
trail.destroy = () => {
trails.delete(trail);
destroy();
};
return trail;
},
bindClick(listener, pointerFree) {
const remove = own(
engine.input.on('click', (event) => {
if (!pointerFree()) return;
const picked = engine.picker.pick(event.position);
const id = typeof picked?.id === 'string' ? picked.id : null;
const entry = state._vehicles.get(id);
listener({
id,
primitiveId: null,
x: event.position.x,
y: event.position.y,
collection: entry
? entry.markerCollection === state._animatedMarkers
? 'animated'
: 'stationary'
: null,
});
}),
);
return { destroy: remove };
},
frameProbe() {
// Public engine API has no per-layer GPU upload hook. Count all scene
// uploads as an explicit upper bound, never misreport unavailable as zero.
return {
canvas: engine.host.canvas,
gl: engine.host.canvas.getContext('webgl2'),
uploads: [],
allUploads: true,
uploadScope: 'scene-upper-bound',
};
},
destroy() {
if (destroyed) return;
destroyed = true;
for (const remove of [...removers]) remove();
this.releaseCamera();
for (const trail of [...trails]) trail.destroy();
sprites?.destroy();
for (const remove of credits.values()) remove();
credits.clear();
attached = false;
},
};
}
-248
View File
@@ -1,248 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { createTransitSpriteBanks } from './navaraSprites.js';
import { createTransitNavaraView } from './navaraView.js';
import { perspectiveProjectedRotation } from '../../../data/iconOrientation.js';
import { createTransitNavaraTrail, transitTrailFrame } from './navaraTrail.js';
const atlas = { canvas: {}, cells: { bus: [0, 0, 1, 1], tram: [0, 0, 1, 1] } };
function moverEngine() {
const sets = [];
return {
sets,
movers: {
createSet(options) {
const set = {
options,
rows: [],
commits: 0,
disposed: 0,
set(i, fields) {
this.rows[i] ??= {};
for (const [key, value] of Object.entries(fields))
if (value !== undefined)
this.rows[i][key] = structuredClone(value);
},
setCount(value) {
this.count = value;
},
commit() {
this.commits++;
},
destroy() {
this.disposed++;
},
ready: () => true,
};
sets.push(set);
return set;
},
},
};
}
const spec = (id) => ({
id,
image: 'bus',
width: 20,
height: 20,
position: { x: 1, y: 2, z: 3 },
});
test('moving bank commits do not upload the static fleet; equal setters are idle', async () => {
const engine = moverEngine(),
banks = createTransitSpriteBanks(engine, { drawAtlas: async () => atlas });
await banks.ready;
const [stationary, moving] = banks.collections;
stationary.show = moving.show = true;
for (let i = 0; i < 800; i++) stationary.add(spec(`static-${i}`));
const source = spec('moving'),
marker = moving.add(source);
banks.flush();
const [staticSet, movingSet] = engine.sets;
const initial = staticSet.commits;
source.position.x = 99;
assert.equal(marker.position.x, 1, 'creation copies mutable coordinates');
const scratch = { x: 4, y: 5, z: 6 };
banks.inFrame(() => {
marker.position = scratch;
marker.rotation = 0.3;
});
scratch.x = 100;
assert.equal(marker.position.x, 4, 'assignment copies mutable coordinates');
assert.equal(staticSet.commits, initial);
const count = movingSet.commits;
banks.inFrame(() => {
marker.position = { x: 4, y: 5, z: 6 };
marker.rotation = 0.3;
marker.show = true;
marker.width = 20;
marker.color = marker.color.clone();
});
assert.equal(movingSet.commits, count);
assert.equal(staticSet.count, 800);
assert.equal(movingSet.count, 1);
const migrated = stationary.add({
...spec('moving'),
position: marker.position,
rotation: marker.rotation,
});
moving.remove(marker);
banks.flush();
assert.equal(migrated.id, marker.id);
assert.deepEqual(migrated.position, marker.position);
assert.equal(movingSet.count, 0);
banks.destroy();
banks.destroy();
assert.ok(engine.sets.every((set) => set.disposed === 1));
});
test('a late atlas cannot resurrect destroyed transit banks', async () => {
let resolve;
const engine = moverEngine();
const banks = createTransitSpriteBanks(engine, {
drawAtlas: () =>
new Promise((done) => {
resolve = done;
}),
});
banks.collections[0].add(spec('late'));
await Promise.resolve();
banks.destroy();
resolve(atlas);
await banks.ready;
assert.equal(engine.sets.length, 0);
});
test('Navara course projection agrees with native across near, far and oblique cameras', () => {
const canvas = {
clientWidth: 1280,
clientHeight: 800,
width: 1280,
height: 800,
};
const camera = new Cesium.Camera({
canvas,
drawingBufferWidth: 1280,
drawingBufferHeight: 800,
mapProjection: new Cesium.GeographicProjection(),
mode: Cesium.SceneMode.SCENE3D,
});
const scene = {
camera,
canvas,
drawingBufferWidth: 1280,
drawingBufferHeight: 800,
mapProjection: new Cesium.GeographicProjection(),
frameState: { mode: Cesium.SceneMode.SCENE3D },
};
const engine = {
camera: {
pose: () => ({
position: camera.positionWC,
cartographic: camera.positionCartographic,
direction: camera.directionWC,
right: camera.rightWC,
up: camera.upWC,
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
fovy: camera.frustum.fovy,
aspectRatio: camera.frustum.aspectRatio,
}),
},
projector: {
worldToWindow: (point, out) =>
Cesium.SceneTransforms.worldToWindowCoordinates(scene, point, out),
horizonVisible: () => true,
},
host: { size: () => ({ width: 1280, height: 800 }) },
};
for (const height of [600, 20000, 1000000])
for (const heading of [0, 1.2, 3.1]) {
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, height),
orientation: { heading, pitch: -0.8, roll: 0 },
});
const view = createTransitNavaraView(engine);
for (const course of [0, 90, 180, 270]) {
const position = Cesium.Cartesian3.fromDegrees(-97.7401, 30.2682, 10);
const expected = perspectiveProjectedRotation(
scene,
position,
course,
0,
);
assert.ok(
Math.abs(view.rotation(position, course, 0) - expected) < 1e-10,
);
}
}
});
test('ground trail updates clip uniforms without replacing corridor textures and releases them', () => {
const created = [],
requests = [];
const view = {
registerEffect() {},
addEffect(config) {
const handle = {
config,
updates: [],
removed: 0,
update(v) {
this.updates.push(v);
},
delete() {
this.removed++;
},
};
created.push(handle);
return handle;
},
};
const engine = {
host: { canvas: { width: 1280 }, size: () => ({ width: 1280 }) },
render: { request: (reason) => requests.push(reason) },
};
const trail = createTransitNavaraTrail(engine, {
view,
EffectDesc: class {},
NavaraEffect: class {},
});
const segment = {
fromSeq: 1,
toSeq: 2,
fromT: 0,
toT: 15000,
positions: [
geo.cartesian3.fromDegrees(-97.7431, 30.2672, 10),
geo.cartesian3.fromDegrees(-97.7431, 30.2674, 30),
],
};
const frame = transitTrailFrame(segment);
assert.ok(frame.length > 20 && frame.length < 25);
assert.ok(Math.abs(geo.cartesian3.dot(frame.up, frame.east)) < 1e-9);
trail.replaceHistory({ revision: 1, segments: [segment] });
const owner = created[0].config.gevTransitGround.owner;
const texture = owner.state.packed.texture;
let disposed = 0;
texture.addEventListener('dispose', () => disposed++);
for (let i = 0; i < 100; i++)
trail.setDisplaySample(
{ displayT: i * 100, fromSeq: 1, toSeq: 2, fraction: i / 150 },
segment.positions[0],
);
assert.equal(owner.state.packed.texture, texture);
assert.equal(trail.diagnostics().rebuilds, 1);
assert.equal(owner.active, 0);
trail.setDisplaySample(
{ displayT: 16000, fromSeq: 2, toSeq: 2, fraction: 1 },
segment.positions[1],
);
assert.equal(owner.active, -1);
trail.destroy();
trail.destroy();
assert.equal(disposed, 1);
assert.equal(created[0].removed, 1);
});
-260
View File
@@ -1,260 +0,0 @@
import * as geo from '../../../geo/index.js';
import { Rgba } from '../../shared/color.js';
import { createMoverBank } from '../../shared/render/navaraMoverBank.js';
import {
decodeImages,
packImageAtlas,
} from '../../shared/render/imageAtlas.js';
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
import { TRANSIT_ICON_KINDS, transitIcon } from '../../../data/transitIcons.js';
/** The same bounded mode/selection/style rasters used by native billboards. */
async function drawTransitAtlas() {
const urls = [
...new Set(
TRANSIT_ICON_KINDS.flatMap((mode) =>
['normal', 'thermal', 'retro'].flatMap((style) =>
[48, 96].map((px) => transitIcon(mode, px, { style })),
),
),
),
];
const images = await decodeImages(urls);
const cell = Math.max(...images.map((image) => image.naturalWidth)) + 4;
return packImageAtlas(
images.map((image, i) => ({
key: urls[i],
image,
width: image.naturalWidth,
height: image.naturalHeight,
})),
{ cell, pad: 2 },
);
}
/** Two independently dirty banks: moving contacts never upload the static fleet. */
export function createTransitSpriteBanks(
engine,
{ drawAtlas = drawTransitAtlas } = {},
) {
let destroyed = false,
atlas = null,
queued = false,
inFrame = 0;
const pending = new Set();
const owners = [];
const api = {
flush() {
queued = false;
if (destroyed) return;
for (const bank of pending) bank.commit();
pending.clear();
},
inFrame(run) {
inFrame++;
try {
return run();
} finally {
if (--inFrame === 0) api.flush();
}
},
destroy() {
if (destroyed) return;
destroyed = true;
for (const owner of owners) owner.destroy();
pending.clear();
atlas = null;
},
};
const schedule = (bank) => {
pending.add(bank);
if (!queued && !inFrame && !destroyed) {
queued = true;
queueMicrotask(() => api.flush());
}
};
function collection(id) {
const handles = new Set();
let bank = null,
visible = false;
const rgba = [1, 1, 1, 1];
const colorValues = (color) => {
rgba[0] = color.red;
rgba[1] = color.green;
rgba[2] = color.blue;
rgba[3] = color.alpha;
return rgba;
};
const fields = {
position: null,
rotation: undefined,
scale: undefined,
color: undefined,
show: undefined,
cell: undefined,
};
const update = (handle, key, value) => {
if (!bank || destroyed || !handles.has(handle)) return;
fields[key] = value;
bank.update(handle, fields);
fields[key] = undefined;
};
fields.position = undefined;
const ensure = () => {
if (bank || !atlas || destroyed) return;
bank = createMoverBank(engine, {
options: (capacity, generation) => ({
id: `${id}-${generation}`,
kind: 'sprite',
capacity,
order: SPRITE_LAYER_ORDER.indexOf(id),
depthTest: 'always',
pickable: true,
sprite: { atlas, sizePx: 20 },
}),
write: (h, set, index) =>
set.set(index, {
position: h.position,
rotation: h.rotation,
scale: h.width / 20,
color: colorValues(h.color),
cell: h.image,
show: h.show,
pickId: h.id,
}),
onCommit: schedule,
});
bank.setVisible(visible);
for (const handle of handles) bank.add(handle);
};
const owner = {
ensure,
destroy() {
handles.clear();
bank?.destroy();
bank = null;
},
};
owners.push(owner);
return {
get show() {
return visible;
},
set show(value) {
const next = Boolean(value);
if (destroyed || next === visible) return;
visible = next;
bank?.setVisible(next);
},
get length() {
return handles.size;
},
get: (index) => bank?.handles[index] ?? [...handles][index],
contains: (handle) => handles.has(handle),
add(spec) {
if (destroyed) throw new Error('Transit sprite banks are destroyed');
let position = geo.cartesian3.clone(spec.position),
image = spec.image,
show = spec.show !== false,
rotation = spec.rotation ?? 0,
width = spec.width ?? 20,
height = spec.height ?? width;
let color = new Rgba(
spec.color?.red,
spec.color?.green,
spec.color?.blue,
spec.color?.alpha,
);
const handle = {
id: spec.id,
gevBank: null,
gevIndex: -1,
alignedAxis: geo.cartesian3.ZERO,
disableDepthTestDistance: Infinity,
get position() {
return position;
},
set position(value) {
if (destroyed || geo.cartesian3.equals(position, value)) return;
geo.cartesian3.clone(value, position);
update(handle, 'position', position);
},
get image() {
return image;
},
set image(value) {
if (destroyed || image === value) return;
image = value;
update(handle, 'cell', image);
},
get show() {
return show;
},
set show(value) {
const next = Boolean(value);
if (destroyed || show === next) return;
show = next;
update(handle, 'show', show);
},
get rotation() {
return rotation;
},
set rotation(value) {
if (destroyed || rotation === value) return;
rotation = value;
update(handle, 'rotation', rotation);
},
get width() {
return width;
},
set width(value) {
if (destroyed || width === value) return;
width = value;
update(handle, 'scale', width / 20);
},
get height() {
return height;
},
set height(value) {
height = value;
},
get color() {
return color;
},
set color(value) {
if (destroyed || color.equals(value)) return;
color = new Rgba(value.red, value.green, value.blue, value.alpha);
update(handle, 'color', colorValues(color));
},
};
handles.add(handle);
ensure();
bank?.add(handle);
return handle;
},
remove(handle) {
if (!handles.delete(handle)) return false;
bank?.remove(handle);
return true;
},
removeAll() {
for (const handle of handles) bank?.remove(handle);
handles.clear();
},
};
}
api.collections = [collection('transit'), collection('transit-motion')];
api.ready = Promise.resolve()
.then(drawAtlas)
.then((drawn) => {
if (destroyed) return;
atlas = drawn;
for (const owner of owners) owner.ensure();
api.flush();
});
// Keep an early decode failure observed even before a caller awaits ready.
api.ready.catch(() => {
if (!destroyed) console.error('Transit sprite atlas failed');
});
return api;
}
-368
View File
@@ -1,368 +0,0 @@
import { Uniform } from 'three';
import * as geo from '../../../geo/index.js';
import { rgbaFromCss } from '../../shared/color.js';
import {
createGroundStorage,
createGroundTexture,
groundSurfaceUniforms,
updateGroundSurface,
} from '../../shared/render/navaraGroundSurface.js';
import {
createGroundGrid,
GRID_COLS,
GRID_ROWS,
} from '../../shared/render/navaraGroundGrid.js';
import {
viewFrame,
groundClipBounds,
} from '../../shared/render/navaraGroundGeometry.js';
import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js';
const KEY = 'gevTransitGround';
const shader = /* glsl */ `
uniform sampler2D tAllDepth,tSurfaceDepth,tSegments,tGrid,tCandidates;
uniform vec2 uSegmentSize,uCandidateSize;
uniform mat4 uInverseProjection;
uniform float uLogDepthFC,uDisplayT,uActive,uPixelRatio;
uniform vec3 uColor;
vec4 slot(sampler2D image,vec2 size,float index) {
return texture2D(image,(vec2(mod(index,size.x),floor(index/size.x))+0.5)/size);
}
vec3 displayColor(vec3 c) {
return mix(c*12.92,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
}
vec3 linearColor(vec3 c) {
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
}
float ageAlpha(float age) {
if(age<120000.0) return mix(0.8,0.45,clamp(age/120000.0,0.0,1.0));
if(age<600000.0) return mix(0.45,0.2,(age-120000.0)/480000.0);
if(age<900000.0) return mix(0.2,0.08,(age-600000.0)/300000.0);
return 0.08;
}
void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
outputColor=inputColor;
float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv));
float distance=exp2(2.0*depth/uLogDepthFC)-1.0;
vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0);
vec3 point=ray.xyz*(distance/-ray.z);
vec3 dx=dFdx(point),dy=dFdy(point);
float onSurface=1.0-step(1.0,depth);
float derivativeMask=1.0-step(0.5,abs(dFdx(onSurface))+abs(dFdy(onSurface)));
if(depth>=1.0 || derivativeMask<0.5) return;
if(depth<texture2D(tSurfaceDepth,uv).r-2e-7) return;
vec2 tile=clamp(floor(uv*vec2(${GRID_COLS}.0,${GRID_ROWS}.0)),vec2(0.0),vec2(${GRID_COLS - 1}.0,${GRID_ROWS - 1}.0));
vec2 range=texture2D(tGrid,(tile+0.5)/vec2(${GRID_COLS}.0,${GRID_ROWS}.0)).xy;
vec3 color=displayColor(inputColor.rgb);
for(int k=0;k<int(range.y);k++) {
float id=slot(tCandidates,uCandidateSize,range.x+float(k)).x;
vec4 center=slot(tSegments,uSegmentSize,id*4.0);
vec4 along=slot(tSegments,uSegmentSize,id*4.0+1.0);
vec4 across=slot(tSegments,uSegmentSize,id*4.0+2.0);
vec4 times=slot(tSegments,uSegmentSize,id*4.0+3.0);
bool completed=times.y<=uDisplayT;
bool isHead=abs(id-uActive)<0.5;
if(!completed&&!isHead) continue;
float fraction=completed?1.0:clamp((uDisplayT-times.x)/(times.y-times.x),0.0,1.0);
vec3 delta=point-center.xyz;
float s=dot(delta,along.xyz)+center.w;
if(s<0.0||s>center.w*2.0*fraction) continue;
float metresPerPixel=length(vec2(dot(across.xyz,dx),dot(across.xyz,dy)));
float lateral=abs(dot(delta,across.xyz))/max(metresPerPixel,1e-6);
float coverage=clamp(2.5*uPixelRatio+0.5-lateral,0.0,1.0);
if(coverage<=0.0) continue;
float core=clamp(1.5*uPixelRatio+0.5-lateral,0.0,1.0);
float alpha=isHead?0.8:ageAlpha(uDisplayT-times.y);
color=mix(color,mix(vec3(0.02,0.03,0.05),uColor,core),alpha*coverage);
}
outputColor=vec4(linearColor(color),inputColor.a);
}
`;
/** Short prepared corridors are extruded along the geodetic normal. */
export function transitTrailFrame(segment) {
const [a, b] = segment.positions;
const center = geo.cartesian3.midpoint(a, b, geo.cartesian3.create());
const up = geo.ellipsoid.WGS84.geodeticSurfaceNormal(
center,
geo.cartesian3.create(),
);
const delta = geo.cartesian3.subtract(b, a, geo.cartesian3.create());
const vertical = geo.cartesian3.dot(delta, up);
delta.x -= up.x * vertical;
delta.y -= up.y * vertical;
delta.z -= up.z * vertical;
const length = geo.cartesian3.magnitude(delta);
const east = geo.cartesian3.normalize(delta, delta);
const north = geo.cartesian3.cross(up, east, geo.cartesian3.create());
return {
center,
east,
north,
up,
length,
radius: length / 2,
floor: -12000,
ceiling: 20000,
};
}
function storage(segments) {
const frames = segments.map(transitTrailFrame);
const packed = createGroundStorage(segments.length * 4);
const grid = createGroundGrid(segments.length);
const gridData = new Float32Array(GRID_COLS * GRID_ROWS * 4);
const gridTexture = createGroundTexture(gridData, GRID_COLS, GRID_ROWS);
let candidates = createGroundStorage(1);
const matrix = new Float64Array(32).fill(NaN),
scratch = new Float64Array(12),
bounds = new Float64Array(4);
let width = 0,
height = 0;
const epoch = segments[0]?.fromT ?? 0;
return {
packed,
gridTexture,
epoch,
get candidates() {
return candidates;
},
update(camera, context) {
const input = context.getInputBuffer(),
v = camera.matrixWorldInverse.elements,
p = camera.projectionMatrix.elements;
let changed = width !== input.width || height !== input.height;
for (let i = 0; i < 16 && !changed; i++)
changed = matrix[i] !== v[i] || matrix[i + 16] !== p[i];
if (!changed) return;
matrix.set(v);
matrix.set(p, 16);
width = input.width;
height = input.height;
frames.forEach((frame, i) => {
viewFrame(frame, camera.matrixWorldInverse, scratch);
const offset = i * 16,
segment = segments[i];
packed.data.set(
[
scratch[0],
scratch[1],
scratch[2],
frame.length / 2,
scratch[3],
scratch[4],
scratch[5],
0,
scratch[6],
scratch[7],
scratch[8],
0,
segment.fromT - epoch,
segment.toT - epoch,
0,
0,
],
offset,
);
// Bound all possible 5 CSS-pixel widths over the height extrusion.
const radius = frame.radius;
frame.radius +=
Math.max(
1,
(Math.abs(scratch[2]) + 32000) /
Math.max(1, Math.abs(p[5]) * height),
) * 32;
groundClipBounds(frame, scratch, camera.projectionMatrix, bounds);
frame.radius = radius;
grid.bounds.set(bounds, i * 4);
});
packed.texture.needsUpdate = true;
const count = grid.pack();
if (candidates.data.length / 4 < count) {
candidates.texture.dispose();
candidates = createGroundStorage(count);
}
for (let i = 0; i < count; i++) candidates.data[i * 4] = grid.indices[i];
for (let i = 0; i < grid.counts.length; i++) {
gridData[i * 4] = grid.offsets[i];
gridData[i * 4 + 1] = grid.counts[i];
}
candidates.texture.needsUpdate = gridTexture.needsUpdate = true;
},
dispose() {
packed.texture.dispose();
gridTexture.dispose();
candidates.texture.dispose();
},
};
}
const groundEffect = defineGroundEffect({
key: KEY,
name: 'GevTransitGround',
fragmentShader: shader,
insertAfter: ['toneMapping'],
insertBefore: [
'gevSharpen',
'smaa',
'fxaa',
'gevPreSharpenOverlay',
'gevOverlay',
'final',
],
uniforms: () => [
...groundSurfaceUniforms(),
['tSegments', new Uniform(null)],
['tGrid', new Uniform(null)],
['tCandidates', new Uniform(null)],
['uSegmentSize', new Uniform([1, 1])],
['uCandidateSize', new Uniform([1, 1])],
['uDisplayT', new Uniform(0)],
['uActive', new Uniform(-1)],
['uColor', new Uniform([1, 1, 1])],
['uPixelRatio', new Uniform(1)],
],
update() {
if (!this.effect) return;
const { state, displayT, active, color, pixelRatio } = this.owner;
const camera = this.view.camera.raw;
updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt'));
state.update(camera, this.ctx);
const u = this.effect.uniforms;
for (const [name, data] of [
['Segments', state.packed],
['Candidates', state.candidates],
]) {
u.get(`t${name}`).value = data.texture;
const size = u.get(
name === 'Segments' ? 'uSegmentSize' : 'uCandidateSize',
).value;
size[0] = data.width;
size[1] = data.height;
}
u.get('tGrid').value = state.gridTexture;
u.get('uDisplayT').value = displayT - state.epoch;
u.get('uActive').value = active;
u.get('uColor').value = color;
u.get('uPixelRatio').value = pixelRatio();
},
});
/** A static corridor texture; playback only updates time and active uniforms. */
export function createTransitNavaraTrail(
engine,
{ view, EffectDesc, NavaraEffect },
) {
groundEffect.register(view, { EffectDesc, NavaraEffect });
let segments = [],
revision = -1,
rebuilds = 0,
visible = true,
destroyed = false,
handle = null;
const owner = {
state: storage([]),
displayT: 0,
active: -1,
color: [0.37, 0.94, 0.54],
pixelRatio: () =>
engine.host.canvas.width / Math.max(1, engine.host.size().width),
};
const body = { show: true, ready: true },
head = { show: false, positions: [], width: 3 },
backing = { show: false, positions: [], width: 5 };
const endpoint = geo.cartesian3.create(),
headPositions = [null, endpoint];
const sync = () => {
const show = visible && segments.length > 0;
if (show && !handle)
handle = view.addEffect({ [KEY]: { owner }, visible: true });
else handle?.update({ visible: show });
};
return {
replaceHistory(next) {
if (destroyed || next.revision === revision) return;
const nextState = storage(next.segments);
const previous = owner.state;
owner.state = nextState;
segments = next.segments;
revision = next.revision;
owner.active = -1;
head.show = backing.show = false;
rebuilds++;
sync();
previous.dispose();
engine.render.request('transit-trail-history');
},
setDisplaySample(sample, renderedPosition) {
if (destroyed) return;
if (!sample || !renderedPosition) {
owner.active = -1;
head.show = backing.show = false;
return;
}
let lo = 0,
hi = segments.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (segments[mid].toT <= sample.displayT) lo = mid + 1;
else hi = mid;
}
const segment = segments[lo];
const active =
segment &&
segment.fromT <= sample.displayT &&
segment.fromSeq === sample.fromSeq &&
segment.toSeq === sample.toSeq &&
sample.fraction < 1
? lo
: -1;
if (owner.displayT === sample.displayT && owner.active === active) return;
owner.displayT = sample.displayT;
owner.active = active;
head.show = backing.show = visible && active >= 0;
if (active >= 0) {
headPositions[0] = segment.positions[0];
geo.cartesian3.clone(renderedPosition, endpoint);
head.positions = backing.positions = headPositions;
}
engine.render.request('transit-trail-playback');
},
setStyle(css) {
const color = rgbaFromCss(css).rgba().slice(0, 3);
if (color.every((value, i) => value === owner.color[i])) return;
owner.color = color;
engine.render.request('transit-trail-style');
},
setVisible(next) {
if (destroyed || visible === next) return;
visible = next;
body.show = next;
head.show = backing.show = next && owner.active >= 0;
sync();
engine.render.request('transit-trail-visible');
},
destroy() {
if (destroyed) return;
destroyed = true;
handle?.delete();
handle = null;
owner.state.dispose();
segments = [];
engine.render.request('transit-trail-destroy');
},
diagnostics: () => ({
revision,
rebuilds,
segments: segments.length,
body: segments.length ? body : null,
head,
backing,
headPrimitive: null,
ground: true,
headStrategy: 'surface-depth corridor with time clip',
headRebuilds: 0,
entitiesAdded: 0,
}),
};
}
-86
View File
@@ -1,86 +0,0 @@
import * as geo from '../../../geo/index.js';
/** Exact 30 m perspective probe, with the native 0.5–3 px basis blend. */
export function createTransitNavaraView(engine) {
const enu = geo.matrix4.create(),
forward = geo.cartesian3.create(),
world = geo.cartesian3.create(),
probe = geo.cartesian3.create(),
at = geo.cartesian2.create(),
ahead = geo.cartesian2.create();
let pose = engine.camera.pose();
const fresh = () => (pose = engine.camera.pose());
return {
cameraCartographic: () => fresh().cartographic,
cameraSignature() {
fresh();
const p = pose.position;
return (
`${Math.round(p.x / 10)}:${Math.round(p.y / 10)}:${Math.round(p.z / 10)}:` +
`${pose.heading.toFixed(3)}:${pose.pitch.toFixed(3)}:${pose.roll.toFixed(3)}`
);
},
rotation(position, course, previous) {
if (!position || !Number.isFinite(course)) return previous;
const angle = geo.math.toRadians(course);
geo.cartesian3.fromElements(
Math.sin(angle) * 30,
Math.cos(angle) * 30,
0,
forward,
);
geo.transforms.eastNorthUpToFixedFrame(
position,
geo.ellipsoid.WGS84,
enu,
);
geo.matrix4.multiplyByPointAsVector(enu, forward, world);
geo.cartesian3.add(position, world, probe);
// Match the native 2 km basis probe's degeneracy threshold.
const dx = geo.cartesian3.dot(world, pose.right) * (2000 / 30);
const dy = -geo.cartesian3.dot(world, pose.up) * (2000 / 30);
const basis = dx * dx + dy * dy < 0.25 ? previous : Math.atan2(-dx, -dy);
const a = engine.projector.worldToWindow(position, at);
const b = engine.projector.worldToWindow(probe, ahead);
if (!a || !b) return basis;
const x = b.x - a.x,
y = b.y - a.y,
separation = Math.hypot(x, y);
const exact = Math.atan2(-x, -y);
if (separation >= 3 || !Number.isFinite(basis)) return exact;
if (separation <= 0.5) return basis;
const delta = Math.atan2(
Math.sin(exact - basis),
Math.cos(exact - basis),
);
return basis + delta * ((separation - 0.5) / 2.5);
},
visibilityTester() {
const camera = fresh();
const tanY = Math.tan(camera.fovy / 2);
const size = engine.host.size();
const tanX = tanY * (camera.aspectRatio ?? size.width / size.height);
const xMargin = 5 * Math.hypot(1, tanX),
yMargin = 5 * Math.hypot(1, tanY);
const delta = geo.cartesian3.create();
return {
hasFrustum: true,
test(point) {
geo.cartesian3.subtract(point, camera.position, delta);
const z = geo.cartesian3.dot(delta, camera.direction);
return {
horizon: engine.projector.horizonVisible(point),
inFrustum:
z >= (camera.near ?? 0) - 5 &&
z <= (camera.far ?? Infinity) + 5 &&
Math.abs(geo.cartesian3.dot(delta, camera.right)) <=
z * tanX + xMargin &&
Math.abs(geo.cartesian3.dot(delta, camera.up)) <=
z * tanY + yMargin,
};
},
};
},
refresh: fresh,
};
}
+60
View File
@@ -0,0 +1,60 @@
// How the transit vehicles and the selected vehicle's trail draw
// (engine.movers sprite sets, engine.lines).
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
/** The stationary fleet and the vehicles mid-glide: one sprite set each. */
export const MARKER_SET_IDS = Object.freeze(['transit', 'transit-motion']);
/** Each set's place among the contact sprites (data/spriteOrder.js). */
export const markerOrder = (id) => SPRITE_LAYER_ORDER.indexOf(id);
/** A sprite's size at scale 1; a marker's width in CSS px sets its scale. */
export const SPRITE_SIZE_PX = 20;
/** The trail's colour before the selected vehicle styles it. */
export const TRAIL_DEFAULT_CSS = '#5EF08A';
/** The dark edge each trail lane is drawn over. */
export const TRAIL_BACKING_RGB = Object.freeze([0.02, 0.03, 0.05]);
export const TRAIL_BACKING_WIDTH_PX = 5;
export const TRAIL_WIDTH_PX = 3;
/** The live head's alpha, and the alpha where the scene hides the trail. */
export const TRAIL_HEAD_ALPHA = 0.8;
export const TRAIL_OCCLUDED_ALPHA = 0.55;
const ALPHA_STOPS = [
[0, 0.8],
[120000, 0.45],
[600000, 0.2],
[900000, 0.08],
];
/** A completed trail segment's alpha `ageMs` after the vehicle left it. */
export function trailAlpha(ageMs) {
const stops = ALPHA_STOPS;
for (let i = 1; i < stops.length; i++) {
if (ageMs === stops[i][0]) return stops[i][1];
if (ageMs < stops[i][0]) {
const [a, x] = stops[i - 1],
[b, y] = stops[i];
return x + ((y - x) * Math.max(0, ageMs - a)) / (b - a);
}
}
return 0.08;
}
// The completed trail draws in alpha bands: segments whose alpha rounds to
// the same band share one line per lane.
const BAND_MIN = 0.08;
const BAND_MAX = 0.8;
export const TRAIL_BANDS = 28;
/** The band of an alpha, 0 (oldest) to TRAIL_BANDS (newest). */
export const trailBand = (alpha) =>
Math.round(
((Math.min(BAND_MAX, Math.max(BAND_MIN, alpha)) - BAND_MIN) /
(BAND_MAX - BAND_MIN)) *
TRAIL_BANDS,
);
/** The alpha a band draws at. */
export const trailBandAlpha = (band) =>
BAND_MIN + ((BAND_MAX - BAND_MIN) * band) / TRAIL_BANDS;
+20
View File
@@ -0,0 +1,20 @@
// The transit glyph atlas for tests: every glyph's cell, laid out as the
// display's atlas lays them out, on a canvas that draws nothing.
import { imageAtlasLayout } from '../../shared/render/imageAtlas.js';
import { transitAtlasIcons } from './display.js';
export function transitAtlasLayout() {
return imageAtlasLayout(
transitAtlasIcons().map((key) => ({ key, width: 48, height: 48 })),
{ cell: 52, pad: 2 },
);
}
export const transitTestAtlas = async () => {
const layout = transitAtlasLayout();
return {
canvas: { width: layout.width, height: layout.height },
cells: layout.cells,
};
};
+3 -3
View File
@@ -22,9 +22,9 @@ export function createState({ services }) {
});
state._overlayHost = state.DEFAULT_OVERLAY_HOST;
/** @type {Cesium.Viewer|null} */
/** The viewer the layer was initialized with (its renderer draws). */
state._viewer = null;
/** @type {Cesium.BillboardCollection|null} */
/** The renderer's marker collections: the fleet and the vehicles mid-glide. */
state._markers = null;
state._animatedMarkers = null;
state._visible = new Set();
@@ -48,7 +48,7 @@ export function createState({ services }) {
/** @type {(() => void)|null} */
state._preRenderRemove = null;
state._renderHeld = false;
/** @type {Cesium.ScreenSpaceEventHandler|null} */
/** @type {{ destroy: () => void }|null} The renderer's click binding. */
state._clickHandler = null;
/**
* The vehicles currently mid-glide. The per-frame pass walks THIS, not the
+176
View File
@@ -0,0 +1,176 @@
// The two engines the CCTV display tests run on: the fake engine and the
// Cesium engine over a stub scene (testSupport/cesiumMoversEngine.mjs). Each
// harness scripts picks of what the display drew (a mover instance or a
// line, as the engine's own pick returns it), fires screen input through
// engine.input and gives the canvas a style and a window, as the browser's.
import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from './cesiumMoversEngine.mjs';
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** A canvas stand-in with a style and a window for blur. */
function testCanvas(width = 1280, height = 800) {
const canvas = Object.assign(new EventTarget(), {
clientWidth: width,
clientHeight: height,
width,
height,
style: {},
});
canvas.ownerDocument = { defaultView: new EventTarget() };
return canvas;
}
// The camera looks down from 50 km over lon 0, lat 0, where the tests draw.
const VIEW = { height: 50_000 };
export function fakeCctvHarness() {
const canvas = testCanvas();
const engine = createFakeEngine({
canvas,
width: 1280,
height: 800,
altitude: VIEW.height,
});
const pickResult = (target) => {
if (target.line) {
const line = engine.fake.line(target.line);
return { id: target.pickId ?? line.options.pickId, owner: target.line };
}
if (target.set) {
const { store } = engine.fake.moverSet(target.set);
return {
id: target.pickId ?? store.pickIds[target.index ?? 0],
owner: target.set,
instance: target.index ?? 0,
};
}
return target.result;
};
return {
name: 'the fake engine',
engine,
canvas,
frame: () => engine.fake.frame(),
settle,
fire: (type, event) => engine.fake.input(type, event),
/** The next picks return these targets: { line } | { set, index } | { result }. */
scriptPicks(...targets) {
const results = targets.map(pickResult);
engine.fake.script('pick', () => results[0]);
engine.fake.script('drillPick', () => results);
},
cameraInputs: () =>
engine.fake.callsOf('input.setCameraInputs').map(({ args }) => args[0]),
lineState: (id) => {
const line = engine.fake.line(id);
return line && { ...line.options, color: line.color, shown: line.shown };
},
destroy: () => engine.destroy(),
};
}
/** Screen input for the Cesium engine's ScreenSpaceEventHandler seam. */
function testEventHandler() {
const actions = new Map();
return {
setInputAction: (action, type, modifier) =>
actions.set(`${type}:${modifier}`, action),
removeInputAction: (type, modifier) =>
actions.delete(`${type}:${modifier}`),
fire: (type, event) => actions.get(`${type}:undefined`)?.(event),
isDestroyed: () => false,
destroy: () => actions.clear(),
};
}
const CESIUM_TYPES = {
click: Cesium.ScreenSpaceEventType.LEFT_CLICK,
move: Cesium.ScreenSpaceEventType.MOUSE_MOVE,
down: Cesium.ScreenSpaceEventType.LEFT_DOWN,
up: Cesium.ScreenSpaceEventType.LEFT_UP,
};
export function cesiumCctvHarness() {
const handler = testEventHandler();
const { engine, viewer, frame, primitive } = createCesiumMoversEngine({
engineOptions: { createEventHandler: () => handler },
});
const canvas = viewer.scene.canvas;
canvas.style = {};
canvas.ownerDocument = { defaultView: new EventTarget() };
viewer.scene.screenSpaceCameraController = { enableInputs: true };
const inputs = [];
const setCameraInputs = engine.input.setCameraInputs;
engine.input.setCameraInputs = (enabled) => {
inputs.push(enabled);
setCameraInputs(enabled);
};
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(0, 0, VIEW.height),
});
const native = (target) => {
if (target.line) {
const drawn = primitive(target.line);
return {
primitive: drawn,
id: target.pickId ?? drawn?.geometryInstances?.id,
};
}
if (target.set) {
const item = primitive(target.set).get(target.index ?? 0);
return { primitive: item, id: target.pickId ?? item.id };
}
return target.native;
};
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
canvas,
primitive,
frame,
settle,
fire: (type, event) =>
handler.fire(CESIUM_TYPES[type], {
...(event.position
? {
position: new Cesium.Cartesian2(
event.position.x,
event.position.y,
),
}
: {}),
...(event.endPosition
? {
startPosition: new Cesium.Cartesian2(
event.startPosition?.x ?? 0,
event.startPosition?.y ?? 0,
),
endPosition: new Cesium.Cartesian2(
event.endPosition.x,
event.endPosition.y,
),
}
: {}),
}),
/** The next picks return these targets: { line } | { set, index } | { native }. */
scriptPicks(...targets) {
const results = targets.map(native);
viewer.scene.pick = () => results[0];
viewer.scene.drillPick = () => results;
},
cameraInputs: () => inputs,
lineState: (id) => {
const drawn = primitive(id);
return drawn && { drawn };
},
destroy: () => engine.destroy(),
};
}
export const CCTV_HARNESSES = [fakeCctvHarness, cesiumCctvHarness];
+2 -8
View File
@@ -1,4 +1,5 @@
import { radioLayer } from '../data/radio.js';
import { radioTestEngine } from './radioTestEngine.mjs';
export function radioStationRow(overrides = {}) {
return {
@@ -76,17 +77,10 @@ export async function withRadioLayer({ stations, failingUrls = [] }, body) {
removeEventListener() {},
},
},
dataSources: { add() {}, remove() {} },
entities: {
add(entity) {
return entity;
},
remove() {},
},
};
radioLayer.destroy();
try {
radioLayer.init(viewer);
radioLayer.init(viewer, { engine: radioTestEngine() });
radioLayer.enable();
await radioLayer.update();
await body({ playUrls, catalog });
+21
View File
@@ -0,0 +1,21 @@
// The globe engine the radio layer tests draw on: the fake engine with its
// camera 7,000 km from the Earth's centre over 0°, 0°, looking down.
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
export function radioTestEngine() {
const engine = createFakeEngine();
engine.fake.setPose({ position: { x: 7_000_000, y: 0, z: 0 } });
return engine;
}
/** The selected station's marker (its pick id and position), or null. */
export function radioSelectedMarker(engine) {
const set = engine.fake.moverSet('radio-selected');
if (!set?.store.count) return null;
const { pickIds, positions } = set.store;
return {
id: pickIds[0],
position: { x: positions[0], y: positions[1], z: positions[2] },
};
}