Skip to content

Camera controllers for asset inspection#13604

Open
ggetz wants to merge 11 commits into
mainfrom
screenspace-camera-controllers
Open

Camera controllers for asset inspection#13604
ggetz wants to merge 11 commits into
mainfrom
screenspace-camera-controllers

Conversation

@ggetz

@ggetz ggetz commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Known TODOs:

Required before merge:

  • Type checks
  • Verify built documentation
  • Complete zoom controller
  • Update CHANGES.md
  • Cleanup sandcastle example
  • Test on touch device

Optional/Follow-ups

  • Maybe: Add optional maximum tilt angle to prevent unground tilt
  • Controller unit tests—Will likely submit as a follow-up given timeline (and open issue to track)
  • Controllers have unknown behavior in 2D—Will test and add early returns as needed, documenting limitations (and open issue to track, if needed)

Description

This PR:

  1. Creates a lightweight, extensible Controller framework, as discussed in Extensible camera controller architecture #13473.
  2. Implements the following camera functions across composable, decoupled Controller classes. These are designed as a drop-in alternative to the default ScreenspaceCameraController for more simple camera controls scoped for an asset inspect use case.

Camera function matrix

Controller Function Default input Implementation notes
ScreenspaceMapCameraController Map pan - Left mouse click and drag
- Single touch drag
- Pans in plane tangential to ellipsoid normal
- Panning plane originates at pointer position
- Inertia with configurable exponential decay
ScreenspaceElevatorCameraController Elevator pan   - Pans in plane perpendicular to ellipsoid normal
- Panning plane originates at pointer position
- Inertia with configurable exponential decay
HybridScreenspacePanCameraController Contextual Pan   - Selects between map panning and elevator panning based on configurable angle to ellipsoid normal, both above and below ellipsoid
ScreenspaceTiltOrbitCameraController Tilt - Right mouse drag
- Left mouse button + ctrl
- Two touch drag
- Adjusts camera pitch based on vertical drag
- Maintains camera up relative to ellipsoid normal
- Tilts around screen center position; Fallbacks to pointer position
- Critical damping with configurable interpolation time and max velocity
- Camera is allowed to rotate below ellipsoid (underground)
ScreenspaceTiltOrbitCameraController Orbit   - Adjusts camera heading based on horizontal drag
- Maintains camera up relative to ellipsoid normal
- Orbits around screen center position; Fallbacks to pointer position
- Critical damping with configurable interpolation time and max velocity
ScreenspaceZoomCameraController Zoom - Scroll/Mouse wheel
- Center mouse button drag
- Two touch pinch
- Zooms in or out along in a vector directed towards the pointer position
- Velocity is proportional to distance from target based on configurable percentage
- Configurable maximum distance limit
- Inertia with configurable exponential decay
  • Inertia: Velocity is maintained after input stops, then decays exponentially. A version of inertia already exists in the current default ScreenspaceCameraController .
  • Critical damping: Continuously smooths input motion, similar to what you see in Google Earth or Sketchfab. Implemented in CesiumMath.smoothDamp with unit tests; Compare to Unity's Mathf.SmoothDamp.
  • Underground & collision: Controllers intentionally do not check collision with terrain or scene primitives to allow simple navigation both underground and through enclosed assets.

Issue number and link

Fixes #13473
CC swisstopo/swissgeol-viewer-suite#1854

Testing plan

TODO: Deployed sandcastle link
TODO: Deployed doc link

  • Validate each camera function in the above matrix against each supported default input binding
  • Remap bindings to alternate mouse buttons/modifiers and verify behavior remains unchanged
  • Review developer-facing docs and controller options for clarity

Author checklist

  • I have submitted a Contributor License Agreement
  • I have added my name to CONTRIBUTORS.md
  • I have updated CHANGES.md with a short summary of my change
  • I have added or updated unit tests to ensure consistent code coverage
  • I have updated the inline documentation, and included code examples where relevant
  • I have performed a self-review of my code

AI acknowledgment

  • I used AI to generate content in this PR
  • If yes, I have reviewed the AI-generated content before submitting

If yes, I used the following Tools(s) and/or Service(s): GitHub Copilot autocomplete

If yes, I used the following Model(s): Various

@github-actions

Copy link
Copy Markdown
Contributor

🔴 There was an error checking the CLA! If this is your first contribution, please send in a Contributor License Agreement.

    Error: Invalid response body while trying to fetch https://www.googleapis.com/oauth2/v4/token: Premature close

Comment thread packages/engine/Source/Scene/Controllers/Controller.js Outdated
Comment thread packages/engine/Source/Scene/Controllers/ControllerHost.js Outdated

@javagl javagl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No context here, just stumbled over this and was curious, because ... #12346 , #12989 , and others.

So very high-level:

I wondered whether the Controller and ControllerHost may need some infrastructure around other state changes, beyond "being added". Right now, there is the firstUpdate function to be called when a controller was added. Do controllers need special treatment depending on whether they have been disabled previously and are enabled in the new frame?

There's a lot of room for generalizations around HybridScreenspacePanCameraController. It's using very specific instances and conditions. One could pull out that angle-based criterion into some 'predicate' and associate that with the respective controller, yielding some CompoundController with pseudocode

const c = new CompoundController();
const condition = () => thatAngleIsBelow(125);
c.register(condition, new ControllerA());
c.register(() => true, new ControllerB());

I also wondered whether there is a conceptual overlap between the ControllerHost and that "Hybrid" controller, insofar that they are ~"both just dispatching input actions to Controller instances". But that may require more thought. Related, or more generally: Whether or not there should be some concept of indicating that ~"an input/event was consumed (and should stop being 'propagated')" may have to be thought through as well.

* interface and is not intended to be instantiated directly.
* @class
* @abstract
* @see {@link HybridScreenspacePanCameraController}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we make casing consistent with ScreenSpaceCameraController? https://cesium.com/learn/cesiumjs/ref-doc/ScreenSpaceCameraController.html?classFilter=ScreenSpace

@lukemckinstry

Copy link
Copy Markdown
Contributor

Posting types diff for easier comprehension

Types diff
diff --git a/Source/Cesium.main.d.ts b/Source/Cesium.d.ts
index 58b1c55252..ca62847bce 100644
--- a/Source/Cesium.main.d.ts
+++ b/Source/Cesium.d.ts
@@ -9788,6 +9788,26 @@ export namespace Math {
      * @returns The linearly interpolated value.
      */
     function lerp(p: number, q: number, time: number): number;
+    /**
+     * @property value - The new value after applying the smooth damp.
+     * @property velocity - The updated current velocity.
+     */
+    type SmoothDampResult = {
+        value: number;
+        velocity: number;
+    };
+    /**
+     * 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 p - The current value.
+     * @param q - The target value.
+     * @param velocity - The current velocity.
+     * @param [deltaTime = 0.0] - The time since the last call to this function. Value must be greater than or equal to 0.0.
+     * @param [maximumSpeed = Number.POSITIVE_INFINITY] - Optionally allows clamping to the specified maximum speed.
+     * @param [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 [result] - An object to store the result. If not provided, a new object will be created and returned.
+     * @returns An object containing the new value and the updated current velocity.
+     */
+    function smoothDamp(p: number, q: number, velocity: number, deltaTime?: number, maximumSpeed?: number, smoothTime?: number, result?: SmoothDampResult): SmoothDampResult;
     /**
      * pi
      */
@@ -28939,6 +28959,13 @@ export class Camera {
      * @param [offset] - The offset from the target in a reference frame centered at the target.
      */
     lookAtTransform(transform: Matrix4, offset?: Cartesian3 | HeadingPitchRange): void;
+    /**
+     * 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 target - The target position in world coordinates.
+     * @param [ellipsoid = Ellipsoid.default] - The ellipsoid to use for determining the world up.
+     */
+    lookAtWorldPosition(target: Cartesian3, ellipsoid?: Ellipsoid): void;
     /**
      * Get the camera position needed to view a rectangle on an ellipsoid or map
      * @param rectangle - The rectangle to view.
@@ -32580,6 +32607,467 @@ export class ConeEmitter {
     constructor(angle?: number);
 }
 
+/**
+ * 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.
+ */
+export class Controller {
+    /**
+     * Determines if the controller is enabled and should be updated by the host scene.
+     */
+    enabled: boolean;
+    /**
+     * Invoked when the controller is added to the DOM. Implement <code>connectedCallback</code> to set up any DOM event listeners.
+     * @param element - The DOM element containing the Cesium scene.
+     */
+    connectedCallback(element: HTMLElement): void;
+    /**
+     * Invoked when the controller is removed from the DOM. Implement <code>disconnectedCallback</code> to tear down any DOM event listeners.
+     * @param element - The DOM element containing the Cesium scene.
+     */
+    disconnectedCallback(element: HTMLElement): void;
+    /**
+     * Invoked once per frame. Implement <code>update</code> to modify the camera or other parts of the scene.
+     * @param time - The current simulation time.
+     */
+    update(scene: Scene, time: JulianDate): void;
+    /**
+     * Invoked when the controller is being updated the first time, immediately before <code>update</code> is called. Implement <code>firstUpdate</code> to perform one-time work after the relevant scene has begun it's render loop. Some examples might include initializing simulation time values or adding a primitive to the scene.
+     * @param time - The current simulation time.
+     */
+    firstUpdate(scene: Scene, time: JulianDate): void;
+}
+
+/**
+ * Creates an instance of a <code>ControllerHost</code>. Typically, a <code>ControllerHost</code> is created by the Scene constructor and accessed via {@link Scene#controllerHost}.
+ */
+export class ControllerHost {
+    /**
+     * The number of controllers registered to this host.
+     */
+    readonly controllerCount: number;
+    /**
+     * Registers a controller implementation with this host.
+     * @param controller - An implementation of the Controller interface to register with this host.
+     * @param element - The DOM element containing the Cesium scene.
+     * @param [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 <code>0</code> would mean the new controller would apply it's 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., it's updates are applied after all other controllers.
+     */
+    registerController(controller: Controller, element: HTMLElement, priority?: number): void;
+    /**
+     * Unregisters a controller implementation from this host.
+     * @param controller - An implementation of the Controller interface to unregister from this host.
+     * @param element - The DOM element containing the Cesium scene.
+     */
+    unregisterController(controller: Controller, element: HTMLElement): void;
+    /**
+     * Invoked once per frame by the host scene. Updates all registered controllers in order of their priority.
+     * @param scene - The host scene.
+     * @param time - The current simulation time.
+     */
+    update(scene: Scene, time: JulianDate): void;
+}
+
+export interface HybridScreenspacePanCameraController extends Controller {
+}
+
+/**
+ * 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), <code>ScreenspaceMapCameraController</code> is used.
+ * If the camera is looking towards the horizon (beyond angleThreshold from nadir), the <code>ScreenspaceElevatorCameraController</code> is used.
+ * @example
+ * viewer.scene.screenSpaceCameraController.enableInputs = false;
+ * viewer.scene.screenSpaceCameraController.enableCollisionDetection = false;
+ *
+ * const hybridController = new HybridScreenspacePanCameraController();
+ * viewer.addController(hybridController);
+ */
+export class HybridScreenspacePanCameraController implements Controller {
+    /**
+     * 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.
+     */
+    angleThreshold: number;
+    /**
+     * The controller that is used when the camera is looking more horizontally (beyond angleThreshold from nadir).
+     */
+    readonly elevatorController: ScreenspaceElevatorCameraController;
+    /**
+     * The controller that is used when the camera is looking mostly down (within angleThreshold of nadir).
+     */
+    readonly mapController: ScreenspaceMapCameraController;
+    /**
+     * Determines if the controller is enabled and should be updated by the host scene.
+     */
+    enabled: boolean;
+    /**
+     * Invoked when the controller is added to the DOM. Implement <code>connectedCallback</code> to set up any DOM event listeners.
+     * @param element - The DOM element containing the Cesium scene.
+     */
+    connectedCallback(element: HTMLElement): void;
+    /**
+     * Invoked when the controller is removed from the DOM. Implement <code>disconnectedCallback</code> to tear down any DOM event listeners.
+     * @param element - The DOM element containing the Cesium scene.
+     */
+    disconnectedCallback(element: HTMLElement): void;
+    /**
+     * Invoked once per frame. Implement <code>update</code> to modify the camera or other parts of the scene.
+     * @param time - The current simulation time.
+     */
+    update(scene: Scene, time: JulianDate): void;
+    /**
+     * Invoked when the controller is being updated the first time, immediately before <code>update</code> is called. Implement <code>firstUpdate</code> to perform one-time work after the relevant scene has begun it's render loop. Some examples might include initializing simulation time values or adding a primitive to the scene.
+     * @param time - The current simulation time.
+     */
+    firstUpdate(scene: Scene, time: JulianDate): void;
+}
+
+/**
+ * This enumerated type is for classifying mouse buttons: left, middle, and right.
+ */
+export enum MouseButton {
+    /**
+     * Represents a mouse left button.
+     */
+    LEFT = 0,
+    /**
+     * Represents a mouse middle button.
+     */
+    MIDDLE = 1,
+    /**
+     * Represents a mouse right button.
+     */
+    RIGHT = 2
+}
+
+export namespace ScreenspaceElevatorCameraController {
+    /**
+     * @property [dragInputs] - The drag input bindings that control panning.
+     */
+    type ControllerOptions = {
+        dragInputs?: ScreenspaceInputBindings.InputBinding[];
+    };
+    /**
+     * @property startPosition - The position of the mouse when the drag started.
+     * @property endPosition - The position of the mouse when the drag ended.
+     */
+    type DragEvent = {
+        startPosition: Cartesian2;
+        endPosition: Cartesian2;
+    };
+}
+
+export interface ScreenspaceElevatorCameraController extends Controller {
+}
+
+/**
+ * Creates an instance of a ScreenspaceElevatorCameraController.
+ * @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);
+ * @param [options] - The options for configuring the controller.
+ */
+export class ScreenspaceElevatorCameraController implements Controller {
+    constructor(options?: ScreenspaceElevatorCameraController.ControllerOptions);
+    /**
+     * The drag input bindings that control vertical panning. Each binding is a combination of the mouse button
+     * and an optional keyboard modifier.
+     */
+    dragInputs: ScreenspaceInputBindings.InputBinding[];
+    /**
+     * The speed in meters per pixel at which the camera pans.
+     */
+    panSpeed: number;
+    /**
+     * The rate at which the camera's pan velocity decays over time.
+     */
+    inertialDecay: number;
+    /**
+     * A parameter in the range <code>[0, 1)</code> 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.
+     */
+    maximumMovementRatio: number;
+    /**
+     * Determines if the controller is enabled and should be updated by the host scene.
+     */
+    enabled: boolean;
+    /**
+     * Invoked when the controller is added to the DOM. Implement <code>connectedCallback</code> to set up any DOM event listeners.
+     * @param element - The DOM element containing the Cesium scene.
+     */
+    connectedCallback(element: HTMLElement): void;
+    /**
+     * Invoked when the controller is removed from the DOM. Implement <code>disconnectedCallback</code> to tear down any DOM event listeners.
+     * @param element - The DOM element containing the Cesium scene.
+     */
+    disconnectedCallback(element: HTMLElement): void;
+    /**
+     * Invoked once per frame. Implement <code>update</code> to modify the camera or other parts of the scene.
+     * @param time - The current simulation time.
+     */
+    update(scene: Scene, time: JulianDate): void;
+    /**
+     * Invoked when the controller is being updated the first time, immediately before <code>update</code> is called. Implement <code>firstUpdate</code> to perform one-time work after the relevant scene has begun it's render loop. Some examples might include initializing simulation time values or adding a primitive to the scene.
+     * @param time - The current simulation time.
+     */
+    firstUpdate(scene: Scene, time: JulianDate): void;
+}
+
+export namespace ScreenspaceInputBindings {
+    /**
+     * @property button - The mouse button used for drag start/stop.
+     * @property [modifier] - The optional keyboard modifier to register.
+     */
+    type InputBinding = {
+        button: MouseButton;
+        modifier?: number;
+    };
+    /**
+     * @property start - Called on drag start.
+     * @property end - Called on drag stop.
+     * @property move - Called on drag move.
+     */
+    type DragInputActions = {
+        start: (...params: any[]) => any;
+        end: (...params: any[]) => any;
+        move: (...params: any[]) => any;
+    };
+    /**
+     * Registers drag input bindings on a screen space event handler.
+     * @param handler - The screen space event handler.
+     * @param inputBindings - The drag bindings to register.
+     * @param dragInputActions - The callbacks to invoke for drag actions.
+     */
+    function registerDragInputBindings(handler: ScreenSpaceEventHandler, inputBindings: InputBinding[], dragInputActions: DragInputActions): void;
+}
+
+export namespace ScreenspaceMapCameraController {
+    /**
+     * @property [dragInputs] - The drag input bindings that control panning.
+     */
+    type ControllerOptions = {
+        dragInputs?: ScreenspaceInputBindings.InputBinding[];
+    };
+    /**
+     * @property startPosition - The position of the mouse when the drag started.
+     * @property endPosition - The position of the mouse when the drag ended.
+     */
+    type DragEvent = {
+        startPosition: Cartesian2;
+        endPosition: Cartesian2;
+    };
+}
+
+export interface ScreenspaceMapCameraController extends Controller {
+}
+
+/**
+ * Creates an instance of a ScreenspaceMapCameraController.
+ * @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);
+ * @param [options] - The options for configuring the controller.
+ */
+export class ScreenspaceMapCameraController implements Controller {
+    constructor(options?: ScreenspaceMapCameraController.ControllerOptions);
+    /**
+     * The drag input bindings that map panning. Each binding is a combination of the mouse button
+     * and an optional keyboard modifier.
+     */
+    dragInputs: ScreenspaceInputBindings.InputBinding[];
+    /**
+     * The speed in meters per pixel at which the camera pans.
+     */
+    panSpeed: number;
+    /**
+     * The rate at which the camera's pan velocity decays over time.
+     */
+    inertialDecay: number;
+    /**
+     * A parameter in the range <code>[0, 1)</code> 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.
+     */
+    maximumMovementRatio: number;
+    /**
+     * Determines if the controller is enabled and should be updated by the host scene.
+     */
+    enabled: boolean;
+    /**
+     * Invoked when the controller is added to the DOM. Implement <code>connectedCallback</code> to set up any DOM event listeners.
+     * @param element - The DOM element containing the Cesium scene.
+     */
+    connectedCallback(element: HTMLElement): void;
+    /**
+     * Invoked when the controller is removed from the DOM. Implement <code>disconnectedCallback</code> to tear down any DOM event listeners.
+     * @param element - The DOM element containing the Cesium scene.
+     */
+    disconnectedCallback(element: HTMLElement): void;
+    /**
+     * Invoked once per frame. Implement <code>update</code> to modify the camera or other parts of the scene.
+     * @param time - The current simulation time.
+     */
+    update(scene: Scene, time: JulianDate): void;
+    /**
+     * Invoked when the controller is being updated the first time, immediately before <code>update</code> is called. Implement <code>firstUpdate</code> to perform one-time work after the relevant scene has begun it's render loop. Some examples might include initializing simulation time values or adding a primitive to the scene.
+     * @param time - The current simulation time.
+     */
+    firstUpdate(scene: Scene, time: JulianDate): void;
+}
+
+export namespace ScreenspaceTiltOrbitCameraController {
+    /**
+     * @property [dragInputs] - The drag input bindings that control tilting and orbiting.
+     */
+    type ControllerOptions = {
+        dragInputs?: ScreenspaceInputBindings.InputBinding[];
+    };
+    /**
+     * @property position - The position of the mouse when the drag started.
+     */
+    type StartDragEvent = {
+        position: Cartesian2;
+    };
+    /**
+     * @property startPosition - The position of the mouse when the drag started.
+     * @property endPosition - The position of the mouse when the drag ended.
+     */
+    type DragEvent = {
+        startPosition: Cartesian2;
+        endPosition: Cartesian2;
+    };
+}
+
+export interface ScreenspaceTiltOrbitCameraController extends Controller {
+}
+
+/**
+ * Creates a new instance of <code>ScreenspaceTiltOrbitCameraController</code>.
+ * @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);
+ * @param [options] - The options for configuring the controller.
+ */
+export class ScreenspaceTiltOrbitCameraController implements Controller {
+    constructor(options?: ScreenspaceTiltOrbitCameraController.ControllerOptions);
+    /**
+     * Enabled dragging to tilt the camera.
+     */
+    tiltEnabled: boolean;
+    /**
+     * Enabled dragging to orbit the camera.
+     */
+    orbitEnabled: boolean;
+    /**
+     * The drag input bindings that control tilting. Each binding is a combination of the mouse button
+     * and an optional keyboard modifier.
+     */
+    dragInputs: ScreenspaceInputBindings.InputBinding[];
+    /**
+     * 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.
+     */
+    tiltMagnitude: number;
+    /**
+     * Specifies the length of time in seconds in which a single tilt animation completes.
+     */
+    tiltAnimationDuration: number;
+    /**
+     * The maximum tilt velocity in radians per second. A value of Number.POSITIVE_INFINITY means that the maximum tilt velocity is unbounded.
+     */
+    maximumTiltVelocity: number;
+    /**
+     * The minimum tilt angle in radians from the zenith, or the ellipsoid surface normal, at the tilt origin at which the camera can orbit.
+     */
+    minimumOrbitTiltAngle: number;
+    /**
+     * 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.
+     */
+    orbitMagnitude: number;
+    /**
+     * Specifies the length of time in seconds in which a single orbit animation completes.
+     */
+    orbitAnimationDuration: number;
+    /**
+     * The maximum orbit velocity in radians per second. A value of Number.POSITIVE_INFINITY means that the maximum orbit velocity is unbounded.
+     */
+    maximumOrbitVelocity: number;
+    /**
+     * A parameter in the range <code>[0, 1)</code> 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.
+     */
+    maximumMovementRatio: number;
+    /**
+     * 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 - The camera to orbit.
+     * @param target - The target position to orbit around in world coordinates.
+     * @param axis - The axis to orbit around, typically the negative of the surface normal at the target position.
+     * @param amount - The amount to orbit the camera in radians. Positive values orbit the camera clockwise, negative values orbit the camera counterclockwise.
+     * @param dt - The time delta in seconds since the last update.
+     * @param [ellipsoid = Ellipsoid.default] - The ellipsoid to pick for the orbit origin. If undefined, the default ellipsoid is used.
+     */
+    orbit(camera: Camera, target: Cartesian3, axis: Cartesian3, amount: number, dt: number, ellipsoid?: Ellipsoid): void;
+    /**
+     * 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 - The camera to tilt.
+     * @param target - The target position to tilt around in world coordinates.
+     * @param axis - The axis to tilt around, typically the negative of the surface normal at the target position.
+     * @param amount - The amount to tilt the camera in radians. Positive values tilt the camera down, negative values tilt the camera up.
+     * @param dt - The time delta in seconds since the last update. Value must be greater than 0.
+     * @param [ellipsoid = Ellipsoid.default] - The ellipsoid to pick for the tilt origin. If undefined, the default ellipsoid is used.
+     */
+    tilt(camera: Camera, target: Cartesian3, axis: Cartesian3, amount: number, dt: number, ellipsoid?: Ellipsoid): void;
+    /**
+     * Determines if the controller is enabled and should be updated by the host scene.
+     */
+    enabled: boolean;
+    /**
+     * Invoked when the controller is added to the DOM. Implement <code>connectedCallback</code> to set up any DOM event listeners.
+     * @param element - The DOM element containing the Cesium scene.
+     */
+    connectedCallback(element: HTMLElement): void;
+    /**
+     * Invoked when the controller is removed from the DOM. Implement <code>disconnectedCallback</code> to tear down any DOM event listeners.
+     * @param element - The DOM element containing the Cesium scene.
+     */
+    disconnectedCallback(element: HTMLElement): void;
+    /**
+     * Invoked once per frame. Implement <code>update</code> to modify the camera or other parts of the scene.
+     * @param time - The current simulation time.
+     */
+    update(scene: Scene, time: JulianDate): void;
+    /**
+     * Invoked when the controller is being updated the first time, immediately before <code>update</code> is called. Implement <code>firstUpdate</code> to perform one-time work after the relevant scene has begun it's render loop. Some examples might include initializing simulation time values or adding a primitive to the scene.
+     * @param time - The current simulation time.
+     */
+    firstUpdate(scene: Scene, time: JulianDate): void;
+}
+
 /**
  * The credit display is responsible for displaying credits on screen.
  * @example
@@ -43634,6 +44122,16 @@ export class Scene {
      * Gets or sets the camera.
      */
     readonly camera: Camera;
+    /**
+     * Collects an array of <code>Controller</code> objects that can be registered with the scene to handle input events, camera animations, and other interactions.
+     * @example
+     * scene.screenSpaceCameraController.enableInputs = false;
+     * scene.screenSpaceCameraController.enableCollisionDetection = false;
+     *
+     * const tiltOrbitController = new Cesium.ScreenspaceTiltOrbitCameraController();
+     * scene.controllerHost.registerController(tiltOrbitController, scene.canvas.parentNode);
+     */
+    readonly controllerHost: ControllerHost;
     /**
      * Gets the controller for camera input handling.
      */
@@ -47234,6 +47732,24 @@ export class CesiumWidget {
      * unless <code>useDefaultRenderLoop</code> is set to false;
      */
     render(): void;
+    /**
+     * 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.
+     * @example
+     * widget.scene.screenSpaceCameraController.enableInputs = false;
+     * widget.scene.screenSpaceCameraController.enableCollisionDetection = false;
+     *
+     * const tiltOrbitController = new Cesium.ScreenspaceTiltOrbitCameraController();
+     * widget.addController(tiltOrbitController);
+     * @param controller - An implementation of the <code>Controller</code> interface.
+     */
+    addController(controller: Controller): void;
+    /**
+     * 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.
+     * @example
+     * widget.removeController(tiltOrbitController);
+     * @param controller - An implementation of the <code>Controller</code> interface.
+     */
+    removeController(controller: Controller): void;
     /**
      * 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,
@@ -49650,6 +50166,24 @@ export class Viewer {
      * removing the widget from layout.
      */
     destroy(): void;
+    /**
+     * 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.
+     * @example
+     * viewer.scene.screenSpaceCameraController.enableInputs = false;
+     * viewer.scene.screenSpaceCameraController.enableCollisionDetection = false;
+     *
+     * const tiltOrbitController = new Cesium.ScreenspaceTiltOrbitCameraController();
+     * viewer.addController(tiltOrbitController);
+     * @param controller - An implementation of the <code>Controller</code> interface.
+     */
+    addController(controller: Controller): void;
+    /**
+     * 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.
+     * @example
+     * viewer.removeController(tiltOrbitController);
+     * @param controller - An implementation of the <code>Controller</code> interface.
+     */
+    removeController(controller: Controller): void;
     /**
      * 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,

@lukemckinstry

Copy link
Copy Markdown
Contributor

This is looking great. Great organization and configurability with new code that is minimal but wires together existing resources. I will continue to test the controllers with the time available.

ggetz and others added 2 commits July 1, 2026 09:39
Co-authored-by: Marco Hutter <javagl@javagl.de>
Co-authored-by: Marco Hutter <javagl@javagl.de>
@ggetz

ggetz commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

I wondered whether the Controller and ControllerHost may need some infrastructure around other state changes, beyond "being added". Right now, there is the firstUpdate function to be called when a controller was added. Do controllers need special treatment depending on whether they have been disabled previously and are enabled in the new frame?

@javagl I think this is going to depend on the Controller implementation. Rather than generalize this, I'm opting to leave it up to the implementation to handle if applicable. The Controllers implemented here, for example, need to reset their timestamps if re-enabled.

Related, or more generally: Whether or not there should be some concept of indicating that ~"an input/event was consumed (and should stop being 'propagated')" may have to be thought through as well.

I considered this as well. At the moment, we do not provide a mechanism to stop event propagation, but I also did not identify any use case where that was needed thus far. I think this could be introduced later in a not completely breaking way if we find its needed.

@lukemckinstry lukemckinstry left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small comments about the sandcastle. Not sure if you had any more plans / WIP for this PR. Once the question about zoom is resolved I think it is good to go as a useful minimal example.

<td>Orbit</td>
</tr>
<tr>
<td>Zoom</td>

@lukemckinstry lukemckinstry Jul 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is zoom enabled currently in this scene? Seems like no?

And is this top left UI menu complete? Is the intent to just list which controls work as the scene is currently set up?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UI menu is not yet complete— I was going to add short descriptions and note the default controls.

scene.screenSpaceCameraController.enableCollisionDetection = false;

// Set up the modular camera controllers
const panController = new Cesium.HybridScreenspacePanCameraController();

@lukemckinstry lukemckinstry Jul 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure I am using / understanding this controller correctly. Is my understanding correct that the behavior changes from horizontal to vertical at the 125 degrees threshold? I am not seeing this happen as I use this sandcastle, maybe I am not understanding where the threshold is. Maybe some more instructions or a visual indicator could help.

Edit: Actually I think I get it now and I really like it. Feels super intuitive. Could be useful to tell to show a hint when the mode changes from Map to Elevator, but not necessary to add now.

@ggetz

ggetz commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the reviews so far @lukemckinstry and @javagl! There are a few remaining todo's, so let's aim to get this in after today's release.

@ggetz ggetz self-assigned this Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Extensible camera controller architecture

3 participants