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.
This commit is contained in:
German Khokhlov committed 2026-10-15 21:27:57 +04:00
1 parent 4ded3fbf15
commit 64d3297301
6 files changed
+347

No files matched your search

+1
View File
@@ -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",
+14
View File
@@ -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) =>
+14
View File
@@ -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
@@ -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();
});
+121
View File
@@ -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 };
}
+135
View File
@@ -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');
});