Open the cockpit on a tracked Navara aircraft

This commit is contained in:
German Khokhlov committed 2026-11-02 22:04:23 +04:00
1 parent 8b179a7e01
commit 2087b55b47
19 files changed
+142 -65

No files matched your search

+2 -2
View File
@@ -39,7 +39,7 @@ export function navaraModeNotice(availability) {
return Object.freeze({
label: t('Experimental Navara engine'),
detail: names.length
? `Data layers: ${names.join(', ')}; the other layers, cockpit and director are unavailable`
? `Data layers: ${names.join(', ')}; the other layers and director are unavailable`
: 'No data layers on this engine yet; cockpit and director are unavailable',
});
}
@@ -347,7 +347,7 @@ export function createViewerlessTools({
if (window.__godsEyeView === debug) delete window.__godsEyeView;
});
// Voice on the engine (phase 3, Task 9): the W1 tools drive the engine;
// the viewer-only ones (director, annotations, cockpit…) refuse.
// the viewer-only ones (director, annotations…) refuse.
const voiceCommands = initGevVoiceCommands({
...voice,
annotationResolver: operations.annotationResolver,
+1 -1
View File
@@ -119,7 +119,7 @@ test('without a viewer the tools run the governor on the engine, show the notice
registry.register('navara', { satellites: () => ({}), flights: () => ({}) });
assert.equal(
navaraModeNotice(createLayerAvailability('navara', { registry })).detail,
'Data layers: Live Flights, Satellites; the other layers, cockpit and director are unavailable',
'Data layers: Live Flights, Satellites; the other layers and director are unavailable',
);
// The governor drives the engine's continuous rendering.
assert.equal(isRenderGovernorInstalled(engine), true);
+3 -3
View File
@@ -42,10 +42,10 @@ function ordered(source, needles, label) {
test('Cockpit takeover invalidates deferred work before camera cancellation', () => {
const enter = cockpitEnter.toString();
ordered(enter, [
'if (!info || !entity?.position) return false;',
'if (!info || !entity) return false;',
'this.onCameraTakeover?.();',
'this.viewer.camera.cancelFlight();',
'this.viewer.trackedEntity = undefined;',
'this.host.camera.cancelFlight();',
'this.host.release();',
], 'Cockpit takeover');
assert.match(
ui,
+4 -4
View File
@@ -223,7 +223,7 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
assert.match(enter, /activeElement/);
assert.match(enter, /mapViewButton.*focus|focus.*mapViewButton/s);
assert.match(exit, /focus\(\{ preventScroll: true \}\)/);
assert.match(exit, /this\.restoreTrackingFrame\(entity\)/);
assert.match(exit, /this\.host\.restore\(entity, this\.restoreTrackingFrame\)/);
assert.doesNotMatch(exit, /applyTrackedCameraFrame/);
assert.match(
ui,
@@ -238,7 +238,7 @@ test('Cockpit shortcut failures do not leak and open utilities own the first Esc
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
assert.match(
keydown,
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.host\.current\(\)\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
);
});
@@ -302,7 +302,7 @@ test('the cockpit reads its aircraft from the layer that owns Cesium tracking',
assert.match(read, /gevTrackedId/);
// In cockpit mode the controller moves the entity off viewer.trackedEntity,
// so its own handle is the tracked identity there.
assert.match(read, /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/);
assert.match(read, /this\.host\.followed\(\) \|\| this\.trackedEntity/);
});
test('programmatic Context layer changes cannot bypass explicit expansion policy', () => {
@@ -1165,7 +1165,7 @@ test('cockpit route direction uses self-contained vector artwork, not a font lig
test('cockpit aircraft handoff invalidates the prior world-position anchor', () => {
const match = _adoptTrackedEntity.toString();
assert.ok(match, 'cockpit tracked-entity handoff block is missing');
assert.match(match, /this\.viewer\.trackedEntity = undefined;/);
assert.match(match, /this\.host\.release\(\);/);
assert.match(match, /this\.cockpitAnchorValid = false;/);
assert.match(
match,
+6 -4
View File
@@ -412,12 +412,11 @@ export function contextRestoreLayerIds(snapshot = {}) {
}
/**
* Cockpit entry belongs to the operational Contacts context bundle. A tracked
* aircraft alone is not sufficient: both observed-flight feeds must still be
* active so the cockpit's surrounding-contact picture is not presented as a
* complete context view when one source is disabled.
* Contacts mode requires both observed-flight feeds. Navara also supports
* first-person flight directly from a single selected aircraft layer.
*
* @param {object} input Current context and dependency visibility.
* @param {boolean} [input.standaloneAircraft] Allow direct entry outside a Context mode.
* @param {string|null} input.contextMode Active Context mode.
* @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled.
* @param {boolean} input.flightsEnabled Whether Live Flights is enabled.
@@ -425,11 +424,14 @@ export function contextRestoreLayerIds(snapshot = {}) {
* @returns {boolean} Whether cockpit entry may be offered or activated.
*/
export function cockpitEntryAllowed({
standaloneAircraft = false,
contextMode,
contextModeChanging,
flightsEnabled,
militaryEnabled,
}) {
if (standaloneAircraft && !contextMode)
return !contextModeChanging && Boolean(flightsEnabled || militaryEnabled);
return (
contextMode === 'flights' &&
!contextModeChanging &&
+9
View File
@@ -587,3 +587,12 @@ test('session bookkeeping ignores programmatic origins, non-visibility events, a
}), false);
assert.equal(snapshot.userAdded.size, 0);
});
test('direct aircraft entry on Navara needs an enabled feed and an idle Context', () => {
const input = { standaloneAircraft: true, contextMode: null, flightsEnabled: true };
assert.equal(cockpitEntryAllowed(input), true);
assert.equal(cockpitEntryAllowed({ ...input, flightsEnabled: false, militaryEnabled: true }), true);
assert.equal(cockpitEntryAllowed({ ...input, flightsEnabled: false }), false);
assert.equal(cockpitEntryAllowed({ ...input, contextModeChanging: true }), false);
assert.equal(cockpitEntryAllowed({ ...input, contextMode: 'space-missions' }), false);
});
+2
View File
@@ -738,6 +738,7 @@ export function createAircraftNavaraRenderer({
// The tracked glyph's distance scale is its bank's (as Cesium's).
sprite._scaleByDistance = spec.scaleByDistance;
sprite.spec = spec;
sprite.resumeAircraftFollow = () => follow.start(sprite);
sprite.viewFrom = null;
trackedSprites.add(sprite);
placeTracked(sprite);
@@ -783,6 +784,7 @@ export function createAircraftNavaraRenderer({
position: () => handle.gevDisplayPosition?.() ?? handle.spec.position(),
};
return engine.camera.follow({
native: handle,
position: () => handle.spec.position(),
viewFrom: () => handle.viewFrom,
label: card,
@@ -190,6 +190,7 @@ test('the tracked glyph reads its callbacks every frame; follow carries its read
assert.equal(typeof stop, 'function');
const following = engine.camera.following();
assert.equal(following, handle.gevFollowHandle);
assert.equal(following.native, handle, 'cockpit follows the rendered aircraft');
assert.equal(following.label.id, 'flights:abc123');
assert.equal(following.label.model(), handle.gevLabelModel);
assert.equal(renderer.follow.isFollowing(handle), true);
@@ -197,7 +198,10 @@ test('the tracked glyph reads its callbacks every frame; follow carries its read
renderer.follow.release();
assert.equal(engine.camera.following(), null);
assert.equal(renderer.follow.current(), undefined);
handle.resumeAircraftFollow();
assert.equal(renderer.follow.current(), handle);
renderer.tracked.remove(handle);
assert.equal(engine.camera.following(), null);
assert.equal(renderer.tracked.isLive(handle), false);
renderer.destroy();
engine.destroy();
+1
View File
@@ -371,6 +371,7 @@ export class StyleManager extends ShellFacade {
this._recording.hud = this.hud;
this._cockpitCoordinator = new CockpitCoordinator({
viewer,
engine,
services: {
flightsLayer: services.flightsLayer,
militaryFlightsLayer: services.militaryFlightsLayer,
+6 -6
View File
@@ -45,7 +45,7 @@ export function update() {
if (
!info ||
!this.trackedEntity ||
!this.viewer.entities.contains(this.trackedEntity)
!this.host.contains(this.trackedEntity)
) {
if (nowMs < this.contextNavigationDeadlineMs) return;
this.exit({ restoreTracking: false });
@@ -61,8 +61,8 @@ export function update() {
return;
this.lastCameraUpdateMs = nowMs;
const target = this.trackedEntity.position.getValue(
this.viewer.clock.currentTime,
const target = this.host.position(
this.trackedEntity,
this.scratchTarget,
);
if (!target) return;
@@ -182,8 +182,8 @@ export function update() {
)
) {
this.lastGroundProbeMs = nowMs;
const viewerCartographic = this.viewer.camera.positionCartographic;
this.services.sampleMeshFloorCells(this.viewer.scene, groundPoint, {
const viewerCartographic = this.host.cartographic();
this.services.sampleMeshFloorCells(this.host.surface, groundPoint, {
excludeObjects: [this.trackedEntity],
viewerLat: geo.math.toDegrees(viewerCartographic.latitude),
viewerLon: geo.math.toDegrees(viewerCartographic.longitude),
@@ -345,7 +345,7 @@ export function update() {
}
}
this.viewer.camera.setView({
this.host.camera.setView({
destination: this.scratchCamera,
orientation: { direction: this.scratchForward, up: this.scratchUp },
});
+6 -3
View File
@@ -63,11 +63,14 @@ import {
import { onKeyDown } from './cockpitInput.js';
import * as geo from '../geo/index.js';
import { cockpitHost } from './cockpitHost.js';
export class CockpitViewController {
constructor(
viewer,
{
services,
engine = null,
onVisionChange = null,
getInitialVisionMode = null,
onCameraTakeover = null,
@@ -79,7 +82,7 @@ export class CockpitViewController {
publish = {},
} = {},
) {
this.viewer = viewer;
this.host = cockpitHost(viewer, engine);
this.services = services;
this.destroyed = false;
this.disposed = false;
@@ -198,8 +201,8 @@ export class CockpitViewController {
// from preRender makes 3D Tiles discover a new view after traversal and can
// create a self-sustaining refinement loop under a moving cockpit camera.
this._listenerRemovers.push(
viewer.scene.preUpdate.addEventListener(() => this.update()),
viewer.trackedEntityChanged.addEventListener(() => {
this.host.preUpdate.addEventListener(() => this.update()),
this.host.followChanged.addEventListener(() => {
if (this.active) this._adoptTrackedEntity(performance.now());
else this.syncEntry();
}),
+9 -6
View File
@@ -9,6 +9,7 @@ import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js';
export class CockpitCoordinator {
constructor({
viewer,
engine,
services,
elements,
operations,
@@ -23,6 +24,7 @@ export class CockpitCoordinator {
this,
{
viewer,
engine,
services,
readDataManager,
readContext,
@@ -35,10 +37,7 @@ export class CockpitCoordinator {
operations,
);
this._cockpitDisplayPortal = null;
// The cockpit flies the Cesium camera after tracked aircraft; without a
// viewer (the Navara engine) there is no cockpit and controlCockpit
// reports it unavailable.
this.cockpitView = viewer
this.cockpitView = viewer || engine?.kind === 'navara'
? this._createCockpitView(viewer, services)
: null;
}
@@ -62,6 +61,7 @@ export class CockpitCoordinator {
weatherCodeLabel,
} = services;
return new CockpitViewController(viewer, {
engine: this.engine,
services: {
flightsLayer,
militaryFlightsLayer,
@@ -88,6 +88,7 @@ export class CockpitCoordinator {
this._stampNavigation({ cancelPendingSelection: false }),
isEntryAllowed: () =>
cockpitEntryAllowed({
standaloneAircraft: this.engine?.kind === 'navara',
contextMode: this._contextMode,
contextModeChanging: this._contextModeChanging,
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
@@ -136,7 +137,7 @@ export class CockpitCoordinator {
// (including the voice model) read that as a broken half-entered state.
const entryAllowed =
!active &&
Boolean(gateOpen && info && this.viewer?.trackedEntity?.position);
Boolean(gateOpen && info && this.cockpitView?.host.current());
return {
active,
entryAllowed,
@@ -278,7 +279,9 @@ export class CockpitCoordinator {
action: 'control_cockpit',
error: this._contextModeChanging
? 'Contacts is still starting up — try Cockpit again in a moment'
: 'Contacts must be active to enter Cockpit — say "open contacts" first',
: this.engine?.kind === 'navara'
? 'Enable an aircraft layer and track a flight to enter Cockpit'
: 'Contacts must be active to enter Cockpit — say "open contacts" first',
state: this.getCockpitState(),
};
}
+46
View File
@@ -0,0 +1,46 @@
// Camera and aircraft ownership used by the shared first-person controller.
export function cockpitHost(viewer, engine) {
if (viewer) {
return {
preUpdate: viewer.scene.preUpdate,
followChanged: viewer.trackedEntityChanged,
followed: () => viewer.trackedEntity,
current: () =>
viewer.trackedEntity?.position ? viewer.trackedEntity : null,
contains: (entity) => viewer.entities.contains(entity),
position: (entity, result) =>
entity.position.getValue(viewer.clock.currentTime, result),
release: () => {
viewer.trackedEntity = undefined;
},
restore: (entity, refocus) => {
viewer.trackedEntity = entity;
refocus(entity);
},
camera: viewer.camera,
cartographic: () => viewer.camera.positionCartographic,
surface: viewer.scene,
inputs: (enabled) => {
viewer.scene.screenSpaceCameraController.enableInputs = enabled;
},
};
}
const aircraft = () => {
const target = engine.camera.following()?.native;
return target?.resumeAircraftFollow ? target : null;
};
return {
preUpdate: engine.frames.preUpdate,
followChanged: engine.camera.followChanged,
followed: aircraft,
current: aircraft,
contains: (target) => target.live,
position: (target) => target.spec.position(),
release: () => engine.camera.following()?.stop(),
restore: (target) => target.resumeAircraftFollow(),
camera: engine.camera,
cartographic: () => engine.camera.pose().cartographic,
surface: engine,
inputs: (enabled) => engine.input.setCameraInputs(enabled),
};
}
+1 -1
View File
@@ -49,7 +49,7 @@ export function onKeyDown(event) {
if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) {
if (!this.active) {
const cockpitAttempt = !!(
this.readAircraftInfo() && this.viewer.trackedEntity?.position
this.readAircraftInfo() && this.host.current()
);
if (!cockpitAttempt) return;
}
+15 -16
View File
@@ -9,7 +9,7 @@ import {
export function readAircraftInfo() {
// In cockpit mode the controller takes the entity off `viewer.trackedEntity`
// (see update()), so the cockpit's own handle is the tracked identity there.
const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity;
const trackedEntity = this.host.followed() || this.trackedEntity;
return resolveTrackedAircraftInfo({
civilian: this.services.flightsLayer.getTrackedInfo?.() || null,
military: this.services.militaryFlightsLayer.getTrackedInfo?.() || null,
@@ -77,7 +77,7 @@ export function syncEntry() {
if (this.destroyed) return false;
if (this.active) return;
const info = this.readAircraftInfo();
const trackedContact = !!(info && this.viewer.trackedEntity?.position);
const trackedContact = !!(info && this.host.current());
this.syncTr3bToggle(trackedContact ? info : null);
const available = !!(this.isEntryAllowed() && trackedContact);
// Change-only publishes: this runs on a preUpdate cadence, and a fresh
@@ -108,22 +108,22 @@ export function navigateContext(direction, options = {}) {
}
export function _adoptTrackedEntity(nowMs, suppliedInfo = null) {
const nextEntity = this.viewer.trackedEntity;
const nextEntity = this.host.current();
if (
!this.active ||
!nextEntity?.position ||
!nextEntity ||
nextEntity === this.trackedEntity
)
return false;
const info = suppliedInfo || this.readAircraftInfo();
if (!info) return false;
if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) {
if (this.trackedEntity && this.host.contains(this.trackedEntity)) {
this.trackedEntity.show = this.trackedEntityWasShown;
}
this.trackedEntity = nextEntity;
this.trackedEntityWasShown = nextEntity.show;
nextEntity.show = false;
this.viewer.trackedEntity = undefined;
this.host.release();
this.cockpitAnchorValid = false;
this.heading = normalizeHeading(info.track ?? 0);
this.lastFrameMs = nowMs;
@@ -140,15 +140,15 @@ export function enter() {
if (this.active) return false;
if (!this.isEntryAllowed()) return false;
const info = this.readAircraftInfo();
const entity = this.viewer.trackedEntity;
if (!info || !entity?.position) return false;
const entity = this.host.current();
if (!info || !entity) return false;
this.entryFocusOrigin =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
// Retire deferred navigation before cancelFlight can run its callbacks.
this.onCameraTakeover?.();
this.viewer.camera.cancelFlight();
this.host.camera.cancelFlight();
this.trackedEntity = entity;
this.trackedEntityWasShown = entity.show;
entity.show = false;
@@ -169,8 +169,8 @@ export function enter() {
// runs on rendered frames, so idle mode would freeze the cockpit solid.
// (perf wave 2)
this.services.holdContinuousRender('cockpit');
this.viewer.trackedEntity = undefined;
this.viewer.scene.screenSpaceCameraController.enableInputs = false;
this.host.release();
this.host.inputs(false);
document.body.classList.add('cockpit-mode');
// Activation hides the entry and TR-3B chip; clearing the chip's signature
// lets the exit-path syncEntry publish it again.
@@ -237,14 +237,13 @@ export function exit({ restoreTracking = true } = {}) {
this.routeView.visible = false;
this.clearPredictiveRoute();
this.setVisionMode('optical');
this.viewer.scene.screenSpaceCameraController.enableInputs = true;
if (entity && this.viewer.entities.contains(entity))
this.host.inputs(true);
if (entity && this.host.contains(entity))
entity.show = this.trackedEntityWasShown;
this.trackedEntityWasShown = true;
this.dispatchCockpitModeChanged(false);
if (restoreTracking && entity && this.viewer.entities.contains(entity)) {
this.viewer.trackedEntity = entity;
this.restoreTrackingFrame(entity);
if (restoreTracking && entity && this.host.contains(entity)) {
this.host.restore(entity, this.restoreTrackingFrame);
}
this.syncEntry();
const restoreTarget =
+1 -10
View File
@@ -1,7 +1,6 @@
import { t, translateKnown } from '../i18n/index.js';
import {
SPACE_MISSIONS_LAYER_BLOCK_REASON,
cockpitEntryAllowed,
isExplicitUserIntentOrigin,
parseContextLayerBlockReason,
recordContextRestoreExplicitChange,
@@ -245,15 +244,7 @@ export function _handleContextLayerChange(change) {
);
}
}
if (
this.cockpitView?.active &&
!cockpitEntryAllowed({
contextMode: this._contextMode,
contextModeChanging: this._contextModeChanging,
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
militaryEnabled: !!this._dataManager?.isEnabled('military'),
})
) {
if (this.cockpitView?.active && !this.cockpitView.isEntryAllowed()) {
this.cockpitView.exit({ restoreTracking: false });
}
this._syncContextModeButtons();
+6 -4
View File
@@ -55,10 +55,12 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
</div>
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" :hidden="!ctx?.flightsActive">
<div class="global-context-actions" :aria-label="t('Contact Context actions')">
<button id="cockpit-entry" type="button" :hidden="!cockpit?.entryAvailable">
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
<span>{{ t('COCKPIT') }}</span>
</button>
<Teleport to="body" :disabled="!cockpit?.entryAvailable || ctx?.flightsActive">
<button id="cockpit-entry" type="button" :hidden="!cockpit?.entryAvailable">
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
<span>{{ t('COCKPIT') }}</span>
</button>
</Teleport>
<button id="installations-search-btn" class="panel-layer-toggle" type="button" :aria-disabled="ctx && String(ctx.installationsBusy)" :aria-busy="ctx && String(ctx.installationsBusy)">{{ t('SEARCH NEARBY SITES') }}</button>
<UiButton id="tr3b-toggle" class="tr3b-toggle" :pressed="!!cockpit?.tr3b?.converted" :hidden="!cockpit?.tr3b" :aria-label="t('Reclassify tracked contact as TR-3B')" :title="cockpit?.tr3b?.converted ? t('Restore real aircraft') : t('Reclassify as TR-3B')">🛸</UiButton>
</div>
+2 -4
View File
@@ -229,7 +229,7 @@ const FRAME_TARGETS = new Map([
/**
* 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
* (orbit, pan, tilt), Contacts (the context mode), Radio and
* CCTV. Without a viewer (the Navara engine) each answers a structured
* refusal (UNAVAILABLE_ON_ENGINE) instead of running.
*/
@@ -239,7 +239,6 @@ export const VIEWER_ONLY_ACTIONS = Object.freeze([
'clear_annotations',
'fly_route',
'move_camera',
'control_cockpit',
'set_context_mode',
'control_radio',
'control_cctv',
@@ -265,7 +264,6 @@ export const UNAVAILABLE_FEATURES = Object.freeze({
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',
@@ -1078,7 +1076,7 @@ export function createGevActionRunner({
contextState?.mode === 'flights' &&
contextState?.active !== false &&
contextState?.changing !== true;
if (!contactsReady) {
if (!contactsReady && engine?.kind !== 'navara') {
const contextResult = await styleManager.setContextMode('flights', {
signal: runOptions.signal,
isCurrent: runOptions.isCurrent,
+18 -1
View File
@@ -94,7 +94,6 @@ test('the viewer-only actions are one exported list of real tools', () => {
'clear_annotations',
'fly_route',
'move_camera',
'control_cockpit',
'set_context_mode',
'control_radio',
'control_cctv',
@@ -105,6 +104,24 @@ test('the viewer-only actions are one exported list of real tools', () => {
assert.ok(tools.has(name), `${name} is a voice tool`);
});
test('Navara enters cockpit on the tracked aircraft without starting Contacts', async () => {
const engine = engineOverAustin({ kind: 'navara' });
const runner = runnerOn(engine, {
styleManager: {
getAircraftTrackingTarget: () => ({ layerId: 'flights', id: '507c84' }),
setContextMode: () => assert.fail('the selected flight already owns the camera'),
controlCockpit: (action, { rollbackTarget }) => {
assert.equal(action, 'enter');
assert.equal(rollbackTarget.id, '507c84');
return { ok: true, state: { active: true } };
},
},
});
const result = await runner('control_cockpit', { action: 'enter' });
assert.equal(result.ok, true);
assert.equal(result.state.active, true);
});
test('without a viewer the viewer-only actions refuse with a structured result', async () => {
const engine = engineOverAustin({ kind: 'navara' });
const touched = [];