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'); +});