From 16107557372a2c95cde253814ebf38092de653b2 Mon Sep 17 00:00:00 2001 From: Charles Richardson Date: Tue, 16 Jun 2026 12:33:10 -0400 Subject: [PATCH] feat(GlobeController): terrain-aware default via withTerrain mixin Extract terrain-following into a reusable withTerrain(Base) mixin so GlobeController is terrain-aware WITHOUT inheriting MapController (removing the maxBounds/normalize band-aid). TerrainController becomes withTerrain(MapController); public API unchanged. Move rotationPivot + _getAltitude to the base Controller so the globe inherits rotate-around-pivot; MapController keeps only its mercator position/maxBounds in setProps. Add GlobeViewport.panByPosition3D for 3D-anchored panning. --- docs/api-reference/core/globe-controller.md | 4 + docs/api-reference/core/globe-view.md | 2 + docs/whats-new.md | 2 +- modules/core/src/controllers/controller.ts | 39 ++++ .../core/src/controllers/globe-controller.ts | 5 +- .../core/src/controllers/map-controller.ts | 56 +----- .../src/controllers/terrain-controller.ts | 153 +-------------- modules/core/src/controllers/terrain.ts | 183 ++++++++++++++++++ modules/core/src/viewports/globe-viewport.ts | 15 ++ .../core/controllers/controllers.spec.ts | 26 ++- .../core/viewports/globe-viewport.spec.ts | 24 +++ 11 files changed, 304 insertions(+), 205 deletions(-) create mode 100644 modules/core/src/controllers/terrain.ts diff --git a/docs/api-reference/core/globe-controller.md b/docs/api-reference/core/globe-controller.md index 5147033b054..b4a879ec910 100644 --- a/docs/api-reference/core/globe-controller.md +++ b/docs/api-reference/core/globe-controller.md @@ -6,6 +6,8 @@ The `GlobeController` class can be passed to either the `Deck` class's [controll `GlobeController` is the default controller for [GlobeView](./globe-view.md). +It is **terrain-aware**: when the scene contains a layer with `pickable: '3d'`, the camera follows picked terrain elevation and rotation pivots around the 3D point under the pointer. This is the same terrain behavior as [TerrainController](./terrain-controller.md), composed in via a shared mixin — `GlobeController` does **not** inherit `MapController`, so the Web-Mercator map constraints never apply to the globe. + ## Usage Use with the default view: @@ -39,10 +41,12 @@ Supports all [Controller options](./controller.md#options) with the following de - `dragPan`: default `'pan'` (drag to pan) - `dragRotate`: shift+drag or right-click drag to change bearing and pitch +- `rotationPivot`: default `'3d'` (rotate around the picked object under the pointer) - `touchRotate`: multi-touch rotate to change bearing - `keyboard`: arrow keys to pan, +/- to zoom - `inertia`: when set to a number (milliseconds), the globe continues spinning after a fling gesture with exponential decay - `maxBounds` - constrains the viewport to the specified bounding box `[[minLng, minLat], [maxLng, maxLat]]` +- Terrain following requires a layer with `pickable: '3d'`; without one, the controller behaves like a standard `GlobeController`. ## Custom GlobeController diff --git a/docs/api-reference/core/globe-view.md b/docs/api-reference/core/globe-view.md index b4ebf172a14..77caddb40e4 100644 --- a/docs/api-reference/core/globe-view.md +++ b/docs/api-reference/core/globe-view.md @@ -88,6 +88,8 @@ By default, `GlobeView` uses the `GlobeController` to handle interactivity. To e const view = new GlobeView({id: 'globe', controller: true}); ``` +The default controller is terrain-aware: it adjusts camera elevation to follow picked terrain and uses a 3D rotation pivot when the scene contains a layer with `pickable: '3d'`. + Visit the [GlobeController](./globe-controller.md) documentation for a full list of supported options. diff --git a/docs/whats-new.md b/docs/whats-new.md index 2da341753ff..5569fa56262 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -70,7 +70,7 @@ Class-specific improvements: - [MapController](./api-reference/core/map-controller.md) - New `rotationPivot: '3d'` option rotates around the object under the pointer, for more natural interaction with terrain and 3D tiles. - [OrbitController](./api-reference/core/orbit-controller.md) now uses 3D picking to determine zoom and pan anchors, providing more intuitive navigation around 3D content. - All controllers - New `maxBounds` option constrains the camera within a (2D or 3D) bounding box, preventing users from navigating outside of the content area. -- [GlobeController](./api-reference/core/globe-controller.md) - Major bug fixes and improved stability. +- [GlobeController](./api-reference/core/globe-controller.md) - Major bug fixes, improved stability, and terrain-aware camera elevation by default. - [OrthographicView](./api-reference/core/orthographic-view.md) is moving away from 2d-array zoom and adds per-axis `zoom*`, `minZoom*`, `maxZoom*` props. ### Layers diff --git a/modules/core/src/controllers/controller.ts b/modules/core/src/controllers/controller.ts index e4d281f550e..4adbbc301d7 100644 --- a/modules/core/src/controllers/controller.ts +++ b/modules/core/src/controllers/controller.ts @@ -74,6 +74,8 @@ export type ControllerOptions = { }; /** Drag behavior without pressing function keys, one of `pan` and `rotate`. */ dragMode?: 'pan' | 'rotate'; + /** Pivot for rotation: `'center'` (viewport center), `'2d'` (pointer at ground level), or `'3d'` (the picked 3D point under the pointer). Default `'center'`. */ + rotationPivot?: 'center' | '2d' | '3d'; /** Enable inertia after panning/pinching. If a number is provided, indicates the duration of time over which the velocity reduces to zero, in milliseconds. Default `false`. */ inertia?: boolean | number; /** Bounding box of content that the controller is constrained in */ @@ -170,6 +172,30 @@ export default abstract class Controller { + if (this.rotationPivot === '2d') { + return 0; + } else if (this.rotationPivot === '3d') { + if (this.pickPosition) { + const {x, y} = this.props; + const pickResult = this.pickPosition(x + pos[0], y + pos[1]); + if (pickResult && pickResult.coordinate && pickResult.coordinate.length >= 3) { + return pickResult.coordinate[2]; + } + } + } + return undefined; + }; protected inertia: number = 0; protected scrollZoom: boolean | {speed?: number; smooth?: boolean} = true; protected dragPan: boolean = true; @@ -345,6 +371,11 @@ export default abstract class Controller | null = null, interactionState: InteractionState = {}) { + // Inject rotation pivot position during rotation for visual feedback + const rotateState = newControllerState.getState() as {startRotateLngLat?: [number, number, number]}; + if (interactionState.isDragging && rotateState.startRotateLngLat) { + interactionState = {...interactionState, rotationPivotPosition: rotateState.startRotateLngLat}; + } else if (interactionState.isDragging === false) { + interactionState = {...interactionState, rotationPivotPosition: undefined}; + } + const viewState = {...newControllerState.getViewportProps(), ...extraProps}; // TODO - to restore diffing, we need to include interactionState diff --git a/modules/core/src/controllers/globe-controller.ts b/modules/core/src/controllers/globe-controller.ts index 8993b2ffee7..391b3efd152 100644 --- a/modules/core/src/controllers/globe-controller.ts +++ b/modules/core/src/controllers/globe-controller.ts @@ -4,6 +4,7 @@ import {clamp} from '@math.gl/core'; import Controller from './controller'; +import {withTerrain} from './terrain'; import {MapState, MapStateProps} from './map-controller'; import type {MapStateInternal} from './map-controller'; @@ -244,7 +245,9 @@ class GlobeState extends MapState { } } -export default class GlobeController extends Controller { +// Terrain-aware by default: the globe camera follows terrain elevation, without +// inheriting MapController (so no Web-Mercator maxBounds leaks onto the globe). +export default class GlobeController extends withTerrain(Controller) { ControllerState = GlobeState; transition = { diff --git a/modules/core/src/controllers/map-controller.ts b/modules/core/src/controllers/map-controller.ts index 72c5d97f54e..be266960a95 100644 --- a/modules/core/src/controllers/map-controller.ts +++ b/modules/core/src/controllers/map-controller.ts @@ -600,66 +600,12 @@ export default class MapController extends Controller { dragMode: 'pan' | 'rotate' = 'pan'; - /** - * Rotation pivot behavior: - * - 'center': Rotate around viewport center (default) - * - '2d': Rotate around pointer position at ground level (z=0) - * - '3d': Rotate around 3D picked point (requires pickPosition callback) - */ - protected rotationPivot: 'center' | '2d' | '3d' = 'center'; - - setProps( - props: ControllerProps & - MapStateProps & { - rotationPivot?: 'center' | '2d' | '3d'; - getAltitude?: (pos: [number, number]) => number | undefined; - } - ) { - if ('rotationPivot' in props) { - this.rotationPivot = props.rotationPivot || 'center'; - } + setProps(props: ControllerProps & MapStateProps) { // this will be passed to MapState constructor - props.getAltitude = this._getAltitude; props.position = props.position || [0, 0, 0]; props.maxBounds = props.maxBounds || (props.normalize === false ? null : WEB_MERCATOR_MAX_BOUNDS); super.setProps(props); } - - protected updateViewport( - newControllerState: MapState, - extraProps: Record | null = null, - interactionState: InteractionState = {} - ): void { - // Inject rotation pivot position during rotation for visual feedback - const state = newControllerState.getState(); - if (interactionState.isDragging && state.startRotateLngLat) { - interactionState = { - ...interactionState, - rotationPivotPosition: state.startRotateLngLat - }; - } else if (interactionState.isDragging === false) { - // Clear pivot when drag ends - interactionState = {...interactionState, rotationPivotPosition: undefined}; - } - - super.updateViewport(newControllerState, extraProps, interactionState); - } - - /** Add altitude to rotateStart params based on rotationPivot mode */ - protected _getAltitude = (pos: [number, number]): number | undefined => { - if (this.rotationPivot === '2d') { - return 0; - } else if (this.rotationPivot === '3d') { - if (this.pickPosition) { - const {x, y} = this.props; - const pickResult = this.pickPosition(x + pos[0], y + pos[1]); - if (pickResult && pickResult.coordinate && pickResult.coordinate.length >= 3) { - return pickResult.coordinate[2]; - } - } - } - return undefined; - }; } diff --git a/modules/core/src/controllers/terrain-controller.ts b/modules/core/src/controllers/terrain-controller.ts index 9c42533021b..332bb975b80 100644 --- a/modules/core/src/controllers/terrain-controller.ts +++ b/modules/core/src/controllers/terrain-controller.ts @@ -3,153 +3,12 @@ // Copyright (c) vis.gl contributors import MapController from './map-controller'; -import {MapState, MapStateProps} from './map-controller'; -import type {ControllerProps, InteractionState} from './controller'; -import type {MjolnirGestureEvent, MjolnirWheelEvent} from 'mjolnir.js'; +import {withTerrain} from './terrain'; /** - * Controller that extends MapController with terrain-aware behavior. - * The camera smoothly follows terrain elevation during pan/zoom. + * MapController with terrain-aware behavior. The camera smoothly follows terrain + * elevation during pan/zoom, and rotation pivots around the 3D point under the + * pointer (`rotationPivot: '3d'`) by default. Requires a layer with + * `pickable: '3d'`; without one it behaves like a standard MapController. */ -export default class TerrainController extends MapController { - /** Cached terrain altitude from depth picking at viewport center (smoothed) */ - private _terrainAltitude?: number = undefined; - /** Raw (unsmoothed) terrain altitude from latest pick */ - private _terrainAltitudeTarget?: number = undefined; - /** rAF handle for periodic terrain altitude picking */ - private _pickFrameId: number | null = null; - /** Timestamp of last pick */ - private _lastPickTime: number = 0; - - setProps( - props: ControllerProps & - MapStateProps & { - rotationPivot?: 'center' | '2d' | '3d'; - getAltitude?: (pos: [number, number]) => number | undefined; - } - ) { - super.setProps({rotationPivot: '3d', ...props}); - - // Periodically pick terrain altitude at the viewport center using rAF. - // Keeps the altitude cache warm so interactions don't need expensive - // synchronous GPU readbacks. rAF naturally pauses when tab is backgrounded. - if (this._pickFrameId === null) { - const loop = () => { - const now = Date.now(); - if (now - this._lastPickTime > 500 && !this.isDragging()) { - this._lastPickTime = now; - this._pickTerrainCenterAltitude(); - // On first successful pick, rebase viewport to terrain altitude. - // Runs from rAF (outside React render) so onViewStateChange won't loop. - if (this._terrainAltitude === undefined && this._terrainAltitudeTarget !== undefined) { - this._terrainAltitude = this._terrainAltitudeTarget; - const controllerState = new this.ControllerState({ - makeViewport: this.makeViewport, - ...this.props, - ...this.state - } as any); - const rebaseProps = this._rebaseViewport(this._terrainAltitudeTarget, controllerState); - if (rebaseProps) { - // Build a controllerState that includes the rebase adjustments so - // internal state matches the rebased viewState after React round-trip. - const rebasedState = new this.ControllerState({ - makeViewport: this.makeViewport, - ...this.props, - ...this.state, - ...rebaseProps - } as any); - super.updateViewport(rebasedState); - } - } - } - this._pickFrameId = requestAnimationFrame(loop); - }; - this._pickFrameId = requestAnimationFrame(loop); - } - } - - finalize() { - if (this._pickFrameId !== null) { - cancelAnimationFrame(this._pickFrameId); - this._pickFrameId = null; - } - super.finalize(); - } - - protected updateViewport( - newControllerState: MapState, - extraProps: Record | null = null, - interactionState: InteractionState = {} - ): void { - // Not initialized yet — pass through to MapController - if (this._terrainAltitude === undefined) { - super.updateViewport(newControllerState, extraProps, interactionState); - return; - } - - // Smoothly blend toward target altitude - const SMOOTHING = 0.05; - this._terrainAltitude += (this._terrainAltitudeTarget! - this._terrainAltitude) * SMOOTHING; - - const viewportProps = newControllerState.getViewportProps(); - const pos = viewportProps.position || [0, 0, 0]; - extraProps = { - ...extraProps, - position: [pos[0], pos[1], this._terrainAltitude] - }; - - super.updateViewport(newControllerState, extraProps, interactionState); - } - - private _pickTerrainCenterAltitude(): void { - if (!this.pickPosition) { - return; - } - const {x, y, width, height} = this.props; - const pickResult = this.pickPosition(x + width / 2, y + height / 2); - if (pickResult?.coordinate && pickResult.coordinate.length >= 3) { - this._terrainAltitudeTarget = pickResult.coordinate[2]; - } - } - - /** - * Compute viewport adjustments to keep the view visually the same - * when shifting position to [0, 0, altitude]. - */ - private _rebaseViewport( - altitude: number, - newControllerState: MapState - ): Record | null { - const viewportProps = newControllerState.getViewportProps(); - const oldViewport = this.makeViewport({...viewportProps, position: [0, 0, 0]}); - const oldCameraPos = oldViewport.cameraPosition; - - const centerZOffset = altitude * oldViewport.distanceScales.unitsPerMeter[2]; - const cameraHeightAboveOldCenter = oldCameraPos[2]; - const newCameraHeightAboveCenter = cameraHeightAboveOldCenter - centerZOffset; - if (newCameraHeightAboveCenter <= 0) { - return null; - } - - const zoomDelta = Math.log2(cameraHeightAboveOldCenter / newCameraHeightAboveCenter); - const newZoom = viewportProps.zoom + zoomDelta; - - const newViewport = this.makeViewport({ - ...viewportProps, - zoom: newZoom, - position: [0, 0, altitude] - }); - const {width, height} = viewportProps; - const screenCenter: [number, number] = [width / 2, height / 2]; - const worldPoint = oldViewport.unproject(screenCenter, {targetZ: altitude}); - if ( - worldPoint && - 'panByPosition3D' in newViewport && - typeof newViewport.panByPosition3D === 'function' - ) { - const adjusted = newViewport.panByPosition3D(worldPoint, screenCenter); - return {position: [0, 0, altitude], zoom: newZoom, ...adjusted}; - } - return null; - } -} +export default class TerrainController extends withTerrain(MapController) {} diff --git a/modules/core/src/controllers/terrain.ts b/modules/core/src/controllers/terrain.ts new file mode 100644 index 00000000000..49ca5844e34 --- /dev/null +++ b/modules/core/src/controllers/terrain.ts @@ -0,0 +1,183 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type Controller from './controller'; +import type {ControllerProps, InteractionState} from './controller'; + +/* A TypeScript mixin cannot see a base class's `protected` members through the + * generic constructor, so the few protected Controller members this mixin reads + * (`props`, `state`, `makeViewport`, `pickPosition`, `ControllerState`) are cast. + * Runtime behavior is identical to a direct subclass. */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export type TerrainControllerProps = { + /** Pivot for rotation: `'center'` | `'2d'` | `'3d'`. Terrain defaults to `'3d'`. */ + rotationPivot?: 'center' | '2d' | '3d'; + getAltitude?: (pos: [number, number]) => number | undefined; +}; + +type Constructor = abstract new (...args: any[]) => T; + +/** + * Mixin that adds terrain-following to any `Controller`: the camera smoothly + * follows terrain elevation (depth-picked at the viewport center) during + * interaction, and rotation defaults to pivoting around the 3D point under the + * pointer. + * + * Composed into `TerrainController` (= `withTerrain(MapController)`) and + * `GlobeController` (= `withTerrain(Controller)`) so neither view's + * controller inherits the other — the globe gets terrain-awareness without + * pulling in MapController's Web-Mercator semantics. + */ +export function withTerrain>>( + Base: TBase +): Constructor> { + abstract class TerrainAware extends Base { + /** Cached terrain altitude from depth picking at viewport center (smoothed) */ + private _terrainAltitude?: number = undefined; + /** Raw (unsmoothed) terrain altitude from latest pick */ + private _terrainAltitudeTarget?: number = undefined; + /** rAF handle for periodic terrain altitude picking */ + private _pickFrameId: number | null = null; + /** Timestamp of last pick */ + private _lastPickTime: number = 0; + + setProps(props: ControllerProps & TerrainControllerProps & Record) { + // Terrain interaction rotates around the 3D point under the pointer. + super.setProps({rotationPivot: '3d', ...props} as any); + + // Periodically pick terrain altitude at the viewport center using rAF, so + // interactions don't need expensive synchronous GPU readbacks. rAF + // naturally pauses when the tab is backgrounded. + if (this._pickFrameId === null) { + const loop = () => { + const now = Date.now(); + if (now - this._lastPickTime > 500 && !this.isDragging()) { + this._lastPickTime = now; + this._pickTerrainCenterAltitude(); + // On first successful pick, rebase the viewport to terrain altitude. + // Runs from rAF (outside React render) so onViewStateChange won't loop. + if (this._terrainAltitude === undefined && this._terrainAltitudeTarget !== undefined) { + this._terrainAltitude = this._terrainAltitudeTarget; + const ControllerState = (this as any).ControllerState; + const controllerState = new ControllerState({ + makeViewport: (this as any).makeViewport, + ...(this as any).props, + ...(this as any).state + }); + const rebaseProps = this._rebaseViewport( + this._terrainAltitudeTarget, + controllerState + ); + if (rebaseProps) { + // Include the rebase adjustments so internal state matches the + // rebased viewState after the React round-trip. + const rebasedState = new ControllerState({ + makeViewport: (this as any).makeViewport, + ...(this as any).props, + ...(this as any).state, + ...rebaseProps + }); + super.updateViewport(rebasedState); + } + } + } + this._pickFrameId = requestAnimationFrame(loop); + }; + this._pickFrameId = requestAnimationFrame(loop); + } + } + + finalize() { + if (this._pickFrameId !== null) { + cancelAnimationFrame(this._pickFrameId); + this._pickFrameId = null; + } + super.finalize(); + } + + protected updateViewport( + newControllerState: any, + extraProps: Record | null = null, + interactionState: InteractionState = {} + ): void { + // Not initialized yet — pass through. + if (this._terrainAltitude === undefined) { + super.updateViewport(newControllerState, extraProps, interactionState); + return; + } + + // Smoothly blend toward the target altitude. + const SMOOTHING = 0.05; + this._terrainAltitude += (this._terrainAltitudeTarget! - this._terrainAltitude) * SMOOTHING; + + const viewportProps = newControllerState.getViewportProps(); + const pos = viewportProps.position || [0, 0, 0]; + extraProps = { + ...extraProps, + position: [pos[0], pos[1], this._terrainAltitude] + }; + + super.updateViewport(newControllerState, extraProps, interactionState); + } + + private _pickTerrainCenterAltitude(): void { + const pickPosition = (this as any).pickPosition; + if (!pickPosition) { + return; + } + const {x, y, width, height} = (this as any).props; + const pickResult = pickPosition(x + width / 2, y + height / 2); + if (pickResult?.coordinate && pickResult.coordinate.length >= 3) { + this._terrainAltitudeTarget = pickResult.coordinate[2]; + } + } + + /** + * Compute viewport adjustments to keep the view visually the same when + * shifting position to `[0, 0, altitude]`. + */ + private _rebaseViewport(altitude: number, newControllerState: any): Record | null { + const viewportProps = newControllerState.getViewportProps(); + const makeViewport = (this as any).makeViewport; + const oldViewport = makeViewport({...viewportProps, position: [0, 0, 0]}); + const oldCameraPos = oldViewport.cameraPosition; + + const centerZOffset = altitude * oldViewport.distanceScales.unitsPerMeter[2]; + const cameraHeightAboveOldCenter = oldCameraPos[2]; + const newCameraHeightAboveCenter = cameraHeightAboveOldCenter - centerZOffset; + if (newCameraHeightAboveCenter <= 0) { + return null; + } + + const zoomDelta = Math.log2(cameraHeightAboveOldCenter / newCameraHeightAboveCenter); + const newZoom = viewportProps.zoom + zoomDelta; + + const newViewport = makeViewport({ + ...viewportProps, + zoom: newZoom, + position: [0, 0, altitude] + }); + const {width, height} = viewportProps; + const screenCenter: [number, number] = [width / 2, height / 2]; + const worldPoint = oldViewport.unproject(screenCenter, {targetZ: altitude}); + if ( + worldPoint && + 'panByPosition3D' in newViewport && + typeof newViewport.panByPosition3D === 'function' + ) { + const adjusted = newViewport.panByPosition3D(worldPoint, screenCenter); + return {position: [0, 0, altitude], zoom: newZoom, ...adjusted}; + } + return null; + } + } + // The mixin overrides only existing members (setProps/finalize/updateViewport), + // so the public surface is unchanged from the base. Returning a *named* constructor + // type — not the inferred anonymous class — lets declaration emit name the base; + // without it, exported subclasses hit TS4094 on every inherited private/protected + // member. InstanceType keeps the base's bound generic (e.g. the globe's + // Controller members) visible to subclasses. + return TerrainAware as unknown as Constructor>; +} diff --git a/modules/core/src/viewports/globe-viewport.ts b/modules/core/src/viewports/globe-viewport.ts index 55e37122bbb..d94ce6df467 100644 --- a/modules/core/src/viewports/globe-viewport.ts +++ b/modules/core/src/viewports/globe-viewport.ts @@ -283,6 +283,21 @@ export default class GlobeViewport extends Viewport { out.zoom += zoomAdjust(out.latitude); return out; } + + /** + * Returns a new longitude and latitude that keeps a 3D coordinate at a given screen pixel. + */ + panByPosition3D(coords: number[], pixel: number[]): GlobeViewportOptions { + const targetZ = coords[2] || 0; + const [longitudeAtPixel, latitudeAtPixel] = this.unproject(pixel, {targetZ}); + const latitude = Math.max(Math.min(this.latitude + coords[1] - latitudeAtPixel, 90), -90); + const out = { + longitude: this.longitude + coords[0] - longitudeAtPixel, + latitude, + zoom: this.zoom + zoomAdjust(latitude) - zoomAdjust(this.latitude) + }; + return out; + } } export function zoomAdjust(latitude: number, clampToPoles?: boolean): number { diff --git a/test/modules/core/controllers/controllers.spec.ts b/test/modules/core/controllers/controllers.spec.ts index 01393fa3bc0..170f8e6086c 100644 --- a/test/modules/core/controllers/controllers.spec.ts +++ b/test/modules/core/controllers/controllers.spec.ts @@ -8,7 +8,8 @@ import { OrbitView, OrthographicView, FirstPersonView, - _GlobeView as GlobeView + _GlobeView as GlobeView, + _GlobeController as GlobeController } from '@deck.gl/core'; import {Timeline} from '@luma.gl/engine'; @@ -110,6 +111,29 @@ test('GlobeController', async () => { ); }); +test('GlobeController is terrain-aware by default', () => { + const controller = createTestController({ + view: new GlobeView({controller: true}), + initialViewState: { + longitude: -122.45, + latitude: 37.78, + zoom: 1 + } + }); + + // GlobeController is terrain-aware via the `withTerrain` mixin — WITHOUT inheriting + // MapController/TerrainController. The mixin defaults rotation to pivot around the + // 3D point under the pointer, which is observable on the controller props. + expect(controller, 'GlobeView default controller is a GlobeController').toBeInstanceOf( + GlobeController + ); + expect( + (controller.props as any).rotationPivot, + 'terrain mixin defaults rotationPivot to 3d' + ).toBe('3d'); + controller.finalize(); +}); + test('OrbitController', async () => { await testController(OrbitView, { orbitAxis: 'Y', diff --git a/test/modules/core/viewports/globe-viewport.spec.ts b/test/modules/core/viewports/globe-viewport.spec.ts index b41ce392668..3dd95f54859 100644 --- a/test/modules/core/viewports/globe-viewport.spec.ts +++ b/test/modules/core/viewports/globe-viewport.spec.ts @@ -168,6 +168,30 @@ test('GlobeViewport#project, unproject', () => { config.EPSILON = oldEpsilon; }); +test('GlobeViewport#panByPosition3D', () => { + const viewport = new GlobeViewport({ + ...TEST_VIEWPORTS[0], + pitch: 35, + bearing: 20 + }); + const fromPixel = [430, 320]; + const toPixel = [410, 300]; + const anchor = viewport.unproject(fromPixel, {targetZ: 1000}); + const adjustedProps = viewport.panByPosition3D(anchor, toPixel); + const adjustedViewport = new GlobeViewport({ + ...TEST_VIEWPORTS[0], + pitch: 35, + bearing: 20, + ...adjustedProps + }); + const projectedAnchor = adjustedViewport.project(anchor); + + expect( + Math.hypot(projectedAnchor[0] - toPixel[0], projectedAnchor[1] - toPixel[1]) < 2, + '3D anchor projects near requested pixel' + ).toBeTruthy(); +}); + test('GlobeViewport#getBounds', () => { for (const testCase of TEST_VIEWPORTS) { const bounds = new GlobeViewport(testCase).getBounds();