From 90166024e912d05a48c78f3068a386432abb7152 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 16:52:35 +0400 Subject: [PATCH] Keep the cockpit camera steady near the ground on both engines Cesium lifts a camera below its globe every frame, between the cockpit's 20 Hz updates, so an eye placed under the terrain (an aircraft reading below it on a cold floor cell) jumped up and down. The cockpit now also keeps its eye above the engine's own surface. Navara's ground floor stands aside while the camera inputs are off, since the cockpit places the camera itself. --- changelog.d/1555-navara-globe-engine.md | 3 + src/globe/navara/createNavaraEngine.js | 4 +- src/globe/navara/createNavaraEngine.test.mjs | 15 +++++ src/globe/navara/groundFloor.js | 4 +- src/globe/navara/input.js | 2 + src/ui/cockpitCamera.js | 20 ++++-- src/ui/cockpitController.test.mjs | 64 ++++++++++++++++++++ src/ui/cockpitHost.js | 2 + 8 files changed, 105 insertions(+), 9 deletions(-) diff --git a/changelog.d/1555-navara-globe-engine.md b/changelog.d/1555-navara-globe-engine.md index f10aca8b6..29b9d7978 100644 --- a/changelog.d/1555-navara-globe-engine.md +++ b/changelog.d/1555-navara-globe-engine.md @@ -5,3 +5,6 @@ 1 GiB more on a Retina screen. Layers keep their data across a switch and draw on both engines; Cesium loads only when a Cesium globe or a Cesium-only feature needs it (German, @gekh, #1555). +- Keep the cockpit camera steady near the ground: it no longer jumps up and + down when an aircraft's reported altitude sits below the globe's terrain, + on either engine (German, @gekh, #1555). diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index d8d829fe9..0af11156e 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -216,10 +216,12 @@ export function createNavaraEngine( const maps = createNavaraMaps(view, fetch ? { fetch } : {}); const { surface } = createNavaraSurface(view, hooks); // Navara's controller zooms through the ground; keep the camera above it. + // With the camera inputs off nothing zooms, and the app places the camera + // itself (the cockpit eye, 12 m up): the floor's 30 m would fight it. groundFloor = createGroundFloor({ surface, position: () => model.camera.livePosition, - isFree: () => model.isFree(), + isFree: () => model.isFree() && input.cameraInputs(), lift: (position) => model.lift(position), setMinimumZoomDistance: (distance) => input.input.setCameraOptions({ minimumZoomDistance: distance }), diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index 1e0837039..6e3b64702 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -562,6 +562,21 @@ test('the floor does not fight flights or a lookAt target', () => { engine.destroy(); }); +test('the floor stands aside while the camera inputs are off (the cockpit drives it)', () => { + const { engine, view } = setup(); + view.sampleTerrainHeight = () => 100; + engine.input.setCameraInputs(false); + engine.camera.setView({ + destination: cartesian3.fromDegrees(2.35, 48.85, 112), + }); + view.emit('preRender', 1); + near(heightOf(engine), 112, 1e-6, 'the cockpit eye 12 m up is kept'); + engine.input.setCameraInputs(true); + view.emit('preRender', 2); + near(heightOf(engine), 130, 1e-6, 'lifted once the inputs are back'); + engine.destroy(); +}); + test('the ground floor reports its own ground and margin (diagnostics, not contract)', () => { const { engine } = setup(); assert.equal(typeof engine.groundFloor.ground, 'function'); diff --git a/src/globe/navara/groundFloor.js b/src/globe/navara/groundFloor.js index e670b1a7d..a33467901 100644 --- a/src/globe/navara/groundFloor.js +++ b/src/globe/navara/groundFloor.js @@ -5,8 +5,8 @@ // controller stops at the surface. // // Each frame (engine preRender, after the camera model reads Navara's -// camera), while the camera is free (no flight running, no lookAt target) -// and below MAX_HEIGHT: +// camera), while the camera is free (no flight running, no lookAt target, +// camera inputs on) and below MAX_HEIGHT: // - the ground under the camera is sampled (surface.sampleHeight: tiles, // else terrain; else the ellipsoid), at most every RESAMPLE_MS or after a // RESAMPLE_METRES move; diff --git a/src/globe/navara/input.js b/src/globe/navara/input.js index 58af87244..5dddae7f8 100644 --- a/src/globe/navara/input.js +++ b/src/globe/navara/input.js @@ -167,6 +167,8 @@ export function createNavaraInput( }; return { input, + /** Whether the app leaves the camera to the user (setCameraInputs). */ + cameraInputs: () => cameraInputs, destroy() { if (destroyed) return; destroyed = true; diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index 8e318d466..817b0c175 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -317,12 +317,20 @@ export function update() { if (cameraCartographic) { const cameraLat = geo.math.toDegrees(cameraCartographic.latitude); const cameraLon = geo.math.toDegrees(cameraCartographic.longitude); - const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon); - if (Number.isFinite(cameraFloorM)) { - cockpitFloorM = Math.max( - cockpitFloorM ?? Number.NEGATIVE_INFINITY, - cameraFloorM, - ); + // The engine's own surface under the eye counts too: Cesium lifts a + // camera below its globe every frame, between these 20 Hz updates, so an + // eye under it (a contact reading below the terrain on a cold floor + // cell) jumped up and down. + for (const floorM of [ + this.services.cachedGroundFloor(cameraLat, cameraLon), + this.host.terrainHeight(cameraCartographic), + ]) { + if (Number.isFinite(floorM)) { + cockpitFloorM = Math.max( + cockpitFloorM ?? Number.NEGATIVE_INFINITY, + floorM, + ); + } } const safeHeightM = cockpitGroundSafeHeight( cameraCartographic.height, diff --git a/src/ui/cockpitController.test.mjs b/src/ui/cockpitController.test.mjs index 216200596..25d5a6e6c 100644 --- a/src/ui/cockpitController.test.mjs +++ b/src/ui/cockpitController.test.mjs @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { CockpitViewController } from './cockpitController.js'; +import { COCKPIT_MIN_GROUND_CLEARANCE_M } from './cockpitPresentation.js'; +import * as geo from '../geo/index.js'; function environment() { const original = { document: globalThis.document, window: globalThis.window }; @@ -244,3 +246,65 @@ test('the weather toggle flips from its own state, however fast it is pressed', env.restore(); } }); + +test('the cockpit eye stays above the engine surface when the aircraft reads below it', () => { + const env = environment(); + try { + // A landing contact whose reported height is 13 m under the terrain, on + // a cell the ground floors have not sampled yet. Cesium lifts a camera + // under its globe every frame, between the cockpit's 20 Hz updates, so + // an eye below the surface jumped up and down. + const SURFACE_M = 120; + const place = { longitude: -1.7047, latitude: 0.5268 }; + const aircraft = geo.cartesian3.fromRadians( + place.longitude, + place.latitude, + 107, + ); + const views = []; + Object.assign(env.viewer, { + clock: { currentTime: 0 }, + camera: { + setView: ({ destination }) => + views.push(geo.cartographic.fromCartesian(destination).height), + positionCartographic: { ...place, height: 130 }, + }, + entities: { contains: () => true }, + }); + env.viewer.scene.globe = { getHeight: () => SURFACE_M }; + const entity = { + position: { + getValue: (time, result) => geo.cartesian3.clone(aircraft, result), + }, + }; + const owner = new CockpitViewController(env.viewer, { + services: { + cachedGroundFloor: () => undefined, + GROUND_FLOOR_LIFT_M: 1.5, + }, + }); + Object.assign(owner, { + active: true, + trackedEntity: entity, + readAircraftInfo: () => ({ + latitude: geo.math.toDegrees(place.latitude), + longitude: geo.math.toDegrees(place.longitude), + track: 180, + velocityMps: 70, + onGround: false, + }), + _adoptTrackedEntity() {}, + updateHud() {}, + }); + owner.update(); + assert.equal(views.length, 1); + assert.ok( + views[0] >= SURFACE_M + COCKPIT_MIN_GROUND_CLEARANCE_M - 1e-6, + `eye at ${views[0]} m, surface at ${SURFACE_M} m`, + ); + owner.active = false; + owner.dispose(); + } finally { + env.restore(); + } +}); diff --git a/src/ui/cockpitHost.js b/src/ui/cockpitHost.js index 62cc2c21f..dd781d2be 100644 --- a/src/ui/cockpitHost.js +++ b/src/ui/cockpitHost.js @@ -19,6 +19,7 @@ export function cockpitHost(viewer, engine) { }, camera: viewer.camera, cartographic: () => viewer.camera.positionCartographic, + terrainHeight: (place) => viewer.scene.globe?.getHeight(place), surface: viewer.scene, inputs: (enabled) => { viewer.scene.screenSpaceCameraController.enableInputs = enabled; @@ -40,6 +41,7 @@ export function cockpitHost(viewer, engine) { restore: (target) => target.resumeAircraftFollow(), camera: engine.camera, cartographic: () => engine.camera.pose().cartographic, + terrainHeight: (place) => engine.surface.terrainHeight(place), surface: engine, inputs: (enabled) => engine.input.setCameraInputs(enabled), };