Orbit, move, look and zoom through the camera port

The Cesium camera port gains move, look, rotateAround and zoom, and the
browser conformance run now checks them, the lookAt-frame commands, the
changed thresholds and moveEnd (timed by the clock, not a frame count).
Orbit, the Bhote Koshi focus and cinematic, the launch zoom anchor and
replay chase, the camera verbs' motion tick and roll levelling, and
voice zoom write through the port.
This commit is contained in:
Sameh Khamis committed 2026-11-06 10:21:04 -08:00
1 parent 68855b2089
commit 1d9c731c10
19 files changed
+174 -112

No files matched your search

+3 -2
View File
@@ -1,3 +1,4 @@
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
import { GLOBE_VIEW } from './locations.js';
import { projectEarthDiscToViewport } from './celestialRing.js';
import assert from 'node:assert/strict';
@@ -146,13 +147,13 @@ function harness(
roll: radians(roll),
},
});
const viewer = {
const viewer = bindFakeCesiumCamera({
camera,
scene,
trackedEntity: undefined,
trackedEntityChanged: new Cesium.Event(),
clock: { onTick: new Cesium.Event() },
};
});
const target = Cesium.Cartesian3.fromDegrees(
longitudeDeg,
latitude,
+23 -21
View File
@@ -30,6 +30,8 @@ import {
releaseContinuousRender,
} from './renderGovernor.js';
import { heightsFor } from './queries/heights.js';
import { cameraOf } from './render/cesium/camera.js';
import { findCamera } from './render/rendererOf.js';
let _isCockpitActive = null;
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
@@ -1004,7 +1006,7 @@ export function interruptCameraMotionIfActive(
function clearLookAt() {
try {
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
findCamera(_viewer)?.release();
} catch {
/* teardown race */
}
@@ -1022,13 +1024,11 @@ function clearLookAt() {
*/
function levelCameraRoll() {
try {
const cam = _viewer?.camera;
if (!cam || typeof cam.setView !== 'function') return false;
const roll = Cesium.Math.negativePiToPi(cam.roll || 0);
if (Math.abs(roll) < 1e-5) return false;
cam.setView({
orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },
});
const camera = findCamera(_viewer);
if (!camera) return false;
const { heading, pitch, roll } = camera.pose();
if (Math.abs(Cesium.Math.negativePiToPi(roll || 0)) < 1e-5) return false;
camera.setView({ orientation: { heading, pitch, roll: 0 } });
return true;
} catch {
return false; // teardown race
@@ -1081,6 +1081,9 @@ export function getActiveCameraMotion() {
return info;
}
/** A pan's direction as the camera move it makes; any other is down. */
const PAN_MOVES = Object.freeze({ left: 'left', right: 'right', up: 'up' });
function onTick() {
if (!_active || !_viewer) return;
// Wall-clock dt: clock.currentTime FREEZES when the app clock isn't
@@ -1091,6 +1094,8 @@ function onTick() {
Math.max(0.001, (nowMs - (_active.lastMs ?? nowMs)) / 1000),
);
_active.lastMs = nowMs;
// Reads stay on Cesium's camera until the query port has them; every
// write goes through the camera port.
const cam = _viewer.camera;
const m = _active;
@@ -1106,6 +1111,7 @@ function onTick() {
}
try {
const camera = cameraOf(_viewer);
if (m.kind === 'orbit' && m.pending) {
// Armed but target-less (started mid-flight, e.g. "fly to X and orbit
// it"): keep trying for a ground target until the camera settles.
@@ -1155,7 +1161,7 @@ function onTick() {
} else {
m.hpr.heading += rate * dt;
}
cam.lookAt(m.target, m.hpr);
camera.lookAt(m.target, m.hpr);
} else if (m.kind === 'pan') {
const heightM = Math.max(50, cam.positionCartographic.height);
const step =
@@ -1163,10 +1169,7 @@ function onTick() {
? heightM * ONCE.panViewFraction * (scale - (m.lastScale || 0))
: heightM * PAN_VIEW_FRACTION_S[m.speed] * dt;
m.lastScale = scale;
if (m.direction === 'left') cam.moveLeft(step);
else if (m.direction === 'right') cam.moveRight(step);
else if (m.direction === 'up') cam.moveUp(step);
else cam.moveDown(step);
camera.move(PAN_MOVES[m.direction] ?? 'down', step);
} else if (
m.kind === 'tilt' ||
m.kind === 'rotate' ||
@@ -1211,7 +1214,7 @@ function onTick() {
interruptCameraMotion('globe-surface-limit');
return;
}
cam.rotate(m.rotationAxis, angle);
camera.rotateAround(m.rotationAxis, angle);
} else if (m.kind === 'tilt') {
const up = m.direction === 'up';
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
@@ -1220,10 +1223,8 @@ function onTick() {
interruptCameraMotion('tilt-clamp');
return;
}
if (up) cam.lookUp(stepRad);
else cam.lookDown(stepRad);
} else if (m.direction === 'left') cam.lookLeft(stepRad);
else cam.lookRight(stepRad);
camera.look(up ? 'up' : 'down', stepRad);
} else camera.look(m.direction === 'left' ? 'left' : 'right', stepRad);
} else if (m.kind === 'route') {
// The dolly's shaping lives in advanceRouteFlight(); the tick only
// applies the pose and honours the same completion path as before.
@@ -1232,7 +1233,7 @@ function onTick() {
// user left it rather than teleporting to an altitude derived from
// nothing. An interrupt during the arm is still instant.
if (frame.arming) return;
cam.setView({
camera.setView({
destination: frame.eye,
orientation: { direction: frame.direction, up: frame.up },
});
@@ -1612,9 +1613,10 @@ export function moveCamera(args = {}, runNavigation = null) {
if (motion === 'rotate_globe') {
// Convert an inherited ground-target frame without changing the world
// pose. Rotation must pivot about Earth, never the previous local target.
const camera = cameraOf(_viewer);
camera.cancelFlight();
camera.release();
const cam = _viewer.camera;
cam.cancelFlight();
cam.lookAtTransform(Cesium.Matrix4.IDENTITY);
// Handoff may level a route's bank. Keep the caller's exact world pose.
// Copy the basis directly: setView's heading/pitch conversion is singular
// at nadir and can change the captured orientation near the poles.
+23 -18
View File
@@ -29,6 +29,8 @@ import { formatDate, t, tn, msg, onLocaleChange } from '../i18n/index.js';
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
import { applicationPinia } from '../ui/vueRuntime.js';
import { heightsFor } from '../queries/heights.js';
import { cameraOf } from '../render/cesium/camera.js';
import { findCamera } from '../render/rendererOf.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -888,20 +890,24 @@ function focusView(viewer, viewpoint) {
viewpoint.lat,
viewpoint.targetElevationM,
);
const offset = new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(viewpoint.headingDeg),
Cesium.Math.toRadians(viewpoint.pitchDeg),
viewpoint.rangeM,
);
viewer.camera.cancelFlight?.();
viewer.camera.flyToBoundingSphere(new Cesium.BoundingSphere(target, 80), {
offset,
duration: 2.2,
complete: () => {
viewer.camera.lookAt(target, offset);
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
const offset = {
heading: Cesium.Math.toRadians(viewpoint.headingDeg),
pitch: Cesium.Math.toRadians(viewpoint.pitchDeg),
range: viewpoint.rangeM,
};
const camera = cameraOf(viewer);
camera.cancelFlight();
camera.flyToBounds(
{ center: target, radius: 80 },
{
offset,
durationS: 2.2,
onComplete: () => {
camera.lookAt(target, offset);
camera.release();
},
},
});
);
}
function setElementProperty(element, property, value) {
@@ -1045,7 +1051,7 @@ export function createBhoteKoshiEventLayer({
let _cinematicActive = false;
let _cinematicReplayArmed = false;
let _cinematicPose = { target: new Cesium.Cartesian3() };
let _cinematicOffset = new Cesium.HeadingPitchRange();
const _cinematicOffset = { heading: 0, pitch: 0, range: 0 };
let _lastCinematicProgress = null;
let _cameraInputTarget = null;
let _evidenceClickTarget = null;
@@ -2626,7 +2632,7 @@ export function createBhoteKoshiEventLayer({
_cinematicOffset.heading = Cesium.Math.toRadians(pose.headingDeg);
_cinematicOffset.pitch = Cesium.Math.toRadians(pose.pitchDeg);
_cinematicOffset.range = pose.rangeM;
_viewer.camera.lookAt(pose.target, _cinematicOffset);
findCamera(_viewer)?.lookAt(pose.target, _cinematicOffset);
_lastCinematicProgress = _progress;
}
@@ -2696,7 +2702,7 @@ export function createBhoteKoshiEventLayer({
_cinematicActive = true;
_lastCinematicProgress = null;
announceNavigationAuthority('bhote-koshi-cinematic');
_viewer?.camera?.cancelFlight?.();
findCamera(_viewer)?.cancelFlight();
}
syncCinematicButton();
return true;
@@ -2713,8 +2719,7 @@ export function createBhoteKoshiEventLayer({
}
_cinematicActive = false;
_lastCinematicProgress = null;
if (releaseCamera)
_viewer?.camera?.lookAtTransform?.(Cesium.Matrix4.IDENTITY);
if (releaseCamera) findCamera(_viewer)?.release();
syncCinematicButton();
renderHost.request('bhote-koshi-cinematic-stop');
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
@@ -239,7 +240,7 @@ function eventViewer(splitPosition = 0.37) {
const canvas = new FakeElement('canvas');
const cameraLooks = [];
const cameraTransforms = [];
return {
return bindFakeCesiumCamera({
scene: { splitPosition, canvas },
camera: {
cancelFlight() {},
@@ -272,7 +273,7 @@ function eventViewer(splitPosition = 0.37) {
cameraLooks,
cameraTransforms,
},
};
});
}
test('public lifecycle restores Nepal scene ownership without forwarding origin to enable', async () => {
+17 -17
View File
@@ -1,6 +1,7 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { cameraOf } from '../../render/cesium/camera.js';
import { findCamera } from '../../render/rendererOf.js';
import {
REPLAY_ASCENT_FALLBACK_SEC,
REPLAY_ASCENT_MIN_SEC,
@@ -315,9 +316,9 @@ export function createReplay({ state: layerState, services, parts, source }) {
layerState._replayCameraToken++;
if (layerState._replayCameraRemover) layerState._replayCameraRemover();
layerState._replayCameraRemover = null;
layerState._viewer?.camera?.cancelFlight();
if (layerState._viewer?.camera)
layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
const camera = findCamera(layerState._viewer);
camera?.cancelFlight();
camera?.release();
layerState._replayCameraLaunchId = null;
layerState._replayPaused = false;
layerState._replayPausedAtMs = null;
@@ -354,7 +355,8 @@ export function createReplay({ state: layerState, services, parts, source }) {
parts.overlays.syncMissionOverlayEntries();
parts.policyHelpers.releaseAircraftTracking(layerState._dataManager);
layerState._viewer.trackedEntity = undefined;
cameraOf(layerState._viewer).cancelFlight();
const camera = cameraOf(layerState._viewer);
camera.cancelFlight();
layerState._animationStarts.set(
launchId,
Date.now() +
@@ -374,6 +376,9 @@ export function createReplay({ state: layerState, services, parts, source }) {
)
return;
const state = track.beginReplayFrame();
// Asked for each frame, not kept: a renderer bound meanwhile
// replaces the port.
const chaseCamera = cameraOf(layerState._viewer);
syncReplayCountdownButton(state);
if (!cameraReady) return;
const path = state.ascending
@@ -432,14 +437,11 @@ export function createReplay({ state: layerState, services, parts, source }) {
)
: cameraView.range;
if (state.ascending) {
layerState._viewer.camera.lookAt(
cameraTarget,
new Cesium.HeadingPitchRange(
track.lastCameraHeading,
cameraView.pitch,
cameraRange,
),
);
chaseCamera.lookAt(cameraTarget, {
heading: track.lastCameraHeading,
pitch: cameraView.pitch,
range: cameraRange,
});
} else {
const tangentStep = Math.max(
0.002,
@@ -464,12 +466,10 @@ export function createReplay({ state: layerState, services, parts, source }) {
);
if (orbitPose) {
if (!track.orbitCameraWorldFrame) {
layerState._viewer.camera.lookAtTransform(
Cesium.Matrix4.IDENTITY,
);
chaseCamera.release();
track.orbitCameraWorldFrame = true;
}
layerState._viewer.camera.setView({
chaseCamera.setView({
destination: orbitPose.destination,
orientation: {
direction: orbitPose.direction,
@@ -486,7 +486,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
}
});
cameraOf(layerState._viewer).flyToBounds(
camera.flyToBounds(
{ center: track.ascentPath[0], radius: 0 },
{
offset: {
+8 -11
View File
@@ -1,6 +1,7 @@
import * as Cesium from 'cesium';
import { MISSION_GLOBE_VIEW_RANGE_M, MISSION_FOCUS_RANGE_M } from './policy.js';
import { cameraOf } from '../../render/cesium/camera.js';
import { findCamera } from '../../render/rendererOf.js';
export function createSelection({
state: layerState,
@@ -94,9 +95,8 @@ export function createSelection({
layerState._missionZoomAnchorRemover();
layerState._missionZoomAnchorRemover = null;
layerState._missionZoomAnchorId = null;
if (layerState._viewer?.camera && !layerState._replayCameraLaunchId) {
layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
}
if (!layerState._replayCameraLaunchId)
findCamera(layerState._viewer)?.release();
}
function startMissionZoomAnchor(launch, initialRange) {
@@ -109,14 +109,11 @@ export function createSelection({
Number(initialRange) || MISSION_GLOBE_VIEW_RANGE_M,
);
layerState._missionZoomAnchorId = launch.id;
layerState._viewer.camera.lookAt(
target,
new Cesium.HeadingPitchRange(
0,
parts.camera.missionZoomPitch(range),
range,
),
);
cameraOf(layerState._viewer).lookAt(target, {
heading: 0,
pitch: parts.camera.missionZoomPitch(range),
range,
});
}
function focusMission(launch) {
+17 -13
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { toRadians } from './geo/index.js';
import { cameraOf, findCamera } from './render/rendererOf.js';
import {
holdContinuousRender,
releaseContinuousRender,
@@ -16,14 +17,15 @@ export class OrbitController {
this.target = null;
this.radius = 500;
this.pitch = -30;
this.speed = Cesium.Math.toRadians(6); // ~6°/sec → full rotation in ~60s
this.speed = toRadians(6); // ~6°/sec → full rotation in ~60s
this.angle = 0;
this._removeListener = null;
}
/**
* Start orbiting around a target position.
* @param {Cesium.Cartesian3} targetCartesian - The point to orbit around
* @param {{x: number, y: number, z: number}} targetCartesian - The point
* to orbit around, ECEF metres
* @param {object} options
* @param {number} options.radius - Distance from target in meters
* @param {number} options.pitch - Tilt angle in degrees (negative = looking down)
@@ -31,18 +33,21 @@ export class OrbitController {
*/
start(targetCartesian, options = {}) {
if (!targetCartesian) return;
// Asked for each frame, not kept: a renderer bound meanwhile replaces
// the port.
const pose = cameraOf(this.viewer).pose();
this.target = targetCartesian;
this.radius = options.radius || this.radius;
this.pitch = options.pitch || this.pitch;
this.speed = Cesium.Math.toRadians(options.speed || 6);
this.speed = toRadians(options.speed || 6);
this.active = true;
// Orbit mutates the camera from preRender — without a hold the loop
// starves after its first idle frame. (perf wave 2)
holdContinuousRender('camera-orbit');
// Start from the camera's current heading for seamless transition
this.angle = this.viewer.camera.heading;
this.angle = pose.heading;
let lastTime = Date.now();
this._removeListener = this.viewer.scene.preRender.addEventListener(() => {
@@ -54,12 +59,11 @@ export class OrbitController {
this.angle += this.speed * dt;
const hpr = new Cesium.HeadingPitchRange(
this.angle,
Cesium.Math.toRadians(this.pitch),
this.radius,
);
this.viewer.camera.lookAt(this.target, hpr);
cameraOf(this.viewer).lookAt(this.target, {
heading: this.angle,
pitch: toRadians(this.pitch),
range: this.radius,
});
});
}
@@ -74,12 +78,12 @@ export class OrbitController {
this._removeListener = null;
}
// Unlock camera for free interaction
this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
findCamera(this.viewer)?.release();
}
/**
* Toggle orbit on/off.
* @param {Cesium.Cartesian3} targetCartesian - Required when starting
* @param {{x: number, y: number, z: number}} targetCartesian - Required when starting
* @param {object} options - Passed to start()
* @returns {boolean} Whether orbit is now active
*/
-4
View File
@@ -339,12 +339,8 @@ const EXPECTED_MISSING = [
'camera.followOffset.get',
'camera.followOffset.set',
'camera.followed',
'camera.look',
'camera.matrices',
'camera.move',
'camera.rotateAround',
'camera.setFov',
'camera.zoom',
'draw.atlas',
'draw.clampState',
'draw.on',
+37
View File
@@ -26,6 +26,25 @@ export const EASINGS = Object.freeze({
sineInOut: Cesium.EasingFunction.SINUSOIDAL_IN_OUT,
});
/**
* The contract's moves and looks as Cesium camera method names, called by
* name: a consumer's camera may come from another copy of Cesium.
*/
const MOVES = Object.freeze({
forward: 'moveForward',
back: 'moveBackward',
left: 'moveLeft',
right: 'moveRight',
up: 'moveUp',
down: 'moveDown',
});
const LOOKS = Object.freeze({
left: 'lookLeft',
right: 'lookRight',
up: 'lookUp',
down: 'lookDown',
});
/** A contract destination as Cesium's: a Cartesian3, or a Rectangle. */
export function toDestination(d) {
if ('rect' in d) {
@@ -221,6 +240,24 @@ export function createCesiumCamera({ scene, camera, report }) {
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
scene.requestRender();
},
move(direction, metres) {
camera[MOVES[direction]](metres);
scene.requestRender();
},
look(direction, radians) {
camera[LOOKS[direction]](radians);
scene.requestRender();
},
rotateAround(axis, radians) {
camera.rotate(toCartesian(axis), radians);
scene.requestRender();
},
zoom(metres) {
// Cesium's zoom, not a move: outside 3D it changes the view's width.
if (metres < 0) camera.zoomOut(-metres);
else camera.zoomIn(metres);
scene.requestRender();
},
viewRectangle() {
const rect = camera.computeViewRectangle(Cesium.Ellipsoid.WGS84);
if (!rect) return null;
+1 -1
View File
@@ -25,7 +25,7 @@ const QUERY_RECORDS = new Set([
'occluded',
]);
/** Commands and records the port does not have yet. */
const MISSING = new Set(['move', 'look', 'rotateAround', 'zoom', 'matrices']);
const MISSING = new Set(['matrices']);
const runnable = CAMERA_SCRIPTS.filter((script) =>
script.steps.every(
+4
View File
@@ -19,6 +19,10 @@ export const CESIUM_CHECKS = new Set([
'camera.trace.setView angles',
'camera.trace.setView keeps the position when only orientation is given',
'camera.trace.lookAt sweep and release',
'camera.trace.move, look, rotate and zoom',
'camera.trace.commands inside a lookAt frame',
'camera.trace.changed fires by each listener’s threshold',
'camera.moveEnd',
'camera.trace.flyTo with a duration of 0 completes inside the call',
'camera.trace.flyTo where the camera already is completes inside the call',
'camera.viewRectangle',
-1
View File
@@ -208,7 +208,6 @@
"src/maps/google3d.js",
"src/maps/imagery.js",
"src/maps/terrain.js",
"src/orbit.js",
"src/overlays/worldOverlay.js",
"src/queries/heights.js",
"src/sceneCommandRelease.js",
+15 -5
View File
@@ -316,20 +316,30 @@ export function cameraChecks() {
r.camera.on('moveStart', () => events.push(['start', h.now(r)]));
r.camera.on('moveEnd', () => events.push(['end', h.now(r)]));
await h.frame(r);
const movingAt = h.now(r);
r.camera.move('forward', 1000);
await h.frame(r);
equal(events[0]?.[0], 'start', 'moveStart after the move');
const movedAt = h.now(r);
for (let i = 0; i < 45; i++) await h.frame(r);
// By the clock, not a frame count: a page's frames come at the
// display's rate.
// The event comes on the first frame past 500 ms, so the slack is
// two of the longest frames seen, never less than two at 60 Hz.
let longestFrame = 1000 / 60;
for (let i = 0; i < 600 && h.now(r) - movedAt < 750; i++) {
const before = h.now(r);
await h.frame(r);
longestFrame = Math.max(longestFrame, h.now(r) - before);
}
const end = events.find(([kind]) => kind === 'end');
ok(end, 'moveEnd fired');
ok(
end[1] - movedAt >= 500 - 1e-6,
`moveEnd waited 500 ms (${end[1] - movedAt})`,
end[1] - movingAt >= 500 - 1e-6,
`moveEnd waited 500 ms (${end[1] - movingAt})`,
);
ok(
end[1] - movedAt < 500 + 2 * (1000 / 60) + 1e-6,
'moveEnd came within two frames of 500 ms',
end[1] - movedAt < 500 + 2 * longestFrame + 1e-6,
`moveEnd came within two frames of 500 ms (${end[1] - movedAt})`,
);
r.destroy();
},
+3 -2
View File
@@ -7,6 +7,7 @@
* the mesh, it flattens under prefers-reduced-motion, and it stops the instant
* anything interrupts it.
*/
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
@@ -99,7 +100,7 @@ function createTickableViewer({ heightM = 700 } = {}) {
if (orientation && Number.isFinite(orientation.pitch)) camera.pitch = orientation.pitch;
},
};
const viewer = {
const viewer = bindFakeCesiumCamera({
trackedEntity: undefined,
clock: { onTick: { addEventListener: (fn) => { listeners.push(fn); return () => {}; } } },
scene: {
@@ -111,7 +112,7 @@ function createTickableViewer({ heightM = 700 } = {}) {
sampleHeight: () => 0,
},
camera,
};
});
return {
viewer,
setViews,
+2
View File
@@ -132,8 +132,10 @@ export function bindFakeCesiumCamera(viewer) {
pose: {
value() {
const c = fake.positionCartographic;
const p = fake.positionWC;
return {
...DEFAULT_POSE,
...(p ? { position: { x: p.x, y: p.y, z: p.z } } : {}),
lon: c ? toDegrees(c.longitude) : NaN,
lat: c ? toDegrees(c.latitude) : NaN,
height: c ? c.height : NaN,
+3 -2
View File
@@ -4,6 +4,7 @@ import { createGevActionRunner } from '../voice/gevActions.js';
import { createGeminiSession } from '../voice/geminiSession.js';
import { createTestVoiceUi } from './voiceUi.mjs';
import { createNarrationScheduler } from '../voice/narration.js';
import { bindFakeCesiumCamera } from './fakeCameraPort.mjs';
export const turnOfEventLoop = () =>
new Promise((resolve) => setImmediate(resolve));
@@ -68,7 +69,7 @@ export function nativeIntegrationFixture(t) {
const acquisitions = { microphone: 0, audio: 0, token: 0, socket: 0 };
const state = { height: 1000, renders: 0, zooms: [], earconStarts: 0 };
const position = { x: 6379137, y: 0, z: 0 };
const viewer = {
const viewer = bindFakeCesiumCamera({
clock: { onTick: { addEventListener: () => () => {} } },
camera: {
positionWC: position,
@@ -99,7 +100,7 @@ export function nativeIntegrationFixture(t) {
state.renders++;
},
},
};
});
const dataManager = { layers: new Map() };
const actualRunner = createGevActionRunner({
viewer,
+3 -2
View File
@@ -1,3 +1,4 @@
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
@@ -47,7 +48,7 @@ const outputs = (ws) =>
function fixture({ holdFirst = false } = {}) {
const state = { height: 1000, renders: 0, providerWrites: [], panels: [] };
const position = { x: 6379137, y: 0, z: 0 };
const viewer = {
const viewer = bindFakeCesiumCamera({
clock: { onTick: { addEventListener: () => () => {} } },
camera: {
positionWC: position,
@@ -76,7 +77,7 @@ function fixture({ holdFirst = false } = {}) {
state.renders++;
},
},
};
});
const runner = createGevActionRunner({
viewer,
dataManager: { layers: new Map() },
+9 -9
View File
@@ -2927,9 +2927,10 @@ function adjustCameraZoom(viewer, args) {
}[amount];
if (!fraction) throw new Error(`Unknown zoom amount: ${args.amount}`);
const camera = viewer.camera;
const beforePosition = Cesium.Cartesian3.clone(camera.positionWC);
const beforeHeightM = camera.positionCartographic.height;
const camera = cameraOf(viewer);
const before = camera.pose();
const beforePosition = before.position;
const beforeHeightM = before.height;
const target = getViewTargetCartesian(viewer);
const targetDistanceM = target
? chordM(beforePosition, target)
@@ -2939,7 +2940,7 @@ function adjustCameraZoom(viewer, args) {
camera.cancelFlight();
if (direction === 'out') {
camera.zoomOut(movementM);
camera.zoom(-movementM);
} else {
const safeMovementM = Math.min(
movementM,
@@ -2954,13 +2955,12 @@ function adjustCameraZoom(viewer, args) {
error: 'Camera is already at the minimum target distance',
};
}
camera.zoomIn(safeMovementM);
camera.zoom(safeMovementM);
}
viewer.scene.requestRender();
const afterPosition = camera.positionWC;
const movedM = chordM(beforePosition, afterPosition);
const afterHeightM = camera.positionCartographic.height;
const after = camera.pose();
const movedM = chordM(beforePosition, after.position);
const afterHeightM = after.height;
const moved = movedM >= 0.5;
return {
ok: moved,
+3 -2
View File
@@ -1,4 +1,5 @@
// Imports intentionally target the candidate, never the immutable reference.
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
@@ -176,7 +177,7 @@ function compositionFixture(
const canvas = new EventTarget();
Object.assign(canvas, { clientWidth: 800, clientHeight: 600 });
const position = { x: 6379137, y: 0, z: 0 };
const viewer = {
const viewer = bindFakeCesiumCamera({
clock: { onTick: { addEventListener: () => () => {} } },
camera: {
positionWC: position,
@@ -202,7 +203,7 @@ function compositionFixture(
state.renders++;
},
},
};
});
const dataManager = { layers: new Map() };
pointer = createPointerTracker({ element: null, now: () => 1000 });
const referents = createReferentRegistry();