fix(voice): Navara refusals name the feature and the way back to Cesium

Review of T8-T9:
- The UNAVAILABLE_ON_ENGINE refusal now names the feature (UNAVAILABLE_FEATURES)
  and suggests the Cesium engine (?engine=cesium): "The cockpit is not
  available on the Navara engine yet. Switch to the Cesium engine
  (?engine=cesium) to use it." New fields: feature, suggestion.
- set_visual_style and set_post_processing refuse without a viewer
  (CESIUM_LOOK_ACTIONS): Navara has no visual styles, so they used to answer
  ok, change nothing and record a style get_current_view_state then reported.
- set_layer_visibility on a known layer the engine's catalog left out refuses
  with the same structured result instead of throwing "Unknown data layer".
  A name no engine knows still throws.
- Removed the dead summarizeEntity and the two helpers only it used.

Cesium (viewer present) is unchanged; tests cover each refusal and that the
style still applies with a viewer.
This commit is contained in:
German Khokhlov committed 2026-10-16 23:17:36 +04:00
1 parent 907cb2092d
commit bf46eb6d97
2 files changed
+141 -69

No files matched your search

+56 -68
View File
@@ -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',
+85 -1
View File
@@ -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',
);
});