Skip to content

Commit 01974db

Browse files
Add GeospatialCamera FlyTo animations and use for 'double tap to zoom' interaction (#17469)
1 parent 2f20875 commit 01974db

File tree

3 files changed

+148
-14
lines changed

3 files changed

+148
-14
lines changed

packages/dev/core/src/Behaviors/Cameras/interpolatingBehavior.ts

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import type { Nullable } from "../../types";
44
import type { Animatable } from "../../Animations/animatable.core";
55
import { Animation } from "../../Animations/animation";
66
import type { Camera } from "../../Cameras/camera";
7+
import type { IColor3Like, IColor4Like, IMatrixLike, IQuaternionLike, IVector2Like, IVector3Like } from "../../Maths/math.like";
8+
9+
export type AllowedAnimValue = number | IVector2Like | IVector3Like | IQuaternionLike | IMatrixLike | IColor3Like | IColor4Like | SizeLike | undefined;
710

811
/**
912
* Animate camera property changes with an interpolation effect
@@ -86,15 +89,17 @@ export class InterpolatingBehavior<C extends Camera = Camera> implements Behavio
8689
this._promiseResolve = undefined;
8790
}
8891

89-
public updateProperties(properties: Map<string, any>): void {
92+
public updateProperties<K extends keyof C>(properties: Map<K, AllowedAnimValue>): void {
9093
properties.forEach((value, key) => {
91-
const animatable = this._animatables.get(key);
92-
animatable && (animatable.target = value);
94+
if (value !== undefined) {
95+
const animatable = this._animatables.get(String(key));
96+
animatable && (animatable.target = value as unknown as any);
97+
}
9398
});
9499
}
95100

96-
public async animatePropertiesAsync(
97-
properties: Map<string, any>,
101+
public async animatePropertiesAsync<K extends keyof C>(
102+
properties: Map<K, AllowedAnimValue>,
98103
transitionDuration: number = this.transitionDuration,
99104
easingFn: EasingFunction = this.easingFunction
100105
): Promise<void> {
@@ -117,13 +122,74 @@ export class InterpolatingBehavior<C extends Camera = Camera> implements Behavio
117122
};
118123

119124
properties.forEach((value, key) => {
120-
const animation = Animation.CreateAnimation(key, Animation.ANIMATIONTYPE_FLOAT, 60, easingFn);
121-
const animatable = Animation.TransitionTo(key, value, camera, scene, 60, animation, transitionDuration, () => checkClear(key));
122-
if (animatable) {
123-
this._animatables.set(key, animatable);
125+
if (value !== undefined) {
126+
const propertyName = String(key);
127+
const animation = Animation.CreateAnimation(propertyName, GetAnimationType(value), 60, easingFn);
128+
const animatable = Animation.TransitionTo(propertyName, value, camera, scene, 60, animation, transitionDuration, () => checkClear(propertyName));
129+
if (animatable) {
130+
this._animatables.set(propertyName, animatable);
131+
}
124132
}
125133
});
126134
});
127135
return await promise;
128136
}
129137
}
138+
139+
// Structural type-guards (no instanceof)
140+
function IsQuaternionLike(v: any): v is IQuaternionLike {
141+
return v != null && typeof v.x === "number" && typeof v.y === "number" && typeof v.z === "number" && typeof v.w === "number";
142+
}
143+
144+
function IsMatrixLike(v: any): v is IMatrixLike {
145+
return v != null && (Array.isArray((v as any).m) || typeof (v as any).m === "object");
146+
}
147+
148+
function IsVector3Like(v: any): v is IVector3Like {
149+
return v != null && typeof v.x === "number" && typeof v.y === "number" && typeof v.z === "number";
150+
}
151+
152+
function IsVector2Like(v: any): v is IVector2Like {
153+
return v != null && typeof v.x === "number" && typeof v.y === "number";
154+
}
155+
156+
function IsColor3Like(v: any): v is IColor3Like {
157+
return v != null && typeof v.r === "number" && typeof v.g === "number" && typeof v.b === "number";
158+
}
159+
160+
function IsColor4Like(v: any): v is IColor4Like {
161+
return v != null && typeof v.r === "number" && typeof v.g === "number" && typeof v.b === "number" && typeof v.a === "number";
162+
}
163+
164+
export type SizeLike = { width: number; height: number };
165+
166+
function IsSizeLike(v: any): v is SizeLike {
167+
return v != null && typeof v.width === "number" && typeof v.height === "number";
168+
}
169+
170+
const GetAnimationType = (value: AllowedAnimValue): number => {
171+
if (IsQuaternionLike(value)) {
172+
return Animation.ANIMATIONTYPE_QUATERNION;
173+
}
174+
if (IsMatrixLike(value)) {
175+
return Animation.ANIMATIONTYPE_MATRIX;
176+
}
177+
if (IsVector3Like(value)) {
178+
return Animation.ANIMATIONTYPE_VECTOR3;
179+
}
180+
if (IsVector2Like(value)) {
181+
return Animation.ANIMATIONTYPE_VECTOR2;
182+
}
183+
if (IsColor3Like(value)) {
184+
return Animation.ANIMATIONTYPE_COLOR3;
185+
}
186+
if (IsColor4Like(value)) {
187+
return Animation.ANIMATIONTYPE_COLOR4;
188+
}
189+
if (IsSizeLike(value)) {
190+
return Animation.ANIMATIONTYPE_SIZE;
191+
}
192+
193+
// Fallback to float for numbers and unknown shapes
194+
return Animation.ANIMATIONTYPE_FLOAT;
195+
};

packages/dev/core/src/Cameras/Inputs/geospatialCameraPointersInput.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ export class GeospatialCameraPointersInput extends OrbitCameraPointersInput {
8383
}
8484
}
8585

86+
public override onDoubleTap(type: string): void {
87+
const scene = this.camera.getScene();
88+
const pickResult = scene.pick(scene.pointerX, scene.pointerY, this.camera.pickPredicate);
89+
90+
if (pickResult.hit && pickResult.pickedPoint) {
91+
const newRadius = this.camera.radius * 0.5; // Zoom to 50% of current distance
92+
void this.camera.flyToAsync(undefined, undefined, newRadius, pickResult.pickedPoint);
93+
}
94+
}
95+
8696
public override onMultiTouch(
8797
pointA: Nullable<PointerTouch>,
8898
pointB: Nullable<PointerTouch>,

packages/dev/core/src/Cameras/geospatialCamera.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { ComputeLocalBasisToRefs, GeospatialCameraMovement } from "./geospatialC
1010
import type { IVector3Like } from "../Maths/math.like";
1111
import { Vector3CopyToRef, Vector3Dot } from "../Maths/math.vector.functions";
1212
import { Clamp } from "../Maths/math.scalar.functions";
13+
import type { AllowedAnimValue } from "../Behaviors/Cameras/interpolatingBehavior";
14+
import { InterpolatingBehavior } from "../Behaviors/Cameras/interpolatingBehavior";
15+
import type { EasingFunction } from "../Animations/easing";
1316

1417
type CameraOptions = {
1518
planetRadius: number; // Radius of the planet
@@ -37,12 +40,19 @@ export class GeospatialCamera extends Camera {
3740
private _isViewMatrixDirty: boolean;
3841
private _lookAtVector: Vector3 = new Vector3();
3942

43+
/** Behavior used for smooth flying animations */
44+
private _flyingBehavior: InterpolatingBehavior<GeospatialCamera>;
45+
private _flyToTargets: Map<keyof GeospatialCamera, AllowedAnimValue> = new Map();
46+
4047
constructor(name: string, scene: Scene, options: CameraOptions, pickPredicate?: MeshPredicate) {
4148
super(name, new Vector3(), scene);
4249

4350
this._limits = new GeospatialLimits(options.planetRadius);
4451
this._resetToDefault(this._limits);
4552

53+
this._flyingBehavior = new InterpolatingBehavior();
54+
this.addBehavior(this._flyingBehavior);
55+
4656
this.movement = new GeospatialCameraMovement(scene, this._limits, this.position, this.center, this._lookAtVector, pickPredicate);
4757

4858
this.pickPredicate = pickPredicate;
@@ -51,7 +61,7 @@ export class GeospatialCamera extends Camera {
5161
}
5262

5363
private _center: Vector3 = new Vector3();
54-
/** The point on the globe that we are anchoring around. If no alternate rotation point is present, this will represent the center of screen*/
64+
/** The point on the globe that we are anchoring around. If no alternate rotation point is supplied, this will represent the center of screen*/
5565
public get center(): Vector3 {
5666
return this._center;
5767
}
@@ -180,6 +190,54 @@ export class GeospatialCamera extends Camera {
180190
return this.movement.alternateRotationPt ?? this.center;
181191
}
182192

193+
/**
194+
* If camera is actively in flight, will update the target properties and use up the remaining duration from original flyTo call
195+
*
196+
* To start a new flyTo curve entirely, call into flyToAsync again (it will stop the inflight animation)
197+
* @param targetYaw
198+
* @param targetPitch
199+
* @param targetRadius
200+
* @param targetCenter
201+
*/
202+
public updateFlyToDestination(targetYaw?: number, targetPitch?: number, targetRadius?: number, targetCenter?: Vector3): void {
203+
this._flyToTargets.clear();
204+
205+
this._flyToTargets.set("yaw", targetYaw);
206+
this._flyToTargets.set("pitch", targetPitch);
207+
this._flyToTargets.set("radius", targetRadius);
208+
this._flyToTargets.set("center", targetCenter);
209+
210+
this._flyingBehavior.updateProperties(this._flyToTargets);
211+
}
212+
213+
/**
214+
* Animate camera towards passed in property values. If undefined, will use current value
215+
* @param targetYaw
216+
* @param targetPitch
217+
* @param targetRadius
218+
* @param targetCenter
219+
* @param flightDurationMs
220+
* @param easingFunction
221+
* @returns Promise that will return when the animation is complete (or interuppted by pointer input)
222+
*/
223+
public async flyToAsync(
224+
targetYaw?: number,
225+
targetPitch?: number,
226+
targetRadius?: number,
227+
targetCenter?: Vector3,
228+
flightDurationMs: number = 1000,
229+
easingFunction?: EasingFunction
230+
): Promise<void> {
231+
this._flyToTargets.clear();
232+
233+
this._flyToTargets.set("yaw", targetYaw);
234+
this._flyToTargets.set("pitch", targetPitch);
235+
this._flyToTargets.set("radius", targetRadius);
236+
this._flyToTargets.set("center", targetCenter);
237+
238+
return await this._flyingBehavior.animatePropertiesAsync(this._flyToTargets, flightDurationMs, easingFunction);
239+
}
240+
183241
private _limits: GeospatialLimits;
184242
public get limits(): GeospatialLimits {
185243
return this._limits;
@@ -250,10 +308,10 @@ export class GeospatialCamera extends Camera {
250308
* This rotation keeps the camera oriented towards the globe as it orbits around it. This is different from cameraCentricRotation which is when the camera rotates around its own axis
251309
*/
252310
private _applyGeocentricRotation(): void {
253-
const currentFrameRotationDelta = this.movement.rotationDeltaCurrentFrame;
254-
if (currentFrameRotationDelta.x !== 0 || currentFrameRotationDelta.y !== 0) {
255-
const pitch = currentFrameRotationDelta.x !== 0 ? Clamp(this._pitch + currentFrameRotationDelta.x, 0, 0.5 * Math.PI - Epsilon) : this._pitch;
256-
const yaw = currentFrameRotationDelta.y !== 0 ? this._yaw + currentFrameRotationDelta.y : this._yaw;
311+
const rotationDeltaCurrentFrame = this.movement.rotationDeltaCurrentFrame;
312+
if (rotationDeltaCurrentFrame.x !== 0 || rotationDeltaCurrentFrame.y !== 0) {
313+
const pitch = rotationDeltaCurrentFrame.x !== 0 ? Clamp(this._pitch + rotationDeltaCurrentFrame.x, 0, 0.5 * Math.PI - Epsilon) : this._pitch;
314+
const yaw = rotationDeltaCurrentFrame.y !== 0 ? this._yaw + rotationDeltaCurrentFrame.y : this._yaw;
257315

258316
// TODO: If _geocentricRotationPt is not the center, this will need to be adjusted.
259317
this._setOrientation(yaw, pitch, this._radius, this._geocentricRotationPt);

0 commit comments

Comments
 (0)