Test layers on one engine and share selection and shader helpers

Layer display tests no longer re-check Cesium primitives in every layer:
they run on the fake engine, and one Cesium translation check per ground
kind lives in the Cesium engine's ground tests. Their harnesses are shared,
and the fake-engine, Cesium-engine and flights tests that repeated the
contract suite are gone, 340 tests in all.

The press-travel measurement is shared by the selection dispatcher and
CCTV, which loses its ScreenSpaceEventHandler shim and world-click module.
ALPR, installations and bikeshare share one selection keeper and selected
marker write. Layers with their own click rules keep their handlers.

Navara's pulse is a class like the ground pass, the ground and imagery
passes share the surface uniforms, and the colour, log-depth and occlusion
GLSL lives in one chunk module, each caller keeping its depth bias. The
ground suite runs inside the contract suite (--only=ground:), and fixture
and script names say what they test.

Behaviour is unchanged on both engines.
This commit is contained in:
German Khokhlov committed 2026-11-08 17:06:10 +04:00
1 parent 7f478e3815
commit b4556f9763
101 files changed
+1095 -4071

No files matched your search

+1 -17
View File
@@ -1,4 +1,3 @@
import { bindCctvWorldClickGesture } from '../../layers/cctv/worldClick.js';
import { createCctvLayer } from '../../layers/cctv/index.js';
import { rendererFor } from './renderers.js';
import * as credits from '../../data/dataCredits.js';
@@ -37,20 +36,5 @@ export function createApplicationCctv({
render,
},
});
return {
...layer,
bindCctvWorldClickGesture,
// Legacy helper; production navigation uses the initialized owner.
focusCctvRecord(engine, record, duration) {
const display = engine
? rendererFor('cctv', engine, { services: {}, state: {} })
: null;
display?.attach();
try {
return layer.focusCctvRecord(display, record, duration);
} finally {
display?.destroy();
}
},
};
return layer;
}
-2
View File
@@ -52,7 +52,6 @@ export const _viewRecordIdsForTest = layer._viewRecordIdsForTest;
export const clearProbeClampOnDeactivation =
layer.clearProbeClampOnDeactivation;
export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation;
export const bindCctvWorldClickGesture = layer.bindCctvWorldClickGesture;
export const setActiveCamera = layer.setActiveCamera;
export const deactivateActiveCamera = layer.deactivateActiveCamera;
export const cctvEmptyClickDeselects = layer.cctvEmptyClickDeselects;
@@ -65,7 +64,6 @@ export const materializeCctvVisibleCoverageEntities =
export const _extractPickedCameraIdForTest =
layer._extractPickedCameraIdForTest;
export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest;
export const focusCctvRecord = layer.focusCctvRecord;
export const maybeAutoHop = layer.maybeAutoHop;
export const cctvCycleIndex = layer.cctvCycleIndex;
export {
+30 -31
View File
@@ -39,7 +39,6 @@ import cctvLayer, {
_pushAmbientCardEntriesForTest,
_setCctvOverlayHostForTest,
activationProbeClampRange,
bindCctvWorldClickGesture,
clearProbeClampOnDeactivation,
computeFrustumGeometry,
cctvCycleIndex,
@@ -60,7 +59,6 @@ import cctvLayer, {
createGeometryProgressNotifier,
normalizeCoverageMode,
frameSignatureFromPixels,
focusCctvRecord,
hideCctvRecordVisuals,
materializeCctvActiveCoverageEntities,
materializeCctvVisibleCoverageEntities,
@@ -85,30 +83,28 @@ import {
} from '../layers/cctv/policy.js';
import { loadLayerRenderers } from '../app/layers/renderers.js';
import { createNavigation } from '../layers/cctv/navigation.js';
import { createCctvDisplay } from '../layers/cctv/render/display.js';
function focusCctvRecord(engine, record, duration) {
const display = engine
? createCctvDisplay(engine, { services: {}, state: {} })
: null;
display?.attach();
try {
return createNavigation({}).focusCctvRecord(display, record, duration);
} finally {
display?.destroy();
}
}
// The layers draw with the manifest's displays, which load through
// import() (src/app/layers/renderers.js).
await loadLayerRenderers('cesium');
const ASPECT = 16 / 9;
const toRad = (deg) => (deg * Math.PI) / 180;
const GESTURE_TYPES = {
LEFT_DOWN: 'left-down',
MOUSE_MOVE: 'mouse-move',
LEFT_UP: 'left-up',
LEFT_CLICK: 'left-click',
};
function makeGestureHandler() {
const actions = new Map();
return {
setInputAction(callback, type) {
actions.set(type, callback);
},
fire(type, event) {
actions.get(type)?.(event);
},
};
}
// Same spherical earth radius projectPoint uses (R = 6371 km) so the
// lat/lon→metres conversion in the assertions matches the module's math.
const M_PER_DEG = (Math.PI / 180) * 6371000;
@@ -1383,14 +1379,16 @@ test('active CCTV camera is absent from host by default and protected only when
test('CCTV drag-then-release over a camera is inert, while a clean tap activates and dispatches', () => {
let timeMs = 0;
let activationCalls = 0;
const handler = makeGestureHandler();
const engine = createFakeEngine();
const display = createCctvDisplay(engine, { state: {} });
display.attach();
const handler = { fire: (type, event) => engine.fake.input(type, event) };
const target = new EventTarget();
const requests = [];
target.addEventListener(CCTV_FOCUS_REQUEST_EVENT, (event) =>
requests.push(event.detail),
);
bindCctvWorldClickGesture(
handler,
display.bindClick(
() => {
activateCctvCameraFromWorldClick(
'atx-cam-3',
@@ -1403,28 +1401,29 @@ test('CCTV drag-then-release over a camera is inert, while a clean tap activates
},
{
now: () => timeMs,
eventTypes: GESTURE_TYPES,
},
);
handler.fire(GESTURE_TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } });
handler.fire('down', { position: { x: 10, y: 10 } });
timeMs = 20;
handler.fire(GESTURE_TYPES.MOUSE_MOVE, { endPosition: { x: 14, y: 10 } });
handler.fire('move', { endPosition: { x: 14, y: 10 } });
timeMs = 40;
handler.fire(GESTURE_TYPES.MOUSE_MOVE, { endPosition: { x: 10, y: 10 } });
handler.fire('move', { endPosition: { x: 10, y: 10 } });
timeMs = 60;
handler.fire(GESTURE_TYPES.LEFT_UP, { position: { x: 10, y: 10 } });
handler.fire(GESTURE_TYPES.LEFT_CLICK, { position: { x: 10, y: 10 } });
handler.fire('up', { position: { x: 10, y: 10 } });
handler.fire('click', { position: { x: 10, y: 10 } });
assert.equal(activationCalls, 0);
assert.deepEqual(requests, []);
timeMs = 100;
handler.fire(GESTURE_TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } });
handler.fire('down', { position: { x: 10, y: 10 } });
timeMs = 180;
handler.fire(GESTURE_TYPES.LEFT_UP, { position: { x: 11, y: 11 } });
handler.fire(GESTURE_TYPES.LEFT_CLICK, { position: { x: 11, y: 11 } });
handler.fire('up', { position: { x: 11, y: 11 } });
handler.fire('click', { position: { x: 11, y: 11 } });
assert.equal(activationCalls, 1);
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
display.destroy();
engine.destroy();
});
test('CCTV auto-hop remains activation-only and never dispatches a focus request', () => {
+3 -8
View File
@@ -114,22 +114,17 @@ test('every scene click handler consults ownership before it picks', () => {
// find and edit each of these. If a layer grows a click handler it has to
// appear here too.
const guarded = [
['src/data/trackingClickGesture.js', 'onClick(click, gesture);'],
['src/data/trackingGesture.js', 'onClick(click, gesture);'],
[
'src/layers/infrastructure/render/display.js',
'const picked = event.picked;',
],
['src/data/cctvGizmo.js', 'pickGizmoPart(event)'],
// ALPR and bikeshare take the engine's click with its pick.
['src/layers/alpr/render/display.js', 'overlay.pick(position) ||'],
['src/layers/shared/selectionKeeper.js', 'const id = pickId(event);'],
['src/layers/bikeshare/render/display.js', 'const picked = event.picked;'],
// FIRMS takes the engine dispatcher's click with its pick.
['src/layers/firms/selection.js', 'const picked = click.picked;'],
// Mapped Installations take the engine's click with its pick.
[
'src/layers/installations/render/display.js',
"const id = typeof picked?.id === 'string' ? picked.id : null;",
],
['src/layers/launches/render/display.js', '[ctx.picked, ...ctx.drilled]'],
[
'src/layers/radio/interaction.js',
@@ -173,7 +168,7 @@ test('ambient selection handlers never claim the pointer themselves', () => {
// Claiming from a selection handler would deadlock every other layer the
// moment a user clicked anything. Only tools claim.
for (const file of [
'src/data/trackingClickGesture.js',
'src/data/trackingGesture.js',
'src/layers/infrastructure/render/display.js',
'src/data/cctvGizmo.js',
'src/layers/vessels/selection.js',
+14 -61
View File
@@ -1,34 +1,17 @@
import { sceneServicesFor } from './sceneServices.js';
// One selection dispatcher for the layers that draw through the globe
// engine: one click subscription with picks
// (engine.input.on('click', …, { pick: true, drill: 8 })), the press
// gesture and one Escape listener, instead of a ScreenSpaceEventHandler
// per layer. Owners register as with pickRegistry (registerPickOwner, so
// layers still on their own handlers keep seeing the ownership) plus
// handlers:
// onSelect(id, ctx) a click whose first pick it owns;
// onEmptyClick(ctx) a clean click (short, no drag) on the map or nothing;
// onEscape() Escape.
// The rules are the old handlers': a pick another layer owns is not empty
// space (a pick nobody owns is); `passThrough` picks (trails) never take the
// click, the next pick does; a `drillThrough` owner takes its object from
// under another layer's; a drag selects nothing; a pointer owned by a tool
// (inputOwnership) selects nothing. Layers not moved yet keep their own
// handlers: the dispatcher never acts on an id it does not own.
// A `lookUnder` owner (local ADS-B) keeps its own old click rule instead:
// its object on top, or under picks no layer owns and picks it looks under
// (`lookUnder(id)`: trails), is selected; another layer's object on top, or
// nothing, is empty space for it (it releases its selection); a click that
// only meets what it looks under does nothing; no gesture rule (its old
// handler took every click). To the others it is another layer.
// An own-rule owner (`onClick(ctx)`: vessels) keeps its whole old click
// handler: it gets every click with the top pick and decides itself (no
// gesture rule, as its old LEFT_CLICK handler); to the others it is another
// layer.
// Shared selection for registered owners, with one pick subscription and
// press accumulator. Normal owners select their first pick, clear on a short
// empty click, and retain selection on another owner's pick. Pass-through
// picks yield to the next hit; drill-through owners may select below an
// unregistered owner's hit. Pointer claims suppress selection.
// Look-under owners select below unowned picks or their declared pass-through
// ids, clear on other owners, and ignore clicks containing only those ids.
// Own-rule owners receive every pointer-free click and apply their own rules.
import { isPointerFree as pointerFree } from './inputOwnership.js';
import { pickRegistryFor, resolvePickId } from './pickRegistry.js';
import {
createPressGesture,
isTrackingClickGesture,
isTrackingSelectionGesture,
} from './trackingGesture.js';
@@ -58,27 +41,7 @@ export function createSelectionDispatcher(
let removers = [];
let destroyed = false;
// The press gesture, as bindTrackingClickGesture measures it.
let press = null;
let completed = null;
const travel = (position) => {
if (!press || !position) return;
if (press.last)
press.travelPx += Math.hypot(
position.x - press.last.x,
position.y - press.last.y,
);
press.last = { x: position.x, y: position.y };
};
const finish = (position) => {
if (!press) return;
travel(position);
completed = {
travelPx: press.travelPx,
durationMs: Math.max(0, now() - press.startedAt),
};
press = null;
};
const press = createPressGesture(now);
const ownerOf = (picked) => {
const id = resolvePickId(picked);
@@ -116,9 +79,7 @@ export function createSelectionDispatcher(
typeof owner.lookUnder !== 'function' && !ownRule(owner);
const onClick = (event) => {
if (press) finish(event.position);
const gesture = completed ?? { travelPx: 0, durationMs: 0 };
completed = null;
const gesture = press.click(event.position);
if (!isPointerFree()) return;
const picked = nearbyContact(
event.picked,
@@ -204,17 +165,9 @@ export function createSelectionDispatcher(
const attach = () => {
if (removers.length || destroyed) return;
removers = [
engine.input.on('down', (event) => {
press = {
startedAt: now(),
travelPx: 0,
last: null,
};
completed = null;
travel(event.position);
}),
engine.input.on('move', (event) => travel(event.endPosition)),
engine.input.on('up', (event) => finish(event.position)),
engine.input.on('down', (event) => press.down(event.position)),
engine.input.on('move', (event) => press.move(event.endPosition)),
engine.input.on('up', (event) => press.up(event.position)),
engine.input.on('click', onClick, { pick: true, drill: SELECTION_DRILL }),
];
keyTarget?.addEventListener?.('keydown', onKey);
-85
View File
@@ -1,85 +0,0 @@
import { trackingEventTypes } from '../globe/cesium/screenEvents.js';
import { isPointerFree } from './inputOwnership.js';
export {
MAX_TRACKING_CLICK_TRAVEL_PX,
MAX_TRACKING_CLICK_DURATION_MS,
isTrackingSelectionGesture,
isTrackingClickGesture,
} from './trackingGesture.js';
/**
* Bind LEFT_DOWN/MOUSE_MOVE/LEFT_UP accounting ahead of a scene click.
* Travel is accumulated segment-by-segment, so an orbit nudge that returns to
* its starting pixel cannot masquerade as a zero-distance click. Every scene
* click reaches `onClick` with its gesture metadata; the caller decides whether
* selection (travel-only) or deselection (travel + duration) is allowed.
* @param {Cesium.ScreenSpaceEventHandler|Object} handler - Input handler.
* @param {(click: Object, gesture: {travelPx: number, durationMs: number}) => void} onClick - Scene-click callback.
* @param {{now?: () => number, eventTypes?: Object, onMouseMove?: (event: Object) => void}} [options] - Test/interop seams.
* @returns {void}
*/
export function bindTrackingClickGesture(handler, onClick, options = {}) {
const now = options.now || (() => performance.now());
const eventTypes = options.eventTypes || trackingEventTypes();
let pressActive = false;
let pressStartedAt = 0;
let previousPosition = null;
let travelPx = 0;
let completedGesture = null;
const appendTravel = (position) => {
if (
!pressActive ||
!Number.isFinite(position?.x) ||
!Number.isFinite(position?.y)
)
return;
if (previousPosition) {
travelPx += Math.hypot(
position.x - previousPosition.x,
position.y - previousPosition.y,
);
}
previousPosition = { x: position.x, y: position.y };
};
const finishPress = (position) => {
if (!pressActive) return;
appendTravel(position);
completedGesture = {
travelPx,
durationMs: Math.max(0, now() - pressStartedAt),
};
pressActive = false;
previousPosition = null;
};
handler.setInputAction((event) => {
pressActive = true;
pressStartedAt = now();
previousPosition = null;
travelPx = 0;
completedGesture = null;
appendTravel(event?.position);
}, eventTypes.LEFT_DOWN);
handler.setInputAction((event) => {
appendTravel(event?.endPosition ?? event?.position);
options.onMouseMove?.(event);
}, eventTypes.MOUSE_MOVE);
handler.setInputAction((event) => {
finishPress(event?.position);
}, eventTypes.LEFT_UP);
handler.setInputAction((click) => {
if (pressActive) finishPress(click?.position);
const gesture = completedGesture || { travelPx: 0, durationMs: 0 };
completedGesture = null;
// A tool owns the pointer: a draw vertex placed over an aircraft is a
// vertex, not a track request (src/data/inputOwnership.js).
if (!isPointerFree()) return;
onClick(click, gesture);
}, eventTypes.LEFT_CLICK);
}
+6 -8
View File
@@ -6,21 +6,21 @@ import {
bindTrackingClickGesture,
isTrackingClickGesture,
isTrackingSelectionGesture,
} from './trackingClickGesture.js';
} from './trackingGesture.js';
import { TRACKED_MODEL_MAX_PX as CIVIL_TRACKED_MODEL_MAX_PX } from './flights.js';
import { TRACKED_MODEL_MAX_PX as MILITARY_TRACKED_MODEL_MAX_PX } from './militaryFlights.js';
const TYPES = {
LEFT_DOWN: 'left-down',
MOUSE_MOVE: 'mouse-move',
LEFT_UP: 'left-up',
LEFT_CLICK: 'left-click',
LEFT_DOWN: 'down',
MOUSE_MOVE: 'move',
LEFT_UP: 'up',
LEFT_CLICK: 'click',
};
function makeHandler() {
const actions = new Map();
return {
setInputAction(callback, type) { actions.set(type, callback); },
on(type, callback) { actions.set(type, callback); return () => actions.delete(type); },
fire(type, event) { actions.get(type)?.(event); },
};
}
@@ -49,7 +49,6 @@ test('synthetic drag-then-click sequence does not reach the untrack callback', (
if (isTrackingClickGesture(gesture)) untrackCalls += 1;
}, {
now: () => timeMs,
eventTypes: TYPES,
});
handler.fire(TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } });
@@ -81,7 +80,6 @@ test('slow clean sprite clicks select, while long presses and orbit nudges canno
if (isTrackingClickGesture(gesture)) untracks += 1;
}, {
now: () => timeMs,
eventTypes: TYPES,
});
handler.fire(TYPES.LEFT_DOWN, { position: { x: 0, y: 0 } });
+69
View File
@@ -1,3 +1,5 @@
import { isPointerFree } from './inputOwnership.js';
export const MAX_TRACKING_CLICK_TRAVEL_PX = 6;
export const MAX_TRACKING_CLICK_DURATION_MS = 400;
@@ -30,3 +32,70 @@ export function isTrackingClickGesture(gesture = {}) {
durationMs <= MAX_TRACKING_CLICK_DURATION_MS
);
}
/** Accumulate press travel and duration, consuming the result on click. */
export function createPressGesture(now = () => performance.now()) {
let press = null;
let completed = null;
const travel = (position) => {
if (
!press ||
!Number.isFinite(position?.x) ||
!Number.isFinite(position?.y)
)
return;
if (press.last)
press.travelPx += Math.hypot(
position.x - press.last.x,
position.y - press.last.y,
);
press.last = { x: position.x, y: position.y };
};
const finish = (position) => {
if (!press) return;
travel(position);
completed = {
travelPx: press.travelPx,
durationMs: Math.max(0, now() - press.startedAt),
};
press = null;
};
return {
down(position) {
press = { startedAt: now(), travelPx: 0, last: null };
completed = null;
travel(position);
},
move: travel,
up: finish,
click(position) {
finish(position);
const gesture = completed ?? { travelPx: 0, durationMs: 0 };
completed = null;
return gesture;
},
};
}
/** Bind engine input with press metadata; the caller chooses its click rule. */
export function bindTrackingClickGesture(
input,
onClick,
{ now, onMouseMove } = {},
) {
const press = createPressGesture(now);
const removes = [
input.on('down', (event) => press.down(event?.position)),
input.on('move', (event) => {
press.move(event?.endPosition ?? event?.position);
onMouseMove?.(event);
}),
input.on('up', (event) => press.up(event?.position)),
input.on('click', (click) => {
const gesture = press.click(click?.position);
if (!isPointerFree()) return;
onClick(click, gesture);
}),
];
return () => removes.forEach((remove) => remove());
}
@@ -171,81 +171,6 @@ function collectionsUnder(viewer) {
);
}
test('movers and lines draw on the scene, answer picks with their pickId and go with the engine', async () => {
const geo = await import('../../geo/index.js');
const viewer = createFakeViewer();
const reasons = [];
const engine = createCesiumEngine(viewer, {
requestRender: (reason) => reasons.push(reason),
});
const set = engine.movers.createSet({
id: 'horizon-points',
pickable: true,
kind: 'point',
capacity: 2,
point: { sizePx: 8 },
});
// The fake camera is at x = 7e6 (over 0°, 0°): 0°E is in front, 180° behind.
set.set(0, { position: geo.cartesian3.fromDegrees(0, 1, 0), pickId: 'a' });
set.set(1, { position: geo.cartesian3.fromDegrees(180, 1, 0), pickId: 'b' });
set.setCount(2);
set.commit();
const line = engine.lines.createLine({
id: 'orbit',
widthPx: 2,
color: [1, 1, 1, 1],
pickId: 'orbit-1',
});
assert.deepEqual(engine.movers.list(), [
{ id: 'horizon-points', kind: 'point', count: 2, capacity: 2 },
]);
assert.deepEqual(engine.lines.list(), [
{ id: 'orbit', points: 0, shown: true },
]);
assert.ok(reasons.includes('movers'));
assert.equal(viewer.primitiveList.length, 2, 'one root each');
const [points] = collectionsUnder(viewer).filter(
(item) => item instanceof Cesium.PointPrimitiveCollection,
);
const front = points.get(0);
const back = points.get(1);
const lineSlot = { gevLine: 'orbit' };
viewer.scene.drillPick = () => [
{ primitive: back, id: 'b' },
{ primitive: front, id: 'stale' },
{ primitive: lineSlot, id: 'orbit-1' },
];
assert.deepEqual(
engine.picker
.drillPick({ x: 1, y: 1 })
.map(({ id, owner, instance }) => [id, owner, instance]),
[
['a', 'horizon-points', 0],
['orbit-1', 'orbit', undefined],
],
'the instance behind the horizon is left out',
);
viewer.scene.pick = () => ({ primitive: front, id: 'stale' });
assert.equal(engine.picker.pick({ x: 1, y: 1 }).id, 'a');
// A mover set in a surface exclusion is hidden for the sample only.
let shownDuring;
viewer.scene.sampleHeight = (position, excluded) => {
shownDuring = [points.show, excluded];
return 5;
};
const place = geo.cartographic.fromDegrees(0, 1, 0);
assert.equal(engine.surface.sampleHeight(place, [set]), 5);
assert.deepEqual(shownDuring, [false, []]);
assert.equal(points.show, true);
engine.destroy();
assert.equal(points.isDestroyed(), true);
assert.deepEqual(viewer.primitiveList, []);
assert.deepEqual(engine.movers.list(), []);
assert.deepEqual(engine.lines.list(), []);
assert.equal(set.isDestroyed(), true);
assert.throws(() => line.show(false), /destroyed/);
});
test('the contract suite movers and lines checks pass on the Cesium engine (no pixels or pointer)', async (t) => {
// Cesium's Material sorts uniform values with instanceof checks against
// these DOM classes; Node has none of them.
+31
View File
@@ -237,6 +237,14 @@ test('fills batch into GroundPrimitive instances, outlines into ground polylines
items('disc', [{ center: [0, 0], radiusM: 500, fill: [1, 1, 1, 1] }]),
);
assert.ok(shapes.fills[0].geometry instanceof Cesium.EllipseGeometry);
assert.equal(shapes.fills[0].geometry._semiMajorAxis, 500);
const cells = groundInstances(
{ id: 'cells', kind: 'cell' },
items('cell', [
{ west: 0, south: 0, east: 1, north: 1, fill: [1, 0, 0, 1] },
]),
);
assert.ok(cells.fills[0].geometry instanceof Cesium.RectangleGeometry);
});
test('sets join scene.groundPrimitives in order and rebuild on commit', () => {
@@ -441,3 +449,26 @@ test('corridor reveal mutates the live material and retains it through a geometr
assert.equal(built[1].appearance.material.uniforms.reveal, 0.5);
port.destroy();
});
test('non-pickable ground fills skip pick commands and retain normal drawing', (t) => {
const viewer = createFakeViewer();
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
const port = createCesiumGround(viewer);
const set = port.ground.createSet({
id: 'cells',
kind: 'cell',
pickable: false,
});
set.replace([{ west: 0, south: 0, east: 1, north: 1, fill: [1, 0, 0, 1] }]);
set.commit();
const fill = viewer.scene.groundPrimitives.get(0).get(0).get(0);
assert.ok(fill instanceof Cesium.GroundPrimitive);
let updates = 0;
t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++);
fill.update({ passes: { pick: true } });
assert.equal(updates, 0);
fill.update({ passes: { render: true, pick: false } });
assert.equal(updates, 1);
port.destroy();
assert.equal(fill.isDestroyed(), true);
});
+3
View File
@@ -16,6 +16,9 @@ import * as geo from '../../../geo/index.js';
const { cartesian3, ellipsoid } = geo;
const DEG = Math.PI / 180;
/** Reveal fraction within a packed corridor item. */
export const REVEAL_OFFSET = 20;
/** Pieces a tile holds before it splits. */
export const TILE_PIECES = 20000;
/** A piece's longest arc, degrees. */
-61
View File
@@ -1,61 +0,0 @@
import { Effect, EffectAttribute } from 'postprocessing';
import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js';
/**
* A screen-space pass on a Navara view, registered once per view under `key`.
* `insertAfter` and `insertBefore` place it in the view's effect chain.
* `uniforms()` lists the pass's [name, Uniform] entries; `update` and
* `destroy` run as the descriptor's updateFrame and onDestroy, with the
* descriptor as `this` (its `effect`, `owner`, `view`, `ctx` and `find`).
* The owner is the `owner` of the config added with `view.addEffect`.
*/
export function defineGroundEffect({
key,
name,
fragmentShader,
uniforms,
insertAfter,
insertBefore,
update,
destroy,
}) {
const registered = new WeakSet();
const descriptor = (EffectDesc, NavaraEffect) =>
class GroundEffectDesc extends withPassDisposal(
withFrameUpdates(EffectDesc),
) {
static allowDuplication = true;
static key = key;
static insertAfter = insertAfter;
static insertBefore = insertBefore;
constructor(view, context, config) {
super(view, context, config);
this.owner = config[key].owner;
}
createPass() {
this.effect = new Effect(name, fragmentShader, {
// Keep a separate pass: inputColor is the already tone-mapped frame.
attributes: EffectAttribute.CONVOLUTION,
uniforms: uniforms && new Map(uniforms()),
});
return new NavaraEffect(this.view.camera.raw, this.effect);
}
updateFrame() {
update.call(this);
}
onDestroy() {
destroy?.call(this);
this.effect?.dispose();
super.onDestroy();
}
};
return {
key,
/** Register the pass on `view` the first time it is used there. */
register(view, { EffectDesc, NavaraEffect }) {
if (registered.has(view)) return;
view.registerEffect(key, descriptor(EffectDesc, NavaraEffect));
registered.add(view);
},
};
}
+11 -3
View File
@@ -12,7 +12,7 @@
import { createGroundRegistry } from '../../ground.js';
import { composeGround, packHeights, packSet } from './pack.js';
import { ITEM_TEXELS } from './corridors.js';
import { REVEAL_OFFSET, ITEM_TEXELS } from './corridors.js';
// Frames before the first new try at a set's missing terrain heights; each
// later try waits twice as long, up to RETRIES tries.
@@ -61,7 +61,11 @@ export function createNavaraGround(
};
const pack = (entry) => {
entry.pack = packSet(entry.options.kind, entry.items, { terrainHeight });
for (let at = 20; at < entry.pack.items.length; at += ITEM_TEXELS * 4)
for (
let at = REVEAL_OFFSET;
at < entry.pack.items.length;
at += ITEM_TEXELS * 4
)
entry.pack.items[at] = entry.fraction;
entry.wait = entry.pack.heights.some(Number.isNaN) ? RETRY_FRAMES : 0;
};
@@ -122,7 +126,11 @@ export function createNavaraGround(
if (entry.fraction === fraction) return;
entry.fraction = fraction;
if (entry.pack) {
for (let at = 20; at < entry.pack.items.length; at += ITEM_TEXELS * 4)
for (
let at = REVEAL_OFFSET;
at < entry.pack.items.length;
at += ITEM_TEXELS * 4
)
entry.pack.items[at] = fraction;
if (entry.offset !== null)
pass.reveal(
+11 -21
View File
@@ -13,6 +13,7 @@
// materials, from the time uniform (src/globe/ground.js groundSeconds).
// No effect remains in the chain while no item is drawn.
import { DISPLAY_COLOR_GLSL, VIEW_POSITION_GLSL } from '../shaderChunks.js';
import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three';
import { Effect, EffectAttribute } from 'postprocessing';
import {
@@ -20,13 +21,13 @@ import {
FloatType,
Matrix4,
NearestFilter,
RGBADepthPacking,
RGBAFormat,
Uniform,
} from 'three';
import { DASH_EDGE, groundSeconds } from '../../ground.js';
import { groundSurfaceUniforms, updateGroundSurface } from './surface.js';
import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js';
import { ITEM_TEXELS, LOOK, MARGIN_RATIO } from './corridors.js';
import { REVEAL_OFFSET, ITEM_TEXELS, LOOK, MARGIN_RATIO } from './corridors.js';
import {
GRID_COLS,
GRID_ROWS,
@@ -60,12 +61,8 @@ uniform float uPixelRatio;
vec4 atSlot(highp sampler2D image, int slot) {
return texelFetch(image, ivec2(slot & ${ROW - 1}, slot >> ${Math.log2(ROW)}), 0);
}
vec3 toDisplay(vec3 c) {
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
}
vec3 toLinear(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));
}
${DISPLAY_COLOR_GLSL}
${VIEW_POSITION_GLSL}
// A corridor item over the colour, nearest metres from its line and along
// metres from its start: its width, glow, pulse and dash, in LDR and
// premultiplied, as Cesium writes ground polylines.
@@ -122,9 +119,9 @@ vec3 blendCorridor(vec3 color, int item, float nearest, vec2 normal, float along
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;
float distance = viewDistance(depth, uLogDepthFC);
vec4 ray = uInverseProjection * vec4(uv * 2.0 - 1.0, -1.0, 1.0);
vec3 point = ray.xyz * (distance / -ray.z);
vec3 point = viewPosition(ray, distance);
// Derivatives before any divergent branch: neighbours test other items.
vec3 pointDx = dFdx(point);
vec3 pointDy = dFdy(point);
@@ -403,8 +400,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
// Its own pass: inputColor is the tone-mapped frame.
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
['tAllDepth', new Uniform(null)],
['tSurfaceDepth', new Uniform(null)],
...groundSurfaceUniforms(),
['tFrames', new Uniform(null)],
['tStyles', new Uniform(null)],
['tSegments', new Uniform(null)],
@@ -413,9 +409,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
['tItems', new Uniform(null)],
['tGrid', new Uniform(null)],
['tCandidates', new Uniform(null)],
['uInverseProjection', new Uniform(new Matrix4())],
['uViewToWorld', new Uniform(new Matrix4())],
['uLogDepthFC', new Uniform(1)],
['uTime', new Uniform(0)],
['uPixelRatio', new Uniform(1)],
]),
@@ -428,8 +422,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
const owner = this.owner;
if (!this.effect || !camera || !owner.state) return;
const mrt = this.find('mrt');
if (mrt?.depthBufferPacking !== RGBADepthPacking)
throw new Error('The ground pass needs packed scene depth');
updateGroundSurface(this.effect, camera, this.ctx, mrt);
const input = this.ctx.getInputBuffer();
const { state } = owner;
if (
@@ -451,11 +444,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
textures.candidates.needsUpdate = true;
}
const u = this.effect.uniforms;
u.get('tAllDepth').value = mrt.depthBuffer;
u.get('tSurfaceDepth').value = this.ctx.getRenderTarget().depthTexture;
u.get('uInverseProjection').value.copy(camera.projectionMatrixInverse);
u.get('uViewToWorld').value.copy(camera.matrixWorld);
u.get('uLogDepthFC').value = 2 / Math.log2(camera.far + 1);
u.get('uTime').value = groundSeconds();
const cssWidth = this.view.canvas?.clientWidth;
u.get('uPixelRatio').value = cssWidth > 0 ? input.width / cssWidth : 1;
@@ -504,7 +493,8 @@ export function createGroundPass(view) {
reveal(offset, count, fraction) {
if (!owner.state) return;
for (let i = 0; i < count; i++)
owner.state.items.data[offset + i * ITEM_TEXELS * 4 + 20] = fraction;
owner.state.items.data[offset + i * ITEM_TEXELS * 4 + REVEAL_OFFSET] =
fraction;
owner.textures.items.needsUpdate = true;
},
textureBytes: () => textureBytes(owner.state),
+59 -38
View File
@@ -1,13 +1,15 @@
// Ground pulse fields over the visible globe, after tone mapping.
import { DISPLAY_COLOR_GLSL, VIEW_POSITION_GLSL } from '../shaderChunks.js';
import { Matrix4, Uniform, Vector3, Vector4 } from 'three';
import { groundSurfaceUniforms, updateGroundSurface } from './surface.js';
import { defineGroundEffect } from './effect.js';
import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three';
import { Effect, EffectAttribute } from 'postprocessing';
import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js';
const KEY = 'gevGroundPulses';
const fragmentShader = /* glsl */ `
uniform sampler2D tAllDepth;
uniform sampler2D tSurfaceDepth;
uniform mat4 uInverseProjection;
uniform mat4 uInverseView;
uniform float uLogDepthFC;
@@ -23,12 +25,8 @@ uniform float uDepth;
uniform float uLineWidth;
uniform float uHaloWidth;
uniform float uPixelRatio;
vec3 toDisplay(vec3 c) {
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
}
vec3 toLinear(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));
}
${DISPLAY_COLOR_GLSL}
${VIEW_POSITION_GLSL}
vec4 over(vec4 top, vec4 under) {
float a = top.a + under.a * (1.0 - top.a);
if (a <= 0.0) return vec4(0.0);
@@ -37,9 +35,9 @@ vec4 over(vec4 top, vec4 under) {
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;
float distance=viewDistance(depth, uLogDepthFC);
vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0);
vec3 point=ray.xyz*(distance/-ray.z);
vec3 point=viewPosition(ray, distance);
vec3 origin=uInverseView[3].xyz;
vec3 direction=normalize((uInverseView*vec4(ray.xyz,0.0)).xyz);
vec3 o=origin/uRadiiM;
@@ -81,36 +79,49 @@ void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
}
`;
const pulseEffect = defineGroundEffect({
key: KEY,
name: 'GevGroundPulses',
fragmentShader,
insertAfter: ['gevGround', 'gevImagery', 'toneMapping'],
insertBefore: [
class PulseEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
static allowDuplication = true;
static key = KEY;
static insertAfter = ['gevGround', 'gevImagery', 'toneMapping'];
static insertBefore = [
'gevPreSharpenOverlay',
'gevSharpen',
'smaa',
'fxaa',
'gevOverlay',
'final',
],
uniforms: () => [
...groundSurfaceUniforms(),
['uInverseView', new Uniform(new Matrix4())],
['uCenter', new Uniform(new Vector3(1, 0, 0))],
['uRadiusAngle', new Uniform(1)],
['uRadiiM', new Uniform(new Vector3())],
['uColor', new Uniform(new Vector4())],
['uLineColor', new Uniform(new Vector4())],
['uHaloColor', new Uniform(new Vector4())],
['uRadii', new Uniform(new Vector4())],
['uAlphas', new Uniform(new Vector4())],
['uDepth', new Uniform(0.35)],
['uLineWidth', new Uniform(3)],
['uHaloWidth', new Uniform(1)],
['uPixelRatio', new Uniform(1)],
],
update() {
];
constructor(view, context, config) {
super(view, context, config);
this.owner = config[KEY].owner;
}
createPass() {
this.effect = new Effect('GevGroundPulses', fragmentShader, {
// Input color is the tone-mapped frame.
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
...groundSurfaceUniforms({ surface: false }),
['uInverseView', new Uniform(new Matrix4())],
['uCenter', new Uniform(new Vector3(1, 0, 0))],
['uRadiusAngle', new Uniform(1)],
['uRadiiM', new Uniform(new Vector3())],
['uColor', new Uniform(new Vector4())],
['uLineColor', new Uniform(new Vector4())],
['uHaloColor', new Uniform(new Vector4())],
['uRadii', new Uniform(new Vector4())],
['uAlphas', new Uniform(new Vector4())],
['uDepth', new Uniform(0.35)],
['uLineWidth', new Uniform(3)],
['uHaloWidth', new Uniform(1)],
['uPixelRatio', new Uniform(1)],
]),
});
return new NavaraEffect(this.view.camera.raw, this.effect);
}
updateFrame() {
const owner = this.owner,
camera = this.view.camera.raw;
if (!this.effect || !camera) return;
@@ -139,8 +150,15 @@ const pulseEffect = defineGroundEffect({
cssWidth > 0
? this.ctx.getInputBuffer().width / cssWidth
: globalThis.devicePixelRatio || 1;
},
});
}
onDestroy() {
this.effect?.dispose();
super.onDestroy();
}
}
const registered = new WeakSet();
/**
* The pulse disc on one Navara view around `center` (Earth-fixed metres):
@@ -149,7 +167,7 @@ const pulseEffect = defineGroundEffect({
* rgba arrays in 0..1: the band, the pulse line and its halo.
*/
export function createNavaraPulseField(
{ view, EffectDesc, NavaraEffect, canvas },
{ view, canvas },
{
center,
radiusM,
@@ -162,7 +180,10 @@ export function createNavaraPulseField(
haloWidth,
},
) {
pulseEffect.register(view, { EffectDesc, NavaraEffect });
if (!registered.has(view)) {
view.registerEffect(KEY, PulseEffectDesc);
registered.add(view);
}
const owner = {
cssWidth: () => canvas?.clientWidth ?? 0,
center: new Vector3(),
+22 -2
View File
@@ -1,7 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../../geo/index.js';
import { createNavaraPulseField } from './pulse.js';
import { registerHooks } from 'node:module';
const hook = registerHooks({
resolve(specifier, context, next) {
if (
specifier === '@navaramap/three' &&
context.parentURL?.endsWith('/ground/pulse.js')
)
return {
url: 'data:text/javascript,export class EffectDesc {} export class Effect {}',
shortCircuit: true,
};
return next(specifier, context);
},
});
let createNavaraPulseField;
try {
({ createNavaraPulseField } = await import('./pulse.js'));
} finally {
hook.deregister();
}
test('the Navara pulse disc draws only while the pulses run', () => {
const effects = [];
@@ -25,7 +45,7 @@ test('the Navara pulse disc draws only while the pulses run', () => {
},
};
const disc = createNavaraPulseField(
{ view, EffectDesc: class {}, NavaraEffect: class {} },
{ view },
{
center: geo.cartesian3.fromDegrees(3, 27, 0),
radiusM: 250_000,
+4 -3
View File
@@ -1,9 +1,9 @@
// Public depth inputs shared by actual terrain/tiles classifiers.
import { Matrix4, Uniform, RGBADepthPacking } from 'three';
export function groundSurfaceUniforms() {
export function groundSurfaceUniforms({ surface = true } = {}) {
return [
['tAllDepth', new Uniform(null)],
['tSurfaceDepth', new Uniform(null)],
...(surface ? [['tSurfaceDepth', new Uniform(null)]] : []),
['uInverseProjection', new Uniform(new Matrix4())],
['uLogDepthFC', new Uniform(1)],
];
@@ -13,7 +13,8 @@ export function updateGroundSurface(effect, camera, context, mrt) {
throw new Error('Ground classification requires packed scene depth');
const u = effect.uniforms;
u.get('tAllDepth').value = mrt.depthBuffer;
u.get('tSurfaceDepth').value = context.getRenderTarget().depthTexture;
if (u.has('tSurfaceDepth'))
u.get('tSurfaceDepth').value = context.getRenderTarget().depthTexture;
u.get('uInverseProjection').value.copy(camera.projectionMatrixInverse);
u.get('uLogDepthFC').value = 2 / Math.log2(camera.far + 1);
}
+12 -19
View File
@@ -15,9 +15,14 @@
// slot's position, taken outside any divergent branch. Costs nothing while
// no layer is drawn (the effect is hidden).
import { DISPLAY_COLOR_GLSL, VIEW_POSITION_GLSL } from '../shaderChunks.js';
import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three';
import { Effect, EffectAttribute } from 'postprocessing';
import { DataTexture, Matrix4, RGBADepthPacking, Uniform } from 'three';
import { DataTexture, Matrix4, Uniform } from 'three';
import {
groundSurfaceUniforms,
updateGroundSurface,
} from '../ground/surface.js';
import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js';
import { MAX_IMAGERY_SLOTS } from './index.js';
@@ -39,12 +44,8 @@ uniform vec4 uBounds${i};
uniform vec4 uLook${i};
uniform float uRaise${i};`,
).join('\n')}
vec3 toDisplay(vec3 c) {
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
}
vec3 toLinear(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));
}
${DISPLAY_COLOR_GLSL}
${VIEW_POSITION_GLSL}
// Geodetic longitude and latitude (Bowring) of a world point, degrees.
vec2 geodetic(vec3 point) {
float p = length(point.xy);
@@ -130,9 +131,9 @@ void derivatives(vec2 geo, vec2 merc, float drawn, out vec2 geoDx, out vec2 geoD
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;
float distance = viewDistance(depth, uLogDepthFC);
vec4 ray = uInverseProjection * vec4(uv * 2.0 - 1.0, -1.0, 1.0);
vec3 point = (uWorld * vec4(ray.xyz * (distance / -ray.z), 1.0)).xyz;
vec3 point = (uWorld * vec4(viewPosition(ray, distance), 1.0)).xyz;
// The ground: the scene depth where it is the surface (the packed depth
// also holds forward meshes, which are not ground).
float ground = (1.0 - step(1.0, depth)) *
@@ -201,11 +202,8 @@ class ImageryEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
// Its own pass: inputColor is the tone-mapped frame.
attributes: EffectAttribute.CONVOLUTION,
uniforms: new Map([
['tAllDepth', new Uniform(null)],
['tSurfaceDepth', new Uniform(null)],
['uInverseProjection', new Uniform(new Matrix4())],
...groundSurfaceUniforms(),
['uWorld', new Uniform(new Matrix4())],
['uLogDepthFC', new Uniform(1)],
['uSplit', new Uniform(0.5)],
...SLOTS.flatMap((i) => [
[`tImage${i}`, new Uniform(null)],
@@ -224,14 +222,9 @@ class ImageryEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
const owner = this.owner;
if (!this.effect || !camera) return;
const mrt = this.find('mrt');
if (mrt?.depthBufferPacking !== RGBADepthPacking)
throw new Error('The imagery pass needs packed scene depth');
updateGroundSurface(this.effect, camera, this.ctx, mrt);
const u = this.effect.uniforms;
u.get('tAllDepth').value = mrt.depthBuffer;
u.get('tSurfaceDepth').value = this.ctx.getRenderTarget().depthTexture;
u.get('uInverseProjection').value.copy(camera.projectionMatrixInverse);
u.get('uWorld').value.copy(camera.matrixWorld);
u.get('uLogDepthFC').value = 2 / Math.log2(camera.far + 1);
u.get('uSplit').value = owner.split;
for (const i of SLOTS) {
const slot = owner.slots[i];
+2 -10
View File
@@ -2,12 +2,7 @@
// the product way (viewOptions.js) and the GlobeEngine on it. Load it with a
// dynamic import() so Navara stays out of the Cesium-mode bundle.
import ThreeView, {
Color as NavaraColor,
MeshDesc,
EffectDesc,
Effect as NavaraEffect,
} from '@navaramap/three';
import ThreeView, { Color as NavaraColor, MeshDesc } from '@navaramap/three';
import * as geo from '../../geo/index.js';
import { DefaultPlugin } from '@navaramap/three-default-plugin';
import { Pass } from 'postprocessing';
@@ -177,10 +172,7 @@ export async function createNavaraGlobe({ container, search, hd = false }) {
ground: createNavaraGround(view, {
createPass: createGroundPass,
pulseField: (options) =>
createNavaraPulseField(
{ view, EffectDesc, NavaraEffect, canvas },
options,
),
createNavaraPulseField({ view, canvas }, options),
}),
imagery: (parts) =>
createNavaraImagery(view, { ...parts, createPass: createImageryPass }),
+3 -1
View File
@@ -1,3 +1,4 @@
import { PACKED_DEPTH_GLSL } from './shaderChunks.js';
// The depth test of gev's screen overlays on Navara (sprites, points and
// lines drawn after tone mapping, overlay.js): there is no depth buffer in
// the post-processing chain, so each fragment compares its own logarithmic
@@ -15,6 +16,7 @@ export const OVERLAY_DEPTH_MODES = Object.freeze({
/** Fragment declarations: uniforms, the varying and gevOverlayHidden(). */
export const OVERLAY_DEPTH_FRAGMENT = /* glsl */ `
${PACKED_DEPTH_GLSL}
uniform sampler2D tGevSceneDepth;
uniform vec2 uGevResolution;
uniform float uGevDepthMode;
@@ -36,7 +38,7 @@ bool gevOverlayHidden() {
#else
float mine = gl_FragCoord.z;
#endif
bool occluded = mine > scene + 2e-6;
bool occluded = packedDepthOccluded(mine, scene, 2e-6);
return uGevDepthMode < 1.5 ? occluded : !occluded;
}
`;
+3 -1
View File
@@ -1,3 +1,4 @@
import { PACKED_DEPTH_GLSL } from './shaderChunks.js';
// Navara draws its transparent scene after the post effects, into a target
// that does not hold the scene's depth, so a transparent mesh behind a wall,
// terrain or the Earth painted through them. Moving such a mesh into the
@@ -15,6 +16,7 @@ const KEY = 'gevSceneDepth';
export const SCENE_DEPTH_BIAS = 5e-6;
const PARS = /* glsl */ `
${PACKED_DEPTH_GLSL}
uniform sampler2D tGevSceneDepth;
uniform vec2 uGevTargetSize;
uniform float uGevSceneDepthOn;
@@ -32,7 +34,7 @@ if (uGevSceneDepthOn > 0.5) {
#else
float gevDepth = gl_FragCoord.z;
#endif
if (gevScene < 1.0 && gevDepth > gevScene + ${SCENE_DEPTH_BIAS.toExponential()})
if (gevScene < 1.0 && packedDepthOccluded(gevDepth, gevScene, ${SCENE_DEPTH_BIAS.toExponential()}))
discard;
}
`;
+4 -1
View File
@@ -32,7 +32,10 @@ test('the scene-depth test goes after the log depth and brings packing once', ()
patched.indexOf('uniform float uGevSceneDepthOn;') <
patched.indexOf('void main() {'),
);
assert.match(patched, /gevDepth > gevScene \+ 5e-6\)\s*discard;/);
assert.match(
patched,
/packedDepthOccluded\(gevDepth, gevScene, 5e-6\)\)\s*discard;/,
);
}
assert.ok(SCENE_DEPTH_BIAS > 0 && SCENE_DEPTH_BIAS < 1e-4);
assert.throws(() => withSceneDepthTest('void main() {}'), /logdepthbuf/);
+24
View File
@@ -0,0 +1,24 @@
// Shader functions shared by the surface passes and depth tests.
export const DISPLAY_COLOR_GLSL = /* glsl */ `
vec3 toDisplay(vec3 c) {
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
}
vec3 toLinear(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));
}
`;
export const VIEW_POSITION_GLSL = /* glsl */ `
vec3 viewPosition(vec4 ray, float distance) {
return ray.xyz * (distance / -ray.z);
}
float viewDistance(float depth, float logDepthFC) {
return exp2(2.0 * depth / logDepthFC) - 1.0;
}
`;
export const PACKED_DEPTH_GLSL = /* glsl */ `
bool packedDepthOccluded(float mine, float scene, float bias) {
return mine > scene + bias;
}
`;
+98 -115
View File
@@ -19,6 +19,40 @@ import {
} from '../contract.js';
const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
const throwsType = (run, label) => {
let error = null;
try {
run();
} catch (caught) {
error = caught;
}
ensure(error instanceof TypeError, `${label} did not throw a TypeError`);
};
const like = (colour, data, i) =>
colour.every(
(channel, k) => k > 2 || Math.abs(data[i + k] - channel * 255) <= 40,
);
const MAGENTA = [255, 0, 255];
const GREEN = [0, 255, 0];
const nearColour = (actual, expected, tolerance = 40) =>
actual.every((value, k) => Math.abs(value - expected[k]) <= tolerance);
const show = (colour) => colour.map((value) => value.toFixed(0)).join(',');
const screenOf = (engine, lon, lat) =>
engine.projector.worldToWindow(cartesian3.fromDegrees(lon, lat));
const meanAt = (engine, readRegion, lon, lat, half) => {
const { x, y } = screenOf(engine, lon, lat);
const data = readRegion(
Math.round(x) - half,
Math.round(y) - half,
2 * half,
2 * half,
);
const mean = [0, 0, 0];
for (let i = 0; i < data.length; i += 4)
for (let k = 0; k < 3; k++) mean[k] += data[i + k];
return mean.map((sum) => sum / (data.length / 4));
};
const WGS84 = ellipsoid.WGS84;
class ContractError extends Error {
@@ -505,11 +539,7 @@ export function createContractSuite(
// count and centroid.
// A colour as drawn: within 40 of the asked one on every channel
// (both engines draw overlays after tone mapping, unlit).
const like = (colour, data, i) =>
colour.every(
(channel, k) =>
k > 2 || Math.abs(data[i + k] - channel * 255) <= 40,
);
const find = (screen, colour) => {
const half = 32;
const x0 = Math.round(screen.x) - half;
@@ -3002,17 +3032,7 @@ function groundChecks(
[lon + half, lat + half],
[lon - half, lat + half],
];
const screenOf = (lon, lat) =>
engine.projector.worldToWindow(cartesian3.fromDegrees(lon, lat));
const throwsType = (run, label) => {
let error = null;
try {
run();
} catch (caught) {
error = caught;
}
ensure(error instanceof TypeError, `${label} did not throw a TypeError`);
};
return [
[
'projector: a lowered horizon ellipsoid keeps below-sea-level ground visible',
@@ -3075,7 +3095,7 @@ function groundChecks(
await until(() => set.ready(), 'reveal ground ready');
await settle();
const magenta = (lon) => {
const { x, y } = screenOf(lon, 10);
const { x, y } = screenOf(engine, lon, 10);
const p = readRegion(Math.round(x), Math.round(y), 1, 1);
return Math.min(p[0], p[2]) - p[1];
};
@@ -3414,7 +3434,8 @@ function groundChecks(
'the pickable fills drawn',
);
await settle(3);
const pickAt = (lon, lat) => engine.picker.pick(screenOf(lon, lat));
const pickAt = (lon, lat) =>
engine.picker.pick(screenOf(engine, lon, lat));
const onCell = pickAt(19.5, 10.2);
ensure(
onCell?.id === 'cell-a' && onCell.owner === 'contract-ground-cells',
@@ -3451,7 +3472,7 @@ function groundChecks(
await settle(3);
if (!pointer) return;
const before = clicks.length;
const at = screenOf(19.5, 10.2);
const at = screenOf(engine, 19.5, 10.2);
await pointer.click(at.x, at.y);
await until(() => clicks.length > before, 'a click payload');
const clicked = clicks.at(-1);
@@ -3474,14 +3495,10 @@ function groundChecks(
await nadirView(1e6);
const MAGENTA = [1, 0, 1, 1];
const GREEN = [0, 1, 0, 1];
const like = (colour, data, i) =>
colour.every(
(channel, k) =>
k > 2 || Math.abs(data[i + k] - channel * 255) <= 40,
);
// Pixels of a colour in a box centred on a ground point.
const countAt = (lon, lat, colour, half = 12) => {
const { x, y } = screenOf(lon, lat);
const { x, y } = screenOf(engine, lon, lat);
const data = readRegion(
Math.round(x) - half,
Math.round(y) - half,
@@ -3554,7 +3571,7 @@ function groundChecks(
`the disc's outline is ${countAt(rimLon, 10, MAGENTA, 3).toFixed(2)} magenta at the rim`,
);
// A 6 px corridor: a column of pixels across it.
const mid = screenOf(20, 8.5);
const mid = screenOf(engine, 20, 8.5);
const column = readRegion(
Math.round(mid.x),
Math.round(mid.y) - 10,
@@ -3753,7 +3770,11 @@ function groundChecks(
const along = (lat, samples = 120) => {
const out = [];
for (let k = 0; k < samples; k++) {
const at = screenOf(19.2 + (1.6 * k) / (samples - 1), lat);
const at = screenOf(
engine,
19.2 + (1.6 * k) / (samples - 1),
lat,
);
out.push([
...readRegion(Math.round(at.x), Math.round(at.y), 1, 1),
]);
@@ -3763,7 +3784,7 @@ function groundChecks(
const magenta = ([r, g, b]) => r > 200 && b > 200 && g < 60;
// The glow: white at its centre line, green toward its edges.
{
const centre = screenOf(20, 9);
const centre = screenOf(engine, 20, 9);
const column = readRegion(
Math.round(centre.x),
Math.round(centre.y) - 12,
@@ -3790,7 +3811,7 @@ function groundChecks(
let trough = 0;
const started = performance.now();
while (performance.now() - started < 1600) {
const centre = screenOf(20, 10);
const centre = screenOf(engine, 20, 10);
const pixel = readRegion(
Math.round(centre.x),
Math.round(centre.y),
@@ -3892,6 +3913,7 @@ function groundChecks(
const metresPerDegree = 111319.49 * Math.cos((10 * Math.PI) / 180);
const at = (radius, angle) => {
const { x, y } = screenOf(
engine,
20 + (radius * Math.cos(angle)) / metresPerDegree,
10 + (radius * Math.sin(angle)) / 110574,
);
@@ -3951,15 +3973,7 @@ function groundChecks(
// engine.imagery: raster layers on the ground (every engine).
function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
const { imagery } = engine;
const throwsType = (run, label) => {
let error = null;
try {
run();
} catch (caught) {
error = caught;
}
ensure(error instanceof TypeError, `${label} did not throw a TypeError`);
};
// A flat colour as RGBA bytes (no canvas needed), size × size texels.
const flat = (rgba, size = 8) => {
const data = new Uint8Array(size * size * 4);
@@ -4132,35 +4146,16 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
async () => {
if (!readRegion) return;
await nadirView(1e6);
const MAGENTA = [255, 0, 255];
const GREEN = [0, 255, 0];
const screenOf = (lon, lat) =>
engine.projector.worldToWindow(cartesian3.fromDegrees(lon, lat));
// The mean colour in a box centred on a ground point.
const meanAt = (lon, lat, half = 6) => {
const { x, y } = screenOf(lon, lat);
const data = readRegion(
Math.round(x) - half,
Math.round(y) - half,
2 * half,
2 * half,
);
const mean = [0, 0, 0];
for (let i = 0; i < data.length; i += 4)
for (let k = 0; k < 3; k++) mean[k] += data[i + k];
return mean.map((sum) => sum / (data.length / 4));
};
const near = (actual, expected, tolerance = 40) =>
actual.every(
(value, k) => Math.abs(value - expected[k]) <= tolerance,
);
const show = (colour) =>
colour.map((value) => value.toFixed(0)).join(',');
// The map's own tiles loaded: what lies under the layers holds still.
await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded');
await settle(4);
const base = meanAt(19.5, 10);
ensure(!near(base, MAGENTA), 'the globe is already magenta there');
const base = meanAt(engine, readRegion, 19.5, 10, 6);
ensure(
!nearColour(base, MAGENTA),
'the globe is already magenta there',
);
const magenta = imagery.createLayer({
id: 'contract-paint-magenta',
source: {
@@ -4173,33 +4168,38 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
try {
await until(() => magenta.ready(), 'the magenta layer ready');
await until(
() => near(meanAt(19.5, 10), MAGENTA),
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA),
'the magenta layer drawn',
);
ensure(
near(meanAt(21, 10), base, 60) || !near(meanAt(21, 10), MAGENTA),
nearColour(meanAt(engine, readRegion, 21, 10, 6), base, 60) ||
!nearColour(meanAt(engine, readRegion, 21, 10, 6), MAGENTA),
'the layer spills outside its rectangle',
);
// Alpha: at 0 the globe under it, at 0.5 half way to magenta.
magenta.setAlpha(0);
await until(() => !near(meanAt(19.5, 10), MAGENTA), 'alpha 0 drawn');
await until(
() => !nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA),
'alpha 0 drawn',
);
await settle(2);
const below = meanAt(19.5, 10);
const below = meanAt(engine, readRegion, 19.5, 10, 6);
magenta.setAlpha(0.5);
const half = below.map((value, k) => (value + MAGENTA[k]) / 2);
await until(
() => near(meanAt(19.5, 10), half, 30),
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), half, 30),
`alpha 0.5 drawn (${show(half)} wanted)`,
);
magenta.setAlpha(1);
magenta.show(false);
await until(
() => near(meanAt(19.5, 10), below, 30),
() =>
nearColour(meanAt(engine, readRegion, 19.5, 10, 6), below, 30),
'the hidden layer gone',
);
magenta.show(true);
await until(
() => near(meanAt(19.5, 10), MAGENTA),
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA),
'the layer shown again',
);
// Order: a higher order draws on top, a lower one under it.
@@ -4224,12 +4224,12 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
});
layers.push(lower);
await until(
() => near(meanAt(19.5, 10), GREEN),
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), GREEN),
'the higher order on top',
);
green.destroy();
await until(
() => near(meanAt(19.5, 10), MAGENTA),
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA),
'the destroyed layer gone',
);
// A new source in place: the same layer, another colour.
@@ -4239,7 +4239,7 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
rectangle: box,
});
await until(
() => near(meanAt(19.5, 10), GREEN),
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), GREEN),
'the new source drawn',
);
magenta.setSource({
@@ -4250,11 +4250,11 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
lower.destroy();
// Split: a left layer draws only left of the split position.
const width = engine.host.size().width;
const middle = screenOf(19.5, 10).x;
const middle = screenOf(engine, 19.5, 10).x;
imagery.setSplitPosition(middle / width);
magenta.setSplit('left');
const left = screenOf(19.25, 10);
const right = screenOf(19.75, 10);
const left = screenOf(engine, 19.25, 10);
const right = screenOf(engine, 19.75, 10);
const at = ({ x, y }) => {
const data = readRegion(Math.round(x) - 3, Math.round(y) - 3, 6, 6);
const mean = [0, 0, 0];
@@ -4263,17 +4263,20 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
return mean;
};
await until(
() => near(at(left), MAGENTA) && !near(at(right), MAGENTA),
() =>
nearColour(at(left), MAGENTA) && !nearColour(at(right), MAGENTA),
`the left split drawn (left ${show(at(left))}, right ${show(at(right))})`,
);
magenta.setSplit('right');
await until(
() => !near(at(left), MAGENTA) && near(at(right), MAGENTA),
() =>
!nearColour(at(left), MAGENTA) && nearColour(at(right), MAGENTA),
'the right split drawn',
);
magenta.setSplit('none');
await until(
() => near(at(left), MAGENTA) && near(at(right), MAGENTA),
() =>
nearColour(at(left), MAGENTA) && nearColour(at(right), MAGENTA),
'the unsplit layer drawn',
);
} finally {
@@ -4283,7 +4286,8 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
await until(
() =>
[MAGENTA, GREEN, [0, 255, 255]].every(
(colour) => !near(meanAt(19.5, 10), colour),
(colour) =>
!nearColour(meanAt(engine, readRegion, 19.5, 10, 6), colour),
),
'every layer gone',
);
@@ -4479,29 +4483,7 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
async () => {
if (!readRegion) return;
await nadirView(1e6);
const MAGENTA = [255, 0, 255];
const GREEN = [0, 255, 0];
const screenOf = (lon, lat) =>
engine.projector.worldToWindow(cartesian3.fromDegrees(lon, lat));
const meanAt = (lon, lat, half = 5) => {
const { x, y } = screenOf(lon, lat);
const data = readRegion(
Math.round(x) - half,
Math.round(y) - half,
2 * half,
2 * half,
);
const mean = [0, 0, 0];
for (let i = 0; i < data.length; i += 4)
for (let k = 0; k < 3; k++) mean[k] += data[i + k];
return mean.map((sum) => sum / (data.length / 4));
};
const near = (actual, expected, tolerance = 40) =>
actual.every(
(value, k) => Math.abs(value - expected[k]) <= tolerance,
);
const show = (colour) =>
colour.map((value) => value.toFixed(0)).join(',');
await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded');
await settle(4);
// Every tile, whatever the app serves, painted magenta by the
@@ -4536,11 +4518,11 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
});
layers.push(tiles);
await until(
() => near(meanAt(19.5, 10), MAGENTA),
`the transformed tiles drawn (${show(meanAt(19.5, 10))})`,
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 5), MAGENTA),
`the transformed tiles drawn (${show(meanAt(engine, readRegion, 19.5, 10, 5))})`,
);
ensure(
!near(meanAt(21, 10), MAGENTA),
!nearColour(meanAt(engine, readRegion, 21, 10, 5), MAGENTA),
'the tiles spill outside their rectangle',
);
// Staged: green replaces the magenta tiles once it is drawn.
@@ -4553,8 +4535,8 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
await until(() => swapped !== undefined, 'the staged image');
ensure(swapped === true, 'the staged image failed');
await until(
() => near(meanAt(19.5, 10), GREEN),
`the staged image drawn (${show(meanAt(19.5, 10))})`,
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 5), GREEN),
`the staged image drawn (${show(meanAt(engine, readRegion, 19.5, 10, 5))})`,
);
tiles.destroy();
// Raised 6 km: drawn over the box (a little parallax at 1000 km),
@@ -4570,8 +4552,8 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
});
layers.push(raisedLayer);
await until(
() => near(meanAt(19.6, 10), MAGENTA),
`the raised layer drawn (${show(meanAt(19.6, 10))})`,
() => nearColour(meanAt(engine, readRegion, 19.6, 10, 5), MAGENTA),
`the raised layer drawn (${show(meanAt(engine, readRegion, 19.6, 10, 5))})`,
);
raisedLayer.setDetail({
image: flat([...GREEN, 255]),
@@ -4579,13 +4561,13 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
});
await until(
() =>
near(meanAt(19.25, 10), GREEN) &&
near(meanAt(19.75, 10), MAGENTA),
`the detail drawn (west ${show(meanAt(19.25, 10))}, east ${show(meanAt(19.75, 10))})`,
nearColour(meanAt(engine, readRegion, 19.25, 10, 5), GREEN) &&
nearColour(meanAt(engine, readRegion, 19.75, 10, 5), MAGENTA),
`the detail drawn (west ${show(meanAt(engine, readRegion, 19.25, 10, 5))}, east ${show(meanAt(engine, readRegion, 19.75, 10, 5))})`,
);
raisedLayer.setDetail(null);
await until(
() => near(meanAt(19.25, 10), MAGENTA),
() => nearColour(meanAt(engine, readRegion, 19.25, 10, 5), MAGENTA),
'the detail cleared',
);
} finally {
@@ -4593,7 +4575,8 @@ function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
}
await until(
() =>
!near(meanAt(19.5, 10), MAGENTA) && !near(meanAt(19.5, 10), GREEN),
!nearColour(meanAt(engine, readRegion, 19.5, 10, 5), MAGENTA) &&
!nearColour(meanAt(engine, readRegion, 19.5, 10, 5), GREEN),
'every layer gone',
);
},
-159
View File
@@ -57,41 +57,6 @@ test('the fake records contract calls for shell-module tests', () => {
assert.equal(engine.fake.renderRequests, 0);
});
test('fake flights advance with frames and finish or cancel exactly once', () => {
const engine = createFakeEngine();
const destination = geo.cartesian3.fromDegrees(30, 40, 5000);
const outcomes = [];
engine.camera.flyTo({
destination,
duration: 1,
complete: () => outcomes.push('complete'),
cancel: () => outcomes.push('cancel'),
});
assert.equal(engine.camera.flightActive(), true);
engine.fake.frames(30, 16);
assert.deepEqual(outcomes, []);
engine.fake.frames(40, 16);
assert.deepEqual(outcomes, ['complete']);
assert.ok(
geo.cartesian3.distance(engine.camera.pose().position, destination) < 1e-6,
);
engine.camera.cancelFlight();
assert.deepEqual(outcomes, ['complete']);
engine.camera.flyTo({
destination: geo.cartesian3.fromDegrees(0, 0, 1e6),
cancel: () => outcomes.push('cancel'),
});
engine.camera.flyTo({
destination,
duration: 0,
complete: () => outcomes.push('second'),
});
// Duration 0 lands and completes before flyTo returns, as in Cesium.
assert.deepEqual(outcomes, ['complete', 'cancel', 'second']);
assert.equal(engine.camera.flightActive(), false);
});
test('fake events can be emitted by hand and frames fire in order', () => {
const engine = createFakeEngine();
const seen = [];
@@ -190,25 +155,6 @@ test('fake credits, maps and destroy keep inspectable state', () => {
assert.equal(engine.fake.destroyed, true);
});
test('fake lookAtTransform keeps the transform until release', () => {
const engine = createFakeEngine();
const target = geo.cartesian3.fromDegrees(10, 10, 0);
const frame = geo.transforms.eastNorthUpToFixedFrame(target);
engine.camera.lookAtTransform(frame, {
heading: 0,
pitch: -0.5,
range: 1000,
});
assert.deepEqual(engine.camera.transform(), [...frame]);
engine.camera.transform()[0] = 42;
assert.notEqual(engine.camera.transform()[0], 42);
engine.camera.release();
assert.deepEqual(engine.camera.transform(), [...geo.matrix4.IDENTITY]);
engine.camera.lookAt(target, { heading: 0, pitch: -0.5, range: 1000 });
assert.deepEqual(engine.camera.transform(), [...frame]);
assert.deepEqual(engine.fake.lookAtTarget(), target);
});
test('fake input honours modifiers like Cesium: unmodified listeners skip modified input', () => {
const engine = createFakeEngine();
const seen = [];
@@ -292,33 +238,6 @@ test('fake isDestroyed, commanded and pickGlobe are inspectable', () => {
assert.equal(engine.isDestroyed(), true);
});
test('the fake clock is the app clock the test controls, latched per frame', () => {
const start = Date.UTC(2026, 9, 16, 12);
const engine = createFakeEngine({ nowMs: start });
const seen = [];
for (const name of ['preUpdate', 'preRender', 'postRender'])
engine.frames[name].addEventListener(() =>
seen.push([name, engine.clock.frameTime()]),
);
assert.equal(engine.clock.now(), start);
engine.fake.frame(16);
assert.deepEqual(seen, [
['preUpdate', start + 16],
['preRender', start + 16],
['postRender', start + 16],
]);
// Time moves between frames; the frame time only at the next frame.
engine.fake.advanceTime(1000);
assert.equal(engine.clock.now(), start + 1016);
assert.equal(engine.clock.frameTime(), start + 16);
engine.fake.frame(16);
assert.equal(engine.clock.frameTime(), start + 1032);
// The app clock's controls work through engine.fake.appClock.
engine.fake.appClock.freeze(start);
engine.fake.frame(16);
assert.equal(engine.clock.frameTime(), start);
});
test('a fake engine can share an app clock', async () => {
const { createAppClock } = await import('../../time/clock.js');
let wall = 5000;
@@ -387,81 +306,3 @@ test('fake lines keep their positions, colour, matrix and visibility', () => {
line.destroy();
assert.equal(engine.fake.line('trail'), undefined);
});
test('input listeners asking for picks get the pick and the drill in the payload', () => {
const engine = createFakeEngine();
engine.fake.script('pick', (screen) =>
screen.x === 10
? { id: 'ae01ce', owner: 'flights', instance: 3 }
: undefined,
);
engine.fake.script('drillPick', (screen, limit) =>
[{ id: 'a' }, { id: 'b' }, { id: 'c' }].slice(0, limit),
);
const seen = [];
engine.input.on('click', (event) => seen.push(['plain', event]));
engine.input.on('click', (event) => seen.push(['pick', event]), {
pick: true,
drill: 2,
});
engine.input.on('move', (event) => seen.push(['hover', event]), {
pick: true,
});
engine.fake.input('click', { position: { x: 10, y: 5 } });
engine.fake.input('move', {
startPosition: { x: 0, y: 0 },
endPosition: { x: 10, y: 5 },
});
engine.fake.input('click', { position: { x: 99, y: 5 } });
assert.deepEqual(seen[0], ['plain', { position: { x: 10, y: 5 } }]);
assert.deepEqual(seen[1][1].picked, {
id: 'ae01ce',
owner: 'flights',
instance: 3,
});
assert.deepEqual(seen[1][1].drilled, [{ id: 'a' }, { id: 'b' }]);
assert.deepEqual(seen[2][1].picked.id, 'ae01ce');
assert.equal(seen[2][1].drilled, undefined, 'no drill unless asked');
assert.equal(seen[4][1].picked, undefined);
assert.throws(
() => engine.input.on('click', () => {}, { drill: -1 }),
TypeError,
);
assert.throws(
() => engine.input.on('up', () => {}, { pick: true }),
TypeError,
);
});
test('input rejects invalid pick windows and forwards copied windows on down and move', () => {
const engine = createFakeEngine();
for (const pickWindow of [
null,
{},
{ width: 0, height: 9 },
{ width: 9, height: 0.5 },
{ width: Infinity, height: 1 },
])
assert.throws(
() => engine.input.on('click', () => {}, { pick: true, pickWindow }),
TypeError,
);
assert.throws(
() =>
engine.input.on('click', () => {}, {
pickWindow: { width: 9, height: 9 },
}),
TypeError,
);
const picked = [];
engine.fake.script('pick', (at, w, h) => ({ id: `${w}:${h}` }));
const pickWindow = { width: 11, height: 7 };
engine.input.on('down', (event) => picked.push(event.picked.id), {
pick: true,
pickWindow,
});
pickWindow.width = 1;
engine.fake.input('down', { position: { x: 2, y: 3 } });
assert.deepEqual(picked, ['11:7']);
engine.destroy();
});
+4 -26
View File
@@ -1,16 +1,12 @@
// The air-quality stations on the engine contract, on the fake engine and
// on the Cesium engine (its ground batches over fake primitives): a 6 km
// disc per reading in its band colour at 0.35, no outline, picks, the view
// rectangle the readout reads, visibility through the layer and teardown.
// Layer display behavior on the fake engine.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
GROUND_HARNESSES,
createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs';
import { rgbaFromCss } from '../../shared/color.js';
import { createAirQualityLayer } from '../index.js';
@@ -95,28 +91,10 @@ for (const makeHarness of GROUND_HARNESSES) {
);
});
assert.equal(f.entries.at(-1)[0], 'air-quality', 'labels published');
if (f.cesium) {
const { fills, lines } = f.primitives('air-quality');
assert.equal(fills.length, 1, 'one ground batch');
assert.equal(lines.length, 0, 'no outline batch');
assert.equal(
fills[0].options.classificationType,
Cesium.ClassificationType.BOTH,
);
fills[0].options.geometryInstances.forEach((instance, index) => {
assert.ok(instance.geometry instanceof Cesium.EllipseGeometry);
assert.equal(instance.geometry._semiMajorAxis, RADIUS_M);
const expected = rgbaFromCss(readings[index].color)
.withAlpha(0.35)
.rgba();
instanceColor(instance).forEach((channel, at) =>
near(channel, expected[at], `colour ${index}.${at}`, 0.5 / 255),
);
});
}
f.layer.disable();
assert.equal(f.set('air-quality').shown, false);
if (f.cesium) assert.equal(f.collectionShown('air-quality'), false);
f.layer.enable();
assert.equal(f.set('air-quality').shown, true);
} finally {
+1 -1
View File
@@ -15,4 +15,4 @@ export * from '../../data/motionModel.js';
export * from '../../data/renderAltitude.js';
export * from '../../data/routePlausible.js';
export * from '../../data/trackedModelRegime.js';
export * from '../../data/trackingClickGesture.js';
export * from '../../data/trackingGesture.js';
+22 -219
View File
@@ -1,3 +1,4 @@
import { createFakeGroundHarness as fakeHarness } from '../../../testSupport/groundDisplayEngines.mjs';
// The aircraft display on the engine contract, on the fake engine and on the
// Cesium engine (real Cesium collections, billboards, models and
// polylines over a stub scene: they draw nothing but hold what the engine
@@ -10,7 +11,7 @@ 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 { aircraftIcon, TRACKED_ICON_PX } from '../../../data/aircraftIcons.js';
import { CYAN, WHITE, nearFarScale, rgbaFromCss } from '../../shared/color.js';
import {
@@ -33,28 +34,7 @@ const AXIS = Cesium.Matrix4.multiplyTransformation(
);
const SCALE_BY_DISTANCE = nearFarScale(1000, 3.0, 8000000, 0.5);
function fakeHarness() {
const engine = createFakeEngine();
return {
name: 'the fake engine',
engine,
frame: () => engine.fake.frame(),
destroy: () => engine.destroy(),
};
}
function cesiumHarness() {
const { engine, ...rest } = createCesiumMoversEngine();
return {
name: 'the Cesium engine',
cesium: true,
engine,
...rest,
destroy: () => engine.destroy(),
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
const HARNESSES = [fakeHarness];
async function setup(makeHarness, options = {}) {
const harness = makeHarness();
@@ -149,34 +129,7 @@ for (const makeHarness of HARNESSES) {
await settle();
const [listed] = setsOf(engine, 'flights-sprites');
assert.equal(listed.count, 1);
if (h.cesium) {
const collection = h.primitive(listed.id);
assert.ok(collection instanceof Cesium.BillboardCollection);
const billboard = collection.get(0);
assert.ok(Cesium.Cartesian3.equals(billboard.position, spec.position));
assert.equal(billboard.id, 'abc123');
near(billboard.scale, 1.5, 'scale');
near(billboard.rotation, 0.3, 'rotation');
assert.equal(billboard.width, 20);
assert.equal(billboard.height, 20);
assert.equal(
billboard.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.ok(
Cesium.NearFarScalar.equals(
billboard.scaleByDistance,
new Cesium.NearFarScalar(1000, 3, 8000000, 0.5),
),
);
assert.ok(
billboard.color.equalsEpsilon(
Cesium.Color.fromCssColorString('#FFB800'),
1e-6,
),
);
assert.equal(billboard.show, true);
} else {
{
const set = engine.fake.moverSet(listed.id);
assert.equal(set.options.depthTest, 'always');
assert.deepEqual(
@@ -195,9 +148,7 @@ for (const makeHarness of HARNESSES) {
near(store.colors[1], 184 / 255, 'green');
}
// Writes inside a frame listener commit at its end, once.
const commits = h.cesium
? null
: () => engine.fake.moverSet(listed.id).commits;
const commits = () => engine.fake.moverSet(listed.id).commits;
const before = commits?.();
const remove = display.view.on('preRender', () => {
sprite.color = WHITE.withAlpha(0.45);
@@ -210,12 +161,7 @@ for (const makeHarness of HARNESSES) {
remove();
if (commits) assert.equal(commits(), before + 1);
assert.equal(sprite.color.alpha, 0.45);
if (h.cesium) {
const billboard = h.primitive(listed.id).get(0);
near(billboard.color.alpha, 0.45, 'alpha');
near(billboard.scale, 2.4, 'scale × width/20');
assert.equal(billboard.show, false);
} else {
{
const { store } = engine.fake.moverSet(listed.id);
near(store.colors[3], 0.45, 'alpha');
near(store.scales[0], 2.4, 'scale × width/20');
@@ -233,9 +179,7 @@ for (const makeHarness of HARNESSES) {
await settle();
assert.equal(setsOf(engine, 'flights-sprites')[0].count, 1);
assert.equal(other.gevIndex, 0);
if (h.cesium) assert.equal(h.primitive(listed.id).get(0).id, 'def456');
else
assert.equal(engine.fake.moverSet(listed.id).store.pickIds[0], 'def456');
assert.equal(engine.fake.moverSet(listed.id).store.pickIds[0], 'def456');
// Another depth mode is another set.
other.disableDepthTestDistance = 0;
await settle();
@@ -288,16 +232,7 @@ for (const makeHarness of HARNESSES) {
await settle();
const [listed] = setsOf(engine, 'flights-models');
assert.equal(listed.count, 1);
if (h.cesium) {
const [instance] = h.loads;
for (let index = 0; index < 12; index++)
near(instance.modelMatrix[index], drawn[index], `drawn ${index}`);
assert.equal(instance.show, false, 'admitted hidden');
assert.equal(instance.id, 'abc123');
assert.equal(model.ready, false, 'until the model has loaded');
instance.ready = true;
assert.equal(model.ready, true);
} else {
{
const q = Cesium.Quaternion.fromRotationMatrix(
Cesium.Matrix4.getMatrix3(drawn, new Cesium.Matrix3()),
);
@@ -311,13 +246,11 @@ for (const makeHarness of HARNESSES) {
}
model.show = true;
await settle();
if (h.cesium) assert.equal(h.loads[0].show, true);
else
assert.equal(
engine.fake.moverSet(listed.id).store.shows[0],
1,
'shown once placed',
);
assert.equal(
engine.fake.moverSet(listed.id).store.shows[0],
1,
'shown once placed',
);
assert.ok(model.computedScale >= model.scale);
// A per-model nose offset (military per-class specs).
display.models.setPose(model, position, 75, 90);
@@ -366,15 +299,6 @@ for (const makeHarness of HARNESSES) {
const [listed] = setsOf(engine, 'flights-tracked');
assert.equal(listed.count, 1);
const read = () => {
if (h.cesium) {
const billboard = h.primitive(listed.id).get(0);
return {
x: billboard.position.x,
alpha: billboard.color.alpha,
rotation: billboard.rotation,
id: billboard.id,
};
}
const { store } = engine.fake.moverSet(listed.id);
return {
x: store.positions[0],
@@ -385,15 +309,7 @@ for (const makeHarness of HARNESSES) {
};
assert.equal(read().id, handle.id);
near(read().rotation, 0.25, 'rotation');
if (h.cesium) {
const billboard = h.primitive(listed.id).get(0);
assert.equal(
billboard.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.equal(billboard.width, 20);
near(billboard.scale, 1.2 * (28 / 20), 'scale × width/20');
}
state.position = geo.cartesian3.fromDegrees(-97.6, 30.2, 3000);
state.color = CYAN.withAlpha(0);
h.frame();
@@ -409,39 +325,19 @@ for (const makeHarness of HARNESSES) {
assert.equal(following.label.model(), handle.gevLabelModel);
assert.equal(display.follow.isFollowing(handle), true);
assert.equal(display.follow.current(), handle);
if (h.cesium) {
const proxy = h.viewer.trackedEntity;
assert.ok(proxy instanceof Cesium.Entity);
assert.equal(
proxy.trackingReferenceFrame,
Cesium.TrackingReferenceFrame.ENU,
);
assert.equal(proxy.gevTrackedId, 'flights:abc123');
const time = h.viewer.clock.currentTime;
assert.ok(
Cesium.Cartesian3.equals(proxy.viewFrom.getValue(time), {
x: 0,
y: -2400,
z: 1650,
}),
);
}
// The glyph draws where the follow centred in its frame.
const centred = state.position;
assert.equal(following.target(), centred);
state.position = geo.cartesian3.fromDegrees(-97.5, 30.2, 3000);
if (h.cesium) {
h.frame();
assert.equal(read().x, centred.x, "the follow's sample");
h.frame();
} else h.frame();
h.frame();
assert.equal(read().x, state.position.x);
display.follow.release();
assert.equal(engine.camera.following(), null);
assert.equal(display.follow.current(), undefined);
assert.equal(display.follow.isFollowing(handle), false);
if (h.cesium) assert.equal(h.viewer.trackedEntity, undefined);
handle.resumeAircraftFollow();
assert.equal(display.follow.current(), handle);
display.tracked.remove(handle);
@@ -466,10 +362,7 @@ for (const makeHarness of HARNESSES) {
h.frame();
await settle();
const [listed] = setsOf(engine, 'flights-tracked');
const drawn = () =>
h.cesium
? h.primitive(listed.id).get(0).show
: engine.fake.moverSet(listed.id).store.shows[0] === 1;
const drawn = () => engine.fake.moverSet(listed.id).store.shows[0] === 1;
assert.equal(drawn(), true);
// The Cockpit hides what the engine follows, then releases the follow.
const ridden = engine.camera.following().native;
@@ -486,10 +379,7 @@ for (const makeHarness of HARNESSES) {
const moved = setsOf(engine, 'flights-tracked').find(
({ count }) => count === 1,
);
const shownIn = (set) =>
h.cesium
? h.primitive(set.id).get(0).show
: engine.fake.moverSet(set.id).store.shows[0] === 1;
const shownIn = (set) => engine.fake.moverSet(set.id).store.shows[0] === 1;
assert.equal(shownIn(moved), false);
ridden.show = true;
ridden.resumeAircraftFollow();
@@ -508,16 +398,8 @@ for (const makeHarness of HARNESSES) {
const remove = display.follow.onChanged(() =>
changes.push(display.follow.current()),
);
const native = h.cesium
? new Cesium.Entity({
position: geo.cartesian3.fromDegrees(0, 0, 400_000),
viewFrom: new Cesium.Cartesian3(0, -900, 400),
})
: { gevSelectionOrigin: 'user' };
if (h.cesium) {
native.gevSelectionOrigin = 'user';
h.viewer.entities.add(native);
}
const native = { gevSelectionOrigin: 'user' };
engine.camera.follow({
native,
position: () => geo.cartesian3.fromDegrees(0, 0, 400_000),
@@ -545,19 +427,6 @@ for (const makeHarness of HARNESSES) {
assert.equal(body.points, 2, 'near-duplicates dropped');
const base = Cesium.Color.fromCssColorString('#00d4ff');
const alphas = (id) => {
if (h.cesium) {
const primitive = h.primitive(id);
assert.equal(primitive.allowPicking, true);
assert.equal(
primitive.geometryInstances.geometry._arcType,
Cesium.ArcType.GEODESIC,
);
assert.equal(primitive.geometryInstances.id, id);
return [
primitive.appearance.material.uniforms.color.alpha,
primitive.depthFailAppearance.material.uniforms.color.alpha,
];
}
const { options } = engine.fake.line(id);
assert.equal(options.arc, 'geodesic');
assert.equal(options.pickId, id);
@@ -567,10 +436,7 @@ for (const makeHarness of HARNESSES) {
const [bodyAlpha, bodyHidden] = alphas(body.id);
near(bodyAlpha, 0.85, 'trail alpha');
near(bodyHidden, 0.4, 'trail occluded alpha');
if (h.cesium) {
const { color } = h.primitive(body.id).appearance.material.uniforms;
near(color.red, base.red, 'trail red');
}
trail.setPositions([a]);
assert.equal(engine.lines.list()[0].points, 0, 'one point draws nothing');
trail.setVisible(false);
@@ -785,66 +651,3 @@ test('reused sprite field records are copied, including a mutated color getter',
display.destroy();
destroy();
});
test("model loads on Cesium carry the layer's look; the IR boost is unlit; readiness is per model", async () => {
const h = await setup(cesiumHarness);
const { display, loads } = h;
const spec = {
url: '/models/airplane.glb',
minimumPixelSize: 24,
scale: 1,
color: CYAN,
blendAmount: 0.94,
unlit: false,
id: 'abc123',
};
const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000);
const fleet = await display.models.loadFleet(spec);
const second = await display.models.loadFleet({ ...spec, id: 'def456' });
const boosted = await display.models.loadTracked({
...spec,
unlit: true,
minimumPixelSize: 40,
id: 'ghi789',
});
for (const model of [fleet, second, boosted]) {
display.models.add(model);
display.models.setPose(model, position, 10);
}
await settle();
assert.equal(loads.length, 3, 'one Cesium model per instance');
const [first, other, unlit] = loads;
for (const { options } of loads) {
assert.equal(options.asynchronous, false);
assert.deepEqual(options.environmentMapOptions, { enabled: false });
assert.equal(options.url, '/models/airplane.glb');
assert.equal(options.colorBlendMode, Cesium.ColorBlendMode.MIX);
assert.equal(options.colorBlendAmount, 0.94);
assert.equal(options.allowPicking, true);
}
assert.equal(first.options.minimumPixelSize, 24);
assert.equal(first.options.customShader, undefined);
assert.equal(unlit.options.minimumPixelSize, 40);
assert.equal(
unlit.options.customShader.lightingModel,
Cesium.LightingModel.UNLIT,
);
assert.equal(first.id, 'abc123', 'picks name the aircraft');
assert.equal(unlit.id, 'ghi789');
assert.ok(first.color.equalsEpsilon(Cesium.Color.CYAN, 1e-6));
// Readiness is each model's own: one loading model leaves the others up.
first.ready = true;
unlit.ready = true;
assert.equal(fleet.ready, true);
assert.equal(second.ready, false);
assert.equal(boosted.ready, true);
other.ready = true;
assert.equal(second.ready, true);
// A removed model's place goes to the last one, ready with its model.
display.models.remove(fleet);
await settle();
assert.equal(second.gevIndex, 0);
assert.equal(second.ready, true);
display.destroy();
h.destroy();
});
+17 -26
View File
@@ -1,3 +1,7 @@
import {
createSelectionKeeper,
writeSelectedMarker,
} from '../../shared/selectionKeeper.js';
// The ALPR camera badges on the globe contract (engine.movers, engine.input,
// engine.camera, engine.projector, engine.surface, engine.credits), the
// same on every engine. Each displayed camera is a plain marker handle: a
@@ -14,7 +18,6 @@
import * as geo from '../../../geo/index.js';
import { distanceScale } from '../../../globe/cpuPicks.js';
import { isPointerFree } from '../../../data/inputOwnership.js';
import { rgbaFromCss } from '../../shared/color.js';
import { alprCreditMarkup } from '../model.js';
import { createAlprOverlayCore } from '../overlay.js';
@@ -196,8 +199,7 @@ export function createAlprDisplay(engine, { state, services, source }) {
};
normal.set(marker.index, { ...fields, show: show && !isSelected });
if (isSelected) {
selected.setCount(1);
selected.set(0, fields);
writeSelectedMarker(selected, fields);
}
// The overlay changes ownership while it paints, after the frame's
// render request was taken: wake the next frame, which commits.
@@ -215,7 +217,7 @@ export function createAlprDisplay(engine, { state, services, source }) {
}
function dropSelectedSprite() {
if (!initialized) return;
selected.setCount(0);
writeSelectedMarker(selected, null);
dirty = true;
}
@@ -378,7 +380,6 @@ export function createAlprDisplay(engine, { state, services, source }) {
function renderRecords() {
if (!initialized) return;
const selectedContext = getSelectedEntityContext();
// Over the render cap, keep the cameras nearest the view centre and say
// coverage is limited rather than dropping an arbitrary subset.
const { visible: shown, capped } = viewRecords(state.records);
@@ -386,13 +387,7 @@ export function createAlprDisplay(engine, { state, services, source }) {
visibleRecords = shown;
// A refresh may retain its own selection, never reclaim one cleared or
// replaced by an aircraft, another layer, or a voice action.
if (
selectedContext?.layerId !== LAYER_ID ||
selectedContext.id !== state.selectedId ||
!shown.some((record) => record.id === state.selectedId)
) {
state.selectedId = null;
selectionStartedAt = 0;
if (!selection.reconcile({ layerId: LAYER_ID, records: shown })) {
dropSelectedSprite();
clearSelectedEntityContextForLayer(LAYER_ID);
}
@@ -595,21 +590,17 @@ export function createAlprDisplay(engine, { state, services, source }) {
return true;
}
function onClick({ position, picked }) {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!state.enabled) return;
const id =
const selection = createSelectionKeeper({
state,
getSelectedEntityContext,
selectRecord,
clearSelection,
pickId: ({ position, picked }) =>
overlay.pick(position) ||
(typeof picked?.id === 'string' ? picked.id : null);
if (
id &&
state.recordById.has(id) &&
(id !== state.selectedId || getSelectedEntityContext()?.id !== id)
)
selectRecord(id);
else if (state.selectedId) clearSelection();
}
(typeof picked?.id === 'string' ? picked.id : null),
reselectContext: true,
});
const onClick = selection.onClick;
// Badges move on screen with the camera: their horizon test and a pick
// target's size follow it.
+10 -107
View File
@@ -1,20 +1,14 @@
// The ALPR badges on the engine contract, on the fake engine and on the
// Cesium engine (real Cesium billboards over a stub scene: they draw
// nothing but hold what the engine writes): the badge sets as the layer
// styles them, floors and the horizon, clicks, selection and its readout
// anchor, the render cap, credits, the nearest-camera flight, the canvas
// overlay's cohort and pick targets, and what toggles and teardown leave.
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { setLocale } from '../../../i18n/index.js';
import { shownEntityLabel } from '../../../voice/speech.js';
import { distanceScale } from '../../../globe/cpuPicks.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createFakeEventHandlers } from '../../../globe/cesium/testing/fakeViewer.mjs';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { MARKER_SCALE_BY_DISTANCE } from '../policy.js';
import { directionWedgePositions } from '../visuals.js';
import { createAlprDisplay } from './display.js';
@@ -69,81 +63,7 @@ function fakeHarness() {
};
}
function cesiumHarness() {
const handlers = createFakeEventHandlers();
const { engine, viewer, primitive, frame } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
},
});
const credits = [];
viewer.creditDisplay = {
addStaticCredit: (credit) =>
credits.push({
html: credit.html,
onScreen: credit.showOnScreen,
credit,
}),
removeStaticCredit(credit) {
const at = credits.findIndex((entry) => entry.credit === credit);
if (at >= 0) credits.splice(at, 1);
},
};
viewer.scene.globe.getHeight = () => undefined;
// What a camera flight needs of the scene.
viewer.scene.tweens = new Cesium.TweenCollection();
viewer.scene.screenSpaceCameraController = {
minimumZoomDistance: 1,
maximumZoomDistance: Number.POSITIVE_INFINITY,
};
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,
primitive,
frame,
click(target) {
native =
target?.owner && primitive(target.owner)
? { primitive: primitive(target.owner).get(target.instance) }
: target
? { id: target.id, primitive: {} }
: undefined;
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(-50, -50),
});
},
credits: () => credits,
surface(height) {
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => height;
},
terrain(height) {
viewer.scene.globe.getHeight = () => height;
},
badge(id, index) {
const billboard = primitive(id).get(index);
return {
shown: index < count(id) && billboard.show,
sizePx: billboard.width,
alpha: billboard.color.alpha,
scale: billboard.scale,
id: billboard.id,
position: billboard.position,
native: billboard,
};
},
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
const HARNESSES = [fakeHarness];
function services() {
const contexts = new Map();
@@ -252,23 +172,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(badge.id, 'alpr:0', 'a pick returns the camera id');
assert.equal(badge.alpha, 1);
assert.ok(Math.abs(heightOf(badge.position) - 151.5) < 0.01);
if (h.cesium) {
const billboard = badge.native;
assert.ok(
h.primitive('alpr-cameras') instanceof Cesium.BillboardCollection,
);
assert.equal(billboard.height, 38);
assert.equal(
billboard.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.ok(
Cesium.NearFarScalar.equals(
billboard.scaleByDistance,
new Cesium.NearFarScalar(...MARKER_SCALE_BY_DISTANCE),
),
);
} else {
{
for (const [id, size, url] of [
['alpr-cameras', 38, /alpr-marker-normal\.png$/],
['alpr-selected', 60, /alpr-marker-selected\.png$/],
@@ -393,11 +297,10 @@ for (const makeHarness of HARNESSES) {
h.load(records(1600));
assert.equal(h.display.count(), 1500);
assert.equal(h.state.renderSaturated, true);
if (!h.cesium)
assert.ok(
h.engine.fake.moverSet('alpr-cameras').commits < 10,
'one buffer commit per batch, not per marker',
);
assert.ok(
h.engine.fake.moverSet('alpr-cameras').commits < 10,
'one buffer commit per batch, not per marker',
);
const credit = h.credits().find((c) => /Fixture cameras/.test(c.html));
assert.ok(credit, 'the source credit shows with the cameras');
assert.equal(credit.onScreen, true);
@@ -416,7 +319,7 @@ for (const makeHarness of HARNESSES) {
follow.stop();
assert.equal(h.display.focusNearest(), true);
assert.equal(h.services.selected.id, 'alpr:0');
if (!h.cesium) {
{
const [{ args }] = h.engine.fake.callsOf('camera.flyToSphere');
assert.equal(args[0].radius, 30);
assert.ok(Math.abs(heightOf(args[0].center) - 0) < 0.01, 'no ground');
+18 -64
View File
@@ -1,30 +1,14 @@
// The Global Context display on the engine contract, on the fake engine and
// on the Cesium engine: the camera it reads, the followed contact, a contact
// flight, its frames, the ring lines and the pulse field.
// Layer display behavior on the fake engine.
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 { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { createFakeGroundHarness } from '../../../testSupport/groundDisplayEngines.mjs';
import { createAwarenessDisplay, RING_HALO_ID, RING_ID } from './display.js';
const HARNESSES = [
() => ({ name: 'the fake engine', engine: createFakeEngine() }),
() => {
const { engine, viewer, primitive } = createCesiumMoversEngine();
// A viewer's camera controller, whose inputs the engine switches.
viewer.scene.screenSpaceCameraController = { enableInputs: true };
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
primitive,
};
},
];
const HARNESSES = [createFakeGroundHarness];
for (const makeHarness of HARNESSES) {
const { name } = makeHarness();
@@ -81,10 +65,7 @@ for (const makeHarness of HARNESSES) {
ends = 0;
const offFrame = display.onFrame(() => frames++);
const offEnd = display.onFrameEnd(() => ends++);
if (h.cesium) {
h.viewer.scene.preRender.raiseEvent(h.viewer.scene);
h.viewer.scene.postRender.raiseEvent(h.viewer.scene);
} else h.engine.fake.frame();
h.engine.fake.frame();
assert.deepEqual([frames, ends], [1, 1]);
offFrame();
offEnd();
@@ -112,46 +93,19 @@ for (const makeHarness of HARNESSES) {
const center = geo.cartesian3.fromDegrees(3, 27, 0);
const rim = display.createRim(center);
const disc = display.createPulses(center);
if (h.cesium) {
const core = h.primitive(RING_ID);
const halo = h.primitive(RING_HALO_ID);
assert.equal(core.geometryInstances.geometry._width, 3);
assert.equal(halo.geometryInstances.geometry._width, 4);
assert.equal(core.allowPicking, false);
assert.ok(disc, 'the Cesium backend draws the pulse disc');
const primitives = Array.from(
{ length: h.viewer.scene.primitives.length },
(_, i) => h.viewer.scene.primitives.get(i),
);
const shown = primitives.find(
(p) => p.appearance instanceof Cesium.MaterialAppearance,
);
assert.equal(shown.allowPicking, false);
disc.draw({ radii: [0.5, 0, 0, 0], alphas: [0.4, 0, 0, 0], level: 1 });
assert.equal(shown.show, true);
assert.equal(shown.appearance.material.uniforms.radii.x, 0.5);
disc.draw({ radii: [0, 0, 0, 0], alphas: [0, 0, 0, 0], level: 0 });
assert.equal(shown.show, false);
disc.destroy();
assert.ok(shown.isDestroyed());
} else {
assert.equal(h.engine.fake.line(RING_ID).options.widthPx, 3);
assert.equal(h.engine.fake.line(RING_HALO_ID).options.widthPx, 4);
assert.ok(disc);
disc.draw({ radii: [0.5], alphas: [0.4], level: 1 });
const field = h.engine.fake.groundPulse('gev-awareness-pulses');
assert.deepEqual(field.radii, [0.5, 0, 0, 0]);
assert.deepEqual(field.alphas, [0.4, 0, 0, 0]);
disc.destroy();
assert.equal(
h.engine.fake.groundPulse('gev-awareness-pulses'),
undefined,
);
}
assert.equal(h.engine.fake.line(RING_ID).options.widthPx, 3);
assert.equal(h.engine.fake.line(RING_HALO_ID).options.widthPx, 4);
assert.ok(disc);
disc.draw({ radii: [0.5], alphas: [0.4], level: 1 });
const field = h.engine.fake.groundPulse('gev-awareness-pulses');
assert.deepEqual(field.radii, [0.5, 0, 0, 0]);
assert.deepEqual(field.alphas, [0.4, 0, 0, 0]);
disc.destroy();
assert.equal(h.engine.fake.groundPulse('gev-awareness-pulses'), undefined);
rim.setAlpha(0.4);
const alpha = h.cesium
? h.primitive(RING_ID).appearance.material.uniforms.color.alpha
: h.engine.fake.line(RING_ID).color[3];
const alpha = h.engine.fake.line(RING_ID).color[3];
assert.ok(Math.abs(alpha - 0.95 * 0.4) < 1e-9);
rim.destroy();
assert.equal(
+3 -19
View File
@@ -56,8 +56,7 @@ for (const make of GROUND_HARNESSES) {
});
const before = h.engine.frames.preRender.numberOfListeners;
const display = drawStorySource(h.engine, source);
const frame = () =>
h.cesium ? h.viewer.scene.preRender.raiseEvent() : h.engine.fake.frame();
const frame = () => h.engine.fake.frame();
frame();
assert.equal(h.sets(), 3);
const haloSet = h.set('story-halo');
@@ -87,21 +86,7 @@ for (const make of GROUND_HARNESSES) {
});
assert.equal(h.set('story-fill').items[0].rings.length, 2);
assert.equal(h.set('story-fill').items[0].fill[3], 0.055);
if (h.cesium) {
const native = h.primitives('story-halo').lines[0].options;
assert.equal(
native.asynchronous,
false,
'reveal geometry builds within the frame',
);
assert.equal(native.appearance.material.type, 'PolylineDash');
assert.equal(native.appearance.material.uniforms.dashLength, 18);
const core =
h.primitives('story-core').lines[0].options.appearance.material
.uniforms;
assert.equal(core.glowPower, 0.24);
assert.equal(core.taperPower, 0.7);
}
frame();
assert.equal(
h.set('story-halo').commits,
@@ -186,8 +171,7 @@ for (const make of GROUND_HARNESSES) {
return 1200;
};
const display = drawStorySource(h.engine, source);
const frame = () =>
h.cesium ? h.viewer.scene.preRender.raiseEvent() : h.engine.fake.frame();
const frame = () => h.engine.fake.frame();
frame();
assert.equal(options.kind, 'point');
assert.equal(options.depthTest, 'always');
+13 -9
View File
@@ -1,3 +1,4 @@
import { writeSelectedMarker } from '../../shared/selectionKeeper.js';
// The bikeshare stations on the globe contract (engine.movers, engine.input,
// engine.camera, engine.surface), the same on every engine:
// - rendering: one point per station in one point set, sized by capacity
@@ -120,15 +121,18 @@ export function createBikeshareDisplay(engine, context) {
const key = state._selectedKey;
const record = key ? state._stationRenderMap.get(key) : null;
const shown = Boolean(record?.point?.position);
if (shown)
highlight.set(0, {
position: record.point.position,
color: SELECTED_COLOR.rgba(),
scale: 1,
show: true,
pickId: key,
});
highlight.setCount(shown ? 1 : 0);
writeSelectedMarker(
highlight,
shown
? {
position: record.point.position,
color: SELECTED_COLOR.rgba(),
scale: 1,
show: true,
pickId: key,
}
: null,
);
highlight.commit();
}
// The stations show and hide together; the highlight goes with the
+34 -124
View File
@@ -1,17 +1,11 @@
// The bikeshare stations on the engine contract, on the fake engine and on
// the Cesium engine (real Cesium point collections over a stub scene: they
// draw nothing but hold what the engine writes): the station discs and the
// selected highlight as the layer styles them, their height on the sampled
// surface, the click and Escape rules, the camera's change listener and
// sensitivity claim, and what enable/disable cycles and teardown leave.
// Layer display behavior on the fake engine.
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 { createBikeshareLayer } from '../index.js';
import { capacityToPixelSize } from '../presentation.js';
import { COLOR_GREEN, COLOR_OUTLINE, COLOR_RED } from '../policy.js';
@@ -79,57 +73,7 @@ function fakeHarness() {
};
}
function cesiumHarness() {
const handlers = createFakeEventHandlers();
const { engine, viewer, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
},
});
const count = (id) => engine.movers.list().find((set) => set.id === id).count;
// A Cesium pick of `picked`'s set and instance: its point primitive.
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
return {
name: 'the Cesium engine',
cesium: true,
engine,
primitive,
click(picked) {
native = undefined;
if (picked?.owner?.startsWith('bikeshare'))
native = { primitive: primitive(picked.owner).get(picked.instance) };
else if (picked) native = { id: picked.id, primitive: {} };
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(100, 100),
});
},
surface(height) {
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => height;
},
point: (id, index) => {
const point = primitive(id).get(index);
return {
shown: index < count(id) && point.show,
pixelSize: point.pixelSize,
color: [
point.color.red,
point.color.green,
point.color.blue,
point.color.alpha,
],
id: point.id,
native: point,
};
},
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
const HARNESSES = [fakeHarness];
function fixture(makeHarness, getStations) {
const h = makeHarness();
@@ -204,47 +148,20 @@ for (const makeHarness of HARNESSES) {
COLOR_GREEN.rgba().forEach((value, i) =>
near(station.color[i], value, `colour ${i}`),
);
if (f.cesium) {
const collection = f.primitive('bikeshare');
assert.ok(collection instanceof Cesium.PointPrimitiveCollection);
const point = station.native;
assert.equal(point.outlineWidth, 1);
assert.ok(
point.outlineColor.equalsEpsilon(
new Cesium.Color(...COLOR_OUTLINE.rgba()),
1e-6,
),
);
assert.ok(
Cesium.NearFarScalar.equals(
point.scaleByDistance,
new Cesium.NearFarScalar(200, 1.35, 130000, 0.4),
),
);
assert.ok(
Cesium.NearFarScalar.equals(
point.translucencyByDistance,
new Cesium.NearFarScalar(200, 1, 180000, 0.15),
),
);
assert.equal(point.disableDepthTestDistance, 2500);
} else {
const { options } = f.engine.fake.moverSet('bikeshare');
assert.equal(options.pickable, true);
assert.equal(options.sharpen, true);
assert.equal(options.order, 3, 'its data/spriteOrder slot');
assert.deepEqual(options.depthTest, { disableWithinM: 2500 });
assert.equal(options.point.outlinePx, 1);
assert.deepEqual(options.point.outlineColor, COLOR_OUTLINE.rgba());
assert.deepEqual(
options.point.scaleByDistance,
[200, 1.35, 130000, 0.4],
);
assert.deepEqual(
options.point.translucencyByDistance,
[200, 1, 180000, 0.15],
);
}
const { options } = f.engine.fake.moverSet('bikeshare');
assert.equal(options.pickable, true);
assert.equal(options.sharpen, true);
assert.equal(options.order, 3, 'its data/spriteOrder slot');
assert.deepEqual(options.depthTest, { disableWithinM: 2500 });
assert.equal(options.point.outlinePx, 1);
assert.deepEqual(options.point.outlineColor, COLOR_OUTLINE.rgba());
assert.deepEqual(options.point.scaleByDistance, [200, 1.35, 130000, 0.4]);
assert.deepEqual(
options.point.translucencyByDistance,
[200, 1, 180000, 0.15],
);
f.setBikes(1);
await f.layer.update();
COLOR_RED.rgba().forEach((value, i) =>
@@ -267,22 +184,16 @@ for (const makeHarness of HARNESSES) {
assert.equal(highlight.shown, true);
near(highlight.pixelSize, 14, 'highlight size');
assert.deepEqual(highlight.color, [0, 1, 1, 1]);
if (f.cesium) {
const point = highlight.native;
assert.equal(point.outlineWidth, 2);
assert.ok(point.outlineColor.equals(Cesium.Color.BLACK));
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY);
assert.equal(point.scaleByDistance, undefined);
} else {
const { options } = f.engine.fake.moverSet('bikeshare-selected');
assert.equal(options.depthTest, 'always');
assert.equal(options.point.scaleByDistance, null);
assert.deepEqual(options.point.outlineColor, [0, 0, 0, 1]);
assert.ok(
options.order > f.engine.fake.moverSet('bikeshare').options.order,
'over the layer’s own points',
);
}
const { options } = f.engine.fake.moverSet('bikeshare-selected');
assert.equal(options.depthTest, 'always');
assert.equal(options.point.scaleByDistance, null);
assert.deepEqual(options.point.outlineColor, [0, 0, 0, 1]);
assert.ok(
options.order > f.engine.fake.moverSet('bikeshare').options.order,
'over the layer’s own points',
);
assert.equal(
f.entries.get('bikeshare-selected')[0].title,
'中央 / Central',
@@ -360,13 +271,12 @@ for (const makeHarness of HARNESSES) {
await settle();
const point = f.layer.getDetectableObjects()[0].position;
near(geo.cartographic.fromCartesian(point).height, 177, 'height');
if (!f.cesium) {
const call = f.engine.fake.callsOf('surface.sampleHeight')[0];
assert.deepEqual(
call.args[1].map((set) => set.id),
['bikeshare', 'bikeshare-selected'],
);
}
const call = f.engine.fake.callsOf('surface.sampleHeight')[0];
assert.deepEqual(
call.args[1].map((set) => set.id),
['bikeshare', 'bikeshare-selected'],
);
} finally {
f.layer.destroy();
}
+11 -17
View File
@@ -17,7 +17,10 @@
import * as geo from '../../../geo/index.js';
import { GIZMO_ID_PREFIX } from '../../../data/cctvGizmo.js';
import { CAMERA_ICON, IDLE_CAMERA_COLOR } from '../policy.js';
import { bindCctvWorldClickGesture } from '../worldClick.js';
import {
bindTrackingClickGesture,
isTrackingClickGesture,
} from '../../../data/trackingGesture.js';
import { createCctvMeshes } from './meshes.js';
import {
COVERAGE_DASH,
@@ -40,13 +43,6 @@ import {
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) =>
@@ -617,17 +613,15 @@ export function createCctvDisplay(engine, { state, services = {} } = {}) {
onMoveStart: (fn) =>
ownListener(engine.camera.moveStart.addEventListener(fn)),
bindClick(onClick, options = {}) {
const removes = [];
bindCctvWorldClickGesture(
{
setInputAction(fn, type) {
removes.push(engine.input.on(type, fn));
return ownListener(
bindTrackingClickGesture(
engine.input,
(click, gesture) => {
if (isTrackingClickGesture(gesture)) onClick(click);
},
},
onClick,
{ ...options, eventTypes: CLICK_EVENT_TYPES },
options,
),
);
return ownListener(() => removes.forEach((remove) => remove()));
},
createGizmoRenderer() {
const gizmo = createGizmoRenderer();
+8 -120
View File
@@ -1,14 +1,8 @@
// 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.
// Layer display behavior on the fake engine.
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';
@@ -90,30 +84,7 @@ for (const makeHarness of CCTV_HARNESSES) {
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');
@@ -178,14 +149,7 @@ for (const makeHarness of CCTV_HARNESSES) {
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');
@@ -213,21 +177,7 @@ for (const makeHarness of CCTV_HARNESSES) {
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);
@@ -237,12 +187,7 @@ for (const makeHarness of CCTV_HARNESSES) {
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);
@@ -354,23 +299,7 @@ for (const makeHarness of CCTV_HARNESSES) {
);
near(geo.cartesian3.distance(corners[index], expected), 0, 'corner');
}
if (h.cesium) {
const primitive = h.primitive('cctv-a-plane');
assert.equal(primitive.show, true);
assert.equal(primitive.appearance.material.uniforms.color.alpha, 0.95);
assert.equal(primitive.allowPicking, true);
h.scriptPicks({ native: { id: 'cctv-a-plane', 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');
@@ -394,7 +323,6 @@ for (const makeHarness of CCTV_HARNESSES) {
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) => {
@@ -422,37 +350,7 @@ for (const makeHarness of CCTV_HARNESSES) {
);
assert.equal(excludeObjects.length, 6);
const direction = p(0, 1, 0);
if (h.cesium) {
const primitive = h.primitive(volume.mesh.id);
assert.equal(primitive.allowPicking, false);
assert.equal(primitive.asynchronous, false);
assert.ok(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.equal(primitive.show, true, 'the excluded volume is restored');
assert.ok(query.exclusions.every((item) => typeof item !== 'string'));
h.display.removeVolume(volume);
assert.equal(h.primitive(volume.mesh.id), undefined);
} else {
{
assert.equal(
h.display.obstructionRay(POSITIONS.mount, direction, [], 30),
undefined,
@@ -558,16 +456,6 @@ for (const makeHarness of CCTV_HARNESSES) {
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',
);
}
});
}
+3 -41
View File
@@ -1,7 +1,4 @@
// 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.
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -185,40 +182,7 @@ for (const makeHarness of CCTV_HARNESSES) {
{ 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(
@@ -262,9 +226,7 @@ for (const makeHarness of CCTV_HARNESSES) {
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;
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');
+5 -17
View File
@@ -18,12 +18,7 @@ const positions = {
};
const identity = { x: 0, y: 0, z: 0, w: 1 };
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
for (const [name, make] of [
['fake', () => ({ engine: createFakeEngine() })],
['Cesium', createCesiumMoversEngine],
]) {
for (const [name, make] of [['fake', () => ({ engine: createFakeEngine() })]]) {
test(`CCTV mesh ownership, live image and geometry updates (${name})`, (t) => {
const h = make();
t.after(() => h.engine.destroy());
@@ -42,7 +37,7 @@ for (const [name, make] of [
assert.equal(meshes.planeFromPick({ id: plane.id }), null);
assert.equal(meshes.drawnBy(plane), plane.mesh);
assert.equal(meshes.drawnBy(volume), volume.mesh);
const image = new HTMLCanvasElement();
const image = {};
image.width = image.height = 8;
material.setImage(image);
const moved = {
@@ -50,16 +45,9 @@ for (const [name, make] of [
capCenter: { ...positions.capCenter, x: positions.capCenter.x + 3 },
};
meshes.updatePlane(plane, shape, moved, identity);
const state = h.engine.fake?.mesh(plane.id);
const primitive = h.primitive?.(plane.id);
const flat =
state?.positions ??
primitive.geometryInstances.geometry.attributes.position.values;
assert.equal(flat[0], moved.capCenter.x - shape.halfW);
assert.equal(
state?.image ?? primitive.appearance.material.uniforms.image,
image,
);
const state = h.engine.fake.mesh(plane.id);
assert.equal(state.positions[0], moved.capCenter.x - shape.halfW);
assert.equal(state.image, image);
assert.throws(
() => meshes.createPlane('b', {}, shape, positions, identity),
/not owned/,
-19
View File
@@ -1,19 +0,0 @@
// The CCTV world click: a tracking click over engine.input, shared by the
// display (render/display.js) and the application layer.
import {
bindTrackingClickGesture,
isTrackingClickGesture,
} from '../../data/trackingClickGesture.js';
/** 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,
);
}
+3 -64
View File
@@ -1,11 +1,10 @@
// The cyclone display, each case on the fake engine and on a headless Cesium
// engine (src/testSupport/cesiumMoversEngine.mjs).
// Layer display behavior on the fake engine.
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 { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { rgbaFromCss } from '../../shared/color.js';
import { createCyclonesDisplay, cycloneSphereVisible } from './display.js';
import {
@@ -93,47 +92,6 @@ const ENGINES = {
preRenderListeners: null,
};
},
cesium() {
const harness = createCesiumMoversEngine();
harness.viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
const { engine, primitive, viewer } = harness;
return {
engine,
viewer,
frame: harness.frame,
points(prefix) {
const id = engine.movers
.list()
.find((set) => set.id.startsWith(prefix))?.id;
const set = engine.movers.list().find((entry) => entry.id === id);
const collection = primitive(id);
return {
id,
count: set.count,
outlinePx: collection.get(0)?.outlineWidth,
depthTest:
collection.get(0)?.disableDepthTestDistance === Infinity
? 'always'
: 'normal',
at: (index) => {
const item = collection.get(index);
return {
color: [
item.color.red,
item.color.green,
item.color.blue,
item.color.alpha,
],
pixelSize: item.pixelSize,
show: item.show,
pickId: item.id,
};
},
};
},
preRenderListeners: () => viewer.scene.preRender.numberOfListeners,
};
},
};
function bothEngines(name, body) {
@@ -258,26 +216,7 @@ bothEngines(
],
'cones under the tracks, draped on terrain and tiles',
);
if (kind === 'cesium') {
const slots = viewer.scene.groundPrimitives.get(0);
const drawn = [];
for (let index = 0; index < slots.length; index++) {
const slot = slots.get(index);
for (let at = 0; at < slot.length; at++) drawn.push(slot.get(at));
}
const cone = drawn.filter(
(p) => p.gevGroundSet === 'weather-cyclones-cones:ep152026',
);
assert.equal(cone.length, 2, 'one fill batch and one outline batch');
assert.equal(cone[0].geometryInstances.length, 2);
assert.equal(cone[1].geometryInstances.length, 3, 'one per ring');
assert.equal(cone[1].geometryInstances[0].geometry.width, 1);
assert.equal(cone[0].classificationType, Cesium.ClassificationType.BOTH);
const track = drawn.find(
(p) => p.gevGroundSet === 'weather-cyclones-tracks:ep152026',
);
assert.equal(track.geometryInstances[0].geometry.width, 2.5);
}
assert.deepEqual(display.getDiagnostics(), {
storms: 1,
tracks: 1,
+1 -5
View File
@@ -66,11 +66,7 @@ function fixture(makeHarness = createFakeGroundHarness) {
const makeEngine = (make = makeHarness) => {
const harness = make();
// The stub Cesium scene has no credit display; the OSM credit goes there.
if (harness.viewer)
harness.viewer.creditDisplay ??= {
addStaticCredit() {},
removeStaticCredit() {},
};
harnesses.set(harness.engine, harness);
return harness.engine;
};
+14 -51
View File
@@ -1,13 +1,8 @@
// The Directions display on the engine contract, on the fake engine and on
// the Cesium engine (real Cesium point and billboard collections and the
// engine's ground batches over fake primitives): the route's flowing
// dashes, width, colour and drape, and the render it holds; the maneuver dots' style, anchoring, selection
// and picks; the A and B markers at the surface with their letters; show and
// hide through the layer; and what teardown leaves.
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs';
import { createApplicationDirectionsLayer } from '../../../data/directions.js';
@@ -114,11 +109,7 @@ function cesiumPoint(viewer, id, index) {
function fixture(t, makeHarness, { document = true } = {}) {
if (document) installDocument(t);
const h = makeHarness();
if (h.viewer)
h.viewer.creditDisplay ??= {
addStaticCredit() {},
removeStaticCredit() {},
};
const movers = recordMovers(h.engine);
let floor = 0;
const requests = [];
@@ -192,22 +183,7 @@ for (const makeHarness of GROUND_HARNESSES) {
getRenderGovernorDiagnostics(f.engine).holds.includes('directions'),
'a drawn route holds continuous render',
);
if (f.cesium) {
const { fills, lines } = f.primitives(IDS.route);
assert.equal(fills.length, 0);
assert.equal(lines.length, 1, 'one ground polyline batch');
assert.equal(
lines[0].options.classificationType,
Cesium.ClassificationType.BOTH,
);
const { uniforms } = lines[0].options.appearance.material;
assert.equal(uniforms.dashCells, 64);
near(uniforms.dashDuty, 0.46, 'duty');
near(uniforms.dashSpeed, 0.55, 'speed');
near(uniforms.dashGap, 0.18, 'gap');
near(uniforms.dashEmission, 0.9, 'emission');
near(uniforms.color.alpha, 0.95, 'alpha');
}
f.layer.setParams({ clear: true });
assert.equal(f.set(IDS.route), undefined);
assert.ok(
@@ -241,13 +217,6 @@ for (const makeHarness of GROUND_HARNESSES) {
const at = geo.cartographic.fromCartesian(anchored.position);
near(at.height, 32, 'floor + lift', 1e-3);
near(geo.math.toDegrees(at.longitude), MID.lon, 'dot lon');
if (f.cesium) {
const point = cesiumPoint(f.viewer, IDS.steps, 0);
assert.equal(point.pixelSize, 8);
assert.equal(point.outlineWidth, 2);
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY);
assert.equal(point.show, true);
}
});
test(`a click on a dot selects it at 13 px and opens its card; a click elsewhere clears it (${name})`, async (t) => {
@@ -258,8 +227,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(f.layer.getRowControls().list.items[1].active, true);
near(f.movers(IDS.steps).rows[0].scale, 13 / 8, 'selected scale');
assert.equal(f.cards.at(-1).id, 'directions-step-1');
if (f.cesium)
assert.equal(cesiumPoint(f.viewer, IDS.steps, 0).pixelSize, 13);
f.click(undefined);
await settle();
assert.equal(f.layer.getRowControls().list.items[1].active, false);
@@ -268,7 +236,7 @@ for (const makeHarness of GROUND_HARNESSES) {
test(`A and B are 14 px discs with a black outline and their letters, at the surface (${name})`, async (t) => {
const f = fixture(t, makeHarness);
if (!f.cesium) f.engine.fake.script('sampleHeight', () => 120);
f.engine.fake.script('sampleHeight', () => 120);
f.layer.placeEndpoint('a', A);
const markers = f.movers(IDS.markers);
assert.equal(markers.options.kind, 'point');
@@ -281,13 +249,12 @@ for (const makeHarness of GROUND_HARNESSES) {
nearAll(markers.rows[0].color, [a.red, a.green, a.blue, 1], 'A colour');
assert.equal(markers.rows[0].show, true);
assert.equal(markers.rows[1].show, false, 'B is not placed yet');
if (!f.cesium)
near(
geo.cartographic.fromCartesian(markers.rows[0].position).height,
120,
'A on the sampled surface',
1e-3,
);
near(
geo.cartographic.fromCartesian(markers.rows[0].position).height,
120,
'A on the sampled surface',
1e-3,
);
const letters = f.movers(IDS.letters);
assert.equal(letters.options.kind, 'sprite');
assert.equal(letters.options.order, 2);
@@ -339,14 +306,10 @@ for (const makeHarness of GROUND_HARNESSES) {
}
test('an engine switch redraws the route, dots and markers on the new engine only', async (t) => {
const [makeFake, makeCesium] = GROUND_HARNESSES;
const [makeFake] = GROUND_HARNESSES;
const f = fixture(t, makeFake);
await f.route();
const next = makeCesium();
next.viewer.creditDisplay ??= {
addStaticCredit() {},
removeStaticCredit() {},
};
const next = makeFake();
const movers = recordMovers(next.engine);
t.after(() => next.destroy());
f.layer.suspendCamera();
@@ -1,17 +1,12 @@
// The FAA airspace zones on the engine contract, on the fake engine and on
// the Cesium engine (its ground batches over fake primitives): fills with
// their holes at 0.25 and a 2 px outline at 0.9 along the outer ring only,
// in the zone type's colour; clicks through the engine's input selecting a
// zone's card; visibility and teardown.
// Layer display behavior on the fake engine.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
GROUND_HARNESSES,
createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs';
import { rgbaFromCss } from '../../shared/color.js';
import { droneAirspaceAccent } from '../cards.js';
@@ -140,51 +135,9 @@ for (const makeHarness of GROUND_HARNESSES) {
lines.forEach(({ outline }, index) =>
assert.deepEqual(outline.color, COLORS[index].withAlpha(0.9).rgba()),
);
if (f.cesium) {
const primitives = f.primitives('drone-airspace');
assert.equal(primitives.fills.length, 1, 'one fill batch');
assert.equal(primitives.lines.length, 1, 'one outline batch');
const fillInstances = primitives.fills[0].options.geometryInstances;
const lineInstances = primitives.lines[0].options.geometryInstances;
assert.equal(fillInstances.length, 3);
assert.equal(lineInstances.length, 3, 'no line along a hole');
assert.equal(
primitives.fills[0].options.classificationType,
Cesium.ClassificationType.BOTH,
);
assert.equal(primitives.fills[0].options.allowPicking, true);
assert.ok(fillInstances[0].geometry instanceof Cesium.PolygonGeometry);
assert.equal(
fillInstances[0].geometry._polygonHierarchy.holes.length,
1,
);
fillInstances.forEach((instance, index) =>
instanceColor(instance).forEach((channel, at) =>
near(
channel,
COLORS[index].withAlpha(0.25).rgba()[at],
`fill ${index}.${at}`,
0.5 / 255,
),
),
);
lineInstances.forEach((instance, index) => {
assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry);
assert.equal(instance.geometry.width, 2);
assert.equal(instance.geometry.loop, true);
instanceColor(instance).forEach((channel, at) =>
near(
channel,
COLORS[index].withAlpha(0.9).rgba()[at],
`line ${index}.${at}`,
0.5 / 255,
),
);
});
}
f.layer.disable();
assert.equal(f.set('drone-airspace').shown, false);
if (f.cesium) assert.equal(f.collectionShown('drone-airspace'), false);
} finally {
f.layer.destroy();
}
+12 -45
View File
@@ -1,16 +1,12 @@
// The earthquake discs on the engine contract, on the fake engine and on the
// Cesium engine (its ground batches over fake primitives): radii, depth
// colours and alphas, no outline, picks, visibility through the layer,
// retained discs across a scene handoff and a failed refresh, and teardown.
// Layer display behavior on the fake engine.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
GROUND_HARNESSES,
createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs';
import { createEarthquakesLayer } from '../index.js';
import { createEarthquakesDisplay } from './display.js';
@@ -113,28 +109,7 @@ for (const makeHarness of GROUND_HARNESSES) {
near(channel, FILLS[index][at], `fill ${index}.${at}`),
),
);
if (f.cesium) {
const { fills, lines } = f.primitives('earthquakes');
assert.equal(fills.length, 1, 'one ground batch');
assert.equal(lines.length, 0, 'no outline batch');
const { geometryInstances, classificationType, allowPicking } =
fills[0].options;
assert.equal(classificationType, Cesium.ClassificationType.BOTH);
assert.equal(allowPicking, true);
geometryInstances.forEach((instance, index) => {
const row = rows[index];
assert.ok(instance.geometry instanceof Cesium.EllipseGeometry);
assert.equal(instance.geometry._semiMajorAxis, 2 ** row.mag * 1000);
assert.equal(instance.geometry._semiMinorAxis, 2 ** row.mag * 1000);
const center = Cesium.Cartesian3.fromDegrees(row.lon, row.lat);
near(instance.geometry._center.x, center.x, 'centre x', 1e-6);
near(instance.geometry._center.y, center.y, 'centre y', 1e-6);
near(instance.geometry._center.z, center.z, 'centre z', 1e-6);
instanceColor(instance).forEach((channel, at) =>
near(channel, FILLS[index][at], `colour ${index}.${at}`, 0.5 / 255),
);
});
}
assert.deepEqual(
f.layer.getAnalystRecords().map((r) => r.id),
['us-a', 'us-b', 'QUAKE-0002'],
@@ -145,7 +120,6 @@ for (const makeHarness of GROUND_HARNESSES) {
f.layer.disable();
assert.deepEqual(f.layer.getAnalystRecords(), []);
assert.equal(f.set('earthquakes').shown, false);
if (f.cesium) assert.equal(f.collectionShown('earthquakes'), false);
} finally {
f.layer.destroy();
}
@@ -169,7 +143,7 @@ for (const makeHarness of GROUND_HARNESSES) {
f.setSnapshot([]);
assert.equal(await f.layer.update(), true);
assert.equal(f.set('earthquakes').items.length, 0);
if (f.cesium) assert.equal(f.primitives('earthquakes').fills.length, 0);
assert.equal(f.layer.getStats().error, null);
} finally {
f.layer.destroy();
@@ -182,21 +156,14 @@ for (const makeHarness of GROUND_HARNESSES) {
try {
await f.layer.update();
let picked;
if (f.cesium) {
const [fills] = f.primitives('earthquakes').fills;
f.viewer.scene.pick = () => ({
id: fills.options.geometryInstances[1].id,
primitive: fills,
});
picked = f.engine.picker.pick({ x: 1, y: 1 });
} else {
// The fake picks its ground items on the CPU, under the camera.
f.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(30, 20, 200_000),
});
const { width, height } = f.engine.host.size();
picked = f.engine.picker.pick({ x: width / 2, y: height / 2 });
}
// The fake picks its ground items on the CPU, under the camera.
f.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(30, 20, 200_000),
});
const { width, height } = f.engine.host.size();
picked = f.engine.picker.pick({ x: width / 2, y: height / 2 });
assert.equal(picked.id, 'earthquake:b');
assert.equal(picked.owner, 'earthquakes');
assert.equal(picked.instance, 1);
+4 -78
View File
@@ -1,14 +1,11 @@
// The FIRMS display on the fake engine and on the Cesium engine over a stub
// scene: the same calls, the same drawing, read back from each engine's own
// store (the fake's mover and ground sets, Cesium's billboards and ground
// primitives).
// Layer display behavior on the fake engine.
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 { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
import { constantRgba } from '../../shared/color.js';
import { createFirmsDisplay } from './display.js';
@@ -94,76 +91,7 @@ function fakeHarness() {
};
}
function cesiumHarness() {
const handlers = createFakeEventHandlers();
const { engine, viewer, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
},
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
engine.camera.setView({ destination: at(FIRE.lon, FIRE.lat, 2_000_000) });
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
const ground = watchGround(engine);
return {
name: 'the Cesium engine',
engine,
viewer,
ground,
sprites(id) {
const collection = primitive(id);
if (!collection) return null;
const listed = engine.movers.list().find((entry) => entry.id === id);
const count = listed?.count ?? 0;
return {
collection,
count,
instances: Array.from({ length: count }, (_, i) => {
const billboard = collection.get(i);
return {
show: billboard.show,
scale: billboard.scale,
alpha: billboard.color.alpha,
pickId: billboard.id,
billboard,
};
}),
};
},
/** The Cesium ground primitives of a set. */
groundPrimitives(id) {
const root = viewer.scene.groundPrimitives.get(0);
for (let i = 0; i < (root?.length ?? 0); i++) {
const slot = root.get(i);
const values = Array.from({ length: slot.length }, (_, j) =>
slot.get(j),
);
if (values.some((value) => value.gevGroundSet === id))
return { slot, values };
}
return null;
},
click(picked) {
native = picked;
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(10, 20),
});
native = undefined;
},
// A Cesium pick of the instance's billboard.
pickOf(setId, instance) {
const billboard = primitive(setId).get(instance);
return { primitive: billboard, id: billboard.id };
},
destroy: () => engine.destroy(),
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
const HARNESSES = [fakeHarness];
const display = (h, options = {}) =>
createFirmsDisplay(h.engine, {
id: 'firms-fixture',
@@ -535,8 +463,6 @@ for (const make of HARNESSES) {
test(`${name}: the heatmap is one imagery layer over the globe, faded by its alpha`, () => {
const restore = stubDocument();
const h = make();
// Cesium drapes it on the globe's own imagery stack.
const stack = new Cesium.ImageryLayerCollection();
if (h.viewer) {
h.viewer.imageryLayers = stack;
h.viewer.scene.globe.show = true;
-79
View File
@@ -215,85 +215,6 @@ test('the flights logic draws and follows on the aircraft display (fake engine)'
engine.destroy();
});
test('the flights logic draws, tracks and follows on the Cesium engine through the same display', async () => {
const { createCesiumMoversEngine } =
await import('../../testSupport/cesiumMoversEngine.mjs');
const { createFlightsDisplay } = await import('./render/display.js');
const { aircraftAtlasLayout } = await import('../aircraft/render/display.js');
const layout = aircraftAtlasLayout();
const { engine, viewer, frame, primitive } = createCesiumMoversEngine();
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.5, 30.3, 400_000),
});
let display = null;
const layer = createApplicationFlights({
surface: defaultSurface,
source: {
label: 'Fixture aircraft',
getSnapshot: async () => snapshotOf(['abc123', 'def456']),
},
militaryRegistry,
renderer: {
// The registry's factory, with the test's atlas.
create: ({ engine: given }) =>
(display = createFlightsDisplay(given, {
drawAtlas: async () => ({
canvas: { width: layout.width, height: layout.height },
cells: layout.cells,
}),
})),
},
});
layer.init(viewer, { engine });
layer.enable();
try {
await display.whenReady();
await layer.update(null);
await new Promise((resolve) => setTimeout(resolve, 0));
const [sprites] = engine.movers
.list()
.filter(({ id }) => id.startsWith('flights-sprites'));
assert.equal(sprites.count, 2);
const billboards = primitive(sprites.id);
assert.equal(billboards.length, 2, 'one Cesium billboard per contact');
assert.deepEqual([billboards.get(0).id, billboards.get(1).id].sort(), [
'abc123',
'def456',
]);
assert.deepEqual(
layer
.qaDrawn()
.map((row) => row.id)
.sort(),
['abc123', 'def456'],
);
// Tracking follows through Cesium's trackedEntity, with the readout.
assert.equal(layer.trackById('def456', { origin: 'voice' }), true);
const following = engine.camera.following();
assert.equal(following?.label?.id, 'flights:def456');
assert.equal(viewer.trackedEntity?.gevTrackedId, 'flights:def456');
frame();
await new Promise((resolve) => setTimeout(resolve, 0));
const tracked = engine.movers
.list()
.find(({ id }) => id.startsWith('flights-tracked'));
assert.equal(tracked.count, 1);
assert.equal(
primitive(tracked.id).get(0).disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.equal(layer.stopTracking(), true);
assert.equal(engine.camera.following(), null);
assert.equal(viewer.trackedEntity, undefined);
} finally {
layer.disable();
layer.destroy(viewer);
}
assert.equal(engine.movers.list().length, 0, 'no mover set left behind');
assert.equal(engine.lines.list().length, 0, 'no line left behind');
engine.destroy();
});
test('a failed engine switch, its rollback and the retry re-follow the tracked aircraft as a restore', async () => {
const { createCesiumMoversEngine } =
await import('../../testSupport/cesiumMoversEngine.mjs');
+14 -113
View File
@@ -1,15 +1,12 @@
// The GhostMaps display on the globe contract, on the fake engine and on the
// Cesium engine over a stub scene: KML polygons, lines and placemarks keep
// the styles the Cesium entities had (colours, alphas, widths, scales and
// order), picks name the report part, a report drops in place, placemarks
// beyond the horizon hide, and teardown leaves nothing drawn.
import { createFakeGroundHarness } from '../../../testSupport/groundDisplayEngines.mjs';
// Layer display behavior on the fake engine.
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 { rgbaFromCss } from '../../shared/color.js';
import { createGhostMapsDisplay } from './display.js';
@@ -41,15 +38,10 @@ function recordGround(engine) {
}
function fakeHarness() {
const engine = createFakeEngine();
const harness = createFakeGroundHarness();
const { engine } = harness;
return {
name: 'the fake engine',
engine,
frame: () => engine.fake.frame(),
click(picked) {
engine.fake.script('pick', picked);
engine.fake.input('click', { position: { x: 100, y: 100 } });
},
...harness,
pin(id, index) {
const { store, options } = engine.fake.moverSet(id);
return {
@@ -63,83 +55,7 @@ function fakeHarness() {
};
}
// Cesium's ground primitives as built, without a WebGL context: the
// options (instances, appearance, classification) the engine hands them.
const groundFactory = (type) => (options) => ({
type,
...options,
ready: true,
destroy() {},
isDestroyed: () => false,
});
function cesiumHarness() {
const handlers = createFakeEventHandlers();
const { engine, viewer, frame, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
groundFactories: {
fills: groundFactory('fills'),
lines: groundFactory('lines'),
},
},
});
// The stub scene has no ground collection of its own.
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
frame,
click(picked) {
native = !picked
? undefined
: picked.ground
? { id: { gevGroundSet: picked.owner, instance: picked.instance } }
: { primitive: primitive(picked.owner).get(picked.instance) };
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(100, 100),
});
},
pin(id, index) {
const item = primitive(id).get(index);
const count = engine.movers.list().find((set) => set.id === id).count;
return {
shown: index < count && item.show,
color: [
item.color.red,
item.color.green,
item.color.blue,
item.color.alpha,
],
sizePx: item.width * item.scale,
id: item.id,
};
},
count: (id) => engine.movers.list().find((set) => set.id === id).count,
// The ground primitives Cesium builds for a set.
groundPrimitives(id) {
const found = [];
const visit = (collection) => {
for (let index = 0; index < collection.length; index++) {
const child = collection.get(index);
if (child.gevGroundSet === id) found.push(child);
else if (child instanceof Cesium.PrimitiveCollection) visit(child);
}
};
visit(viewer.scene.groundPrimitives);
return found;
},
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
const HARNESSES = [fakeHarness];
// Seams for Node: decoded icons by URL and a canvas that records draws.
function iconSeams(sizes = {}) {
@@ -324,26 +240,11 @@ for (const makeHarness of HARNESSES) {
assert.ok(Math.abs(big.sizePx - 20 * 1.5) < 1e-6, 'scale capped at 1.5');
const set = harness.engine.movers.list().find(({ id }) => id === pins);
assert.equal(set.kind, 'sprite');
if (harness.cesium) {
// The per-instance colours and widths Cesium batches.
const [fills, outlines] = harness.groundPrimitives(polygons().options.id);
assert.deepEqual(
[fills.type, outlines.type],
['fills', 'lines'],
'one fill and one outline batch',
);
assert.equal(fills.classificationType, Cesium.ClassificationType.BOTH);
assert.equal(fills.allowPicking, true);
const colour = fills.geometryInstances[0].attributes.color.value;
assert.deepEqual([...colour], [255, 90, 54, 64]);
assert.equal(outlines.geometryInstances[0].geometry.width, 1.5);
const [corridors] = harness.groundPrimitives(lines().options.id);
assert.equal(corridors.geometryInstances[0].geometry.width, 8);
} else {
const { options } = harness.engine.fake.moverSet(pins);
assert.equal(options.depthTest, 'always');
assert.equal(options.pickable, true);
}
const { options } = harness.engine.fake.moverSet(pins);
assert.equal(options.depthTest, 'always');
assert.equal(options.pickable, true);
display.destroy();
});
+4 -55
View File
@@ -1,18 +1,12 @@
// The GNSS integrity cells on the engine contract, on the fake engine and
// on the Cesium engine (its ground batches over fake primitives, and once
// over Cesium's own GroundPrimitive): one unpicked cell per bin in its
// level's colour and alpha, no outline, the camera and view anchor the
// layer reads, visibility and teardown.
// Layer display behavior on the fake engine.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
GROUND_HARNESSES,
createCesiumGroundHarness,
createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs';
import { rgbaFromCss } from '../../shared/color.js';
import { GNSS_LEVEL_COLORS } from '../records.js';
@@ -71,39 +65,12 @@ for (const makeHarness of GROUND_HARNESSES) {
fill: fillOf(level),
})),
);
if (h.cesium) {
const { fills, lines } = h.primitives('gnss-integrity');
assert.equal(fills.length, 1, 'one ground batch');
assert.equal(lines.length, 0, 'no outline batch');
const { geometryInstances, classificationType, allowPicking } =
fills[0].options;
assert.equal(classificationType, Cesium.ClassificationType.BOTH);
assert.equal(allowPicking, false);
geometryInstances.forEach((instance, index) => {
const cell = cells[index];
assert.ok(instance.geometry instanceof Cesium.RectangleGeometry);
const rectangle = instance.geometry._rectangle;
near(rectangle.west, geo.math.toRadians(cell.west), 'west');
near(rectangle.south, geo.math.toRadians(cell.south), 'south');
near(rectangle.east, geo.math.toRadians(cell.east), 'east');
near(rectangle.north, geo.math.toRadians(cell.north), 'north');
instanceColor(instance).forEach((channel, at) =>
near(
channel,
fillOf(cell.level)[at],
`colour ${index}.${at}`,
0.5 / 255,
),
);
});
}
display.setVisible(false);
assert.equal(h.set('gnss-integrity').shown, false);
if (h.cesium) assert.equal(h.collectionShown('gnss-integrity'), false);
display.replace([], ALPHA);
assert.equal(h.set('gnss-integrity').items.length, 0);
if (h.cesium)
assert.equal(h.primitives('gnss-integrity').fills.length, 0);
} finally {
display.destroy();
display.destroy();
@@ -149,21 +116,3 @@ test('the anchor under a globe-wide view is the screen centre (the fake engine)'
near(anchor.longitude, -97, 'longitude', 0.01);
h.destroy();
});
test("Cesium's ground fill emits no pick-pass commands but keeps normal rendering", (t) => {
const h = createCesiumGroundHarness({ realFills: true });
const display = createGnssDisplay(h.engine);
display.setVisible(true);
display.replace(cells, ALPHA);
const [fill] = h.primitives('gnss-integrity').fills;
assert.ok(fill instanceof Cesium.GroundPrimitive);
let updates = 0;
t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++);
fill.update({ passes: { pick: true } });
assert.equal(updates, 0);
fill.update({ passes: { render: true, pick: false } });
assert.equal(updates, 1);
display.destroy();
assert.equal(fill.isDestroyed(), true);
h.destroy();
});
@@ -12,8 +12,7 @@ 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 { claimPointer, releasePointer } from '../../../data/inputOwnership.js';
import {
INFRA_LOD_ACTIVE_MAX,
@@ -250,114 +249,7 @@ function fakeHarness() {
};
}
function cesiumHarness() {
const handlers = createFakeEventHandlers();
const { engine, viewer, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
},
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
viewer.scene.screenSpaceCameraController = { enableInputs: true };
// The fake engine's 800 × 600 view, with Cesium's 60° frustum.
Object.assign(viewer.scene.canvas, {
clientWidth: 800,
clientHeight: 600,
width: 800,
height: 600,
});
viewer.scene.drawingBufferWidth = 800;
viewer.scene.drawingBufferHeight = 600;
viewer.camera.frustum.aspectRatio = 800 / 600;
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
const watched = watch(engine);
const count = (id) => engine.movers.list().find((set) => set.id === id).count;
let sampleCalls = 0;
let photoreal = false;
engine.maps.photorealActive = () => photoreal;
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
...watched,
preRender: () => viewer.scene.preRender.raiseEvent(viewer.scene),
moveEnd: () => viewer.camera.moveEnd.raiseEvent(),
surface({
supported = false,
sample = () => undefined,
terrain,
tilesLoaded = true,
photoreal: tiles = false,
} = {}) {
viewer.scene.sampleHeightSupported = supported;
viewer.scene.sampleHeight = (carto) => {
sampleCalls++;
const height = sample(carto);
// A scene whose tiles are not sampleable yet throws.
if (height === undefined) throw new Error('tiles not sampleable yet');
return height;
};
photoreal = tiles;
const globe = viewer.scene.globe;
globe.show = !tiles;
globe.getHeight = () => terrain();
Object.defineProperty(globe, 'tilesLoaded', {
configurable: true,
get: () => tilesLoaded(),
});
},
sampleCalls: () => sampleCalls,
point(id, index) {
const collection = primitive(id);
if (!collection || index >= count(id)) return undefined;
const item = collection.get(index);
return {
shown: item.show,
color: [
item.color.red,
item.color.green,
item.color.blue,
item.color.alpha,
],
sizePx: item.pixelSize,
outlinePx: item.outlineWidth,
depthTest:
item.disableDepthTestDistance === Number.POSITIVE_INFINITY
? 'always'
: item.disableDepthTestDistance === 0
? 'normal'
: item.disableDepthTestDistance,
id: item.id,
position: item.position,
};
},
moverIds: () => engine.movers.list().map(({ id }) => id),
click(target) {
native =
target?.ground !== undefined
? { id: { gevGroundSet: target.ground, instance: target.instance } }
: target?.mover !== undefined
? { primitive: primitive(target.mover).get(target.instance) }
: target?.line !== undefined
? {
primitive: primitive(watched.line(target.line).owner),
id: watched.line(target.line).options.pickId,
}
: target;
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(100, 100),
});
native = undefined;
},
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
const HARNESSES = [fakeHarness];
/** Freezes performance.now so the 450 ms walk gate is under test control. */
function installFakeClock(t, startMs = 1_000_000) {
+23 -28
View File
@@ -1,3 +1,7 @@
import {
createSelectionKeeper,
writeSelectedMarker,
} from '../../shared/selectionKeeper.js';
import { bindOverlayHost } from '../../../overlays/bindOverlayHost.js';
// Mapped Installations on the globe contract (engine.movers, engine.ground,
// engine.input, engine.camera), the same on every engine:
@@ -19,7 +23,6 @@ import { bindOverlayHost } from '../../../overlays/bindOverlayHost.js';
import { t } from '../../../i18n/index.js';
import * as geo from '../../../geo/index.js';
import { isPointerFree } from '../../../data/inputOwnership.js';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
@@ -250,13 +253,16 @@ export function createInstallationsDisplay(
});
}
normal.setCount(count);
selected.setCount(chosen ? 1 : 0);
if (chosen)
selected.set(0, {
position: chosen.position,
color: SELECTED_MARKER_COLOR,
pickId: chosen.record.id,
});
writeSelectedMarker(
selected,
chosen
? {
position: chosen.position,
color: SELECTED_MARKER_COLOR,
pickId: chosen.record.id,
}
: null,
);
normal.commit();
selected.commit();
}
@@ -413,13 +419,7 @@ export function createInstallationsDisplay(
if (!initialized) return;
// Context navigation can select another layer without a canvas click.
// A delayed floor/data repaint must not steal that newer selection back.
const selectedContext = getSelectedEntityContext();
if (
!claimSelection &&
state.selectedId &&
selectedContext?.id !== state.selectedId
)
state.selectedId = null;
selection.reconcile({ claimSelection });
let migratedSelection = false;
if (state.selectedId && !state.recordById.has(state.selectedId)) {
const replacement = state.records.find((record) =>
@@ -529,22 +529,17 @@ export function createInstallationsDisplay(
return state.selectedId === id;
}
function onClick({ picked }) {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!state.enabled) return;
const id = typeof picked?.id === 'string' ? picked.id : null;
if (id && state.recordById.has(id) && id !== state.selectedId) {
selectRecord(id);
} else if (state.selectedId) {
// Clicking the selected site again, empty map, or another contact
// releases this layer's selection. Clear only our shared context so a
// sibling click handler's newly selected aircraft/site stays intact.
const selection = createSelectionKeeper({
state,
getSelectedEntityContext,
selectRecord,
clearSelection() {
state.selectedId = null;
clearSelectedEntityContextForLayer(LAYER_ID);
renderRecords();
}
}
},
});
const onClick = selection.onClick;
function installInteraction() {
if (removeClick) return;
@@ -5,7 +5,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import * as context from '../../../data/contextStore.js';
import { claimPointer, releasePointer } from '../../../data/inputOwnership.js';
@@ -101,21 +101,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(disc.pickId, 'osm:way:1', 'a pick names the site');
closeTo(disc.color, classColor(airfield(), 1));
assert.equal(h.markers(SELECTED_MARKER_SET_ID).length, 0);
if (h.cesium) {
const point = disc.point;
assert.ok(
h.collection(MARKER_SET_ID) instanceof Cesium.PointPrimitiveCollection,
);
assert.equal(point.pixelSize, 9);
assert.equal(point.outlineWidth, 1);
assert.ok(
point.outlineColor.equalsEpsilon(
Cesium.Color.BLACK.withAlpha(0.8),
1e-6,
),
);
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY);
} else {
{
const { options } = h.engine.fake.moverSet(MARKER_SET_ID);
assert.equal(options.kind, 'point');
assert.equal(options.point.sizePx, 9);
@@ -134,7 +120,6 @@ for (const makeHarness of GROUND_HARNESSES) {
const [chosen] = h.markers(SELECTED_MARKER_SET_ID);
assert.equal(chosen.pickId, 'osm:way:1');
closeTo(chosen.color, [1, 1, 1, 1]);
if (h.cesium) assert.equal(chosen.point.pixelSize, 13);
});
test(`a named area draws no disc but its outlines are drawn, as on Cesium (${name})`, (t) => {
@@ -180,20 +165,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(line.widthPx, 2);
assert.equal(line.pickId, 'osm:way:1');
closeTo(line.color, classColor(airfield(), 0.85));
if (h.cesium) {
const [primitive] = h.primitives(`${OUTLINE_SET_ID}-1`).lines;
assert.equal(
primitive.options.classificationType,
Cesium.ClassificationType.BOTH,
);
}
// Cesium builds ground lines in workers: the first set is drawn now.
const built = (n) => {
for (const primitive of h.primitives(`${OUTLINE_SET_ID}-${n}`).lines)
primitive.ready = true;
h.viewer.scene.postRender.raiseEvent();
};
if (h.cesium) built(1);
const commits = [0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`).commits);
for (let i = 0; i < 5; i++) h.parts.display.renderRecords();
assert.deepEqual(
@@ -211,14 +183,7 @@ for (const makeHarness of GROUND_HARNESSES) {
h.parts.display.renderRecords();
const counts = () =>
[0, 1].map((n) => h.set(`${OUTLINE_SET_ID}-${n}`).items.length);
if (h.cesium) {
assert.deepEqual(
counts(),
[2, 1],
'Cesium is still building the new set',
);
built(0);
}
assert.deepEqual(counts(), [2, 0]);
});
@@ -245,13 +210,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(fill.items[0].pickId, named.id);
assert.equal(fill.items[0].outline, undefined);
closeTo(fill.items[0].fill, classColor(named, 0.22));
if (h.cesium) {
const [primitive] = h.primitives(SELECTION_FILL_SET_ID).fills;
assert.equal(
primitive.options.classificationType,
Cesium.ClassificationType.CESIUM_3D_TILE,
);
}
const commits = fill.commits;
for (let i = 0; i < 10; i++) h.parts.display.renderRecords();
assert.equal(
@@ -1,28 +1,10 @@
// Mapped Installations' display under test, on a ground harness of
// src/testSupport/groundDisplayEngines.mjs (the fake engine or the Cesium
// engine, passed in): the layer's own
// state and model over test services, with the display's parts assigned as
// the layer assigns them (../index.js). `markers(setId)` reads a point
// set's drawn instances on either engine.
// Installation state and services over the fake ground display harness.
import * as contextStore from '../../../data/contextStore.js';
import { createModel } from '../model.js';
import { createState } from '../state.js';
import { createInstallationsDisplay } from './display.js';
const children = (collection) =>
Array.from({ length: collection?.length ?? 0 }, (_, index) =>
collection.get(index),
);
/** The Cesium collection drawing a mover set. */
function cesiumCollection(viewer, id) {
for (const root of children(viewer.scene.primitives))
for (const child of children(root))
if (child.gevMoverSet === id) return child;
return undefined;
}
/**
* @param {Function} makeEngine A ground harness factory.
* @param {{ context?: object, overlayHost?: object,
@@ -123,23 +105,7 @@ export function createDisplayHarness(
markers(id) {
const count =
h.engine.movers.list().find((entry) => entry.id === id)?.count ?? 0;
if (h.cesium) {
const collection = cesiumCollection(h.viewer, id);
return children(collection)
.slice(0, count)
.map((point) => ({
pickId: point.id,
color: [
point.color.red,
point.color.green,
point.color.blue,
point.color.alpha,
],
show: point.show,
position: point.position,
point,
}));
}
const { store } = h.engine.fake.moverSet(id);
return Array.from({ length: count }, (_, index) => ({
pickId: store.pickIds[index],
@@ -152,8 +118,6 @@ export function createDisplayHarness(
},
}));
},
/** The Cesium collection of a point set (Cesium only). */
collection: (id) => cesiumCollection(h.viewer, id),
destroy() {
methods.destroy();
h.destroy();
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as context from '../../data/contextStore.js';
import { SELECTION_FILL_SET_ID } from './render/display.js';
import { createDisplayHarness } from './render/displayHarness.mjs';
@@ -65,11 +65,7 @@ for (const makeHarness of GROUND_HARNESSES) {
'the first footprint keeps its hole',
);
assert.deepEqual(items[0].fill, items[1].fill);
if (h.cesium)
assert.equal(
h.primitives(SELECTION_FILL_SET_ID).fills[0].options.classificationType,
Cesium.ClassificationType.CESIUM_3D_TILE,
);
for (let i = 0; i < 100; i++) render.syncSelectionFill();
assert.equal(fill().commits, commits, 'repaints retain ground geometry');
h.state.selectedId = null;
@@ -86,11 +82,7 @@ for (const makeHarness of GROUND_HARNESSES) {
h.changeMapStack();
assert.equal(fill().options.drape, 'both');
assert.equal(fill().items.length, 2);
if (h.cesium)
assert.equal(
h.primitives(SELECTION_FILL_SET_ID).fills[0].options.classificationType,
Cesium.ClassificationType.BOTH,
);
lifecycle.disable();
assert.equal(fill().items.length, 0);
assert.equal(h.mapStackRemoved(), 1);
+51 -146
View File
@@ -1,3 +1,4 @@
import { createFakeGroundHarness as fakeHarness } from '../../../testSupport/groundDisplayEngines.mjs';
// The Space Missions display on the engine contract, on the fake engine and
// on the Cesium engine (real Cesium collections and polylines over a stub
// scene: they draw nothing but hold what the engine writes): the dots and the
@@ -8,11 +9,10 @@
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 { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { dashPattern } from '../../../globe/cesium/lines.js';
import { rgbaFromCss } from '../../shared/color.js';
import { LAUNCH_PAD_ZONE_RADIUS_M, LAUNCH_PAD_ZONE_STYLE } from '../policy.js';
import { createLaunchesDisplay } from './display.js';
@@ -21,42 +21,7 @@ import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs'
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
const reticle = { width: 48, height: 48, getContext: () => null };
function fakeHarness() {
const engine = createFakeEngine({ canvas: { style: { cursor: '' } } });
return {
name: 'the fake engine',
engine,
frame: () => engine.fake.frame(),
destroy: () => engine.destroy(),
};
}
function cesiumHarness() {
const { engine, viewer, ...rest } = createCesiumMoversEngine({
engineOptions: {
groundFactories: {
fills: (options) => ({
...options,
ready: true,
destroy() {},
isDestroyed: () => false,
}),
},
},
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
viewer.scene.globe.getHeight = () => 7;
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
...rest,
destroy: () => engine.destroy(),
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
const HARNESSES = [fakeHarness];
function setup(makeHarness) {
const harness = makeHarness();
@@ -109,29 +74,18 @@ for (const makeHarness of HARNESSES) {
assert.equal(five.kind, 'point');
assert.equal(five.count, 1);
assert.equal(four.count, 1);
if (h.cesium) {
const point = h.primitive(five.id).get(0);
assert.equal(point.pixelSize, 5);
assert.equal(point.outlineWidth, 0);
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY);
assert.equal(point.id, 'rocket-launch:a');
assert.equal(point.show, true);
assert.ok(Cesium.Cartesian3.equals(point.position, PAD));
near(point.color.green, color.green, 'green');
assert.equal(h.primitive(four.id).get(0).pixelSize, 4);
} else {
const set = engine.fake.moverSet(five.id);
assert.equal(set.options.point.sizePx, 5);
assert.equal(set.options.point.outlinePx, 0);
assert.equal(set.options.depthTest, 'always');
assert.equal(set.options.pickable, true);
assert.equal(set.store.pickIds[0], 'rocket-launch:a');
near(set.store.colors[1], color.green, 'green');
}
const set = engine.fake.moverSet(five.id);
assert.equal(set.options.point.sizePx, 5);
assert.equal(set.options.point.outlinePx, 0);
assert.equal(set.options.depthTest, 'always');
assert.equal(set.options.pickable, true);
assert.equal(set.store.pickIds[0], 'rocket-launch:a');
near(set.store.colors[1], color.green, 'green');
pad.show = false;
await settle();
if (h.cesium) assert.equal(h.primitive(five.id).get(0).show, false);
else assert.equal(engine.fake.moverSet(five.id).store.shows[0], 0);
assert.equal(engine.fake.moverSet(five.id).store.shows[0], 0);
display.setVisible(false);
assert.deepEqual(
engine.movers.list().map(({ count }) => count),
@@ -155,23 +109,13 @@ for (const makeHarness of HARNESSES) {
await settle();
const [listed] = engine.movers.list();
assert.equal(listed.kind, 'sprite');
if (h.cesium) {
const billboard = h.primitive(listed.id).get(0);
assert.equal(billboard.width, 24);
assert.equal(billboard.height, 24);
assert.equal(
billboard.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.equal(billboard.id, 'rocket-launch:a');
assert.equal(billboard.show, false);
} else {
const set = engine.fake.moverSet(listed.id);
assert.equal(set.options.sprite.sizePx, 24);
assert.equal(set.options.sprite.atlas.canvas, reticle);
assert.equal(set.options.depthTest, 'always');
assert.equal(set.store.shows[0], 0);
}
const set = engine.fake.moverSet(listed.id);
assert.equal(set.options.sprite.sizePx, 24);
assert.equal(set.options.sprite.atlas.canvas, reticle);
assert.equal(set.options.depthTest, 'always');
assert.equal(set.store.shows[0], 0);
display.destroy();
h.destroy();
});
@@ -200,23 +144,21 @@ for (const makeHarness of HARNESSES) {
assert.equal(reads, 2);
const [listed] = engine.movers.list();
const drawn = () => {
if (h.cesium) return h.primitive(listed.id).get(0).position;
const { positions } = engine.fake.moverSet(listed.id).store;
return geo.cartesian3.create(positions[0], positions[1], positions[2]);
};
assert.ok(geo.cartesian3.equalsEpsilon(drawn(), at, 0, 1e-6));
display.setVisible(false);
if (!h.cesium) engine.fake.resetCalls();
engine.fake.resetCalls();
at = geo.cartesian3.fromDegrees(2, 0, 400_000);
h.frame();
h.frame();
assert.equal(reads, 2, 'hidden motion is not read');
if (!h.cesium)
assert.equal(
engine.fake.renderRequests,
0,
'hidden motion must not sustain rendering',
);
assert.equal(
engine.fake.renderRequests,
0,
'hidden motion must not sustain rendering',
);
display.destroy();
h.destroy();
});
@@ -235,33 +177,19 @@ for (const makeHarness of HARNESSES) {
dash: { lengthPx: 6, gapPx: 6 },
});
const [listed] = engine.lines.list();
if (h.cesium) {
const primitive = h.primitive(listed.id);
const { geometry, id } = primitive.geometryInstances;
assert.equal(geometry._width, 1.5);
assert.equal(geometry._arcType, Cesium.ArcType.NONE);
assert.equal(id, 'rocket-transfer:a');
const { material } = primitive.appearance;
assert.equal(material.type, 'PolylineDash');
// Cesium's dashLength 24 px with 0xf0f0: 6 px on, 6 px off.
assert.equal(material.uniforms.dashLength, 12);
assert.equal(material.uniforms.dashPattern, dashPattern(6, 6));
assert.equal(material.uniforms.dashPattern, 0xff00);
near(material.uniforms.color.alpha, 0.8, 'alpha');
assert.equal(primitive.depthFailAppearance, undefined);
} else {
const { options } = engine.fake.line(listed.id);
assert.equal(options.widthPx, 1.5);
assert.equal(options.arc, 'none');
assert.equal(options.pickId, 'rocket-transfer:a');
assert.deepEqual(options.dash, {
kind: 'pixels',
lengthPx: 6,
gapPx: 6,
});
assert.deepEqual(options.color, color.rgba());
assert.equal(options.depthFailAlpha, 0);
}
const { options } = engine.fake.line(listed.id);
assert.equal(options.widthPx, 1.5);
assert.equal(options.arc, 'none');
assert.equal(options.pickId, 'rocket-transfer:a');
assert.deepEqual(options.dash, {
kind: 'pixels',
lengthPx: 6,
gapPx: 6,
});
assert.deepEqual(options.color, color.rgba());
assert.equal(options.depthFailAlpha, 0);
transfer.show = false;
assert.equal(engine.lines.list()[0].shown, false);
transfer.show = true;
@@ -291,27 +219,16 @@ for (const makeHarness of HARNESSES) {
const [listed] = engine.lines.list();
modelMatrix[12] = 42;
ring.setModelMatrix(modelMatrix);
if (h.cesium) {
const primitive = h.primitive(listed.id);
const { material } = primitive.appearance;
assert.equal(material.type, 'GevLineTactical');
assert.equal(material.uniforms.groupCount, 4);
assert.equal(material.uniforms.dashCount, 100);
near(material.uniforms.color.alpha, 0.95, 'alpha');
assert.equal(primitive.geometryInstances.geometry._width, 3);
assert.equal(primitive.modelMatrix[12], 42);
// The live ring is not picked (Cesium's polyline collection).
assert.equal(primitive.allowPicking, false);
} else {
const line = engine.fake.line(listed.id);
assert.deepEqual(line.options.dash, {
kind: 'tactical',
groups: 4,
dashes: 100,
});
assert.equal(line.modelMatrix[12], 42);
assert.equal(line.options.pickId, undefined);
}
const line = engine.fake.line(listed.id);
assert.deepEqual(line.options.dash, {
kind: 'tactical',
groups: 4,
dashes: 100,
});
assert.equal(line.modelMatrix[12], 42);
assert.equal(line.options.pickId, undefined);
display.destroy();
assert.deepEqual(engine.lines.list(), []);
h.destroy();
@@ -422,19 +339,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(record.shown, false);
display.setVisible(true);
assert.equal(h.set(id).shown, true);
if (h.cesium) {
const [primitive] = h.primitives(id).fills;
assert.equal(
primitive.options.classificationType,
Cesium.ClassificationType.BOTH,
);
assert.equal(primitive.options.allowPicking, false);
const appearance = primitive.options.appearance;
assert.equal(appearance.flat, true);
assert.equal(appearance.renderState.polygonOffset.factor, -1);
for (const [key, value] of Object.entries(radial))
assert.equal(appearance.material.uniforms[key], value);
}
pad.show = false;
display.setVisible(false);
display.setVisible(true);
+20 -62
View File
@@ -1,16 +1,12 @@
// The OSM Places points on the engine contract, on the fake engine and on
// the Cesium engine (real Cesium point collections over a stub scene): the
// disc's size, outline, colour and close-range depth test, its height on the
// sampled surface, what a pick returns, and the points' life across
// toggles, a globe switch and teardown.
// Layer display behavior on the fake engine.
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 { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { createOsmPlacesLayer, OSM_PLACE_COLOR } from '../index.js';
import { createOsmPlacesLayer } from '../index.js';
import { createOsmPlacesDisplay } from './display.js';
const places = [
@@ -31,22 +27,7 @@ function fakeHarness() {
};
}
function cesiumHarness() {
const { engine, viewer, primitive } = createCesiumMoversEngine();
return {
name: 'the Cesium engine',
cesium: true,
engine,
primitive,
surface(height) {
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => height;
},
destroy: () => engine.destroy(),
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
const HARNESSES = [fakeHarness];
function layerOn(engine) {
const overlay = new Map();
@@ -76,38 +57,17 @@ for (const makeHarness of HARNESSES) {
display.setVisible(true);
display.replace(places);
assert.equal(count(h.engine), 2);
if (h.cesium) {
const collection = h.primitive('osm-places');
assert.ok(collection instanceof Cesium.PointPrimitiveCollection);
const point = collection.get(0);
assert.equal(point.pixelSize, 9);
assert.equal(point.outlineWidth, 2);
assert.ok(
point.outlineColor.equalsEpsilon(
Cesium.Color.BLACK.withAlpha(0.8),
1e-6,
),
);
assert.ok(
point.color.equalsEpsilon(
Cesium.Color.fromCssColorString(OSM_PLACE_COLOR),
1e-6,
),
);
assert.equal(point.disableDepthTestDistance, 2500);
assert.equal(point.id, 'osm-place:node/1', 'a pick names the place');
} else {
const set = h.engine.fake.moverSet('osm-places');
assert.equal(set.options.kind, 'point');
assert.equal(set.options.point.sizePx, 9);
assert.equal(set.options.point.outlinePx, 2);
assert.deepEqual(set.options.point.outlineColor, [0, 0, 0, 0.8]);
assert.deepEqual(set.options.depthTest, { disableWithinM: 2500 });
assert.equal(set.options.pickable, true);
assert.ok(set.options.order < 0, 'under the moving contacts');
assert.equal(set.store.pickIds[1], 'osm-place:node/2');
assert.ok(Math.abs(set.store.colors[0] - 0x7f / 255) < 1e-6);
}
const set = h.engine.fake.moverSet('osm-places');
assert.equal(set.options.kind, 'point');
assert.equal(set.options.point.sizePx, 9);
assert.equal(set.options.point.outlinePx, 2);
assert.deepEqual(set.options.point.outlineColor, [0, 0, 0, 0.8]);
assert.deepEqual(set.options.depthTest, { disableWithinM: 2500 });
assert.equal(set.options.pickable, true);
assert.ok(set.options.order < 0, 'under the moving contacts');
assert.equal(set.store.pickIds[1], 'osm-place:node/2');
assert.ok(Math.abs(set.store.colors[0] - 0x7f / 255) < 1e-6);
} finally {
display.destroy();
h.destroy();
@@ -119,12 +79,10 @@ for (const makeHarness of HARNESSES) {
const h = makeHarness();
const display = createOsmPlacesDisplay(h.engine);
const position = () =>
h.cesium
? h.primitive('osm-places').get(0).position
: (() => {
const { positions } = h.engine.fake.moverSet('osm-places').store;
return { x: positions[0], y: positions[1], z: positions[2] };
})();
(() => {
const { positions } = h.engine.fake.moverSet('osm-places').store;
return { x: positions[0], y: positions[1], z: positions[2] };
})();
try {
h.surface(undefined);
display.setVisible(true);
+3 -50
View File
@@ -1,17 +1,12 @@
// The fire perimeters on the engine contract, on the fake engine and on the
// Cesium engine (its ground batches over fake primitives): fills with their
// holes at 0.25 and a 2 px fire line at 0.9 along the outer ring only, in
// the containment colour; clicks through the engine's input selecting an
// incident's card; visibility and teardown.
// Layer display behavior on the fake engine.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
GROUND_HARNESSES,
createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs';
import { rgbaFromCss } from '../../shared/color.js';
import { containmentAccent } from '../cards.js';
@@ -137,51 +132,9 @@ for (const makeHarness of GROUND_HARNESSES) {
lines.forEach(({ outline }, index) =>
assert.deepEqual(outline.color, COLORS[index].withAlpha(0.9).rgba()),
);
if (f.cesium) {
const primitives = f.primitives('fire-perimeters');
assert.equal(primitives.fills.length, 1, 'one fill batch');
assert.equal(primitives.lines.length, 1, 'one fire-line batch');
const fillInstances = primitives.fills[0].options.geometryInstances;
const lineInstances = primitives.lines[0].options.geometryInstances;
assert.equal(fillInstances.length, 3);
assert.equal(lineInstances.length, 3, 'no line along a hole');
assert.equal(
primitives.fills[0].options.classificationType,
Cesium.ClassificationType.BOTH,
);
assert.equal(primitives.fills[0].options.allowPicking, true);
assert.ok(fillInstances[0].geometry instanceof Cesium.PolygonGeometry);
assert.equal(
fillInstances[0].geometry._polygonHierarchy.holes.length,
1,
);
fillInstances.forEach((instance, index) =>
instanceColor(instance).forEach((channel, at) =>
near(
channel,
COLORS[index].withAlpha(0.25).rgba()[at],
`fill ${index}.${at}`,
0.5 / 255,
),
),
);
lineInstances.forEach((instance, index) => {
assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry);
assert.equal(instance.geometry.width, 2);
assert.equal(instance.geometry.loop, true);
instanceColor(instance).forEach((channel, at) =>
near(
channel,
COLORS[index].withAlpha(0.9).rgba()[at],
`line ${index}.${at}`,
0.5 / 255,
),
);
});
}
f.layer.disable();
assert.equal(f.set('fire-perimeters').shown, false);
if (f.cesium) assert.equal(f.collectionShown('fire-perimeters'), false);
} finally {
f.layer.destroy();
}
+6 -97
View File
@@ -1,17 +1,11 @@
// 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.
// Layer display behavior on the fake engine.
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';
@@ -85,54 +79,7 @@ function fakeHarness() {
};
}
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];
const HARNESSES = [fakeHarness];
function fixture(t, makeHarness) {
const previous = globalThis.Audio;
@@ -252,32 +199,7 @@ for (const makeHarness of HARNESSES) {
.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);
@@ -313,20 +235,7 @@ for (const makeHarness of HARNESSES) {
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);
@@ -1,12 +1,10 @@
// The Recent Imagery display on the fake engine and on the Cesium engine
// over a stub scene: the same calls, read back from each engine's own
// store (the fake's imagery layers, Cesium's imagery layers and their
// template providers).
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { BOX } from '../testDoubles.mjs';
import { createRecentImageryDisplay } from './display.js';
@@ -42,62 +40,7 @@ function fakeHarness() {
};
}
function cesiumHarness() {
const { engine, viewer } = createCesiumMoversEngine();
viewer.imageryLayers = new Cesium.ImageryLayerCollection();
// The map's base imagery, under the slot's layers.
const tilingScheme = new Cesium.WebMercatorTilingScheme();
const base = new Cesium.ImageryLayer({
tilingScheme,
rectangle: tilingScheme.rectangle,
tileWidth: 256,
tileHeight: 256,
minimumLevel: 0,
maximumLevel: 18,
errorEvent: new Cesium.Event(),
requestImage: () => undefined,
});
viewer.imageryLayers.add(base);
viewer.scene.globe.show = true;
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
const side = (direction) =>
({
[Cesium.SplitDirection.LEFT]: 'left',
[Cesium.SplitDirection.RIGHT]: 'right',
})[direction] ?? 'none';
const degrees = (value) => Number(Cesium.Math.toDegrees(value).toFixed(9));
return {
name: 'the Cesium engine',
engine,
drawn: () =>
Array.from({ length: viewer.imageryLayers.length - 1 }, (_, i) => {
const layer = viewer.imageryLayers.get(i + 1);
const provider = layer.imageryProvider;
const { west, south, east, north } = provider.rectangle;
return {
url: provider.url,
maxLevel: provider.maximumLevel,
opaque: !provider.hasAlphaChannel,
rectangle: {
west: degrees(west),
south: degrees(south),
east: degrees(east),
north: degrees(north),
},
credit: provider.credit?.html ?? null,
alpha: layer.alpha,
split: side(layer.splitDirection),
shown: layer.show,
};
}),
hideSurface() {
viewer.scene.globe.show = false;
},
destroy: () => engine.destroy(),
};
}
for (const make of [fakeHarness, cesiumHarness]) {
for (const make of [fakeHarness]) {
const name = make().name;
test(`${name}: a slot drapes one GIBS template bounded to the box; the same day only restyles it`, () => {
+17 -88
View File
@@ -1,3 +1,4 @@
import { createFakeGroundHarness as fakeHarness } from '../../../testSupport/groundDisplayEngines.mjs';
// The satellites display on the engine contract, on the fake engine and on
// the Cesium engine (real Cesium point collections and polylines over a stub
// scene: they draw nothing but hold what the engine writes): the fleet's
@@ -8,10 +9,10 @@
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 { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { CYAN, WHITE, nearFarScale } from '../../shared/color.js';
import { createSatellitesDisplay } from './display.js';
@@ -34,28 +35,7 @@ const near = (actual, expected, label, tolerance = 1e-6) =>
`${label}: ${actual} vs ${expected}`,
);
function fakeHarness() {
const engine = createFakeEngine();
return {
name: 'the fake engine',
engine,
frame: () => engine.fake.frame(),
destroy: () => engine.destroy(),
};
}
function cesiumHarness() {
const { engine, ...rest } = createCesiumMoversEngine();
return {
name: 'the Cesium engine',
cesium: true,
engine,
...rest,
destroy: () => engine.destroy(),
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
const HARNESSES = [fakeHarness];
function setup(makeHarness) {
const harness = makeHarness();
@@ -70,20 +50,6 @@ const setsOf = (engine) =>
/** What a set's instance draws: its size, outline, depth mode and colour. */
function drawn(h, setId, index) {
if (h.cesium) {
const item = h.primitive(setId).get(index);
return {
position: item.position,
pixelSize: item.pixelSize,
outlineWidth: item.outlineWidth,
outline: item.outlineColor,
depth: item.disableDepthTestDistance,
scale: item.scaleByDistance,
color: item.color,
show: item.show,
id: item.id,
};
}
const { options, store } = h.engine.fake.moverSet(setId);
const [r, g, b, a] = store.colors.slice(index * 4, index * 4 + 4);
const [nearM, nearValue, farM, farValue] =
@@ -139,9 +105,7 @@ for (const makeHarness of HARNESSES) {
// Reads are the display's own copies.
assert.notEqual(all[0].color, WHITE);
assert.equal(all[0].color.alpha, 1);
const commits = h.cesium
? null
: () => engine.fake.moverSet(listed.id).commits;
const commits = () => engine.fake.moverSet(listed.id).commits;
const before = commits?.();
const remove = display.onFrame(() => {
all[0].position = { x: 1, y: 2, z: 3 };
@@ -222,8 +186,7 @@ for (const makeHarness of HARNESSES) {
'yellow',
);
assert.equal(dot.id, 25544);
if (!h.cesium)
assert.equal(engine.fake.moverSet(tracked.id).options.order, 100);
assert.equal(engine.fake.moverSet(tracked.id).options.order, 100);
assert.ok(
setsOf(engine)
.filter(({ id }) => id !== tracked.id)
@@ -271,25 +234,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(following.label.position(), target);
assert.equal(display.follow.current(), handle);
assert.equal(following.target(), target);
if (h.cesium) {
const proxy = h.viewer.trackedEntity;
assert.ok(proxy instanceof Cesium.Entity);
assert.equal(
proxy.trackingReferenceFrame,
Cesium.TrackingReferenceFrame.ENU,
);
assert.equal(proxy.gevTrackedId, 'satellites:25544');
assert.equal(proxy.gevSelectionOrigin, 'user');
const scratch = new Cesium.Cartesian3();
const value = proxy.viewFrom.getValue(
h.viewer.clock.currentTime,
scratch,
);
assert.deepEqual(
[value.x, value.y, value.z],
[offset.x, offset.y, offset.z],
);
}
// The display's own release is guarded; a later external change is not.
seen.length = 0;
const guards = [];
@@ -322,7 +267,7 @@ for (const makeHarness of HARNESSES) {
display.follow.start(handle);
display.removeTracked(handle);
assert.equal(engine.camera.following(), null);
if (h.cesium) assert.equal(h.viewer.trackedEntity, undefined);
display.destroy();
h.destroy();
});
@@ -339,34 +284,18 @@ for (const makeHarness of HARNESSES) {
assert.deepEqual(orbit.modelMatrix, geo.matrix4.IDENTITY);
const [before] = engine.lines.list();
assert.equal(before.points, 2);
if (h.cesium) {
const primitive = h.primitive(before.id);
assert.equal(primitive.allowPicking, false, 'rings are not picked');
assert.equal(
primitive.geometryInstances.geometry._arcType,
Cesium.ArcType.GEODESIC,
);
assert.equal(primitive.geometryInstances.geometry._width, 2.5);
near(primitive.appearance.material.uniforms.color.alpha, 0.6, 'alpha');
near(
primitive.depthFailAppearance.material.uniforms.color.alpha,
0.35,
'occluded alpha',
);
assert.equal(primitive.appearance.material.uniforms.color.red, 0);
} else {
const { options } = engine.fake.line(before.id);
assert.equal(options.widthPx, 2.5);
assert.equal(options.arc, 'geodesic');
assert.equal(options.pickId, undefined, 'rings are not picked');
near(options.color[3], 0.6, 'alpha');
near(options.color[3] * options.depthFailAlpha, 0.35, 'occluded alpha');
}
const { options } = engine.fake.line(before.id);
assert.equal(options.widthPx, 2.5);
assert.equal(options.arc, 'geodesic');
assert.equal(options.pickId, undefined, 'rings are not picked');
near(options.color[3], 0.6, 'alpha');
near(options.color[3] * options.depthFailAlpha, 0.35, 'occluded alpha');
orbit.modelMatrix[12] = 123;
display.commitOrbit(orbit);
assert.equal(engine.lines.list()[0].id, before.id, 'the same line');
if (h.cesium) assert.equal(h.primitive(before.id).modelMatrix[12], 123);
else assert.equal(engine.fake.line(before.id).modelMatrix[12], 123);
assert.equal(engine.fake.line(before.id).modelMatrix[12], 123);
orbit.show = false;
assert.equal(orbit.show, false);
assert.equal(engine.lines.list()[0].shown, false);
+47
View File
@@ -0,0 +1,47 @@
import { isPointerFree } from '../../data/inputOwnership.js';
/** Selection state shared by record-backed marker layers. */
export function createSelectionKeeper({
state,
getSelectedEntityContext,
selectRecord,
clearSelection,
pickId = ({ picked }) => (typeof picked?.id === 'string' ? picked.id : null),
reselectContext = false,
}) {
return {
onClick(event) {
if (!isPointerFree()) return;
if (!state.enabled) return;
const id = pickId(event);
if (
id &&
state.recordById.has(id) &&
(id !== state.selectedId ||
(reselectContext && getSelectedEntityContext()?.id !== id))
)
selectRecord(id);
else if (state.selectedId) clearSelection();
},
reconcile({ claimSelection = false, layerId, records } = {}) {
const selected = getSelectedEntityContext();
if (
!claimSelection &&
(selected?.id !== state.selectedId ||
(layerId !== undefined && selected?.layerId !== layerId) ||
(records &&
!records.some((record) => record.id === state.selectedId)))
) {
state.selectedId = null;
return false;
}
return true;
},
};
}
/** Write the single selected marker; the caller owns the commit timing. */
export function writeSelectedMarker(set, fields) {
set.setCount(fields ? 1 : 0);
if (fields) set.set(0, fields);
}
@@ -1,11 +1,4 @@
// The cables display on the engine contract, on the fake engine and on the
// Cesium engine: cable paths and their colours, widths, picks and drape per
// map stack; landing markers; the reference sweep (tips, horizon, labels,
// the publish skip, the winners' stems, the moveEnd and motion cadence);
// clicks that fly to a reference; hide, show and destroy; and the
// antimeridian edge cases. Each harness records what the ground sets were
// given and the flights asked for, drives preRender and moveEnd by hand, and
// clicks with a pick of a ground item, a mover instance or a line.
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -13,8 +6,7 @@ import { readFileSync } from 'node:fs';
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 { claimPointer, releasePointer } from '../../../data/inputOwnership.js';
import { rgbaFromCss } from '../../shared/color.js';
import {
@@ -162,81 +154,7 @@ function fakeHarness() {
};
}
function cesiumHarness() {
const handlers = createFakeEventHandlers();
const { engine, viewer, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
},
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
const watched = watch(engine);
const count = (id) => engine.movers.list().find((set) => set.id === id).count;
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
...watched,
preRender: () => viewer.scene.preRender.raiseEvent(viewer.scene),
moveEnd: () => viewer.camera.moveEnd.raiseEvent(),
/** The Cesium ground primitives a set drew, in its slot. */
groundPrimitives(id) {
const root = viewer.scene.groundPrimitives.get(0);
const found = [];
for (let index = 0; index < (root?.length ?? 0); index++) {
const slot = root.get(index);
for (let at = 0; at < slot.length; at++)
if (slot.get(at).gevGroundSet === id) found.push(slot.get(at));
}
return found;
},
point(id, index) {
const collection = primitive(id);
if (!collection || index >= count(id)) return undefined;
const item = collection.get(index);
return {
shown: item.show,
color: [
item.color.red,
item.color.green,
item.color.blue,
item.color.alpha,
],
sizePx: item.pixelSize,
outlinePx: item.outlineWidth,
outlineColor: item.outlineColor,
id: item.id,
position: item.position,
native: item,
};
},
click(target) {
native =
target?.ground !== undefined
? { id: { gevGroundSet: target.ground, instance: target.instance } }
: target?.mover !== undefined
? { primitive: primitive(target.mover).get(target.instance) }
: target?.line !== undefined
? {
primitive: primitive(watched.line(target.line).owner),
id: watched.line(target.line).pickId,
}
: target;
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(100, 100),
});
native = undefined;
},
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
const HARNESSES = [fakeHarness];
const PATHS = `${CABLE_OVERLAY_SOURCE_ID}-paths`;
const MARKERS = `${CABLE_OVERLAY_SOURCE_ID}-markers`;
@@ -356,17 +274,6 @@ for (const makeHarness of HARNESSES) {
assert.equal(set.items[0].pickId, set.items[1].pickId);
assert.notEqual(set.items[0].pickId, set.items[2].pickId);
assert.deepEqual(set.items[0].positions, alpha.geometry.coordinates[0]);
if (f.cesium) {
const [batch] = f.groundPrimitives(PATHS);
assert.ok(batch instanceof Cesium.GroundPolylinePrimitive);
assert.equal(batch.geometryInstances.length, 3);
assert.equal(batch.geometryInstances[0].geometry.width, 2.5);
assert.equal(batch.classificationType, Cesium.ClassificationType.TERRAIN);
assert.deepEqual(
[...batch.geometryInstances[0].attributes.color.value],
[255, 0, 0, Math.round(0.92 * 255)],
);
}
});
test(`the paths drape on the active map stack's surface and follow its changes (${name})`, (t) => {
@@ -378,11 +285,7 @@ for (const makeHarness of HARNESSES) {
listener({ detail: { activeId: 'photoreal', status: 'ready' } });
assert.equal(f.groundSet(PATHS).options.drape, 'tiles');
assert.equal(f.groundSet(PATHS).items.length, 3, 'rebuilt whole');
if (f.cesium)
assert.equal(
f.groundPrimitives(PATHS)[0].classificationType,
Cesium.ClassificationType.CESIUM_3D_TILE,
);
listener({ detail: { activeId: 'osm', status: 'ready' } });
assert.equal(f.groundSet(PATHS).options.drape, 'terrain');
listener({ detail: { activeId: 'some-future-stack' } });
@@ -440,19 +343,7 @@ for (const makeHarness of HARNESSES) {
const landingTip = f.point(LANDING_TIPS, 0);
assert.equal(landingTip.sizePx, 8);
near(landingTip.color, rgba(BASE_LANDING_COLOR, 0.94), 'landing tip');
if (f.cesium) {
assert.equal(alphaTip.native.disableDepthTestDistance, 0, 'depth-tested');
near(
[
alphaTip.outlineColor.red,
alphaTip.outlineColor.green,
alphaTip.outlineColor.blue,
alphaTip.outlineColor.alpha,
],
[0, 0, 0, 0.65],
'tip outline',
);
}
const tipHeight = geo.cartographic.fromCartesian(alphaTip.position).height;
assert.ok(tipHeight >= 700 && tipHeight <= 85_000, `tip ${tipHeight} m`);
@@ -598,15 +489,14 @@ for (const makeHarness of HARNESSES) {
f.click({ id: 'cable-reference-0-alpha', owner: 'someone-else' });
assert.equal(f.flights.length, 4);
f.display.hide();
if (!f.cesium) {
f.engine.fake.script('pick', {
id: 'cable-reference-0-alpha',
owner: PATHS,
});
f.engine.fake.input('click', { position: { x: 1, y: 1 } });
f.engine.fake.unscript('pick');
assert.equal(f.flights.length, 4, 'a hidden display takes no clicks');
}
f.engine.fake.script('pick', {
id: 'cable-reference-0-alpha',
owner: PATHS,
});
f.engine.fake.input('click', { position: { x: 1, y: 1 } });
f.engine.fake.unscript('pick');
assert.equal(f.flights.length, 4, 'a hidden display takes no clicks');
});
test(`hide clears what the display drew and its labels; show draws it again (${name})`, (t) => {
+5 -68
View File
@@ -1,9 +1,4 @@
// The traffic display on the engine contract, on the fake engine and on the
// Cesium engine: the vehicle sets (outline under fill, depth-tested, not
// picked), each dot's size and fade by camera distance as Cesium's
// NearFarScalar, what is skipped, show and hide; the heat-line corridors
// (colours, widths, order, drape on 3D tiles) and the layer's jam and slow
// styles; the sets heights are sampled through; teardown.
// Layer display behavior on the fake engine.
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -53,32 +48,7 @@ function drawn(h, id) {
const listed = h.engine.movers.list().find((set) => set.id === id);
if (!listed) return undefined;
const rows = [];
if (h.cesium) {
const items = (collection) =>
Array.from({ length: collection?.length ?? 0 }, (_, i) =>
collection.get(i),
);
let collection;
for (const root of items(h.viewer.scene.primitives))
for (const child of items(root))
if (child.gevMoverSet === id) collection = child;
for (let i = 0; i < listed.count; i++) {
const point = collection.get(i);
rows.push({
sizePx: point.pixelSize,
color: [
point.color.red,
point.color.green,
point.color.blue,
point.color.alpha,
],
shown: point.show,
outlineWidth: point.outlineWidth,
depthTestDistance: point.disableDepthTestDistance,
scaleByDistance: point.scaleByDistance,
});
}
} else {
{
const { store, options } = h.engine.fake.moverSet(id);
for (let i = 0; i < store.count; i++)
rows.push({
@@ -141,9 +111,7 @@ for (const makeHarness of GROUND_HARNESSES) {
[IDS.outline, IDS.fill],
);
for (const [index, id] of [IDS.outline, IDS.fill].entries()) {
const { options } = h.cesium
? { options: null }
: h.engine.fake.moverSet(id);
const { options } = h.engine.fake.moverSet(id);
assert.equal(
sets.find((set) => set.id === id).capacity,
MAX_DOTS,
@@ -198,13 +166,6 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(outlines.length, 1, 'only the outlined dot has an outline');
near(outlines[0].sizePx, (7 + 2 * 2) * jamScale, 'outline size', 1e-4);
nearAll(outlines[0].color, [0, 0, 0, 0.9 * fade], 'outline colour', 1e-6);
if (h.cesium) {
for (const row of [...fills, ...outlines]) {
assert.equal(row.shown, true);
assert.equal(row.outlineWidth, 0);
assert.equal(row.depthTestDistance, 0, 'depth-tested');
}
}
});
test(`hidden, zero-size, faded-out and over-the-horizon dots draw nothing (${name})`, (t) => {
@@ -284,19 +245,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.deepEqual(jam.items[0].color, [1, 0, 0, 0.55]);
assert.equal(slow.items.length, 1);
assert.equal(slow.items[0].widthPx, 4);
if (h.cesium) {
const { fills, lines } = h.primitives(IDS.jam);
assert.equal(fills.length, 0);
assert.equal(lines.length, 1, 'one ground polyline batch');
assert.equal(
lines[0].options.classificationType,
Cesium.ClassificationType.CESIUM_3D_TILE,
);
assert.equal(
lines[0].options.geometryInstances[0].geometry.width,
HEAT_LINE_JAM_WIDTH,
);
}
display.clearHeat();
assert.equal(h.set(IDS.jam), undefined);
assert.equal(h.set(IDS.slow), undefined);
@@ -364,19 +313,7 @@ for (const makeHarness of GROUND_HARNESSES) {
h.engine.ground.list().find(({ id }) => id === IDS.jam).animated,
true,
);
if (h.cesium) {
// The jam batch draws through the engine's animated material.
const [line] = h.primitives(IDS.jam).lines;
const { uniforms } = line.options.appearance.material;
near(uniforms.glowPower, 0.25, 'glowPower');
near(uniforms.pulseMin, 0.35, 'pulse min', 1e-6);
near(uniforms.pulseMax, 0.75, 'pulse max', 1e-6);
near(uniforms.pulsePeriod, (2 * Math.PI * 260) / 1000, 'pulse period');
assert.equal(
line.options.classificationType,
Cesium.ClassificationType.CESIUM_3D_TILE,
);
}
assert.equal(slow.items.length, 1);
nearAll(slow.items[0].color, HEAT_SLOW_COLOR.rgba(), 'slow colour');
assert.equal(slow.items[0].widthPx, HEAT_LINE_SLOW_WIDTH);
+9 -171
View File
@@ -1,19 +1,11 @@
// 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.
// Layer display behavior on the fake engine.
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';
@@ -65,91 +57,7 @@ function fakeHarness(options = {}) {
};
}
function cesiumHarness() {
const handlers = createFakeEventHandlers();
const groundPrimitives = [];
const groundPrimitive = (options) => {
const primitive = {
...options,
ready: true,
destroy() {},
isDestroyed: () => false,
};
groundPrimitives.push(primitive);
return primitive;
};
const { engine, viewer, frame, primitive } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
groundFactories: {
lines: groundPrimitive,
fills: groundPrimitive,
},
},
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
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,
groundPrimitives,
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];
const HARNESSES = [fakeHarness];
function setup(
makeHarness,
@@ -325,13 +233,7 @@ for (const makeHarness of HARNESSES) {
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);
@@ -344,9 +246,7 @@ for (const makeHarness of HARNESSES) {
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')];
const before = [f.commits('transit-1'), f.commits('transit-motion-1')];
let wrote = false;
const remove = f.display.onPreRender(() => {
if (wrote) return;
@@ -426,13 +326,6 @@ for (const makeHarness of HARNESSES) {
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);
@@ -509,7 +402,7 @@ for (const makeHarness of HARNESSES) {
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 },
@@ -623,13 +516,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(lines.length, 3);
const lanes = lines.map(({ id }) => ({ id, ...f.trailLine(id) }));
assert.ok(lanes.every((lane) => lane.options.dynamic));
if (f.cesium) {
assert.equal(f.groundPrimitives.length, 3);
assert.ok(
f.groundPrimitives.every((primitive) => !primitive.asynchronous),
'history and head build immediately without worker jobs',
);
}
const [bodyBacking, bodyLane, headLane] = lanes;
assert.equal(bodyBacking.points, 2);
assert.equal(bodyLane.points, 2);
@@ -649,7 +536,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(headLane.fraction, 0.5);
assert.equal(headLane.commits, 1, 'head geometry built once');
assert.equal(headLane.options.pickable, undefined, 'picks pass through');
if (!f.cesium) {
{
assert.equal(bodyBacking.widthPx, 5);
assert.equal(bodyLane.widthPx, 3);
}
@@ -818,55 +705,6 @@ test('sensor styles drawn by the engine move a click to the pixel the shader sho
engine.destroy();
});
test("on the Cesium engine the surveillance lens and FLIR's pixel grid move a click too", () => {
const h = cesiumHarness();
// The scene's post stages, which draw the styles.
h.viewer.scene.postProcessStages = {
bloom: { enabled: false, uniforms: {} },
add() {},
remove() {},
};
Object.assign(h.viewer.scene.canvas, {
clientWidth: 1920,
clientHeight: 1080,
width: 1920,
height: 1080,
});
const state = { _stylePreset: 'surveillance', _vehicles: new Map() };
const display = createTransitDisplay(h.engine, {
state,
services: {},
drawAtlas: transitTestAtlas,
});
display.attach();
const seen = [];
display.bindClick(
({ x, y }) => seen.push([x, y]),
() => true,
);
h.click({ x: 1440, y: 540 });
assert.deepEqual(seen.at(-1), [1440, 540], 'no style, no bend');
h.engine.render.setStyle('surveillance', {
intensity: 1,
uniforms: { pixelation: 1 },
});
h.click({ x: 1440, y: 540 });
near(seen.at(-1)[0], 1472, 'barrel distortion');
near(seen.at(-1)[1], 540, 'on the axis');
near(h.picked().at(-1).x, 1472, 'the engine picks where the lens drew');
state._stylePreset = 'thermal';
h.engine.render.setStyle('surveillance', { intensity: 0 });
h.engine.render.setStyle('thermal', {
intensity: 1,
uniforms: { pixelation: 1.5 },
});
h.click({ x: 100.9, y: 200.1 });
near(seen.at(-1)[0], 100.5, 'pixel grid x');
near(seen.at(-1)[1], 201, 'pixel grid y');
display.destroy();
h.engine.destroy();
});
test('terrain answers the floor unless photoreal tiles hide the globe', async () => {
const engine = createFakeEngine();
const display = createTransitDisplay(engine, {
+7 -73
View File
@@ -1,3 +1,4 @@
import { createFakeGroundHarness as fakeHarness } from '../../../testSupport/groundDisplayEngines.mjs';
// The vessels display on the engine contract, on the fake engine and on the
// Cesium engine (real Cesium billboards and polylines over a stub scene:
// they draw nothing but hold what the engine writes): the chevron bank
@@ -10,7 +11,7 @@ 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 { createPickRegistry } from '../../../data/pickRegistry.js';
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
import { vesselTypeCss } from '../../../data/vesselLabels.js';
@@ -32,28 +33,7 @@ const near = (actual, expected, label, tolerance = 1e-6) =>
const CARGO = vesselChevronIcon(vesselTypeCss('Cargo'), false);
const SELECTED = vesselChevronIcon('#ffffff', true);
function fakeHarness() {
const engine = createFakeEngine();
return {
name: 'the fake engine',
engine,
frame: () => engine.fake.frame(),
destroy: () => engine.destroy(),
};
}
function cesiumHarness() {
const { engine, ...rest } = createCesiumMoversEngine();
return {
name: 'the Cesium engine',
cesium: true,
engine,
...rest,
destroy: () => engine.destroy(),
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
const HARNESSES = [fakeHarness];
async function setup(makeHarness) {
const harness = makeHarness();
@@ -82,17 +62,6 @@ const spec = (record, overrides = {}) => ({
/** What a chevron draws. */
function drawn(h, setId, index) {
if (h.cesium) {
const billboard = h.primitive(setId).get(index);
return {
id: billboard.id,
position: billboard.position,
scale: billboard.scale,
rotation: billboard.rotation,
alpha: billboard.color.alpha,
show: billboard.show,
};
}
const { store } = h.engine.fake.moverSet(setId);
return {
id: store.pickIds[index],
@@ -147,23 +116,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(chevron.id, '111', 'picks name the MMSI');
near(chevron.scale, 0.6, 'scale');
assert.equal(chevron.show, true);
if (h.cesium) {
const billboard = h.primitive(listed.id).get(0);
assert.ok(h.primitive(listed.id) instanceof Cesium.BillboardCollection);
assert.equal(billboard.width, 32);
assert.equal(billboard.height, 32);
assert.equal(
billboard.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.equal(billboard.horizontalOrigin, Cesium.HorizontalOrigin.CENTER);
assert.equal(billboard.verticalOrigin, Cesium.VerticalOrigin.CENTER);
assert.ok(
Cesium.Cartesian3.equals(billboard.alignedAxis, Cesium.Cartesian3.ZERO),
'rotation in screen space',
);
assert.equal(billboard.scaleByDistance, undefined);
} else {
{
const { options } = engine.fake.moverSet(listed.id);
assert.equal(options.depthTest, 'always');
assert.equal(options.sprite.sizePx, 32);
@@ -173,9 +126,7 @@ for (const makeHarness of HARNESSES) {
assert.equal(chevron.cell, CARGO);
}
// Writes inside a frame listener commit at its end, once.
const commits = h.cesium
? null
: () => engine.fake.moverSet(listed.id).commits;
const commits = () => engine.fake.moverSet(listed.id).commits;
const before = commits?.();
const moved = geo.cartesian3.fromDegrees(-73.8, 40.6, 1);
const remove = display.view.on('preRender', () => {
@@ -193,7 +144,7 @@ for (const makeHarness of HARNESSES) {
near(chevron.rotation, 0.5, 'rotation');
near(chevron.scale, 0.72, 'scale');
near(chevron.alpha, 0.3, 'alpha');
if (!h.cesium) assert.equal(chevron.cell, SELECTED);
assert.equal(chevron.cell, SELECTED);
// Reads are the display's own copies.
assert.equal(handle.image, SELECTED);
assert.equal(handle.color.alpha, 0.3);
@@ -300,24 +251,7 @@ for (const makeHarness of HARNESSES) {
assert.match(line.id, /^gev-trail:ais-live-vessels-\d+$/);
assert.equal(line.points, 2, 'near-duplicates and gaps dropped');
const base = Cesium.Color.fromCssColorString('#00d4ff');
if (h.cesium) {
const primitive = h.primitive(line.id);
assert.equal(primitive.allowPicking, true);
assert.equal(primitive.geometryInstances.id, line.id);
assert.equal(
primitive.geometryInstances.geometry._arcType,
Cesium.ArcType.GEODESIC,
);
assert.equal(primitive.geometryInstances.geometry._width, 2.5);
const { color } = primitive.appearance.material.uniforms;
near(color.red, base.red, 'red');
near(color.alpha, 0.85, 'alpha');
near(
primitive.depthFailAppearance.material.uniforms.color.alpha,
0.4,
'occluded alpha',
);
} else {
{
const { options } = engine.fake.line(line.id);
assert.equal(options.arc, 'geodesic');
assert.equal(options.pickId, line.id);
@@ -1,10 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as geo from '../../geo/index.js';
import {
createFakeGroundHarness,
createCesiumGroundHarness,
} from '../../testSupport/groundDisplayEngines.mjs';
import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import {
createRendering,
@@ -454,7 +451,7 @@ test('a selected observation remains rendered outside the viewport and observati
h.rendering.destroy();
});
for (const make of [createFakeGroundHarness, createCesiumGroundHarness]) {
for (const make of [createFakeGroundHarness]) {
test(`privacy cells drape accent fills and outlines and release them (${make.name})`, () => {
const cell = { south: 47.6, west: -122.4, north: 47.8, east: -122.2 };
const groundHarness = make();
-1
View File
@@ -42,7 +42,6 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(diagnostics.recentRequests[0].reason, 'tick-4');
diagnostics.holds.push('external');
assert.deepEqual(governor.getRenderGovernorDiagnostics().holds, []);
if (harness.viewer) assert.equal(harness.viewer.scene.maximumRenderTimeChange, Infinity);
});
test(`${makeHarness.name}: identical owners and teardown stay in their scene`, (t) => {
+2 -120
View File
@@ -1,13 +1,5 @@
// 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';
// CCTV input tests use scripted picks and a canvas with style and window events.
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from './cesiumMoversEngine.mjs';
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -80,114 +72,4 @@ export function fakeCctvHarness() {
};
}
/** 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,
scheduleFrame: (run) => run(),
},
});
viewer.scene.pick = () => undefined;
viewer.scene.drillPick = () => [];
const canvas = viewer.scene.canvas;
canvas.style = {};
canvas.ownerDocument = { defaultView: new EventTarget() };
viewer.scene.screenSpaceCameraController = { enableInputs: true };
// Camera inputs off at each suspension, on again at its resume.
const inputs = [];
const suspend = engine.input.suspendCameraInputs;
engine.input.suspendCameraInputs = () => {
inputs.push(false);
const resume = suspend();
return () => {
inputs.push(true);
resume();
};
};
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(0, 0, VIEW.height),
});
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];
export const CCTV_HARNESSES = [fakeCctvHarness];
+3 -106
View File
@@ -1,18 +1,6 @@
// Ground displays under test, on the fake engine and on the Cesium engine.
// Both harnesses record what each ground set was given (the items of its
// last commit, its commits) and count the live input listeners. The Cesium
// one is the engine over a real Cesium camera on a stub scene
// (cesiumMoversEngine.mjs) with fake GroundPrimitive and
// GroundPolylinePrimitive factories: the engine's own geometry instances,
// colours and options reach them, and they draw nothing. A click goes
// through the engine's input with a pick of a ground item (`{ set,
// instance }`), of another object (`{ id }`) or of nothing (undefined).
import * as Cesium from 'cesium';
// Ground display tests record committed items and input ownership on the fake engine.
import { normalizeGroundSetOptions } from '../globe/ground.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { createFakeEventHandlers } from '../globe/cesium/testing/fakeViewer.mjs';
import { createCesiumMoversEngine } from './cesiumMoversEngine.mjs';
const CLICK_AT = { x: 100, y: 100 };
@@ -73,8 +61,8 @@ export function createFakeGroundHarness(options = {}) {
const watched = watch(engine);
return {
name: 'the fake engine',
cesium: false,
engine,
frame: () => engine.fake.frame(),
...watched,
click(target, position = CLICK_AT) {
let picked = target;
@@ -94,95 +82,4 @@ export function createFakeGroundHarness(options = {}) {
};
}
/**
* The Cesium engine over a stub scene, with fake ground primitives; with
* `realFills`, Cesium's own GroundPrimitive draws the fills (it builds in
* Node; GroundPolylinePrimitive does not).
*/
export function createCesiumGroundHarness({
realFills = false,
engineOptions = {},
} = {}) {
const handlers = createFakeEventHandlers();
const primitive = (type) => (options) => {
const value = {
type,
options,
ready: false,
show: true,
destroyed: false,
destroy() {
value.destroyed = true;
},
isDestroyed: () => value.destroyed,
};
return value;
};
const { engine, viewer } = createCesiumMoversEngine({
engineOptions: {
...engineOptions,
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
groundFactories: {
...(realFills ? {} : { fills: primitive('fills') }),
lines: primitive('lines'),
},
},
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
const children = (collection) =>
Array.from({ length: collection?.length ?? 0 }, (_, index) =>
collection.get(index),
);
// The engine's collection of sets, each set's collection of primitives.
const slots = () => children(viewer.scene.groundPrimitives.get(0));
const slotOf = (id) =>
slots().find((slot) =>
children(slot).some((value) => value.gevGroundSet === id),
);
const live = (id) => children(slotOf(id));
const typeOf = (value) =>
value.type ?? (value instanceof Cesium.GroundPrimitive ? 'fills' : 'lines');
return {
name: 'the Cesium engine',
cesium: true,
engine,
viewer,
...watch(engine),
/** The live Cesium primitives of a set: fills and outlines. */
primitives: (id) => ({
fills: live(id).filter((value) => typeOf(value) === 'fills'),
lines: live(id).filter((value) => typeOf(value) === 'lines'),
}),
/** Whether the set's Cesium collection is shown (undefined: none). */
collectionShown: (id) => slotOf(id)?.show,
click(target, position = CLICK_AT) {
native = undefined;
if (target?.set !== undefined) {
const [first] = live(target.set);
native = {
id: { gevGroundSet: target.set, instance: target.instance },
primitive: first,
};
} else if (target) native = { id: target.id, primitive: {} };
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(position.x, position.y),
});
native = undefined;
},
destroy: () => engine.destroy(),
};
}
export const GROUND_HARNESSES = [
createFakeGroundHarness,
createCesiumGroundHarness,
];
/** A Cesium colour attribute's value as rgba in 0..1. */
export const instanceColor = (instance) =>
Array.from(instance.attributes.color.value, (byte) => byte / 255);
export const GROUND_HARNESSES = [createFakeGroundHarness];
+23 -134
View File
@@ -1,148 +1,37 @@
// Display cases run on both engines. Cesium uses real imagery layers and a
// headless camera; only GPU shell drawing and provider requests are faked.
import nodeTest from 'node:test';
import * as Cesium from 'cesium';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { normalizeImageryLayerOptions } from '../globe/imagery.js';
import { createCesiumMoversEngine } from './cesiumMoversEngine.mjs';
function createCesiumImageryEngine() {
let renderRequests = 0;
let pending = false;
let frame;
const {
engine,
viewer,
frame: draw,
} = createCesiumMoversEngine({
engineOptions: {
requestRender() {
renderRequests++;
if (pending) return;
pending = true;
queueMicrotask(() => {
pending = false;
if (!engine.isDestroyed()) frame();
});
},
imageryFactories: {
tiles(options) {
const tilingScheme =
options.tilingScheme ?? new Cesium.WebMercatorTilingScheme();
return {
tilingScheme,
rectangle: options.rectangle ?? tilingScheme.rectangle,
tileWidth: 256,
tileHeight: 256,
minimumLevel: 0,
maximumLevel: 18,
errorEvent: new Cesium.Event(),
requestImage: () => undefined,
};
},
shell(options) {
let detail = null;
return {
rectangle: options.rectangle,
setImage() {},
setAlpha() {},
setShow() {},
setDetail: (image) => {
detail = image;
},
detail: () => detail,
drawn: () => true,
destroy() {},
};
},
},
},
});
frame = draw;
viewer.scene.globe.show = true;
viewer.scene.globe.tilesLoaded = true;
viewer.imageryLayers = new Cesium.ImageryLayerCollection();
const layers = new Map();
const create = engine.imagery.createLayer;
engine.imagery.createLayer = (options) => {
const layer = create(options);
layers.set(layer.id, {
layer,
options: normalizeImageryLayerOptions(options),
});
return layer;
};
engine.fake = {
get renderRequests() {
return renderRequests;
},
script(method, value) {
engine.camera[method] = typeof value === 'function' ? value : () => value;
},
emit(name) {
if (name === 'frames.preRender') frame();
else throw new Error(`Unsupported event ${name}`);
},
frames(count) {
for (let i = 0; i < count; i++) frame();
},
imageryLayers: () => engine.imagery.list().map(({ id }) => id),
imageryLayer(id) {
const record = layers.get(id);
if (!record || record.layer.isDestroyed()) return undefined;
const { layer, options } = record;
return {
options,
source: layer.source(),
alpha: layer.alpha(),
shown: layer.shown(),
split: layer.split(),
raise: layer.raise(),
detail: layer.detail(),
staging: layer.staging(),
};
},
};
return engine;
}
/** Each test owns its engines and releases them even after an assertion fails. */
export function imageryDisplayTests() {
let make;
let engines;
return {
test(name, run) {
for (const factory of [createFakeEngine, createCesiumImageryEngine])
nodeTest(
`${name} (${factory === createFakeEngine ? 'fake' : 'cesium'})`,
async (t) => {
make = factory;
engines = [];
if (!globalThis.document) {
globalThis.document = {
createElement: () => ({
getContext: () => ({
createImageData: (w, h) => ({
data: new Uint8ClampedArray(w * h * 4),
}),
putImageData() {},
}),
nodeTest(name, async (t) => {
engines = [];
if (!globalThis.document) {
globalThis.document = {
createElement: () => ({
getContext: () => ({
createImageData: (w, h) => ({
data: new Uint8ClampedArray(w * h * 4),
}),
};
t.after(() => {
delete globalThis.document;
});
}
try {
await run(t);
} finally {
for (const engine of engines) engine.destroy();
}
},
);
putImageData() {},
}),
}),
};
t.after(() => {
delete globalThis.document;
});
}
try {
await run(t);
} finally {
for (const engine of engines) engine.destroy();
}
});
},
createEngine(options) {
const engine = make(options);
const engine = createFakeEngine(options);
engines.push(engine);
return engine;
},
+1 -2
View File
@@ -413,10 +413,9 @@ export function cesiumStreetLevelMap({ view = true, terrain = 0 } = {}) {
};
}
/** The display on each engine: the fake one and Cesium's, over Sacramento. */
/** The display on the fake engine over Sacramento. */
export const STREET_LEVEL_MAPS = Object.freeze([
() => ({ name: 'the fake engine', ...streetLevelMap({ view: true }) }),
() => cesiumStreetLevelMap(),
]);
/** The centre of the tile holding a point at zoom `z`, so a small view is one tile. */
@@ -1,19 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { watchW1Page } from '../../scripts/qa-browserEvidence.mjs';
import { watchBrowserPage } from '../../scripts/qa-browserEvidence.mjs';
const baseUrl = 'http://127.0.0.1:4195/';
const message = (type, text, url) => ({
type: () => type,
text: () => text,
location: () => ({ url }),
});
test('W1 main and share pages aggregate console, runtime and crash errors', () => {
test('Browser main and share pages aggregate console, runtime and crash errors', () => {
const report = {},
main = new EventEmitter(),
shared = new EventEmitter();
watchW1Page(main, report, { name: 'main', baseUrl });
watchW1Page(shared, report, { name: 'share', baseUrl });
watchBrowserPage(main, report, { name: 'main', baseUrl });
watchBrowserPage(shared, report, { name: 'share', baseUrl });
main.emit('console', message('warning', 'ordinary warning', baseUrl));
shared.emit(
'console',
@@ -26,10 +26,10 @@ test('W1 main and share pages aggregate console, runtime and crash errors', () =
assert.match(report.browserErrors[0], /\[share\] late error/);
assert.ok(!JSON.stringify(report).includes('sample-secret'));
});
test('W1 preview exception stays exact on a share page', () => {
test('Browser preview exception stays exact on a share page', () => {
const report = {},
shared = new EventEmitter();
watchW1Page(shared, report, { name: 'share', baseUrl, preview: true });
watchBrowserPage(shared, report, { name: 'share', baseUrl, preview: true });
const failure = (url) =>
shared.emit(
'console',
@@ -45,10 +45,10 @@ test('W1 preview exception stays exact on a share page', () => {
assert.deepEqual(report.expectedPreviewErrors, ['/api/setup/status: 404']);
assert.equal(report.browserErrors.length, 2);
});
test('W1 response diagnostics retain page/status/path without query credentials', () => {
test('Browser response diagnostics retain page/status/path without query credentials', () => {
const report = {},
shared = new EventEmitter();
watchW1Page(shared, report, { name: 'share', baseUrl });
watchBrowserPage(shared, report, { name: 'share', baseUrl });
shared.emit('response', {
status: () => 503,
url: () => baseUrl + 'api/vessels?token=sample-secret',
+1 -1
View File
@@ -109,7 +109,7 @@ for (const [name, files, pattern] of [
],
[
'W1 shared trackingClickGesture keeps SDK imports in the engine',
{ 'src/data/trackingClickGesture.js': "import 'cesium';" },
{ 'src/data/trackingGesture.js': "import 'cesium';" },
/cesium imports belong in src\/globe\/cesium\//,
],
[