diff --git a/packages/dev/core/src/Behaviors/Cameras/interpolatingBehavior.ts b/packages/dev/core/src/Behaviors/Cameras/interpolatingBehavior.ts index db6292d24bb..3d46b9f316b 100644 --- a/packages/dev/core/src/Behaviors/Cameras/interpolatingBehavior.ts +++ b/packages/dev/core/src/Behaviors/Cameras/interpolatingBehavior.ts @@ -4,6 +4,9 @@ import type { Nullable } from "../../types"; import type { Animatable } from "../../Animations/animatable.core"; import { Animation } from "../../Animations/animation"; import type { Camera } from "../../Cameras/camera"; +import type { IColor3Like, IColor4Like, IMatrixLike, IQuaternionLike, IVector2Like, IVector3Like } from "../../Maths/math.like"; + +export type AllowedAnimValue = number | IVector2Like | IVector3Like | IQuaternionLike | IMatrixLike | IColor3Like | IColor4Like | SizeLike | undefined; /** * Animate camera property changes with an interpolation effect @@ -86,15 +89,17 @@ export class InterpolatingBehavior implements Behavio this._promiseResolve = undefined; } - public updateProperties(properties: Map): void { + public updateProperties(properties: Map): void { properties.forEach((value, key) => { - const animatable = this._animatables.get(key); - animatable && (animatable.target = value); + if (value !== undefined) { + const animatable = this._animatables.get(String(key)); + animatable && (animatable.target = value as unknown as any); + } }); } - public async animatePropertiesAsync( - properties: Map, + public async animatePropertiesAsync( + properties: Map, transitionDuration: number = this.transitionDuration, easingFn: EasingFunction = this.easingFunction ): Promise { @@ -117,13 +122,74 @@ export class InterpolatingBehavior implements Behavio }; properties.forEach((value, key) => { - const animation = Animation.CreateAnimation(key, Animation.ANIMATIONTYPE_FLOAT, 60, easingFn); - const animatable = Animation.TransitionTo(key, value, camera, scene, 60, animation, transitionDuration, () => checkClear(key)); - if (animatable) { - this._animatables.set(key, animatable); + if (value !== undefined) { + const propertyName = String(key); + const animation = Animation.CreateAnimation(propertyName, GetAnimationType(value), 60, easingFn); + const animatable = Animation.TransitionTo(propertyName, value, camera, scene, 60, animation, transitionDuration, () => checkClear(propertyName)); + if (animatable) { + this._animatables.set(propertyName, animatable); + } } }); }); return await promise; } } + +// Structural type-guards (no instanceof) +function IsQuaternionLike(v: any): v is IQuaternionLike { + return v != null && typeof v.x === "number" && typeof v.y === "number" && typeof v.z === "number" && typeof v.w === "number"; +} + +function IsMatrixLike(v: any): v is IMatrixLike { + return v != null && (Array.isArray((v as any).m) || typeof (v as any).m === "object"); +} + +function IsVector3Like(v: any): v is IVector3Like { + return v != null && typeof v.x === "number" && typeof v.y === "number" && typeof v.z === "number"; +} + +function IsVector2Like(v: any): v is IVector2Like { + return v != null && typeof v.x === "number" && typeof v.y === "number"; +} + +function IsColor3Like(v: any): v is IColor3Like { + return v != null && typeof v.r === "number" && typeof v.g === "number" && typeof v.b === "number"; +} + +function IsColor4Like(v: any): v is IColor4Like { + return v != null && typeof v.r === "number" && typeof v.g === "number" && typeof v.b === "number" && typeof v.a === "number"; +} + +export type SizeLike = { width: number; height: number }; + +function IsSizeLike(v: any): v is SizeLike { + return v != null && typeof v.width === "number" && typeof v.height === "number"; +} + +const GetAnimationType = (value: AllowedAnimValue): number => { + if (IsQuaternionLike(value)) { + return Animation.ANIMATIONTYPE_QUATERNION; + } + if (IsMatrixLike(value)) { + return Animation.ANIMATIONTYPE_MATRIX; + } + if (IsVector3Like(value)) { + return Animation.ANIMATIONTYPE_VECTOR3; + } + if (IsVector2Like(value)) { + return Animation.ANIMATIONTYPE_VECTOR2; + } + if (IsColor3Like(value)) { + return Animation.ANIMATIONTYPE_COLOR3; + } + if (IsColor4Like(value)) { + return Animation.ANIMATIONTYPE_COLOR4; + } + if (IsSizeLike(value)) { + return Animation.ANIMATIONTYPE_SIZE; + } + + // Fallback to float for numbers and unknown shapes + return Animation.ANIMATIONTYPE_FLOAT; +}; diff --git a/packages/dev/core/src/Cameras/Inputs/geospatialCameraPointersInput.ts b/packages/dev/core/src/Cameras/Inputs/geospatialCameraPointersInput.ts index 521d9a7463e..894db062d6d 100644 --- a/packages/dev/core/src/Cameras/Inputs/geospatialCameraPointersInput.ts +++ b/packages/dev/core/src/Cameras/Inputs/geospatialCameraPointersInput.ts @@ -83,6 +83,16 @@ export class GeospatialCameraPointersInput extends OrbitCameraPointersInput { } } + public override onDoubleTap(type: string): void { + const scene = this.camera.getScene(); + const pickResult = scene.pick(scene.pointerX, scene.pointerY, this.camera.pickPredicate); + + if (pickResult.hit && pickResult.pickedPoint) { + const newRadius = this.camera.radius * 0.5; // Zoom to 50% of current distance + void this.camera.flyToAsync(undefined, undefined, newRadius, pickResult.pickedPoint); + } + } + public override onMultiTouch( pointA: Nullable, pointB: Nullable, diff --git a/packages/dev/core/src/Cameras/geospatialCamera.ts b/packages/dev/core/src/Cameras/geospatialCamera.ts index c226c49c1f6..1e503a4111e 100644 --- a/packages/dev/core/src/Cameras/geospatialCamera.ts +++ b/packages/dev/core/src/Cameras/geospatialCamera.ts @@ -10,6 +10,9 @@ import { ComputeLocalBasisToRefs, GeospatialCameraMovement } from "./geospatialC import type { IVector3Like } from "../Maths/math.like"; import { Vector3CopyToRef, Vector3Dot } from "../Maths/math.vector.functions"; import { Clamp } from "../Maths/math.scalar.functions"; +import type { AllowedAnimValue } from "../Behaviors/Cameras/interpolatingBehavior"; +import { InterpolatingBehavior } from "../Behaviors/Cameras/interpolatingBehavior"; +import type { EasingFunction } from "../Animations/easing"; type CameraOptions = { planetRadius: number; // Radius of the planet @@ -37,12 +40,19 @@ export class GeospatialCamera extends Camera { private _isViewMatrixDirty: boolean; private _lookAtVector: Vector3 = new Vector3(); + /** Behavior used for smooth flying animations */ + private _flyingBehavior: InterpolatingBehavior; + private _flyToTargets: Map = new Map(); + constructor(name: string, scene: Scene, options: CameraOptions, pickPredicate?: MeshPredicate) { super(name, new Vector3(), scene); this._limits = new GeospatialLimits(options.planetRadius); this._resetToDefault(this._limits); + this._flyingBehavior = new InterpolatingBehavior(); + this.addBehavior(this._flyingBehavior); + this.movement = new GeospatialCameraMovement(scene, this._limits, this.position, this.center, this._lookAtVector, pickPredicate); this.pickPredicate = pickPredicate; @@ -51,7 +61,7 @@ export class GeospatialCamera extends Camera { } private _center: Vector3 = new Vector3(); - /** The point on the globe that we are anchoring around. If no alternate rotation point is present, this will represent the center of screen*/ + /** The point on the globe that we are anchoring around. If no alternate rotation point is supplied, this will represent the center of screen*/ public get center(): Vector3 { return this._center; } @@ -180,6 +190,54 @@ export class GeospatialCamera extends Camera { return this.movement.alternateRotationPt ?? this.center; } + /** + * If camera is actively in flight, will update the target properties and use up the remaining duration from original flyTo call + * + * To start a new flyTo curve entirely, call into flyToAsync again (it will stop the inflight animation) + * @param targetYaw + * @param targetPitch + * @param targetRadius + * @param targetCenter + */ + public updateFlyToDestination(targetYaw?: number, targetPitch?: number, targetRadius?: number, targetCenter?: Vector3): void { + this._flyToTargets.clear(); + + this._flyToTargets.set("yaw", targetYaw); + this._flyToTargets.set("pitch", targetPitch); + this._flyToTargets.set("radius", targetRadius); + this._flyToTargets.set("center", targetCenter); + + this._flyingBehavior.updateProperties(this._flyToTargets); + } + + /** + * Animate camera towards passed in property values. If undefined, will use current value + * @param targetYaw + * @param targetPitch + * @param targetRadius + * @param targetCenter + * @param flightDurationMs + * @param easingFunction + * @returns Promise that will return when the animation is complete (or interuppted by pointer input) + */ + public async flyToAsync( + targetYaw?: number, + targetPitch?: number, + targetRadius?: number, + targetCenter?: Vector3, + flightDurationMs: number = 1000, + easingFunction?: EasingFunction + ): Promise { + this._flyToTargets.clear(); + + this._flyToTargets.set("yaw", targetYaw); + this._flyToTargets.set("pitch", targetPitch); + this._flyToTargets.set("radius", targetRadius); + this._flyToTargets.set("center", targetCenter); + + return await this._flyingBehavior.animatePropertiesAsync(this._flyToTargets, flightDurationMs, easingFunction); + } + private _limits: GeospatialLimits; public get limits(): GeospatialLimits { return this._limits; @@ -250,10 +308,10 @@ export class GeospatialCamera extends Camera { * 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 */ private _applyGeocentricRotation(): void { - const currentFrameRotationDelta = this.movement.rotationDeltaCurrentFrame; - if (currentFrameRotationDelta.x !== 0 || currentFrameRotationDelta.y !== 0) { - const pitch = currentFrameRotationDelta.x !== 0 ? Clamp(this._pitch + currentFrameRotationDelta.x, 0, 0.5 * Math.PI - Epsilon) : this._pitch; - const yaw = currentFrameRotationDelta.y !== 0 ? this._yaw + currentFrameRotationDelta.y : this._yaw; + const rotationDeltaCurrentFrame = this.movement.rotationDeltaCurrentFrame; + if (rotationDeltaCurrentFrame.x !== 0 || rotationDeltaCurrentFrame.y !== 0) { + const pitch = rotationDeltaCurrentFrame.x !== 0 ? Clamp(this._pitch + rotationDeltaCurrentFrame.x, 0, 0.5 * Math.PI - Epsilon) : this._pitch; + const yaw = rotationDeltaCurrentFrame.y !== 0 ? this._yaw + rotationDeltaCurrentFrame.y : this._yaw; // TODO: If _geocentricRotationPt is not the center, this will need to be adjusted. this._setOrientation(yaw, pitch, this._radius, this._geocentricRotationPt);