From 2da7b6d0d942def68b609a084c6919dbe450422f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 17 Oct 2026 03:48:12 +0400 Subject: [PATCH] fix(ui): north-up and tilt keep an engine's own follow (Navara) Inside a Navara follow the orientation controls frame the follow's target and retarget its viewFrom instead of moving the camera, and the release cancels only a running flight (a cancel is a camera command, which ends the follow). Cesium's trackedEntity path is unchanged. Updated test: cameraOrientationControls.test.mjs (orientation navigation starts a flight before expecting its cancel; no flight, no cancel). --- src/ui/cameraOrientationControls.js | 32 ++++++++++++++- src/ui/cameraOrientationControls.test.mjs | 50 +++++++++++++++++++++++ src/ui/navigationController.js | 5 ++- 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index 58cd97a99..77150e98c 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -24,6 +24,18 @@ function followedEntity(engine, viewer) { return handle ? (handle.native ?? null) : (viewer?.trackedEntity ?? null); } +// An engine's own follow (no tracked entity: Navara): its handle, which +// knows its target and takes a new offset without ending the follow. +function engineFollow(engine, viewer) { + const handle = engine?.camera.following?.(); + return handle && + !followedEntity(engine, viewer) && + typeof handle.target === 'function' && + typeof handle.retarget === 'function' + ? handle + : null; +} + function trackedTarget(entity, viewer) { // Read the visual's sample first; sampling a flight CallbackProperty twice // can advance dead reckoning ahead of the displayed aircraft. @@ -89,9 +101,10 @@ export function readCameraTargetFrame(target, tracker) { if (!engine) return null; const viewer = trackerOf(target, tracker); const entity = followedEntity(engine, viewer); + const follow = engineFollow(engine, viewer); const center = entity ? trackedTarget(entity, viewer) - : pickViewTarget(engine); + : (follow?.target() ?? pickViewTarget(engine)); const pose = engine.camera.pose(); if (!center || !isPickedWorldPosition(pose.position)) return null; @@ -133,6 +146,23 @@ export function setCameraTargetFrame(target, frame, tracker) { if (!engine || !frame?.target || !Number.isFinite(frame.range)) return false; const viewer = trackerOf(target, tracker); const { camera } = engine; + // Inside an engine's own follow: the new frame is the follow's offset (a + // camera command would end the follow; Cesium's tracked entity keeps its + // follow through lookAt below, so both engines keep following). + const follow = engineFollow(engine, viewer); + if (follow) { + const heading = normalizedHeading(frame.heading); + const flat = Math.cos(frame.pitch); + follow.retarget({ + viewFrom: geo.cartesian3.create( + -frame.range * Math.sin(heading) * flat, + -frame.range * Math.cos(heading) * flat, + -frame.range * Math.sin(frame.pitch), + ), + }); + engine.render.request('camera-orientation'); + return true; + } try { const trackedTransform = followedEntity(engine, viewer) ? camera.transform() diff --git a/src/ui/cameraOrientationControls.test.mjs b/src/ui/cameraOrientationControls.test.mjs index fbe86a67b..43ce28e91 100644 --- a/src/ui/cameraOrientationControls.test.mjs +++ b/src/ui/cameraOrientationControls.test.mjs @@ -13,6 +13,7 @@ import { } from './cameraOrientationControls.js'; import { NavigationController } from './navigationController.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import * as geo from '../geo/index.js'; import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js'; import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js'; @@ -651,6 +652,10 @@ test('orientation navigation preserves follow and selection; new navigation canc cancelFlight(); }; let cockpit = false; + engine.camera.flyTo({ + destination: geo.cartesian3.fromDegrees(30, 40, 5000), + duration: 1, + }); const navigation = new NavigationController({ viewer, engine, @@ -675,6 +680,15 @@ test('orientation navigation preserves follow and selection; new navigation canc 'cancel-flight', ]); calls.length = 0; + // No running flight: a cancel would be a camera command, ending an engine's + // own follow, so the orientation leaves the camera alone. + navigation.runOrientation('camera', () => 'animated'); + assert.deepEqual(calls, [ + 'cancel-orientation', + 'interrupt-motion', + 'stop-orbit', + ]); + calls.length = 0; navigation._stampNavigation({ cancelPendingSelection: false }); assert.deepEqual(calls, ['cancel-orientation', 'clear-location']); calls.length = 0; @@ -761,3 +775,39 @@ test('the controls run on a globe engine alone', (t) => { assert.equal(engine.frames.preUpdate.numberOfListeners, 0); assert.equal(engine.camera.moveEnd.numberOfListeners, 0); }); + +test("north-up and tilt inside an engine's own follow keep following (Navara)", () => { + const engine = createFakeEngine(); + const target = Cesium.Cartesian3.fromDegrees(-73.78, 40.64, 3000); + const handle = engine.camera.follow({ + position: () => target, + // 2 km south-east of the target, 1 km up: heading about 315°. + viewFrom: { x: 1400, y: -1400, z: 1000 }, + }); + engine.fake.frame(); + const before = readCameraTargetFrame(engine); + assert.ok(before, 'a frame around the followed target'); + sameXYZ(before.target, target, 'the frame centres the follow target'); + assert.equal(resetCameraNorth(engine), true); + engine.fake.frame(); + assert.equal(engine.camera.following(), handle, 'north-up keeps the follow'); + const north = readCameraTargetFrame(engine); + const headingDeg = Cesium.Math.toDegrees(north.heading); + assert.ok( + Math.min(headingDeg, 360 - headingDeg) < 0.01, + `north-up left the follow at ${headingDeg.toFixed(2)} deg`, + ); + assert.ok(Math.abs(north.range - before.range) < 1, 'same range'); + assert.ok(Math.abs(north.pitch - before.pitch) < 1e-6, 'same pitch'); + const tilt = toggleCameraTilt(engine); + assert.ok(tilt, 'tilt applied'); + engine.fake.frame(); + assert.equal(engine.camera.following(), handle, 'tilt keeps the follow'); + const tilted = readCameraTargetFrame(engine); + assert.ok( + Math.abs(tilted.pitch - tilt.pitch) < 1e-6, + 'the follow took the tilted pitch', + ); + handle.stop(); + engine.destroy(); +}); diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index fa671fb1e..44c866840 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -220,7 +220,10 @@ export class NavigationController { release: () => { this.interruptCameraMotion('camera-orientation'); this.stopOrbit(); - this.engine?.camera.cancelFlight(); + // Only a running flight: a cancel is a camera command, which ends an + // engine's own follow (Navara) that the orientation keeps. + if (this.engine?.camera.flightActive?.() !== false) + this.engine?.camera.cancelFlight(); }, navigate, });