From 64d329730133e4260c37bbc8a9cd53d9e1efdc43 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 15 Oct 2026 21:27:41 +0400 Subject: [PATCH] fix(globe): keep the Navara camera above the ground Navara 0.2.1's camera controller has no terrain or tile collision; its only zoom limit, minimumZoomDistance, is a distance from the Earth's centre, so a wheel zoom near the ground went through it (to -5.9 km). groundFloor.js runs on the engine's preRender after the camera model reads Navara's camera. While the camera is free (no flight, no lookAt target) and below 20 km it samples the ground under it (tiles, else terrain, else the ellipsoid; at most every 250 ms or 20 m), feeds Navara's clamp (minimumZoomDistance = geocentric radius of the ground point + 30 m, sent on changes over 10 m, reset to Navara's default during flights and above 20 km) so the wheel stops smoothly, and lifts a camera still below ground + 30 m straight up without camera.commanded. It only lifts, so it cannot oscillate with the shell's ground guard, whose lift is a flight the floor stands aside for. --- scripts/package-boundaries.json | 1 + src/globe/navara/cameraModel.js | 14 ++ src/globe/navara/createNavaraEngine.js | 14 ++ src/globe/navara/createNavaraEngine.test.mjs | 62 +++++++++ src/globe/navara/groundFloor.js | 121 +++++++++++++++++ src/globe/navara/groundFloor.test.mjs | 135 +++++++++++++++++++ 6 files changed, 347 insertions(+) create mode 100644 src/globe/navara/groundFloor.js create mode 100644 src/globe/navara/groundFloor.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4088d9477..0d21800c7 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1764,6 +1764,7 @@ "src/globe/navara/cameraModel.js", "src/globe/navara/createNavaraEngine.js", "src/globe/navara/credits.js", + "src/globe/navara/groundFloor.js", "src/globe/navara/index.js", "src/globe/navara/input.js", "src/globe/navara/internals.js", diff --git a/src/globe/navara/cameraModel.js b/src/globe/navara/cameraModel.js index f0991a4ed..a58c20d64 100644 --- a/src/globe/navara/cameraModel.js +++ b/src/globe/navara/cameraModel.js @@ -394,6 +394,20 @@ export function createCameraModel({ * for the user's own gestures, which are not a new camera owner. */ stopFlight: () => cancelCurrentFlight(), + /** + * Whether nothing else drives the camera: no flight and no lookAt target + * (identity transform). The engine's ground floor acts only then. + */ + isFree: () => + flight == null && + transform.every((value, index) => value === matrix4.IDENTITY[index]), + /** + * Moves the camera to `position`, keeping its direction and up, without + * camera.commanded: a correction (the ground floor), not a new owner. + */ + lift(position) { + place({ ...state, position: copy3(position) }); + }, /** The current camera for projections: pose, vertical fov and CSS size. */ view, worldToWindow: (position, result) => diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 1d9ec5521..db38fbf70 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -14,6 +14,7 @@ import { createNavaraCredits } from './credits.js'; import { createNavaraInput } from './input.js'; import { createNavaraMaps } from './maps.js'; import { createNavaraSurface } from './surface.js'; +import { createGroundFloor } from './groundFloor.js'; const { cartesian3, math } = geo; @@ -104,10 +105,13 @@ export function createNavaraEngine( preRender: createEngineEvent(), postRender: createEngineEvent(), }; + // Defined below, with the surface and input it needs. + let groundFloor = null; const handlers = { preUpdate: (time) => frames.preUpdate.raiseEvent(time), preRender: (time) => { model.sync(); + groundFloor?.update(); frames.preRender.raiseEvent(time); }, postRender: (time) => { @@ -126,6 +130,16 @@ export function createNavaraEngine( const credits = createNavaraCredits(view); const maps = createNavaraMaps(view, fetch ? { fetch } : {}); const { surface } = createNavaraSurface(view, hooks); + // Navara's controller zooms through the ground; keep the camera above it. + groundFloor = createGroundFloor({ + surface, + position: () => model.camera.livePosition, + isFree: () => model.isFree(), + lift: (position) => model.lift(position), + setMinimumZoomDistance: (distance) => + input.input.setCameraOptions({ minimumZoomDistance: distance }), + ...(clock ? { now: () => clock.now() } : {}), + }); const picker = { // Navara picks its own meshes only on click gestures (featureClick); there diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index 235cd1847..34fb980ea 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -243,3 +243,65 @@ test('a user gesture cancels a flight without raising camera.commanded', () => { assert.equal(engine.camera.flightActive(), false); assert.equal(commanded, 0, 'a gesture is the user, not a new camera owner'); }); + +// --- The ground floor (groundFloor.js) on the engine's frames --------------- + +const heightOf = (engine) => engine.camera.pose().cartographic.height; + +test('a free camera below the ground is lifted on preRender, without camera.commanded', () => { + const { engine, view } = setup(); + view.sampleTerrainHeight = () => 100; + engine.camera.setView({ + destination: cartesian3.fromDegrees(2.35, 48.85, 40), + orientation: { heading: 0.5, pitch: -0.3, roll: 0 }, + }); + let commanded = 0; + engine.camera.commanded.addEventListener(() => commanded++); + const before = engine.camera.pose(); + view.emit('preRender', 1); + near(heightOf(engine), 130, 1e-6, 'lifted to ground + 30 m'); + const after = engine.camera.pose(); + near(after.heading, before.heading, 1e-7, 'heading kept'); + near(after.pitch, before.pitch, 1e-7, 'pitch kept'); + assert.equal(commanded, 0); + const [, pose] = view.calls.findLast(([name]) => name === 'setCamera'); + near(pose.height, 130, 1e-3, 'drawn lifted'); + // Navara's own wheel clamp follows the ground. + const [, options] = view.calls.findLast(([name]) => name === 'options'); + const radius = cartesian3.magnitude(cartesian3.fromDegrees(2.35, 48.85, 100)); + near(options.minimumZoomDistance, radius + 30, 1e-6, 'clamp'); + engine.destroy(); +}); + +test('the floor does not fight flights or a lookAt target', () => { + const { engine, view } = setup(); + view.sampleTerrainHeight = () => 100; + engine.camera.setView({ + destination: cartesian3.fromDegrees(2.35, 48.85, 500), + }); + engine.camera.flyTo({ + destination: cartesian3.fromDegrees(2.35, 48.85, 50), + duration: 3, + }); + const calls = view.calls.length; + view.emit('preRender', 1); + assert.equal( + view.calls.slice(calls).some(([name]) => name === 'setCamera'), + false, + 'no lift during a flight', + ); + engine.camera.cancelFlight(); + // An orbit-style lookAt from below the floor stays where it was put. + engine.camera.lookAt(cartesian3.fromDegrees(2.35, 48.85, 0), { + heading: 0, + pitch: -0.1, + range: 50, + }); + const held = heightOf(engine); + view.emit('preRender', 2); + near(heightOf(engine), held, 1e-9, 'lookAt kept'); + engine.camera.release(); + view.emit('preRender', 3); + near(heightOf(engine), 130, 1e-6, 'lifted once released'); + engine.destroy(); +}); diff --git a/src/globe/navara/groundFloor.js b/src/globe/navara/groundFloor.js new file mode 100644 index 000000000..53e8e12ea --- /dev/null +++ b/src/globe/navara/groundFloor.js @@ -0,0 +1,121 @@ +// Keeps the Navara camera above the ground. Navara 0.2.1's camera controller +// has no terrain or 3D-tile collision: its only zoom limit is +// minimumZoomDistance, a distance from the Earth's centre (default: the +// semi-minor axis), so a wheel zoom near the ground goes through it. Cesium's +// 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: +// - 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; +// - Navara's own clamp follows it (minimumZoomDistance = geocentric radius of +// the ground point + MIN_ABOVE), so the wheel stops smoothly; it is sent +// only when it moves more than CLAMP_STEP, and reset to Navara's default +// while a flight runs or the camera is high, so a stale clamp from another +// latitude can never hold the camera up; +// - a camera still below ground + MIN_ABOVE is lifted straight up, keeping +// its orientation. +// The floor only ever lifts, so it cannot oscillate with the shell's ground +// guard (src/cameraGroundGuard.js), which also only lifts, by camera.flyTo; +// during that flight the floor stands aside. + +import * as geo from '../../geo/index.js'; + +const { cartesian3, cartographic } = geo; +const WGS84 = geo.ellipsoid.WGS84; + +export const GROUND_FLOOR = Object.freeze({ + /** Metres the camera stays above the ground. */ + MIN_ABOVE: 30, + /** Ellipsoid height (m) above which there is no floor. */ + MAX_HEIGHT: 20000, + /** Change (m) of the clamp radius worth sending to Navara. */ + CLAMP_STEP: 10, + /** Horizontal move (m) after which the ground is sampled again. */ + RESAMPLE_METRES: 20, + /** Age (ms) after which the ground is sampled again. */ + RESAMPLE_MS: 250, +}); + +/** + * @param {{ + * surface: { sampleHeight: Function, terrainHeight?: Function }, + * position: () => import('../../geo/index.js').Cartesian3, + * isFree: () => boolean, + * lift: (position: object) => void, + * setMinimumZoomDistance: (distance: number | undefined) => void, + * now?: () => number, + * }} options + * @returns {{ update: () => boolean, ground: () => number | undefined }} + * `update` returns whether it lifted the camera. + */ +export function createGroundFloor({ + surface, + position, + isFree, + lift, + setMinimumZoomDistance, + now = () => performance.now(), +}) { + let sample = null; // { point, at, height } + let clamp; // the radius last sent, or undefined for Navara's default + + const sendClamp = (radius) => { + if (radius === undefined) { + if (clamp === undefined) return; + } else if ( + clamp !== undefined && + Math.abs(radius - clamp) <= GROUND_FLOOR.CLAMP_STEP + ) + return; + clamp = radius; + setMinimumZoomDistance(radius); + }; + + function groundAt(place, at) { + const point = cartesian3.fromRadians( + place.longitude, + place.latitude, + 0, + WGS84, + ); + if ( + sample && + at - sample.at < GROUND_FLOOR.RESAMPLE_MS && + cartesian3.distance(point, sample.point) < GROUND_FLOOR.RESAMPLE_METRES + ) + return sample.height; + const height = + surface.sampleHeight(place) ?? surface.terrainHeight?.(place) ?? 0; + sample = { point, at, height }; + const ground = cartesian3.fromRadians( + place.longitude, + place.latitude, + height, + WGS84, + ); + sendClamp(cartesian3.magnitude(ground) + GROUND_FLOOR.MIN_ABOVE); + return height; + } + + function update() { + if (!isFree()) { + sendClamp(undefined); + return false; + } + const place = cartographic.fromCartesian(position(), WGS84); + if (!place || place.height > GROUND_FLOOR.MAX_HEIGHT) { + sendClamp(undefined); + sample = null; + return false; + } + const floor = groundAt(place, now()) + GROUND_FLOOR.MIN_ABOVE; + if (!(place.height < floor)) return false; + lift(cartesian3.fromRadians(place.longitude, place.latitude, floor, WGS84)); + return true; + } + + return { update, ground: () => sample?.height }; +} diff --git a/src/globe/navara/groundFloor.test.mjs b/src/globe/navara/groundFloor.test.mjs new file mode 100644 index 000000000..9f80a109b --- /dev/null +++ b/src/globe/navara/groundFloor.test.mjs @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../geo/index.js'; +import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js'; + +const { cartesian3, cartographic } = geo; +const WGS84 = geo.ellipsoid.WGS84; +const at = (lon, lat, height) => cartesian3.fromDegrees(lon, lat, height); +const heightOf = (position) => + cartographic.fromCartesian(position, WGS84).height; +const radiusAt = (lon, lat, height) => + cartesian3.magnitude(at(lon, lat, height)); + +function setup({ ground = 100, terrain, free = true } = {}) { + const state = { + position: at(2.35, 48.85, 5000), + free, + time: 0, + ground, + samples: 0, + lifts: [], + clamps: [], + }; + const floor = createGroundFloor({ + surface: { + sampleHeight: () => { + state.samples += 1; + return state.ground; + }, + terrainHeight: () => terrain, + }, + position: () => state.position, + isFree: () => state.free, + lift: (position) => { + state.lifts.push(position); + state.position = position; + }, + setMinimumZoomDistance: (distance) => state.clamps.push(distance), + now: () => state.time, + }); + return { floor, state }; +} + +test('a camera below the ground plus the margin is lifted straight up', () => { + const { floor, state } = setup({ ground: 100 }); + state.position = at(2.35, 48.85, 40); + assert.equal(floor.update(), true); + assert.equal(state.lifts.length, 1); + const lifted = cartographic.fromCartesian(state.lifts[0], WGS84); + assert.ok(Math.abs(lifted.height - (100 + GROUND_FLOOR.MIN_ABOVE)) < 1e-6); + assert.ok(Math.abs(geo.math.toDegrees(lifted.longitude) - 2.35) < 1e-9); + assert.ok(Math.abs(geo.math.toDegrees(lifted.latitude) - 48.85) < 1e-9); + // Navara's own clamp: the geocentric radius of the ground plus the margin. + assert.equal(state.clamps.length, 1); + assert.ok( + Math.abs( + state.clamps[0] - (radiusAt(2.35, 48.85, 100) + GROUND_FLOOR.MIN_ABOVE), + ) < 1e-6, + ); + // Now at the floor: nothing more to do. + assert.equal(floor.update(), false); + assert.equal(state.lifts.length, 1); +}); + +test('a camera above the floor is left alone', () => { + const { floor, state } = setup({ ground: 100 }); + state.position = at(2.35, 48.85, 1000); + assert.equal(floor.update(), false); + assert.deepEqual(state.lifts, []); + assert.equal(state.clamps.length, 1, 'the clamp still follows the ground'); +}); + +test('the ground falls back to the terrain, then to the ellipsoid', () => { + for (const [terrain, expected] of [ + [250, 250], + [undefined, 0], + ]) { + const { floor, state } = setup({ ground: null, terrain }); + state.position = at(10, 45, -500); + assert.equal(floor.update(), true); + assert.ok( + Math.abs(heightOf(state.position) - (expected + GROUND_FLOOR.MIN_ABOVE)) < + 1e-6, + ); + } +}); + +test('the floor stands aside during a flight or a lookAt and resets the clamp', () => { + const { floor, state } = setup({ ground: 100 }); + state.position = at(2.35, 48.85, 500); + floor.update(); + assert.equal(state.clamps.length, 1); + state.free = false; + state.position = at(2.35, 48.85, 10); + assert.equal(floor.update(), false); + assert.deepEqual(state.lifts, []); + assert.deepEqual( + state.clamps.slice(1), + [undefined], + "Navara's default again", + ); + assert.equal(floor.update(), false); + assert.equal(state.clamps.length, 2, 'reset once'); + state.free = true; + assert.equal(floor.update(), true, 'lifted once the camera is free again'); +}); + +test('above the floor height the ground is not sampled and the clamp is reset', () => { + const { floor, state } = setup({ ground: 100 }); + state.position = at(2.35, 48.85, 500); + floor.update(); + state.position = at(2.35, 48.85, GROUND_FLOOR.MAX_HEIGHT + 1000); + const samples = state.samples; + assert.equal(floor.update(), false); + assert.equal(state.samples, samples); + assert.deepEqual(state.clamps.slice(1), [undefined]); +}); + +test('the ground is resampled after a move or a while, and the clamp only on real change', () => { + const { floor, state } = setup({ ground: 100 }); + state.position = at(2.35, 48.85, 500); + floor.update(); + floor.update(); + assert.equal(state.samples, 1, 'cached within the same place and time'); + state.time = GROUND_FLOOR.RESAMPLE_MS + 1; + state.ground = 105; + floor.update(); + assert.equal(state.samples, 2, 'resampled after RESAMPLE_MS'); + assert.equal(state.clamps.length, 1, 'a 5 m change is not sent'); + state.position = at(2.36, 48.85, 500); + state.ground = 140; + floor.update(); + assert.equal(state.samples, 3, 'resampled after a move'); + assert.equal(state.clamps.length, 2, 'a 40 m change is sent'); +});