Hand features the engine instead of guessing it from a viewer

No code outside src/globe calls isGlobeEngine or engineFromViewer any
more (14 calls left before this). The features that still sniffed take
the engine from their caller:

- Projection and surface helpers (focus de-emphasis, icon orientation,
  ground snap, the mesh floor sampler, ground picks) take the engine; the
  engine view's scene carries its projector instead of registering itself
  as a viewer.
- Credits go only through engine.credits, so annotations and layers
  share one OSM credit state on Cesium too (it could show twice).
- The annotation board and the resolver work on the engine: camera
  assists, visibility and picks go through engine.camera,
  engine.projector and src/geo, without Cesium.
- The Recent Imagery box tool has one implementation for both engines,
  on capture-phase canvas pointer events with the layer's engine.ground
  preview; the Navara copy goes. On Cesium the preview outline is solid
  now, as on Navara.
- Scene interactions take the engine, and the render governor has an
  engine variant beside the Cesium one.

Remaining engine-specific features stay behind explicit seams until the
contract covers them: the draw tool's Cesium surface (stock click actions
cannot be suspended), the annotation drapes (animated materials), share
follow, Cesium post effects and cyber sonar, and data packs (meshes).

Coupling: Cesium-importing files fall from 31 to 27, `viewer` lines
from 1367 to 1234, engine sniffing calls from 14 to 0.
This commit is contained in:
German Khokhlov committed 2026-11-07 08:46:30 +04:00
1 parent 406dd9122e
commit f1ec9d4a22
61 files changed
+823 -1230

No files matched your search

+6 -6
View File
@@ -2,15 +2,15 @@
"metrics": {
"cesiumLayerRendererLines": 330,
"navaraLayerRendererLines": 2304,
"cesiumImportFiles": 31,
"viewerLines": 1367,
"navaraKindChecks": 6,
"engineSniffCalls": 14,
"cesiumImportFiles": 27,
"viewerLines": 1234,
"navaraKindChecks": 5,
"engineSniffCalls": 0,
"engineExtensionReads": 12,
"groundEffectPasses": 3,
"cesiumSdkLoads": 4,
"screenSpaceHandlerFiles": 4,
"engineNameLines": 78
"engineNameLines": 73
},
"measuredAt": "after engine.imagery with FIRMS heat and Recent Imagery on it, 2026-11-07"
"measuredAt": "after the phase 7 feature moves, 2026-11-07"
}
-1
View File
@@ -1401,7 +1401,6 @@
"scripts/fixtures/w2/radio.mjs",
"scripts/qa-map-policy.mjs",
"scripts/qa-map-policy.test.mjs",
"src/ui/imageryBoxNavara.test.mjs",
"scripts/qa-w2-imagery.mjs",
"src/globe/navara/postSamplingPass.test.mjs",
"src/globe/navara/emptySceneRender.test.mjs",
-1
View File
@@ -3415,7 +3415,6 @@
"src/layers/vessels/icons.js",
"src/maps/sceneApply.js",
"src/layers/localAdsb/qaDrawn.js",
"src/ui/imageryBoxNavara.js",
"src/geo/",
"src/time/clock.js",
"src/globe/navara/overlay.js",
+5 -4
View File
@@ -6,9 +6,10 @@ import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from './resolver.js';
import { createAnnotationEngine } from './annotationEngine.js';
import { _resetRenderGovernorForTest } from '../renderGovernor.js';
import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs';
function viewerAt(lat, lon, height = 20_000) {
return {
return cameraOnlyEngine({
camera: {
positionCartographic: {
latitude: (lat * Math.PI) / 180,
@@ -16,7 +17,7 @@ function viewerAt(lat, lon, height = 20_000) {
height,
},
},
};
});
}
/** A resolver whose boundary lookups and geocoder record every call. */
@@ -45,7 +46,7 @@ function harness(place = null) {
const resolve = (target, viewer, extra = {}) =>
resolveAnnotationTarget({
placeSearch,
viewer,
engine: viewer,
target,
footprint: true,
deferFootprint: true,
@@ -287,7 +288,7 @@ test('the engine keeps every part on the mark, drawn at once or upgraded', async
destroy() {},
};
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer,
resolveTarget: async ({ target }) =>
target === 'now'
+27 -70
View File
@@ -1,8 +1,6 @@
import { isUnavailableCapability } from '../sources/capability.js';
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
import { isGlobeEngine } from '../globe/contract.js';
import { greatCircleMeters } from '../geoDistance.js';
import {
holdContinuousRender,
@@ -22,16 +20,6 @@ import {
} from '../sources/phaseTiming.js';
import { formatDistanceText } from '../data/distanceFormat.js';
// Dev convenience: expose the app's Cesium instance for console/preview probing
// (single shared module instance — avoids dual-Cesium state bugs when testing).
// A getter, because the production build's Cesium global loads only with the
// first Cesium globe.
if (typeof window !== 'undefined' && !('__CESIUM__' in window))
Object.defineProperty(window, '__CESIUM__', {
configurable: true,
get: () => Cesium,
});
/**
* Annotation engine — the voice agent's "whiteboard" over the 3D world.
*
@@ -148,7 +136,7 @@ const DETACHED_RENDERER = Object.freeze({
export function createAnnotationEngine({
placeSearch = defaultGeospatial,
viewer = null,
globe = null,
renderer = DETACHED_RENDERER,
outlineRetryDelaysMs = OUTLINE_RETRY_DELAYS_MS,
resolveTarget = resolveAnnotationTarget,
@@ -428,7 +416,7 @@ export function createAnnotationEngine({
* @returns {Promise<{ok, drawn, failed, ids, results}>}
*/
async function annotate(requests, opts = {}) {
if (destroyed || !viewer)
if (destroyed || !globe)
return {
ok: false,
drawn: 0,
@@ -437,7 +425,7 @@ export function createAnnotationEngine({
results: [],
error: destroyed
? 'destroyed'
: 'Annotations are drawn on the Cesium globe only.',
: 'Annotations are drawn on a globe only.',
};
const list = (Array.isArray(requests) ? requests : [requests]).map(
promoteStreetHighlight,
@@ -914,7 +902,7 @@ export function createAnnotationEngine({
const type = normalizeType(spec?.type);
// MANUAL geometry (drawTool.js): the person clicked the vertices, so there is
// nothing to resolve. Same record shapes as a resolved spec, source 'manual'.
if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);
if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, globe);
if (type === 'route') {
const points = Array.isArray(spec.points) ? spec.points : [];
if (points.length < 2)
@@ -925,7 +913,7 @@ export function createAnnotationEngine({
const name = pt.target ?? pt.name ?? null;
const r = await resolveTarget({
placeSearch,
viewer,
engine: globe,
target: name,
latitude: pt.latitude,
longitude: pt.longitude,
@@ -983,7 +971,7 @@ export function createAnnotationEngine({
if (type === 'arrow') {
const from = await resolveTarget({
placeSearch,
viewer,
engine: globe,
target: spec.target,
latitude: spec.latitude,
longitude: spec.longitude,
@@ -995,7 +983,7 @@ export function createAnnotationEngine({
});
const to = await resolveTarget({
placeSearch,
viewer,
engine: globe,
target: spec.toTarget,
latitude: spec.toLatitude,
longitude: spec.toLongitude,
@@ -1028,7 +1016,7 @@ export function createAnnotationEngine({
type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
return resolveTarget({
placeSearch,
viewer,
engine: globe,
target: spec.target,
latitude: spec.latitude,
longitude: spec.longitude,
@@ -1669,7 +1657,7 @@ export function createAnnotationEngine({
/** Fly the camera to one mark; a line is framed whole, not by its start. */
function frame(id) {
const anno = annotations.get(id);
if (!anno || !viewer) return false;
if (!anno || !globe) return false;
if (anno.type === 'route' && anno.path?.length) {
const ring = anno.path.map((p) => [p.lon, p.lat]);
// Unwrap around the first vertex, as ringRange does, so a line across
@@ -1739,7 +1727,7 @@ export function createAnnotationEngine({
const cart = points.map((p) =>
geo.cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
);
const sphere = Cesium.BoundingSphere.fromPoints(cart);
const sphere = geo.boundingSphere.fromPoints(cart);
flyToSphere(sphere.center, sphere.radius, {
pitch: geo.math.toRadians(-35),
range: Math.max(900, sphere.radius * 2.6),
@@ -1755,23 +1743,7 @@ export function createAnnotationEngine({
function isPointOnScreen(p) {
try {
const pos = geo.cartesian3.fromDegrees(p.lon, p.lat, p.height || 0);
if (isGlobeEngine(viewer)) return engineSeesPoint(viewer, pos);
const camera = viewer.camera;
const cv = camera.frustum.computeCullingVolume(
camera.position,
camera.direction,
camera.up,
);
if (
cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) ===
Cesium.Intersect.OUTSIDE
)
return false;
const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
camera.position,
);
return occluder.isPointVisible(pos);
return engineSeesPoint(globe, pos);
} catch {
return true;
}
@@ -1810,28 +1782,13 @@ export function createAnnotationEngine({
}
/**
* Fly to a sphere keeping the camera's heading: Cesium's
* flyToBoundingSphere on a viewer, the contract's flyToSphere on a globe
* engine (the same offset semantics).
* Fly to a sphere keeping the camera's heading (the contract's
* flyToSphere: Cesium's flyToBoundingSphere offset).
*/
function flyToSphere(center, radius, { pitch, range, duration }) {
if (isGlobeEngine(viewer)) {
viewer.camera.flyToSphere(
{ center, radius },
{ heading: viewer.camera.pose().heading, pitch, range, duration },
);
return;
}
viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(center, radius),
{
offset: new Cesium.HeadingPitchRange(
viewer.camera.heading,
pitch,
range,
),
duration,
},
globe.camera.flyToSphere(
{ center, radius },
{ heading: globe.camera.pose().heading, pitch, range, duration },
);
}
@@ -1861,7 +1818,7 @@ export function createAnnotationEngine({
generation++;
const previous = renderer;
renderer = DETACHED_RENDERER;
viewer = null;
globe = null;
for (const anno of annotations.values()) {
anno.pendingOutline = false;
try {
@@ -1877,13 +1834,13 @@ export function createAnnotationEngine({
}
},
/**
* Draw the board on a globe: `viewer` and the `renderer` built for it.
* Marks kept while no globe drew them appear again as they were.
* Draw the board on a globe: its `engine` and the `renderer` built for
* it. Marks kept while no globe drew them appear again as they were.
*/
attachRenderer({ viewer: nextViewer, renderer: nextRenderer }) {
attachRenderer({ engine: nextGlobe = null, renderer: nextRenderer }) {
if (destroyed) return;
engine.detachRenderer();
viewer = nextViewer;
globe = nextGlobe;
renderer = nextRenderer ?? DETACHED_RENDERER;
for (const anno of annotations.values()) {
try {
@@ -2093,7 +2050,7 @@ export function createAnnotationEngine({
}) {
if (destroyed) return;
try {
viewer?.camera.flyTo({
globe?.camera.flyTo({
destination: geo.cartesian3.fromDegrees(lon, lat, height),
orientation: {
heading: geo.math.toRadians(heading),
@@ -2371,7 +2328,7 @@ function manualPairs(list) {
* (not synthesized) outline so it drapes solid; a line is a `route` with mode
* 'manual', its length as distance and no travel time; a pin is a point.
*/
function resolveManualSpec(spec, type, viewer) {
function resolveManualSpec(spec, type, globe) {
if (type === 'route') {
// height 0 is a placeholder, not a placement: the world renderer draws a
// route with clampToGround + CESIUM_3D_TILE classification, so the line is
@@ -2412,7 +2369,7 @@ function resolveManualSpec(spec, type, viewer) {
return {
lon,
lat,
height: sampleGroundHeight(viewer, lon, lat),
height: sampleGroundHeight(globe, lon, lat),
ring,
...(polygons ? { polygons } : {}),
footprintKind: 'area',
@@ -2427,7 +2384,7 @@ function resolveManualSpec(spec, type, viewer) {
return {
lon,
lat,
height: sampleGroundHeight(viewer, lon, lat),
height: sampleGroundHeight(globe, lon, lat),
ring: null,
source: 'manual',
};
@@ -2490,8 +2447,8 @@ function appendDistance(baseLabel, distM) {
/**
* Whether a globe engine shows a world point: in front of the camera, inside
* the canvas and on the near side of the globe (what the Cesium path's
* culling volume and ellipsoid occluder answer).
* the canvas and on the near side of the globe (what Cesium's culling
* volume and ellipsoid occluder answer).
*/
function engineSeesPoint(engine, position) {
if (!engine.projector.horizonVisible(position)) return false;
+42 -41
View File
@@ -241,7 +241,7 @@ test('outline queue: an 8-spec batch keeps at most two FIFO upgrades in flight',
};
};
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer,
resolveTarget,
});
@@ -313,7 +313,7 @@ test('outline queue: clear drops queued-but-unstarted upgrades without a later f
}),
});
engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer,
resolveTarget,
});
@@ -379,7 +379,7 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429
});
const resolved = await resolveAnnotationTarget({
viewer: {},
globe: {},
target: 'PT2 rate-limit fixture',
latitude: 12.345,
longitude: 67.89,
@@ -453,7 +453,7 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
});
const resolved = await resolveAnnotationTarget({
viewer: {},
globe: {},
target: 'PT2 transient fixture',
latitude: -12.345,
longitude: -67.89,
@@ -603,8 +603,8 @@ test('outline upgrade updates the rendered element in place without remove/add',
remove() { calls.remove += 1; },
sync() {},
};
const viewer = {};
const engine = createAnnotationEngine({ viewer, renderer, placeSearch: createStandalonePlaceSearch({ resolveApiKey: () => 'unit-test-key' }) });
const globe = {};
const engine = createAnnotationEngine({ globe, renderer, placeSearch: createStandalonePlaceSearch({ resolveApiKey: () => 'unit-test-key' }) });
const upgraded = new Promise((resolve) => engine.onOutlineEvent(resolve));
const result = await engine.annotate([{
@@ -715,7 +715,7 @@ test('fresh async building batch reserves slots together and releases failed/cle
t.after(() => _resetRenderGovernorForTest());
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer: h.renderer,
resolveTarget: async ({ target }) => ({
...BUILDING_FP,
@@ -762,7 +762,7 @@ test('outline workers retain completion ownership and clear cancels resolved-but
const h = controlledBuildingRenderer();
const started = [];
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer: h.renderer,
resolveTarget: async ({ target }) => ({
...FIXED_POINT,
@@ -805,7 +805,7 @@ test('building recaption timeout retains old route and a newer replacement owns
installAnimationFrameStubs(t);
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer: h.renderer,
resolveTarget: async () => ({ ...BUILDING_FP }),
});
@@ -860,7 +860,7 @@ test('replacement cleanup failure still removes the old route and retains failed
installAnimationFrameStubs(t);
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
});
const initial = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]);
await flushMicrotasks();
@@ -905,7 +905,7 @@ test('identical refresh revokes an older building replacement even if cancellati
installAnimationFrameStubs(t);
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
});
const initial = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]);
await flushMicrotasks(); h.pending[0].finish(); await initial;
@@ -936,7 +936,7 @@ test('successful replacement survives failed old-route retirement and keeps clea
installAnimationFrameStubs(t);
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
});
const initial = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]);
await flushMicrotasks(); h.pending[0].finish(); await initial;
@@ -975,7 +975,7 @@ test('pending fresh predecessor failure cannot erase a newer building intent', a
globalThis.requestAnimationFrame = (callback) => { frames.push(callback); return frames.length; };
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
});
const first = engine.annotate([{ type: 'area', target: 'Capitol', label: 'First' }]);
await flushMicrotasks();
@@ -1004,7 +1004,7 @@ test('duplicate pending building results reflect final failure rather than a dea
installAnimationFrameStubs(t);
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }),
});
const spec = { type: 'area', target: 'Capitol', label: 'Same' };
const batch = engine.annotate([spec, spec]);
@@ -1023,7 +1023,7 @@ test('separate duplicate callers observe the original building install outcome',
for (const outcome of ['installed', 'failed', 'timeout', 'clear']) {
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer: h.renderer,
resolveTarget: async () => ({ ...BUILDING_FP }),
});
@@ -1077,7 +1077,7 @@ test('a duplicate joins the physical deferred install but not another outline lo
const h = controlledBuildingRenderer();
let lookups = 0;
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer: h.renderer,
resolveTarget: async () => ({
...FIXED_POINT,
@@ -1119,7 +1119,7 @@ test('duplicate-replacement: a renderer throw leaves no phantom mark and no leak
const { renderer, failAddOnce } = throwingRendererHarness();
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer,
resolveTarget: async () => ({ ...FIXED_POINT }),
});
@@ -1173,7 +1173,7 @@ test('fresh path: a renderer throw is rolled back the same way', async (t) => {
const { renderer, failAddOnce } = throwingRendererHarness();
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer,
resolveTarget: async () => ({ ...FIXED_POINT }),
});
@@ -1237,7 +1237,7 @@ test('fresh path: a throw AFTER partial renderer state leaves nothing on the boa
const { renderer, failAddOnce, live } = partialStateRendererHarness();
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer,
resolveTarget: async () => ({ ...FIXED_POINT }),
});
@@ -1278,7 +1278,7 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing
const { renderer, failAddOnce, live } = partialStateRendererHarness();
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer,
resolveTarget: async () => ({ ...FIXED_POINT }),
});
@@ -1335,7 +1335,7 @@ test('destroy releases the renderer and prevents subsequent annotation work', as
renderer.destroy = () => {
destroyed++;
};
const engine = createAnnotationEngine({ viewer: {}, renderer });
const engine = createAnnotationEngine({ globe: {}, renderer });
const drawn = await engine.annotate([
{ type: 'pin', longitude: -97.74, latitude: 30.27 },
]);
@@ -1360,7 +1360,7 @@ test('a late annotation resolver cannot redraw after destruction', async (t) =>
let release;
let signal;
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer,
resolveTarget: (options) => {
signal = options.signal;
@@ -1385,9 +1385,10 @@ test('caller cancellation prevents a late annotation and camera mutation', async
let resolverSignal;
let flights = 0;
const engine = createAnnotationEngine({
viewer: {
globe: {
camera: {
flyToBoundingSphere() {
pose: () => ({ heading: 0 }),
flyToSphere() {
flights += 1;
},
},
@@ -1424,7 +1425,7 @@ test('only an explicit navigation request permits resolving distant annotation t
renderer.destroy = () => {};
const received = [];
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer,
resolveTarget: async (options) => {
received.push(options);
@@ -1448,7 +1449,7 @@ test('recaption inherits outline credit from borrowed geometry rather than its n
const { renderer } = fakeRenderer();
let first = true;
const ring = [[-99,31],[-98.99,31],[-98.99,31.01],[-99,31]];
const engine = createAnnotationEngine({ viewer: {}, renderer,
const engine = createAnnotationEngine({ globe: {}, renderer,
resolveTarget: async () => {
if (first) { first = false; return { ...FIXED_POINT, ring, footprintKind: 'area', outlineCredit: 'tiles' }; }
return { ...FIXED_POINT, source: 'osm-local', resolveOutline: async () => null };
@@ -1471,7 +1472,7 @@ test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and
const { renderer, calls } = fakeRenderer();
const flights = [];
const engine = createAnnotationEngine({
viewer: { camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } },
globe: { camera: { pose: () => ({ heading: 0 }), flyToSphere: (sphere) => flights.push(sphere) } },
renderer,
});
let changes = 0;
@@ -1540,10 +1541,10 @@ test('hand-drawn marks: remove, hide, clear, expiry and framing have their effec
const { renderer, calls } = fakeRenderer();
const flights = [];
const engine = createAnnotationEngine({
viewer: {
globe: {
camera: {
heading: 0,
flyToBoundingSphere: (sphere) => flights.push(sphere),
pose: () => ({ heading: 0 }),
flyToSphere: (sphere) => flights.push(sphere),
},
},
renderer,
@@ -1638,7 +1639,7 @@ test('hand-drawn marks: a voice mark or a hidden mark is not taken as their dupl
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({ viewer: {}, renderer });
const engine = createAnnotationEngine({ globe: {}, renderer });
await engine.annotate(
[
{
@@ -1691,7 +1692,7 @@ test('hand-drawn marks: only a manual mark can be restyled', async (t) => {
t.after(() => _resetRenderGovernorForTest());
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer,
resolveTarget: async () => ({ ...FIXED_POINT }),
});
@@ -1706,7 +1707,7 @@ test('a drawn line that rounds up to the next unit is labelled in it, not as 100
installAnimationFrameStubs(t);
const labels = [];
const renderer = { add(anno) { labels.push(anno.label); }, update() {}, remove() {}, sync() {}, destroy() {} };
const engine = createAnnotationEngine({ viewer: {}, renderer });
const engine = createAnnotationEngine({ globe: {}, renderer });
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
for (const metres of [994, 996, 9940, 9960]) {
await engine.annotate([
@@ -1722,7 +1723,7 @@ test('hand-drawn marks: two unnamed pins close together are two pins, whatever t
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({ viewer: {}, renderer });
const engine = createAnnotationEngine({ globe: {}, renderer });
const pin = (lon, color) => ({ type: 'pin', manual: true, latitude: 30.27, longitude: lon, label: '', color });
await engine.annotate([pin(-97.74, 'primary')], { persist: true });
await engine.annotate([pin(-97.7402, 'primary')], { persist: true });
@@ -1740,7 +1741,7 @@ test('hand-drawn marks: hiding a mark before its first frame takes it off at onc
t.after(() => _resetRenderGovernorForTest());
let syncs = 0;
const renderer = { add() {}, update() {}, remove() {}, sync() { syncs += 1; }, destroy() {} };
const engine = createAnnotationEngine({ viewer: {}, renderer });
const engine = createAnnotationEngine({ globe: {}, renderer });
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
const [mark] = engine.list();
const before = syncs;
@@ -1759,7 +1760,7 @@ test('hand-drawn marks: with every mark hidden the scene idles, and showing one
let renders = 0;
installRenderGovernor({ scene: { requestRenderMode: true, requestRender: () => (renders += 1) } });
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({ viewer: {}, renderer });
const engine = createAnnotationEngine({ globe: {}, renderer });
await engine.annotate([
{ type: 'pin', manual: true, latitude: 1, longitude: 1 },
{ type: 'pin', manual: true, latitude: 2, longitude: 2 },
@@ -1784,7 +1785,7 @@ test('hand-drawn marks: a restyle the renderer cannot draw takes the mark off an
const { renderer, failAddOnce } = throwingRendererHarness();
let syncs = 0;
renderer.sync = () => (syncs += 1);
const engine = createAnnotationEngine({ viewer: {}, renderer });
const engine = createAnnotationEngine({ globe: {}, renderer });
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
const [mark] = engine.list();
let changes = 0;
@@ -1811,7 +1812,7 @@ test('hand-drawn marks: a mark shown again right after it was drawn appears at o
});
const tick = () => queued.splice(0).forEach((callback) => callback());
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({ viewer: {}, renderer });
const engine = createAnnotationEngine({ globe: {}, renderer });
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
const [mark] = engine.list();
engine.setHidden(mark.id, true);
@@ -1832,7 +1833,7 @@ test('removing one mark whose cleanup fails keeps it addressable for a retry', a
calls.remove += 1;
if (failRemove) throw new Error('renderer remove failed');
};
const engine = createAnnotationEngine({ viewer: { camera: { heading: 0 } }, renderer });
const engine = createAnnotationEngine({ globe: { camera: { pose: () => ({ heading: 0 }) } }, renderer });
await engine.annotate([
{ type: 'pin', manual: true, latitude: 30.27, longitude: -97.74, label: 'Camp' },
], { persist: true });
@@ -1850,7 +1851,7 @@ test('a building install in the batch does not re-judge marks that never install
installAnimationFrameStubs(t);
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer: h.renderer,
resolveTarget: async ({ target }) =>
target === 'Tower' ? { ...BUILDING_FP, lon: -97.7 } : { ...FIXED_POINT },
@@ -1873,7 +1874,7 @@ test('plain recaption keeps the old mark addressable when its removal throws', a
installAnimationFrameStubs(t);
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({
viewer: {}, renderer, resolveTarget: async () => ({ ...FIXED_POINT }),
globe: {}, renderer, resolveTarget: async () => ({ ...FIXED_POINT }),
});
await engine.annotate([{ type: 'point', target: 'Capitol', label: 'Capitol' }]);
const old = engine.list()[0];
+18 -17
View File
@@ -19,6 +19,7 @@ import {
refineScope,
isGroundsLikeAsk,
} from './annotationResolver.js';
import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs';
// Build a square way of ~`areaM2` centred `dLatM`/`dLonM` metres from an anchor,
// in Overpass `out geom` element shape ({ tags, geometry: [{lat,lon}...] }).
@@ -157,7 +158,7 @@ test('refineScope: entityKind refines only an unresolved (auto) scope', () => {
});
function closeViewportViewer() {
return {
return cameraOnlyEngine({
camera: {
positionCartographic: {
latitude: 30.2672 * Math.PI / 180,
@@ -165,7 +166,7 @@ function closeViewportViewer() {
height: 1000,
},
},
};
});
}
function geocodePayload({ lat, lon, types, label }) {
@@ -221,7 +222,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa
});
const resolved = await resolveAnnotationTarget({
viewer: closeViewportViewer(),
engine: closeViewportViewer(),
target: 'the Texas Capitol',
});
@@ -245,7 +246,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim
});
const resolved = await resolveAnnotationTarget({
viewer: closeViewportViewer(),
engine: closeViewportViewer(),
target: 'state of Texas',
});
@@ -286,7 +287,7 @@ for (const fixture of [
});
const resolved = await resolveAnnotationTarget({
viewer: closeViewportViewer(),
engine: closeViewportViewer(),
target: fixture.target,
});
@@ -312,7 +313,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path',
});
const resolved = await resolveAnnotationTarget({
viewer: closeViewportViewer(),
engine: closeViewportViewer(),
target: 'Texas',
});
@@ -351,7 +352,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township
for (const target of fixtures.keys()) {
const resolved = await resolveAnnotationTarget({
viewer: closeViewportViewer(),
engine: closeViewportViewer(),
target,
});
assert.ok(resolved);
@@ -369,11 +370,11 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township
// Synthetic footprints reproduce the real Capitol/YOTEL selection without live APIs.
const CAPITOL_ANCHOR = { lat: 38.8899389, lon: -77.0090505 };
function capitolViewer() {
return { camera: { positionCartographic: {
return cameraOnlyEngine({ camera: { positionCartographic: {
latitude: CAPITOL_ANCHOR.lat * Math.PI / 180,
longitude: CAPITOL_ANCHOR.lon * Math.PI / 180,
height: 1000,
} } };
} } });
}
function installCapitolMocks(t, elements, components = [
{ long_name: 'Washington', types: ['locality', 'political'] },
@@ -401,7 +402,7 @@ for (const [target, deferFootprint] of [
const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' });
const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', tourism: 'hotel', name: 'YOTEL Washington DC' });
installCapitolMocks(t, [hotel, capitol]);
const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target, entityKind: 'building', footprint: true, deferFootprint });
const result = await resolveAnnotationTarget({ engine: capitolViewer(), target, entityKind: 'building', footprint: true, deferFootprint });
assert.ok(result);
const outline = deferFootprint ? await result.resolveOutline() : result;
assert.deepEqual(outline.ring, capitol.geometry.map(p => [p.lon, p.lat]));
@@ -412,7 +413,7 @@ for (const [target, deferFootprint] of [
test('address-only landmark without a matching outline keeps its geocoded point', async t => {
const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', tourism: 'hotel', name: 'YOTEL Washington DC' });
installCapitolMocks(t, [hotel]);
const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'US Capitol building, Washington', entityKind: 'building', footprint: true, deferFootprint: true });
const result = await resolveAnnotationTarget({ engine: capitolViewer(), target: 'US Capitol building, Washington', entityKind: 'building', footprint: true, deferFootprint: true });
assert.ok(result);
assert.deepEqual([result.lat, result.lon], [CAPITOL_ANCHOR.lat, CAPITOL_ANCHOR.lon]);
assert.equal(await result.resolveOutline(), null);
@@ -422,7 +423,7 @@ test('missing address components do not turn a formatted city address into the l
const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' });
const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', name: 'YOTEL Washington DC' });
installCapitolMocks(t, [hotel, capitol], []);
const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'United States Capitol building', footprint: true });
const result = await resolveAnnotationTarget({ engine: capitolViewer(), target: 'United States Capitol building', footprint: true });
assert.deepEqual(result.ring, capitol.geometry.map(p => [p.lon, p.lat]));
});
@@ -430,7 +431,7 @@ test('a genuine geocoded feature name still canonicalizes an alternate user name
const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' });
const decoy = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', name: 'Congress meeting building' });
installCapitolMocks(t, [decoy, capitol], [{ long_name: 'United States Capitol', types: ['landmark'] }]);
const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'Congress meeting building', footprint: true });
const result = await resolveAnnotationTarget({ engine: capitolViewer(), target: 'Congress meeting building', footprint: true });
assert.deepEqual(result.ring, capitol.geometry.map(p => [p.lon, p.lat]));
});
@@ -479,7 +480,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
});
const resolved = await resolveAnnotationTarget({
viewer: closeViewportViewer(),
engine: closeViewportViewer(),
target: 'Lady Bird Lake',
});
@@ -534,7 +535,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s
});
const resolved = await resolveAnnotationTarget({
viewer: closeViewportViewer(),
engine: closeViewportViewer(),
target: 'the monument near the Texas Capitol, Austin',
footprint: true,
});
@@ -581,11 +582,11 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t)
});
const target = 'Waller Creek';
assert.equal(await resolveAnnotationTarget({ viewer: closeViewportViewer(), target }), null);
assert.equal(await resolveAnnotationTarget({ engine: closeViewportViewer(), target }), null);
const duringOutage = requests.length;
photonReachable = true;
const retried = await resolveAnnotationTarget({ viewer: closeViewportViewer(), target });
const retried = await resolveAnnotationTarget({ engine: closeViewportViewer(), target });
assert.ok(retried, 'the same target must resolve once the network is back');
assert.deepEqual([retried.lat, retried.lon], [30.2665, -97.7385]);
+5 -5
View File
@@ -47,7 +47,7 @@ test('the marks survive a globe leaving and are drawn again on the next one', as
withAnimationFrames(t);
const log = [];
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer: recordingRenderer(log, 'cesium'),
resolveTarget: async () => ({ ...BUILDING }),
});
@@ -69,7 +69,7 @@ test('the marks survive a globe leaving and are drawn again on the next one', as
log.length = 0;
engine.attachRenderer({
viewer: {},
engine: {},
renderer: recordingRenderer(log, 'next'),
});
assert.deepEqual(log, ['next add Capitol']);
@@ -83,13 +83,13 @@ test('detaching twice and attaching over an attached globe release each drawing
withAnimationFrames(t);
const log = [];
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer: recordingRenderer(log, 'a'),
});
engine.detachRenderer();
engine.detachRenderer();
engine.attachRenderer({ viewer: {}, renderer: recordingRenderer(log, 'b') });
engine.attachRenderer({ viewer: {}, renderer: recordingRenderer(log, 'c') });
engine.attachRenderer({ engine: {}, renderer: recordingRenderer(log, 'b') });
engine.attachRenderer({ engine: {}, renderer: recordingRenderer(log, 'c') });
assert.deepEqual(log, ['a destroy', 'b destroy']);
engine.destroy();
});
+21 -14
View File
@@ -67,15 +67,17 @@ const PREVIEW = DRAW_COLOR_CSS;
* through the actions it gives `connect`, which returns their disconnect.
* A row's fly-to is an immediate navigation through `navigation`, like
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
* The globe it draws on is a Cesium `viewer` or a draw `surface` factory
* (`surface({ session, cursor })`: the input and preview of a globe without
* a viewer, src/annotations/drawInputNavara.js), which gets the open shape
* and the rubber band's end as live getters.
* @param {{viewer?: Cesium.Viewer, surface?: Function, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
* The globe it draws on is a Cesium `viewer` (with its `engine`, which
* answers the picks) or a draw `surface` factory (`surface({ session,
* cursor })`: the input and preview of a globe without a viewer,
* src/annotations/drawInputNavara.js), which gets the open shape and the
* rubber band's end as live getters.
* @param {{viewer?: Cesium.Viewer, engine?: object, surface?: Function, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
* @returns {{destroy: Function}|null}
*/
export function initDrawTool({
viewer = null,
engine = null,
surface: makeSurface = null,
annotations,
navigation = null,
@@ -119,7 +121,7 @@ export function initDrawTool({
const live = { session: () => session, cursor: () => cursor };
const surface = makeSurface
? makeSurface(live)
: cesiumDrawSurface(viewer, live);
: cesiumDrawSurface(viewer, engine, live);
const present = () =>
publish({
@@ -485,7 +487,7 @@ export function initDrawTool({
* click actions on its own handler (select, track); they are borrowed while a
* sketch is open and given back on the way out.
*/
function cesiumDrawSurface(viewer, live) {
function cesiumDrawSurface(viewer, engine, live) {
const previewEntities = [];
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
@@ -526,12 +528,15 @@ function cesiumDrawSurface(viewer, live) {
});
return {
canvas: viewer.scene.canvas,
/** The world point under a canvas position, or null. */
/** The world point under a canvas position (the engine's picks), or null. */
pick(position) {
const canvas = viewer.scene.canvas;
const w = canvas.clientWidth || canvas.width || 1;
const h = canvas.clientHeight || canvas.height || 1;
return pickWorldFromScreen(viewer, position.x / w, position.y / h);
if (!engine) return null;
const { width, height } = engine.host.size();
return pickWorldFromScreen(
engine,
position.x / (width || 1),
position.y / (height || 1),
);
},
/** Show the open shape (null: none) in `color`. */
sync(session, color) {
@@ -657,8 +662,8 @@ function cesiumDrawSurface(viewer, live) {
* The Draw control for the page session. The marks live on the annotation
* board and the chosen shape and colour here; the drawing tool itself (its
* preview, click handler and Escape/Enter keys) belongs to the attached
* globe. `attachScene({ viewer, navigation })` starts the tool on a Cesium
* viewer, `attachScene({ surface, navigation })` on a globe without one
* globe. `attachScene({ viewer, engine, navigation })` starts the tool on
* a Cesium viewer, `attachScene({ surface, navigation })` on a globe without one
* (Navara: src/annotations/drawInputNavara.js), with the shape and colour
* last chosen; `detachScene()` ends it, an open shape included. Without a
* globe the controls answer nothing. Same handle as initDrawTool's
@@ -731,6 +736,7 @@ export function createSessionDrawTool({
whenSettled: () => inner?.whenSettled() ?? settled,
attachScene({
viewer = null,
engine = null,
surface = null,
navigation: next = navigation,
} = {}) {
@@ -740,6 +746,7 @@ export function createSessionDrawTool({
if (!viewer && !surface) return;
inner = initDrawTool({
viewer,
engine,
surface,
annotations,
navigation,
+1 -1
View File
@@ -53,7 +53,7 @@ test('the engine resolves manual geometry before any name resolution', () => {
'async function resolveSpec(spec, signal) {',
);
const manualAt = engine.indexOf(
'if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);',
'if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, globe);',
resolveAt,
);
const routeAt = engine.indexOf("if (type === 'route') {", resolveAt);
+3 -2
View File
@@ -24,9 +24,10 @@ import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
*/
export function createHybridAnnotationRenderer(
viewer,
{ screen: screenOptions, world: worldRenderer, screenScene } = {},
{ screen: screenOptions, world: worldRenderer, screenScene, engine } = {},
) {
const world = worldRenderer ?? createWorldAnnotationRenderer(viewer);
const world =
worldRenderer ?? createWorldAnnotationRenderer(viewer, { engine });
const screen = createScreenAnnotationRenderer(
screenScene ?? viewer,
screenOptions,
@@ -5,11 +5,18 @@ import { createPinia } from 'pinia';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
import { createCesiumCredits } from '../globe/cesium/credits.js';
/** The engine fakeViewer built for each viewer double (its credits). */
const enginesOf = new WeakMap();
/** A hybrid renderer whose screen board is presented to its own store. */
function hybridWithBoard(viewer) {
const store = useScreenAnnotationsStore(createPinia());
const renderer = createHybridAnnotationRenderer(viewer, { screen: { store } });
const renderer = createHybridAnnotationRenderer(viewer, {
screen: { store },
engine: enginesOf.get(viewer),
});
return { store, renderer };
}
@@ -107,10 +114,7 @@ function fakeViewer(lon, lat) {
scene.renderRequests += 1;
},
};
return {
dataSources,
frames,
viewer: {
const viewer = {
credits,
creditDisplay: {
addStaticCredit: (credit) => credits.add(credit),
@@ -130,8 +134,14 @@ function fakeViewer(lon, lat) {
return index >= 0;
},
},
},
};
// The Cesium engine's credits over the viewer's credit display.
const engine = {
credits: createCesiumCredits(viewer).credits,
render: { request: () => scene.requestRender() },
};
enginesOf.set(viewer, engine);
return { dataSources, frames, viewer, engine };
}
function controlledFrames() {
@@ -475,14 +485,14 @@ function workTrace() {
test('buildings share one probe per frame and preserve probe geometry, filtering and fallback', async (t) => {
installBrowserGlobals(t);
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
const { viewer, engine, dataSources, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer, { engine });
t.after(() => renderer.destroy());
const first = buildingAnnotation('sampled-building');
const second = buildingAnnotation('fallback-building', 0.01);
second.outlineCredit = 'osm';
const otherOwner = {};
showOsmCredit(viewer, otherOwner);
showOsmCredit(engine, otherOwner);
const probes = [];
const outcomes = [-500, 120, 30, null, new Error('tile unavailable'), 60, 9001, 90];
viewer.scene.clampToHeight = (position) => {
@@ -583,7 +593,7 @@ test('buildings share one probe per frame and preserve probe geometry, filtering
'removing the last tile proxy retains the OSM building and other owner');
renderer.remove(second);
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1);
hideOsmCredit(viewer, otherOwner);
hideOsmCredit(engine, otherOwner);
assert.equal(viewer.credits.size, 0);
});
+12 -10
View File
@@ -12,22 +12,25 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
*/
export function initAnnotations({
viewer,
engine: globe = null,
tileset = null,
placeSearch,
resolver,
}) {
const engine = createSessionAnnotations({ placeSearch, resolver });
engine.attachScene({ viewer, tileset });
engine.attachScene({ viewer, engine: globe, tileset });
return engine;
}
/**
* The page's annotation board, kept across globe engine switches: the marks
* belong to the page session, their drawing to the attached globe.
* `attachScene({ viewer, tileset })` draws the board on a Cesium viewer;
* `attachScene({ engine, renderer })` on a globe engine without a viewer
* (Navara: src/annotations/navaraAnnotationRenderer.js, built by the caller
* so this module never loads it); with neither the marks stay undrawn.
* belong to the page session, their drawing to the attached globe. The board
* works on the globe `engine` (camera, picks, surface); its drawing is the
* `renderer` built for that engine (Navara:
* src/annotations/navaraAnnotationRenderer.js, built by the caller so this
* module never loads it), or on a Cesium `viewer` the hybrid renderer built
* here. `attachScene({ engine, viewer, tileset })` or
* `attachScene({ engine, renderer })`; with neither the marks stay undrawn.
* `detachScene()` releases the drawing and keeps the marks.
*/
export function createSessionAnnotations({ placeSearch, resolver }) {
@@ -41,8 +44,7 @@ export function createSessionAnnotations({ placeSearch, resolver }) {
engine: globe = null,
renderer = null,
} = {}) => {
if (!viewer && globe && renderer)
return engine.attachRenderer({ viewer: globe, renderer });
if (renderer) return engine.attachRenderer({ engine: globe, renderer });
if (!viewer) return engine.detachRenderer();
// World-space footprint draping; clamped marks can use the photoreal tiles.
if (tileset) {
@@ -53,8 +55,8 @@ export function createSessionAnnotations({ placeSearch, resolver }) {
}
}
engine.attachRenderer({
viewer,
renderer: createHybridAnnotationRenderer(viewer),
engine: globe,
renderer: createHybridAnnotationRenderer(viewer, { engine: globe }),
});
};
engine.detachScene = () => engine.detachRenderer();
+3 -3
View File
@@ -52,7 +52,7 @@ test('outlines sharing an outer ring but not their holes stay two marks', async
footprintKind: 'area',
});
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer,
resolveTarget,
});
@@ -108,7 +108,7 @@ test('the engine resolves a bare street highlight with its footprint', async (t)
};
};
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer,
resolveTarget,
});
@@ -138,7 +138,7 @@ test('a duplicate that fails to clean up never costs the new outline', async (t)
const stuck = new Set();
const outcomes = [];
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer: {
...renderer,
add(anno) {
+13 -11
View File
@@ -28,6 +28,7 @@ import {
createNominatimGate,
} from '../../server/providers/regional/nominatimGate.js';
import { createVoiceAreas } from '../voice/areaActions.js';
import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs';
const TILES = [6745, 6746].map((y) =>
decodeOpenFreeMapOutlineTile(
@@ -924,15 +925,16 @@ const PLACES = {
},
};
const viewerAt = (lat, lon, height = 4000) => ({
camera: {
positionCartographic: {
latitude: (lat * Math.PI) / 180,
longitude: (lon * Math.PI) / 180,
height,
const viewerAt = (lat, lon, height = 4000) =>
cameraOnlyEngine({
camera: {
positionCartographic: {
latitude: (lat * Math.PI) / 180,
longitude: (lon * Math.PI) / 180,
height,
},
},
},
});
});
test('default settings: outline, street and building asks send nothing to Overpass', async (t) => {
const prior = process.env.OVERPASS_UPSTREAMS;
@@ -979,9 +981,9 @@ test('default settings: outline, street and building asks send nothing to Overpa
const placeSearch = {
geocode: async (q) => ({ place: PLACES[q] || null, answered: true }),
};
const resolve = async (viewer, args) => {
const resolve = async (engine, args) => {
const result = await resolver.resolveAnnotationTarget({
viewer,
engine,
placeSearch,
footprint: true,
deferFootprint: true,
@@ -1000,7 +1002,7 @@ test('default settings: outline, street and building asks send nothing to Overpa
});
const areas = createVoiceAreas({
viewer: viewerAt(27.7, 85.3, 40_000),
engine: viewerAt(27.7, 85.3, 40_000),
annotationResolver: resolver,
placeSearch,
});
+5 -4
View File
@@ -7,9 +7,10 @@ import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from './resolver.js';
import { polygonsContain } from '../data/adminBoundaries.js';
import { streetFootprint } from './outlineRungs.js';
import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs';
function viewerAt(lat, lon, height = 20_000) {
return {
return cameraOnlyEngine({
camera: {
positionCartographic: {
latitude: (lat * Math.PI) / 180,
@@ -17,7 +18,7 @@ function viewerAt(lat, lon, height = 20_000) {
height,
},
},
};
});
}
/** A resolver whose boundary lookups and geocoder record every call. */
@@ -47,7 +48,7 @@ function harness(place = null, { outlineRungs } = {}) {
const resolve = (target, viewer, extra = {}) =>
resolveAnnotationTarget({
placeSearch,
viewer,
engine: viewer,
target,
footprint: true,
deferFootprint: true,
@@ -710,7 +711,7 @@ test('configured OSM geometry retains its origin through inline, deferred and ca
});
for (const deferFootprint of [false, true]) {
const result = await resolver.resolveAnnotationTarget({
viewer: viewerAt(31, -99, 3000),
engine: viewerAt(31, -99, 3000),
latitude: 31,
longitude: -99,
target: 'Library',
+66 -147
View File
@@ -14,7 +14,6 @@ import {
nearestPointOnLines,
} from '../sources/featureGeometry.js';
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js';
import {
@@ -46,7 +45,6 @@ import {
import { createOutlineRungs } from './outlineRungs.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
import { isGlobeEngine } from '../globe/contract.js';
/** Prepare only local dateline outlines for planar admission and centering. */
function localOutlineFrame(fp) {
@@ -180,7 +178,8 @@ export function createAnnotationResolver({
* Resolve a single annotation target to a normalized world anchor.
*
* @param {object} opts
* @param {Cesium.Viewer} opts.viewer
* @param {object} [opts.engine] The globe engine (view bias, screen picks,
* ground heights, credits).
* @param {string} [opts.target] Place name to geocode.
* @param {number} [opts.latitude] Explicit latitude (wins over target).
* @param {number} [opts.longitude] Explicit longitude.
@@ -202,7 +201,7 @@ export function createAnnotationResolver({
*/
async function resolveAnnotationTarget({
placeSearch = unavailablePlaceSearch,
viewer,
engine,
target,
latitude,
longitude,
@@ -273,7 +272,7 @@ export function createAnnotationResolver({
!NON_ADMIN_ENTITY_KINDS.has(entityKind)
) {
const center =
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
pickWorldFromScreen(engine, 0.5, 0.5) || viewportProximity(engine);
bundledCenter = center;
const admin = await findAdminArea(trace.query, { near: center }).catch(
() => null,
@@ -282,7 +281,7 @@ export function createAnnotationResolver({
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
coarseCityShape = admin;
else if (admin)
return bundledAdminTarget(viewer, admin, trace.query, signal);
return bundledAdminTarget(engine, admin, trace.query, signal);
// Bundled US places and neighborhoods named at or near the view. A
// campus, grounds or monument ask ("Stanford" as a compound, "the
// Capitol grounds") wants that feature, not a district of its name,
@@ -290,10 +289,10 @@ export function createAnnotationResolver({
if (intent !== 'around_the_thing' && !isMonumentLikeQuery(trace.query)) {
const area = await bundledAreaNear(trace.query, center, {
entityKind,
viewRadiusKm: viewportProximity(viewer)?.radiusKm ?? null,
viewRadiusKm: viewportProximity(engine)?.radiusKm ?? null,
});
signal?.throwIfAborted();
if (area) return bundledAdminTarget(viewer, area, trace.query, signal);
if (area) return bundledAdminTarget(engine, area, trace.query, signal);
}
}
@@ -301,7 +300,7 @@ export function createAnnotationResolver({
const query = String(target || '').trim();
if (query) {
const center =
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
pickWorldFromScreen(engine, 0.5, 0.5) || viewportProximity(engine);
// Monument / grounds names scatter under Geocoding — try a view-biased Places Text Search FIRST.
// A hit near the view centre is trusted (skips the proximity gate, like the osm-local snap); on a
// miss we fall through to geocode + fetchLocalMonument below. The model's entityKind counts too:
@@ -338,7 +337,7 @@ export function createAnnotationResolver({
if (source !== 'places') {
const geocoded = await geocodePlace(
query,
viewportBias(viewer),
viewportBias(engine),
signal,
placeSearch,
);
@@ -411,7 +410,7 @@ export function createAnnotationResolver({
Number.isFinite(Number(screenY))
) {
const picked = pickWorldFromScreen(
viewer,
engine,
Number(screenX),
Number(screenY),
);
@@ -424,7 +423,7 @@ export function createAnnotationResolver({
if (!Number.isFinite(lat) || !Number.isFinite(lon))
return coarseCityHolds(bundledCenter)
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
? bundledAdminTarget(engine, coarseCityShape, trace.query, signal)
: null;
// Monument/memorial/statue names geocode unreliably — Google scatters them across the city (e.g.
@@ -437,7 +436,7 @@ export function createAnnotationResolver({
(isMonumentLikeQuery(target) || entityKind === 'point_feature')
) {
const center =
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
pickWorldFromScreen(engine, 0.5, 0.5) || viewportProximity(engine);
if (center) {
const mon = await fetchLocalMonument(
center.lat,
@@ -470,7 +469,7 @@ export function createAnnotationResolver({
// footprint resolution below re-checks its own recentered centroid against the same bound — a
// polygon that would drag a good anchor out of plausibility is dropped (the mark stays an
// honest point) rather than rejecting the whole annotation.
const vpGate = viewportProximity(viewer);
const vpGate = viewportProximity(engine);
const gateDrift = (gLat, gLon) => {
if (!vpGate) return null; // no camera info → pass
const driftKm =
@@ -491,7 +490,7 @@ export function createAnnotationResolver({
);
}
return coarseCityHolds(bundledCenter)
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
? bundledAdminTarget(engine, coarseCityShape, trace.query, signal)
: null;
}
}
@@ -581,7 +580,7 @@ export function createAnnotationResolver({
);
signal?.throwIfAborted();
if (ne) {
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
registerDynamicCredit(engine, NATURAL_EARTH_CREDIT);
traceBundledOutline(ne.name, 'natural-earth');
const neCentroid = ringCentroid(ne.ring);
return {
@@ -594,7 +593,7 @@ export function createAnnotationResolver({
lat: neCentroid?.lat ?? lat,
lon: neCentroid?.lon ?? lon,
height: sampleGroundHeight(
viewer,
engine,
neCentroid?.lon ?? lon,
neCentroid?.lat ?? lat,
),
@@ -642,7 +641,7 @@ export function createAnnotationResolver({
signal?.throwIfAborted();
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
coarseCityShape ||= admin;
else if (admin) return bundledAdminOutline(viewer, admin, signal);
else if (admin) return bundledAdminOutline(engine, admin, signal);
}
// A geocoded US city, town or CDP: the Census place that holds it.
if (scope === 'city') {
@@ -652,7 +651,7 @@ export function createAnnotationResolver({
lon,
).catch(() => null);
signal?.throwIfAborted();
if (place) return bundledAdminOutline(viewer, place, signal);
if (place) return bundledAdminOutline(engine, place, signal);
}
// Pure admin: only an admin boundary is correct — never fall back to a
// building/landuse (a city is never a single building).
@@ -676,7 +675,7 @@ export function createAnnotationResolver({
lon,
).catch(() => null);
signal?.throwIfAborted();
if (wof) return bundledAdminOutline(viewer, wof, signal);
if (wof) return bundledAdminOutline(engine, wof, signal);
}
// Else fall through to the OSM admin/place → named-landuse → synthesis ladder. Each
// returns a footprint, null (definitively no polygon), or undefined (transient
@@ -875,7 +874,7 @@ export function createAnnotationResolver({
...(fp.adminLevel ? { adminLevel: fp.adminLevel } : {}),
lat: centroid.lat,
lon: centroid.lon,
height: sampleGroundHeight(viewer, centroid.lon, centroid.lat),
height: sampleGroundHeight(engine, centroid.lon, centroid.lat),
};
};
@@ -891,11 +890,11 @@ export function createAnnotationResolver({
pointLike,
groundsLike,
around: intent === 'around_the_thing',
view: viewportProximity(viewer),
view: viewportProximity(engine),
signal,
credit: (kind) =>
registerDynamicCredit(
viewer,
engine,
kind === 'tiles' ? OPENMAPTILES_CREDIT : OSM_CREDIT,
),
};
@@ -911,7 +910,7 @@ export function createAnnotationResolver({
coarseCityHolds({ lat, lon }) &&
!(result?.ring && !result.synthesized)
)
return bundledAdminOutline(viewer, coarseCityShape, signal);
return bundledAdminOutline(engine, coarseCityShape, signal);
return result;
};
@@ -939,7 +938,7 @@ export function createAnnotationResolver({
}
}
const height = sampleGroundHeight(viewer, lon, lat);
const height = sampleGroundHeight(engine, lon, lat);
// One concise line per target so the resolution path is visible in the browser console.
if (trace.query) {
console.log(
@@ -1004,13 +1003,13 @@ export function createAnnotationResolver({
const COARSE_CITY_MAX_VERTICES = 32;
/** Credit the pack a bundled boundary came from. */
function creditAdminSource(viewer, admin) {
function creditAdminSource(engine, admin) {
const credit = {
'natural-earth': NATURAL_EARTH_CREDIT,
'us-census': US_CENSUS_CREDIT,
wof: WOF_CREDIT,
}[admin.source];
if (credit) registerDynamicCredit(viewer, credit);
if (credit) registerDynamicCredit(engine, credit);
}
/**
@@ -1113,9 +1112,9 @@ export function createAnnotationResolver({
* draw them. Like the Natural Earth rung, it bypasses the scope caps and the
* centroid drift bound: the unit IS the asked scope.
*/
function bundledAdminOutline(viewer, admin, signal) {
function bundledAdminOutline(engine, admin, signal) {
signal?.throwIfAborted();
creditAdminSource(viewer, admin);
creditAdminSource(engine, admin);
traceBundledOutline(admin.name, admin.source, admin.polygons?.length);
const { lat, lon } = admin.label;
return {
@@ -1128,7 +1127,7 @@ export function createAnnotationResolver({
outlineSource: admin.source,
lat,
lon,
height: sampleGroundHeight(viewer, lon, lat),
height: sampleGroundHeight(engine, lon, lat),
};
}
@@ -1158,8 +1157,8 @@ export function createAnnotationResolver({
}
/** A complete resolved target for a bundled administrative unit (no outline pending). */
function bundledAdminTarget(viewer, admin, query, signal) {
const outline = bundledAdminOutline(viewer, admin, signal);
function bundledAdminTarget(engine, admin, query, signal) {
const outline = bundledAdminOutline(engine, admin, signal);
const [west, south, east, north] = admin.bbox;
console.log(
`[Resolver] "${query}": bundled ${admin.kind} "${admin.name}"` +
@@ -2200,7 +2199,7 @@ export function createAnnotationResolver({
return matches;
}
// --- viewer helpers ---------------------------------------------------------
// --- engine helpers ---------------------------------------------------------
/**
* Current view rectangle as a Google `bounds` string `swLat,swLng|neLat,neLng`,
@@ -2216,12 +2215,9 @@ export function createAnnotationResolver({
* Radius is scaled from camera height (a generous proxy for how much ground is on screen), with a
* floor so a super-zoomed-in view doesn't over-reject genuinely nearby places.
*/
function viewportProximity(viewer) {
function viewportProximity(engine) {
try {
// A globe engine (search) or a Cesium viewer (annotations until 1c).
const carto = isGlobeEngine(viewer)
? viewer.camera.pose().cartographic
: viewer?.camera?.positionCartographic;
const carto = engine?.camera.pose().cartographic;
if (!carto) return null;
const lat = geo.math.toDegrees(carto.latitude);
const lon = geo.math.toDegrees(carto.longitude);
@@ -2234,11 +2230,9 @@ export function createAnnotationResolver({
}
/** Exported for searchAndFlyTo (src/locations.js), which shares this bias. */
function viewportBias(viewer) {
function viewportBias(engine) {
try {
const rect = isGlobeEngine(viewer)
? viewer.camera.viewRectangle()
: viewer?.camera?.computeViewRectangle?.();
const rect = engine?.camera.viewRectangle();
if (!rect) return null;
const swLat = geo.math.toDegrees(rect.south).toFixed(4);
const swLng = geo.math.toDegrees(rect.west).toFixed(4);
@@ -2262,14 +2256,14 @@ export function createAnnotationResolver({
* ({ lat, lon, label, types, viewport, distanceM, … }) or null.
*/
async function placesNearViewRecovery(
viewer,
engine,
query,
geocoded = null,
signal = undefined,
placeSearch = defaultGeospatial,
) {
const center =
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
pickWorldFromScreen(engine, 0.5, 0.5) || viewportProximity(engine);
if (!center) return null;
const geocodeFar =
!geocoded ||
@@ -2313,9 +2307,28 @@ export function createAnnotationResolver({
return geo.cartesian3.distance(hit, camera) < Math.max(2, 0.01 * reference);
}
/** pickWorldFromScreen through a globe engine: the same cascade. */
function enginePickWorldFromScreen(engine, nx, ny) {
const { width: w, height: h } = engine.host.size();
/**
* Convert a NORMALIZED screen point (x,y in [0,1] of the current viewport) into
* a world lon/lat using the depth-aware pick cascade — the inverse of the
* project-to-screen used by the renderers. This is the "point at the pixel"
* fallback: the agent indicates a spot in the viewport screenshot when it can't
* name the place, and we anchor the mark to the actual world point under it.
*
* Each stage is validated before it is accepted: a depth pick over empty sky
* can return a NaN or centre-of-the-earth Cartesian. A degenerate pick IS a
* missed pick, so it falls through to the next stage and ultimately to the
* caller's null. The answer carries the height the pick landed on (a roof, a
* hillside, or 0 on the ellipsoid): the draw tool's live preview needs it,
* or the rubber band sinks to sea level while the pointer is on a hill.
*/
function pickWorldFromScreen(engine, nx, ny) {
let size;
try {
size = engine?.host.size();
} catch {
return null;
}
const { width: w, height: h } = size ?? {};
if (!w || !h) return null;
const screen = {
x: Math.max(0, Math.min(1, nx)) * w,
@@ -2343,107 +2356,13 @@ export function createAnnotationResolver({
}
/**
* Convert a NORMALIZED screen point (x,y in [0,1] of the current viewport) into
* a world lon/lat using the depth-aware pick cascade — the inverse of the
* project-to-screen used by the renderers. This is the "point at the pixel"
* fallback: the agent indicates a spot in the viewport screenshot when it can't
* name the place, and we anchor the mark to the actual world point under it.
* Best-effort ground height at a coordinate. The globe is hidden behind
* the Google 3D tiles, so we try to clamp onto the rendered surface; if the
* tiles for that spot aren't loaded we fall back to the terrain, else the
* ellipsoid (0).
*/
function pickWorldFromScreen(viewer, nx, ny) {
if (isGlobeEngine(viewer)) return enginePickWorldFromScreen(viewer, nx, ny);
const scene = viewer?.scene;
if (!scene) return null;
const canvas = scene.canvas;
const w = canvas.clientWidth || canvas.width || 0;
const h = canvas.clientHeight || canvas.height || 0;
if (!w || !h) return null;
const px = Math.max(0, Math.min(1, nx)) * w;
const py = Math.max(0, Math.min(1, ny)) * h;
const pos = new Cesium.Cartesian2(px, py);
// Each stage is validated before it is accepted: a depth pick over empty sky
// can return a NaN or centre-of-the-earth Cartesian, and converting one of
// those throws inside Cesium. A degenerate pick IS a missed pick, so it falls
// through to the next stage and ultimately to the caller's null.
let cart = null;
if (
scene.pickPositionSupported &&
typeof scene.pickPosition === 'function'
) {
try {
cart = scene.pickPosition(pos);
} catch {
cart = null;
}
}
if (
!isPickedWorldPosition(cart) &&
typeof viewer.camera.pickEllipsoid === 'function'
) {
try {
cart = viewer.camera.pickEllipsoid(pos, Cesium.Ellipsoid.WGS84);
} catch {
cart = null;
}
}
if (
!isPickedWorldPosition(cart) &&
typeof viewer.camera.getPickRay === 'function'
) {
try {
const ray = viewer.camera.getPickRay(pos);
cart = ray ? scene.globe?.pick(ray, scene) || null : null;
} catch {
cart = null;
}
}
if (!isPickedWorldPosition(cart)) return null;
const carto = geo.cartographic.fromCartesian(cart);
if (!carto) return null;
return {
lat: geo.math.toDegrees(carto.latitude),
lon: geo.math.toDegrees(carto.longitude),
// The height the pick actually landed on — a roof, a hillside, or 0 on
// the ellipsoid. Callers that only want a coordinate ignore it; the draw
// tool's live preview needs it, or the rubber band sinks to sea level
// while the pointer is on a hill.
height: Number.isFinite(carto.height) ? carto.height : 0,
};
}
/**
* Best-effort ground height at a coordinate. The Cesium globe is hidden behind
* the Google 3D tiles, so we try to clamp onto the photoreal tile surface; if
* the tiles for that spot aren't loaded we fall back to the ellipsoid (0).
*/
function sampleGroundHeight(viewer, lon, lat) {
if (isGlobeEngine(viewer)) return engineGroundHeight(viewer, lon, lat);
const scene = viewer?.scene;
if (!scene) return 0;
try {
if (
scene.clampToHeightSupported &&
typeof scene.clampToHeight === 'function'
) {
const carto = geo.cartographic.fromDegrees(lon, lat);
const surface = geo.cartographic.toCartesian(carto);
const clamped = scene.clampToHeight(surface);
if (clamped) {
const h = geo.cartographic.fromCartesian(clamped).height;
if (Number.isFinite(h)) return h;
}
}
} catch {
/* tiles not ready — fall through */
}
const globeHeight = scene.globe?.getHeight?.(
geo.cartographic.fromDegrees(lon, lat),
);
return Number.isFinite(globeHeight) && globeHeight > 0 ? globeHeight : 0;
}
/** sampleGroundHeight on a globe engine: the rendered surface, else terrain. */
function engineGroundHeight(engine, lon, lat) {
function sampleGroundHeight(engine, lon, lat) {
if (!engine) return 0;
try {
if (engine.surface.clampToHeightSupported) {
const clamped = engine.surface.clampToHeight(
+2 -3
View File
@@ -1,6 +1,5 @@
// The view-centred search helpers read the camera through a globe engine as
// well as through a viewer (src/locations.js searchAndFlyTo hands them the
// engine).
// The view-centred search helpers read the camera through the globe engine
// (src/locations.js searchAndFlyTo and the annotation board hand it to them).
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../geo/index.js';
+10 -5
View File
@@ -47,7 +47,12 @@ const PALETTE = {
* BOTH paints onto terrain AND onto 3D tiles, so one value covers both worlds.
*/
export function createWorldAnnotationRenderer(viewer) {
/**
* @param {Cesium.Viewer} viewer
* @param {{ engine?: object }} [options] `engine`: the globe engine whose
* credits show the OpenStreetMap attribution of the marks drawn from it.
*/
export function createWorldAnnotationRenderer(viewer, { engine = null } = {}) {
const CLASSIFY = Cesium.ClassificationType.BOTH;
const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND;
const dataSource = new Cesium.CustomDataSource('gev-annotations');
@@ -522,7 +527,7 @@ export function createWorldAnnotationRenderer(viewer) {
}),
);
osmAnnotations.add(anno);
showOsmCredit(viewer, anno);
showOsmCredit(engine, anno);
} else if (anno.type === 'arrow' && anno.to) {
// Connector draped across the ground from origin to destination.
const positions = [
@@ -577,7 +582,7 @@ export function createWorldAnnotationRenderer(viewer) {
) {
// The actual world proxy owns both geometry and credit, including rollback.
osmAnnotations.add(anno);
showOsmCredit(viewer, anno, {
showOsmCredit(engine, anno, {
openMapTiles: anno.outlineCredit === 'tiles',
});
}
@@ -600,7 +605,7 @@ export function createWorldAnnotationRenderer(viewer) {
function remove(anno) {
pendingGround.get(anno)?.finish('cancelled');
hideOsmCredit(viewer, anno);
hideOsmCredit(engine, anno);
osmAnnotations.delete(anno);
drawn.delete(anno);
if (!anno?._entities) return;
@@ -630,7 +635,7 @@ export function createWorldAnnotationRenderer(viewer) {
function destroy() {
destroyed = true;
for (const task of [...pendingGround.values()]) task.finish('cancelled');
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
for (const anno of osmAnnotations) hideOsmCredit(engine, anno);
osmAnnotations.clear();
drawn.clear();
try {
+4 -4
View File
@@ -13,11 +13,11 @@ import {
} from './layers/renderers.js';
import {
governorRequestRender,
installRenderGovernor,
installEngineRenderGovernor,
uninstallRenderGovernor,
} from '../renderGovernor.js';
import { attachSessionVoice } from './sessionVoice.js';
import { initNavaraImageryBoxTool } from '../ui/imageryBoxNavara.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import {
attachNavaraAnnotations,
attachNavaraDraw,
@@ -368,7 +368,7 @@ export function attachViewerlessTools(
});
const recentImagery = dataManager.layers.get('recent-imagery')?.module;
if (recentImagery) {
const tool = initNavaraImageryBoxTool({
const tool = initImageryBoxTool({
engine,
onBox: (box) => recentImagery.setBox(box),
onPreview: (box) => recentImagery.setBoxPreview(box),
@@ -397,7 +397,7 @@ export function attachViewerlessTools(
}
// Holds drive engine.render.setContinuous; requests go to the engine. A
// parked engine uninstalls it (createNavaraScene suspend).
installRenderGovernor(engine);
installEngineRenderGovernor(engine);
const syncVisibility = () => {
if (document.hidden && !isEmbeddedInline()) return;
data.presentation.flushVisible();
+2 -2
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { createApplicationScene } from './scene.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import {
installRenderGovernor,
installEngineRenderGovernor,
isRenderGovernorInstalled,
} from '../renderGovernor.js';
@@ -164,7 +164,7 @@ test('the Navara scene tears down the controller, then the globe', async (t) =>
const run = await startScene();
// The tools install the governor on the engine (src/app/tools.js); the
// scene removes it before the engine goes.
installRenderGovernor(run.engine);
installEngineRenderGovernor(run.engine);
const view = globalThis.window;
await run.teardown();
assert.equal(run.globeDestroyed(), 1);
+4 -3
View File
@@ -275,7 +275,7 @@ function attachViewerTools(
resolver: operations.annotationResolver,
defer,
});
annotations.attachScene({ viewer, tileset });
annotations.attachScene({ viewer, engine, tileset });
defer(() => annotations.detachScene());
// UTILITIES ▸ Draw on this viewer (src/app/sessionTools.js): the tool
// claims this viewer's pointer while a sketch is open.
@@ -285,7 +285,7 @@ function attachViewerTools(
styleManager,
defer,
});
drawTool?.attachScene({ viewer, navigation: styleManager });
drawTool?.attachScene({ viewer, engine, navigation: styleManager });
defer(() => drawTool?.detachScene());
// UTILITIES ▸ Import on this viewer: the next Cesium viewer reads the
// session's files again. The drop listeners belong to this viewer.
@@ -309,8 +309,9 @@ function attachViewerTools(
if (recentImagery) {
recentImagery.attachTileset(tileset);
const imageryBoxTool = initImageryBoxTool({
viewer,
engine,
onBox: (box) => recentImagery.setBox(box),
onPreview: (box) => recentImagery.setBoxPreview(box),
onCancel: (reason, message, box) => {
if (message) recentImagery.reportBoxRefusal(message, box);
},
+10
View File
@@ -6,6 +6,16 @@ import {
globeOutOfFrame,
} from '../globeInView.js';
// Dev convenience: expose the app's Cesium instance for console/preview probing
// (single shared module instance — avoids dual-Cesium state bugs when testing).
// A getter, because the production build's Cesium global loads only with the
// first Cesium globe.
if (typeof window !== 'undefined' && !('__CESIUM__' in window))
Object.defineProperty(window, '__CESIUM__', {
configurable: true,
get: () => Cesium,
});
const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120;
+2 -2
View File
@@ -3569,7 +3569,7 @@ export function createBhoteKoshiEventLayer({
else await _viewer.dataSources.add(_floodDataSource);
if (!current()) return;
}
registerDynamicCredit(engine ?? viewer, BHOTE_KOSHI_CREDIT);
registerDynamicCredit(engine, BHOTE_KOSHI_CREDIT);
_scenePathPositionCache.clear();
_lastCinematicProgress = null;
const terrain = syncTerrainComparison();
@@ -3640,7 +3640,7 @@ export function createBhoteKoshiEventLayer({
try {
const event = await loadEvent();
throwIfEnableCancelled(signal, _enabled);
registerDynamicCredit(_engine ?? viewer, BHOTE_KOSHI_CREDIT);
registerDynamicCredit(_engine, BHOTE_KOSHI_CREDIT);
if (_mapStackController) {
_comparisonLease = acquireImageryComparison(_mapStackController, {
owner: BHOTE_KOSHI_LAYER_ID,
+4 -5
View File
@@ -291,7 +291,6 @@ export function createBhoteKoshiLocatorLayer({
let _native = null;
let _pausedApproach = null;
const cameraForScene = () => _engine?.camera ?? _viewer?.camera;
const creditTarget = () => _engine ?? _viewer;
const requestRender = (reason) =>
_engine?.kind === 'navara'
? _engine.render.request(reason)
@@ -1049,8 +1048,8 @@ export function createBhoteKoshiLocatorLayer({
if (!_dataSource) return;
if (_viewer) _viewer.dataSources.add(_dataSource);
else if (_engine) _native = createNativeRenderer(_engine, _dataSource);
registerDynamicCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_CREDIT);
showOsmCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_LAYER_ID);
registerDynamicCredit(_engine, BHOTE_KOSHI_LOCATOR_CREDIT);
showOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID);
}
function attachRenderer(viewer, { engine = null } = {}) {
@@ -1080,7 +1079,7 @@ export function createBhoteKoshiLocatorLayer({
_native?.destroy();
_native = null;
if (_viewer && _dataSource) _viewer.dataSources.remove(_dataSource, false);
hideOsmCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_LAYER_ID);
hideOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID);
_engine = _viewer = null;
}
@@ -1137,7 +1136,7 @@ export function createBhoteKoshiLocatorLayer({
}
async function disable() {
hideOsmCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_LAYER_ID);
hideOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID);
_pausedApproach = null;
_enabled = false;
_boundaryReady = false;
+3 -2
View File
@@ -9,6 +9,7 @@ import * as Cesium from 'cesium';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from '../annotations/resolver.js';
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs';
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
@@ -135,8 +136,8 @@ test('drawing bundled place and neighborhood outlines creates no Cesium label',
3000,
);
const resolved = await resolveAnnotationTarget({
// The resolver reads only the camera (no scene to pick from).
viewer: { camera: viewer.camera },
// The resolver reads only the camera (no canvas to pick from).
engine: cameraOnlyEngine({ camera: viewer.camera }),
target,
footprint: true,
deferFootprint: true,
+13 -27
View File
@@ -1,8 +1,5 @@
import { isGlobeEngine } from '../globe/contract.js';
import { createCesiumCredits } from '../globe/cesium/credits.js';
/**
* Per-layer data attribution registered into Cesium's credit display.
* Per-layer data attribution registered into the globe engine's credits.
*
* Legal requirement (see DATA_SOURCES.md, findings H10/H11 in
* docs/pre-ship-audit-2026-07-01.md): every third-party data layer this app can
@@ -13,7 +10,7 @@ import { createCesiumCredits } from '../globe/cesium/credits.js';
*
* These credits are registered ONCE at init as STATIC credits with
* showOnScreen=false, so they live in the expandable bottom-left "Data
* attribution" lightbox (Cesium's credit popover) rather than cluttering the
* attribution" lightbox (the engine's credit popover) rather than cluttering the
* on-globe line. Always-present is intentional and reversible: the lightbox is
* the app's canonical attribution surface and DATA_SOURCES.md is the
* machine-readable index. Strings are copied verbatim from DATA_SOURCES.md — if
@@ -49,29 +46,18 @@ export const ACARTIA_CREDIT = Object.freeze({
const osmDisplays = new WeakMap();
const googlePlacesDisplays = new WeakMap();
const viewerCredits = new WeakMap();
/**
* Credits of a globe engine, or of a Cesium viewer's credit display for
* callers that still hold a viewer (Cesium layers); null without one.
* Credits of a globe engine (engine.credits, shown on its next frame); null
* without one.
* @returns {{ addStatic: Function, request: () => void } | null}
*/
function creditsOf(target) {
if (isGlobeEngine(target))
return {
addStatic: (html, options) => target.credits.addStatic(html, options),
request: () => target.render.request('credits'),
};
if (typeof target?.creditDisplay?.addStaticCredit !== 'function') return null;
let credits = viewerCredits.get(target);
if (!credits) {
credits = {
addStatic: createCesiumCredits(target).credits.addStatic,
request: () => target.scene?.requestRender?.(),
};
viewerCredits.set(target, credits);
}
return credits;
function creditsOf(engine) {
if (typeof engine?.credits?.addStatic !== 'function') return null;
return {
addStatic: (html, options) => engine.credits.addStatic(html, options),
request: () => engine.render?.request('credits'),
};
}
function updateOsmDisplay(credits, state) {
@@ -713,7 +699,7 @@ const dynamicCreditKeys = new WeakMap();
* Register a conditional credit at the moment its data source activates.
* Idempotent per `credit.key`; lands in the same "Data attribution" popover
* as the static credits (showOnScreen=false).
* @param {object} target — the globe engine, or the initialized Cesium viewer
* @param {object} target — the globe engine
* @param {{ key: string, html: string }} credit — e.g. `TOMTOM_CREDIT`
* @returns {boolean} True when the credit is (now) registered.
*/
@@ -730,10 +716,10 @@ export function registerDynamicCredit(target, credit) {
}
/**
* Register every per-layer data credit into the viewer's credit display.
* Register every per-layer data credit into the engine's credit display.
* Idempotent: safe to call once at init. Credits are static and always
* present in the "Data attribution" popover.
* @param {object} target — the globe engine, or the initialized Cesium viewer
* @param {object} target — the globe engine
*/
export function registerDataCredits(target, credits = DATA_CREDITS) {
const display = creditsOf(target);
+27 -17
View File
@@ -56,17 +56,25 @@ test('OpenStreetMap has one generic data credit with separate tile and names dis
);
});
// A Cesium engine's credits over a viewer double's credit display.
async function cesiumCreditsEngine(viewer) {
const { createCesiumCredits } = await import('../globe/cesium/credits.js');
return {
credits: createCesiumCredits(viewer).credits,
render: { request() {} },
};
}
test('inline OSM attribution persists until its last display owner leaves', async (t) => {
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
t.mock.timers.enable({ apis: ['setTimeout'] });
const visible = new Set();
const viewer = {
const viewer = await cesiumCreditsEngine({
creditDisplay: {
addStaticCredit: (c) => visible.add(c),
removeStaticCredit: (c) => visible.delete(c),
},
scene: { requestRender() {} },
};
});
assert.equal(showOsmCredit(viewer, 'alpr'), true);
assert.equal(showOsmCredit(viewer, 'alpr'), false);
assert.equal(visible.size, 1);
@@ -96,29 +104,31 @@ test('inline OSM attribution persists until its last display owner leaves', asyn
assert.equal(visible.size, 1, 'reenabling restores the short credit');
});
test('tile attribution follows source changes and viewers have independent ownership', async () => {
test('tile attribution follows source changes and engines have independent ownership', async () => {
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
const create = () => {
const create = async () => {
const credits = new Set();
return {
credits,
creditDisplay: {
addStaticCredit: (c) => credits.add(c),
removeStaticCredit: (c) => credits.delete(c),
},
shown: credits,
...(await cesiumCreditsEngine({
creditDisplay: {
addStaticCredit: (c) => credits.add(c),
removeStaticCredit: (c) => credits.delete(c),
},
})),
};
};
const a = create(),
b = create();
const a = await create(),
b = await create();
showOsmCredit(a, 'installations', { openMapTiles: true });
showOsmCredit(b, 'installations');
assert.equal(a.credits.size, 2);
assert.equal(b.credits.size, 1);
assert.equal(a.shown.size, 2);
assert.equal(b.shown.size, 1);
showOsmCredit(a, 'installations');
assert.equal(a.credits.size, 1, 'wide names no longer use tiles');
assert.equal(a.shown.size, 1, 'wide names no longer use tiles');
hideOsmCredit(a, 'installations');
assert.equal(a.credits.size, 0);
assert.equal(b.credits.size, 1);
assert.equal(a.shown.size, 0);
assert.equal(b.shown.size, 1);
});
test('data credits go through the globe engine credits', async () => {
+6 -8
View File
@@ -1,15 +1,13 @@
import { worldToWindowCoordinates } from './render/cesiumProjection.js';
import * as geo from '../geo/index.js';
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
// A window position through the engine's projector when `scene` has a
// registered engine (a Cesium scene in the app, the Navara aircraft
// renderer's scene adapter), else Cesium's SceneTransforms (bare scenes in
// tests). The two agree on Cesium (the engine projector is SceneTransforms).
// A window position through the engine projector the scene carries (the
// layers' engine views, src/layers/shared/render/engineView.js), else
// Cesium's SceneTransforms (a Cesium scene). The two agree on Cesium (the
// engine projector is SceneTransforms).
function worldToWindow(scene, position, result) {
const projector = engineFromViewer(scene)?.projector;
return projector
? projector.worldToWindow(position, result)
return scene.projector
? scene.projector.worldToWindow(position, result)
: worldToWindowCoordinates(scene, position, result);
}
+6 -8
View File
@@ -163,12 +163,9 @@ test('NearFarScalar rendered-size interpolation matches Cesium clamp endpoints',
assert.ok(middle > 0.5 && middle < 3);
});
test('a focus target projects through the engine registered for its scene', async () => {
test('a focus target projects through the projector its scene carries', async () => {
const { publishFocusTargetFromCachedPosition, clearFocusTarget } =
await import('./focusDeemphasis.js');
const { registerViewerEngine } = await import(
'../globe/cesium/engineFromViewer.js'
);
const { createFakeEngine } = await import('../globe/testing/fakeEngine.js');
const engine = createFakeEngine();
const projected = [];
@@ -176,9 +173,11 @@ test('a focus target projects through the engine registered for its scene', asyn
projected.push(position);
return Object.assign(result, { x: 300, y: 200 });
};
// A scene with no Cesium behind it (the Navara renderer's adapter).
const scene = { frameState: { frameNumber: 7 } };
const unregister = registerViewerEngine(scene, engine);
// A scene with no Cesium behind it (a layer's engine view).
const scene = {
frameState: { frameNumber: 7 },
projector: engine.projector,
};
try {
const target = publishFocusTargetFromCachedPosition({
ownerLayer: 'flights',
@@ -201,7 +200,6 @@ test('a focus target projects through the engine registered for its scene', asyn
assert.equal(target.frameNumber, 7);
} finally {
clearFocusTarget('flights');
unregister();
engine.destroy();
}
});
+4 -6
View File
@@ -26,14 +26,12 @@ import {
horizonOccluder,
} from './render/cesiumProjection.js';
import * as geo from '../geo/index.js';
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
// A window position through the engine's projector when `scene` has a
// registered engine (engineFromViewer), else Cesium's SceneTransforms.
// A window position through the engine projector the scene carries (the
// layers' engine views), else Cesium's SceneTransforms (a Cesium scene).
function worldToWindow(scene, position, result) {
const projector = engineFromViewer(scene)?.projector;
return projector
? projector.worldToWindow(position, result)
return scene.projector
? scene.projector.worldToWindow(position, result)
: worldToWindowCoordinates(scene, position, result);
}
+7 -12
View File
@@ -746,13 +746,12 @@ test('the perspective helper is continuous through the sub-pixel band, not a 23
);
});
test('the exact projection asks the engine projector of a registered scene', async () => {
test('the exact projection asks the engine projector its scene carries', async () => {
const geo = await import('../geo/index.js');
const { registerViewerEngine } = await import('../globe/cesium/engineFromViewer.js');
const { perspectiveProjectedRotation: rotation } = await import('./iconOrientation.js');
const scene = { camera: { rightWC: { x: 0, y: 1, z: 0 }, upWC: { x: 0, y: 0, z: 1 } } };
const asked = [];
const unregister = registerViewerEngine({ scene }, {
const scene = {
camera: { rightWC: { x: 0, y: 1, z: 0 }, upWC: { x: 0, y: 0, z: 1 } },
projector: {
// Screen-right for every point further east (the probe), so 90° on screen.
worldToWindow(position, result = geo.cartesian2.create()) {
@@ -760,12 +759,8 @@ test('the exact projection asks the engine projector of a registered scene', asy
return Object.assign(result, { x: asked.length === 1 ? 100 : 120, y: 100 });
},
},
});
try {
const value = rotation(scene, geo.cartesian3.fromDegrees(0, 0, 0), 90, null);
assert.equal(asked.length, 2, 'the anchor and the probe');
assert.ok(Math.abs(value - -Math.PI / 2) < 1e-9, `rotation ${value}`);
} finally {
unregister();
}
};
const value = rotation(scene, geo.cartesian3.fromDegrees(0, 0, 0), 90, null);
assert.equal(asked.length, 2, 'the anchor and the probe');
assert.ok(Math.abs(value - -Math.PI / 2) < 1e-9, `rotation ${value}`);
});
+7 -19
View File
@@ -1,5 +1,3 @@
import { isGlobeEngine } from '../globe/contract.js';
/**
* Validation for positions that came back from a Cesium screen pick.
*
@@ -54,28 +52,18 @@ export function isPickedWorldPosition(position) {
* The world position under a screen point: the rendered surface where the
* depth buffer has it, else the ellipsoid; null over the sky.
*
* @param {object} target The globe engine (`engine.picker.pickPosition`, then
* `engine.camera.pickEllipsoid`), or a Cesium viewer from callers that
* still hold one (`scene.pickPosition`, then `camera.pickEllipsoid`).
* @param {object} engine The globe engine (`engine.picker.pickPosition`,
* then `engine.camera.pickEllipsoid`); without one, null.
* @param {{x: number, y: number}} screenPosition
* @returns {{x: number, y: number, z: number}|null} A Cartesian3.
*/
export function pickGroundPosition(target, screenPosition) {
if (isGlobeEngine(target)) {
const { picker, camera } = target;
return groundUnder(
screenPosition,
picker.pickPositionSupported ? picker : null,
camera,
);
}
const scene = target?.scene;
export function pickGroundPosition(engine, screenPosition) {
if (!engine) return null;
const { picker, camera } = engine;
return groundUnder(
screenPosition,
scene?.pickPositionSupported && typeof scene.pickPosition === 'function'
? scene
: null,
typeof target?.camera?.pickEllipsoid === 'function' ? target.camera : null,
picker.pickPositionSupported ? picker : null,
camera,
);
}
+3 -14
View File
@@ -162,20 +162,6 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - 30.2672) < 1e-6);
});
test('the ground under a click: the rendered surface, else the ellipsoid, never the sky', async () => {
const { pickGroundPosition } = await import('./scenePick.js');
const ground = { x: 6378137, y: 0, z: 0 };
const sky = { x: 500, y: 0, z: 0 };
const viewer = (picked, ellipsoid) => ({
scene: { pickPositionSupported: true, pickPosition: () => picked },
camera: { pickEllipsoid: () => ellipsoid },
});
assert.equal(pickGroundPosition(viewer(ground, null), {}), ground);
assert.equal(pickGroundPosition(viewer(sky, ground), {}), ground);
assert.equal(pickGroundPosition(viewer(undefined, undefined), {}), null);
assert.equal(pickGroundPosition(null, {}), null);
});
test('a surface pick is nothing, or Google 3D tile content without an id', async () => {
const { isSurfacePick } = await import('./scenePick.js');
assert.equal(isSurfacePick(undefined), true);
@@ -210,6 +196,9 @@ test('on a globe engine the ground is the picked surface, else the ellipsoid, ne
unsupported.fake.script('pickEllipsoid', ground);
assert.equal(pickGroundPosition(unsupported, screen), ground);
assert.equal(unsupported.fake.callsOf('picker.pickPosition').length, 0);
unsupported.fake.script('pickEllipsoid', undefined);
assert.equal(pickGroundPosition(unsupported, screen), null);
assert.equal(pickGroundPosition(null, screen), null, 'no engine');
});
test('engine pick results classify like the Cesium picks they wrap', async () => {
+8 -2
View File
@@ -10,6 +10,7 @@ import { createDirectionsSource } from '../layers/directions/source.js';
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
import { createCctvDisplay } from '../layers/cctv/render/display.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { createCesiumCredits } from '../globe/cesium/credits.js';
import * as input from './inputOwnership.js';
import { createOverpassAlprSource } from '../layers/alpr/source.js';
@@ -34,6 +35,11 @@ function viewer(t) {
};
return v;
}
// The Cesium engine's credits over the viewer double's credit display.
const cesiumCredits = (v) => ({
credits: createCesiumCredits(v).credits,
render: { request: noop },
});
function assertCredit(v) {
assert.ok(v.credits.some((credit) => credit.showOnScreen && credit.html.includes('>© OpenStreetMap<')));
}
@@ -55,13 +61,13 @@ for (const index of [0, 1]) test(`${index ? 'dams' : 'datacenters'} introduce th
test('Nepal locator introduces the shared OSM credit', async (t) => {
const v = viewer(t);
const layer = createBhoteKoshiLocatorLayer({ boundaryResolver: async () => ({ name: 'Nepal', ring: [[80, 26], [88, 26], [88, 30], [80, 30]] }), overlayHost, requestRender: noop, scheduleFrame: () => 1, cancelFrame: noop });
await layer.init(v); await layer.enable(v, { origin: 'scene' });
await layer.init(v, { engine: cesiumCredits(v) }); await layer.enable(v, { origin: 'scene' });
assertCredit(v); await layer.destroy(v);
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
});
test('voice route geometry introduces the shared OSM credit', (t) => {
const v = viewer(t), renderer = createWorldAnnotationRenderer(v);
const v = viewer(t), renderer = createWorldAnnotationRenderer(v, { engine: cesiumCredits(v) });
renderer.add({ id: 'route', type: 'route', color: 'primary', anchor: { lat: 0, lon: 0 }, path: [{ lat: 0, lon: 0 }, { lat: 0.01, lon: 0.01 }] });
assertCredit(v); renderer.destroy();
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
+2 -2
View File
@@ -173,8 +173,8 @@ function viewerAtHeight(height, { tilesLoaded = true, sampleHeight = () => undef
trackedEntity: undefined,
entities: { remove() {} },
};
// The ground snap samples through the viewer's engine (engineFromViewer):
// the engine surface over this stub's tileset and scene.sampleHeight.
// The ground snap samples through the viewer's engine (the test
// renderer's surfaceOwner, engineFromViewer): the engine surface over this stub's tileset and scene.sampleHeight.
registerViewerEngine(viewer, {
isDestroyed: () => false,
surface: {
+2 -2
View File
@@ -34,8 +34,8 @@
// rightWC, heading, pitch, roll, frustum.fovy) and `scene` scene-shaped
// ({ camera, canvas.clientHeight, frameState.frameNumber }) for the shared
// helpers that take them (iconOrientation, focusDeemphasis): adapters over
// engine.camera.pose(), with the engine registered for the scene
// (engineFromViewer) so projections go through its projector.
// engine.camera.pose(), the scene carrying the engine's `projector` so
// projections go through it.
/**
* @typedef {object} AircraftView
+1 -2
View File
@@ -1364,8 +1364,7 @@ test('with SELECT BOX armed, Escape clears the preview first and the next Escape
const fakes = boxToolFakes();
const cancels = [];
const tool = initImageryBoxTool({
viewer: fakes.viewer,
cesium: fakes.cesium,
engine: fakes.engine,
pickWorld: fakes.pickWorld,
documentRef: fakes.documentRef,
onBox: (box) => f.layer.setBox(box),
+93 -116
View File
@@ -1,6 +1,6 @@
/**
* Test doubles shared by the Recent Imagery tests: manual timers, fetch
* responses, and the Cesium/viewer/document fakes the box tool needs.
* responses, and the engine and document fakes the box tool needs.
*/
import { thumbnailOrder } from './model.js';
@@ -60,139 +60,116 @@ export function response({
};
}
const EVENT_TYPES = {
LEFT_DOWN: 'down',
MOUSE_MOVE: 'move',
LEFT_UP: 'up',
LEFT_CLICK: 'click',
LEFT_DOUBLE_CLICK: 'dblclick',
};
/**
* Cesium, viewer and document fakes for `initImageryBoxTool`. The canvas is
* 1000 × 500 px mapped linearly onto 0.2° near Austin; y < 40 px is sky.
*/
export function boxToolFakes() {
const handlers = [];
class ScreenSpaceEventHandler {
constructor(canvas) {
this.canvas = canvas;
this.actions = new Map();
this.destroyed = false;
handlers.push(this);
}
setInputAction(fn, type) {
this.actions.set(type, fn);
}
getInputAction(type) {
return this.actions.get(type);
}
removeInputAction(type) {
this.actions.delete(type);
}
destroy() {
this.destroyed = true;
}
}
class CustomDataSource {
constructor(name) {
this.name = name;
const values = [];
this.entities = {
values,
add: (entity) => (values.push(entity), entity),
removeAll: () => values.splice(0),
};
}
}
const color = { withAlpha: (alpha) => ({ alpha }) };
const cesium = {
ScreenSpaceEventHandler,
ScreenSpaceEventType: EVENT_TYPES,
CustomDataSource,
CallbackProperty: class {},
PolylineDashMaterialProperty: class {},
ClassificationType: { BOTH: 'both' },
Color: { fromCssColorString: () => color },
Rectangle: { fromDegrees: () => ({}) },
Cartesian3: { fromDegreesArray: (values) => values },
};
const stock = new ScreenSpaceEventHandler('stock');
const originalClick = () => 'select';
const originalDouble = () => 'track';
stock.setInputAction(originalClick, EVENT_TYPES.LEFT_CLICK);
stock.setInputAction(originalDouble, EVENT_TYPES.LEFT_DOUBLE_CLICK);
const sources = [];
const viewer = {
renders: 0,
scene: {
canvas: { clientWidth: 1000, clientHeight: 500 },
screenSpaceCameraController: { enableInputs: true },
requestRender: () => {
viewer.renders += 1;
},
},
screenSpaceEventHandler: stock,
dataSources: {
sources,
add: (source) => (sources.push(source), Promise.resolve(source)),
remove: (source) => sources.splice(sources.indexOf(source), 1),
},
};
/** A DOM event target that keeps its listeners by type. */
function eventTarget(extra = {}) {
const listeners = [];
const classes = new Set();
const documentRef = {
return {
listeners,
classes,
addEventListener: (type, fn) => listeners.push([type, fn]),
removeEventListener: (type, fn) => {
const index = listeners.findIndex((entry) => entry[1] === fn);
const index = listeners.findIndex(
(entry) => entry[0] === type && entry[1] === fn,
);
if (index >= 0) listeners.splice(index, 1);
},
dispatch(type, event) {
for (const [name, fn] of [...listeners]) if (name === type) fn(event);
return event;
},
...extra,
};
}
/** A pointer or key event that records whether it was consumed. */
function domEvent(fields) {
return {
prevented: false,
stopped: false,
preventDefault() {
this.prevented = true;
},
stopImmediatePropagation() {
this.stopped = true;
},
...fields,
};
}
/**
* Globe engine and document fakes for `initImageryBoxTool`. The canvas is
* 1000 × 500 px mapped linearly onto 0.2° near Austin; y < 40 px is sky.
* `suspend: true` gives the engine input a counted suspendCameraInputs
* (Navara's); without it the tool turns setCameraInputs off and on.
*/
export function boxToolFakes({ suspend = false } = {}) {
const camera = { inputs: true, suspended: 0 };
const captured = new Set();
const canvas = eventTarget({
getBoundingClientRect: () => ({ left: 0, top: 0 }),
setPointerCapture: (id) => captured.add(id),
hasPointerCapture: (id) => captured.has(id),
releasePointerCapture: (id) => captured.delete(id),
});
const input = {
setCameraInputs(enabled) {
camera.inputs = Boolean(enabled);
},
};
if (suspend)
input.suspendCameraInputs = () => {
camera.suspended += 1;
return () => {
camera.suspended -= 1;
};
};
const engine = {
host: { canvas, size: () => ({ width: 1000, height: 500 }) },
input,
};
const windowRef = eventTarget();
const classes = new Set();
const documentRef = eventTarget({
classes,
defaultView: windowRef,
body: {
classList: {
add: (name) => classes.add(name),
remove: (name) => classes.delete(name),
},
},
};
const pickWorld = (_viewer, nx, ny) =>
});
const pickWorld = (_engine, nx, ny) =>
ny * 500 < 40 ? null : { lon: -97.9 + nx * 0.2, lat: 30.4 - ny * 0.2 };
/** The live (non-stock) canvas handler, if the tool has one bound. */
const handler = () =>
handlers.find((h) => h.canvas !== 'stock' && !h.destroyed) || null;
const fire = (type, event) =>
handler()?.actions.get(EVENT_TYPES[type])?.(event);
const pointer = (at, fields = {}) =>
domEvent({
button: 0,
pointerId: 1,
clientX: at.x,
clientY: at.y,
...fields,
});
/** Pointer events as the browser sends them: a press on the canvas, moves and the release on the document. */
const down = (at, fields) =>
canvas.dispatch('pointerdown', pointer(at, fields));
const move = (at, fields) =>
documentRef.dispatch('pointermove', pointer(at, fields));
const up = (at, fields) =>
documentRef.dispatch('pointerup', pointer(at, fields));
/** Dispatch a keydown to the tool's document listener. */
const key = (name) => {
const event = {
key: name,
prevented: false,
stopped: false,
preventDefault() {
this.prevented = true;
},
stopImmediatePropagation() {
this.stopped = true;
},
};
listeners.find(([type]) => type === 'keydown')?.[1](event);
return event;
};
const key = (name) =>
documentRef.dispatch('keydown', domEvent({ key: name }));
return {
cesium,
viewer,
engine,
canvas,
camera,
captured,
documentRef,
windowRef,
pickWorld,
handler,
fire,
down,
move,
up,
key,
types: EVENT_TYPES,
originalClick,
originalDouble,
};
}
+6 -7
View File
@@ -1,13 +1,12 @@
// An engine-contract view shared by layer renderers and the celestial HUD:
// `camera` camera-shaped and `scene`
// scene-shaped adapters over engine.camera.pose() (refreshed once per frame,
// and whenever the camera moved), with the engine registered for the scene
// (engineFromViewer) so the shared helpers (iconOrientation, focus) project
// through its projector; the frame number, frustum and horizon tests, the
// window projection and the frame/camera events. No three, no Cesium.
// and whenever the camera moved), the scene carrying the engine's projector
// so the shared helpers (iconOrientation, focus) project through it; the
// frame number, frustum and horizon tests, the window projection and the
// frame/camera events. No three, no Cesium.
import * as geo from '../../../geo/index.js';
import { registerViewerEngine } from '../../../globe/cesium/engineFromViewer.js';
/**
* @param {import('../../../globe/contract.js').GlobeEngine} engine
@@ -87,9 +86,9 @@ export function createEngineLayerView(
return frameNumber;
},
},
// What the shared helpers project through.
projector: engine.projector,
};
// Shared helpers project through the engine registered for their scene.
removers.push(registerViewerEngine(scene, engine));
const horizon = {
isPointVisible: (position) => engine.projector.horizonVisible(position),
};
+2 -2
View File
@@ -19,8 +19,8 @@
// The view: `camera` camera-shaped (positionWC, positionCartographic,
// rightWC, upWC, heading, pitch, roll, pickEllipsoid(screen)) and `scene`
// scene-shaped ({ camera, canvas }) for the shared helpers that take them
// (iconOrientation, the view-area request), with the engine registered for
// the scene (engineFromViewer) so projections go through its projector.
// (iconOrientation, the view-area request), the scene carrying the engine's
// `projector` so projections go through it.
/**
* @typedef {object} VesselView
+2 -2
View File
@@ -10,11 +10,11 @@ export class MapStackController extends MapSourceController {
const googleApiKey =
typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
// Sources for the page's engine: Navara builds its own from the
// descriptions (src/maps/defaultSources.js).
// descriptions (src/maps/defaultSources.js). Without an engine, Cesium's.
const registry = createDefaultMapSources({
...options,
googleApiKey,
engineKind: options.engine?.kind === 'navara' ? 'navara' : 'cesium',
engineKind: options.engine?.kind ?? 'cesium',
});
super(viewer, {
registry,
+2 -2
View File
@@ -1,5 +1,4 @@
import * as geo from '../geo/index.js';
import { isGlobeEngine } from '../globe/contract.js';
import { WORLD_OVERLAY_OCCLUDER_SELECTORS } from './worldOverlayChrome.js';
export { WORLD_OVERLAY_OCCLUDER_SELECTORS } from './worldOverlayChrome.js';
import {
@@ -2727,7 +2726,8 @@ function createDevFacade() {
* @param {import('../globe/contract.js').GlobeEngine} engine
*/
export function initWorldOverlay(engine) {
if (!isGlobeEngine(engine))
// What the host reads of the engine: a Cesium viewer has none of it.
if (!engine?.projector || !engine.frames?.postRender || !engine.host)
throw new TypeError('initWorldOverlay requires a globe engine');
if (_engine === engine && _canvas && _removePostRender) return;
if (_engine) destroyWorldOverlay();
+1 -1
View File
@@ -410,7 +410,7 @@ test('annotation capability miss keeps its pin and exposes the unavailable label
event,
attempts = 0;
const engine = createAnnotationEngine({
viewer: {},
globe: {},
renderer: {
add() {},
update(mark) {
+23 -17
View File
@@ -38,12 +38,10 @@
* `scene.requestRender()`. It needs no frame events (`engine.frames`).
*
* Any other globe engine (Navara) is the governor's backend instead: install
* it on the engine and the owner-counted holds drive
* `engine.render.setContinuous`, requests go to `engine.render.request`.
* it on the engine (installEngineRenderGovernor) and the owner-counted holds
* drive `engine.render.setContinuous`, requests go to `engine.render.request`.
*/
import { isGlobeEngine } from './globe/contract.js';
let _viewer = null;
/** The non-Cesium engine the governor drives, or null (Cesium: `_viewer`). */
let _engine = null;
@@ -75,22 +73,14 @@ function applyMode() {
}
/**
* Install the governor on the viewer. Idempotent. Before install,
* hold/release still record into the holds set (and apply at install time);
* requests are safe no-ops — so modules can call all three unconditionally
* in tests without a viewer.
* @param {Cesium.Viewer | import('./globe/contract.js').GlobeEngine} viewer
* The Cesium viewer, or a non-Cesium globe engine.
* Install the governor on a Cesium viewer, as the Cesium engine's render
* backend. Idempotent. Before install, hold/release still record into the
* holds set (and apply at install time); requests are safe no-ops — so
* modules can call all three unconditionally in tests without a viewer.
* @param {Cesium.Viewer} viewer
* @returns {void}
*/
export function installRenderGovernor(viewer) {
if (isGlobeEngine(viewer) && viewer.kind !== 'cesium') {
_engine = viewer;
_viewer = viewer;
_installed = true;
applyMode();
return;
}
if (!viewer?.scene)
throw new TypeError('installRenderGovernor requires a Cesium viewer');
_engine = null;
@@ -103,6 +93,22 @@ export function installRenderGovernor(viewer) {
applyMode();
}
/**
* Install the governor on a globe engine that renders by itself (Navara):
* the holds drive `engine.render.setContinuous`, requests go to
* `engine.render.request`. Idempotent, like installRenderGovernor.
* @param {import('./globe/contract.js').GlobeEngine} engine
* @returns {void}
*/
export function installEngineRenderGovernor(engine) {
if (typeof engine?.render?.setContinuous !== 'function')
throw new TypeError('installEngineRenderGovernor requires a globe engine');
_engine = engine;
_viewer = engine;
_installed = true;
applyMode();
}
/**
* Register a continuous-render hold. Idempotent per owner.
* Call where the owner's per-frame work BEGINS (scene listener installed,
+3 -2
View File
@@ -2,6 +2,7 @@ import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import {
installRenderGovernor,
installEngineRenderGovernor,
holdContinuousRender,
releaseContinuousRender,
governorRequestRender,
@@ -118,7 +119,7 @@ test('on a non-Cesium globe engine the governor drives engine.render with owner-
_resetRenderGovernorForTest();
const engine = createFakeEngine({ kind: 'navara' });
holdContinuousRender('before-install');
installRenderGovernor(engine);
installEngineRenderGovernor(engine);
assert.equal(engine.render.isContinuous(), true, 'a hold taken before install applies');
releaseContinuousRender('before-install');
assert.equal(engine.render.isContinuous(), false);
@@ -141,7 +142,7 @@ test('releasing an absent Navara hold does not wake an already idle engine', asy
const { createFakeEngine } = await import('./globe/testing/fakeEngine.js');
_resetRenderGovernorForTest();
const engine = createFakeEngine({ kind: 'navara' });
installRenderGovernor(engine);
installEngineRenderGovernor(engine);
engine.fake.resetCalls();
for (let i = 0; i < 22; i++) releaseContinuousRender('cctv-projection');
assert.equal(engine.fake.callsOf('render.request').length, 0);
+3 -3
View File
@@ -116,7 +116,7 @@ export class SceneDirector {
});
this._sharing = createSceneSharing(this);
this._interactionTransitions = 0;
this._interactions = createSceneInteractions(viewer, {
this._interactions = createSceneInteractions({
engine,
available: () =>
!this._destroyed && !this._running && !this.viewer?.trackedEntity,
@@ -255,7 +255,7 @@ export class SceneDirector {
});
this._cameraHandoffUnsubscribe?.();
this._removeCameraInput?.();
this._interactions.setScene(null, null);
this._interactions.setScene(null);
this._dataPacks.setScene(null, null);
this._flight?.suspend();
this.engine = this.viewer = null;
@@ -268,7 +268,7 @@ export class SceneDirector {
this._isMapStackAvailable = isAvailable;
try {
this._dataPacks.setScene(viewer, native, engine.host.container);
this._interactions.setScene(viewer, engine, this._dataPacks.getTargets());
this._interactions.setScene(engine, this._dataPacks.getTargets());
} catch (error) {
// Release any packs already drawn before the caller parks the failed globe.
this.suspendScene();
+16 -27
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import { nextTick } from 'vue';
import { isPointerFree } from '../data/inputOwnership.js';
import { createInteractionSession } from '../director/interactions/session.js';
@@ -9,21 +8,19 @@ import { applicationPinia } from '../ui/vueRuntime.js';
/**
* Own the active shot's pick handler, accessible actions and plain-text
* feedback. The Scene actions panel is presented to the scene interactions
* store, which its view renders inside the viewer's container.
* store, which its view renders inside the globe engine's container. A
* feature is picked through engine.input (a click with its pick).
*/
// Keys each activation's panel, so a new one never reuses an old one's DOM.
let activations = 0;
export function createSceneInteractions(
viewer,
{
execute,
engine = null,
available = () => true,
store = useSceneInteractionsStore(applicationPinia()),
},
) {
let container = engine?.host?.container ?? viewer?.container;
export function createSceneInteractions({
execute,
engine = null,
available = () => true,
store = useSceneInteractionsStore(applicationPinia()),
}) {
let container = engine?.host?.container;
let panel = null,
handler,
items = [],
@@ -79,7 +76,7 @@ export function createSceneInteractions(
return ok;
}
function bindInput(owner) {
if (!engine && !viewer) return;
if (!engine) return;
const click = (event) => {
if (
panel?.owner !== owner ||
@@ -88,9 +85,7 @@ export function createSceneInteractions(
!available()
)
return;
const picked = engine
? event.picked?.id
: viewer.scene.pick(event.position)?.id;
const picked = event.picked?.id;
const matches = items.filter(
(item) =>
targets.get(
@@ -108,32 +103,26 @@ export function createSceneInteractions(
void nextTick(() => {
if (panel?.owner !== owner || !shown()) return;
const id = globalThis.CSS?.escape?.(matches[0].id) ?? matches[0].id;
(engine?.host.container ?? viewer?.container)
engine?.host.container
?.querySelector?.(
`[data-director-interactions] [data-director-action="${id}"]`,
)
?.focus({ preventScroll: true });
});
};
if (engine) {
handler = { destroy: engine.input.on('click', click, { pick: true }) };
} else {
handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction(click, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
handler = { destroy: engine.input.on('click', click, { pick: true }) };
}
return {
clear,
destroy: clear,
getState: session.getState,
setScene(nextViewer, nextEngine, registered) {
setScene(nextEngine, registered) {
handler?.destroy();
handler = null;
viewer = nextViewer;
engine = nextEngine;
if (registered) targets = registered;
if (!panel || (!engine && !viewer)) return;
container = engine?.host?.container ?? viewer?.container ?? container;
if (!panel || !engine) return;
container = engine.host?.container ?? container;
update({ container });
bindInput(panel.owner);
},
+43 -16
View File
@@ -1,5 +1,5 @@
// src/scenes/interactionsView.test.mjs — the Scene actions panel renders in
// the viewer's container from what the scene interactions owner presents.
// the engine's container from what the scene interactions owner presents.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -8,6 +8,7 @@ import { createPinia } from 'pinia';
import { useSceneInteractionsStore } from '../ui/stores/sceneInteractions.js';
import SceneInteractions from '../ui/views/panels/SceneInteractions.vue';
import { createSceneInteractions } from './interactions.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
const target = (featureId) => ({ packId: 'qa', featureId });
const key = (featureId) => JSON.stringify(['qa', featureId]);
@@ -36,25 +37,25 @@ function fixture(t, { execute = async () => true } = {}) {
const app = createApp(SceneInteractions).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
const executed = [];
const interactions = createSceneInteractions(
{ container, scene: { canvas, pick: () => null } },
{
execute: (action, signal) => {
executed.push(action.type);
return execute(action, signal);
},
store: useSceneInteractionsStore(pinia),
const engine = createFakeEngine({ container, canvas });
const interactions = createSceneInteractions({
engine,
execute: (action, signal) => {
executed.push(action.type);
return execute(action, signal);
},
);
store: useSceneInteractionsStore(pinia),
});
t.after(() => {
interactions.destroy();
app.unmount();
engine.destroy();
});
const $ = (selector) => container.querySelector(selector);
const settle = async () => {
for (let flush = 0; flush < 4; flush += 1) await nextTick();
};
return { interactions, container, executed, $, settle, pinia };
return { interactions, engine, container, executed, $, settle, pinia };
}
test('a shot with actions shows its panel in the viewer container', async (t) => {
@@ -175,11 +176,12 @@ test('a button reused before the next render still belongs to its own panel', as
});
test("an earlier owner leaves a later owner's panel alone", async (t) => {
const { interactions, container, $, settle, pinia } = fixture(t);
const later = createSceneInteractions(
{ container, scene: { canvas: $('canvas'), pick: () => null } },
{ execute: async () => true, store: useSceneInteractionsStore(pinia) },
);
const { interactions, engine, $, settle, pinia } = fixture(t);
const later = createSceneInteractions({
engine,
execute: async () => true,
store: useSceneInteractionsStore(pinia),
});
t.after(() => later.destroy());
interactions.activate(SHOT, new Map([[key('point'), {}]]));
later.activate(SHOT, new Map([[key('point'), {}]]));
@@ -190,3 +192,28 @@ test("an earlier owner leaves a later owner's panel alone", async (t) => {
await settle();
assert.equal(later.getState().selected, 'fly');
});
test('a click on a feature of the shot outlines its actions through engine.input', async (t) => {
const { interactions, engine, $, settle } = fixture(t);
const feature = { kind: 'pack-feature' };
interactions.activate(SHOT, new Map([[key('point'), feature]]));
await settle();
engine.fake.script('pick', { id: { kind: 'other' } });
engine.fake.input('click', { position: { x: 10, y: 10 } });
await settle();
assert.deepEqual(interactions.getState().outlined ?? [], []);
assert.equal(
$('[data-director-action="card"]').style.outline,
'',
'another object selects nothing',
);
engine.fake.script('pick', { id: feature });
engine.fake.input('click', { position: { x: 10, y: 10 } });
await settle();
assert.equal(
$('[role=status]').textContent,
'Selected feature: point. Choose an action.',
);
assert.ok($('[data-director-action="card"]').style.outline);
assert.ok($('[data-director-action="fly"]').style.outline);
});
+4 -7
View File
@@ -47,7 +47,6 @@
*/
import * as geo from '../geo/index.js';
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
import { acceptedSurfaceHeight } from '../globe/surfaceDetail.js';
import { MAX_FLOOR_TILE_SIZE_M } from './meshFloorSampler.js';
@@ -155,7 +154,7 @@ export function firstVisibleTileset(primitives) {
/**
* Create a per-layer ground-snap cache.
* @returns {{
* heightFor: (target: object, icao: string, pos: Cesium.Cartesian3,
* heightFor: (engine: object, icao: string, pos: Cesium.Cartesian3,
* getExclusions?: () => Array<unknown>) => number|null,
* forget: (icao: string) => void,
* clear: () => void,
@@ -266,9 +265,8 @@ export function createGroundSnap({ groundFloor }) {
* A contact that HAS resolved once keeps that answer through a resample outage;
* see `heldSnapM`.
*
* @param {object} target - The globe engine (`engine.surface`:
* sampleHeightDetailed and tilesLoaded), or a viewer whose engine is
* registered (engineFromViewer); without one, no sample.
* @param {object} engine - The globe engine (`engine.surface`:
* sampleHeightDetailed and tilesLoaded); without one, no sample.
* @param {string} icao - Cache key (per-layer icao space).
* @param {Cesium.Cartesian3} pos - Current display position. Cache movement
* is measured after projecting this input to the WGS84 ellipsoid, so a
@@ -279,8 +277,7 @@ export function createGroundSnap({ groundFloor }) {
* lands on its icon/model otherwise). Only invoked when a sample fires.
* @returns {number|null}
*/
function heightFor(target, icao, pos, getExclusions) {
const engine = engineFromViewer(target);
function heightFor(engine, icao, pos, getExclusions) {
const surfacePos = geo.ellipsoid.WGS84.scaleToGeodeticSurface(
pos,
scratchSurfacePos,
+3 -11
View File
@@ -52,9 +52,7 @@ test('on a globe engine loading tiles or a miss return null without a sample cac
assert.equal(engine.fake.callsOf('surface.sampleHeightDetailed').length, 1);
});
test('a coarse tile answer or only terrain is a miss; a viewer reaches its engine', async () => {
const { registerViewerEngine } =
await import('../globe/cesium/engineFromViewer.js');
test('a coarse tile answer or only terrain is a miss', async () => {
const snap = createGroundSnap({ groundFloor: groundFloor() });
const engine = createFakeEngine();
engine.fake.script('sampleHeightDetailed', () => ({
@@ -73,14 +71,8 @@ test('a coarse tile answer or only terrain is a miss; a viewer reaches its engin
source: 'tiles',
tileSizeM: 241,
}));
const viewer = {};
const unregister = registerViewerEngine(viewer, engine);
try {
assert.equal(snap.heightFor(viewer, 'd3', KAUS), 122.5);
} finally {
unregister();
}
assert.equal(snap.heightFor({}, 'd4', KAUS), null, 'no engine: no sample');
assert.equal(snap.heightFor(engine, 'd3', KAUS), 122.5);
assert.equal(snap.heightFor(null, 'd4', KAUS), null, 'no engine: no sample');
});
const atDenverAirport = geo.cartesian3.fromDegrees(-104.6737, 39.8561, 0);
+3 -6
View File
@@ -1,5 +1,4 @@
import * as geo from '../geo/index.js';
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
import { acceptedSurfaceHeight } from '../globe/surfaceDetail.js';
/** Answers from tiles larger than this are too coarse to latch (m).
@@ -86,9 +85,8 @@ export function createMeshFloorSampler({
* layer poll (never per contact, never per frame). Synchronous — Cesium's
* sampleHeight is a CPU-side query against loaded tiles.
*
* @param {object|undefined} target - The globe engine (engine.surface,
* camera.height, maps.photorealActive), or a viewer or scene whose
* engine is registered (engineFromViewer); without one, nothing.
* @param {object|undefined} engine - The globe engine (engine.surface,
* camera.height, maps.photorealActive); without one, nothing.
* @param {Array<{lat: number, lon: number}>} points - Contact/waypoint coords.
* @param {object} [options]
* @param {Array<object>} [options.excludeObjects] - What the probe passes
@@ -98,12 +96,11 @@ export function createMeshFloorSampler({
* Viewer subpoint (computed once by the caller's poll).
*/
function sampleMeshFloorCells(
target,
engine,
points,
{ excludeObjects = [], viewerLat, viewerLon } = {},
) {
if (!meshFloorPreferred()) return;
const engine = engineFromViewer(target);
if (!engine || engine.isDestroyed?.() || !engine.surface.supported) return;
if (!Array.isArray(points) || !points.length) return;
// Round-5 hardening (the CCTV projectionTilesReady lesson, ported late):
+4 -2
View File
@@ -74,11 +74,13 @@ export function aircraftViewerRenderer({
get scene() {
return viewer?.scene;
},
// The engine registered for the viewer (the surface samplers take an
// engine).
get surfaceOwner() {
return viewer;
return engine ?? engineFromViewer(viewer);
},
get surfaceScene() {
return viewer?.scene;
return engine ?? engineFromViewer(viewer);
},
frameNumber: () => viewer?.scene?.frameState?.frameNumber ?? -1,
viewportHeightPx: () => viewer.scene.canvas.clientHeight,
+16
View File
@@ -36,3 +36,19 @@ export function trackingEngine(viewer) {
});
return engine;
}
/**
* A globe engine with only the camera of a viewer test double: its pose at
* the double's positionCartographic and its view rectangle. It has no canvas
* (screen picks miss) and no surface (ground heights are 0).
*/
export function cameraOnlyEngine(viewer) {
const camera = viewer?.camera;
return {
camera: {
pose: () => ({ cartographic: camera?.positionCartographic }),
viewRectangle: () => camera?.computeViewRectangle?.(),
},
host: { size: () => ({ width: 0, height: 0 }) },
};
}
-159
View File
@@ -1,159 +0,0 @@
import * as geo from '../geo/index.js';
import {
claimPointer,
pointerOwner,
releasePointer,
} from '../data/inputOwnership.js';
import { validateBox } from '../layers/recentImagery/model.js';
/** Native DOM capture keeps a box drag out of Navara's camera controller. */
export function initNavaraImageryBoxTool({
engine,
onBox,
onPreview,
onCancel,
onActive,
onEscape,
documentRef = globalThis.document,
}) {
const canvas = engine.host.canvas;
let active = false,
destroyed = false,
anchor = null,
corner = null;
let lease = null,
thaw = null,
pointerId = null;
const box = () =>
anchor && {
west: Math.min(anchor.lon, corner.lon),
east: Math.max(anchor.lon, corner.lon),
south: Math.min(anchor.lat, corner.lat),
north: Math.max(anchor.lat, corner.lat),
};
function point(event) {
const rect = canvas.getBoundingClientRect(),
at = { x: event.clientX - rect.left, y: event.clientY - rect.top };
const hit = engine.picker.pickPosition(at) || engine.picker.pickGlobe(at);
const c = hit && geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84);
return c
? {
lon: (c.longitude * 180) / Math.PI,
lat: (c.latitude * 180) / Math.PI,
}
: null;
}
const consume = (event) => {
event.preventDefault?.();
event.stopImmediatePropagation?.();
};
function endDrag() {
anchor = corner = null;
if (pointerId !== null && canvas.hasPointerCapture?.(pointerId))
canvas.releasePointerCapture(pointerId);
pointerId = null;
thaw?.();
thaw = null;
onPreview(null);
}
function down(event) {
if (
!active ||
anchor ||
event.button !== 0 ||
event.shiftKey ||
event.ctrlKey ||
event.altKey
)
return;
consume(event);
const p = point(event);
if (!p) {
onCancel?.('sky', 'Press on the ground, not the sky');
return;
}
anchor = corner = p;
pointerId = event.pointerId;
thaw = engine.input.suspendCameraInputs();
canvas.setPointerCapture?.(pointerId);
onPreview(box());
}
function move(event) {
if (!anchor || event.pointerId !== pointerId) return;
consume(event);
corner = point(event) || corner;
onPreview(box());
}
function up(event) {
if (!anchor || event.pointerId !== pointerId) return;
consume(event);
corner = point(event) || corner;
const selected = box(),
result = validateBox(selected);
if (result.reason === 'degenerate') result.message = 'Drag to size the box';
stop();
if (result.ok) onBox(result.box);
else onCancel?.(result.reason, result.message, selected);
}
function key(event) {
if (!active || event.key !== 'Escape') return;
consume(event);
if (onEscape?.() !== true) cancel('escape');
}
function pointerCancel(event) {
if (anchor && event.pointerId === pointerId) cancel('pointer-cancel');
}
const bindings = [
[canvas, 'pointerdown', down],
[documentRef, 'pointermove', move],
[documentRef, 'pointerup', up],
[documentRef, 'pointercancel', pointerCancel],
[documentRef, 'keydown', key],
];
if (documentRef.defaultView)
bindings.push([documentRef.defaultView, 'blur', () => cancel('blur')]);
function stop() {
if (!active) return;
for (const [target, type, listener] of bindings)
target.removeEventListener(type, listener, true);
endDrag();
releasePointer(lease);
lease = null;
active = false;
documentRef.body?.classList.remove('gev-imagery-box');
onActive?.(false);
}
function cancel(reason = 'cancel') {
if (active) {
stop();
onCancel?.(reason);
}
}
return {
start() {
if (destroyed) return false;
if (active) return true;
lease = claimPointer('recent-imagery-box');
if (!lease) {
onCancel?.(
'pointer-busy',
`${pointerOwner()} is using the pointer — close it first`,
);
return false;
}
active = true;
for (const [target, type, listener] of bindings)
target.addEventListener(type, listener, true);
documentRef.body?.classList.add('gev-imagery-box');
onActive?.(true);
return true;
},
cancel,
isActive: () => active,
destroy() {
if (destroyed) return;
stop();
destroyed = true;
},
};
}
-82
View File
@@ -1,82 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../geo/index.js';
import { pointerOwner } from '../data/inputOwnership.js';
import { initNavaraImageryBoxTool } from './imageryBoxNavara.js';
test('blur and destruction during a Navara box drag return input and pointer ownership', () => {
const target = () => {
const listeners = new Map();
return {
listeners,
addEventListener(type, fn) {
listeners.set(type, fn);
},
removeEventListener(type) {
listeners.delete(type);
},
};
};
const windowRef = target(),
documentRef = {
...target(),
defaultView: windowRef,
body: { classList: { add() {}, remove() {} } },
};
const canvas = {
...target(),
getBoundingClientRect: () => ({ left: 0, top: 0 }),
setPointerCapture() {},
hasPointerCapture: () => true,
releasePointerCapture() {},
};
let suspended = 0,
preview = null;
const engine = {
host: { canvas },
picker: { pickPosition: () => geo.cartesian3.fromDegrees(10, 20, 0) },
input: {
suspendCameraInputs() {
suspended++;
return () => suspended--;
},
},
};
const tool = initNavaraImageryBoxTool({
engine,
documentRef,
onBox() {},
onPreview: (b) => (preview = b),
});
const down = () =>
canvas.listeners.get('pointerdown')({
button: 0,
pointerId: 1,
clientX: 10,
clientY: 20,
preventDefault() {},
stopImmediatePropagation() {},
});
try {
assert.equal(tool.start(), true);
down();
assert.equal(suspended, 1);
assert.ok(preview);
windowRef.listeners.get('blur')();
assert.equal(suspended, 0);
assert.equal(preview, null);
assert.equal(pointerOwner(), null);
assert.equal(tool.isActive(), false);
tool.start();
down();
tool.destroy();
tool.destroy();
assert.equal(suspended, 0);
assert.equal(pointerOwner(), null);
assert.equal(canvas.listeners.size, 0);
assert.equal(documentRef.listeners.size, 0);
assert.equal(windowRef.listeners.size, 0);
} finally {
tool.destroy();
}
});
+131 -207
View File
@@ -1,11 +1,16 @@
/**
* Box selection for the Recent Imagery layer: press on the world, drag, and
* release to hand the layer a degrees box. Follows the draw tool's ownership
* rules — the pointer is claimed for the session, the viewer's own click
* actions are borrowed and given back, and `destroy()` returns everything —
* and freezes the camera only while a drag is in progress.
* rules — the pointer is claimed for the session and `destroy()` returns
* everything — and freezes the camera only while a drag is in progress.
*
* The same on every engine: the tool takes the pointer events of the
* engine's canvas in the capture phase, ahead of the engine's own handlers,
* so a press that starts a box and the drag it makes reach neither the
* camera nor any click (a layer's, or Cesium's stock select and track).
* Moves outside a drag pass through. The layer draws the preview
* (`onPreview`, a ground cell on engine.ground).
*/
import * as Cesium from 'cesium';
import { pickWorldFromScreen } from '../annotations/annotationResolver.js';
import {
claimPointer,
@@ -18,35 +23,51 @@ import { msg } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
export const IMAGERY_BOX_POINTER_OWNER = 'recent-imagery-box';
/**
* The camera held still until the returned release: the engine's counted
* suspension where it has one (Navara keeps the app's own setCameraInputs
* choice under it), else camera inputs off and on again.
*/
function freezeCamera(input) {
if (typeof input.suspendCameraInputs === 'function')
return input.suspendCameraInputs();
input.setCameraInputs(false);
return () => input.setCameraInputs(true);
}
/**
* Wire the box tool.
* @param {{ viewer: object, onBox: (box: object) => void, onCancel?: (reason: string, message?: string, box?: object) => void, onActive?: (active: boolean) => void, onEscape?: () => boolean, cesium?: object, pickWorld?: Function, documentRef?: object }} deps
* `onEscape` runs before the tool cancels on Escape; returning `true` claims
* the key (it is still consumed) and leaves the tool armed.
* @returns {{ start: () => boolean, cancel: (reason?: string) => void, isActive: () => boolean, destroy: () => Promise<void> } | null}
* @param {{ engine: import('../globe/contract.js').GlobeEngine,
* onBox: (box: object) => void, onPreview?: (box: object | null) => void,
* onCancel?: (reason: string, message?: string, box?: object) => void,
* onActive?: (active: boolean) => void, onEscape?: () => boolean,
* pickWorld?: Function, documentRef?: object }} deps
* `onPreview` gets the box being dragged, and null once the drag ends.
* `onEscape` runs before the tool cancels on Escape; returning `true`
* claims the key (it is still consumed) and leaves the tool armed.
* @returns {{ start: () => boolean, cancel: (reason?: string) => void, isActive: () => boolean, destroy: () => void } | null}
*/
export function initImageryBoxTool({
viewer,
engine,
onBox,
onPreview = () => {},
onCancel,
onActive,
onEscape,
cesium = Cesium,
pickWorld = pickWorldFromScreen,
documentRef = globalThis.document,
} = {}) {
if (!viewer?.scene || typeof onBox !== 'function') return null;
const canvas = engine?.host?.canvas;
if (!canvas || typeof onBox !== 'function') return null;
let active = false;
let destroyed = false;
/** Drag corners in degrees; `anchor` is null while no drag is live. */
let anchor = null;
let corner = null;
let handler = null;
let lease = null;
let savedSingleClick = null;
let savedDoubleClick = null;
let savedCameraInputs = null;
let thaw = null;
let pointerId = null;
const dragBox = () =>
anchor && {
@@ -55,241 +76,144 @@ export function initImageryBoxTool({
east: Math.max(anchor.lon, corner.lon),
north: Math.max(anchor.lat, corner.lat),
};
const dataSource = new cesium.CustomDataSource(
'gev-recent-imagery-box-preview',
);
let attaching = Promise.resolve(viewer.dataSources.add(dataSource)).catch(
() => null,
);
const stroke = cesium.Color.fromCssColorString('#8be9ff');
const previewRectangle = () => {
const box = dragBox();
return box
? cesium.Rectangle.fromDegrees(box.west, box.south, box.east, box.north)
: undefined;
};
const previewOutline = () => {
const box = dragBox();
if (!box) return [];
return cesium.Cartesian3.fromDegreesArray([
box.west,
box.south,
box.east,
box.south,
box.east,
box.north,
box.west,
box.north,
box.west,
box.south,
]);
};
const fill = dataSource.entities.add({
show: false,
rectangle: {
coordinates: new cesium.CallbackProperty(previewRectangle, false),
material: stroke.withAlpha(0.12),
classificationType: cesium.ClassificationType.BOTH,
},
});
// A rectangle graphic cannot draw a dashed edge on the ground, so the
// outline is its own draped polyline traced round the same corners.
const outline = dataSource.entities.add({
show: false,
polyline: {
positions: new cesium.CallbackProperty(previewOutline, false),
width: 2,
clampToGround: true,
classificationType: cesium.ClassificationType.BOTH,
material: new cesium.PolylineDashMaterialProperty({
color: stroke.withAlpha(0.95),
dashLength: 12,
}),
},
});
const requestRender = () => viewer.scene.requestRender?.();
const setPreview = (visible) => {
fill.show = visible;
outline.show = visible;
requestRender();
};
const worldAt = (position) => {
if (!position) return null;
const canvas = viewer.scene.canvas;
const w = canvas?.clientWidth || canvas?.width || 1;
const h = canvas?.clientHeight || canvas?.height || 1;
const point = pickWorld(viewer, position.x / w, position.y / h);
const worldAt = (event) => {
const rect = canvas.getBoundingClientRect();
const { width, height } = engine.host.size();
const point = pickWorld(
engine,
(event.clientX - rect.left) / (width || 1),
(event.clientY - rect.top) / (height || 1),
);
const lon = Number(point?.lon);
const lat = Number(point?.lat);
return Number.isFinite(lon) && Number.isFinite(lat) ? { lon, lat } : null;
};
const cameraController = () => viewer.scene.screenSpaceCameraController;
function freezeCamera() {
const controller = cameraController();
if (!controller || savedCameraInputs !== null) return;
savedCameraInputs = controller.enableInputs;
controller.enableInputs = false;
}
function thawCamera() {
const controller = cameraController();
if (controller && savedCameraInputs !== null)
controller.enableInputs = savedCameraInputs;
savedCameraInputs = null;
}
const consume = (event) => {
event.preventDefault?.();
event.stopImmediatePropagation?.();
};
function endDrag() {
anchor = null;
corner = null;
thawCamera();
setPreview(false);
if (pointerId !== null && canvas.hasPointerCapture?.(pointerId))
canvas.releasePointerCapture(pointerId);
pointerId = null;
thaw?.();
thaw = null;
onPreview(null);
}
const onDown = (event) => {
if (!active || destroyed || anchor) return;
const point = worldAt(event.position);
function down(event) {
if (
!active ||
anchor ||
event.button !== 0 ||
event.shiftKey ||
event.ctrlKey ||
event.altKey
)
return;
consume(event);
const point = worldAt(event);
if (!point) {
onCancel?.('sky', msg('Press on the ground, not the sky'));
return;
}
anchor = point;
corner = point;
freezeCamera();
setPreview(true);
};
const onMove = (event) => {
const point = anchor && !destroyed && worldAt(event.endPosition);
if (!point) return;
corner = point;
requestRender();
};
const onUp = (event) => {
if (!anchor || destroyed) return;
corner = worldAt(event.position) || corner;
pointerId = event.pointerId;
thaw = freezeCamera(engine.input);
canvas.setPointerCapture?.(pointerId);
onPreview(dragBox());
}
function move(event) {
if (!anchor || event.pointerId !== pointerId) return;
consume(event);
corner = worldAt(event) || corner;
onPreview(dragBox());
}
function up(event) {
if (!anchor || event.pointerId !== pointerId) return;
consume(event);
corner = worldAt(event) || corner;
const box = dragBox();
const result = validateBox(box);
if (result.reason === 'degenerate')
result.message = msg('Drag to size the box');
endDrag();
setActive(false);
stop();
if (result.ok) onBox(result.box);
else onCancel?.(result.reason, result.message, box);
};
}
// Escape is taken from a document capture listener, ahead of every other
// handler, so the interceptor runs first: when it claims the key (the
// panel exits a live comparison), the tool stays armed and the key still
// goes no further; otherwise Escape cancels the session.
const onKey = (event) => {
if (!active || destroyed || event.key !== 'Escape') return;
event.preventDefault?.();
event.stopImmediatePropagation?.();
if (onEscape?.() === true) return;
cancel('escape');
};
function bindScene() {
if (handler) return;
handler = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction(onDown, cesium.ScreenSpaceEventType.LEFT_DOWN);
handler.setInputAction(onMove, cesium.ScreenSpaceEventType.MOUSE_MOVE);
handler.setInputAction(onUp, cesium.ScreenSpaceEventType.LEFT_UP);
const stock = viewer.screenSpaceEventHandler;
if (stock) {
savedSingleClick =
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
savedDoubleClick =
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null;
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK);
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
}
documentRef?.addEventListener?.('keydown', onKey, true);
documentRef?.body?.classList?.add?.('gev-imagery-box');
function key(event) {
if (!active || event.key !== 'Escape') return;
consume(event);
if (onEscape?.() !== true) cancel('escape');
}
// A drag the browser takes away (a touch turned into a scroll, the window
// losing focus) ends the session and gives the camera back.
function pointerCancel(event) {
if (anchor && event.pointerId === pointerId) cancel('pointer-cancel');
}
const bindings = [
[canvas, 'pointerdown', down],
[documentRef, 'pointermove', move],
[documentRef, 'pointerup', up],
[documentRef, 'pointercancel', pointerCancel],
[documentRef, 'keydown', key],
];
if (documentRef?.defaultView)
bindings.push([documentRef.defaultView, 'blur', () => cancel('blur')]);
function releaseScene() {
if (handler) {
handler.destroy();
handler = null;
}
const stock = viewer.screenSpaceEventHandler;
if (stock && savedSingleClick) {
stock.setInputAction(
savedSingleClick,
cesium.ScreenSpaceEventType.LEFT_CLICK,
);
}
if (stock && savedDoubleClick) {
stock.setInputAction(
savedDoubleClick,
cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
);
}
savedSingleClick = null;
savedDoubleClick = null;
documentRef?.removeEventListener?.('keydown', onKey, true);
function stop() {
if (!active) return;
for (const [target, type, listener] of bindings)
target?.removeEventListener?.(type, listener, true);
endDrag();
releasePointer(lease);
lease = null;
active = false;
documentRef?.body?.classList?.remove?.('gev-imagery-box');
}
function setActive(next) {
if (next === active) return active;
if (next) {
lease = claimPointer(IMAGERY_BOX_POINTER_OWNER);
if (!lease) {
onCancel?.(
'pointer-busy',
`${pointerOwner()} is using the pointer — close it first`,
);
return active;
}
active = true;
bindScene();
} else {
if (anchor) endDrag();
releaseScene();
releasePointer(lease);
lease = null;
active = false;
}
onActive?.(active);
return active;
onActive?.(false);
}
function cancel(reason = 'cancel') {
if (!active || destroyed) return;
setActive(false);
stop();
onCancel?.(reason);
}
return {
/** Begin a box session. False when the pointer is held elsewhere. */
start() {
return !destroyed && setActive(true);
if (destroyed) return false;
if (active) return true;
lease = claimPointer(IMAGERY_BOX_POINTER_OWNER);
if (!lease) {
onCancel?.(
'pointer-busy',
`${pointerOwner()} is using the pointer — close it first`,
);
return false;
}
active = true;
for (const [target, type, listener] of bindings)
target?.addEventListener?.(type, listener, true);
documentRef?.body?.classList?.add?.('gev-imagery-box');
onActive?.(true);
return true;
},
cancel,
isActive() {
return active;
},
/** Returns everything; resolves once the preview is detached. */
isActive: () => active,
/** Returns the pointer, the camera and every listener. */
destroy() {
if (destroyed) return attaching;
if (active) setActive(false);
if (destroyed) return;
stop();
destroyed = true;
releaseScene();
releasePointer(lease);
lease = null;
thawCamera();
dataSource.entities.removeAll();
attaching = attaching.then(() => {
try {
viewer.dataSources.remove(dataSource, true);
} catch {
/* viewer already disposed */
}
});
return attaching;
},
};
}
+85 -55
View File
@@ -12,53 +12,50 @@ import {
} from './imageryBoxTool.js';
import { boxToolFakes } from '../layers/recentImagery/testDoubles.mjs';
function fixture(options = {}) {
function fixture(options = {}, fakeOptions = {}) {
resetPointerOwnership();
const fakes = boxToolFakes();
const fakes = boxToolFakes(fakeOptions);
const boxes = [];
const cancels = [];
const actives = [];
const previews = [];
const tool = initImageryBoxTool({
viewer: fakes.viewer,
cesium: fakes.cesium,
engine: fakes.engine,
pickWorld: fakes.pickWorld,
documentRef: fakes.documentRef,
onBox: (box) => boxes.push(box),
onPreview: (box) => previews.push(box),
onCancel: (reason, message) => cancels.push([reason, message]),
onActive: (active) => actives.push(active),
...options,
});
const camera = fakes.viewer.scene.screenSpaceCameraController;
const stockAction = (type) =>
fakes.viewer.screenSpaceEventHandler.getInputAction(fakes.types[type]);
const preview = () =>
fakes.viewer.dataSources.sources[0]?.entities.values.every((e) => e.show);
return {
...fakes,
tool,
boxes,
cancels,
actives,
camera,
stockAction,
preview,
};
return { ...fakes, tool, boxes, cancels, actives, previews };
}
test('a session claims the pointer, borrows the stock clicks, freezes the camera only mid-drag and returns everything with the box', async () => {
const listenerCount = (f) =>
f.canvas.listeners.length +
f.documentRef.listeners.length +
f.windowRef.listeners.length;
test('a session claims the pointer, keeps the drag from the engine, freezes the camera only mid-drag and returns everything with the box', () => {
const f = fixture();
assert.equal(f.tool.start(), true);
assert.equal(pointerOwner(), IMAGERY_BOX_POINTER_OWNER);
assert.equal(f.stockAction('LEFT_CLICK'), undefined);
assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), undefined);
assert.ok(f.documentRef.classes.has('gev-imagery-box'));
assert.equal(f.camera.enableInputs, true, 'not frozen before the press');
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
assert.equal(f.camera.enableInputs, false);
assert.equal(f.preview(), true);
f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 250 } });
f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 10 } }); // sky: ignored
f.fire('LEFT_UP', { position: { x: 500, y: 250 } });
assert.equal(f.camera.inputs, true, 'not frozen before the press');
const press = f.down({ x: 250, y: 125 });
assert.equal(
press.prevented && press.stopped,
true,
'the press reaches no other handler (camera, clicks)',
);
assert.equal(f.camera.inputs, false);
assert.ok(f.captured.has(1), 'the drag holds the pointer capture');
assert.ok(f.previews.at(-1), 'the preview shows');
const drag = f.move({ x: 500, y: 250 });
assert.equal(drag.stopped, true);
f.move({ x: 500, y: 10 }); // sky: the corner stays
f.up({ x: 500, y: 250 });
assert.equal(f.boxes.length, 1);
const { west, south, east, north } = f.boxes[0];
assert.deepEqual(
@@ -68,37 +65,49 @@ test('a session claims the pointer, borrows the stock clicks, freezes the camera
assert.deepEqual(f.cancels, []);
assert.equal(f.tool.isActive(), false);
assert.equal(pointerOwner(), null);
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), f.originalDouble);
assert.equal(f.camera.enableInputs, true);
assert.equal(f.preview(), false);
assert.equal(f.camera.inputs, true);
assert.equal(f.captured.size, 0);
assert.equal(f.previews.at(-1), null, 'the preview goes with the drag');
assert.equal(f.documentRef.classes.has('gev-imagery-box'), false);
assert.equal(f.documentRef.listeners.length, 0);
assert.equal(f.handler(), null);
assert.equal(listenerCount(f), 0);
assert.deepEqual(f.actives, [true, false]);
await f.tool.destroy();
f.tool.destroy();
});
test('a sky press, a click without a drag and Escape each say why', async () => {
test('presses with a modifier or another button, and moves outside a drag, pass through', () => {
const f = fixture();
f.tool.start();
f.fire('LEFT_DOWN', { position: { x: 250, y: 10 } });
for (const fields of [{ shiftKey: true }, { ctrlKey: true }, { button: 2 }]) {
const press = f.down({ x: 250, y: 125 }, fields);
assert.equal(press.stopped, false, JSON.stringify(fields));
}
assert.equal(f.camera.inputs, true);
assert.equal(f.move({ x: 300, y: 200 }).stopped, false, 'a hover');
assert.equal(f.tool.isActive(), true);
f.tool.destroy();
});
test('a sky press, a click without a drag and Escape each say why', () => {
const f = fixture();
f.tool.start();
f.down({ x: 250, y: 10 });
assert.deepEqual(f.cancels.at(-1), [
'sky',
'Press on the ground, not the sky',
]);
assert.equal(f.tool.isActive(), true, 'a sky press keeps the session');
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
f.fire('LEFT_UP', { position: { x: 250, y: 125 } });
assert.equal(f.camera.inputs, true, 'and freezes nothing');
f.down({ x: 250, y: 125 });
f.up({ x: 250, y: 125 });
assert.deepEqual(f.cancels.at(-1), ['degenerate', 'Drag to size the box']);
f.tool.start();
assert.equal(f.key('Escape').prevented, true);
assert.deepEqual(f.cancels.at(-1), ['escape', undefined]);
assert.equal(pointerOwner(), null);
await f.tool.destroy();
f.tool.destroy();
});
test('an Escape interceptor that claims the key leaves the tool armed; the next Escape cancels', async () => {
test('an Escape interceptor that claims the key leaves the tool armed; the next Escape cancels', () => {
let claim = true;
let asked = 0;
const f = fixture({
@@ -122,33 +131,54 @@ test('an Escape interceptor that claims the key leaves the tool armed; the next
f.tool.start();
f.key('Enter');
assert.equal(asked, 2, 'other keys never reach the interceptor');
await f.tool.destroy();
f.tool.destroy();
});
test('a held pointer refuses the session and nothing is borrowed', async () => {
test('a held pointer refuses the session and nothing is bound', () => {
const f = fixture();
const lease = claimPointer('draw');
assert.equal(f.tool.start(), false);
assert.deepEqual(f.cancels, [
['pointer-busy', 'draw is using the pointer — close it first'],
]);
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
assert.equal(f.handler(), null);
assert.equal(listenerCount(f), 0);
releasePointer(lease);
assert.equal(f.tool.start(), true);
await f.tool.destroy();
f.tool.destroy();
});
test('destroy mid-drag restores the camera, releases the claim and detaches the preview', async () => {
test('destroy mid-drag restores the camera, releases the claim and clears the preview', () => {
const f = fixture();
f.tool.start();
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
assert.equal(f.viewer.dataSources.sources.length, 1);
await f.tool.destroy();
assert.equal(f.camera.enableInputs, true);
f.down({ x: 250, y: 125 });
assert.equal(f.camera.inputs, false);
f.tool.destroy();
f.tool.destroy();
assert.equal(f.camera.inputs, true);
assert.equal(pointerOwner(), null);
assert.equal(f.viewer.dataSources.sources.length, 0);
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
assert.equal(f.previews.at(-1), null);
assert.equal(listenerCount(f), 0);
assert.equal(f.tool.start(), false);
assert.equal(initImageryBoxTool({ viewer: null, onBox() {} }), null);
assert.equal(initImageryBoxTool({ engine: null, onBox() {} }), null);
});
test('blur and a cancelled pointer during a drag return the camera, the preview and the pointer', () => {
const f = fixture({}, { suspend: true });
f.tool.start();
f.down({ x: 250, y: 125 });
assert.equal(f.camera.suspended, 1, "the engine's counted suspension");
assert.equal(f.camera.inputs, true, 'the app choice is left alone');
f.windowRef.dispatch('blur', {});
assert.equal(f.camera.suspended, 0);
assert.equal(f.previews.at(-1), null);
assert.equal(pointerOwner(), null);
assert.equal(f.tool.isActive(), false);
assert.deepEqual(f.cancels.at(-1), ['blur', undefined]);
f.tool.start();
f.down({ x: 250, y: 125 });
f.documentRef.dispatch('pointercancel', { pointerId: 1 });
assert.equal(f.camera.suspended, 0);
assert.deepEqual(f.cancels.at(-1), ['pointer-cancel', undefined]);
assert.equal(listenerCount(f), 0);
f.tool.destroy();
});
+7 -7
View File
@@ -149,13 +149,13 @@ const cancelledResult = () => ({
/**
* Build the runner's area services.
* @param {object} options
* @param {object} options.viewer
* @param {object} options.engine The globe engine (view centre, credits).
* @param {object|null} options.annotations Annotation engine.
* @param {object} [options.placeSearch]
* @param {object} [options.annotationResolver] The annotation outline ladder.
*/
export function createVoiceAreas({
viewer,
engine = null,
annotations = null,
placeSearch = unavailablePlaceSearch,
annotationResolver = null,
@@ -173,10 +173,10 @@ export function createVoiceAreas({
/** The ground at the centre of the view, else the camera's position. */
function viewCenter() {
const picked = annotationResolver?.pickWorldFromScreen?.(viewer, 0.5, 0.5);
const picked = annotationResolver?.pickWorldFromScreen?.(engine, 0.5, 0.5);
if (Number.isFinite(picked?.lat) && Number.isFinite(picked?.lon))
return picked;
const carto = viewer?.camera?.positionCartographic;
const carto = engine?.camera.pose().cartographic;
return carto
? {
lat: (carto.latitude * 180) / Math.PI,
@@ -223,7 +223,7 @@ export function createVoiceAreas({
try {
resolved = await annotationResolver.resolveAnnotationTarget({
placeSearch,
viewer,
engine,
target: query,
footprint: true,
deferFootprint: true,
@@ -405,7 +405,7 @@ export function createVoiceAreas({
'us-census': US_CENSUS_CREDIT,
wof: WOF_CREDIT,
}[source];
if (credit) registerDynamicCredit(viewer, credit);
if (credit) registerDynamicCredit(engine, credit);
},
});
@@ -632,7 +632,7 @@ export function createVoiceAreas({
// OpenFreeMap area holding it) is a bounded extra.
resolved = await annotationResolver.resolveAnnotationTarget({
placeSearch,
viewer,
engine,
target,
footprint: true,
deferFootprint: true,
-5
View File
@@ -113,7 +113,6 @@ function fakeAnnotations() {
function makeAreas(overrides = {}) {
const annotations = overrides.annotations || fakeAnnotations();
const areas = createVoiceAreas({
viewer: {},
annotations,
annotationResolver: fakeLadder(),
findRegion: async () => null,
@@ -181,7 +180,6 @@ test('draw failures stay honest in the result, spoken line, and card', async (t)
for (const entry of cases) {
await t.test(entry.name, async () => {
const areas = createVoiceAreas({
viewer: {},
annotations: entry.annotations,
annotationResolver: fakeLadder(),
findRegion: async () => null,
@@ -732,7 +730,6 @@ test('a clear during a slow lookup is not undone by a late outline', async () =>
release = resolve;
});
const areas = createVoiceAreas({
viewer: {},
annotations,
findRegion: async () => null,
findAdminCandidates: async () => [],
@@ -781,7 +778,6 @@ test('an outlined area keeps its full geometry past store eviction', async () =>
test('an outline from before a page reload asks to resolve again, never counts its drawing', async () => {
const annotations = fakeAnnotations();
const first = createVoiceAreas({
viewer: {},
annotations,
annotationResolver: fakeLadder(),
findRegion: async () => null,
@@ -792,7 +788,6 @@ test('an outline from before a page reload asks to resolve again, never counts i
// outline stays on the map. A mic restart keeps the runner (and handles);
// see integrationContract.test.mjs.
const second = createVoiceAreas({
viewer: {},
annotations,
findRegion: async () => null,
findAdminCandidates: async () => [],
+2 -2
View File
@@ -378,7 +378,7 @@ export function createGevActionRunner({
// analyst's region/area/drawn scopes, find_imagery and osm_query all read
// the same store.
const voiceAreas = createVoiceAreas({
viewer,
engine,
annotations,
placeSearch,
annotationResolver,
@@ -1452,7 +1452,7 @@ export function createGevActionRunner({
runOptions,
),
search: searchOsmFeatures,
onCredit: () => registerDynamicCredit(viewer, OSM_PLACES_CREDIT),
onCredit: () => registerDynamicCredit(engine, OSM_PLACES_CREDIT),
// The listed places become the analyst's "last answer", so
// "which of those…" filters them and never an older set.
rememberResults: (rows, presentation, { truncated = false } = {}) => {