diff --git a/packages/engine/Source/Core/Math.js b/packages/engine/Source/Core/Math.js index 2ea709c7d076..4c49cc6f8154 100644 --- a/packages/engine/Source/Core/Math.js +++ b/packages/engine/Source/Core/Math.js @@ -339,6 +339,64 @@ CesiumMath.lerp = function (p, q, time) { return (1.0 - time) * p + time * q; }; +/** @typedef {Object} SmoothDampResult + * @property {number} value The new value after applying the smooth damp. + * @property {number} velocity The updated current velocity. + */ + +/** + * Gradually changes a value towards a target value over time. The smoothing function uses a spring-damping algorithm based on Game Programming Gems 4 Chapter 1.10. + * @param {number} p The current value. + * @param {number} q The target value. + * @param {number} velocity The current velocity. + * @param {number} [deltaTime=0.0] The time since the last call to this function. Value must be greater than or equal to 0.0. + * @param {number} [maximumSpeed=Number.POSITIVE_INFINITY] Optionally allows clamping to the specified maximum speed. + * @param {number} [smoothTime=0.0001] Approximately the time it will take to reach the target. A smaller value will reach the target faster. This value must be greater than or equal to 0.0001. + * @param {SmoothDampResult} [result] An object to store the result. If not provided, a new object will be created and returned. + * @returns {SmoothDampResult} An object containing the new value and the updated current velocity. + */ +CesiumMath.smoothDamp = function ( + p, + q, + velocity, + deltaTime = 0.0, + maximumSpeed = Number.POSITIVE_INFINITY, + smoothTime = 0.0001, + result = {}, +) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.number("p", p); + Check.typeOf.number("q", q); + Check.typeOf.number("velocity", velocity); + Check.typeOf.number.greaterThanOrEquals("deltaTime", deltaTime, 0.0); + Check.typeOf.number.greaterThanOrEquals("maximumSpeed", maximumSpeed, 0.0); + Check.typeOf.number.greaterThanOrEquals("smoothTime", smoothTime, 0.0001); + Check.typeOf.object("result", result); + //>>includeEnd('debug'); + + // As a fallback, prevent crashes even if smoothTime is too small + smoothTime = Math.max(0.0001, smoothTime); + const omega = 2.0 / smoothTime; + + const x = omega * deltaTime; + const exp = 1.0 / (1.0 + x + 0.48 * x * x + 0.235 * x * x * x); + + const maxChange = maximumSpeed * smoothTime; + let change = p - q; + change = CesiumMath.clamp(change, -maxChange, maxChange); + + const target = p - change; + + const temp = (velocity + omega * change) * deltaTime; + + velocity = (velocity - omega * temp) * exp; + + result.value = target + (change + temp) * exp; + result.velocity = velocity; + + return result; +}; + /** * pi * @@ -1122,4 +1180,5 @@ CesiumMath.fastApproximateAtan2 = function (x, y) { t = y < 0.0 ? -t : t; return t; }; + export default CesiumMath; diff --git a/packages/engine/Source/Core/getTimestamp.js b/packages/engine/Source/Core/getTimestamp.js index 6c1ce26a1874..5dc46bbfab2c 100644 --- a/packages/engine/Source/Core/getTimestamp.js +++ b/packages/engine/Source/Core/getTimestamp.js @@ -1,11 +1,12 @@ +// @ts-check + /** * Gets a timestamp that can be used in measuring the time between events. Timestamps * are expressed in milliseconds, but it is not specified what the milliseconds are * measured from. This function uses performance.now() if it is available, or Date.now() * otherwise. - * + * @type {Function} * @function getTimestamp - * * @returns {number} The timestamp in milliseconds since some unspecified reference time. */ let getTimestamp; @@ -23,4 +24,5 @@ if ( return Date.now(); }; } + export default getTimestamp; diff --git a/packages/engine/Source/Scene/Camera.js b/packages/engine/Source/Scene/Camera.js index d29567035520..1ec683b58aba 100644 --- a/packages/engine/Source/Scene/Camera.js +++ b/packages/engine/Source/Scene/Camera.js @@ -3,6 +3,7 @@ import Cartesian2 from "../Core/Cartesian2.js"; import Cartesian3 from "../Core/Cartesian3.js"; import Cartesian4 from "../Core/Cartesian4.js"; import Cartographic from "../Core/Cartographic.js"; +import Check from "../Core/Check.js"; import Frozen from "../Core/Frozen.js"; import defined from "../Core/defined.js"; import DeveloperError from "../Core/DeveloperError.js"; @@ -2356,7 +2357,7 @@ const scratchLookAtHeadingPitchRangeQuaternion1 = new Quaternion(); const scratchLookAtHeadingPitchRangeQuaternion2 = new Quaternion(); const scratchHeadingPitchRangeMatrix3 = new Matrix3(); -function offsetFromHeadingPitchRange(heading, pitch, range) { +function offsetFromHeadingPitchRange(heading, pitch, range, result) { pitch = CesiumMath.clamp( pitch, -CesiumMath.PI_OVER_TWO, @@ -2380,10 +2381,7 @@ function offsetFromHeadingPitchRange(heading, pitch, range) { scratchHeadingPitchRangeMatrix3, ); - const offset = Cartesian3.clone( - Cartesian3.UNIT_X, - scratchLookAtHeadingPitchRangeOffset, - ); + const offset = Cartesian3.clone(Cartesian3.UNIT_X, result); Matrix3.multiplyByVector(rotMatrix, offset, offset); Cartesian3.negate(offset, offset); Cartesian3.multiplyByScalar(offset, range, offset); @@ -2441,6 +2439,7 @@ Camera.prototype.lookAtTransform = function (transform, offset) { offset.heading, offset.pitch, offset.range, + scratchLookAtHeadingPitchRangeOffset, ); } else { cartesianOffset = offset; @@ -2492,6 +2491,72 @@ Camera.prototype.lookAtTransform = function (transform, offset) { this._adjustOrthographicFrustum(true); }; +const scratchLookAtWorldPositionTransform = new Matrix4(); +const scratchLookAtWorldPositionDirection = new Cartesian3(); +const scratchLookAtWorldPositionWorldUp = new Cartesian3(); +const scratchLookAtWorldPositionRight = new Cartesian3(); + +/** + * Sets the camera orientation to look at a target position in world coordinates. The camera's up vector will be oriented to the world up vector at the target position. + * If the camera is at the target position, the camera will be oriented to the world up vector at the target position. + * @param {Cartesian3} target The target position in world coordinates. + * @param {Ellipsoid} [ellipsoid=Ellipsoid.default] The ellipsoid to use for determining the world up. + */ +Camera.prototype.lookAtWorldPosition = function ( + target, + ellipsoid = Ellipsoid.default, +) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.object("target", target); + Check.typeOf.object("ellipsoid", ellipsoid); + //>>includeEnd('debug'); + + const transform = Matrix4.clone( + this._transform, + scratchLookAtWorldPositionTransform, + ); + + this._setTransform(Matrix4.IDENTITY); + + // Get direction to look at target + let direction = Cartesian3.subtract( + target, + this.positionWC, + scratchLookAtWorldPositionDirection, + ); + + // If the camera is at the target position, we can't look at it, but we should still continue to re-orient the camera to the world up vector at the target position. + if (Cartesian3.magnitudeSquared(direction) < CesiumMath.EPSILON8) { + direction = Cartesian3.clone( + this.directionWC, + scratchLookAtWorldPositionDirection, + ); + } + + direction = Cartesian3.normalize(direction, this.direction); + + // Orient the camera to the world up vector at the target position + const worldUp = ellipsoid.geodeticSurfaceNormal( + target, + scratchLookAtWorldPositionWorldUp, + ); + + let right = Cartesian3.cross( + direction, + worldUp, + scratchLookAtWorldPositionRight, + ); + if (Cartesian3.magnitudeSquared(right) < CesiumMath.EPSILON8) { + right = Cartesian3.clone(this.rightWC, scratchLookAtWorldPositionRight); + } + Cartesian3.normalize(right, this.right); + + const up = Cartesian3.cross(right, direction, this.up); + Cartesian3.normalize(up, this.up); + + this._setTransform(transform); +}; + const viewRectangle3DCartographic1 = new Cartographic(); const viewRectangle3DCartographic2 = new Cartographic(); const viewRectangle3DNorthEast = new Cartesian3(); @@ -3621,6 +3686,7 @@ Camera.prototype.flyToBoundingSphere = function (boundingSphere, options) { offset.heading, offset.pitch, offset.range, + scratchflyToBoundingSphereDestination, ); } diff --git a/packages/engine/Source/Scene/Controllers/Controller.js b/packages/engine/Source/Scene/Controllers/Controller.js new file mode 100644 index 000000000000..72ae18165054 --- /dev/null +++ b/packages/engine/Source/Scene/Controllers/Controller.js @@ -0,0 +1,63 @@ +import DeveloperError from "../../Core/DeveloperError.js"; + +/** + * An interface for a camera controller that can be registered with the scene to handle input events, camera animations, and other interactions. Implementations of this interface are expected to be registered with the scene via a {@link ControllerHost}. + * This type describes an + * interface and is not intended to be instantiated directly. + * @class + * @abstract + * @see {@link HybridScreenSpacePanCameraController} + * @see {@link ScreenSpaceElevatorCameraController} + * @see {@link ScreenSpaceMapCameraController} + * @see {@link ScreenSpaceTiltOrbitCameraController} + */ +class Controller { + /** + * Determines if the controller is enabled and should be updated by the host scene. + * @type {boolean} + */ + get enabled() { + return DeveloperError.throwInstantiationError(); + } + set enabled(value) { + DeveloperError.throwInstantiationError(); + } + + /** + * Invoked when the controller is added to the DOM. Implement connectedCallback to set up any DOM event listeners. + * @param {HTMLElement} element The DOM element containing the Cesium scene. + */ + connectedCallback(element) { + DeveloperError.throwInstantiationError(); + } + + /** + * Invoked when the controller is removed from the DOM. Implement disconnectedCallback to tear down any DOM event listeners. + * @param {HTMLElement} element The DOM element containing the Cesium scene. + */ + disconnectedCallback(element) { + DeveloperError.throwInstantiationError(); + } + + /** + * Invoked once per frame. Implement update to modify the camera or other parts of the scene. + * @see {@link https://cesium.com/blog/2018/01/24/cesium-scene-rendering-performance/#updaterender-cycle-events|Update/Render Cycle Events} + * @param {Scene} scene + * @param {JulianDate} time The current simulation time. + */ + update(scene, time) { + DeveloperError.throwInstantiationError(); + } + + /** + * Invoked when the controller is being updated the first time, immediately before update is called. Implement firstUpdate to perform one-time work after the relevant scene has begun its render loop. Some examples might include initializing simulation time values or adding a primitive to the scene. + * @see Controller#update + * @param {Scene} scene + * @param {JulianDate} time The current simulation time. + */ + firstUpdate(scene, time) { + DeveloperError.throwInstantiationError(); + } +} + +export default Controller; diff --git a/packages/engine/Source/Scene/Controllers/ControllerHost.js b/packages/engine/Source/Scene/Controllers/ControllerHost.js new file mode 100644 index 000000000000..aed2140c75f6 --- /dev/null +++ b/packages/engine/Source/Scene/Controllers/ControllerHost.js @@ -0,0 +1,80 @@ +/** + * Collects an array of Controller objects that can be registered with the scene to handle input events, camera animations, and other interactions. + * @class + * @see {@link Controller} + * @see {@link Scene#controllerHost} + */ +class ControllerHost { + /** + * Creates an instance of a ControllerHost. Typically, a ControllerHost is created by the Scene constructor and accessed via {@link Scene#controllerHost}. + * @constructor + * @alias ControllerHost + * @see {@link Scene#controllerHost} + */ + constructor() { + /** + * @type {Controller[]} + * @private + */ + this._controllers = []; + this._needsUpdate = new Set(); + } + + /** + * The number of controllers registered to this host. + * @type {number} + * @readonly + */ + get controllerCount() { + return this._controllers.length; + } + + /** + * Registers a controller implementation with this host. + * @param {Controller} controller An implementation of the Controller interface to register with this host. + * @param {HTMLElement} element The DOM element containing the Cesium scene. + * @param {number} [priority=0] An index, less than or equal to the current count of registed controllers, that defines the precedence of the new controller relative to those previously registered. A priority of 0 would mean the new controller would apply its updates before any other controller. As subsequent controllers are updated, their effects are applied on top of any previous update effects. If omitted, the new controller becomes the highest priority, i.e., its updates are applied after all other controllers. + */ + registerController(controller, element, priority) { + const index = priority ?? this.controllerCount; + this._controllers.splice(index, 0, controller); + this._needsUpdate.add(controller); + controller.connectedCallback(element); + } + + /** + * Unregisters a controller implementation from this host. + * @param {Controller} controller An implementation of the Controller interface to unregister from this host. + * @param {HTMLElement} element The DOM element containing the Cesium scene. + */ + unregisterController(controller, element) { + const controllers = this._controllers; + const index = controllers.indexOf(controller); + if (index !== -1) { + controllers.splice(index, 1); + controller.disconnectedCallback(element); + } + } + + /** + * Invoked once per frame by the host scene. Updates all registered controllers in order of their priority. + * @param {Scene} scene The host scene. + * @param {JulianDate} time The current simulation time. + */ + update(scene, time) { + for (const controller of this._controllers) { + if (!controller.enabled) { + continue; + } + + if (this._needsUpdate.has(controller)) { + controller.firstUpdate(scene, time); + this._needsUpdate.delete(controller); + } + + controller.update(scene, time); + } + } +} + +export default ControllerHost; diff --git a/packages/engine/Source/Scene/Controllers/HybridScreenSpacePanCameraController.js b/packages/engine/Source/Scene/Controllers/HybridScreenSpacePanCameraController.js new file mode 100644 index 000000000000..4971513113f7 --- /dev/null +++ b/packages/engine/Source/Scene/Controllers/HybridScreenSpacePanCameraController.js @@ -0,0 +1,106 @@ +import ScreenSpaceElevatorCameraController from "./ScreenSpaceElevatorCameraController.js"; +import ScreenSpaceMapCameraController from "./ScreenSpaceMapCameraController.js"; +import Cartesian3 from "../../Core/Cartesian3.js"; +import CesiumMath from "../../Core/Math.js"; + +/** + * A contextual camera controller that combines screenspace map panning and screenspace elevator panning. The controller automatically switches between the two based on the camera's angle relative to nadir. If the camera is looking mostly down (within angleThreshold of nadir), ScreenSpaceMapCameraController is used. + * If the camera is looking towards the horizon (beyond angleThreshold from nadir), the ScreenSpaceElevatorCameraController is used. + * @implements Controller + * @example + * viewer.scene.screenSpaceCameraController.enableInputs = false; + * viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; + * + * const hybridController = new HybridScreenSpacePanCameraController(); + * viewer.addController(hybridController); + */ +class HybridScreenSpacePanCameraController { + constructor() { + this._elevatorController = new ScreenSpaceElevatorCameraController(); + this._mapController = new ScreenSpaceMapCameraController(); + + this._enabled = true; + this._ellipsoidNormal = new Cartesian3(); + + /** + * The angle threshold in radians that determines which controller is used. If the camera is looking within this angle of nadir, the map controller is used. Otherwise, the elevator controller is used. + * @type {number} + * @default CesiumMath.toRadians(125) + */ + this.angleThreshold = CesiumMath.toRadians(125); + } + + get enabled() { + return this._enabled; + } + + set enabled(value) { + this._enabled = value; + } + + /** + * The controller that is used when the camera is looking more horizontally (beyond angleThreshold from nadir). + * @type {ScreenSpaceElevatorCameraController} + * @readonly + */ + get elevatorController() { + return this._elevatorController; + } + + /** + * The controller that is used when the camera is looking mostly down (within angleThreshold of nadir). + * @type {ScreenSpaceMapCameraController} + * @readonly + */ + get mapController() { + return this._mapController; + } + + /** + * @inheritdoc + * @param {HTMLElement} element The DOM element containing the Cesium scene. + */ + connectedCallback(element) { + this._elevatorController.connectedCallback(element); + this._mapController.connectedCallback(element); + } + + /** + * @inheritdoc + * @param {HTMLElement} element The DOM element containing the Cesium scene. + */ + disconnectedCallback(element) { + this._elevatorController.disconnectedCallback(element); + this._mapController.disconnectedCallback(element); + } + + /** + * @inheritdoc + */ + firstUpdate() { + this._elevatorController.firstUpdate(); + this._mapController.firstUpdate(); + } + + /** + * @inheritdoc + * @param {Scene} scene + */ + update(scene) { + const camera = scene.camera; + const normal = scene.ellipsoid.geodeticSurfaceNormal( + camera.positionWC, + this._ellipsoidNormal, + ); + + const angle = Math.abs(Cartesian3.angleBetween(normal, camera.directionWC)); + const activeController = + angle < this.angleThreshold + ? this._elevatorController + : this._mapController; + + activeController.update(scene); + } +} + +export default HybridScreenSpacePanCameraController; diff --git a/packages/engine/Source/Scene/Controllers/MouseButton.js b/packages/engine/Source/Scene/Controllers/MouseButton.js new file mode 100644 index 000000000000..332ec69a0d97 --- /dev/null +++ b/packages/engine/Source/Scene/Controllers/MouseButton.js @@ -0,0 +1,32 @@ +// @ts-check + +/** + * This enumerated type is for classifying mouse buttons: left, middle, and right. + * @enum {number} + */ +const MouseButton = { + /** + * Represents a mouse left button. + * @type {number} + * @constant + */ + LEFT: 0, + + /** + * Represents a mouse middle button. + * @type {number} + * @constant + */ + MIDDLE: 1, + + /** + * Represents a mouse right button. + * @type {number} + * @constant + */ + RIGHT: 2, +}; + +Object.freeze(MouseButton); + +export default MouseButton; diff --git a/packages/engine/Source/Scene/Controllers/ScreenSpaceElevatorCameraController.js b/packages/engine/Source/Scene/Controllers/ScreenSpaceElevatorCameraController.js new file mode 100644 index 000000000000..309ca28a39b5 --- /dev/null +++ b/packages/engine/Source/Scene/Controllers/ScreenSpaceElevatorCameraController.js @@ -0,0 +1,276 @@ +import Cartesian2 from "../../Core/Cartesian2.js"; +import Cartesian3 from "../../Core/Cartesian3.js"; +import defined from "../../Core/defined.js"; +import Frozen from "../../Core/Frozen.js"; +import getTimestamp from "../../Core/getTimestamp.js"; +import CesiumMath from "../../Core/Math.js"; +import ScreenSpaceEventHandler from "../../Core/ScreenSpaceEventHandler.js"; +import TimeConstants from "../../Core/TimeConstants.js"; +import ScreenSpaceInputBindings from "./ScreenSpaceInputBindings.js"; +import MouseButton from "./MouseButton.js"; + +/** + * @typedef {object} ControllerOptions + * @memberof ScreenSpaceElevatorCameraController + * @property {ScreenSpaceInputBindings.InputBinding[]} [dragInputs] The drag input bindings that control panning. + */ + +/** + * A camera controller that allows panning the camera tangential to the ellipsoid, i.e., up and down relative to the ellipsoid normal, in screen space + * by clicking and dragging the mouse. + * @class + * @alias ScreenSpaceElevatorCameraController + * @implements Controller + * @example + * viewer.scene.screenSpaceCameraController.enableInputs = false; + * viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; + * + * const elevatorCameraController = new Cesium.ScreenSpaceElevatorCameraController(); + * viewer.addController(elevatorCameraController); + * + * @example + * // Configure the controller to use the right mouse button for panning instead of the default left mouse button. + * const elevatorCameraController = new Cesium.ScreenSpaceElevatorCameraController({ + * dragInputs: [{ button: Cesium.MouseButton.RIGHT}] + * }); + * viewer.addController(elevatorCameraController); + */ +class ScreenSpaceElevatorCameraController { + /** + * @private + * @returns {ScreenSpaceInputBindings.InputBinding[]} The default drag input bindings. + */ + static _getDefaultDragInputs() { + return [ + Object.freeze({ + button: MouseButton.LEFT, + }), + ]; + } + + /** + * Creates an instance of a ScreenSpaceElevatorCameraController. + * @param {ScreenSpaceElevatorCameraController.ControllerOptions} [options] The options for configuring the controller. + */ + constructor(options = Frozen.EMPTY_OBJECT) { + this._enabled = true; + this._handler = undefined; + this._lastUpdateTime = undefined; + + /** + * The drag input bindings that control vertical panning. Each binding is a combination of the mouse button + * and an optional keyboard modifier. + * @type {ScreenSpaceInputBindings.InputBinding[]} + * @see ScreenSpaceEventHandler + */ + this.dragInputs = + options.dragInputs ?? + ScreenSpaceElevatorCameraController._getDefaultDragInputs(); + + this._isPanning = false; + this._panDelta = new Cartesian2(); + this._panPosition = new Cartesian2(); + + this._ellipsoidNormal = new Cartesian3(); + this._ellipsoidSurfacePosition = new Cartesian3(); + this._panDirectionX = new Cartesian3(); + this._panDirectionY = new Cartesian3(); + this._pixelSize = new Cartesian2(); + this._panVelocity = new Cartesian2(); + + /** + * The speed in meters per pixel at which the camera pans. + * @type {number} + * @default 1.0 + */ + this.panSpeed = 1.0; + + /** + * The rate at which the camera's pan velocity decays over time. + * @type {number} + * @default 6.0 + */ + this.inertialDecay = 6.0; + + /** + * A parameter in the range [0, 1) used to limit the range + * of inputs to a percentage of the window width/height per animation frame. + * This helps keep the camera under control in low-frame-rate situations. + * @type {number} + * @default 0.1 + */ + this.maximumMovementRatio = 0.1; + } + + /** + * @inheritdoc + */ + get enabled() { + return this._enabled; + } + set enabled(value) { + this._enabled = value; + + if (value) { + this._lastUpdateTime = getTimestamp(); + this._panDelta.x = 0; + this._panDelta.y = 0; + } else { + this._isPanning = false; + } + } + + /** + * @inheritdoc + * @param {HTMLElement} element The DOM element containing the Cesium scene. + */ + connectedCallback(element) { + const handler = new ScreenSpaceEventHandler(element); + this._handler = handler; + + ScreenSpaceInputBindings.registerDragInputBindings( + handler, + this.dragInputs, + { + start: this._handleStartPan.bind(this), + end: this._handleStopPan.bind(this), + move: this._handlePan.bind(this), + }, + ); + } + + /** + * @inheritdoc + * @param {HTMLElement} element The DOM element containing the Cesium scene. + */ + disconnectedCallback(element) { + const handler = this._handler; + if (defined(handler) && !handler.isDestroyed()) { + handler.destroy(); + } + } + + /** + * @inheritdoc + */ + firstUpdate() { + this._lastUpdateTime = getTimestamp(); + this._panDelta.x = 0; + this._panDelta.y = 0; + } + + /** + * @inheritdoc + * @param {any} scene + */ + update(scene) { + const dt = + (getTimestamp() - this._lastUpdateTime) * + TimeConstants.SECONDS_PER_MILLISECOND; + + const { camera, ellipsoid, canvas } = scene; + const { clientWidth, clientHeight } = canvas; + if (dt === 0 || clientWidth === 0 || clientHeight === 0) { + // Reset for next frame + this._lastUpdateTime = getTimestamp(); + this._panDelta.x = 0; + this._panDelta.y = 0; + return; + } + + let surface = camera.pickEllipsoid( + this._panPosition, + ellipsoid, + this._ellipsoidSurfacePosition, + ); + + if (!defined(surface)) { + surface = ellipsoid.scaleToGeodeticSurface( + camera.positionWC, + this._ellipsoidSurfacePosition, + ); + } + + let xAxis = Cartesian3.clone(camera.rightWC, this._panDirectionX); + xAxis = Cartesian3.normalize(xAxis, this._panDirectionX); + const zAxis = Cartesian3.normalize(surface, this._panDirectionY); + + const distance = Cartesian3.distance(camera.positionWC, surface); + const { drawingBufferWidth, drawingBufferHeight, pixelRatio } = scene; + const pixelSize = camera.frustum.getPixelDimensions( + drawingBufferWidth, + drawingBufferHeight, + distance, + pixelRatio, + this._pixelSize, + ); + + let dx = -this._panDelta.x; + let dy = this._panDelta.y; + + if (!this._isPanning) { + const damping = Math.exp(-this.inertialDecay * dt); + this._panVelocity.x *= damping; + this._panVelocity.y *= damping; + + dx = this._panVelocity.x * dt; + dy = this._panVelocity.y * dt; + } + + const maxPixels = + this.maximumMovementRatio * Math.max(clientWidth, clientHeight); + dx = CesiumMath.clamp(dx, -maxPixels, maxPixels); + this._panVelocity.x = dx / dt; + dx *= this.panSpeed * pixelSize.x; + + dy = CesiumMath.clamp(dy, -maxPixels, maxPixels); + this._panVelocity.y = dy / dt; + dy *= this.panSpeed * pixelSize.y; + + camera.move(xAxis, dx); + camera.move(zAxis, dy); + + // Reset for next frame + this._lastUpdateTime = getTimestamp(); + this._panDelta.x = 0; + this._panDelta.y = 0; + } + + _handleStartPan() { + if (!this.enabled) { + return; + } + + this._isPanning = true; + this._panDelta.x = 0; + this._panDelta.y = 0; + } + + _handleStopPan() { + this._isPanning = false; + } + + /** + * @typedef {object} DragEvent + * @memberof ScreenSpaceElevatorCameraController + * @property {Cartesian2} startPosition The position of the mouse when the drag started. + * @property {Cartesian2} endPosition The position of the mouse when the drag ended. + */ + + /** + * @param {DragEvent} event + * @private + */ + _handlePan(event) { + if (!this._isPanning) { + return; + } + + this._panDelta.x += event.endPosition.x - event.startPosition.x; + this._panDelta.y += event.endPosition.y - event.startPosition.y; + this._panPosition.x = event.endPosition.x; + this._panPosition.y = event.endPosition.y; + } +} + +export default ScreenSpaceElevatorCameraController; diff --git a/packages/engine/Source/Scene/Controllers/ScreenSpaceInputBindings.js b/packages/engine/Source/Scene/Controllers/ScreenSpaceInputBindings.js new file mode 100644 index 000000000000..8ebb6357b103 --- /dev/null +++ b/packages/engine/Source/Scene/Controllers/ScreenSpaceInputBindings.js @@ -0,0 +1,116 @@ +import Check from "../../Core/Check.js"; +import defined from "../../Core/defined.js"; +import ScreenSpaceEventType from "../../Core/ScreenSpaceEventType.js"; +import MouseButton from "./MouseButton.js"; + +/** + * @typedef {object} InputBinding + * @memberof ScreenSpaceInputBindings + * @property {MouseButton} button The mouse button used for drag start/stop. + * @property {number} [modifier] The optional keyboard modifier to register. + */ + +/** + * @typedef {object} DragInputActions + * @memberof ScreenSpaceInputBindings + * @property {Function} start Called on drag start. + * @property {Function} end Called on drag stop. + * @property {Function} move Called on drag move. + */ + +/** + * @private + * @param {MouseButton} button The mouse button. + * @returns {ScreenSpaceEventType|undefined} The corresponding down event type. + */ +function getDownEventType(button) { + if (button === MouseButton.LEFT) { + return ScreenSpaceEventType.LEFT_DOWN; + } + + if (button === MouseButton.MIDDLE) { + return ScreenSpaceEventType.MIDDLE_DOWN; + } + + if (button === MouseButton.RIGHT) { + return ScreenSpaceEventType.RIGHT_DOWN; + } + + return undefined; +} + +/** + * @private + * @param {MouseButton} button The mouse button. + * @returns {ScreenSpaceEventType|undefined} The corresponding down event type. + */ +function getUpEventType(button) { + if (button === MouseButton.LEFT) { + return ScreenSpaceEventType.LEFT_UP; + } + + if (button === MouseButton.MIDDLE) { + return ScreenSpaceEventType.MIDDLE_UP; + } + + if (button === MouseButton.RIGHT) { + return ScreenSpaceEventType.RIGHT_UP; + } + + return undefined; +} + +/** + * @namespace + * @alias ScreenSpaceInputBindings + */ +class ScreenSpaceInputBindings { + /** + * Registers drag input bindings on a screen space event handler. + * @param {ScreenSpaceEventHandler} handler The screen space event handler. + * @param {InputBinding[]} inputBindings The drag bindings to register. + * @param {DragInputActions} dragInputActions The callbacks to invoke for drag actions. + */ + static registerDragInputBindings(handler, inputBindings, dragInputActions) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.object("handler", handler); + Check.defined("inputBindings", inputBindings); + Check.typeOf.object("dragInputActions", dragInputActions); + //>>includeEnd('debug'); + + const moveModifiers = new Set(); + + for (const binding of inputBindings) { + const downEventType = getDownEventType(binding.button); + const upEventType = getUpEventType(binding.button); + + if (defined(downEventType)) { + handler.setInputAction( + dragInputActions.start, + downEventType, + binding.modifier, + ); + } + + if (defined(upEventType)) { + handler.setInputAction( + dragInputActions.end, + upEventType, + binding.modifier, + ); + } + + moveModifiers.add(binding.modifier); + } + + for (const modifier of moveModifiers) { + handler.setInputAction( + dragInputActions.move, + ScreenSpaceEventType.MOUSE_MOVE, + modifier, + ); + } + } +} + +export default ScreenSpaceInputBindings; diff --git a/packages/engine/Source/Scene/Controllers/ScreenSpaceMapCameraController.js b/packages/engine/Source/Scene/Controllers/ScreenSpaceMapCameraController.js new file mode 100644 index 000000000000..f2bf0acce957 --- /dev/null +++ b/packages/engine/Source/Scene/Controllers/ScreenSpaceMapCameraController.js @@ -0,0 +1,292 @@ +import Cartesian2 from "../../Core/Cartesian2.js"; +import Cartesian3 from "../../Core/Cartesian3.js"; +import defined from "../../Core/defined.js"; +import Frozen from "../../Core/Frozen.js"; +import getTimestamp from "../../Core/getTimestamp.js"; +import CesiumMath from "../../Core/Math.js"; +import ScreenSpaceEventHandler from "../../Core/ScreenSpaceEventHandler.js"; +import TimeConstants from "../../Core/TimeConstants.js"; +import ScreenSpaceInputBindings from "./ScreenSpaceInputBindings.js"; +import MouseButton from "./MouseButton.js"; + +/** + * @typedef {object} ControllerOptions + * @memberof ScreenSpaceMapCameraController + * @property {ScreenSpaceInputBindings.InputBinding[]} [dragInputs] The drag input bindings that control panning. + */ + +/** + * A camera controller that allows panning the camera tangential to the ellipsoid in screen space + * by clicking and dragging the mouse. + * @class + * @alias ScreenSpaceMapCameraController + * @implements Controller + * @example + * viewer.scene.screenSpaceCameraController.enableInputs = false; + * + * const mapCameraController = new Cesium.ScreenSpaceMapCameraController(); + * viewer.addController(mapCameraController); + * + * @example + * // Configure the controller to use the right mouse button for panning instead of the default left mouse button. + * const mapCameraController = new Cesium.ScreenSpaceMapCameraController({ + * dragInputs: [{ button: Cesium.MouseButton.RIGHT}] + * }); + * viewer.addController(mapCameraController); + */ +class ScreenSpaceMapCameraController { + /** + * @private + * @returns {ScreenSpaceInputBindings.InputBinding[]} The default drag input bindings. + */ + static _getDefaultDragInputs() { + return [ + Object.freeze({ + button: MouseButton.LEFT, + }), + ]; + } + + /** + * Creates an instance of a ScreenSpaceMapCameraController. + * @param {ScreenSpaceMapCameraController.ControllerOptions} [options] The options for configuring the controller. + */ + constructor(options = Frozen.EMPTY_OBJECT) { + this._enabled = true; + this._handler = undefined; + this._lastUpdateTime = undefined; + + /** + * The drag input bindings that map panning. Each binding is a combination of the mouse button + * and an optional keyboard modifier. + * @type {ScreenSpaceInputBindings.InputBinding[]} + * @see ScreenSpaceEventHandler + */ + this.dragInputs = + options.dragInputs ?? + ScreenSpaceMapCameraController._getDefaultDragInputs(); + + this._isPanning = false; + this._panDelta = new Cartesian2(); + this._panPosition = new Cartesian2(); + + this._ellipsoidNormal = new Cartesian3(); + this._ellipsoidSurfacePosition = new Cartesian3(); + this._panDirectionX = new Cartesian3(); + this._panDirectionY = new Cartesian3(); + this._pixelSize = new Cartesian2(); + this._panVelocity = new Cartesian2(); + + /** + * The speed in meters per pixel at which the camera pans. + * @type {number} + * @default 1.0 + */ + this.panSpeed = 1.0; + + /** + * The rate at which the camera's pan velocity decays over time. + * @type {number} + * @default 6.0 + */ + this.inertialDecay = 6.0; + + /** + * A parameter in the range [0, 1) used to limit the range + * of inputs to a percentage of the window width/height per animation frame. + * This helps keep the camera under control in low-frame-rate situations. + * @type {number} + * @default 0.1 + */ + this.maximumMovementRatio = 0.1; + } + + /** + * @inheritdoc + */ + get enabled() { + return this._enabled; + } + set enabled(value) { + this._enabled = value; + + if (value) { + this._panDelta.x = 0; + this._panDelta.y = 0; + } else { + this._isPanning = false; + } + } + + /** + * @inheritdoc + * @param {HTMLElement} element The DOM element containing the Cesium scene. + */ + connectedCallback(element) { + const handler = new ScreenSpaceEventHandler(element); + this._handler = handler; + + ScreenSpaceInputBindings.registerDragInputBindings( + handler, + this.dragInputs, + { + start: this._handleStartPan.bind(this), + end: this._handleStopPan.bind(this), + move: this._handlePan.bind(this), + }, + ); + } + + /** + * @inheritdoc + * @param {HTMLElement} element The DOM element containing the Cesium scene. + */ + disconnectedCallback(element) { + const handler = this._handler; + if (defined(handler) && !handler.isDestroyed()) { + handler.destroy(); + } + } + + /** + * @inheritdoc + */ + firstUpdate() { + this._lastUpdateTime = getTimestamp(); + this._panDelta.x = 0; + this._panDelta.y = 0; + } + + /** + * @inheritdoc + * @param {any} scene + */ + update(scene) { + const dt = + (getTimestamp() - this._lastUpdateTime) * + TimeConstants.SECONDS_PER_MILLISECOND; + + const { camera, ellipsoid, canvas } = scene; + const { clientWidth, clientHeight } = canvas; + if (dt === 0 || clientWidth === 0 || clientHeight === 0) { + // Reset for next frame + this._lastUpdateTime = getTimestamp(); + this._panDelta.x = 0; + this._panDelta.y = 0; + return; + } + + let surface = camera.pickEllipsoid( + this._panPosition, + ellipsoid, + this._ellipsoidSurfacePosition, + ); + + if (!defined(surface)) { + surface = ellipsoid.scaleToGeodeticSurface( + camera.positionWC, + this._ellipsoidSurfacePosition, + ); + } + + const zAxis = ellipsoid.geodeticSurfaceNormal( + surface, + this._ellipsoidNormal, + ); + + let xAxis = Cartesian3.clone(camera.rightWC, this._panDirectionX); + xAxis = Cartesian3.normalize(xAxis, this._panDirectionX); + + // If z-axis is parallel to camera forward, we use the camera up vector to compute the y-axis. Otherwise, we use the z-axis and x-axis to compute the y-axis. + let yAxis = Cartesian3.clone(camera.upWC, this._panDirectionY); + const theta = Math.abs(Cartesian3.dot(zAxis, camera.directionWC)); + if (CesiumMath.lessThan(theta, 1.0, CesiumMath.EPSILON6)) { + yAxis = Cartesian3.cross(zAxis, xAxis, this._panDirectionY); + } + + yAxis = Cartesian3.normalize(yAxis, this._panDirectionY); + + const distance = Cartesian3.distance(camera.positionWC, surface); + const { drawingBufferWidth, drawingBufferHeight, pixelRatio } = scene; + const pixelSize = camera.frustum.getPixelDimensions( + drawingBufferWidth, + drawingBufferHeight, + distance, + pixelRatio, + this._pixelSize, + ); + + let dx = -this._panDelta.x; + let dy = this._panDelta.y; + + if (!this._isPanning) { + const damping = Math.exp(-this.inertialDecay * dt); + this._panVelocity.x *= damping; + this._panVelocity.y *= damping; + + dx = this._panVelocity.x * dt; + dy = this._panVelocity.y * dt; + } + + const maxPixels = + this.maximumMovementRatio * Math.max(clientWidth, clientHeight); + + dx = CesiumMath.clamp(dx, -maxPixels, maxPixels); + this._panVelocity.x = dx / dt; + dx *= this.panSpeed * pixelSize.x; + + dy = CesiumMath.clamp(dy, -maxPixels, maxPixels); + this._panVelocity.y = dy / dt; + dy *= this.panSpeed * pixelSize.y; + + camera.move(xAxis, dx); + camera.move(yAxis, dy); + + // Reset for next frame + this._lastUpdateTime = getTimestamp(); + this._panDelta.x = 0; + this._panDelta.y = 0; + } + + /** + * @private + * @param {Event} event + */ + _handleStartPan(event) { + if (!this.enabled) { + return; + } + + this._isPanning = true; + this._panDelta.x = 0; + this._panDelta.y = 0; + } + + _handleStopPan() { + this._isPanning = false; + } + + /** + * @typedef {object} DragEvent + * @memberof ScreenSpaceMapCameraController + * @property {Cartesian2} startPosition The position of the mouse when the drag started. + * @property {Cartesian2} endPosition The position of the mouse when the drag ended. + */ + + /** + * @param {DragEvent} event + * @private + */ + _handlePan(event) { + if (!this._isPanning) { + return; + } + + this._panDelta.x += event.endPosition.x - event.startPosition.x; + this._panDelta.y += event.endPosition.y - event.startPosition.y; + this._panPosition.x = event.endPosition.x; + this._panPosition.y = event.endPosition.y; + } +} + +export default ScreenSpaceMapCameraController; diff --git a/packages/engine/Source/Scene/Controllers/ScreenSpaceTiltOrbitCameraController.js b/packages/engine/Source/Scene/Controllers/ScreenSpaceTiltOrbitCameraController.js new file mode 100644 index 000000000000..5d73cdd9ed91 --- /dev/null +++ b/packages/engine/Source/Scene/Controllers/ScreenSpaceTiltOrbitCameraController.js @@ -0,0 +1,626 @@ +import Cartesian2 from "../../Core/Cartesian2.js"; +import Cartesian3 from "../../Core/Cartesian3.js"; +import Check from "../../Core/Check.js"; +import defined from "../../Core/defined.js"; +import Ellipsoid from "../../Core/Ellipsoid.js"; +import Frozen from "../../Core/Frozen.js"; +import getTimestamp from "../../Core/getTimestamp.js"; +import KeyboardEventModifier from "../../Core/KeyboardEventModifier.js"; +import CesiumMath from "../../Core/Math.js"; +import Matrix3 from "../../Core/Matrix3.js"; +import Matrix4 from "../../Core/Matrix4.js"; +import ScreenSpaceEventHandler from "../../Core/ScreenSpaceEventHandler.js"; +import Quaternion from "../../Core/Quaternion.js"; +import TimeConstants from "../../Core/TimeConstants.js"; +import Transforms from "../../Core/Transforms.js"; +import ScreenSpaceInputBindings from "./ScreenSpaceInputBindings.js"; +import MouseButton from "./MouseButton.js"; + +/** + * @typedef {object} ControllerOptions + * @memberof ScreenSpaceTiltOrbitCameraController + * @property {ScreenSpaceInputBindings.InputBinding[]} [dragInputs] The drag input bindings that control tilting and orbiting. + */ + +/** + * A camera controller that allows tilting and orbiting the camera around a target position in screen space by clicking and dragging the mouse or touching and dragging on a touch screen. + * @class + * @implements Controller + * @example + * viewer.scene.screenSpaceCameraController.enableInputs = false; + * viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; + * + * const tiltOrbitController = new Cesium.ScreenSpaceTiltOrbitCameraController(); + * viewer.addController(tiltOrbitController); + * + * @example + * // Configure the controller to use the left mouse button for tilting and orbiting instead of the default right mouse button. + * const tiltOrbitController = new Cesium.ScreenSpaceTiltOrbitCameraController({ + * dragInputs: [{ button: Cesium.MouseButton.LEFT }] + * }); + * viewer.addController(tiltOrbitController); + */ +class ScreenSpaceTiltOrbitCameraController { + /** + * @private + * @returns {ScreenSpaceInputBindings.InputBinding[]} The default drag input bindings. + */ + static _getDefaultDragInputs() { + return [ + Object.freeze({ + button: MouseButton.LEFT, + modifier: KeyboardEventModifier.CTRL, + }), + Object.freeze({ + button: MouseButton.RIGHT, + }), + ]; + } + + /** + * Creates a new instance of ScreenSpaceTiltOrbitCameraController. + * @param {ScreenSpaceTiltOrbitCameraController.ControllerOptions} [options] The options for configuring the controller. + * @constructor + */ + constructor(options = Frozen.EMPTY_OBJECT) { + this._enabled = true; + this._handler = undefined; + this._lastUpdateTime = undefined; + + /** + * Enabled dragging to tilt the camera. + * @type {boolean} + * @default true + */ + this.tiltEnabled = true; + + /** + * Enabled dragging to orbit the camera. + * @type {boolean} + * @default true + */ + this.orbitEnabled = true; + + /** + * If false, the camera will orbit and tilt around the position at the center of the screen. If true, the camera will orbit and tilt around the position under the cursor or tap when dragging starts. + * @type {boolean} + * @default false + */ + this.useDragPosition = false; + + /** + * The drag input bindings that control tilting. Each binding is a combination of the mouse button + * and an optional keyboard modifier. + * @type {ScreenSpaceInputBindings.InputBinding[]} + * @see ScreenSpaceEventHandler + */ + this.dragInputs = + options.dragInputs ?? + ScreenSpaceTiltOrbitCameraController._getDefaultDragInputs(); + + this._isDragging = false; + this._dragDelta = new Cartesian2(); + this._screenSpaceDragPosition = new Cartesian2(); + this._screenSpaceOrigin = new Cartesian2(); + + this._origin = new Cartesian3(); + this._shouldPickTarget = true; + this._target = new Cartesian3(); + this._axis = new Cartesian3(); + + /** + * The amount at which the camera tilts per dragged pixel. A value of 1.0 means that dragging the mouse across the entire canvas will tilt the camera by 90 degrees. + * @type {number} + * @default 2.0 + */ + this.tiltMagnitude = 2.0; + + /** + * Specifies the length of time in seconds in which a single tilt animation completes. + * @type {number} + * @default 0.0045 + */ + this.tiltAnimationDuration = 0.0045; + + /** + * The maximum tilt velocity in radians per second. A value of Number.POSITIVE_INFINITY means that the maximum tilt velocity is unbounded. + * @type {number} + * @default CesiumMath.PI + */ + this.maximumTiltVelocity = CesiumMath.PI; + + /** + * @private + * @type {number} + * @default CesiumMath.EPSILON20 + */ + this.minimumTiltVelocity = CesiumMath.EPSILON20; + + /** + * The minimum tilt angle in radians from the zenith, or the ellipsoid surface normal, at the tilt origin at which the camera can orbit. + * @type {number} + * @default CesiumMath.toRadians(5) + */ + this.minimumOrbitTiltAngle = CesiumMath.toRadians(5); + + /** + * The amount at which the camera orbits per dragged pixel. A value of 1.0 means that dragging the mouse across the entire canvas will orbit the camera by 180 degrees. + * @type {number} + * @default 2.0 + */ + this.orbitMagnitude = 2.0; + + /** + * Specifies the length of time in seconds in which a single orbit animation completes. + * @type {number} + * @default 0.0045 + */ + this.orbitAnimationDuration = 0.0045; + + /** + * The maximum orbit velocity in radians per second. A value of Number.POSITIVE_INFINITY means that the maximum orbit velocity is unbounded. + * @type {number} + * @default CesiumMath.TWO_PI + */ + this.maximumOrbitVelocity = CesiumMath.TWO_PI; + + /** + * @private + * @type {number} + * @default CesiumMath.EPSILON20 + */ + this.minimumOrbitVelocity = CesiumMath.EPSILON20; + + this._tiltAxis = new Cartesian3(); + this._tiltQuaternion = new Quaternion(); + this._tiltOffset = new Cartesian3(); + this._tiltOrigin = new Cartesian3(); + this._tiltDampenedResults = { + velocity: 0.0, + value: 0.0, + }; + + this._orbitTargetEnu = new Matrix4(); + this._orbitTargetEast = new Cartesian3(); + this._orbitQuaternion = new Quaternion(); + this._orbitOffset = new Cartesian3(); + this._orbitLookOffset = new Cartesian3(); + this._orbitOrigin = new Cartesian3(); + this._orbitDampenedResults = { + velocity: 0.0, + value: 0.0, + }; + + /** + * A parameter in the range [0, 1) used to limit the range + * of inputs to a percentage of the window width/height per animation frame. + * This helps keep the camera under control in low-frame-rate situations. + * @type {number} + * @default 0.1 + */ + this.maximumMovementRatio = 0.1; + } + + /** + * @inheritdoc + */ + get enabled() { + return this._enabled; + } + set enabled(value) { + this._enabled = value; + + if (value) { + this._lastUpdateTime = getTimestamp(); + this._dragDelta.x = 0; + this._dragDelta.y = 0; + } else { + this._isDragging = false; + } + } + + /** + * @inheritdoc + * @param {HTMLElement} element The DOM element containing the Cesium scene. + */ + connectedCallback(element) { + const handler = new ScreenSpaceEventHandler(element); + this._handler = handler; + + ScreenSpaceInputBindings.registerDragInputBindings( + handler, + this.dragInputs, + { + start: this._handleStartDrag.bind(this), + end: this._handleStopDrag.bind(this), + move: this._handleDrag.bind(this), + }, + ); + } + + /** + * @inheritdoc + * @param {HTMLElement} element The DOM element containing the Cesium scene. + */ + disconnectedCallback(element) { + const handler = this._handler; + if (defined(handler) && !handler.isDestroyed()) { + handler.destroy(); + } + } + + /** + * @inheritdoc + */ + firstUpdate() { + this._lastUpdateTime = getTimestamp(); + this._dragDelta.x = 0; + this._dragDelta.y = 0; + } + + /** + * @typedef {object} StartDragEvent + * @memberof ScreenSpaceTiltOrbitCameraController + * @property {Cartesian2} position The position of the mouse when the drag started. + */ + + /** + * @private + * @param {StartDragEvent} event + */ + _handleStartDrag(event) { + if (!this.enabled) { + return; + } + + this._isDragging = true; + this._shouldPickTarget = true; + this._screenSpaceDragPosition.x = event.position.x; + this._screenSpaceDragPosition.y = event.position.y; + this._dragDelta.x = 0; + this._dragDelta.y = 0; + } + + _handleStopDrag() { + this._isDragging = false; + } + + /** + * @typedef {object} DragEvent + * @memberOf ScreenSpaceTiltOrbitCameraController + * @property {Cartesian2} startPosition The position of the mouse when the drag started. + * @property {Cartesian2} endPosition The position of the mouse when the drag ended. + */ + + /** + * @private + * @param {DragEvent} event + */ + _handleDrag(event) { + if (!this._isDragging) { + return; + } + + this._dragDelta.x += event.endPosition.x - event.startPosition.x; + this._dragDelta.y += event.endPosition.y - event.startPosition.y; + } + + /** + * The current tilt angle of the camera in radians. A value of 0.0 means that the camera is looking straight down at the ellipsoid, and a value of PI/2 means that the camera is looking at the horizon. + * @type {number} + * @private + */ + get tiltAngle() { + return this._tiltDampenedResults.value; + } + + /** + * The current tilt velocity of the camera in radians per second. + * @type {number} + * @private + */ + get tiltVelocity() { + return this._tiltDampenedResults.velocity; + } + + /** + * The current tilt velocity of the camera in radians per second. + * @type {number} + * @private + */ + set tiltVelocity(value) { + this._tiltDampenedResults.velocity = value; + } + + /** + * The current orbit angle of the camera in radians around the target. A value of 0.0 means that the camera is looking at the target from the east, and a value of PI/2 means that the camera is looking at the target from the north. + * @type {number} + * @private + */ + get orbitAngle() { + return this._orbitDampenedResults.value; + } + + /** + * The current orbit velocity of the camera in radians per second. + * @type {number} + * @private + */ + get orbitVelocity() { + return this._orbitDampenedResults.velocity; + } + + /** + * The current orbit velocity of the camera in radians per second. + * @type {number} + * @private + */ + set orbitVelocity(value) { + this._orbitDampenedResults.velocity = value; + } + + /** + * Attempts to orbit the camera around the specified origin by the specified amount in radians. Positive values orbit the camera clockwise, negative values orbit the camera counterclockwise. If the drag origin is not on the ellipsoid, no orbit is applied. + * @param {Camera} camera The camera to orbit. + * @param {Cartesian3} target The origin position to orbit around in world coordinates. + * @param {Cartesian3} axis The axis to orbit around, typically the negative of the surface normal at the target position. + * @param {number} amount The amount to orbit the camera in radians. Positive values orbit the camera clockwise, negative values orbit the camera counterclockwise. + * @param {number} dt The time delta in seconds since the last update. + * @param {Ellipsoid} [ellipsoid=Ellipsoid.default] The ellipsoid to pick for the orbit origin. If undefined, the default ellipsoid is used. + */ + orbit(camera, target, axis, amount, dt, ellipsoid = Ellipsoid.default) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.object("camera", camera); + Check.typeOf.object("target", target); + Check.typeOf.object("axis", axis); + Check.typeOf.number("amount", amount); + Check.typeOf.number.greaterThan("dt", dt, 0); + Check.typeOf.object("ellipsoid", ellipsoid); + //>>includeEnd('debug'); + + const currentTiltAngle = Cartesian3.angleBetween(camera.direction, axis); + if (currentTiltAngle < this.minimumOrbitTiltAngle) { + return; + } + + const enu = Transforms.eastNorthUpToFixedFrame( + target, + ellipsoid, + this._orbitTargetEnu, + ); + const east = Matrix4.multiplyByPointAsVector( + enu, + Cartesian3.UNIT_X, + this._orbitTargetEast, + ); + const currentOrbitAngle = Cartesian3.angleBetween(camera.directionWC, east); + + if (Math.abs(this.orbitVelocity) < this.minimumOrbitVelocity) { + this.orbitVelocity = 0.0; + } + + // Apply inertia + if (!this._isDragging) { + amount += this.orbitVelocity * dt; + } + + if (amount === 0.0) { + return; + } + + const targetOrbitAngle = currentOrbitAngle + amount; + + // Apply critical dampening + this._orbitDampenedResults = CesiumMath.smoothDamp( + currentOrbitAngle, + targetOrbitAngle, + this.orbitVelocity, + dt, + this.maximumOrbitVelocity * this.orbitMagnitude, + this.orbitAnimationDuration, + this._orbitDampenedResults, + ); + + const theta = this.orbitAngle - currentOrbitAngle; + const rotation = Matrix3.fromQuaternion( + Quaternion.fromAxisAngle(axis, -theta, this._orbitQuaternion), + ); + + const targetOffset = Cartesian3.subtract( + camera.positionWC, + target, + this._orbitOffset, + ); + const t = Cartesian3.dot(targetOffset, camera.directionWC); + const offset = Cartesian3.multiplyByScalar( + camera.directionWC, + t, + this._orbitLookOffset, + ); + const lookOffset = Cartesian3.subtract( + targetOffset, + offset, + this._orbitLookOffset, + ); + + const rotatedTargetOffset = Matrix3.multiplyByVector( + rotation, + targetOffset, + this._orbitOffset, + ); + + const rotatedLookOffset = Matrix3.multiplyByVector( + rotation, + lookOffset, + this._orbitLookOffset, + ); + + Cartesian3.add(target, rotatedTargetOffset, camera.position); + + const lookTarget = Cartesian3.add( + target, + rotatedLookOffset, + this._orbitOrigin, + ); + camera.lookAtWorldPosition(lookTarget, ellipsoid); + } + + /** + * Attempts to tilt the camera by the specified amount in radians. Positive values tilt the camera down, negative values tilt the camera up. If the drag origin is not on the ellipsoid, no tilt is applied. + * @param {Camera} camera The camera to tilt. + * @param {Cartesian3} target The origin position to tilt around in world coordinates. + * @param {Cartesian3} axis The axis to tilt around, typically the negative of the surface normal at the target position. + * @param {number} amount The amount to tilt the camera in radians. Positive values tilt the camera down, negative values tilt the camera up. + * @param {number} dt The time delta in seconds since the last update. Value must be greater than 0. + * @param {Ellipsoid} [ellipsoid=Ellipsoid.default] The ellipsoid to pick for the tilt origin. If undefined, the default ellipsoid is used. + */ + tilt(camera, target, axis, amount, dt, ellipsoid = Ellipsoid.default) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.object("camera", camera); + Check.typeOf.object("target", target); + Check.typeOf.object("axis", axis); + Check.typeOf.number("amount", amount); + Check.typeOf.number.greaterThan("dt", dt, 0); + Check.typeOf.object("ellipsoid", ellipsoid); + //>>includeEnd('debug'); + + const currentTiltAngle = Cartesian3.angleBetween(camera.direction, axis); + + if (Math.abs(this.tiltVelocity) < this.minimumTiltVelocity) { + this.tiltVelocity = 0.0; + } + + // Apply inertia + if (!this._isDragging) { + amount += this.tiltVelocity * dt; + } + + if (amount === 0.0) { + return; + } + + const targetRotationAngle = currentTiltAngle + amount; + + CesiumMath.smoothDamp( + currentTiltAngle, + targetRotationAngle, + this.tiltVelocity, + dt, + this.maximumTiltVelocity * this.tiltMagnitude, + this.tiltAnimationDuration, + this._tiltDampenedResults, + ); + + const theta = this.tiltAngle - currentTiltAngle; + const rotation = Matrix3.fromQuaternion( + Quaternion.fromAxisAngle(camera.rightWC, -theta, this._tiltQuaternion), + ); + + const offset = Cartesian3.subtract( + camera.position, + target, + this._tiltOffset, + ); + const t = Cartesian3.dot(offset, camera.directionWC); + const lookOffset = Cartesian3.multiplyByScalar( + camera.directionWC, + t, + this._tiltOffset, + ); + + const lookTarget = Cartesian3.subtract( + camera.position, + lookOffset, + this._tiltOrigin, + ); + + const rotatedOffset = Matrix3.multiplyByVector( + rotation, + lookOffset, + this._tiltOffset, + ); + + Cartesian3.add(lookTarget, rotatedOffset, camera.position); + camera.lookAtWorldPosition(lookTarget, ellipsoid); + } + + /** + * @inheritdoc + * @param {Scene} scene + */ + update(scene) { + const dt = + (getTimestamp() - this._lastUpdateTime) * + TimeConstants.SECONDS_PER_MILLISECOND; + + const { camera, ellipsoid, canvas } = scene; + const { clientWidth, clientHeight } = canvas; + if (dt === 0 || clientWidth === 0 || clientHeight === 0) { + // Reset for next frame + this._lastUpdateTime = getTimestamp(); + this._dragDelta.x = 0; + this._dragDelta.y = 0; + return; + } + + const screenSpaceOrigin = this._screenSpaceOrigin; + screenSpaceOrigin.x = clientWidth / 2.0; + screenSpaceOrigin.y = clientHeight / 2.0; + const origin = camera.pickEllipsoid( + screenSpaceOrigin, + ellipsoid, + this._origin, + ); + + if (defined(origin)) { + let target = origin; + if (this._isDragging && this.useDragPosition) { + target = this._target; + if (this._shouldPickTarget) { + const dragPositionTarget = camera.pickEllipsoid( + this._screenSpaceDragPosition, + ellipsoid, + this._target, + ); + const picked = defined(dragPositionTarget); + this._shouldPickTarget = !picked; + target = picked ? dragPositionTarget : origin; + } + } + + const normal = ellipsoid.geodeticSurfaceNormal(target, this._axis); + + const axis = Cartesian3.negate(normal, this._axis); + + if (this.orbitEnabled) { + let dx = this._dragDelta.x / clientWidth; + dx = CesiumMath.clamp( + dx, + -this.maximumMovementRatio, + this.maximumMovementRatio, + ); + dx *= this.orbitMagnitude * CesiumMath.TWO_PI; + + this.orbit(camera, target, axis, dx, dt, ellipsoid); + } + + if (this.tiltEnabled) { + let dy = this._dragDelta.y / clientHeight; + dy = CesiumMath.clamp( + dy, + -this.maximumMovementRatio, + this.maximumMovementRatio, + ); + dy *= this.tiltMagnitude * CesiumMath.PI; + this.tilt(camera, target, axis, dy, dt, ellipsoid); + } + } + + // Reset for next frame + this._lastUpdateTime = getTimestamp(); + this._dragDelta.x = 0; + this._dragDelta.y = 0; + } +} + +export default ScreenSpaceTiltOrbitCameraController; diff --git a/packages/engine/Source/Scene/Controllers/ScreenSpaceZoomCameraController.js b/packages/engine/Source/Scene/Controllers/ScreenSpaceZoomCameraController.js new file mode 100644 index 000000000000..091178152b93 --- /dev/null +++ b/packages/engine/Source/Scene/Controllers/ScreenSpaceZoomCameraController.js @@ -0,0 +1,184 @@ +import Cartesian2 from "../../Core/Cartesian2.js"; +import Cartesian3 from "../../Core/Cartesian3.js"; +import defined from "../../Core/defined.js"; +import Frozen from "../../Core/Frozen.js"; +import getTimestamp from "../../Core/getTimestamp.js"; +import ScreenSpaceEventHandler from "../../Core/ScreenSpaceEventHandler.js"; +import ScreenSpaceEventType from "../../Core/ScreenSpaceEventType.js"; +import TimeConstants from "../../Core/TimeConstants.js"; + +/** + * @typedef {object} ControllerOptions + * @memberof ScreenSpaceZoomCameraController + */ + +/** + * A camera controller that allows zooming the camera in and out based on the pointer location in screen space. + * @class + * @alias ScreenSpaceZoomCameraController + * @implements Controller + * @example + * TODO + */ +class ScreenSpaceZoomCameraController { + /** + * Creates a new instance of ScreenSpaceZoomCameraController. + * @param {ScreenSpaceZoomCameraController.ControllerOptions} [options] The options for configuring the controller. + * @constructor + */ + constructor(options = Frozen.EMPTY_OBJECT) { + this._enabled = true; + this._handler = undefined; + this._lastUpdateTime = undefined; + + // TODO: Input maps + + /** + * The rate at which the camera zooms in and out based on the mouse wheel delta. + * @type {number} + * @default 0.1 + */ + this.zoomSensitivity = 0.1; + + /** + * The ratio of the camera's distance to the zoom target that defines how much the camera zooms in and out per second. + * @type {number} + * @default 0.06 + */ + this.zoomDistanceRatio = 0.06; + + /** + * The rate at which the camera's zoom velocity decays over time. + * @type {number} + * @default 6.0 + */ + this.inertiaDecay = 6.0; + + // TODO: Maximum distance + + this._zoomDelta = 0.0; + this._zoomPosition = new Cartesian2(); + this._target = new Cartesian3(); + this._zoomVelocity = 0.0; + } + + /** + * @inheritdoc + */ + get enabled() { + return this._enabled; + } + set enabled(value) { + this._enabled = value; + + if (value) { + this._lastUpdateTime = getTimestamp(); + } + } + + /** + * @inheritdoc + * @param {HTMLElement} element The DOM element containing the Cesium scene. + */ + connectedCallback(element) { + const handler = new ScreenSpaceEventHandler(element); + this._handler = handler; + + handler.setInputAction( + this._handleZoom.bind(this), + ScreenSpaceEventType.WHEEL, + ); + + handler.setInputAction( + this._handleZoomPosition.bind(this), + ScreenSpaceEventType.MOUSE_MOVE, + ); + } + + /** + * @inheritdoc + * @param {HTMLElement} element The DOM element containing the Cesium scene. + */ + disconnectedCallback(element) { + const handler = this._handler; + if (defined(handler) && !handler.isDestroyed()) { + handler.destroy(); + } + } + + /** + * @inheritdoc + */ + firstUpdate() { + this._lastUpdateTime = getTimestamp(); + this._zoomDelta = 0.0; + } + + /** + * @inheritdoc + * @param {Scene} scene + */ + update(scene) { + const now = getTimestamp(); + const dt = + (now - this._lastUpdateTime) * TimeConstants.SECONDS_PER_MILLISECOND; + + if (dt === 0 || Cartesian2.magnitude(this._zoomPosition) <= 0.0) { + this._lastUpdateTime = getTimestamp(); + this._zoomDelta = 0.0; + return; + } + + const dz = this._zoomDelta; + // TODO: Inertia + + const { camera, ellipsoid } = scene; + const target = camera.pickEllipsoid( + this._zoomPosition, + ellipsoid, + this._target, + ); + let distance = + Cartesian3.magnitude(camera.positionWC) - ellipsoid.maximumRadius; + + if (defined(target)) { + distance = Cartesian3.distance(camera.positionWC, target); + } + + const zoom = dz * distance * this.zoomDistanceRatio; + this._zoomVelocity = zoom / dt; + + // TODO: To target, not center of screen + camera.move(camera.direction, zoom); + + // Reset for next frame + this._lastUpdateTime = getTimestamp(); + this._zoomDelta = 0.0; + } + + /** + * @private + * @param {number} amount + */ + _handleZoom(amount) { + this._zoomDelta += amount * this.zoomSensitivity; + } + + /** + * @typedef {object} DragEvent + * @memberof ScreenSpaceZoomCameraController + * @property {Cartesian2} startPosition The position of the mouse when the drag started. + * @property {Cartesian2} endPosition The position of the mouse when the drag ended. + */ + + /** + * @param {DragEvent} event + * @private + */ + _handleZoomPosition(event) { + this._zoomPosition.x = event.endPosition.x; + this._zoomPosition.y = event.endPosition.y; + } +} + +export default ScreenSpaceZoomCameraController; diff --git a/packages/engine/Source/Scene/Scene.js b/packages/engine/Source/Scene/Scene.js index ae8a45e06d69..4149a01de610 100644 --- a/packages/engine/Source/Scene/Scene.js +++ b/packages/engine/Source/Scene/Scene.js @@ -45,6 +45,7 @@ import BrdfLutGenerator from "./BrdfLutGenerator.js"; import Camera from "./Camera.js"; import Cesium3DTilePass from "./Cesium3DTilePass.js"; import Cesium3DTilePassState from "./Cesium3DTilePassState.js"; +import ControllerHost from "./Controllers/ControllerHost.js"; import CreditDisplay from "./CreditDisplay.js"; import DebugCameraPrimitive from "./DebugCameraPrimitive.js"; import DepthPlane from "./DepthPlane.js"; @@ -241,6 +242,8 @@ function Scene(options) { this._preRender = new Event(); this._postRender = new Event(); + this._controllerHost = new ControllerHost(); + this._minimumDisableDepthTestDistance = 0.0; this._debugInspector = new DebugInspector(); @@ -1079,6 +1082,25 @@ Object.defineProperties(Scene.prototype, { }, }, + /** + * Collects an array of Controller objects that can be registered with the scene to handle input events, camera animations, and other interactions. + * @see {@link Controller} + * @type {ControllerHost} + * @memberof Scene.prototype + * @readonly + * @example + * scene.screenSpaceCameraController.enableInputs = false; + * scene.screenSpaceCameraController.enableCollisionDetection = false; + * + * const tiltOrbitController = new Cesium.ScreenSpaceTiltOrbitCameraController(); + * scene.controllerHost.registerController(tiltOrbitController, scene.canvas.parentNode); + */ + controllerHost: { + get: function () { + return this._controllerHost; + }, + }, + /** * Gets the controller for camera input handling. * @memberof Scene.prototype @@ -4432,6 +4454,8 @@ Scene.prototype.render = function (time) { time = JulianDate.now(); } + this._controllerHost.update(this, time); + const cameraChanged = this._view.checkForCameraUpdates(this); if (cameraChanged) { this._globeHeightDirty = true; diff --git a/packages/engine/Source/Widget/CesiumWidget.js b/packages/engine/Source/Widget/CesiumWidget.js index 9444cdb4b663..4fc1b5e7dfdb 100644 --- a/packages/engine/Source/Widget/CesiumWidget.js +++ b/packages/engine/Source/Widget/CesiumWidget.js @@ -1227,6 +1227,36 @@ CesiumWidget.prototype._onDataSourceRemoved = function ( } }; +/** + * Adds a controller— an implementation of the {@link Controller} interface used to handle input events, camera animations, and other interactions— to the widget's scene. + * @param {Controller} controller An implementation of the Controller interface. + * @example + * widget.scene.screenSpaceCameraController.enableInputs = false; + * widget.scene.screenSpaceCameraController.enableCollisionDetection = false; + * + * const tiltOrbitController = new Cesium.ScreenSpaceTiltOrbitCameraController(); + * widget.addController(tiltOrbitController); + */ +CesiumWidget.prototype.addController = function (controller) { + return this.scene.controllerHost.registerController( + controller, + this.container, + ); +}; + +/** + * Removes a controller— an implementation of the {@link Controller} interface used to handle input events, camera animations, and other interactions— from the widget's scene. + * @param {Controller} controller An implementation of the Controller interface. + * @example + * widget.removeController(tiltOrbitController); + */ +CesiumWidget.prototype.removeController = function (controller) { + return this.scene.controllerHost.unregisterController( + controller, + this.container, + ); +}; + /** * Asynchronously sets the camera to view the provided entity, entities, or data source. * If the data source is still in the process of loading or the visualization is otherwise still loading, diff --git a/packages/engine/Specs/Scene/CameraSpec.js b/packages/engine/Specs/Scene/CameraSpec.js index 584aacd4997a..10ca587a5cdd 100644 --- a/packages/engine/Specs/Scene/CameraSpec.js +++ b/packages/engine/Specs/Scene/CameraSpec.js @@ -2193,6 +2193,123 @@ describe("Scene/Camera", function () { }).toThrowDeveloperError(); }); + it("lookAtWorldPosition", function () { + const tempCamera = Camera.clone(camera); + const target = Cartesian3.fromDegrees(-75.0, 40.0, 0.0); + + tempCamera.lookAtWorldPosition(target); + + const expectedDirection = new Cartesian3( + 0.19881574, + -0.74199046, + 0.64025187, + ); + const expectedRight = new Cartesian3(-0.96592583, -0.25881905, 0.0); + const expectedUp = new Cartesian3(-0.16570938, 0.61843582, 0.76816505); + + expect(tempCamera.directionWC).toEqualEpsilon( + expectedDirection, + CesiumMath.EPSILON8, + ); + expect(tempCamera.rightWC).toEqualEpsilon( + expectedRight, + CesiumMath.EPSILON8, + ); + expect(tempCamera.upWC).toEqualEpsilon(expectedUp, CesiumMath.EPSILON8); + }); + + it("lookAtWorldPosition uses Ellipsoid.default when ellipsoid is omitted", function () { + const originalDefaultEllipsoid = Ellipsoid.default; + Ellipsoid.default = Ellipsoid.UNIT_SPHERE; + + try { + const target = new Cartesian3(0.0, 1.0, 0.0); + const cameraWithDefault = Camera.clone(camera); + const cameraWithExplicit = Camera.clone(camera); + + cameraWithDefault.lookAtWorldPosition(target); + cameraWithExplicit.lookAtWorldPosition(target, Ellipsoid.UNIT_SPHERE); + + expect(cameraWithDefault.directionWC).toEqualEpsilon( + cameraWithExplicit.directionWC, + CesiumMath.EPSILON12, + ); + expect(cameraWithDefault.rightWC).toEqualEpsilon( + cameraWithExplicit.rightWC, + CesiumMath.EPSILON12, + ); + expect(cameraWithDefault.upWC).toEqualEpsilon( + cameraWithExplicit.upWC, + CesiumMath.EPSILON12, + ); + } finally { + Ellipsoid.default = originalDefaultEllipsoid; + } + }); + + it("lookAtWorldPosition when target equals camera position", function () { + const tempCamera = Camera.clone(camera); + const target = Cartesian3.clone(tempCamera.positionWC, new Cartesian3()); + const directionBefore = Cartesian3.clone( + tempCamera.directionWC, + new Cartesian3(), + ); + + tempCamera.lookAtWorldPosition(target); + + expect(tempCamera.directionWC).toEqualEpsilon( + directionBefore, + CesiumMath.EPSILON12, + ); + expect(Cartesian3.magnitude(tempCamera.directionWC)).toEqualEpsilon( + 1.0, + CesiumMath.EPSILON12, + ); + expect(Cartesian3.magnitude(tempCamera.upWC)).toEqualEpsilon( + 1.0, + CesiumMath.EPSILON12, + ); + expect(Cartesian3.magnitude(tempCamera.rightWC)).toEqualEpsilon( + 1.0, + CesiumMath.EPSILON12, + ); + }); + + it("lookAtWorldPosition with non-identity transform", function () { + const tempCamera = Camera.clone(camera); + const ellipsoid = Ellipsoid.WGS84; + const transform = Transforms.eastNorthUpToFixedFrame( + Cartesian3.fromDegrees(-75.0, 40.0, 0.0), + ellipsoid, + ); + tempCamera._setTransform(transform); + + const target = Cartesian3.fromDegrees(-75.1, 40.0, 150.0); + tempCamera.lookAtWorldPosition(target, ellipsoid); + + const expectedDirectionWC = new Cartesian3( + 0.19752041, + -0.74233628, + 0.64025193, + ); + expect(tempCamera.directionWC).toEqualEpsilon( + expectedDirectionWC, + CesiumMath.EPSILON8, + ); + }); + + it("lookAtWorldPosition throws with no target parameter", function () { + expect(function () { + camera.lookAtWorldPosition(undefined, Ellipsoid.WGS84); + }).toThrowDeveloperError(); + }); + + it("lookAtWorldPosition throws with no ellipsoid parameter", function () { + expect(function () { + camera.lookAtWorldPosition(Cartesian3.ZERO, undefined); + }).toThrowDeveloperError(); + }); + it("lookAtTransform", function () { const target = new Cartesian3(-1.0, -1.0, 0.0); const offset = new Cartesian3(1.0, 1.0, 0.0); diff --git a/packages/sandcastle/gallery/camera-controllers/index.html b/packages/sandcastle/gallery/camera-controllers/index.html new file mode 100644 index 000000000000..776ff57f4a5a --- /dev/null +++ b/packages/sandcastle/gallery/camera-controllers/index.html @@ -0,0 +1,24 @@ + +
+

Loading...

+
+ + + + + + + + + + + + + + + + +
Camera function
Pan
Tilt
Orbit
Zoom
+
diff --git a/packages/sandcastle/gallery/camera-controllers/main.js b/packages/sandcastle/gallery/camera-controllers/main.js new file mode 100644 index 000000000000..28959d0ca18f --- /dev/null +++ b/packages/sandcastle/gallery/camera-controllers/main.js @@ -0,0 +1,39 @@ +import * as Cesium from "cesium"; + +const viewer = new Cesium.Viewer("cesiumContainer"); +const scene = viewer.scene; + +// Disable the default camera controls +scene.screenSpaceCameraController.enableInputs = false; +scene.screenSpaceCameraController.enableCollisionDetection = false; + +// Set up the modular camera controllers +const panController = new Cesium.HybridScreenSpacePanCameraController(); +viewer.addController(panController); + +const tiltController = new Cesium.ScreenSpaceTiltOrbitCameraController(); +viewer.addController(tiltController); + +const zoomController = new Cesium.ScreenSpaceZoomCameraController(); +viewer.addController(zoomController); + +// Load a 3D Tiles power plant asset +try { + const tileset = await Cesium.Cesium3DTileset.fromIonAssetId(2464651); + scene.primitives.add(tileset); + + viewer.clock.currentTime = Cesium.JulianDate.fromIso8601( + "2022-08-01T00:00:00Z", + ); + + viewer.zoomTo( + tileset, + new Cesium.HeadingPitchRange( + 0.5, + -0.2, + tileset.boundingSphere.radius * 4.0, + ), + ); +} catch (error) { + console.log(`Error loading tileset: ${error}`); +} diff --git a/packages/sandcastle/gallery/camera-controllers/sandcastle.yaml b/packages/sandcastle/gallery/camera-controllers/sandcastle.yaml new file mode 100644 index 000000000000..b0b970fb79f3 --- /dev/null +++ b/packages/sandcastle/gallery/camera-controllers/sandcastle.yaml @@ -0,0 +1,5 @@ +title: Camera Controllers for Asset Inspection +description: Configure modular camera controllers (pan, orbit, tilt, and zoom) to inspect 3D assets in a scene. This example includes contextual panning that allows the camera to move either horizontally or vertically based on the camera's orientation. +labels: + - Camera +thumbnail: thumbnail.jpg diff --git a/packages/sandcastle/gallery/camera-controllers/thumbnail.jpg b/packages/sandcastle/gallery/camera-controllers/thumbnail.jpg new file mode 100644 index 000000000000..23dadd4bacd4 Binary files /dev/null and b/packages/sandcastle/gallery/camera-controllers/thumbnail.jpg differ diff --git a/packages/widgets/Source/Viewer/Viewer.js b/packages/widgets/Source/Viewer/Viewer.js index 530f256ac38f..3797e149d1ab 100644 --- a/packages/widgets/Source/Viewer/Viewer.js +++ b/packages/widgets/Source/Viewer/Viewer.js @@ -1973,6 +1973,30 @@ Viewer.prototype._onDataSourceRemoved = function ( this._dataSourceChangedListeners[id] = undefined; }; +/** + * Adds a controller— an implementation of the {@link Controller} interface used to handle input events, camera animations, and other interactions— to the viewer's scene. + * @param {Controller} controller An implementation of the Controller interface. + * @example + * viewer.scene.screenSpaceCameraController.enableInputs = false; + * viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; + * + * const tiltOrbitController = new Cesium.ScreenSpaceTiltOrbitCameraController(); + * viewer.addController(tiltOrbitController); + */ +Viewer.prototype.addController = function (controller) { + return this._cesiumWidget.addController(controller); +}; + +/** + * Removes a controller— an implementation of the {@link Controller} interface used to handle input events, camera animations, and other interactions— from the viewer's scene. + * @param {Controller} controller An implementation of the Controller interface. + * @example + * viewer.removeController(tiltOrbitController); + */ +Viewer.prototype.removeController = function (controller) { + return this._cesiumWidget.removeController(controller); +}; + /** * Asynchronously sets the camera to view the provided entity, entities, or data source. * If the data source is still in the process of loading or the visualization is otherwise still loading,