mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
fix(globe): the Navara follow orbits on drag and zooms to the floor
Review of Task 5: while following on Navara a left drag panned the globe about the ground point under the cursor (range 2,154 m → 54,136 m) and the wheel zoomed toward that point (462 m after 40 notches, Cesium stops at the 150 m floor). While a follow runs, Navara's spin, zoom and tilt are now suspended (input.suspendCameraInputs, which gives the app's own setCameraInputs choice back after the release) and followGestures.js turns a drag into heading and pitch of the ENU offset (a canvas width is one turn, its height half a turn; elevation kept within 2°-89°) and a wheel notch into 20 % of its range, never inside the minimum range, as Cesium's EntityView orbits the target. The offset is no longer read back from a camera the follow alone moves. Also (review item 7): a follow requests a render only when the target, the offset or a gesture changed, so a still target lets Navara idle. The contract follow check drags (range within ±5 %, target within 3 px) and turns the wheel 40 notches (never below 149 m, ends within 5 % of 150 m) on both engines with real input (qa-globe-contract's pointer gains drag and wheel); after stop it waits for Cesium's gesture inertia to end before checking that the camera is free. Contract: Cesium 73/73, Navara 79/79.
This commit is contained in:
1 parent
68ff4e1c25
commit
4e195dac6b
9 files changed
+362
-15
No files matched your search
@@ -1092,5 +1092,7 @@
|
|||||||
"src/globe/cesium/follow.test.mjs",
|
"src/globe/cesium/follow.test.mjs",
|
||||||
"src/globe/surfaceDetail.js",
|
"src/globe/surfaceDetail.js",
|
||||||
"src/globe/surfaceDetail.test.mjs",
|
"src/globe/surfaceDetail.test.mjs",
|
||||||
"scripts/qa-surface-heights.mjs"
|
"scripts/qa-surface-heights.mjs",
|
||||||
|
"src/globe/navara/followGestures.js",
|
||||||
|
"src/globe/navara/followGestures.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -2564,7 +2564,8 @@
|
|||||||
"src/globe/navara/movers/pack.js",
|
"src/globe/navara/movers/pack.js",
|
||||||
"src/globe/navara/movers/modelMover.js",
|
"src/globe/navara/movers/modelMover.js",
|
||||||
"src/globe/navara/movers/index.js",
|
"src/globe/navara/movers/index.js",
|
||||||
"src/globe/surfaceDetail.js"
|
"src/globe/surfaceDetail.js",
|
||||||
|
"src/globe/navara/followGestures.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
|
|||||||
+96
-6
@@ -11,7 +11,7 @@
|
|||||||
import * as geo from '../geo/index.js';
|
import * as geo from '../geo/index.js';
|
||||||
import { createEngineEvent } from './contract.js';
|
import { createEngineEvent } from './contract.js';
|
||||||
|
|
||||||
const { cartesian3, matrix4 } = geo;
|
const { cartesian3, math, matrix4 } = geo;
|
||||||
const WGS84 = geo.ellipsoid.WGS84;
|
const WGS84 = geo.ellipsoid.WGS84;
|
||||||
|
|
||||||
export const MIN_FOLLOW_RANGE_M = 150;
|
export const MIN_FOLLOW_RANGE_M = 150;
|
||||||
@@ -53,6 +53,7 @@ const scratchInverse = matrix4.create();
|
|||||||
const scratchOffset = cartesian3.create();
|
const scratchOffset = cartesian3.create();
|
||||||
const scratchWorld = cartesian3.create();
|
const scratchWorld = cartesian3.create();
|
||||||
const scratchUp = cartesian3.create();
|
const scratchUp = cartesian3.create();
|
||||||
|
const scratchBefore = cartesian3.create();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One follow step: the camera pose that keeps `target` centred at `offset`
|
* One follow step: the camera pose that keeps `target` centred at `offset`
|
||||||
@@ -149,21 +150,78 @@ export function enuOffset(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Elevation of the camera above the target's horizon: kept between these
|
||||||
|
// (radians), as Cesium's tracked camera stays above the ground plane.
|
||||||
|
const MIN_ELEVATION = math.toRadians(2);
|
||||||
|
const MAX_ELEVATION = math.toRadians(89);
|
||||||
|
|
||||||
|
/** The ENU offset turned by `dHeading` and raised by `dPitch` (radians), same range. */
|
||||||
|
export function orbitOffset(
|
||||||
|
offset,
|
||||||
|
dHeading,
|
||||||
|
dPitch,
|
||||||
|
result = cartesian3.create(),
|
||||||
|
) {
|
||||||
|
const range = cartesian3.magnitude(offset);
|
||||||
|
if (!(range > 0)) return cartesian3.clone(offset, result);
|
||||||
|
const heading = Math.atan2(offset.x, offset.y) + dHeading;
|
||||||
|
const elevation = math.clamp(
|
||||||
|
Math.asin(math.clamp(offset.z / range, -1, 1)) + dPitch,
|
||||||
|
MIN_ELEVATION,
|
||||||
|
MAX_ELEVATION,
|
||||||
|
);
|
||||||
|
const horizontal = range * Math.cos(elevation);
|
||||||
|
return cartesian3.fromElements(
|
||||||
|
horizontal * Math.sin(heading),
|
||||||
|
horizontal * Math.cos(heading),
|
||||||
|
range * Math.sin(elevation),
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ENU offset scaled by `factor`, never inside `minRange`. */
|
||||||
|
export function zoomOffset(
|
||||||
|
offset,
|
||||||
|
factor,
|
||||||
|
minRange,
|
||||||
|
result = cartesian3.create(),
|
||||||
|
) {
|
||||||
|
const range = cartesian3.magnitude(offset);
|
||||||
|
if (!(range > 0) || !(factor > 0)) return cartesian3.clone(offset, result);
|
||||||
|
const next = Math.max(minRange, range * factor);
|
||||||
|
return cartesian3.multiplyByScalar(offset, next / range, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STILL_M = 1e-6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{
|
* @param {{
|
||||||
* camera: { pose: () => object, commanded: object },
|
* camera: { pose: () => object, commanded: object },
|
||||||
* aim: (pose: { position, direction, up }) => void,
|
* aim: (pose: { position, direction, up }) => void,
|
||||||
* frame: { addEventListener: Function },
|
* frame: { addEventListener: Function },
|
||||||
* requestRender: () => void,
|
* requestRender: () => void,
|
||||||
|
* gestures?: (handlers: { orbit: (dHeading: number, dPitch: number) => void,
|
||||||
|
* zoom: (factor: number) => void }) => () => void,
|
||||||
* }} ports `aim` places the camera without camera.commanded; `frame` is
|
* }} ports `aim` places the camera without camera.commanded; `frame` is
|
||||||
* the event the follow steps on (the engine's preRender, after it read
|
* the event the follow steps on (the engine's preRender, after it read
|
||||||
* the user's camera).
|
* the user's camera). `gestures` (Navara): while following, the engine's
|
||||||
|
* own drag and wheel handling is suspended and its drag and wheel turn
|
||||||
|
* and scale the offset instead (as Cesium's EntityView orbits the
|
||||||
|
* target); it returns the release. Without it the offset is re-read from
|
||||||
|
* the camera each frame (the fake engine).
|
||||||
*/
|
*/
|
||||||
export function createFollowController({ camera, aim, frame, requestRender }) {
|
export function createFollowController({
|
||||||
|
camera,
|
||||||
|
aim,
|
||||||
|
frame,
|
||||||
|
requestRender,
|
||||||
|
gestures = null,
|
||||||
|
}) {
|
||||||
const followChanged = createEngineEvent();
|
const followChanged = createEngineEvent();
|
||||||
let current = null;
|
let current = null;
|
||||||
let removeFrame = null;
|
let removeFrame = null;
|
||||||
let removeCommanded = null;
|
let removeCommanded = null;
|
||||||
|
let releaseGestures = null;
|
||||||
let aiming = false;
|
let aiming = false;
|
||||||
|
|
||||||
const end = (handle) => {
|
const end = (handle) => {
|
||||||
@@ -171,10 +229,29 @@ export function createFollowController({ camera, aim, frame, requestRender }) {
|
|||||||
current = null;
|
current = null;
|
||||||
removeFrame?.();
|
removeFrame?.();
|
||||||
removeCommanded?.();
|
removeCommanded?.();
|
||||||
|
releaseGestures?.();
|
||||||
removeFrame = null;
|
removeFrame = null;
|
||||||
removeCommanded = null;
|
removeCommanded = null;
|
||||||
|
releaseGestures = null;
|
||||||
followChanged.raiseEvent(null);
|
followChanged.raiseEvent(null);
|
||||||
};
|
};
|
||||||
|
// Drag and wheel (gestures port): applied at the next step.
|
||||||
|
const gestureHandlers = {
|
||||||
|
orbit(dHeading, dPitch) {
|
||||||
|
const handle = current;
|
||||||
|
if (!handle?.offset) return;
|
||||||
|
orbitOffset(handle.offset, dHeading, dPitch, handle.offset);
|
||||||
|
handle.moved = true;
|
||||||
|
requestRender();
|
||||||
|
},
|
||||||
|
zoom(factor) {
|
||||||
|
const handle = current;
|
||||||
|
if (!handle?.offset) return;
|
||||||
|
zoomOffset(handle.offset, factor, handle.options.minRange, handle.offset);
|
||||||
|
handle.moved = true;
|
||||||
|
requestRender();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const step = () => {
|
const step = () => {
|
||||||
const handle = current;
|
const handle = current;
|
||||||
@@ -186,14 +263,24 @@ export function createFollowController({ camera, aim, frame, requestRender }) {
|
|||||||
target = null;
|
target = null;
|
||||||
}
|
}
|
||||||
if (!isCartesian(target)) return;
|
if (!isCartesian(target)) return;
|
||||||
|
let changed = handle.moved || !handle.previous;
|
||||||
|
handle.moved = false;
|
||||||
if (!handle.offset) {
|
if (!handle.offset) {
|
||||||
const from = handle.options.viewFrom();
|
const from = handle.options.viewFrom();
|
||||||
if (!isCartesian(from)) return;
|
if (!isCartesian(from)) return;
|
||||||
handle.offset = cartesian3.clone(from);
|
handle.offset = cartesian3.clone(from);
|
||||||
} else if (handle.previous) {
|
changed = true;
|
||||||
|
} else if (handle.previous && !gestures) {
|
||||||
// The user may have turned or zoomed the camera since the last step.
|
// The user may have turned or zoomed the camera since the last step.
|
||||||
|
const before = cartesian3.clone(handle.offset, scratchBefore);
|
||||||
enuOffset(handle.previous, camera.pose().position, handle.offset);
|
enuOffset(handle.previous, camera.pose().position, handle.offset);
|
||||||
|
if (cartesian3.distance(before, handle.offset) > STILL_M) changed = true;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
handle.previous &&
|
||||||
|
cartesian3.distance(handle.previous, target) > STILL_M
|
||||||
|
)
|
||||||
|
changed = true;
|
||||||
const pose = followPose(target, handle.offset, handle.options.minRange);
|
const pose = followPose(target, handle.offset, handle.options.minRange);
|
||||||
handle.previous = cartesian3.clone(target, handle.previous ?? undefined);
|
handle.previous = cartesian3.clone(target, handle.previous ?? undefined);
|
||||||
// The offset in force (the minimum range applied).
|
// The offset in force (the minimum range applied).
|
||||||
@@ -204,8 +291,9 @@ export function createFollowController({ camera, aim, frame, requestRender }) {
|
|||||||
} finally {
|
} finally {
|
||||||
aiming = false;
|
aiming = false;
|
||||||
}
|
}
|
||||||
// A moving target needs the next frame.
|
// A moving target (or a gesture) needs the next frame; a still one
|
||||||
requestRender();
|
// lets the engine idle.
|
||||||
|
if (changed) requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -231,6 +319,8 @@ export function createFollowController({ camera, aim, frame, requestRender }) {
|
|||||||
};
|
};
|
||||||
current = handle;
|
current = handle;
|
||||||
removeFrame = frame.addEventListener(step);
|
removeFrame = frame.addEventListener(step);
|
||||||
|
if (gestures && !releaseGestures)
|
||||||
|
releaseGestures = gestures(gestureHandlers);
|
||||||
removeCommanded = camera.commanded.addEventListener(() => {
|
removeCommanded = camera.commanded.addEventListener(() => {
|
||||||
if (!aiming) end(handle);
|
if (!aiming) end(handle);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -127,3 +127,75 @@ test('stop ends the follow; a new follow replaces the old one', () => {
|
|||||||
TypeError,
|
TypeError,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('with engine gestures, drag orbits at the same range and wheel stops at the floor', () => {
|
||||||
|
let pose = {
|
||||||
|
position: cartesian3.fromDegrees(0, 0, 1e7),
|
||||||
|
direction: cartesian3.create(-1, 0, 0),
|
||||||
|
up: cartesian3.create(0, 0, 1),
|
||||||
|
};
|
||||||
|
const commanded = createEngineEvent();
|
||||||
|
const frame = createEngineEvent();
|
||||||
|
let handlers = null;
|
||||||
|
let suspended = 0;
|
||||||
|
const controller = createFollowController({
|
||||||
|
camera: { pose: () => pose, commanded },
|
||||||
|
aim: (next) => {
|
||||||
|
pose = next;
|
||||||
|
},
|
||||||
|
frame,
|
||||||
|
requestRender: () => {},
|
||||||
|
gestures: (next) => {
|
||||||
|
handlers = next;
|
||||||
|
suspended++;
|
||||||
|
return () => {
|
||||||
|
handlers = null;
|
||||||
|
suspended--;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const handle = controller.follow({
|
||||||
|
position: () => target0,
|
||||||
|
viewFrom: cartesian3.create(0, -1000, 500),
|
||||||
|
});
|
||||||
|
assert.equal(suspended, 1, 'the engine gestures are taken');
|
||||||
|
frame.raiseEvent();
|
||||||
|
const range = () => cartesian3.distance(pose.position, target0);
|
||||||
|
const start = range();
|
||||||
|
handlers.orbit(0.8, 0.3);
|
||||||
|
frame.raiseEvent();
|
||||||
|
assert.ok(Math.abs(range() - start) < 1e-6 * start, 'orbit keeps the range');
|
||||||
|
const offset = enuOffset(target0, pose.position);
|
||||||
|
assert.ok(Math.abs(offset.x) > 100, 'the heading turned');
|
||||||
|
// A camera move nobody commanded (the engine's own controller) is not
|
||||||
|
// read back while the gestures are the follow's.
|
||||||
|
pose = { ...pose, position: cartesian3.fromDegrees(10, 10, 1e6) };
|
||||||
|
frame.raiseEvent();
|
||||||
|
assert.ok(Math.abs(range() - start) < 1e-6 * start);
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
handlers.zoom(0.5);
|
||||||
|
frame.raiseEvent();
|
||||||
|
assert.ok(range() >= MIN_FOLLOW_RANGE_M - 1e-6, `range ${range()}`);
|
||||||
|
}
|
||||||
|
assert.ok(Math.abs(range() - MIN_FOLLOW_RANGE_M) < 1e-6, 'at the floor');
|
||||||
|
// Pitch stays above the target's horizon and short of straight down.
|
||||||
|
handlers.orbit(0, 10);
|
||||||
|
frame.raiseEvent();
|
||||||
|
const up = enuOffset(target0, pose.position);
|
||||||
|
assert.ok(up.z > 0 && up.z < MIN_FOLLOW_RANGE_M, 'pitch clamped');
|
||||||
|
handle.stop();
|
||||||
|
assert.equal(suspended, 0, 'the gestures are given back');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a still target and offset request no renders', () => {
|
||||||
|
const h = harness();
|
||||||
|
h.controller.follow({
|
||||||
|
position: () => target0,
|
||||||
|
viewFrom: cartesian3.create(0, -1000, 500),
|
||||||
|
});
|
||||||
|
h.frame();
|
||||||
|
h.frame();
|
||||||
|
const settled = h.renders();
|
||||||
|
for (let i = 0; i < 5; i++) h.frame();
|
||||||
|
assert.equal(h.renders(), settled, 'idle while nothing moves');
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import { createNavaraCredits } from './credits.js';
|
|||||||
import { createNavaraInput } from './input.js';
|
import { createNavaraInput } from './input.js';
|
||||||
import { createNavaraPicking } from './picking.js';
|
import { createNavaraPicking } from './picking.js';
|
||||||
import { createFollowController } from '../follow.js';
|
import { createFollowController } from '../follow.js';
|
||||||
|
import { navaraFollowGestures } from './followGestures.js';
|
||||||
import { createNavaraMaps } from './maps.js';
|
import { createNavaraMaps } from './maps.js';
|
||||||
import { createNavaraSurface } from './surface.js';
|
import { createNavaraSurface } from './surface.js';
|
||||||
import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js';
|
import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js';
|
||||||
@@ -153,6 +154,11 @@ export function createNavaraEngine(
|
|||||||
aim: (pose) => model.aim(pose),
|
aim: (pose) => model.aim(pose),
|
||||||
frame: followStep,
|
frame: followStep,
|
||||||
requestRender: () => view.forceUpdate(),
|
requestRender: () => view.forceUpdate(),
|
||||||
|
// Drag orbits and wheel scales the follow's offset (followGestures.js).
|
||||||
|
gestures: (handlers) =>
|
||||||
|
navaraFollowGestures(view.canvas, () =>
|
||||||
|
input.input.suspendCameraInputs(),
|
||||||
|
)(handlers),
|
||||||
});
|
});
|
||||||
Object.assign(model.camera, {
|
Object.assign(model.camera, {
|
||||||
follow: (options) => follow.follow(options),
|
follow: (options) => follow.follow(options),
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// The follow camera's drag and wheel on Navara (src/globe/follow.js
|
||||||
|
// `gestures`): while following, Navara's own spin, zoom and tilt are off
|
||||||
|
// (they pan about the ground point under the cursor and zoom toward it) and
|
||||||
|
// a left drag turns the offset around the target, a wheel scales its range,
|
||||||
|
// as Cesium's tracked camera does: a drag across the whole canvas width is
|
||||||
|
// one turn of heading, across its height half a turn of pitch; one wheel
|
||||||
|
// notch (deltaY 100) is 20 % of the range.
|
||||||
|
|
||||||
|
const WHEEL_STEP = 1.2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLCanvasElement} canvas
|
||||||
|
* @param {() => () => void} suspendCameraInputs The engine input's.
|
||||||
|
* @param {Window | EventTarget} [windowTarget] Where moves and releases
|
||||||
|
* outside the canvas arrive.
|
||||||
|
* @returns {(handlers: { orbit: Function, zoom: Function }) => () => void}
|
||||||
|
*/
|
||||||
|
export function navaraFollowGestures(
|
||||||
|
canvas,
|
||||||
|
suspendCameraInputs,
|
||||||
|
windowTarget = globalThis.window ?? canvas,
|
||||||
|
) {
|
||||||
|
return ({ orbit, zoom }) => {
|
||||||
|
const resume = suspendCameraInputs();
|
||||||
|
let last = null;
|
||||||
|
const down = (event) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
last = { x: event.clientX, y: event.clientY };
|
||||||
|
};
|
||||||
|
const move = (event) => {
|
||||||
|
if (!last) return;
|
||||||
|
const dx = event.clientX - last.x;
|
||||||
|
const dy = event.clientY - last.y;
|
||||||
|
last = { x: event.clientX, y: event.clientY };
|
||||||
|
const width = canvas.clientWidth || 1;
|
||||||
|
const height = canvas.clientHeight || 1;
|
||||||
|
orbit((-dx / width) * 2 * Math.PI, (dy / height) * Math.PI);
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
last = null;
|
||||||
|
};
|
||||||
|
const wheel = (event) => {
|
||||||
|
if (!Number.isFinite(event.deltaY) || event.deltaY === 0) return;
|
||||||
|
event.preventDefault?.();
|
||||||
|
zoom(WHEEL_STEP ** (event.deltaY / 100));
|
||||||
|
};
|
||||||
|
canvas.addEventListener('pointerdown', down);
|
||||||
|
windowTarget.addEventListener('pointermove', move);
|
||||||
|
windowTarget.addEventListener('pointerup', up);
|
||||||
|
canvas.addEventListener('wheel', wheel, { passive: false });
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener('pointerdown', down);
|
||||||
|
windowTarget.removeEventListener('pointermove', move);
|
||||||
|
windowTarget.removeEventListener('pointerup', up);
|
||||||
|
canvas.removeEventListener('wheel', wheel, { passive: false });
|
||||||
|
resume();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { navaraFollowGestures } from './followGestures.js';
|
||||||
|
|
||||||
|
const event = (type, fields = {}) =>
|
||||||
|
Object.assign(new Event(type, { cancelable: true }), fields);
|
||||||
|
|
||||||
|
test('drag turns, wheel scales, and Navara inputs are off until release', () => {
|
||||||
|
const canvas = new EventTarget();
|
||||||
|
canvas.clientWidth = 1000;
|
||||||
|
canvas.clientHeight = 500;
|
||||||
|
const win = new EventTarget();
|
||||||
|
let suspended = 0;
|
||||||
|
const gestures = navaraFollowGestures(
|
||||||
|
canvas,
|
||||||
|
() => {
|
||||||
|
suspended++;
|
||||||
|
return () => suspended--;
|
||||||
|
},
|
||||||
|
win,
|
||||||
|
);
|
||||||
|
const calls = [];
|
||||||
|
const release = gestures({
|
||||||
|
orbit: (h, p) => calls.push(['orbit', h, p]),
|
||||||
|
zoom: (f) => calls.push(['zoom', f]),
|
||||||
|
});
|
||||||
|
assert.equal(suspended, 1);
|
||||||
|
canvas.dispatchEvent(
|
||||||
|
event('pointerdown', { button: 0, clientX: 100, clientY: 100 }),
|
||||||
|
);
|
||||||
|
win.dispatchEvent(event('pointermove', { clientX: 350, clientY: 225 }));
|
||||||
|
win.dispatchEvent(event('pointerup'));
|
||||||
|
win.dispatchEvent(event('pointermove', { clientX: 0, clientY: 0 }));
|
||||||
|
const wheel = event('wheel', { deltaY: 100 });
|
||||||
|
canvas.dispatchEvent(wheel);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.ok(Math.abs(calls[0][1] + Math.PI / 2) < 1e-12, 'quarter turn');
|
||||||
|
assert.ok(Math.abs(calls[0][2] - Math.PI / 4) < 1e-12);
|
||||||
|
assert.deepEqual(calls[1], ['zoom', 1.2]);
|
||||||
|
assert.equal(wheel.defaultPrevented, true);
|
||||||
|
release();
|
||||||
|
assert.equal(suspended, 0);
|
||||||
|
canvas.dispatchEvent(event('wheel', { deltaY: 100 }));
|
||||||
|
assert.equal(calls.length, 2, 'no handling after release');
|
||||||
|
});
|
||||||
@@ -111,6 +111,15 @@ export function createNavaraInput(
|
|||||||
passive: true,
|
passive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let suspended = 0;
|
||||||
|
const sendCameraInputs = () => {
|
||||||
|
const enabled = cameraInputs && suspended === 0;
|
||||||
|
input.setCameraOptions({
|
||||||
|
enableSpin: enabled,
|
||||||
|
enableZoom: enabled,
|
||||||
|
enableTilt: enabled,
|
||||||
|
});
|
||||||
|
};
|
||||||
const input = {
|
const input = {
|
||||||
on(type, listener, options = {}) {
|
on(type, listener, options = {}) {
|
||||||
const { modifier, pick, drill } = normalizeInputOptions(type, options);
|
const { modifier, pick, drill } = normalizeInputOptions(type, options);
|
||||||
@@ -128,11 +137,23 @@ export function createNavaraInput(
|
|||||||
},
|
},
|
||||||
setCameraInputs(enabled) {
|
setCameraInputs(enabled) {
|
||||||
cameraInputs = Boolean(enabled);
|
cameraInputs = Boolean(enabled);
|
||||||
input.setCameraOptions({
|
sendCameraInputs();
|
||||||
enableSpin: cameraInputs,
|
},
|
||||||
enableZoom: cameraInputs,
|
/**
|
||||||
enableTilt: cameraInputs,
|
* Navara-only: Navara's spin, zoom and tilt off until the returned
|
||||||
});
|
* release (the follow camera takes drag and wheel itself); the app's
|
||||||
|
* setCameraInputs choice comes back after the last release.
|
||||||
|
*/
|
||||||
|
suspendCameraInputs() {
|
||||||
|
suspended++;
|
||||||
|
sendCameraInputs();
|
||||||
|
let released = false;
|
||||||
|
return () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
suspended--;
|
||||||
|
sendCameraInputs();
|
||||||
|
};
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Navara-only extension: camera controller options (zoom distances,
|
* Navara-only extension: camera controller options (zoom distances,
|
||||||
|
|||||||
@@ -2588,6 +2588,43 @@ export function createContractSuite(
|
|||||||
`the target drifted ${worst.toFixed(2)} px off centre`,
|
`the target drifted ${worst.toFixed(2)} px off centre`,
|
||||||
);
|
);
|
||||||
ensure(step - firstStep >= 120, 'the target did not move');
|
ensure(step - firstStep >= 120, 'the target did not move');
|
||||||
|
if (pointer?.drag && pointer?.wheel) {
|
||||||
|
// A left drag orbits the target: same range (±5 %), centred.
|
||||||
|
const { width, height } = engine.host.size();
|
||||||
|
const before = range();
|
||||||
|
await pointer.drag(
|
||||||
|
width / 2,
|
||||||
|
height / 2,
|
||||||
|
width / 2 + 200,
|
||||||
|
height / 2 + 60,
|
||||||
|
);
|
||||||
|
await settle(4);
|
||||||
|
ensure(camera.following() === handle, 'a drag ended the follow');
|
||||||
|
ensure(
|
||||||
|
Math.abs(range() - before) <= 0.05 * before,
|
||||||
|
`a drag changed the range ${before.toFixed(0)} → ${range().toFixed(0)} m`,
|
||||||
|
);
|
||||||
|
ensure(offCentre() <= 3, 'the target left the centre after a drag');
|
||||||
|
// The wheel zooms toward the target down to the floor, never below.
|
||||||
|
let lowest = Infinity;
|
||||||
|
for (let notch = 0; notch < 40; notch++) {
|
||||||
|
await pointer.wheel(width / 2, height / 2, -100);
|
||||||
|
await settle(2);
|
||||||
|
lowest = Math.min(lowest, range());
|
||||||
|
}
|
||||||
|
await settle(4);
|
||||||
|
lowest = Math.min(lowest, range());
|
||||||
|
ensure(lowest >= 149, `the wheel reached ${lowest.toFixed(1)} m`);
|
||||||
|
ensure(
|
||||||
|
range() <= 150 * 1.05,
|
||||||
|
`40 wheel notches stopped at ${range().toFixed(1)} m, not the 150 m floor`,
|
||||||
|
);
|
||||||
|
ensure(
|
||||||
|
offCentre() <= 3,
|
||||||
|
'the target left the centre after the wheel',
|
||||||
|
);
|
||||||
|
ensure(camera.following() === handle, 'the wheel ended the follow');
|
||||||
|
}
|
||||||
// A zoom through the target stops at the minimum range.
|
// A zoom through the target stops at the minimum range.
|
||||||
camera.zoomIn(5000);
|
camera.zoomIn(5000);
|
||||||
await settle(4);
|
await settle(4);
|
||||||
@@ -2599,12 +2636,26 @@ export function createContractSuite(
|
|||||||
ensure(camera.following() === null, 'following() after stop');
|
ensure(camera.following() === null, 'following() after stop');
|
||||||
ensure(changes.at(-1) === null, 'followChanged did not report null');
|
ensure(changes.at(-1) === null, 'followChanged did not report null');
|
||||||
camera.release();
|
camera.release();
|
||||||
await settle();
|
// Gesture inertia (Cesium) may carry the camera a few frames:
|
||||||
|
// wait for it to rest, then the target moves on without it.
|
||||||
|
let last = camera.pose().position;
|
||||||
|
let stillFrames = 0;
|
||||||
|
await until(() => {
|
||||||
|
const now = camera.pose().position;
|
||||||
|
stillFrames =
|
||||||
|
cartesian3.distance(now, last) < 1e-3 ? stillFrames + 1 : 0;
|
||||||
|
last = now;
|
||||||
|
return stillFrames >= 5;
|
||||||
|
}, 'the camera at rest after stop');
|
||||||
const free = camera.pose().position;
|
const free = camera.pose().position;
|
||||||
await settle(10);
|
await settle(10);
|
||||||
ensure(
|
ensure(
|
||||||
cartesian3.distance(camera.pose().position, free) < 1e-3,
|
cartesian3.distance(camera.pose().position, free) < 1e-3,
|
||||||
'the camera still follows after stop',
|
`the camera still moves after stop (${cartesian3
|
||||||
|
.distance(camera.pose().position, free)
|
||||||
|
.toFixed(2)} m in 10 frames; following ${Boolean(
|
||||||
|
camera.following(),
|
||||||
|
)})`,
|
||||||
);
|
);
|
||||||
// A command that takes the camera ends the follow.
|
// A command that takes the camera ends the follow.
|
||||||
camera.follow({
|
camera.follow({
|
||||||
|
|||||||
Reference in new issue
Block a user