refactor(voice): W1 actions on the engine

The voice runner drives the globe engine (an `engine` option, by default
the one registered for the viewer) instead of the Cesium viewer:
track_entity, stop_tracking (camera.following().stop()), frame_overhead
(camera.flyToSphere with the pose heading), fly_to_location, zoom_to_globe
(follow released through camera.following()), adjust_camera_zoom,
next_iss_pass / next_satellite_pass, select_nearest_aircraft and the
analyst view context, get_current_view_state, get_entity_context
(projector.worldToWindow), the view target (picker.pickPosition, then
camera.pickEllipsoid, then picker.pickGlobe) and its moveEnd prewarm, and
the viewport samples. getBasemapLabelContext takes the engine or a viewer
with a registered engine (the HUD).

Without a viewer the tools that still need one (VIEWER_ONLY_ACTIONS:
control_scene, annotate_map, clear_annotations, fly_route, move_camera,
control_cockpit, set_context_mode, control_radio, control_cctv) answer a
structured refusal (code UNAVAILABLE_ON_ENGINE) instead of throwing.

Point-and-ask picks with engine.picker and keys the camera pose from
engine.camera.pose(); the viewport capture reads engine.host.canvas and
waits for the engine's postRender (both engines keep their drawing
buffer: Cesium's contextOptions, Navara's own WebGL2 canvas, S4).

Tests: gevActionsEngine.test.mjs runs each moved action on the fake
engine. gevActions.test.mjs keeps its expectations; its runner helper now
registers an engine for the test viewer (the Cesium engine over it, or a
fake engine at its positionCartographic) and the two harness cameras gain
roll and frustum for camera.pose().
This commit is contained in:
German Khokhlov committed 2026-10-16 22:52:21 +04:00
1 parent ec7dcc944e
commit bc281ab246
9 files changed
+701 -190

No files matched your search

+1
View File
@@ -1010,6 +1010,7 @@
"scripts/qa/navaraContractPage.js", "scripts/qa/navaraContractPage.js",
"scripts/qa/tileCache.mjs", "scripts/qa/tileCache.mjs",
"src/annotations/resolverEngine.test.mjs", "src/annotations/resolverEngine.test.mjs",
"src/voice/gevActionsEngine.test.mjs",
"src/app/controls.test.mjs", "src/app/controls.test.mjs",
"src/app/engineChoice.js", "src/app/engineChoice.js",
"src/app/engineChoice.test.mjs", "src/app/engineChoice.test.mjs",
+2 -1
View File
@@ -1705,7 +1705,8 @@
"src/voice/narration.js", "src/voice/narration.js",
"src/voice/earcon.js", "src/voice/earcon.js",
"src/voice/pointerContext.js", "src/voice/pointerContext.js",
"src/voice/pointerReticle.js" "src/voice/pointerReticle.js",
"src/globe/contract.js"
], ],
"external": [] "external": []
}, },
+178 -152
View File
@@ -43,6 +43,7 @@ import {
} from '../cameraVerbs.js'; } from '../cameraVerbs.js';
import * as defaultFloorServices from '../data/groundFloor.js'; import * as defaultFloorServices from '../data/groundFloor.js';
import { isPickedWorldPosition } from '../data/scenePick.js'; import { isPickedWorldPosition } from '../data/scenePick.js';
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js'; import { unavailablePlaceSearch } from '../search/placeSearch.js';
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
import { normalizeRadioCountryInput } from '../data/radioCountry.js'; import { normalizeRadioCountryInput } from '../data/radioCountry.js';
@@ -264,6 +265,38 @@ const FRAME_TARGETS = new Map([
['ships', 'ais-live-vessels'], ['ships', 'ais-live-vessels'],
]); ]);
/**
* The tools that still need the Cesium viewer or a subsystem built on it:
* the scene director, annotations and routes over them, the camera verbs
* (orbit, pan, tilt), the cockpit, Contacts (the context mode), Radio and
* CCTV. Without a viewer (the Navara engine) each answers a structured
* refusal (UNAVAILABLE_ON_ENGINE) instead of running.
*/
export const VIEWER_ONLY_ACTIONS = Object.freeze([
'control_scene',
'annotate_map',
'clear_annotations',
'fly_route',
'move_camera',
'control_cockpit',
'set_context_mode',
'control_radio',
'control_cctv',
]);
/** The refusal code of a viewer-only tool on an engine without a viewer. */
export const UNAVAILABLE_ON_ENGINE = 'UNAVAILABLE_ON_ENGINE';
function unavailableOnEngine(name, engine) {
return {
ok: false,
action: name,
unavailable: true,
code: UNAVAILABLE_ON_ENGINE,
engine: engine?.kind ?? null,
error: 'Unavailable on this engine',
};
}
const serviceCaches = new WeakMap(); const serviceCaches = new WeakMap();
function cachesFor(service) { function cachesFor(service) {
service.signal?.throwIfAborted(); service.signal?.throwIfAborted();
@@ -289,10 +322,21 @@ function cachesFor(service) {
const VISIBLE_ENTITY_SHORTLIST = 64; const VISIBLE_ENTITY_SHORTLIST = 64;
const BASEMAP_CONTEXT_WAIT_MS = 1500; const BASEMAP_CONTEXT_WAIT_MS = 1500;
const viewTargetCache = new WeakMap(); const viewTargetCache = new WeakMap();
const prewarmedEngines = new WeakSet();
/** Create application actions over the supplied scene and services. */ /** The camera's place (radians, metres) from its live position. */
const cameraCartographic = (engine) =>
geo.cartographic.fromCartesian(engine.camera.livePosition);
/**
* Create application actions over the supplied scene and services. The
* actions drive the globe `engine` (by default the one registered for the
* viewer); `viewer` is null on an engine without one (Navara), where the
* VIEWER_ONLY_ACTIONS refuse.
*/
export function createGevActionRunner({ export function createGevActionRunner({
viewer, viewer,
engine: givenEngine = null,
styleManager, styleManager,
dataManager, dataManager,
sceneDirector = null, sceneDirector = null,
@@ -310,20 +354,22 @@ export function createGevActionRunner({
const builders = speechBuilders const builders = speechBuilders
? { ...SPEECH_BUILDERS, ...speechBuilders } ? { ...SPEECH_BUILDERS, ...speechBuilders }
: SPEECH_BUILDERS; : SPEECH_BUILDERS;
const engine = givenEngine ?? engineFromViewer(viewer);
const _layerEnabledAt = new Map(); const _layerEnabledAt = new Map();
let analystEngine; let analystEngine;
const resolveRegionRing = (name, signal) => const resolveRegionRing = (name, signal) =>
annotationResolver.resolveRegionRingForQuery(name, signal, placeSearch); annotationResolver.resolveRegionRingForQuery(name, signal, placeSearch);
const ensureAnalystEngine = () => const ensureAnalystEngine = () =>
(analystEngine ||= createAnalystEngine( (analystEngine ||= createAnalystEngine(
analystProviders(viewer, dataManager, { analystProviders(engine, dataManager, {
placeSearch, placeSearch,
resolveRegionRing, resolveRegionRing,
isWarming: (layerKey) => layerIsWarming(_layerEnabledAt, layerKey), isWarming: (layerKey) => layerIsWarming(_layerEnabledAt, layerKey),
}), }),
)); ));
installViewTargetPrewarm(viewer); installViewTargetPrewarm(engine);
initCameraVerbs(viewer, getViewTargetCartesian); // The camera verbs (move_camera, fly_route) still run on the viewer.
if (viewer) initCameraVerbs(viewer, () => getViewTargetCartesian(engine));
// Nested calls (select_nearest_aircraft composing layer + flight) use the // Nested calls (select_nearest_aircraft composing layer + flight) use the
// raw runner; only the outer call gets a voice envelope. // raw runner; only the outer call gets a voice envelope.
const runGevAction = async function runGevAction( const runGevAction = async function runGevAction(
@@ -343,6 +389,8 @@ export function createGevActionRunner({
/* Progress is advisory. */ /* Progress is advisory. */
} }
}; };
if (!viewer && VIEWER_ONLY_ACTIONS.includes(name))
return unavailableOnEngine(name, engine);
// Navigation tools interrupt any continuous camera motion (spec §1.1) — // Navigation tools interrupt any continuous camera motion (spec §1.1) —
// checked FIRST because each handler returns. // checked FIRST because each handler returns.
@@ -352,8 +400,8 @@ export function createGevActionRunner({
// Explicit navigation while TRACKING supersedes the follow camera — // Explicit navigation while TRACKING supersedes the follow camera —
// otherwise the tracker drags the view back and "I flew there but can't // otherwise the tracker drags the view back and "I flew there but can't
// do anything" (field finding). track_entity manages its own handoff. // do anything" (field finding). track_entity manages its own handoff.
if (name === 'zoom_to_globe' && viewer.trackedEntity) { if (name === 'zoom_to_globe' && engine?.camera.following()) {
stopAllTracking(viewer, dataManager); stopAllTracking(engine, dataManager);
} }
// Zoom during an active orbit adjusts the orbit RADIUS (spiral in/out) — // Zoom during an active orbit adjusts the orbit RADIUS (spiral in/out) —
@@ -587,7 +635,7 @@ export function createGevActionRunner({
}); });
} else if (typeof layerModule?.update === 'function') { } else if (typeof layerModule?.update === 'function') {
refreshed = refreshed =
(await layerModule.update(viewer, { (await layerModule.update(viewer ?? engine, {
signal: runOptions.signal, signal: runOptions.signal,
})) !== false; })) !== false;
} }
@@ -620,7 +668,7 @@ export function createGevActionRunner({
reportProgress('nearest', location.label); reportProgress('nearest', location.label);
const nearest = await createAnalystEngine( const nearest = await createAnalystEngine(
analystProviders(viewer, dataManager, { analystProviders(engine, dataManager, {
recordLimitByLayer: { [layerId]: Number.MAX_SAFE_INTEGER }, recordLimitByLayer: { [layerId]: Number.MAX_SAFE_INTEGER },
placeSearch, placeSearch,
resolveRegionRing, resolveRegionRing,
@@ -653,7 +701,7 @@ export function createGevActionRunner({
} }
const stableAircraftId = aircraft.icao24 || aircraft.id; const stableAircraftId = aircraft.icao24 || aircraft.id;
const selection = await trackEntity(viewer, dataManager, styleManager, { const selection = await trackEntity(engine, dataManager, styleManager, {
query: stableAircraftId, query: stableAircraftId,
layerId, layerId,
}); });
@@ -950,7 +998,7 @@ export function createGevActionRunner({
runOptions, runOptions,
); );
} }
return flyToRequestedLocation(viewer, args, { return flyToRequestedLocation(engine, args, {
onProgress: reportProgress, onProgress: reportProgress,
placeSearch, placeSearch,
searchNavigation, searchNavigation,
@@ -975,20 +1023,20 @@ export function createGevActionRunner({
return; return;
} }
interruptCameraMotion('nav:fly_to_location'); interruptCameraMotion('nav:fly_to_location');
if (viewer.trackedEntity) stopAllTracking(viewer, dataManager); if (engine?.camera.following()) stopAllTracking(engine, dataManager);
}, },
}); });
} }
if (name === 'adjust_camera_zoom') { if (name === 'adjust_camera_zoom') {
return adjustCameraZoom(viewer, args); return adjustCameraZoom(engine, args);
} }
if (name === 'zoom_to_globe') { if (name === 'zoom_to_globe') {
if (typeof styleManager?.resetToGlobeView === 'function') { if (typeof styleManager?.resetToGlobeView === 'function') {
return styleManager.resetToGlobeView(); return styleManager.resetToGlobeView();
} }
const result = flyToGlobeView(viewer); const result = flyToGlobeView(engine);
return { return {
ok: true, ok: true,
action: 'zoom_to_globe', action: 'zoom_to_globe',
@@ -1001,11 +1049,11 @@ export function createGevActionRunner({
} }
if (name === 'next_satellite_pass') { if (name === 'next_satellite_pass') {
return nextSatellitePass(viewer, dataManager, args); return nextSatellitePass(engine, dataManager, args);
} }
if (name === 'next_iss_pass') { if (name === 'next_iss_pass') {
return nextIssPass(viewer, dataManager, args); return nextIssPass(engine, dataManager, args);
} }
if (name === 'analyst_query') { if (name === 'analyst_query') {
@@ -1049,7 +1097,7 @@ export function createGevActionRunner({
if (name === 'get_entity_context') { if (name === 'get_entity_context') {
return getEntityContext( return getEntityContext(
viewer, engine,
dataManager, dataManager,
styleManager, styleManager,
args, args,
@@ -1059,7 +1107,7 @@ export function createGevActionRunner({
if (name === 'get_current_view_state') { if (name === 'get_current_view_state') {
return getCurrentViewState( return getCurrentViewState(
viewer, engine,
styleManager, styleManager,
dataManager, dataManager,
sceneDirector, sceneDirector,
@@ -1164,17 +1212,17 @@ export function createGevActionRunner({
} }
if (name === 'track_entity') { if (name === 'track_entity') {
return trackEntity(viewer, dataManager, styleManager, args, { return trackEntity(engine, dataManager, styleManager, args, {
isCurrent: current, isCurrent: current,
}); });
} }
if (name === 'stop_tracking') { if (name === 'stop_tracking') {
return stopAllTracking(viewer, dataManager); return stopAllTracking(engine, dataManager);
} }
if (name === 'frame_overhead') { if (name === 'frame_overhead') {
return frameOverhead(viewer, dataManager, styleManager, args); return frameOverhead(engine, dataManager, styleManager, args);
} }
if (name === 'annotate_map') { if (name === 'annotate_map') {
@@ -2187,7 +2235,7 @@ export function formatTrackedEntityLabel(found, query = '') {
/** Finds and tracks/selects an entity by spoken query across layer families. */ /** Finds and tracks/selects an entity by spoken query across layer families. */
async function trackEntity( async function trackEntity(
viewer, engine,
dataManager, dataManager,
styleManager, styleManager,
args = {}, args = {},
@@ -2240,7 +2288,7 @@ async function trackEntity(
'fire', 'fire',
'track_entity', 'track_entity',
() => { () => {
flyToLandmark(viewer, strongest.latitude, strongest.longitude, { flyToLandmark(engine, strongest.latitude, strongest.longitude, {
range: 14000, range: 14000,
pitch: -50, pitch: -50,
heading: 0, heading: 0,
@@ -2300,7 +2348,7 @@ async function trackEntity(
let trackedOk = false; let trackedOk = false;
if (family.kind === 'vessel') { if (family.kind === 'vessel') {
trackedOk = !!module.selectById?.(found.mmsi); trackedOk = !!module.selectById?.(found.mmsi);
flyToLandmark(viewer, found.latitude, found.longitude, { flyToLandmark(engine, found.latitude, found.longitude, {
range: 6000, range: 6000,
pitch: -45, pitch: -45,
heading: 0, heading: 0,
@@ -2346,7 +2394,7 @@ async function trackEntity(
} }
/** Releases tracking/selection on every entity layer family. */ /** Releases tracking/selection on every entity layer family. */
function stopAllTracking(viewer, dataManager) { function stopAllTracking(engine, dataManager) {
const released = []; const released = [];
const failed = new Set(); const failed = new Set();
for (const family of TRACKABLE_FAMILIES) { for (const family of TRACKABLE_FAMILIES) {
@@ -2397,7 +2445,7 @@ function stopAllTracking(viewer, dataManager) {
failed.add(layerId); failed.add(layerId);
} }
} }
if (viewer) viewer.trackedEntity = undefined; engine?.camera.following()?.stop();
if (failed.size) { if (failed.size) {
const failedLayerIds = [...failed]; const failedLayerIds = [...failed];
return { return {
@@ -2418,7 +2466,7 @@ function stopAllTracking(viewer, dataManager) {
* detection so the framed entities are labeled, and reports * detection so the framed entities are labeled, and reports
* detectionEnabled so the voice agent can mention labels are on. * detectionEnabled so the voice agent can mention labels are on.
*/ */
async function frameOverhead(viewer, dataManager, styleManager, args = {}) { async function frameOverhead(engine, dataManager, styleManager, args = {}) {
const targetRaw = String(args.target || 'flights').toLowerCase(); const targetRaw = String(args.target || 'flights').toLowerCase();
const layerId = const layerId =
FRAME_TARGETS.get(targetRaw) || normalizeLayerId(targetRaw) || 'flights'; FRAME_TARGETS.get(targetRaw) || normalizeLayerId(targetRaw) || 'flights';
@@ -2445,7 +2493,9 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) {
? 120 ? 120
: 150; : 150;
const radiusKm = clampNumber(args.radiusKm, 10, 20000, defaultRadiusKm); const radiusKm = clampNumber(args.radiusKm, 10, 20000, defaultRadiusKm);
const center = getViewTargetCartesian(viewer) || viewer.camera.positionWC; const center =
getViewTargetCartesian(engine) ||
geo.cartesian3.clone(engine.camera.livePosition);
let entries = []; let entries = [];
if (typeof module.getNearby === 'function') { if (typeof module.getNearby === 'function') {
@@ -2472,23 +2522,25 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) {
}; };
} }
const sphere = Cesium.BoundingSphere.fromPoints( // Cesium's bounding sphere (pure math, no viewer), flown by the engine.
const bounds = Cesium.BoundingSphere.fromPoints(
entries.map((entry) => entry.position), entries.map((entry) => entry.position),
); );
sphere.radius = Math.max(sphere.radius * 1.25, 8000); const sphere = {
center: geo.cartesian3.clone(bounds.center),
radius: Math.max(bounds.radius * 1.25, 8000),
};
const pitch = geo.math.toRadians(isSatellites ? -35 : -62); const pitch = geo.math.toRadians(isSatellites ? -35 : -62);
return runManagedVoiceNavigation( return runManagedVoiceNavigation(
styleManager, styleManager,
'frame', 'frame',
'frame_overhead', 'frame_overhead',
() => { () => {
viewer.camera.flyToBoundingSphere(sphere, { engine.camera.flyToSphere(sphere, {
duration: 2.0, duration: 2.0,
offset: new Cesium.HeadingPitchRange( heading: engine.camera.pose().heading,
viewer.camera.heading, pitch,
pitch, range: sphere.radius * 2.4,
sphere.radius * 2.4,
),
}); });
let detectionEnabled = false; let detectionEnabled = false;
@@ -2567,25 +2619,30 @@ function collectTrackedEntities(dataManager) {
return tracked; return tracked;
} }
/**
* @param {object} target The globe engine, or a Cesium viewer with a
* registered engine (the HUD).
*/
export async function getBasemapLabelContext( export async function getBasemapLabelContext(
viewer, target,
service = defaultGeospatial, service = defaultGeospatial,
{ cachedOnly = false } = {}, { cachedOnly = false } = {},
) { ) {
const engine = engineFromViewer(target);
const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service); const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service);
const samples = sampleViewportCartographics(viewer); const viewTarget = engine ? getViewTargetCartographic(engine) : null;
const cameraHeightM = viewer.camera.positionCartographic.height; if (!viewTarget) {
const target = getViewTargetCartographic(viewer);
if (!target) {
return { return {
placeLabels: [], placeLabels: [],
streetLabels: [], streetLabels: [],
nearbyPlaceLabels: [], nearbyPlaceLabels: [],
}; };
} }
const samples = sampleViewportCartographics(engine);
const cameraHeightM = engine.camera.height();
const latitude = Number(geo.math.toDegrees(target.latitude).toFixed(6)); const latitude = Number(geo.math.toDegrees(viewTarget.latitude).toFixed(6));
const longitude = Number(geo.math.toDegrees(target.longitude).toFixed(6)); const longitude = Number(geo.math.toDegrees(viewTarget.longitude).toFixed(6));
const cachedViewportPlaces = viewportPlacesFromCache( const cachedViewportPlaces = viewportPlacesFromCache(
samples, samples,
cameraHeightM, cameraHeightM,
@@ -2646,12 +2703,12 @@ export async function getBasemapLabelContext(
}; };
} }
function installViewTargetPrewarm(viewer) { function installViewTargetPrewarm(engine) {
if (viewer.__gevViewTargetPrewarmInstalled) return; if (!engine || prewarmedEngines.has(engine)) return;
viewer.__gevViewTargetPrewarmInstalled = true; prewarmedEngines.add(engine);
let timer = null; let timer = null;
let reportedPrewarmFailure = false; let reportedPrewarmFailure = false;
viewer.camera.moveEnd.addEventListener(() => { engine.camera.moveEnd.addEventListener(() => {
if (timer) window.clearTimeout(timer); if (timer) window.clearTimeout(timer);
timer = window.setTimeout(() => { timer = window.setTimeout(() => {
timer = null; timer = null;
@@ -2659,10 +2716,10 @@ function installViewTargetPrewarm(viewer) {
// because an idle callback is an UNCAUGHT context — nothing above it can // because an idle callback is an UNCAUGHT context — nothing above it can
// handle a surprise from the scene graph, and a red console error on a // handle a surprise from the scene graph, and a red console error on a
// plain camera flight is worse than a cold cache. Reported once per // plain camera flight is worse than a cold cache. Reported once per
// viewer so a repeating cause cannot spam the console. // engine so a repeating cause cannot spam the console.
const warm = () => { const warm = () => {
try { try {
getViewTargetCartographic(viewer); getViewTargetCartographic(engine);
} catch (error) { } catch (error) {
if (reportedPrewarmFailure) return; if (reportedPrewarmFailure) return;
reportedPrewarmFailure = true; reportedPrewarmFailure = true;
@@ -2681,7 +2738,7 @@ function installViewTargetPrewarm(viewer) {
}); });
} }
function adjustCameraZoom(viewer, args) { function adjustCameraZoom(engine, args) {
const direction = String(args.direction || '').toLowerCase(); const direction = String(args.direction || '').toLowerCase();
if (direction !== 'in' && direction !== 'out') { if (direction !== 'in' && direction !== 'out') {
throw new Error('adjust_camera_zoom direction must be "in" or "out"'); throw new Error('adjust_camera_zoom direction must be "in" or "out"');
@@ -2695,10 +2752,10 @@ function adjustCameraZoom(viewer, args) {
}[amount]; }[amount];
if (!fraction) throw new Error(`Unknown zoom amount: ${args.amount}`); if (!fraction) throw new Error(`Unknown zoom amount: ${args.amount}`);
const camera = viewer.camera; const camera = engine.camera;
const beforePosition = geo.cartesian3.clone(camera.positionWC); const beforePosition = geo.cartesian3.clone(camera.livePosition);
const beforeHeightM = camera.positionCartographic.height; const beforeHeightM = camera.height();
const target = getViewTargetCartesian(viewer); const target = getViewTargetCartesian(engine);
const targetDistanceM = target const targetDistanceM = target
? geo.cartesian3.distance(beforePosition, target) ? geo.cartesian3.distance(beforePosition, target)
: Math.max(100, beforeHeightM); : Math.max(100, beforeHeightM);
@@ -2724,11 +2781,11 @@ function adjustCameraZoom(viewer, args) {
} }
camera.zoomIn(safeMovementM); camera.zoomIn(safeMovementM);
} }
viewer.scene.requestRender(); engine.render.request('voice-zoom');
const afterPosition = camera.positionWC; const afterPosition = camera.livePosition;
const movedM = geo.cartesian3.distance(beforePosition, afterPosition); const movedM = geo.cartesian3.distance(beforePosition, afterPosition);
const afterHeightM = camera.positionCartographic.height; const afterHeightM = camera.height();
const moved = movedM >= 0.5; const moved = movedM >= 0.5;
return { return {
ok: moved, ok: moved,
@@ -2766,11 +2823,11 @@ function compassDir(azDeg) {
return COMPASS_16[Math.round((((azDeg % 360) + 360) % 360) / 22.5) % 16]; return COMPASS_16[Math.round((((azDeg % 360) + 360) % 360) / 22.5) % 16];
} }
function nextIssPass(viewer, dataManager, args) { function nextIssPass(engine, dataManager, args) {
let latDeg = Number.isFinite(args.latitude) ? args.latitude : null; let latDeg = Number.isFinite(args.latitude) ? args.latitude : null;
let lonDeg = Number.isFinite(args.longitude) ? args.longitude : null; let lonDeg = Number.isFinite(args.longitude) ? args.longitude : null;
if (latDeg == null || lonDeg == null) { if (latDeg == null || lonDeg == null) {
const carto = viewer?.camera?.positionCartographic; const carto = engine ? cameraCartographic(engine) : null;
if (!carto) throw new Error('Camera position unavailable'); if (!carto) throw new Error('Camera position unavailable');
latDeg = geo.math.toDegrees(carto.latitude); latDeg = geo.math.toDegrees(carto.latitude);
lonDeg = geo.math.toDegrees(carto.longitude); lonDeg = geo.math.toDegrees(carto.longitude);
@@ -2816,7 +2873,7 @@ function nextIssPass(viewer, dataManager, args) {
}; };
} }
function nextSatellitePass(viewer, dataManager, args) { function nextSatellitePass(engine, dataManager, args) {
const layer = dataManager?.layers?.get('satellites')?.module; const layer = dataManager?.layers?.get('satellites')?.module;
const identity = layer?.resolveSatelliteForPass?.(args.target) || { const identity = layer?.resolveSatelliteForPass?.(args.target) || {
status: 'not-found', status: 'not-found',
@@ -2849,7 +2906,7 @@ function nextSatellitePass(viewer, dataManager, args) {
], ],
]), ]),
}; };
const result = nextIssPass(viewer, adapter, args); const result = nextIssPass(engine, adapter, args);
if (result.error) { if (result.error) {
result.error = result.error.replace( result.error = result.error.replace(
/ISS/g, /ISS/g,
@@ -3015,7 +3072,7 @@ function normalizeStyle(value) {
} }
async function flyToRequestedLocation( async function flyToRequestedLocation(
viewer, engine,
args, args,
{ {
placeSearch = unavailablePlaceSearch, placeSearch = unavailablePlaceSearch,
@@ -3067,7 +3124,7 @@ async function flyToRequestedLocation(
if (locationId) { if (locationId) {
const result = immediate(() => const result = immediate(() =>
flyToPresetLocation(viewer, locationId, { flyToPresetLocation(engine, locationId, {
...(rangeM || args.viewMode === 'close' ...(rangeM || args.viewMode === 'close'
? { range: rangeM || 250 } ? { range: rangeM || 250 }
: { viewMode: 'overview' }), : { viewMode: 'overview' }),
@@ -3097,7 +3154,7 @@ async function flyToRequestedLocation(
const longitude = Number(args.longitude); const longitude = Number(args.longitude);
if (Number.isFinite(latitude) && Number.isFinite(longitude)) { if (Number.isFinite(latitude) && Number.isFinite(longitude)) {
const result = immediate(() => const result = immediate(() =>
flyToLandmark(viewer, latitude, longitude, { flyToLandmark(engine, latitude, longitude, {
range: rangeM || 250, range: rangeM || 250,
pitch: -35, pitch: -35,
heading: 0, heading: 0,
@@ -3129,7 +3186,7 @@ async function flyToRequestedLocation(
const poiMatch = findPoiByName(query); const poiMatch = findPoiByName(query);
if (poiMatch) { if (poiMatch) {
const result = immediate(() => const result = immediate(() =>
flyToPOI(viewer, poiMatch.cityId, poiMatch.index, { flyToPOI(engine, poiMatch.cityId, poiMatch.index, {
duration: 2.2, duration: 2.2,
onStart: immediateOnStart, onStart: immediateOnStart,
...arrivalHooks, ...arrivalHooks,
@@ -3154,7 +3211,7 @@ async function flyToRequestedLocation(
if (generation === false) return cancelled(query); if (generation === false) return cancelled(query);
onProgress('search', query); onProgress('search', query);
const managedDeferred = typeof reassertDeferred === 'function'; const managedDeferred = typeof reassertDeferred === 'function';
const destination = await searchNavigation(viewer, query, { const destination = await searchNavigation(engine, query, {
placeSearch, placeSearch,
signal, signal,
...(rangeM ? { range: rangeM } : {}), ...(rangeM ? { range: rangeM } : {}),
@@ -3195,12 +3252,12 @@ function normalizeLocationId(value) {
} }
function getCurrentViewState( function getCurrentViewState(
viewer, engine,
styleManager, styleManager,
dataManager, dataManager,
sceneDirector = null, sceneDirector = null,
) { ) {
const cartographic = geo.cartographic.fromCartesian(viewer.camera.positionWC); const cartographic = cameraCartographic(engine);
return { return {
ok: true, ok: true,
action: 'get_current_view_state', action: 'get_current_view_state',
@@ -3255,7 +3312,7 @@ function getCurrentViewState(
} }
async function getEntityContext( async function getEntityContext(
viewer, engine,
dataManager, dataManager,
styleManager, styleManager,
args = {}, args = {},
@@ -3264,15 +3321,15 @@ async function getEntityContext(
const startedAt = performance.now(); const startedAt = performance.now();
const scope = String(args.scope || 'auto').toLowerCase(); const scope = String(args.scope || 'auto').toLowerCase();
if (scope === 'target' && args.target) { if (scope === 'target' && args.target) {
return getTargetContext(viewer, dataManager, styleManager, args, service); return getTargetContext(engine, dataManager, styleManager, args, service);
} }
const layerId = normalizeLayerId(args.layerId || args.layer); const layerId = normalizeLayerId(args.layerId || args.layer);
const limit = Math.round(clampNumber(args.limit, 1, 12, 5)); const limit = Math.round(clampNumber(args.limit, 1, 12, 5));
const selected = selectedEntityContext(dataManager); const selected = selectedEntityContext(dataManager);
const cameraHeightM = viewer.camera.positionCartographic.height; const cameraHeightM = engine.camera.height();
const viewTarget = getViewTargetCartographic(viewer); const viewTarget = getViewTargetCartographic(engine);
const scenePromise = getSceneContext( const scenePromise = getSceneContext(
viewer, engine,
styleManager, styleManager,
dataManager, dataManager,
viewTarget, viewTarget,
@@ -3282,7 +3339,7 @@ async function getEntityContext(
selected && (scope === 'selected' || scope === 'auto'); selected && (scope === 'selected' || scope === 'auto');
const visible = const visible =
!selectedWillBeReturned && shouldScanVisibleEntities(cameraHeightM) !selectedWillBeReturned && shouldScanVisibleEntities(cameraHeightM)
? visibleEntityContexts(viewer, dataManager, { ? visibleEntityContexts(engine, dataManager, {
layerId, layerId,
limit, limit,
target: viewTarget, target: viewTarget,
@@ -3420,7 +3477,7 @@ function describeTargetEntity(dataManager, target) {
/** get_entity_context for a pointed/referenced target (scope 'target'). */ /** get_entity_context for a pointed/referenced target (scope 'target'). */
async function getTargetContext( async function getTargetContext(
viewer, engine,
dataManager, dataManager,
styleManager, styleManager,
args, args,
@@ -3440,10 +3497,10 @@ async function getTargetContext(
const hasPosition = Number.isFinite(lat) && Number.isFinite(lon); const hasPosition = Number.isFinite(lat) && Number.isFinite(lon);
const viewTarget = hasPosition const viewTarget = hasPosition
? geo.cartographic.fromDegrees(lon, lat) ? geo.cartographic.fromDegrees(lon, lat)
: getViewTargetCartographic(viewer); : getViewTargetCartographic(engine);
const selected = describeTargetEntity(dataManager, target); const selected = describeTargetEntity(dataManager, target);
const scene = await getSceneContext( const scene = await getSceneContext(
viewer, engine,
styleManager, styleManager,
dataManager, dataManager,
viewTarget, viewTarget,
@@ -3458,9 +3515,9 @@ async function getTargetContext(
selected, selected,
}; };
} }
const cameraHeightM = viewer.camera.positionCartographic.height; const cameraHeightM = engine.camera.height();
const visible = shouldScanVisibleEntities(cameraHeightM) const visible = shouldScanVisibleEntities(cameraHeightM)
? visibleEntityContexts(viewer, dataManager, { ? visibleEntityContexts(engine, dataManager, {
layerId: normalizeLayerId(args.layerId || args.layer), layerId: normalizeLayerId(args.layerId || args.layer),
limit: Math.round(clampNumber(args.limit, 1, 12, 5)), limit: Math.round(clampNumber(args.limit, 1, 12, 5)),
target: viewTarget, target: viewTarget,
@@ -3747,14 +3804,12 @@ function selectedEntityContext(dataManager) {
} }
function visibleEntityContexts( function visibleEntityContexts(
viewer, engine,
dataManager, dataManager,
{ layerId = null, limit = 5, target = null } = {}, { layerId = null, limit = 5, target = null } = {},
) { ) {
const nearbyRecords = []; const nearbyRecords = [];
const canvas = viewer.scene.canvas; const { width, height } = engine.host.size();
const width = canvas.clientWidth || canvas.width || 0;
const height = canvas.clientHeight || canvas.height || 0;
const centerX = width / 2; const centerX = width / 2;
const centerY = height / 2; const centerY = height / 2;
const targetLat = target ? geo.math.toDegrees(target.latitude) : null; const targetLat = target ? geo.math.toDegrees(target.latitude) : null;
@@ -3795,10 +3850,7 @@ function visibleEntityContexts(
for (const { record } of nearbyRecords) { for (const { record } of nearbyRecords) {
const position = record.entity?.__localBaseCartesian; const position = record.entity?.__localBaseCartesian;
if (!position) continue; if (!position) continue;
const screen = Cesium.SceneTransforms.worldToWindowCoordinates( const screen = engine.projector.worldToWindow(position);
viewer.scene,
position,
);
if ( if (
!screen || !screen ||
screen.x < 0 || screen.x < 0 ||
@@ -3843,14 +3895,14 @@ function insertNearestRecord(records, candidate, limit) {
} }
async function getSceneContext( async function getSceneContext(
viewer, engine,
styleManager, styleManager,
dataManager, dataManager,
viewTarget = null, viewTarget = null,
service = defaultGeospatial, service = defaultGeospatial,
) { ) {
const cartographic = geo.cartographic.fromCartesian(viewer.camera.positionWC); const cartographic = cameraCartographic(engine);
const basemap = await getBasemapContext(viewer, viewTarget, service); const basemap = await getBasemapContext(engine, viewTarget, service);
const enabledLayers = dataManager const enabledLayers = dataManager
.getAll() .getAll()
.filter((layer) => layer.enabled) .filter((layer) => layer.enabled)
@@ -3873,17 +3925,14 @@ async function getSceneContext(
} }
async function getBasemapContext( async function getBasemapContext(
viewer, engine,
viewTarget = null, viewTarget = null,
service = defaultGeospatial, service = defaultGeospatial,
) { ) {
const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service); const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service);
const target = viewTarget; const target = viewTarget;
const samples = sampleViewportCartographics(viewer); const samples = sampleViewportCartographics(engine);
const cameraCartographic = geo.cartographic.fromCartesian( const cameraHeightM = cameraCartographic(engine).height;
viewer.camera.positionWC,
);
const cameraHeightM = cameraCartographic.height;
const viewScale = classifyViewScale(cameraHeightM); const viewScale = classifyViewScale(cameraHeightM);
const cachedViewportPlaces = viewportPlacesFromCache( const cachedViewportPlaces = viewportPlacesFromCache(
samples, samples,
@@ -4076,23 +4125,23 @@ function coarseBasemapPlace(
}; };
} }
function getViewTargetCartographic(viewer) { function getViewTargetCartographic(engine) {
const signature = cameraViewSignature(viewer); const signature = cameraViewSignature(engine);
const cached = viewTargetCache.get(viewer); const cached = viewTargetCache.get(engine);
if ( if (
cached?.signature === signature && cached?.signature === signature &&
performance.now() - cached.cachedAt < 2500 performance.now() - cached.cachedAt < 2500
) { ) {
return cached.target; return cached.target;
} }
const position = getViewTargetCartesian(viewer); const position = getViewTargetCartesian(engine);
// `fromCartesian` still returns undefined for a point too near the ellipsoid // `fromCartesian` still returns undefined for a point too near the ellipsoid
// center to project; normalize that to the same "no target" null the callers // center to project; normalize that to the same "no target" null the callers
// already handle for a missed pick. // already handle for a missed pick.
const target = position const target = position
? geo.cartographic.fromCartesian(position) || null ? geo.cartographic.fromCartesian(position) || null
: null; : null;
viewTargetCache.set(viewer, { viewTargetCache.set(engine, {
signature, signature,
target, target,
cachedAt: performance.now(), cachedAt: performance.now(),
@@ -4100,15 +4149,16 @@ function getViewTargetCartographic(viewer) {
return target; return target;
} }
function cameraViewSignature(viewer) { function cameraViewSignature(engine) {
const camera = viewer.camera; const cartographic = cameraCartographic(engine);
const cartographic = camera.positionCartographic; const height = engine.camera.height();
const { heading, pitch } = engine.camera.pose();
return [ return [
geo.math.toDegrees(cartographic.latitude).toFixed(5), geo.math.toDegrees(cartographic.latitude).toFixed(5),
geo.math.toDegrees(cartographic.longitude).toFixed(5), geo.math.toDegrees(cartographic.longitude).toFixed(5),
Math.round(cartographic.height / 2), Math.round(height / 2),
camera.heading.toFixed(3), heading.toFixed(3),
camera.pitch.toFixed(3), pitch.toFixed(3),
].join(':'); ].join(':');
} }
@@ -4120,55 +4170,32 @@ function cameraViewSignature(viewer) {
* MISSED pick, so it falls through to the next stage rather than poisoning * MISSED pick, so it falls through to the next stage rather than poisoning
* every caller downstream. * every caller downstream.
*/ */
function getViewTargetCartesian(viewer) { function getViewTargetCartesian(engine) {
const scene = viewer.scene; if (!engine) return null;
const canvas = scene.canvas; const { width, height } = engine.host.size();
const width = canvas.clientWidth || canvas.width || 0; const center = geo.cartesian2.create(width / 2, height / 2);
const height = canvas.clientHeight || canvas.height || 0; const stages = [
const center = new Cesium.Cartesian2(width / 2, height / 2); () =>
let position = null; engine.picker.pickPositionSupported
? engine.picker.pickPosition(center)
if (scene.pickPositionSupported && typeof scene.pickPosition === 'function') { : undefined,
() => engine.camera.pickEllipsoid(center),
() => engine.picker.pickGlobe(center),
];
for (const stage of stages) {
let position = null;
try { try {
position = scene.pickPosition(center); position = stage();
} catch { } catch {
position = null; position = null;
} }
if (isPickedWorldPosition(position)) return position;
} }
return null;
if (
!isPickedWorldPosition(position) &&
viewer.camera &&
typeof viewer.camera.pickEllipsoid === 'function'
) {
try {
position = viewer.camera.pickEllipsoid(center, Cesium.Ellipsoid.WGS84);
} catch {
position = null;
}
}
if (
!isPickedWorldPosition(position) &&
viewer.camera &&
typeof viewer.camera.getPickRay === 'function'
) {
try {
const ray = viewer.camera.getPickRay(center);
position = scene.globe?.pick(ray, scene) || null;
} catch {
position = null;
}
}
return isPickedWorldPosition(position) ? position : null;
} }
function sampleViewportCartographics(viewer) { function sampleViewportCartographics(engine) {
const scene = viewer.scene; const { width, height } = engine.host.size();
const canvas = scene.canvas;
const width = canvas.clientWidth || canvas.width || 0;
const height = canvas.clientHeight || canvas.height || 0;
if (!width || !height) return []; if (!width || !height) return [];
const points = [ const points = [
@@ -4183,9 +4210,8 @@ function sampleViewportCartographics(viewer) {
const samples = []; const samples = [];
for (const [x, y] of points) { for (const [x, y] of points) {
const cartesian = viewer.camera.pickEllipsoid( const cartesian = engine.camera.pickEllipsoid(
geo.cartesian2.create(width * x, height * y), geo.cartesian2.create(width * x, height * y),
Cesium.Ellipsoid.WGS84,
); );
if (!isPickedWorldPosition(cartesian)) continue; if (!isPickedWorldPosition(cartesian)) continue;
const carto = geo.cartographic.fromCartesian(cartesian); const carto = geo.cartographic.fromCartesian(cartesian);
@@ -4664,7 +4690,7 @@ function layerIsWarming(enabledAt, layerKey) {
} }
function analystProviders( function analystProviders(
viewer, engine,
dataManager, dataManager,
{ {
recordLimitByLayer = null, recordLimitByLayer = null,
@@ -4751,7 +4777,7 @@ function analystProviders(
}; };
}, },
getViewContext() { getViewContext() {
const carto = viewer.camera.positionCartographic; const carto = cameraCartographic(engine);
const altKm = carto.height / 1000; const altKm = carto.height / 1000;
// View radius scales with altitude: street-level asks stay local, // View radius scales with altitude: street-level asks stay local,
// country-level asks sweep wide. Clamped so "in view" is never absurd. // country-level asks sweep wide. Clamped so "in view" is never absurd.
+29 -2
View File
@@ -25,7 +25,8 @@ import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { presentResult } from './resultDisplay.js'; import { presentResult } from './resultDisplay.js';
import { attachVoiceResult } from './speech.js'; import { attachVoiceResult } from './speech.js';
import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js';
import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js'; import { engineFromViewer, registerViewerEngine } from '../globe/cesium/engineFromViewer.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
test('every live basemap is reachable by its own id — no enum value without a voice alias', () => { test('every live basemap is reachable by its own id — no enum value without a voice alias', () => {
// B1 regression: a stack added to MAP_STACKS (and the set_map_stack enum) // B1 regression: a stack added to MAP_STACKS (and the set_map_stack enum)
@@ -125,6 +126,9 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) {
positionCartographic: Cesium.Cartographic.fromCartesian(position), positionCartographic: Cesium.Cartographic.fromCartesian(position),
heading: Cesium.Math.toRadians(28), heading: Cesium.Math.toRadians(28),
pitch: Cesium.Math.toRadians(-45), pitch: Cesium.Math.toRadians(-45),
// The rest of the pose the engine camera reads (camera.pose()).
roll: 0,
frustum: { fovy: 1, aspectRatio: 1.5 },
cancelFlight() { order.push('cancel'); }, cancelFlight() { order.push('cancel'); },
flyToBoundingSphere() { flyToBoundingSphere() {
order.push(`fly:${viewer.trackedEntity === undefined ? 'released' : 'owned'}`); order.push(`fly:${viewer.trackedEntity === undefined ? 'released' : 'owned'}`);
@@ -2582,6 +2586,9 @@ function createPrewarmHarness({ pickPosition, positionCartographic } = {}) {
positionWC: position, positionWC: position,
heading: Cesium.Math.toRadians(28), heading: Cesium.Math.toRadians(28),
pitch: Cesium.Math.toRadians(-45), pitch: Cesium.Math.toRadians(-45),
// The rest of the pose the engine camera reads (camera.pose()).
roll: 0,
frustum: { fovy: 1, aspectRatio: 1.5 },
pickEllipsoid() { calls.pickEllipsoid += 1; return surface; }, pickEllipsoid() { calls.pickEllipsoid += 1; return surface; },
getPickRay() { calls.getPickRay += 1; return null; }, getPickRay() { calls.getPickRay += 1; return null; },
cancelFlight() {}, cancelFlight() {},
@@ -3236,7 +3243,27 @@ test('voice Radio: the keyless path applies no country filter the keyed path wou
}); });
const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ }); const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ });
function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); } function createGevActionRunner(options) {
registerTestEngine(options.viewer);
return createActionRunner({ placeSearch: testPlaceSearch(), ...options });
}
/**
* The runner drives the globe engine registered for its viewer, as the app's
* scene engine is (src/app/globeEngine.js): a test viewer gets the Cesium
* engine over it or, when its camera only states where it is
* (positionCartographic), a fake engine at that place.
*/
function registerTestEngine(viewer) {
if (!viewer || engineFromViewer(viewer)) return;
const camera = viewer.camera ?? {};
const place = Object.getOwnPropertyDescriptor(camera, 'positionCartographic')?.value;
registerViewerEngine(
viewer,
!camera.positionWC && place
? createFakeEngine({ longitude: place.longitude, latitude: place.latitude, altitude: place.height })
: createCesiumEngine(viewer),
);
}
function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); } function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); }
test('ALPR common names toggle only the registered camera layer through the normal voice action', async () => { test('ALPR common names toggle only the registered camera layer through the normal voice action', async () => {
+422
View File
@@ -0,0 +1,422 @@
// The W1 voice actions on the globe engine (phase 3, Task 9): the same
// results as on the Cesium viewer, through engine.camera / picker /
// projector only, and a structured refusal for the actions that still need
// a viewer when there is none (the Navara engine).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { GEV_ACTION_SCHEMAS } from './actionSchemas.js';
import {
UNAVAILABLE_ON_ENGINE,
VIEWER_ONLY_ACTIONS,
createGevActionRunner,
} from './gevActions.js';
import { cameraPoseKey } from './pointerContext.js';
import { createPointerPicker } from './pointerPick.js';
import { renderFreshFrame } from './realtimeViewport.js';
globalThis.window ||= {
setTimeout,
clearTimeout,
requestIdleCallback: null,
};
const toRad = geo.math.toRadians;
/** A fake engine over Austin at `altitude`, looking straight down. */
function engineOverAustin(options = {}) {
return createFakeEngine({
longitude: toRad(-97.74),
latitude: toRad(30.27),
altitude: 20_000,
...options,
});
}
/** The UI navigation seam, without its policy: runs the move. */
const navigatingStyleManager = (extra = {}) => ({
runImmediateNavigation: (noun, navigate) => navigate(),
...extra,
});
function dataManagerWith(modules, { enabled = Object.keys(modules) } = {}) {
const on = new Set(enabled);
const params = [];
return {
params,
layers: new Map(
Object.entries(modules).map(([id, module]) => [id, { id, module }]),
),
isEnabled: (id) => on.has(id),
getAll: () =>
Object.keys(modules).map((id) => ({
id,
name: id,
enabled: on.has(id),
stats: modules[id]?.getStats?.() || {},
})),
setLayerParams(id, values) {
params.push({ id, values });
return true;
},
async setEnabled(id, value) {
if (value) on.add(id);
else on.delete(id);
return true;
},
};
}
const runnerOn = (engine, options = {}) =>
createGevActionRunner({
viewer: null,
engine,
styleManager: navigatingStyleManager(),
dataManager: dataManagerWith({}),
...options,
});
const follow = (engine, position) =>
engine.camera.follow({
position: () => position,
viewFrom: { x: 0, y: -2000, z: 800 },
});
test('the viewer-only actions are one exported list of real tools', () => {
assert.deepEqual(VIEWER_ONLY_ACTIONS, [
'control_scene',
'annotate_map',
'clear_annotations',
'fly_route',
'move_camera',
'control_cockpit',
'set_context_mode',
'control_radio',
'control_cctv',
]);
assert.ok(Object.isFrozen(VIEWER_ONLY_ACTIONS));
const tools = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));
for (const name of VIEWER_ONLY_ACTIONS)
assert.ok(tools.has(name), `${name} is a voice tool`);
});
test('without a viewer the viewer-only actions refuse with a structured result', async () => {
const engine = engineOverAustin({ kind: 'navara' });
const touched = [];
const styleManager = new Proxy(navigatingStyleManager(), {
get(target, key) {
if (typeof key === 'string') touched.push(key);
return target[key];
},
});
const runner = runnerOn(engine, { styleManager });
engine.fake.resetCalls();
for (const name of VIEWER_ONLY_ACTIONS) {
const result = await runner(name, { action: 'status' });
assert.equal(result.ok, false, name);
assert.equal(result.action, name);
assert.equal(result.unavailable, true, name);
assert.equal(result.code, UNAVAILABLE_ON_ENGINE, name);
assert.equal(result.engine, 'navara', name);
assert.equal(result.error, 'Unavailable on this engine', name);
}
assert.deepEqual(touched, [], 'a refusal reaches no controller');
assert.deepEqual(engine.fake.calls, [], 'a refusal moves no camera');
});
test('with a viewer the viewer-only actions still run', async () => {
const runner = createGevActionRunner({
viewer: {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: { moveEnd: { addEventListener() {} } },
},
engine: engineOverAustin(),
styleManager: {},
dataManager: dataManagerWith({}),
});
const radio = await runner('control_radio', { action: 'status' });
assert.equal(radio.error, 'Radio layer unavailable');
assert.equal(radio.unavailable, undefined);
});
test('the runner warms the view target on the engine camera, once per engine', () => {
const engine = engineOverAustin({ kind: 'navara' });
runnerOn(engine);
runnerOn(engine);
assert.equal(engine.camera.moveEnd.numberOfListeners, 1);
});
test('stop_tracking stops the engine follow', async () => {
const engine = engineOverAustin({ kind: 'navara' });
const target = geo.cartesian3.fromDegrees(-97.7, 30.3, 9000);
follow(engine, target);
assert.ok(engine.camera.following());
const released = [];
const runner = runnerOn(engine, {
dataManager: dataManagerWith({
flights: {
getTrackedInfo: () => ({ id: 'abc' }),
stopTracking: () => released.push('flights'),
},
}),
});
const result = await runner('stop_tracking');
assert.equal(result.ok, true);
assert.deepEqual(result.released, ['flights']);
assert.equal(engine.camera.following(), null);
});
test('zoom_to_globe leaves the follow and flies the engine camera', async () => {
const engine = engineOverAustin({ kind: 'navara' });
follow(engine, geo.cartesian3.fromDegrees(-97.7, 30.3, 9000));
const runner = runnerOn(engine);
const result = await runner('zoom_to_globe');
assert.equal(result.ok, true);
assert.equal(result.heightKm > 1000, true);
assert.equal(engine.camera.following(), null);
assert.equal(engine.fake.callsOf('camera.flyTo').length, 1);
});
test('fly_to_location to coordinates leaves the follow and flies the engine', async () => {
const engine = engineOverAustin({ kind: 'navara' });
follow(engine, geo.cartesian3.fromDegrees(-97.7, 30.3, 9000));
const runner = runnerOn(engine);
const result = await runner('fly_to_location', {
latitude: 51.5,
longitude: -0.12,
});
assert.equal(result.ok, true);
assert.equal(result.navigationMode, 'close-coordinate');
assert.equal(engine.camera.following(), null);
assert.equal(engine.fake.callsOf('camera.flyToSphere').length, 1);
engine.fake.frames(400, 16);
const place = engine.camera.pose().cartographic;
assert.ok(Math.abs(geo.math.toDegrees(place.latitude) - 51.5) < 0.05);
});
test('track_entity flies to a vessel through the engine', async () => {
const engine = engineOverAustin({ kind: 'navara' });
let selected = null;
const runner = runnerOn(engine, {
dataManager: dataManagerWith({
'ais-live-vessels': {
findByQuery: () => ({
mmsi: 366999999,
name: 'EVER GIVEN',
latitude: 29.7,
longitude: -95.0,
}),
selectById: (mmsi) => (selected = mmsi),
},
}),
});
const result = await runner('track_entity', {
query: 'ever given',
layerId: 'ais-live-vessels',
});
assert.equal(result.ok, true);
assert.equal(result.label, 'EVER GIVEN');
assert.equal(selected, 366999999);
assert.equal(engine.fake.callsOf('camera.flyToSphere').length, 1);
});
test('frame_overhead frames the nearby contacts with flyToSphere as on Cesium', async () => {
const engine = engineOverAustin({ kind: 'navara' });
const center = engine.camera.pickEllipsoid({ x: 512, y: 384 });
const positions = [
geo.cartesian3.fromDegrees(-97.7, 30.3, 9000),
geo.cartesian3.fromDegrees(-97.9, 30.1, 11000),
geo.cartesian3.fromDegrees(-97.5, 30.5, 10000),
];
let asked = null;
const runner = runnerOn(engine, {
dataManager: dataManagerWith({
flights: {
getNearby(at, radiusM, limit) {
asked = { at, radiusM, limit };
return positions.map((position, index) => ({
id: `F${index}`,
icao24: `a${index}`,
label: `F${index}`,
position,
}));
},
},
}),
});
const heading = engine.camera.pose().heading;
const result = await runner('frame_overhead', { target: 'flights' });
assert.equal(result.ok, true);
assert.equal(result.count, 3);
assert.equal(asked.radiusM, 150_000);
assert.equal(asked.limit, 80);
assert.ok(geo.cartesian3.distance(asked.at, center) < 1e-6);
// The same sphere and offset the Cesium path flew.
const sphere = Cesium.BoundingSphere.fromPoints(positions);
const radius = Math.max(sphere.radius * 1.25, 8000);
const [call] = engine.fake.callsOf('camera.flyToSphere');
const [flown, options] = call.args;
assert.ok(geo.cartesian3.distance(flown.center, sphere.center) < 1e-6);
assert.equal(flown.radius, radius);
assert.equal(options.duration, 2);
assert.equal(options.heading, heading);
assert.equal(options.pitch, toRad(-62));
assert.equal(options.range, radius * 2.4);
});
test('next_iss_pass observes from the engine camera', async () => {
const engine = engineOverAustin({ kind: 'navara' });
let observer = null;
const runner = runnerOn(engine, {
dataManager: dataManagerWith({
satellites: {
getNextIssPass(options) {
observer = options;
return { status: 'none' };
},
},
}),
});
const result = await runner('next_iss_pass');
assert.equal(result.ok, false);
assert.ok(Math.abs(observer.latDeg - 30.27) < 1e-6);
assert.ok(Math.abs(observer.lonDeg + 97.74) < 1e-6);
});
test('get_current_view_state reads the engine camera', async () => {
const engine = engineOverAustin({ kind: 'navara' });
const runner = runnerOn(engine, {
styleManager: navigatingStyleManager({ activeStyle: 'noir' }),
});
const result = await runner('get_current_view_state');
assert.equal(result.ok, true);
assert.ok(Math.abs(result.camera.latitude - 30.27) < 1e-6);
assert.ok(Math.abs(result.camera.longitude + 97.74) < 1e-6);
assert.ok(Math.abs(result.camera.heightM - 20_000) < 1e-3);
});
test('adjust_camera_zoom moves the engine camera toward the view target', async () => {
const engine = engineOverAustin({ kind: 'navara' });
const runner = runnerOn(engine);
const result = await runner('adjust_camera_zoom', {
direction: 'in',
amount: 'medium',
});
assert.equal(result.ok, true);
assert.equal(result.movementRequestedM, 11_000);
assert.equal(result.movementActualM, 11_000);
assert.equal(result.afterHeightM, 9000);
assert.equal(engine.fake.callsOf('camera.cancelFlight').length, 1);
});
test('get_entity_context samples the view through the engine', async () => {
// Continental: no reverse geocoding, every viewport sample on the globe.
const engine = engineOverAustin({ kind: 'navara', altitude: 4_000_000 });
const runner = runnerOn(engine);
const result = await runner('get_entity_context', { scope: 'in_view' });
assert.equal(result.ok, true);
assert.equal(result.scene.camera.heightM, 4_000_000);
assert.equal(result.scene.basemap.viewScale, 'continental');
assert.equal(result.scene.basemap.viewportSamples.length, 7);
// The view centre, on the ellipsoid under the camera.
const [centre] = result.scene.basemap.viewportSamples;
assert.ok(Math.abs(centre.latitude - 30.27) < 0.1);
assert.equal(centre.longitude, -97.74);
});
test('select_nearest_aircraft runs on the engine end to end', async () => {
const engine = engineOverAustin({ kind: 'navara' });
let tracked = null;
const flights = {
getStats: () => ({ count: 2, source: 'fixture' }),
getAnalystRecords: () => [
{
id: 'FAR1',
icao24: 'f1',
callsign: 'FAR1',
lat: 30.4,
lon: -97.7,
onGround: false,
},
{
id: 'NEAR1',
icao24: 'n1',
callsign: 'NEAR1',
lat: 30.3,
lon: -97.7,
onGround: false,
},
],
findByQuery: (query) =>
query === 'n1'
? { icao24: 'n1', callsign: 'NEAR1', latitude: 30.3, longitude: -97.7 }
: null,
trackById: (id) => (tracked = id),
};
const dataManager = dataManagerWith({ flights }, { enabled: [] });
dataManager.refreshLayer = async () => true;
const runner = runnerOn(engine, { dataManager });
const pending = runner('select_nearest_aircraft', {
latitude: 30.27,
longitude: -97.74,
});
for (let index = 0; index < 50 && !tracked; index++) {
engine.fake.frames(20, 16);
await new Promise((resolve) => setImmediate(resolve));
}
const result = await pending;
assert.equal(result.ok, true, result.error);
assert.equal(result.aircraft.id, 'n1');
assert.equal(tracked, 'n1');
});
test('the camera pose key reads the engine as it reads the viewer', () => {
const engine = engineOverAustin({ kind: 'navara' });
const pose = engine.camera.pose();
const viewer = {
camera: {
positionWC: pose.position,
heading: pose.heading,
pitch: pose.pitch,
roll: pose.roll,
},
};
assert.equal(typeof cameraPoseKey(engine), 'string');
assert.equal(cameraPoseKey(engine), cameraPoseKey(viewer));
});
test('the pointer picker picks through the engine', () => {
const engine = engineOverAustin({ kind: 'navara' });
engine.fake.script('pick', { id: { __gevContextId: 'none' } });
const pick = createPointerPicker({ engine, dataManager: null });
const result = pick(512, 384);
assert.equal(result.entity, null);
assert.ok(Math.abs(result.ground.lat - 30.27) < 1e-4);
assert.ok(Math.abs(result.ground.lon + 97.74) < 1e-4);
assert.equal(result.radiusKm, 15);
assert.equal(result.cameraKey, cameraPoseKey(engine));
const [screen] = engine.fake.callsOf('picker.pick')[0].args;
assert.deepEqual([screen.x, screen.y], [512, 384]);
});
test('a fresh frame for capture comes from the engine render loop', async () => {
const previous = globalThis.document;
try {
globalThis.document = { hidden: false };
const engine = engineOverAustin({ kind: 'navara' });
engine.fake.resetCalls();
const fresh = renderFreshFrame(engine);
assert.equal(engine.fake.renderRequests, 1);
engine.fake.frame();
assert.equal(await fresh, true);
globalThis.document = { hidden: true };
assert.equal(await renderFreshFrame(engine), false);
assert.equal(engine.fake.renderRequests, 1, 'no render while hidden');
} finally {
globalThis.document = previous;
}
});
+11 -5
View File
@@ -5,6 +5,7 @@ import { createVoiceCommands } from './commands.js';
import { cameraPoseKey, createPointerTracker } from './pointerContext.js'; import { cameraPoseKey, createPointerTracker } from './pointerContext.js';
import { createPointerPicker } from './pointerPick.js'; import { createPointerPicker } from './pointerPick.js';
import { createReferentRegistry } from './referents.js'; import { createReferentRegistry } from './referents.js';
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
export * from './realtimeController.js'; export * from './realtimeController.js';
const ACTION_NAMES = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name)); const ACTION_NAMES = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));
@@ -30,10 +31,15 @@ export function withToolCatalog(runner, loadCatalog) {
return runGevTool; return runGevTool;
} }
/** Compose the standalone action runner with the voice controls. */ /**
* Compose the standalone action runner with the voice controls, on the globe
* `engine` (by default the one registered for `options.viewer`). Without a
* viewer (the Navara engine) the runner refuses the viewer-only tools.
*/
export function initGevVoiceCommands(options) { export function initGevVoiceCommands(options) {
const canvas = options.viewer?.scene?.canvas || null; const engine = options.engine ?? engineFromViewer(options.viewer);
const cameraKey = () => cameraPoseKey(options.viewer); const canvas = engine?.host.canvas || null;
const cameraKey = () => cameraPoseKey(engine);
// Point-and-ask: the tracker and referents are shared by the runner (which // Point-and-ask: the tracker and referents are shared by the runner (which
// resolves 'pointer' and referent:n) and the controls (which snapshot the // resolves 'pointer' and referent:n) and the controls (which snapshot the
// pointer at turn start and clear both when the session ends). // pointer at turn start and clear both when the session ends).
@@ -42,7 +48,7 @@ export function initGevVoiceCommands(options) {
element: canvas, element: canvas,
cameraKey, cameraKey,
pick: createPointerPicker({ pick: createPointerPicker({
viewer: options.viewer, engine,
dataManager: options.dataManager, dataManager: options.dataManager,
}), }),
}) })
@@ -55,7 +61,7 @@ export function initGevVoiceCommands(options) {
pointer, pointer,
referents, referents,
runner: withToolCatalog( runner: withToolCatalog(
createGevActionRunner({ ...options, deixis }), createGevActionRunner({ ...options, engine, deixis }),
options.toolCatalog, options.toolCatalog,
), ),
}); });
+10 -3
View File
@@ -19,6 +19,8 @@
* Provider-neutral and Cesium-free: the pick is injected. * Provider-neutral and Cesium-free: the pick is injected.
*/ */
import { isGlobeEngine } from '../globe/contract.js';
/** Movement below this many CSS pixels is still the same dwell. */ /** Movement below this many CSS pixels is still the same dwell. */
export const POINTER_DWELL_PX = 12; export const POINTER_DWELL_PX = 12;
/** Stillness that makes a dwell point. */ /** Stillness that makes a dwell point. */
@@ -57,11 +59,16 @@ const finite = finiteOrNull;
* Camera pose identity: position to 1 m, orientation to about 0.06 degrees. * Camera pose identity: position to 1 m, orientation to about 0.06 degrees.
* A pixel read under one key names nothing under another. Reads plain * A pixel read under one key names nothing under another. Reads plain
* numbers only, so it needs no Cesium import. * numbers only, so it needs no Cesium import.
* @param {object} viewer Cesium viewer. * @param {object} globe The globe engine (its camera pose), or a Cesium
* viewer.
* @returns {string|null} * @returns {string|null}
*/ */
export function cameraPoseKey(viewer) { export function cameraPoseKey(globe) {
const camera = viewer?.camera; let camera = globe?.camera;
if (isGlobeEngine(globe)) {
const pose = camera.pose();
camera = { ...pose, positionWC: pose.position };
}
const p = camera?.positionWC; const p = camera?.positionWC;
if (!p || !Number.isFinite(p.x)) return null; if (!p || !Number.isFinite(p.x)) return null;
const angle = (value) => Math.round((Number(value) || 0) * 1000); const angle = (value) => Math.round((Number(value) || 0) * 1000);
+12 -14
View File
@@ -11,37 +11,35 @@ export function pointerRadiusKm(cameraHeightM) {
} }
/** /**
* The Cesium side of point-and-ask: one entity pick and one depth-aware world * The globe side of point-and-ask: one object pick (engine.picker.pick) and
* pick at canvas CSS pixels. Called lazily by the pointer tracker (dwell and * one depth-aware world pick at canvas CSS pixels, on either engine. Called
* snapshot only), never per mouse move. * lazily by the pointer tracker (dwell and snapshot only), never per mouse
* move.
* @param {object} options * @param {object} options
* @param {object} options.viewer Cesium viewer. * @param {object} options.engine The globe engine.
* @param {object} [options.dataManager] Layer lifecycle. * @param {object} [options.dataManager] Layer lifecycle.
* @param {Function} [options.pickWorldFromScreen] Normalized-pixel world pick. * @param {Function} [options.pickWorldFromScreen] Normalized-pixel world pick.
* @returns {(x: number, y: number) => {entity: object|null, ground: object|null, radiusKm: number}|null} * @returns {(x: number, y: number) => {entity: object|null, ground: object|null, radiusKm: number}|null}
*/ */
export function createPointerPicker({ export function createPointerPicker({
viewer, engine,
dataManager = null, dataManager = null,
pickWorldFromScreen = defaultAnnotationResolver.pickWorldFromScreen, pickWorldFromScreen = defaultAnnotationResolver.pickWorldFromScreen,
}) { }) {
return function pickPointer(x, y) { return function pickPointer(x, y) {
const scene = viewer?.scene; if (!engine || engine.isDestroyed?.()) return null;
const canvas = scene?.canvas; const { width, height } = engine.host.size();
if (!scene || !canvas) return null;
const width = canvas.clientWidth || canvas.width || 0;
const height = canvas.clientHeight || canvas.height || 0;
if (!width || !height) return null; if (!width || !height) return null;
let picked = null; let picked = null;
try { try {
picked = scene.pick(geo.cartesian2.create(x, y)); picked = engine.picker.pick(geo.cartesian2.create(x, y)) ?? null;
} catch { } catch {
picked = null; picked = null;
} }
const entity = resolvePickedContext(picked, { dataManager }); const entity = resolvePickedContext(picked, { dataManager });
let ground = null; let ground = null;
try { try {
const hit = pickWorldFromScreen(viewer, x / width, y / height); const hit = pickWorldFromScreen(engine, x / width, y / height);
if (hit && Number.isFinite(hit.lat) && Number.isFinite(hit.lon)) if (hit && Number.isFinite(hit.lat) && Number.isFinite(hit.lon))
ground = { ground = {
lat: hit.lat, lat: hit.lat,
@@ -54,8 +52,8 @@ export function createPointerPicker({
return { return {
entity, entity,
ground, ground,
radiusKm: pointerRadiusKm(viewer.camera?.positionCartographic?.height), radiusKm: pointerRadiusKm(engine.camera.height()),
cameraKey: cameraPoseKey(viewer), cameraKey: cameraPoseKey(engine),
}; };
}; };
} }
+36 -13
View File
@@ -3,10 +3,22 @@ import {
hasStructuredViewIdentity, hasStructuredViewIdentity,
} from './realtimeProtocol.js'; } from './realtimeProtocol.js';
import { cameraPoseKey } from './pointerContext.js'; import { cameraPoseKey } from './pointerContext.js';
import { isGlobeEngine } from '../globe/contract.js';
/** The live viewer's camera pose identity (Cesium-free read). */ /** The page's globe engine (both engines), else its Cesium viewer. */
function pageGlobe() {
const debug = globalThis.window?.__godsEyeView;
return debug?.engine ?? debug?.viewer ?? null;
}
/** The map canvas: the engine's host canvas, else the viewer's. */
function pageCanvas(globe) {
return isGlobeEngine(globe) ? globe.host.canvas : globe?.scene?.canvas;
}
/** The live camera pose identity (Cesium-free read). */
export function readViewerCameraKey() { export function readViewerCameraKey() {
return cameraPoseKey(globalThis.window?.__godsEyeView?.viewer); return cameraPoseKey(pageGlobe());
} }
// Viewport-screenshot size guards (M13). The old code clamped WIDTH only, so a // Viewport-screenshot size guards (M13). The old code clamped WIDTH only, so a
// tall portrait window produced an oversized capture whose dc.send could throw. // tall portrait window produced an oversized capture whose dc.send could throw.
@@ -18,16 +30,18 @@ export const VIEWPORT_MAX_PIXELS = 1200 * 900;
export const VIEWPORT_MAX_ENCODED_BYTES = 200 * 1024; export const VIEWPORT_MAX_ENCODED_BYTES = 200 * 1024;
export async function captureViewportImage() { export async function captureViewportImage() {
const viewer = window.__godsEyeView?.viewer; // Both engines draw with preserveDrawingBuffer (Cesium's contextOptions;
// Navara's own WebGL2 canvas, S4), so the canvas holds the last frame.
const globe = pageGlobe();
const source = const source =
viewer?.scene?.canvas || pageCanvas(globe) ||
document.querySelector('#cesiumContainer .cesium-widget canvas'); document.querySelector('#cesiumContainer .cesium-widget canvas');
if (!source || !source.width || !source.height) return null; if (!source || !source.width || !source.height) return null;
// No fresh frame (hidden, or the bounded render wait timed out) → no // No fresh frame (hidden, or the bounded render wait timed out) → no
// capture. The caller labels this image "Current"; a stale preserved // capture. The caller labels this image "Current"; a stale preserved
// frame would feed the model old entities as current context. (perf // frame would feed the model old entities as current context. (perf
// wave 2 fix) // wave 2 fix)
const fresh = await renderFreshCesiumFrame(viewer); const fresh = await renderFreshFrame(globe);
if (!fresh) return null; if (!fresh) return null;
// Clamp BOTH dimensions by a total-pixel budget so tall portrait windows are // Clamp BOTH dimensions by a total-pixel budget so tall portrait windows are
@@ -129,10 +143,10 @@ export function pointerCropRect({
* the crop's normalized bounds in the full canvas, or null. * the crop's normalized bounds in the full canvas, or null.
*/ */
export async function capturePointerCrop(screenPx) { export async function capturePointerCrop(screenPx) {
const viewer = window.__godsEyeView?.viewer; const globe = pageGlobe();
const source = viewer?.scene?.canvas; const source = pageCanvas(globe);
if (!source?.width || !source?.height || !screenPx) return null; if (!source?.width || !source?.height || !screenPx) return null;
const fresh = await renderFreshPointerFrame(viewer); const fresh = await renderFreshPointerFrame(globe);
if (!fresh) return null; if (!fresh) return null;
const rect = pointerCropRect({ const rect = pointerCropRect({
x: screenPx.x, x: screenPx.x,
@@ -186,10 +200,10 @@ export async function capturePointerCrop(screenPx) {
* Pointer crops get one bounded recovery request for a slow render loop. * Pointer crops get one bounded recovery request for a slow render loop.
* Full-viewport captures intentionally keep their single 400 ms attempt. * Full-viewport captures intentionally keep their single 400 ms attempt.
*/ */
export async function renderFreshPointerFrame(viewer) { export async function renderFreshPointerFrame(globe) {
if (await renderFreshCesiumFrame(viewer)) return true; if (await renderFreshFrame(globe)) return true;
if (typeof document !== 'undefined' && document.hidden) return false; if (typeof document !== 'undefined' && document.hidden) return false;
return renderFreshCesiumFrame(viewer); return renderFreshFrame(globe);
} }
// Scale (w, h) down so w*h <= maxPixels while preserving aspect ratio. Never // Scale (w, h) down so w*h <= maxPixels while preserving aspect ratio. Never
@@ -228,8 +242,14 @@ export function estimateDataUrlBytes(dataUrl) {
* when the bounded wait timed out. Callers must not label a non-fresh * when the bounded wait timed out. Callers must not label a non-fresh
* canvas as current. (perf wave 2) * canvas as current. (perf wave 2)
*/ */
export async function renderFreshCesiumFrame(viewer) { export async function renderFreshFrame(globe) {
const scene = viewer?.scene; // The engine's frame events and render request, else the viewer's scene.
const scene = isGlobeEngine(globe)
? {
postRender: globe.frames.postRender,
requestRender: () => globe.render.request('voice-capture'),
}
: globe?.scene;
if (!scene) return false; if (!scene) return false;
// While the document is hidden the render loop is suspended — don't // While the document is hidden the render loop is suspended — don't
// secretly restart rendering for an optional screenshot, and don't pass // secretly restart rendering for an optional screenshot, and don't pass
@@ -488,3 +508,6 @@ export class RealtimeViewport {
return true; return true;
} }
} }
/** renderFreshFrame for callers that hold a Cesium viewer. */
export const renderFreshCesiumFrame = renderFreshFrame;