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), };