diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 34e3d37f2..20a502a82 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -286,14 +286,53 @@ export const VIEWER_ONLY_ACTIONS = Object.freeze([ /** 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) { +/** + * The tools that change the look of the Cesium scene (post stages, bloom, + * sharpen). The Navara engine has no visual styles yet: without a viewer + * these refuse too, rather than answer ok while changing nothing (and + * recording a style the view does not show). + */ +export const CESIUM_LOOK_ACTIONS = Object.freeze([ + 'set_visual_style', + 'set_post_processing', +]); + +/** What each refused tool is, for the refusal's message. */ +export const UNAVAILABLE_FEATURES = Object.freeze({ + control_scene: 'The scene director', + annotate_map: 'Map annotation', + clear_annotations: 'Map annotation', + fly_route: 'Route flying', + move_camera: 'Camera motion (orbit, pan, tilt)', + control_cockpit: 'The cockpit', + set_context_mode: 'The Contacts mode', + control_radio: 'Radio', + control_cctv: 'CCTV', + set_visual_style: 'The visual style', + set_post_processing: 'Post-processing (bloom, sharpen)', +}); + +/** The engine to switch to for a refused feature. */ +export const SWITCH_ENGINE_HINT = '?engine=cesium'; + +const engineName = (engine) => + engine?.kind === 'navara' ? 'the Navara engine' : 'this engine'; + +function unavailableOnEngine( + name, + engine, + feature = UNAVAILABLE_FEATURES[name], +) { + const what = feature || name; return { ok: false, action: name, unavailable: true, code: UNAVAILABLE_ON_ENGINE, engine: engine?.kind ?? null, - error: 'Unavailable on this engine', + feature: what, + suggestion: SWITCH_ENGINE_HINT, + error: `${what} is not available on ${engineName(engine)} yet. Switch to the Cesium engine (${SWITCH_ENGINE_HINT}) to use it.`, }; } @@ -389,7 +428,10 @@ export function createGevActionRunner({ /* Progress is advisory. */ } }; - if (!viewer && VIEWER_ONLY_ACTIONS.includes(name)) + if ( + !viewer && + (VIEWER_ONLY_ACTIONS.includes(name) || CESIUM_LOOK_ACTIONS.includes(name)) + ) return unavailableOnEngine(name, engine); // Navigation tools interrupt any continuous camera motion (spec §1.1) — @@ -427,6 +469,17 @@ export function createGevActionRunner({ throw new Error(`Unknown data layer: ${args.layerId || 'missing'}`); } if (!dataManager.layers.has(layerId)) { + // A layer this engine cannot show (the catalog left it out): a + // refusal that names it, not an unknown-layer error. + if (!viewer && voiceLayer(layerId)) + return { + ...unavailableOnEngine( + 'set_layer_visibility', + engine, + `The ${layerId} layer`, + ), + layerId, + }; if (layerId === 'radio') { return { ok: false, @@ -4512,46 +4565,6 @@ function dominantValue(values) { }; } -function summarizeEntity(viewer, entity, { includeProperties = false } = {}) { - const now = Cesium.JulianDate.now(); - if (entity.__gevContextId) { - const store = window.__gevContextStore; - const record = store?.entities?.get(entity.__gevContextId); - if (record) return summarizeContextRecord(record, { includeProperties }); - } - const props = propertyObject(entity); - const layerId = entity.__localLayerId || props.layerId || null; - const tags = props.tags || {}; - const label = cleanText( - props.name || - tags.name || - tags['name:en'] || - tags.official_name || - tags.operator || - props.operator || - entity.name || - layerTitle(layerId), - ); - const position = - entity.__localBaseCartesian || - entity.position?.getValue?.(now) || - polygonCenter(entity, now); - const carto = position ? geo.cartographic.fromCartesian(position) : null; - return { - id: String(entity.id || ''), - name: label || layerTitle(layerId), - layerId, - layerName: layerTitle(layerId), - latitude: carto - ? Number(geo.math.toDegrees(carto.latitude).toFixed(6)) - : null, - longitude: carto - ? Number(geo.math.toDegrees(carto.longitude).toFixed(6)) - : null, - properties: includeProperties ? compactProperties(props) : undefined, - }; -} - function summarizeContextRecord(record, { includeProperties = false } = {}) { return { id: String(record.id || ''), @@ -4570,31 +4583,6 @@ function summarizeContextRecord(record, { includeProperties = false } = {}) { }; } -function polygonCenter(entity, now) { - const hierarchy = entity.polygon?.hierarchy?.getValue?.(now); - const positions = hierarchy?.positions; - if (!positions?.length) return null; - return Cesium.BoundingSphere.fromPoints(positions).center; -} - -function propertyObject(entity) { - const raw = entity?.properties?.getValue?.(Cesium.JulianDate.now()) || {}; - return unwrapProperties(raw); -} - -function unwrapProperties(value) { - if (!value || typeof value !== 'object') return value; - if (Array.isArray(value)) return value.map(unwrapProperties); - const out = {}; - for (const [key, entry] of Object.entries(value)) { - out[key] = - entry && typeof entry.getValue === 'function' - ? unwrapProperties(entry.getValue(Cesium.JulianDate.now())) - : unwrapProperties(entry); - } - return out; -} - function compactProperties(props) { const preferredKeys = [ 'name', diff --git a/src/voice/gevActionsEngine.test.mjs b/src/voice/gevActionsEngine.test.mjs index 4df8308b4..7506fee0c 100644 --- a/src/voice/gevActionsEngine.test.mjs +++ b/src/voice/gevActionsEngine.test.mjs @@ -10,6 +10,8 @@ import * as geo from '../geo/index.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { GEV_ACTION_SCHEMAS } from './actionSchemas.js'; import { + CESIUM_LOOK_ACTIONS, + UNAVAILABLE_FEATURES, UNAVAILABLE_ON_ENGINE, VIEWER_ONLY_ACTIONS, createGevActionRunner, @@ -121,7 +123,13 @@ test('without a viewer the viewer-only actions refuse with a structured result', 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.equal(result.feature, UNAVAILABLE_FEATURES[name], name); + assert.equal(result.suggestion, '?engine=cesium', name); + assert.equal( + result.error, + `${UNAVAILABLE_FEATURES[name]} is not available on the Navara engine yet. Switch to the Cesium engine (?engine=cesium) to use it.`, + name, + ); } assert.deepEqual(touched, [], 'a refusal reaches no controller'); assert.deepEqual(engine.fake.calls, [], 'a refusal moves no camera'); @@ -420,3 +428,79 @@ test('a fresh frame for capture comes from the engine render loop', async () => globalThis.document = previous; } }); + +test('every refused tool names its feature', () => { + for (const name of [...VIEWER_ONLY_ACTIONS, ...CESIUM_LOOK_ACTIONS]) + assert.ok(UNAVAILABLE_FEATURES[name], `${name} has a feature name`); + assert.deepEqual(CESIUM_LOOK_ACTIONS, [ + 'set_visual_style', + 'set_post_processing', + ]); +}); + +test('without a viewer the visual style and post-processing refuse and record nothing', async () => { + const engine = engineOverAustin({ kind: 'navara' }); + const calls = []; + const styleManager = navigatingStyleManager({ + setStyle: (...args) => calls.push(['setStyle', ...args]), + setBloom: (...args) => calls.push(['setBloom', ...args]) && {}, + setSharpen: (...args) => calls.push(['setSharpen', ...args]) && {}, + }); + const runner = runnerOn(engine, { styleManager }); + const style = await runner('set_visual_style', { style: 'thermal' }); + assert.equal(style.ok, false); + assert.equal(style.code, UNAVAILABLE_ON_ENGINE); + assert.match( + style.error, + /visual style is not available on the Navara engine/, + ); + const post = await runner('set_post_processing', { + bloom: { enabled: true }, + }); + assert.equal(post.ok, false); + assert.equal(post.code, UNAVAILABLE_ON_ENGINE); + assert.deepEqual(calls, [], 'no style is applied or recorded'); +}); + +test('with a viewer the visual style still applies', async () => { + const calls = []; + const runner = createGevActionRunner({ + viewer: { + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, + camera: { moveEnd: { addEventListener() {} } }, + }, + engine: engineOverAustin(), + styleManager: navigatingStyleManager({ + setStyle: (style) => calls.push(style), + }), + dataManager: dataManagerWith({}), + }); + const result = await runner('set_visual_style', { style: 'thermal' }); + assert.equal(result.ok, true); + assert.deepEqual(calls, ['thermal']); +}); + +test('without a viewer a layer the engine lacks refuses instead of throwing', async () => { + const engine = engineOverAustin({ kind: 'navara' }); + const runner = runnerOn(engine, { + dataManager: dataManagerWith({ flights: {} }), + }); + const result = await runner('set_layer_visibility', { + layerId: 'cctv', + enabled: true, + }); + assert.equal(result.ok, false); + assert.equal(result.code, UNAVAILABLE_ON_ENGINE); + assert.equal(result.layerId, 'cctv'); + assert.equal(result.suggestion, '?engine=cesium'); + assert.match( + result.error, + /The cctv layer is not available on the Navara engine yet/, + ); + await assert.rejects( + runner('set_layer_visibility', { layerId: 'no-such-layer', enabled: true }), + /Unknown data layer/, + 'a name no engine knows is still an unknown layer', + ); +});