diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index 814e4de1e..1456981fd 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -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, diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index c6453c4c7..3ea4fe670 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -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. diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 20315ba8e..837aac8a6 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -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'); } diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs index cf3630ae1..17b3934d5 100644 --- a/src/data/bhoteKoshiEvent.test.mjs +++ b/src/data/bhoteKoshiEvent.test.mjs @@ -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 () => { diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index 7b6dbf0ba..a423faa09 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -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: { diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js index c23752043..1e40c10bb 100644 --- a/src/layers/launches/selection.js +++ b/src/layers/launches/selection.js @@ -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) { diff --git a/src/orbit.js b/src/orbit.js index 7a8220566..6478712d0 100644 --- a/src/orbit.js +++ b/src/orbit.js @@ -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 */ diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index 357e127b5..2e88bbf65 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -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', diff --git a/src/render/cesium/camera.js b/src/render/cesium/camera.js index 2264691e9..7592d4026 100644 --- a/src/render/cesium/camera.js +++ b/src/render/cesium/camera.js @@ -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; diff --git a/src/render/cesium/cameraPort.test.mjs b/src/render/cesium/cameraPort.test.mjs index 1d96e8155..a12572d26 100644 --- a/src/render/cesium/cameraPort.test.mjs +++ b/src/render/cesium/cameraPort.test.mjs @@ -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( diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index dad47c267..8dd88d010 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -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', diff --git a/src/render/cesiumAllowList.json b/src/render/cesiumAllowList.json index ed4f75c71..6e77d949b 100644 --- a/src/render/cesiumAllowList.json +++ b/src/render/cesiumAllowList.json @@ -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", diff --git a/src/render/conformance/checks/camera.js b/src/render/conformance/checks/camera.js index 9a97556a1..fb71848d2 100644 --- a/src/render/conformance/checks/camera.js +++ b/src/render/conformance/checks/camera.js @@ -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(); }, diff --git a/src/routeCinematics.test.mjs b/src/routeCinematics.test.mjs index 622c8ab63..d081ea152 100644 --- a/src/routeCinematics.test.mjs +++ b/src/routeCinematics.test.mjs @@ -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, diff --git a/src/testSupport/fakeCameraPort.mjs b/src/testSupport/fakeCameraPort.mjs index fde7284ba..e5dcfea69 100644 --- a/src/testSupport/fakeCameraPort.mjs +++ b/src/testSupport/fakeCameraPort.mjs @@ -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, diff --git a/src/testSupport/geminiIntegrationFixture.mjs b/src/testSupport/geminiIntegrationFixture.mjs index 6d91d3a45..5e18fc811 100644 --- a/src/testSupport/geminiIntegrationFixture.mjs +++ b/src/testSupport/geminiIntegrationFixture.mjs @@ -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, diff --git a/src/voice/geminiNativeRecovery.test.mjs b/src/voice/geminiNativeRecovery.test.mjs index 10d23a609..90685bdc5 100644 --- a/src/voice/geminiNativeRecovery.test.mjs +++ b/src/voice/geminiNativeRecovery.test.mjs @@ -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() }, diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 8b7bc5566..071b42f0f 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -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, diff --git a/src/voice/providerCommands.test.mjs b/src/voice/providerCommands.test.mjs index 41d092e73..20d803b48 100644 --- a/src/voice/providerCommands.test.mjs +++ b/src/voice/providerCommands.test.mjs @@ -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();