diff --git a/packages/dev/core/src/Cameras/Inputs/arcRotateCameraGamepadInput.ts b/packages/dev/core/src/Cameras/Inputs/arcRotateCameraGamepadInput.ts index b13d0cf7e994..353d4e45f010 100644 --- a/packages/dev/core/src/Cameras/Inputs/arcRotateCameraGamepadInput.ts +++ b/packages/dev/core/src/Cameras/Inputs/arcRotateCameraGamepadInput.ts @@ -98,14 +98,16 @@ export class ArcRotateCameraGamepadInput implements ICameraInput 0.005) { - camera.inertialAlphaOffset += normalizedRX; + camera.movement.activeInput = true; + camera.movement.rotationAccumulatedPixels.x += normalizedRX; } } if (rsValues.y != 0) { const normalizedRY = (rsValues.y / this.gamepadRotationSensibility) * this._yAxisScale; if (normalizedRY != 0 && Math.abs(normalizedRY) > 0.005) { - camera.inertialBetaOffset += normalizedRY; + camera.movement.activeInput = true; + camera.movement.rotationAccumulatedPixels.y += normalizedRY; } } } @@ -114,7 +116,8 @@ export class ArcRotateCameraGamepadInput implements ICameraInput 0.005) { - this.camera.inertialRadiusOffset -= normalizedLY; + camera.movement.activeInput = true; + camera.movement.zoomAccumulatedPixels -= normalizedLY; } } } diff --git a/packages/dev/core/src/Cameras/Inputs/arcRotateCameraKeyboardMoveInput.ts b/packages/dev/core/src/Cameras/Inputs/arcRotateCameraKeyboardMoveInput.ts index 34f8509f7266..8a8f86fedcad 100644 --- a/packages/dev/core/src/Cameras/Inputs/arcRotateCameraKeyboardMoveInput.ts +++ b/packages/dev/core/src/Cameras/Inputs/arcRotateCameraKeyboardMoveInput.ts @@ -7,6 +7,7 @@ import { type ICameraInput, CameraInputTypes } from "../../Cameras/cameraInputsM import { type KeyboardInfo, KeyboardEventTypes } from "../../Events/keyboardEvents"; import { Tools } from "../../Misc/tools"; import { type AbstractEngine } from "../../Engines/abstractEngine"; +import { type KeyboardConditions } from "../inputMapper"; /** * Manage the keyboard inputs to control the movement of an arc rotate camera. @@ -49,6 +50,20 @@ export class ArcRotateCameraKeyboardMoveInput implements ICameraInput(); private _ctrlPressed: boolean; @@ -84,6 +128,9 @@ export class ArcRotateCameraKeyboardMoveInput implements ICameraInput { this._keys.length = 0; }); @@ -115,7 +164,9 @@ export class ArcRotateCameraKeyboardMoveInput implements ICameraInput 0) { + // When zoomToMouseLocation is active, the accumulating state lives in our private + // `_zoomToMouseRadiusImpulse` field. Otherwise fall back to the legacy surface. + const currentAccumulated = this.zoomToMouseLocation ? this._zoomToMouseRadiusImpulse : this.camera.inertialRadiusOffset; let estimatedTargetRadius = this.camera.radius; - let targetInertia = this.camera.inertialRadiusOffset + delta; + let targetInertia = currentAccumulated + delta; for (let i = 0; i < 20; i++) { // 20 iterations should be enough to converge if (estimatedTargetRadius <= targetInertia) { @@ -117,7 +120,11 @@ export class ArcRotateCameraMouseWheelInput implements ICameraInput upperLimit) { - delta = (camera.radius - upperLimit) * inertiaComp - camera.inertialRadiusOffset; + if (camera.radius - (this._zoomToMouseRadiusImpulse + delta * inputScale) / inertiaComp > upperLimit) { + delta = ((camera.radius - upperLimit) * inertiaComp - this._zoomToMouseRadiusImpulse) / inputScale; } } @@ -264,9 +301,11 @@ export class ArcRotateCameraMouseWheelInput implements ICameraInput, multiTouchPanPosition: Nullable): void { - if (this.panningSensibility !== 0 && previousMultiTouchPanPosition && multiTouchPanPosition) { + if (previousMultiTouchPanPosition && multiTouchPanPosition) { const moveDeltaX = multiTouchPanPosition.x - previousMultiTouchPanPosition.x; const moveDeltaY = multiTouchPanPosition.y - previousMultiTouchPanPosition.y; - this.camera.inertialPanningX += -moveDeltaX / this.panningSensibility; - this.camera.inertialPanningY += moveDeltaY / this.panningSensibility; + // Multi-touch pan is a gesture (no button), so consult the default pointer→pan entry for an + // explicit `sensitivity` override. When unset, fall back to the legacy `panningSensibility` + // (treating panningSensibility=0 as "panning disabled" for backward compatibility). + const panEntry = this.camera.movement.input.getEntry("pointer", "pan"); + const panScale = panEntry?.sensitivity ?? (this.panningSensibility !== 0 ? 1 / this.panningSensibility : 0); + if (panScale !== 0) { + this.camera.movement.activeInput = true; + this.camera.movement.panAccumulatedPixels.x += -moveDeltaX * panScale; + this.camera.movement.panAccumulatedPixels.y += moveDeltaY * panScale; + } } } @@ -110,11 +127,15 @@ export class ArcRotateCameraPointersInput extends OrbitCameraPointersInput { if (this.useNaturalPinchZoom) { this.camera.radius = (radius * Math.sqrt(previousPinchSquaredDistance)) / Math.sqrt(pinchSquaredDistance); } else if (this.pinchDeltaPercentage) { - this.camera.inertialRadiusOffset += (pinchSquaredDistance - previousPinchSquaredDistance) * 0.001 * radius * this.pinchDeltaPercentage; + const delta = (pinchSquaredDistance - previousPinchSquaredDistance) * 0.001 * radius * this.pinchDeltaPercentage; + this.camera.movement.activeInput = true; + this.camera.movement.zoomAccumulatedPixels += delta; } else { - this.camera.inertialRadiusOffset += + const delta = (pinchSquaredDistance - previousPinchSquaredDistance) / ((this.pinchPrecision * (this.pinchInwards ? 1 : -1) * (this.angularSensibilityX + this.angularSensibilityY)) / 2); + this.camera.movement.activeInput = true; + this.camera.movement.zoomAccumulatedPixels += delta; } } @@ -125,12 +146,29 @@ export class ArcRotateCameraPointersInput extends OrbitCameraPointersInput { * @param offsetY offset on Y */ public override onTouch(point: Nullable, offsetX: number, offsetY: number): void { - if (this.panningSensibility !== 0 && ((this._ctrlKey && this.camera._useCtrlForPanning) || this._isPanClick)) { - this.camera.inertialPanningX += -offsetX / this.panningSensibility; - this.camera.inertialPanningY += offsetY / this.panningSensibility; - } else { - this.camera.inertialAlphaOffset -= offsetX / this.angularSensibilityX; - this.camera.inertialBetaOffset -= offsetY / this.angularSensibilityY; + // In pointer-lock mode, mouse movement rotates the camera even without a button held. + // This matches legacy behavior where pointer-lock mouse deltas always drove rotation. + const entry = this._activeEntry ?? (this.camera.getEngine().isPointerLock ? this.camera.movement.input.resolveInteraction("pointer", this._pointerLockConditions) : null); + if (entry) { + // Per-pixel scale. The inputMap entry's `sensitivity` takes precedence so consumers can + // tune feel declaratively (and so we can phase out the legacy sensibility properties). + // When `sensitivity` is unset, fall back to the legacy properties for backward compat. + // For rotate, a single `sensitivity` value applies to both axes; the legacy fallback + // preserves separate X/Y tuning via `angularSensibilityX/Y`. + if (entry.interaction === "pan") { + const panScale = entry.sensitivity ?? (this.panningSensibility !== 0 ? 1 / this.panningSensibility : 0); + if (panScale !== 0) { + this.camera.movement.activeInput = true; + this.camera.movement.panAccumulatedPixels.x += -offsetX * panScale; + this.camera.movement.panAccumulatedPixels.y += offsetY * panScale; + } + } else if (entry.interaction === "rotate") { + const rotateScaleX = entry.sensitivityX ?? entry.sensitivity ?? 1 / this.angularSensibilityX; + const rotateScaleY = entry.sensitivityY ?? entry.sensitivity ?? 1 / this.angularSensibilityY; + this.camera.movement.activeInput = true; + this.camera.movement.rotationAccumulatedPixels.x += -offsetX * rotateScaleX; + this.camera.movement.rotationAccumulatedPixels.y += -offsetY * rotateScaleY; + } } } @@ -171,7 +209,11 @@ export class ArcRotateCameraPointersInput extends OrbitCameraPointersInput { * @param evt Defines the event to track */ public override onButtonDown(evt: IPointerEvent): void { - this._isPanClick = evt.button === this.camera._panningMouseButton; + this._pointerConditions.button = evt.button; + this._pointerConditions.modifiers!.ctrl = evt.ctrlKey; + this._pointerConditions.modifiers!.alt = evt.altKey; + this._pointerConditions.modifiers!.shift = evt.shiftKey; + this._activeEntry = this.camera.movement.input.resolveInteraction("pointer", this._pointerConditions); super.onButtonDown(evt); } @@ -181,6 +223,7 @@ export class ArcRotateCameraPointersInput extends OrbitCameraPointersInput { * @param _evt Defines the event to track */ public override onButtonUp(_evt: IPointerEvent): void { + this._activeEntry = null; super.onButtonUp(_evt); } @@ -188,7 +231,7 @@ export class ArcRotateCameraPointersInput extends OrbitCameraPointersInput { * Called when window becomes inactive. */ public override onLostFocus(): void { - this._isPanClick = false; + this._activeEntry = null; super.onLostFocus(); } } diff --git a/packages/dev/core/src/Cameras/Inputs/geospatialCameraKeyboardInput.ts b/packages/dev/core/src/Cameras/Inputs/geospatialCameraKeyboardInput.ts index 832624850a69..cb62dc84f30b 100644 --- a/packages/dev/core/src/Cameras/Inputs/geospatialCameraKeyboardInput.ts +++ b/packages/dev/core/src/Cameras/Inputs/geospatialCameraKeyboardInput.ts @@ -7,6 +7,7 @@ import { type ICameraInput, CameraInputTypes } from "../cameraInputsManager"; import { type KeyboardInfo, KeyboardEventTypes } from "../../Events/keyboardEvents"; import { Tools } from "../../Misc/tools"; import { type AbstractEngine } from "../../Engines/abstractEngine"; +import { type KeyboardConditions } from "../inputMapper"; /** * Manage the keyboard inputs to control the movement of a geospatial camera. @@ -60,31 +61,66 @@ export class GeospatialCameraKeyboardInput implements ICameraInput(); - private _modifierPressed: boolean; + private _ctrlPressed: boolean; + private _altPressed: boolean; + private _shiftPressed: boolean; private _onCanvasBlurObserver: Nullable>; private _onKeyboardObserver: Nullable>; private _engine: AbstractEngine; private _scene: Scene; + /** Cached conditions object to avoid per-frame allocations in checkInputs */ + private _keyboardConditions: KeyboardConditions = { modifiers: { ctrl: false, alt: false, shift: false } }; + /** * Attach the input controls to a specific dom element to get the input from. * @param noPreventDefault Defines whether event caught by the controls should call preventdefault() (https://developer.mozilla.org/en-US/docs/Web/API/Event/preventDefault) @@ -108,7 +144,9 @@ export class GeospatialCameraKeyboardInput implements ICameraInput = null; + /** Cached resolved inputMap entry for the current pointer gesture */ + private _activeEntry: PointerInputMapEntry | null = null; + + /** Cached conditions object for pointer-down resolution */ + private _pointerConditions: PointerConditions = { modifiers: { ctrl: false, alt: false, shift: false } }; + /** - * Defines the rotation sensitivity of the pointer when rotating camera around the x axis (pitch) + * Defines the rotation sensitivity of the pointer when rotating camera around the x axis (pitch). * (Multiplied by the true pixel delta of pointer input, before rotation speed factor is applied by movement class) + * @deprecated Use the `sensitivity` field on the pointer rotate entry in `camera.movement.input.inputMap` instead. */ - public pitchSensitivity = 1.0; + public get pitchSensitivity(): number { + const entry = this.camera?.movement.input.getEntry("pointer", "rotate"); + return entry?.sensitivityY ?? entry?.sensitivity ?? 1; + } + + public set pitchSensitivity(value: number) { + for (const entry of this.camera?.movement.input.getEntries("pointer", "rotate") ?? []) { + entry.sensitivityY = value; + } + } /** - * Defines the rotation sensitivity of the pointer when rotating the camera around the Y axis (yaw) + * Defines the rotation sensitivity of the pointer when rotating the camera around the Y axis (yaw). * (Multiplied by the true pixel delta of pointer input, before rotation speed factor is applied by movement class) + * @deprecated Use the `sensitivity` field on the pointer rotate entry in `camera.movement.input.inputMap` instead. */ - public yawSensitivity: number = 1.0; + public get yawSensitivity(): number { + const entry = this.camera?.movement.input.getEntry("pointer", "rotate"); + return entry?.sensitivityX ?? entry?.sensitivity ?? 1; + } + + public set yawSensitivity(value: number) { + for (const entry of this.camera?.movement.input.getEntries("pointer", "rotate") ?? []) { + entry.sensitivityX = value; + } + } /** * Defines the distance used to consider the camera in pan mode vs pinch/zoom. @@ -43,30 +69,39 @@ export class GeospatialCameraPointersInput extends OrbitCameraPointersInput { return "GeospatialCameraPointersInput"; } + /** + * Handles the pointer-down event. Captures the active button + modifier state, resolves which + * inputMap entry should drive the gesture, and starts pan tracking if the resolved interaction is "pan". + * @param evt - The pointer-down event. + */ public override onButtonDown(evt: IPointerEvent): void { this.camera.movement.activeInput = true; const scene = this.camera.getScene(); - switch (evt.button) { - case 0: // Left button - drag/pan globe under cursor - this.camera.movement.startDrag(scene.pointerX, scene.pointerY); - break; - default: - break; + + this._pointerConditions.button = evt.button; + this._pointerConditions.modifiers!.ctrl = evt.ctrlKey; + this._pointerConditions.modifiers!.alt = evt.altKey; + this._pointerConditions.modifiers!.shift = evt.shiftKey; + this._activeEntry = this.camera.movement.input.resolveInteraction("pointer", this._pointerConditions); + + if (this._activeEntry?.interaction === "pan") { + this.camera.movement.input.handlers.pan.start(scene.pointerX, scene.pointerY); } } public override onTouch(point: Nullable, offsetX: number, offsetY: number): void { - // Single finger touch (no button property) or left button (button 0) = drag - const button = point?.button ?? 0; // Default to button 0 (drag) if undefined + if (!this._activeEntry) { + return; + } + const sens = this._activeEntry.sensitivity ?? 1; + const sensX = this._activeEntry.sensitivityX ?? sens; + const sensY = this._activeEntry.sensitivityY ?? sens; const scene = this.camera.getScene(); - switch (button) { - case 0: // Left button / single touch - drag/pan globe under cursor - this.camera.movement.handleDrag(scene.pointerX, scene.pointerY); - break; - case 1: // Middle button - tilt camera - case 2: // Right button - tilt camera - this._handleTilt(offsetX, offsetY); - break; + + if (this._activeEntry.interaction === "pan") { + this.camera.movement.input.handlers.pan.update(scene.pointerX, scene.pointerY); + } else if (this._activeEntry.interaction === "rotate") { + this.camera.movement.input.handlers.rotate(offsetX * sensX, -offsetY * sensY); } } @@ -124,7 +159,13 @@ export class GeospatialCameraPointersInput extends OrbitCameraPointersInput { if (previousMultiTouchPanPosition && multiTouchPanPosition) { const moveDeltaX = multiTouchPanPosition.x - previousMultiTouchPanPosition.x; const moveDeltaY = multiTouchPanPosition.y - previousMultiTouchPanPosition.y; - this._handleTilt(moveDeltaX, moveDeltaY); + // Multi-touch is a gesture (no button), so `_activeEntry` is null. Resolve a fresh + // pointer→rotate entry so the configured rotate sensitivity (yaw/pitch) is honored. + const rotateEntry = this.camera.movement.input.getEntry("pointer", "rotate"); + const sens = rotateEntry?.sensitivity ?? 1; + const sensX = rotateEntry?.sensitivityX ?? sens; + const sensY = rotateEntry?.sensitivityY ?? sens; + this.camera.movement.input.handlers.rotate(moveDeltaX * sensX, -moveDeltaY * sensY); } } @@ -135,6 +176,17 @@ export class GeospatialCameraPointersInput extends OrbitCameraPointersInput { } } + /** + * Handles a multi-touch (pinch / two-finger pan) gesture. Detects whether the gesture should be + * interpreted as a pinch zoom or a two-finger pan based on cumulative finger distance change, + * and forwards the gesture to the parent class once a mode is decided. + * @param pointA - First active touch point, or null if it just ended. + * @param pointB - Second active touch point, or null if it just ended. + * @param previousPinchSquaredDistance - Squared distance between the two touches on the previous frame. + * @param pinchSquaredDistance - Squared distance between the two touches on the current frame. + * @param previousMultiTouchPanPosition - Centroid of the two touches on the previous frame, or null if unavailable. + * @param multiTouchPanPosition - Centroid of the two touches on the current frame, or null if the gesture ended. + */ public override onMultiTouch( pointA: Nullable, pointB: Nullable, @@ -167,7 +219,10 @@ export class GeospatialCameraPointersInput extends OrbitCameraPointersInput { } public override onButtonUp(_evt: IPointerEvent): void { - this.camera.movement.stopDrag(); + if (this._activeEntry?.interaction === "pan") { + this.camera.movement.input.handlers.pan.stop(); + } + this._activeEntry = null; this.camera.movement.activeInput = false; this._initialPinchSquaredDistance = 0; this._pinchCentroid = null; @@ -175,13 +230,9 @@ export class GeospatialCameraPointersInput extends OrbitCameraPointersInput { } public override onLostFocus(): void { + this._activeEntry = null; this._initialPinchSquaredDistance = 0; this._pinchCentroid = null; super.onLostFocus(); } - - private _handleTilt(deltaX: number, deltaY: number): void { - this.camera.movement.rotationAccumulatedPixels.y += deltaX * this.yawSensitivity; // yaw - looking side to side - this.camera.movement.rotationAccumulatedPixels.x -= deltaY * this.pitchSensitivity; // pitch - look up towards sky / down towards ground - } } diff --git a/packages/dev/core/src/Cameras/arcRotateCamera.ts b/packages/dev/core/src/Cameras/arcRotateCamera.ts index 5f36c8a24eef..78fa9f9516e5 100644 --- a/packages/dev/core/src/Cameras/arcRotateCamera.ts +++ b/packages/dev/core/src/Cameras/arcRotateCamera.ts @@ -19,6 +19,7 @@ import { ArcRotateCameraInputsManager } from "../Cameras/arcRotateCameraInputsMa import { Epsilon } from "../Maths/math.constants"; import { Tools } from "../Misc/tools"; import { RegisterClass } from "../Misc/typeStore"; +import { ArcRotateCameraMovement } from "./arcRotateCameraMovement"; import { type Collider } from "../Collisions/collider"; import { type TransformNode } from "core/Meshes/transformNode"; @@ -182,24 +183,93 @@ export class ArcRotateCamera extends TargetCamera { /** * Current inertia value on the longitudinal axis. - * The bigger this number the longer it will take for the camera to stop. + * When nonzero, represents the per-frame angular offset (in radians) applied to `alpha`. + * Each frame, this value is multiplied by {@link inertia} (a decay coefficient where + * 0 = instant stop, 0.9 = smooth glide, 1 = never stops). + * Reading this value also reflects the rotation delta the movement system will apply this frame + * (decays toward 0 over the inertia tail), preserving legacy semantics for "is the camera still animating?" checks. + * Setting this to 0 also stops the movement system's rotation velocity for backward compatibility. */ @serialize() - public inertialAlphaOffset = 0; + public get inertialAlphaOffset(): number { + if (this._inertialAlphaOffset !== 0) { + return this._inertialAlphaOffset; + } + if (this.movement.rotationAccumulatedPixels.x !== 0) { + return this.movement.rotationAccumulatedPixels.x; + } + const delta = this.movement.rotationDeltaCurrentFrame.x; + return Math.abs(delta) < this._rotationEpsilon ? 0 : delta; + } + + public set inertialAlphaOffset(value: number) { + this._inertialAlphaOffset = value; + if (value === 0) { + this.movement.resetRotationVelocity(); + } + } + + private _inertialAlphaOffset: number = 0; /** * Current inertia value on the latitudinal axis. - * The bigger this number the longer it will take for the camera to stop. + * When nonzero, represents the per-frame angular offset (in radians) applied to `beta`. + * Each frame, this value is multiplied by {@link inertia} (a decay coefficient where + * 0 = instant stop, 0.9 = smooth glide, 1 = never stops). + * Reading this value also reflects the rotation delta the movement system will apply this frame + * (decays toward 0 over the inertia tail), preserving legacy semantics for "is the camera still animating?" checks. + * Setting this to 0 also stops the movement system's rotation velocity for backward compatibility. */ @serialize() - public inertialBetaOffset = 0; + public get inertialBetaOffset(): number { + if (this._inertialBetaOffset !== 0) { + return this._inertialBetaOffset; + } + if (this.movement.rotationAccumulatedPixels.y !== 0) { + return this.movement.rotationAccumulatedPixels.y; + } + const delta = this.movement.rotationDeltaCurrentFrame.y; + return Math.abs(delta) < this._rotationEpsilon ? 0 : delta; + } + + public set inertialBetaOffset(value: number) { + this._inertialBetaOffset = value; + if (value === 0) { + this.movement.resetRotationVelocity(); + } + } + + private _inertialBetaOffset: number = 0; /** * Current inertia value on the radius axis. - * The bigger this number the longer it will take for the camera to stop. + * When nonzero, represents the per-frame offset (in scene units) applied to `radius`. + * Each frame, this value is multiplied by {@link inertia} (a decay coefficient where + * 0 = instant stop, 0.9 = smooth glide, 1 = never stops). + * Reading this value also reflects the zoom delta the movement system will apply this frame + * (decays toward 0 over the inertia tail), preserving legacy semantics for "is the camera still animating?" checks. + * Setting this to 0 also stops the movement system's zoom velocity for backward compatibility. */ @serialize() - public inertialRadiusOffset = 0; + public get inertialRadiusOffset(): number { + if (this._inertialRadiusOffset !== 0) { + return this._inertialRadiusOffset; + } + if (this.movement.zoomAccumulatedPixels !== 0) { + return this.movement.zoomAccumulatedPixels; + } + const delta = this.movement.zoomDeltaCurrentFrame; + return Math.abs(delta) < this.speed * this._rotationEpsilon ? 0 : delta; + } + + public set inertialRadiusOffset(value: number) { + this._inertialRadiusOffset = value; + if (value === 0) { + this.movement.resetZoomVelocity(); + } + } + + private _inertialRadiusOffset: number = 0; /** * Minimum allowed angle on the longitudinal axis. @@ -286,10 +356,42 @@ export class ArcRotateCamera extends TargetCamera { /** * Defines the value of the inertia used during panning. - * 0 would mean stop inertia and one would mean no deceleration at all. + * A decay coefficient applied per reference frame at 60fps: + * 0 means stop instantly, 0.9 means smooth glide, 1 means never stop. + * Setting this also updates the movement system's pan inertia. */ @serialize() - public panningInertia = 0.9; + public get panningInertia(): number { + return this._panningInertia; + } + + public set panningInertia(value: number) { + this._panningInertia = value; + if (this.movement) { + this.movement.panInertia = value; + } + } + + private _panningInertia = 0.9; + + private _inertia = 0.9; + + /** + * Defines the rotation/zoom inertia (decay coefficient applied per reference frame at 60fps). + * Override of {@link Camera.inertia} that automatically syncs to the movement system + * (rotation and zoom). Panning inertia is controlled separately via {@link panningInertia}. + */ + public override get inertia(): number { + return this._inertia; + } + + public override set inertia(value: number) { + this._inertia = value; + if (this.movement) { + this.movement.rotationInertia = value; + this.movement.zoomInertia = value; + } + } //-- begin properties for backward compatibility for inputs @@ -587,16 +689,84 @@ export class ArcRotateCamera extends TargetCamera { /** @internal */ public override _viewMatrix = new Matrix(); - /** @internal */ - public _useCtrlForPanning: boolean; - /** @internal */ - public _panningMouseButton: number; + + private _useCtrlForPanningInternal: boolean = true; + private _panningMouseButtonInternal: number = 2; /** * Defines the input associated to the camera. */ public override inputs: ArcRotateCameraInputsManager; + /** + * Movement controller that provides framerate-independent physics and the declarative + * inputMap for configuring which inputs map to which camera behaviors. + * + * See {@link InputMapper} for the full inputMap API (e.g. `setInteraction`, `getEntry`, `addEntry`). + */ + public movement: ArcRotateCameraMovement; + + /** + * Gets or sets whether ctrl+keyboard triggers panning. + * Setting this updates the keyboard→pan inputMap entry. + * @internal kept for backward compatibility + */ + public get _useCtrlForPanning(): boolean { + return this._useCtrlForPanningInternal; + } + + public set _useCtrlForPanning(value: boolean) { + this._useCtrlForPanningInternal = value; + const input = this.movement.input; + + // Manage keyboard ctrl → pan entry + const keyboardEntry = input.getEntry("keyboard", "pan", { modifiers: { ctrl: true } }); + if (!value && keyboardEntry) { + input.inputMap.splice(input.inputMap.indexOf(keyboardEntry), 1); + } else if (value && !keyboardEntry) { + input.addEntry({ source: "keyboard", modifiers: { ctrl: true }, interaction: "pan" }); + } + + // Manage pointer ctrl+left-drag → pan entry (matches legacy ArcRotateCameraPointersInput behavior) + const pointerEntry = input.getEntry("pointer", "pan", { modifiers: { ctrl: true } }); + if (!value && pointerEntry) { + input.inputMap.splice(input.inputMap.indexOf(pointerEntry), 1); + } else if (value && !pointerEntry) { + input.addEntry({ source: "pointer", button: 0, modifiers: { ctrl: true }, interaction: "pan" }); + } + } + + /** + * Gets or sets which mouse button triggers panning (0=left, 1=middle, 2=right). + * Setting this updates the pointer→pan inputMap entry. + * @internal kept for backward compatibility with attachControl signature + */ + public get _panningMouseButton(): number { + return this._panningMouseButtonInternal; + } + + public set _panningMouseButton(value: number) { + this._panningMouseButtonInternal = value; + const entry = this.movement.input.getEntry("pointer", "pan", { modifiers: {} }); + if (entry) { + entry.button = value; + } + } + + /** + * @deprecated The movement system is always active. This setter is a no-op kept for backward compatibility. + */ + public set useMovementSystem(_value: boolean) { + // no-op: movement system is always active + } + + /** + * @deprecated The movement system is always active. Always returns true. + */ + public get useMovementSystem(): boolean { + return true; + } + /** @internal */ public override _reset: () => void; @@ -780,6 +950,12 @@ export class ArcRotateCamera extends TargetCamera { this.getViewMatrix(); this.inputs = new ArcRotateCameraInputsManager(this); this.inputs.addKeyboard().addMouseWheel().addPointers(); + this.movement = new ArcRotateCameraMovement(this.getScene(), this._position); + // Seed movement-system inertia from the values set during base/subclass construction. + // After this point, the inertia/panningInertia setters on this class push directly to movement. + this.movement.rotationInertia = this._inertia; + this.movement.zoomInertia = this._inertia; + this.movement.panInertia = this._panningInertia; } // Cache @@ -1032,6 +1208,67 @@ export class ArcRotateCamera extends TargetCamera { } } + /** + * Applies rotation and zoom deltas to the camera, handling invertRotation, handedness, and beta-flip. + * Shared by both the movement system and legacy inertia paths. + * @param alphaOffset - Alpha (horizontal orbit) delta + * @param betaOffset - Beta (vertical orbit) delta + * @param radiusOffset - Radius (zoom) delta + */ + private _applyRotationAndZoomDelta(alphaOffset: number, betaOffset: number, radiusOffset: number): void { + const directionModifier = this.invertRotation ? -1 : 1; + const handednessMultiplier = this._calculateHandednessMultiplier(); + let adjustedAlpha = alphaOffset * handednessMultiplier; + + if (this.beta < 0) { + adjustedAlpha *= -1; + } + + this.alpha += adjustedAlpha * directionModifier; + this.beta += betaOffset * directionModifier; + this.radius -= radiusOffset; + } + + /** + * Applies a pan delta to the camera target in screen space. + * Shared by both the movement system and legacy inertia paths. + * @param panX - Horizontal pan delta + * @param panY - Vertical pan delta + */ + private _applyPanDelta(panX: number, panY: number): void { + const localDirection = TmpVectors.Vector3[0].copyFromFloats(panX, panY, panY); + + this._viewMatrix.invertToRef(this._cameraTransformMatrix); + localDirection.multiplyInPlace(this.panningAxis); + Vector3.TransformNormalToRef(localDirection, this._cameraTransformMatrix, this._transformedDirection); + + if (this.mapPanning) { + const up = this.upVector; + const right = Vector3.CrossToRef(this._transformedDirection, up, this._transformedDirection); + Vector3.CrossToRef(up, right, this._transformedDirection); + } else if (!this.panningAxis.y) { + this._transformedDirection.y = 0; + } + + if (!this._targetHost) { + if (this.panningDistanceLimit) { + this._transformedDirection.addInPlace(this._target); + const distanceSquared = Vector3.DistanceSquared(this._transformedDirection, this.panningOriginTarget); + if (distanceSquared <= this.panningDistanceLimit * this.panningDistanceLimit) { + this._target.copyFrom(this._transformedDirection); + } + } else { + if (this.parent) { + const m = TmpVectors.Matrix[0]; + this.parent.getWorldMatrix().getRotationMatrixToRef(m); + m.transposeToRef(m); + Vector3.TransformCoordinatesToRef(this._transformedDirection, m, this._transformedDirection); + } + this._target.addInPlace(this._transformedDirection); + } + } + } + /** @internal */ public override _checkInputs(): void { //if (async) collision inspection was triggered, don't update the camera's position - until the collision callback was called. @@ -1042,78 +1279,50 @@ export class ArcRotateCamera extends TargetCamera { this.inputs.checkInputs(); let hasUserInteractions = false; - // Inertia - if (this.inertialAlphaOffset !== 0 || this.inertialBetaOffset !== 0 || this.inertialRadiusOffset !== 0) { - hasUserInteractions = true; + this.movement.computeCurrentFrameDeltas(); - const directionModifier = this.invertRotation ? -1 : 1; - const handednessMultiplier = this._calculateHandednessMultiplier(); - let inertialAlphaOffset = this.inertialAlphaOffset * handednessMultiplier; + const rotDelta = this.movement.rotationDeltaCurrentFrame; + const zoomDelta = this.movement.zoomDeltaCurrentFrame; + const panDelta = this.movement.panDeltaCurrentFrame; - if (this.beta < 0) { - inertialAlphaOffset *= -1; + if (rotDelta.x !== 0 || rotDelta.y !== 0 || zoomDelta !== 0 || panDelta.x !== 0 || panDelta.y !== 0) { + hasUserInteractions = true; + if (rotDelta.x !== 0 || rotDelta.y !== 0 || zoomDelta !== 0) { + this._applyRotationAndZoomDelta(rotDelta.x, rotDelta.y, zoomDelta); } - this.alpha += inertialAlphaOffset * directionModifier; - this.beta += this.inertialBetaOffset * directionModifier; + if (panDelta.x !== 0 || panDelta.y !== 0) { + this._applyPanDelta(panDelta.x, panDelta.y); + } + } - this.radius -= this.inertialRadiusOffset; - this.inertialAlphaOffset *= this.inertia; - this.inertialBetaOffset *= this.inertia; - this.inertialRadiusOffset *= this.inertia; - if (Math.abs(this.inertialAlphaOffset) < this._rotationEpsilon) { - this.inertialAlphaOffset = 0; + // Legacy inertial offsets — backward compat path. Handles direct writes to inertialAlphaOffset + // etc. (including zoomToMouseLocation which writes to inertialRadiusOffset). + // Only activates when these values are nonzero, so it is free when unused. + // We check the private fields here instead of the public getters because the getters fall back + // to the movement system's current-frame deltas (for back-compat polling), which would cause + // double-application of the rotation/zoom that movement.computeCurrentFrameDeltas just produced. + if (this._inertialAlphaOffset !== 0 || this._inertialBetaOffset !== 0 || this._inertialRadiusOffset !== 0) { + this._applyRotationAndZoomDelta(this._inertialAlphaOffset, this._inertialBetaOffset, this._inertialRadiusOffset); + this._inertialAlphaOffset *= this.inertia; + this._inertialBetaOffset *= this.inertia; + this._inertialRadiusOffset *= this.inertia; + if (Math.abs(this._inertialAlphaOffset) < this._rotationEpsilon) { + this._inertialAlphaOffset = 0; } - if (Math.abs(this.inertialBetaOffset) < this._rotationEpsilon) { - this.inertialBetaOffset = 0; + if (Math.abs(this._inertialBetaOffset) < this._rotationEpsilon) { + this._inertialBetaOffset = 0; } - if (Math.abs(this.inertialRadiusOffset) < this.speed * this._rotationEpsilon) { - this.inertialRadiusOffset = 0; + if (Math.abs(this._inertialRadiusOffset) < this.speed * this._rotationEpsilon) { + this._inertialRadiusOffset = 0; } + hasUserInteractions = true; } - // Panning inertia if (this.inertialPanningX !== 0 || this.inertialPanningY !== 0) { - hasUserInteractions = true; - - const localDirection = TmpVectors.Vector3[0]; - localDirection.copyFromFloats(this.inertialPanningX, this.inertialPanningY, this.inertialPanningY); - - this._viewMatrix.invertToRef(this._cameraTransformMatrix); - localDirection.multiplyInPlace(this.panningAxis); - Vector3.TransformNormalToRef(localDirection, this._cameraTransformMatrix, this._transformedDirection); - - // If mapPanning is enabled, we need to take the upVector into account and - // make sure we're not panning in the y direction - if (this.mapPanning) { - const up = this.upVector; - const right = Vector3.CrossToRef(this._transformedDirection, up, this._transformedDirection); - Vector3.CrossToRef(up, right, this._transformedDirection); - } else if (!this.panningAxis.y) { - this._transformedDirection.y = 0; - } - - if (!this._targetHost) { - if (this.panningDistanceLimit) { - this._transformedDirection.addInPlace(this._target); - const distanceSquared = Vector3.DistanceSquared(this._transformedDirection, this.panningOriginTarget); - if (distanceSquared <= this.panningDistanceLimit * this.panningDistanceLimit) { - this._target.copyFrom(this._transformedDirection); - } - } else { - if (this.parent) { - const m = TmpVectors.Matrix[0]; - this.parent.getWorldMatrix().getRotationMatrixToRef(m); - m.transposeToRef(m); - Vector3.TransformCoordinatesToRef(this._transformedDirection, m, this._transformedDirection); - } - this._target.addInPlace(this._transformedDirection); - } - } - + this._applyPanDelta(this.inertialPanningX, this.inertialPanningY); this.inertialPanningX *= this.panningInertia; this.inertialPanningY *= this.panningInertia; - const inertialPanningLimit = this.speed * this._panningEpsilon; if (Math.abs(this.inertialPanningX) < inertialPanningLimit) { this.inertialPanningX = 0; @@ -1121,6 +1330,7 @@ export class ArcRotateCamera extends TargetCamera { if (Math.abs(this.inertialPanningY) < inertialPanningLimit) { this.inertialPanningY = 0; } + hasUserInteractions = true; } if (hasUserInteractions) { diff --git a/packages/dev/core/src/Cameras/arcRotateCameraMovement.ts b/packages/dev/core/src/Cameras/arcRotateCameraMovement.ts new file mode 100644 index 000000000000..49c68a020b31 --- /dev/null +++ b/packages/dev/core/src/Cameras/arcRotateCameraMovement.ts @@ -0,0 +1,78 @@ +import { CameraMovement } from "./cameraMovement"; +import { type Scene } from "../scene"; +import { type Vector3 } from "../Maths/math.vector"; +import { type InterpolatingBehavior } from "../Behaviors/Cameras/interpolatingBehavior"; +import { type ArcRotateCamera } from "./arcRotateCamera"; +import { type InputMapEntry, InputMapper } from "./inputMapper"; + +// ── ArcRotate handler types ──────────────────────────────────────── + +/** + * Handler shape for arc-rotate camera interactions. + * Property names are the canonical interaction type strings used in inputMap entries. + * All handlers are plain functions since none need multi-method lifecycle. + */ +export type ArcRotateHandlers = { + /** Pan by pre-scaled pixel deltas */ + pan: (deltaX: number, deltaY: number) => void; + /** Orbit by pre-scaled pixel deltas */ + rotate: (deltaX: number, deltaY: number) => void; + /** Zoom by a pre-computed delta (already scaled by input) */ + zoom: (delta: number) => void; +}; + +/** Interaction type string for arc-rotate camera, derived from handler property names */ +export type ArcRotateInteraction = keyof ArcRotateHandlers; + +/** + * Arc-rotate camera movement system that provides framerate-independent physics + * and input mapping for pan, rotate, and zoom interactions. + * + * Default accumulator-based flow: input classes feed pixel deltas into the accumulators + * (panAccumulatedPixels, rotationAccumulatedPixels, zoomAccumulatedPixels). + * The base class's computeCurrentFrameDeltas() converts these to framerate-independent + * deltas with proper inertia, which the camera reads each frame. + */ +export class ArcRotateCameraMovement extends CameraMovement { + /** Input system that maps physical inputs to interactions and dispatches to handlers. */ + public readonly input: InputMapper; + + constructor(scene: Scene, cameraPosition: Vector3, behavior?: InterpolatingBehavior) { + super(scene, cameraPosition, behavior); + + this.input = new InputMapper( + { + pan: (deltaX, deltaY) => { + this.panAccumulatedPixels.x += deltaX; + this.panAccumulatedPixels.y += deltaY; + }, + rotate: (deltaX, deltaY) => { + this.rotationAccumulatedPixels.x += deltaX; + this.rotationAccumulatedPixels.y += deltaY; + }, + zoom: (delta) => { + this.zoomAccumulatedPixels += delta; + }, + }, + () => this._createDefaultInputMap() + ); + } + + private _createDefaultInputMap(): InputMapEntry[] { + // Default entries leave `sensitivity` unset so each input class falls back to its legacy + // sensibility properties (`angularSensibilityX/Y`, `panningSensibility`, `wheelPrecision`, + // `angularSpeed`, `zoomingSensibility`) for backward compatibility. Setting `sensitivity` + // on an entry overrides those properties — the long-term path for phasing them out. + return [ + // ctrl+left-drag → pan (more specific than the bare rotate entry below; must come first so first-match-wins picks it). + { source: "pointer", button: 0, modifiers: { ctrl: true }, interaction: "pan" }, + { source: "pointer", button: 0, interaction: "rotate" }, + { source: "pointer", button: 2, interaction: "pan" }, + { source: "wheel", interaction: "zoom" }, + { source: "keyboard", key: [187, 107, 189, 109], interaction: "zoom" }, // +/-/numpad+/numpad- + { source: "keyboard", modifiers: { ctrl: true }, interaction: "pan" }, + { source: "keyboard", modifiers: { alt: true }, interaction: "zoom" }, + { source: "keyboard", interaction: "rotate" }, + ]; + } +} diff --git a/packages/dev/core/src/Cameras/camera.ts b/packages/dev/core/src/Cameras/camera.ts index 52f6148d1f4e..c3f84d9357ef 100644 --- a/packages/dev/core/src/Cameras/camera.ts +++ b/packages/dev/core/src/Cameras/camera.ts @@ -299,7 +299,15 @@ export class Camera extends Node { * This helps giving a smooth feeling to the camera movement. */ @serialize() - public inertia = 0.9; + public get inertia(): number { + return this._baseInertia; + } + + public set inertia(value: number) { + this._baseInertia = value; + } + + private _baseInertia = 0.9; private _mode = Camera.PERSPECTIVE_CAMERA; diff --git a/packages/dev/core/src/Cameras/cameraMovement.ts b/packages/dev/core/src/Cameras/cameraMovement.ts index 5531af18d403..a09dcaa1b899 100644 --- a/packages/dev/core/src/Cameras/cameraMovement.ts +++ b/packages/dev/core/src/Cameras/cameraMovement.ts @@ -1,19 +1,26 @@ import { type Scene } from "../scene"; import { Vector3 } from "../Maths/math.vector"; -import { Epsilon } from "../Maths/math.constants"; import { type InterpolatingBehavior } from "../Behaviors/Cameras/interpolatingBehavior"; -const FrameDurationAt60FPS = 1000 / 60; +const DefaultReferenceFrameRate = 60; /** - * Holds all logic related to converting input pixel deltas into current frame deltas, taking speed / framerate into account - * to ensure smooth frame-rate-independent movement + * Base class for camera movement systems that convert raw input into framerate-independent camera deltas. + * + * This class handles the physics layer: velocity tracking, inertial decay, speed multipliers, + * and per-frame delta computation. Input mapping (which physical inputs trigger which interactions) + * is handled by an `InputMapper` instance composed on each movement subclass as `input`. + * + * **Speed and inertia** — Properties on this class that control how accumulated pixel deltas + * are converted to framerate-independent camera deltas via `computeCurrentFrameDeltas()`: + * - `panSpeed`, `rotationXSpeed`, `rotationYSpeed`, `zoomSpeed` — units of movement per pixel + * - `panInertia`, `rotationInertia`, `zoomInertia` — velocity decay factor when input stops (0 = instant stop, 0.9 = smooth glide) */ export class CameraMovement { protected _scene: Scene; /** - * Should be set by input classes to indicates whether there is active input this frame - * This helps us differentiate between 0 pixel delta due to no input vs user actively holding still + * Should be set by input classes to indicate whether there is active input this frame. + * This helps differentiate between 0 pixel delta due to no input vs user actively holding still. */ public activeInput: boolean = false; @@ -22,6 +29,11 @@ export class CameraMovement { * Speed defines the amount of camera movement expected per input pixel movement * ----------------------------------- */ + /** + * Global speed multiplier applied to all movement (pan, rotation, zoom). + * Acts as a master scale factor on top of the individual speed properties. + */ + public speed: number = 1; /** * Desired coordinate unit movement per input pixel when zooming */ @@ -66,17 +78,25 @@ export class CameraMovement { */ /** * Inertia applied to the zoom velocity when there is no user input. - * Higher inertia === slower decay, velocity retains more of its value each frame + * Higher inertia === slower decay, velocity retains more of its value each frame. + * + * Note: ArcRotateCamera syncs this from `camera.inertia` via an accessor on the camera class. + * To tune independently, override inside `scene.onBeforeRenderObservable` after `camera.inertia` is read. */ public zoomInertia: number = 0.9; /** * Inertia applied to the panning velocity when there is no user input. - * Higher inertia === slower decay, velocity retains more of its value each frame + * Higher inertia === slower decay, velocity retains more of its value each frame. + * + * Note: ArcRotateCamera overrides this from `camera.panningInertia` (which defaults to `camera.inertia`). */ public panInertia: number = 0.9; /** * Inertia applied to the rotation velocity when there is no user input. - * Higher inertia === slower decay, velocity retains more of its value each frame + * Higher inertia === slower decay, velocity retains more of its value each frame. + * + * Note: ArcRotateCamera syncs this from `camera.inertia` via an accessor on the camera class. + * To tune independently, override inside `scene.onBeforeRenderObservable` after `camera.inertia` is read. */ public rotationInertia: number = 0.9; @@ -135,6 +155,14 @@ export class CameraMovement { * Pan velocity used for inertia calculations (movement / time) */ private _panVelocity: Vector3 = new Vector3(); + /** + * Framerate (Hz) at which inertia values are calibrated. Default 60 matches legacy camera feel + * at any actual refresh rate. Override to 120, 144, etc. only if your app was tuned on that + * specific refresh rate under the legacy (framerate-dependent) camera math and you want to + * preserve that exact decay characteristic. Most applications should leave this at 60. + */ + public referenceFrameRate: number = DefaultReferenceFrameRate; + /** * Rotation velocity used for inertia calculations (movement / time) */ @@ -143,7 +171,7 @@ export class CameraMovement { /** * Used when calculating inertial decay. Default to 60fps */ - private _prevFrameTimeMs: number = FrameDurationAt60FPS; + private _prevFrameTimeMs: number = 1000 / DefaultReferenceFrameRate; constructor( scene: Scene, @@ -160,6 +188,35 @@ export class CameraMovement { */ public computeCurrentFrameDeltas(): void { const deltaTimeMs = this._scene.getEngine().getDeltaTime(); + // Use prevFrameTime as fallback when deltaTime is 0 (e.g. first render frame in tests or unusual conditions) + const effectiveDeltaMs = deltaTimeMs > 0 ? deltaTimeMs : this._prevFrameTimeMs; + + // Fast-path: when nothing is moving (no accumulated input, all velocities zero), skip all work. + if ( + this._zoomVelocity === 0 && + this.zoomAccumulatedPixels === 0 && + this._panVelocity.x === 0 && + this._panVelocity.y === 0 && + this._panVelocity.z === 0 && + this.panAccumulatedPixels.x === 0 && + this.panAccumulatedPixels.y === 0 && + this.panAccumulatedPixels.z === 0 && + this._rotationVelocity.x === 0 && + this._rotationVelocity.y === 0 && + this._rotationVelocity.z === 0 && + this.rotationAccumulatedPixels.x === 0 && + this.rotationAccumulatedPixels.y === 0 && + this.rotationAccumulatedPixels.z === 0 && + !this.activeInput + ) { + this.panDeltaCurrentFrame.setAll(0); + this.rotationDeltaCurrentFrame.setAll(0); + this.zoomDeltaCurrentFrame = 0; + if (deltaTimeMs > 0) { + this._prevFrameTimeMs = deltaTimeMs; + } + return; + } this.panDeltaCurrentFrame.setAll(0); this.rotationDeltaCurrentFrame.setAll(0); @@ -176,7 +233,7 @@ export class CameraMovement { this._calculateCurrentVelocity(this._panVelocity.y, this.panAccumulatedPixels.y, this.panInertia), this._calculateCurrentVelocity(this._panVelocity.z, this.panAccumulatedPixels.z, this.panInertia) ); - this._panVelocity.scaleToRef(this.panSpeed * this._panSpeedMultiplier * deltaTimeMs, this.panDeltaCurrentFrame); + this._panVelocity.scaleToRef(this.speed * this.panSpeed * this._panSpeedMultiplier * effectiveDeltaMs, this.panDeltaCurrentFrame); this._rotationVelocity.copyFromFloats( this._calculateCurrentVelocity(this._rotationVelocity.x, this.rotationAccumulatedPixels.x, this.rotationInertia), @@ -184,38 +241,116 @@ export class CameraMovement { this._calculateCurrentVelocity(this._rotationVelocity.z, this.rotationAccumulatedPixels.z, this.rotationInertia) ); this.rotationDeltaCurrentFrame.copyFromFloats( - this._rotationVelocity.x * this.rotationXSpeed * deltaTimeMs, - this._rotationVelocity.y * this.rotationYSpeed * deltaTimeMs, - this._rotationVelocity.z * this.rotationYSpeed * deltaTimeMs + this._rotationVelocity.x * this.speed * this.rotationXSpeed * effectiveDeltaMs, + this._rotationVelocity.y * this.speed * this.rotationYSpeed * effectiveDeltaMs, + // z is not used by current handlers; keep at 0. Add a rotationZSpeed if z motion is wired up later. + 0 ); this._zoomVelocity = this._calculateCurrentVelocity(this._zoomVelocity, this.zoomAccumulatedPixels, this.zoomInertia); - this.zoomDeltaCurrentFrame = this._zoomVelocity * (this.zoomSpeed * this._zoomSpeedMultiplier) * deltaTimeMs; + this.zoomDeltaCurrentFrame = this._zoomVelocity * (this.speed * this.zoomSpeed * this._zoomSpeedMultiplier) * effectiveDeltaMs; - this._prevFrameTimeMs = deltaTimeMs; + if (deltaTimeMs > 0) { + this._prevFrameTimeMs = deltaTimeMs; + } this.zoomAccumulatedPixels = 0; this.panAccumulatedPixels.setAll(0); this.rotationAccumulatedPixels.setAll(0); + this.activeInput = false; + } + + /** + * Resets the rotation velocity and accumulated pixels, stopping any in-progress rotation inertia. + * Called when inertialAlphaOffset or inertialBetaOffset are explicitly zeroed (backward compat). + */ + public resetRotationVelocity(): void { + this._rotationVelocity.setAll(0); + this.rotationAccumulatedPixels.setAll(0); + } + + /** + * Resets the pan velocity and accumulated pixels, stopping any in-progress pan inertia. + */ + public resetPanVelocity(): void { + this._panVelocity.setAll(0); + this.panAccumulatedPixels.setAll(0); } + /** + * Resets the zoom velocity and accumulated pixels, stopping any in-progress zoom inertia. + * Called when inertialRadiusOffset is explicitly zeroed out (backward compat). + */ + public resetZoomVelocity(): void { + this._zoomVelocity = 0; + this.zoomAccumulatedPixels = 0; + } + + /** + * Returns true when the camera is playing an interpolating (fly-to) animation. + * Useful for suppressing user-input movement while a programmatic animation is active. + */ public get isInterpolating(): boolean { return !!this._behavior?.isInterpolating; } + /** + * Returns the per-frame decay factor for a given inertia, adjusted to this frame's `dt`. + * At the reference frame rate, returns `inertia` unchanged (matches legacy per-frame `*= inertia`). + * Use this when implementing custom decaying accumulators (e.g. zoom-to-cursor coupled pan) + * that need framerate-independent glide duration. + * @param inertia - The inertia value (0-1) whose per-frame decay factor is needed. + * @returns The decay factor to multiply a value by this frame. + */ + public getFrameIndependentDecay(inertia: number): number { + const dt = this._scene.getEngine().getDeltaTime(); + const effectiveDt = dt > 0 ? dt : this._prevFrameTimeMs; + const referenceFrameDurationMs = 1000 / this.referenceFrameRate; + return Math.pow(inertia, effectiveDt / referenceFrameDurationMs); + } + + /** + * Returns the input-scale factor to apply to an impulse injected into a decaying accumulator + * so that the integrated total is framerate-independent and matches legacy at 60fps. + * At the reference frame rate, returns 1 (no-op). At high fps, scales the impulse down so + * the sum over the decay tail stays equal to `impulse / (1 - inertia)` — the legacy total. + * @param inertia - The inertia value (0-1) used by the accumulator. + * @returns The scaling factor to multiply an impulse by before adding it to the accumulator. + */ + public getFrameIndependentInputScale(inertia: number): number { + const oneMinusInertia = 1 - inertia; + if (oneMinusInertia <= 0) { + return 1; + } + const decay = this.getFrameIndependentDecay(inertia); + return (1 - decay) / oneMinusInertia; + } + private _calculateCurrentVelocity(velocityRef: number, pixelDelta: number, inertialDecayFactor: number): number { let inputVelocity = velocityRef; const deltaTimeMs = this._scene.getEngine().getDeltaTime(); + // Use prevFrameTime as fallback when deltaTime is 0 (e.g. first render frame in tests or unusual conditions) + const effectiveDeltaMs = deltaTimeMs > 0 ? deltaTimeMs : this._prevFrameTimeMs; - // If we are actively receiving input or have accumulated some pixel delta since last frame, calculate inputVelocity (inertia doesn't kick in yet) + if (effectiveDeltaMs === 0) { + return inputVelocity; + } + + // Apply inertial decay every frame + const frameIndependentDecay = this.getFrameIndependentDecay(inertialDecayFactor); + inputVelocity *= frameIndependentDecay; + + // When there's input this frame, add it on top of the decayed velocity — matches legacy's + // `offset += pointerDelta` accumulation. The `inputScale` factor keeps the sustained-drag + // steady-state identical to legacy at the reference framerate (`R/(1-inertia)`) at any + // actual framerate. Without this factor, `v_ss = R/(1-pow(k, dt/T))` blows up at high fps + // (2.3x at 144fps). When running at the reference framerate, inputScale = 1 (no-op). if (pixelDelta !== 0 || this.activeInput) { - inputVelocity = pixelDelta / deltaTimeMs; - } else if (!this.activeInput && inputVelocity !== 0) { - // If we are not receiving input and velocity isn't already zero, apply inertial decay to decelerate velocity - const frameIndependentDecay = Math.pow(inertialDecayFactor, this._prevFrameTimeMs / FrameDurationAt60FPS); - inputVelocity *= frameIndependentDecay; - if (Math.abs(inputVelocity) <= Epsilon) { - inputVelocity = 0; - } + const oneMinusInertia = 1 - inertialDecayFactor; + const inputScale = oneMinusInertia > 0 ? (1 - frameIndependentDecay) / oneMinusInertia : 1; + inputVelocity += (pixelDelta / effectiveDeltaMs) * inputScale; + } else if (Math.abs(inputVelocity) < 1e-6) { + // Epsilon cutoff when gliding with no input + inputVelocity = 0; } return inputVelocity; diff --git a/packages/dev/core/src/Cameras/geospatialCamera.ts b/packages/dev/core/src/Cameras/geospatialCamera.ts index 19b53f5ff1f8..1c0181936138 100644 --- a/packages/dev/core/src/Cameras/geospatialCamera.ts +++ b/packages/dev/core/src/Cameras/geospatialCamera.ts @@ -34,7 +34,7 @@ export type GeospatialCameraOptions = { export class GeospatialCamera extends Camera { override inputs: GeospatialCameraInputsManager; - /** Movement controller that turns input pixelDeltas into currentFrameDeltas used by camera*/ + /** Movement controller that provides input mapping and framerate-independent physics for geospatial interactions */ public readonly movement: GeospatialCameraMovement; // Temp vars @@ -67,6 +67,7 @@ export class GeospatialCamera extends Camera { this.addBehavior(this._flyingBehavior); this.movement = new GeospatialCameraMovement(scene, this._limits, this.position, this.center, this._lookAtVector, options.pickPredicate, this._flyingBehavior); + this._resetToDefault(this._limits); this.inputs = new GeospatialCameraInputsManager(this); @@ -216,10 +217,10 @@ export class GeospatialCamera extends Camera { * 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 + * @param targetYaw - Target yaw in radians, or undefined to keep the current yaw. + * @param targetPitch - Target pitch in radians, or undefined to keep the current pitch. + * @param targetRadius - Target radius (distance from the look-at center), or undefined to keep the current radius. + * @param targetCenter - Target look-at center in scene coordinates, or undefined to keep the current center. */ public updateFlyToDestination(targetYaw?: number, targetPitch?: number, targetRadius?: number, targetCenter?: Vector3): void { this._flyToTargets.clear(); @@ -236,12 +237,12 @@ export class GeospatialCamera extends Camera { /** * 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 + * @param targetYaw - Target yaw in radians, or undefined to keep the current yaw. + * @param targetPitch - Target pitch in radians, or undefined to keep the current pitch. + * @param targetRadius - Target radius (distance from the look-at center), or undefined to keep the current radius. + * @param targetCenter - Target look-at center in scene coordinates, or undefined to keep the current center. + * @param flightDurationMs - Total duration of the animation in milliseconds. Defaults to 1000ms. + * @param easingFunction - Optional easing function applied to the animation curve. * @param centerHopScale If supplied, will define the parabolic hop height scale for center animation to create a "bounce" effect * @returns Promise that will return when the animation is complete (or interuppted by pointer input) */ diff --git a/packages/dev/core/src/Cameras/geospatialCameraMovement.ts b/packages/dev/core/src/Cameras/geospatialCameraMovement.ts index acdfcf78caae..cec38df9e8fb 100644 --- a/packages/dev/core/src/Cameras/geospatialCameraMovement.ts +++ b/packages/dev/core/src/Cameras/geospatialCameraMovement.ts @@ -12,6 +12,40 @@ import { type PickingInfo } from "../Collisions/pickingInfo"; import { type Nullable } from "../types"; import { type InterpolatingBehavior } from "../Behaviors/Cameras/interpolatingBehavior"; import { type GeospatialCamera } from "./geospatialCamera"; +import { type InputMapEntry, InputMapper } from "./inputMapper"; + +// ── Geospatial handler types ──────────────────────────────────────── + +/** + * Handler for geospatial pan (globe drag) interactions. + * Pan uses screen coordinates and needs a lifecycle (start/update/stop) because + * it establishes a drag plane on the globe surface to anchor the cursor. + */ +export type GeospatialPanHandler = { + /** Begin a pan gesture at screen position */ + start(screenX: number, screenY: number): void; + /** Continue panning to new screen position */ + update(screenX: number, screenY: number): void; + /** End the pan gesture */ + stop(): void; +}; + +/** + * Handler shape for geospatial camera interactions. + * Property names are the canonical interaction type strings used in inputMap entries. + * Single-method handlers are plain functions; multi-method handlers (pan) are objects. + */ +export type GeospatialHandlers = { + /** Handler for pan (globe drag) interactions — object because it needs start/update/stop lifecycle */ + pan: GeospatialPanHandler; + /** Handler for rotate (tilt) interactions — accepts yaw (horizontal) and pitch (vertical) deltas */ + rotate: (yaw: number, pitch: number) => void; + /** Handler for zoom interactions — accepts delta and whether to zoom toward cursor */ + zoom: (delta: number, toCursor: boolean) => void; +}; + +/** Interaction type string for geospatial camera, derived from handler property names */ +export type GeospatialInteraction = keyof GeospatialHandlers; /** * Geospatial-specific camera movement system that extends the base movement with @@ -29,11 +63,22 @@ export class GeospatialCameraMovement extends CameraMovement { /** Predicate function to determine which meshes to pick against (e.g., globe mesh) */ public pickPredicate?: MeshPredicate; - /** World-space picked point under cursor for zoom-to-cursor behavior (may be undefined) */ + /** + * World-space picked point under the cursor, computed each frame that zoom input is active. + * Used to determine the zoom direction when `zoomToCursor` is true. + * Undefined when there is no active zoom or the pick misses the globe. + */ public computedPerFrameZoomPickPoint?: Vector3; + /** + * When true, zooming moves toward the point under the cursor. + * When false, zooming moves along the camera's look vector. + */ public zoomToCursor: boolean = true; + /** Input system that maps physical inputs to interactions and dispatches to handlers. */ + public readonly input: InputMapper; + private _tempPickingRay: Ray; private _hitPointRadius?: number = undefined; @@ -45,6 +90,7 @@ export class GeospatialCameraMovement extends CameraMovement { constructor( scene: Scene, + /** Geospatial bounds (min/max latitude, longitude, altitude, etc.) used to clamp camera motion. */ public limits: GeospatialLimits, cameraPosition: Vector3, private _cameraCenter: Vector3, @@ -60,6 +106,43 @@ export class GeospatialCameraMovement extends CameraMovement { this.rotationXSpeed = Math.PI / 500; // Move 1/500th of a half circle per pixel this.rotationYSpeed = Math.PI / 500; // Move 1/500th of a half circle per pixel this.zoomSpeed = 2; // Base zoom speed; actual speed is scaled based on altitude + + this.input = new InputMapper( + { + pan: { + start: (screenX: number, screenY: number) => { + this.startDrag(screenX, screenY); + }, + update: (screenX: number, screenY: number) => { + this.handleDrag(screenX, screenY); + }, + stop: () => { + this.stopDrag(); + }, + }, + rotate: (yaw: number, pitch: number) => { + this.rotationAccumulatedPixels.y += yaw; + this.rotationAccumulatedPixels.x += pitch; + }, + zoom: (delta: number, toCursor: boolean) => { + this.handleZoom(delta, toCursor); + }, + }, + () => this._createDefaultInputMap() + ); + } + + private _createDefaultInputMap(): InputMapEntry[] { + return [ + { source: "pointer", button: 0, interaction: "pan" }, + { source: "pointer", button: 1, interaction: "rotate" }, + { source: "pointer", button: 2, interaction: "rotate" }, + { source: "wheel", interaction: "zoom" }, + { source: "keyboard", key: [187, 107, 189, 109], interaction: "zoom", sensitivity: 1.0 }, // +/-/numpad+/numpad- + { source: "keyboard", modifiers: { ctrl: true }, interaction: "rotate", sensitivity: 1.0 }, + { source: "keyboard", modifiers: { alt: true }, interaction: "rotate", sensitivity: 1.0 }, + { source: "keyboard", interaction: "pan", sensitivity: 1.0 }, + ]; } /** @@ -74,6 +157,12 @@ export class GeospatialCameraMovement extends CameraMovement { return point.normalizeToRef(result); }; + /** + * Begins a drag (pan) gesture by picking the globe at the given screen position + * and establishing a drag plane for subsequent updates. + * @param pointerX - Screen X coordinate of the pointer + * @param pointerY - Screen Y coordinate of the pointer + */ public startDrag(pointerX: number, pointerY: number) { const pickResult = this._scene.pick(pointerX, pointerY, this.pickPredicate); if (pickResult.pickedPoint && pickResult.ray) { @@ -87,6 +176,9 @@ export class GeospatialCameraMovement extends CameraMovement { } } + /** + * Ends the current drag gesture, releasing the drag plane. + */ public stopDrag() { this._hitPointRadius = undefined; } @@ -125,6 +217,12 @@ export class GeospatialCameraMovement extends CameraMovement { } } + /** + * Updates the drag gesture by recalculating the intersection with the drag plane + * and accumulating the resulting pan delta. + * @param pointerX - Current screen X coordinate + * @param pointerY - Current screen Y coordinate + */ public handleDrag(pointerX: number, pointerY: number) { const scene = this._scene; if (!this._hitPointRadius || !scene.activeCamera) { @@ -154,7 +252,12 @@ export class GeospatialCameraMovement extends CameraMovement { this.panAccumulatedPixels.subtractInPlace(delta); } - /** @override */ + /** + * Consumes the per-frame accumulated pan/rotate/zoom deltas and applies them to the camera state, + * with geospatial-specific dampening (e.g. slower panning near the poles, parallax-based pan compensation). + * Called once per frame by the scene's render loop via `_checkInputs`. + * @override + */ public override computeCurrentFrameDeltas(): void { const cameraCenter = this._cameraCenter; @@ -179,9 +282,9 @@ export class GeospatialCameraMovement extends CameraMovement { this._panSpeedMultiplier = 1; } - // If a pan drag is occurring, stop zooming. + // If a pan drag or active rotation is occurring, stop zooming. let zoomTargetDistance: number | undefined; - if (this.isDragging) { + if (this.isDragging || this.rotationAccumulatedPixels.lengthSquared() > Epsilon) { this._zoomSpeedMultiplier = 0; this._zoomVelocity = 0; } else { @@ -194,10 +297,18 @@ export class GeospatialCameraMovement extends CameraMovement { super.computeCurrentFrameDeltas(); } + /** + * Returns true when a drag gesture is active (between startDrag and stopDrag). + */ public get isDragging() { return this._hitPointRadius !== undefined; } + /** + * Accumulates a zoom delta and determines the zoom target point via raycasting. + * @param zoomDelta - Signed zoom amount (positive = zoom in, negative = zoom out) + * @param toCursor - When true, zoom toward the point under the cursor; when false, zoom along the look vector + */ public handleZoom(zoomDelta: number, toCursor: boolean) { if (zoomDelta !== 0) { this.zoomAccumulatedPixels += zoomDelta; @@ -214,6 +325,11 @@ export class GeospatialCameraMovement extends CameraMovement { } } + /** + * Casts a ray from the camera position along the given direction and returns the pick result. + * @param vector - World-space direction to cast along + * @returns The pick result, or null if no hit + */ public pickAlongVector(vector: Vector3): Nullable { this._tempPickingRay.origin.copyFrom(this._cameraPosition); this._tempPickingRay.direction.copyFrom(vector); diff --git a/packages/dev/core/src/Cameras/index.ts b/packages/dev/core/src/Cameras/index.ts index 3cf6cc267224..9cb339fe18d1 100644 --- a/packages/dev/core/src/Cameras/index.ts +++ b/packages/dev/core/src/Cameras/index.ts @@ -20,3 +20,7 @@ export * from "./virtualJoysticksCamera"; export * from "./VR/index"; export * from "./RigModes/index"; export * from "./geospatialCamera"; +export * from "./inputMapper"; +export * from "./cameraMovement"; +export * from "./geospatialCameraMovement"; +export * from "./arcRotateCameraMovement"; diff --git a/packages/dev/core/src/Cameras/inputMapper.ts b/packages/dev/core/src/Cameras/inputMapper.ts new file mode 100644 index 000000000000..ca551bac0ec8 --- /dev/null +++ b/packages/dev/core/src/Cameras/inputMapper.ts @@ -0,0 +1,449 @@ +/** + * Physical input source that generated an interaction. + */ +export type InputSource = "pointer" | "wheel" | "touch" | "keyboard"; + +/** + * Modifier key state, shared across input sources that support modifiers. + */ +export type InputModifiers = { + /** Ctrl key pressed */ + ctrl?: boolean; + /** Shift key pressed */ + shift?: boolean; + /** Alt key pressed */ + alt?: boolean; +}; + +// ── Per-source condition shapes ──────────────────────────────────── + +/** + * Conditions for pointer inputs. + */ +export type PointerConditions = { + /** Mouse button (0=left, 1=middle, 2=right). Omit to match any button. */ + button?: number; + /** Modifier key state. Only specified keys are checked; omitted = don't-care. */ + modifiers?: InputModifiers; +}; + +/** + * Conditions for mouse wheel inputs. + */ +export type WheelConditions = { + /** Modifier key state. Only specified keys are checked; omitted = don't-care. */ + modifiers?: InputModifiers; +}; + +/** + * Conditions for touch inputs. + */ +export type TouchConditions = { + /** Number of active touch points. Omit to match any count. */ + touchCount?: number; +}; + +/** + * Conditions for keyboard inputs. + */ +export type KeyboardConditions = { + /** Key code of the current key being resolved. Omit to match any key. */ + key?: number; + /** Modifier key state. Only specified keys are checked; omitted = don't-care. */ + modifiers?: InputModifiers; +}; + +// ── Per-source inputMap entry types ──────────────────────────────── + +/** + * Mapping rule for pointer (mouse button) inputs. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export type PointerInputMapEntry = { + source: "pointer"; + interaction: TInteraction; + /** Multiplier applied to input deltas before passing to the handler. Default is 1. */ + sensitivity?: number; + /** Optional per-axis override for the X (horizontal / yaw) component. Falls back to `sensitivity` if unset. */ + sensitivityX?: number; + /** Optional per-axis override for the Y (vertical / pitch) component. Falls back to `sensitivity` if unset. */ + sensitivityY?: number; +} & PointerConditions; + +/** + * Mapping rule for mouse wheel inputs. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export type WheelInputMapEntry = { + source: "wheel"; + interaction: TInteraction; + /** Multiplier applied to input deltas before passing to the handler. Default is 1. */ + sensitivity?: number; +} & WheelConditions; + +/** + * Mapping rule for touch inputs. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export type TouchInputMapEntry = { + source: "touch"; + interaction: TInteraction; + /** Multiplier applied to input deltas before passing to the handler. Default is 1. */ + sensitivity?: number; + /** Optional per-axis override for the X component. Falls back to `sensitivity` if unset. */ + sensitivityX?: number; + /** Optional per-axis override for the Y component. Falls back to `sensitivity` if unset. */ + sensitivityY?: number; +} & TouchConditions; + +/** + * Mapping rule for keyboard inputs. + * The `key` field on the entry supports a single key code or an array of key codes for matching. + * When resolving, the condition's `key` is checked against the entry's `key` value(s). + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export type KeyboardInputMapEntry = { + /** Discriminator: keyboard input source */ + source: "keyboard"; + /** Interaction type to dispatch when this entry matches */ + interaction: TInteraction; + /** Multiplier applied to input deltas before passing to the handler. Default is 1. */ + sensitivity?: number; + /** Key code filter(s). Supports a single code or an array. Omit to match any key. */ + key?: number | number[]; + /** Modifier keys that must be active for this entry to match. Omit to match regardless of modifiers. */ + modifiers?: InputModifiers; +}; + +/** + * A single mapping rule: source + optional conditions → interaction type. + * The inputMap is an ordered array on the movement class; first matching entry wins. + * The interaction string should match a handler property name on the camera's movement subclass. + * + * Discriminated union by `source` — only fields relevant to that source are available. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export type InputMapEntry = + | PointerInputMapEntry + | WheelInputMapEntry + | TouchInputMapEntry + | KeyboardInputMapEntry; + +/** + * Flat conditions object passed to resolveInteraction(). + * Only the fields relevant to the source type need to be set. + * Per-source condition types (PointerConditions, KeyboardConditions, etc.) are subtypes + * of this and should be used at call sites for clarity. + */ +export type InputConditions = { + /** Mouse button (0=left, 1=middle, 2=right) */ + button?: number; + /** Current modifier key state */ + modifiers?: InputModifiers; + /** Number of active touch points */ + touchCount?: number; + /** Key code of the current key being resolved */ + key?: number; +}; + +/** + * Extracts the string-typed interaction names from a handlers object type. + * Equivalent to `keyof THandlers & string` — filters out symbol/number keys. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export type InteractionName = keyof THandlers & string; + +/** + * Generic input-to-interaction mapper that resolves physical input events to semantic interaction types + * and dispatches them to typed handlers. + * + * `InputMapper` is not tied to cameras — any object that needs a configurable, prioritized + * mapping from physical inputs (pointer, keyboard, wheel, touch) to named interactions can use it. + * + * The mapper holds an ordered `inputMap` array. When `resolveInteraction` is called, the first + * entry whose source and conditions match the current input wins. More specific entries (with more + * conditions like button, key, modifiers) should be placed before less specific ones; use `addEntry` + * to auto-insert based on specificity. + * + * @typeParam THandlers - Object type whose keys are the valid interaction type strings and values + * are the handler functions/objects for each interaction (e.g. `ArcRotateHandlers`). + * Interaction types are derived as `InteractionName`. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export class InputMapper> { + /** + * Ordered list of input-to-interaction mapping rules. First matching entry wins. + */ + public inputMap: InputMapEntry>[] = []; + + /** + * Interaction handlers keyed by interaction type. + * Override individual handlers to customize behavior without changing input mapping. + */ + public readonly handlers: THandlers; + + /** + * Creates a new InputMapper. + * @param handlers - The interaction handlers, keyed by interaction type. + * @param createDefaultEntries - Optional factory that returns the default inputMap entries. + * Called by `resetInputMap()` and during construction. When omitted, the default map is empty. + */ + constructor(handlers: THandlers, createDefaultEntries?: () => InputMapEntry>[]) { + this.handlers = handlers; + this._createDefaultEntries = createDefaultEntries; + this.resetInputMap(); + } + + private _createDefaultEntries?: () => InputMapEntry>[]; + + /** + * Resolves a physical input event to a matching inputMap entry. + * Iterates the inputMap in order; the first entry whose source and conditions match wins. + * @param source - The physical input source (e.g. "pointer", "keyboard") + * @param currentConditions - Conditions to match against, specific to the source type + * @returns The matched InputMapEntry, or null if no entry matches + */ + public resolveInteraction(source: "pointer", currentConditions?: InputConditions): PointerInputMapEntry> | null; + public resolveInteraction(source: "wheel", currentConditions?: InputConditions): WheelInputMapEntry> | null; + public resolveInteraction(source: "touch", currentConditions?: InputConditions): TouchInputMapEntry> | null; + public resolveInteraction(source: "keyboard", currentConditions?: InputConditions): KeyboardInputMapEntry> | null; + public resolveInteraction(source: InputSource, currentConditions?: InputConditions): InputMapEntry> | null; + public resolveInteraction(source: InputSource, currentConditions?: InputConditions): InputMapEntry> | null { + for (const entry of this.inputMap) { + if (entry.source === source && this._entryMatches(entry, currentConditions)) { + return entry; + } + } + return null; + } + + /** + * Restores the inputMap to the default entries provided at construction time. + * If no factory was provided, resets to an empty array. + */ + public resetInputMap(): void { + this.inputMap = this._createDefaultEntries?.() ?? []; + } + + /** + * Finds the first inputMap entry matching the given source, interaction, and optional entry conditions. + * Useful for modifying entry properties (e.g. sensitivity) without rebuilding the entire inputMap. + * @param source - The physical input source to match + * @param interaction - The interaction type to match + * @param conditions - Optional entry conditions to match. Omitted condition fields are ignored. + * @returns The matching entry, or undefined if not found + */ + public getEntry(source: "pointer", interaction: InteractionName, conditions?: PointerConditions): PointerInputMapEntry> | undefined; + public getEntry(source: "wheel", interaction: InteractionName, conditions?: WheelConditions): WheelInputMapEntry> | undefined; + public getEntry(source: "touch", interaction: InteractionName, conditions?: TouchConditions): TouchInputMapEntry> | undefined; + public getEntry(source: "keyboard", interaction: InteractionName, conditions?: KeyboardConditions): KeyboardInputMapEntry> | undefined; + public getEntry(source: InputSource, interaction: InteractionName, conditions?: InputConditions): InputMapEntry> | undefined; + public getEntry(source: InputSource, interaction: InteractionName, conditions?: InputConditions): InputMapEntry> | undefined { + // Manual loop instead of `inputMap.find(arrow)` to avoid per-call closure allocation; + // this is hit per pointer-move from multi-touch panning paths. + const arr = this.inputMap; + for (let i = 0; i < arr.length; i++) { + const e = arr[i]; + if (e.source === source && e.interaction === interaction && this._entryConditionsMatch(e, conditions)) { + return e; + } + } + return undefined; + } + + /** + * Finds all inputMap entries matching the given source, interaction, and optional entry conditions. + * Useful for bulk updates when more than one physical input maps to the same interaction. + * @param source - The physical input source to match + * @param interaction - The interaction type to match + * @param conditions - Optional entry conditions to match. Omitted condition fields are ignored. + * @returns All matching entries, in inputMap order + */ + public getEntries(source: "pointer", interaction: InteractionName, conditions?: PointerConditions): PointerInputMapEntry>[]; + public getEntries(source: "wheel", interaction: InteractionName, conditions?: WheelConditions): WheelInputMapEntry>[]; + public getEntries(source: "touch", interaction: InteractionName, conditions?: TouchConditions): TouchInputMapEntry>[]; + public getEntries(source: "keyboard", interaction: InteractionName, conditions?: KeyboardConditions): KeyboardInputMapEntry>[]; + public getEntries(source: InputSource, interaction: InteractionName, conditions?: InputConditions): InputMapEntry>[]; + public getEntries(source: InputSource, interaction: InteractionName, conditions?: InputConditions): InputMapEntry>[] { + const matches: InputMapEntry>[] = []; + const arr = this.inputMap; + for (let i = 0; i < arr.length; i++) { + const e = arr[i]; + if (e.source === source && e.interaction === interaction && this._entryConditionsMatch(e, conditions)) { + matches.push(e); + } + } + return matches; + } + + /** + * Adds an entry to the inputMap at the correct position based on specificity. + * More specific entries (with more conditions like button, key, modifiers) are placed + * before less specific ones, ensuring they match first. Among equally specific entries, + * the new entry is placed after existing ones. + * @param entry - The entry to add + */ + public addEntry(entry: InputMapEntry>): void { + const score = this._entrySpecificity(entry); + let insertIndex = this.inputMap.length; + for (let i = 0; i < this.inputMap.length; i++) { + if (this._entrySpecificity(this.inputMap[i]) < score) { + insertIndex = i; + break; + } + } + this.inputMap.splice(insertIndex, 0, entry); + } + + /** + * Changes the interaction for the first inputMap entry matching the given source and conditions. + * This is the simplest way to remap a single input without rebuilding the entire inputMap. + * + * Note: only the first matching entry is updated. To update every matching entry use + * {@link setInteractions}; to address an individual entry beyond the first, look it up via + * {@link getEntries} and assign `entry.interaction` directly. + * @param source - The physical input source to match + * @param conditions - Conditions to match (button, modifiers, key, etc.) + * @param interaction - The new interaction to assign to the matched entry + * @returns true if a matching entry was found and updated, false otherwise + */ + public setInteraction(source: InputSource, conditions: InputConditions | undefined, interaction: InteractionName): boolean { + const entry = this.resolveInteraction(source, conditions); + if (entry) { + entry.interaction = interaction; + return true; + } + return false; + } + + /** + * Changes the interaction for every inputMap entry matching the given source and conditions. + * Useful when more than one entry maps to the same physical input (e.g. duplicate bindings, + * or several keys aliased to the same action) and all of them should be remapped together. + * @param source - The physical input source to match + * @param conditions - Conditions to match (button, modifiers, key, etc.). Uses the same + * event-resolution semantics as {@link resolveInteraction}: omitted entry + * condition fields are treated as wildcards and will match. + * @param interaction - The new interaction to assign to every matched entry + * @returns The number of entries that were updated + */ + public setInteractions(source: InputSource, conditions: InputConditions | undefined, interaction: InteractionName): number { + let count = 0; + const arr = this.inputMap; + for (let i = 0; i < arr.length; i++) { + const entry = arr[i]; + if (entry.source === source && this._entryMatches(entry, conditions)) { + entry.interaction = interaction; + count++; + } + } + return count; + } + + private _entryMatches(entry: InputMapEntry>, currentConditions?: InputConditions): boolean { + switch (entry.source) { + case "pointer": + if (entry.button !== undefined && entry.button !== currentConditions?.button) { + return false; + } + return this._matchModifiers(entry.modifiers, currentConditions?.modifiers); + case "wheel": + return this._matchModifiers(entry.modifiers, currentConditions?.modifiers); + case "touch": + if (entry.touchCount !== undefined && entry.touchCount !== currentConditions?.touchCount) { + return false; + } + return true; + case "keyboard": + if (entry.key !== undefined) { + if (Array.isArray(entry.key) ? entry.key.indexOf(currentConditions?.key ?? -1) === -1 : entry.key !== currentConditions?.key) { + return false; + } + } + return this._matchModifiers(entry.modifiers, currentConditions?.modifiers); + } + } + + private _entryConditionsMatch(entry: InputMapEntry>, conditions?: InputConditions): boolean { + if (!conditions) { + return true; + } + + switch (entry.source) { + case "pointer": + if ("button" in conditions && entry.button !== conditions.button) { + return false; + } + return !("modifiers" in conditions) || this._entryModifiersMatch(entry.modifiers, conditions.modifiers); + case "wheel": + return !("modifiers" in conditions) || this._entryModifiersMatch(entry.modifiers, conditions.modifiers); + case "touch": + return !("touchCount" in conditions) || entry.touchCount === conditions.touchCount; + case "keyboard": + if ("key" in conditions) { + if (entry.key === undefined) { + return conditions.key === undefined; + } + if (conditions.key === undefined || (Array.isArray(entry.key) ? entry.key.indexOf(conditions.key) === -1 : entry.key !== conditions.key)) { + return false; + } + } + return !("modifiers" in conditions) || this._entryModifiersMatch(entry.modifiers, conditions.modifiers); + } + } + + private _entrySpecificity(entry: InputMapEntry>): number { + let score = 0; + if ("button" in entry && entry.button !== undefined) { + score++; + } + if ("key" in entry && entry.key !== undefined) { + score++; + } + if ("touchCount" in entry && entry.touchCount !== undefined) { + score++; + } + if ("modifiers" in entry && entry.modifiers) { + score++; + } + return score; + } + + private _matchModifiers(entryModifiers?: InputModifiers, currentModifiers?: InputModifiers): boolean { + if (!entryModifiers) { + return true; + } + if (entryModifiers.ctrl !== undefined && entryModifiers.ctrl !== (currentModifiers?.ctrl ?? false)) { + return false; + } + if (entryModifiers.shift !== undefined && entryModifiers.shift !== (currentModifiers?.shift ?? false)) { + return false; + } + if (entryModifiers.alt !== undefined && entryModifiers.alt !== (currentModifiers?.alt ?? false)) { + return false; + } + return true; + } + + private _entryModifiersMatch(entryModifiers?: InputModifiers, conditionsModifiers?: InputModifiers): boolean { + if (!conditionsModifiers) { + return !entryModifiers; + } + + const hasModifierConditions = conditionsModifiers.ctrl !== undefined || conditionsModifiers.shift !== undefined || conditionsModifiers.alt !== undefined; + if (!hasModifierConditions) { + return !entryModifiers || (entryModifiers.ctrl === undefined && entryModifiers.shift === undefined && entryModifiers.alt === undefined); + } + + if (conditionsModifiers.ctrl !== undefined && entryModifiers?.ctrl !== conditionsModifiers.ctrl) { + return false; + } + if (conditionsModifiers.shift !== undefined && entryModifiers?.shift !== conditionsModifiers.shift) { + return false; + } + if (conditionsModifiers.alt !== undefined && entryModifiers?.alt !== conditionsModifiers.alt) { + return false; + } + return true; + } +} diff --git a/packages/dev/core/test/unit/Cameras/arcRotateCameraFramerateIndependence.test.ts b/packages/dev/core/test/unit/Cameras/arcRotateCameraFramerateIndependence.test.ts new file mode 100644 index 000000000000..88d426c842e0 --- /dev/null +++ b/packages/dev/core/test/unit/Cameras/arcRotateCameraFramerateIndependence.test.ts @@ -0,0 +1,430 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ArcRotateCamera } from "core/Cameras/arcRotateCamera"; +import { Vector3 } from "core/Maths/math.vector"; +import { NullEngine } from "core/Engines/nullEngine"; +import { Scene } from "core/scene"; +import type { Nullable } from "core/types"; + +/** + * Tests for ArcRotateCamera framerate-independent inertia + back-compat regressions. + * + * These tests use the camera's movement input handlers to inject pixel deltas (mimicking what + * input plugins do), then drive `_checkInputs` repeatedly while controlling the engine's + * reported deltaTime to simulate different framerates. + * + * Conventions: + * - `simulateDuration(camera, fps, durationMs)` — runs N frames of `1000/fps`ms each. + * - "Framerate-independent" assertions check that the same wall-clock duration produces the + * same camera state regardless of the simulated fps (within a tight tolerance). + */ + +// Helpers --------------------------------------------------------------------------------------- + +function setFrameRate(engine: NullEngine, fps: number): void { + vi.spyOn(engine, "getDeltaTime").mockReturnValue(1000 / fps); +} + +function simulateFrames(camera: ArcRotateCamera, frames: number): void { + for (let i = 0; i < frames; i++) { + camera._checkInputs(); + } +} + +function simulateDuration(camera: ArcRotateCamera, fps: number, durationMs: number): void { + const frames = Math.round((durationMs * fps) / 1000); + simulateFrames(camera, frames); +} + +function injectRotationPixels(camera: ArcRotateCamera, x: number, y: number): void { + camera.movement.input.handlers.rotate(x, y); + camera.movement.activeInput = true; +} + +function injectZoomPixels(camera: ArcRotateCamera, delta: number): void { + camera.movement.input.handlers.zoom(delta); + camera.movement.activeInput = true; +} + +function injectPanPixels(camera: ArcRotateCamera, x: number, y: number): void { + camera.movement.input.handlers.pan(x, y); + camera.movement.activeInput = true; +} + +// Reusable setup -------------------------------------------------------------------------------- + +function makeCamera(): { engine: NullEngine; scene: Scene; camera: ArcRotateCamera } { + const engine = new NullEngine({ + renderHeight: 256, + renderWidth: 256, + textureSize: 256, + deterministicLockstep: false, + lockstepMaxSteps: 1, + }); + const scene = new Scene(engine); + const camera = new ArcRotateCamera("test", 0, Math.PI / 2, 10, Vector3.Zero(), scene); + return { engine, scene, camera }; +} + +// ================================================================================================= +// A. Framerate-independence tests +// These assert that the same wall-clock simulated duration produces the same camera state +// regardless of the simulated fps. They should FAIL on master and PASS on this branch. +// ================================================================================================= + +describe("ArcRotateCamera framerate independence", () => { + let engine: Nullable = null; + let scene: Nullable = null; + + afterEach(() => { + scene?.dispose(); + engine?.dispose(); + scene = null; + engine = null; + }); + + function runRotationGlide(fps: number, totalDurationMs: number): number { + const setup = makeCamera(); + engine = setup.engine; + scene = setup.scene; + const camera = setup.camera; + camera.inertia = 0.9; + setFrameRate(engine, fps); + + // Inject a single one-shot rotation impulse, then let inertia decay over `totalDurationMs`. + injectRotationPixels(camera, 50, 0); + simulateDuration(camera, fps, totalDurationMs); + return camera.alpha; + } + + function runZoomGlide(fps: number, totalDurationMs: number): number { + const setup = makeCamera(); + engine = setup.engine; + scene = setup.scene; + const camera = setup.camera; + camera.inertia = 0.9; + setFrameRate(engine, fps); + + injectZoomPixels(camera, 100); + simulateDuration(camera, fps, totalDurationMs); + return camera.radius; + } + + function runPanGlide(fps: number, totalDurationMs: number): number { + const setup = makeCamera(); + engine = setup.engine; + scene = setup.scene; + const camera = setup.camera; + camera.inertia = 0.9; + setFrameRate(engine, fps); + + injectPanPixels(camera, 50, 0); + simulateDuration(camera, fps, totalDurationMs); + return camera.target.x; + } + + function runSustainedRotation(fps: number, totalDurationMs: number, pixelsPerSecond: number): number { + const setup = makeCamera(); + engine = setup.engine; + scene = setup.scene; + const camera = setup.camera; + camera.inertia = 0.9; + setFrameRate(engine, fps); + + // Each frame, inject (pixelsPerSecond * dt) pixels — mimicking constant hand speed. + const dtMs = 1000 / fps; + const pixelsPerFrame = (pixelsPerSecond * dtMs) / 1000; + const frames = Math.round((totalDurationMs * fps) / 1000); + for (let i = 0; i < frames; i++) { + injectRotationPixels(camera, pixelsPerFrame, 0); + camera._checkInputs(); + } + return camera.alpha; + } + + describe("rotation glide", () => { + it("60fps and 120fps produce equivalent final alpha after 500ms", () => { + const alpha60 = runRotationGlide(60, 500); + const alpha120 = runRotationGlide(120, 500); + // 1% relative tolerance (or 0.001 absolute floor for tiny values) + const diff = Math.abs(alpha60 - alpha120); + const rel = diff / Math.max(Math.abs(alpha60), 0.001); + expect(rel).toBeLessThan(0.05); + }); + + it("60fps and 30fps produce equivalent final alpha after 500ms", () => { + const alpha60 = runRotationGlide(60, 500); + const alpha30 = runRotationGlide(30, 500); + const rel = Math.abs(alpha60 - alpha30) / Math.max(Math.abs(alpha60), 0.001); + expect(rel).toBeLessThan(0.05); + }); + + it("60fps and 144fps produce equivalent final alpha after 500ms", () => { + const alpha60 = runRotationGlide(60, 500); + const alpha144 = runRotationGlide(144, 500); + const rel = Math.abs(alpha60 - alpha144) / Math.max(Math.abs(alpha60), 0.001); + expect(rel).toBeLessThan(0.05); + }); + }); + + describe("zoom glide", () => { + it("60fps and 120fps produce equivalent final radius after 500ms", () => { + const r60 = runZoomGlide(60, 500); + const r120 = runZoomGlide(120, 500); + const rel = Math.abs(r60 - r120) / Math.max(Math.abs(r60 - 10), 0.001); + expect(rel).toBeLessThan(0.05); + }); + }); + + describe("pan glide", () => { + it("60fps and 120fps produce equivalent final target after 500ms", () => { + const t60 = runPanGlide(60, 500); + const t120 = runPanGlide(120, 500); + const rel = Math.abs(t60 - t120) / Math.max(Math.abs(t60), 0.001); + expect(rel).toBeLessThan(0.05); + }); + }); + + describe("sustained input steady-state", () => { + it("60fps and 120fps produce equivalent alpha for constant 1000 px/s drag over 500ms", () => { + const alpha60 = runSustainedRotation(60, 500, 1000); + const alpha120 = runSustainedRotation(120, 500, 1000); + const rel = Math.abs(alpha60 - alpha120) / Math.max(Math.abs(alpha60), 0.001); + expect(rel).toBeLessThan(0.05); + }); + }); + + describe("inertia=0 corner case", () => { + it("rotation stops in one frame at any framerate", () => { + for (const fps of [30, 60, 120, 144]) { + const setup = makeCamera(); + engine = setup.engine; + scene = setup.scene; + const camera = setup.camera; + camera.inertia = 0; + setFrameRate(engine, fps); + + injectRotationPixels(camera, 10, 0); + camera._checkInputs(); // frame 1: applies impulse + const alphaAfter1 = camera.alpha; + camera._checkInputs(); // frame 2: should be no further movement + expect(camera.alpha).toBeCloseTo(alphaAfter1, 6); + + scene.dispose(); + engine.dispose(); + scene = null; + engine = null; + } + }); + }); +}); + +// ================================================================================================= +// B. Combinational regression tests — verify general functionality across speed/inertia/sensibility +// ================================================================================================= + +describe("ArcRotateCamera back-compat parameter combinations", () => { + let engine: Nullable = null; + let scene: Nullable = null; + let camera: ArcRotateCamera; + + beforeEach(() => { + const setup = makeCamera(); + engine = setup.engine; + scene = setup.scene; + camera = setup.camera; + setFrameRate(engine, 60); + }); + + afterEach(() => { + scene?.dispose(); + engine?.dispose(); + }); + + describe("input scaling", () => { + it("rotation scales linearly with input pixels", () => { + injectRotationPixels(camera, 1, 0); + camera._checkInputs(); + const alpha1 = camera.alpha; + + // Reset + scene!.dispose(); + engine!.dispose(); + const setup = makeCamera(); + engine = setup.engine; + scene = setup.scene; + camera = setup.camera; + setFrameRate(engine, 60); + + injectRotationPixels(camera, 10, 0); + camera._checkInputs(); + const alpha10 = camera.alpha; + + // 10x the input should produce ~10x the rotation + expect(Math.abs(alpha10 / alpha1 - 10)).toBeLessThan(0.1); + }); + + it("panningInertia setter syncs to movement.panInertia", () => { + camera.panningInertia = 0.5; + expect(camera.movement.panInertia).toBe(0.5); + camera.panningInertia = 0.8; + expect(camera.movement.panInertia).toBe(0.8); + }); + + it("camera.inertia is synced to movement.rotationInertia and zoomInertia each frame", () => { + camera.inertia = 0.5; + // Trigger a frame so the sync runs + camera._checkInputs(); + expect(camera.movement.rotationInertia).toBe(0.5); + expect(camera.movement.zoomInertia).toBe(0.5); + }); + }); + + describe("inertia tuning at runtime", () => { + it("setting camera.inertia=0 stops rotation glide immediately", () => { + camera.inertia = 0.9; + injectRotationPixels(camera, 50, 0); + camera._checkInputs(); + const alphaInitial = camera.alpha; + + // Now zero out inertia and run more frames — alpha should not change further + camera.inertia = 0; + simulateFrames(camera, 5); + expect(camera.alpha).toBeCloseTo(alphaInitial, 6); + }); + + it("setting camera.inertia=0.99 produces a longer glide than 0.5", () => { + // High inertia + camera.inertia = 0.99; + injectRotationPixels(camera, 50, 0); + simulateFrames(camera, 30); + const alphaHigh = Math.abs(camera.alpha); + + // Reset with low inertia + scene!.dispose(); + engine!.dispose(); + const setup = makeCamera(); + engine = setup.engine; + scene = setup.scene; + camera = setup.camera; + setFrameRate(engine, 60); + camera.inertia = 0.5; + injectRotationPixels(camera, 50, 0); + simulateFrames(camera, 30); + const alphaLow = Math.abs(camera.alpha); + + // High inertia accumulates more rotation across the same number of frames + expect(alphaHigh).toBeGreaterThan(alphaLow); + }); + + it("camera.panningInertia is independent of camera.inertia for pan glide", () => { + camera.inertia = 0.9; + camera.panningInertia = 0; // pan should stop in one frame + injectPanPixels(camera, 50, 0); + camera._checkInputs(); + const targetAfter1 = camera.target.x; + simulateFrames(camera, 5); + expect(camera.target.x).toBeCloseTo(targetAfter1, 6); + }); + }); + + describe("zoom glide", () => { + it("zoom moves the radius and decays toward zero", () => { + camera.inertia = 0.9; + const r0 = camera.radius; + injectZoomPixels(camera, 100); + simulateFrames(camera, 60); + // Should have changed + expect(camera.radius).not.toBeCloseTo(r0, 4); + // After a long time the velocity should be effectively 0 + simulateFrames(camera, 200); + const rSettled = camera.radius; + simulateFrames(camera, 5); + expect(camera.radius).toBeCloseTo(rSettled, 6); + }); + }); +}); + +// ================================================================================================= +// C. Back-compat API tests for the legacy inertialAlphaOffset / inertialBetaOffset / inertialRadiusOffset +// ================================================================================================= + +describe("ArcRotateCamera legacy inertialOffset back-compat", () => { + let engine: Nullable = null; + let scene: Nullable = null; + let camera: ArcRotateCamera; + + beforeEach(() => { + const setup = makeCamera(); + engine = setup.engine; + scene = setup.scene; + camera = setup.camera; + setFrameRate(engine, 60); + }); + + afterEach(() => { + scene?.dispose(); + engine?.dispose(); + }); + + it("direct write to inertialAlphaOffset advances alpha", () => { + const a0 = camera.alpha; + camera.inertialAlphaOffset = 0.1; + camera._checkInputs(); + expect(camera.alpha).not.toBeCloseTo(a0, 4); + }); + + it("direct write to inertialAlphaOffset decays toward zero with inertia", () => { + camera.inertia = 0.9; + camera.inertialAlphaOffset = 0.1; + camera._checkInputs(); + const offsetAfter1 = camera.inertialAlphaOffset; + // After one frame, the underlying private offset is 0.1 * 0.9 = 0.09 + expect(offsetAfter1).toBeCloseTo(0.09, 4); + }); + + it("setting inertialAlphaOffset = 0 cancels the movement-system rotation glide", () => { + injectRotationPixels(camera, 50, 0); + camera._checkInputs(); + expect(camera.alpha).not.toBeCloseTo(0, 4); + + // Cancel via legacy API + camera.inertialAlphaOffset = 0; + const alphaAfterCancel = camera.alpha; + simulateFrames(camera, 10); + expect(camera.alpha).toBeCloseTo(alphaAfterCancel, 6); + }); + + it("inertialAlphaOffset getter reflects pending movement-system rotation", () => { + // Inject input but DO NOT run a frame — accumulator should be visible via getter + injectRotationPixels(camera, 5, 0); + expect(camera.inertialAlphaOffset).toBe(5); + }); + + it("inertialAlphaOffset getter reflects current-frame applied rotation during glide", () => { + injectRotationPixels(camera, 50, 0); + camera._checkInputs(); + // After the frame, accumulator is reset, but rotationDeltaCurrentFrame is non-zero (glide active) + expect(camera.inertialAlphaOffset).not.toBe(0); + }); + + it("inertialAlphaOffset getter returns 0 when the camera is fully idle", () => { + // No input, no glide — getter should return 0 + camera._checkInputs(); + expect(camera.inertialAlphaOffset).toBe(0); + }); + + it("inertialPanningX direct write decays toward zero with panningInertia (legacy back-compat)", () => { + // Note: we don't assert the camera target moves here because _applyPanDelta requires the + // view matrix to be initialized via render — which the null engine doesn't do automatically. + // The contract that matters for back-compat is that the field is read, scaled by inertia, + // and zeroed out when small. + camera.panningInertia = 0.9; + camera.inertialPanningX = 0.5; + camera._checkInputs(); + // After one frame, inertialPanningX = 0.5 * 0.9 = 0.45 + expect(camera.inertialPanningX).toBeCloseTo(0.45, 4); + camera._checkInputs(); + // After two frames, 0.5 * 0.9 * 0.9 = 0.405 + expect(camera.inertialPanningX).toBeCloseTo(0.405, 4); + }); +}); diff --git a/packages/dev/core/test/unit/Cameras/arcRotateCameraMovement.test.ts b/packages/dev/core/test/unit/Cameras/arcRotateCameraMovement.test.ts new file mode 100644 index 000000000000..c13dde362349 --- /dev/null +++ b/packages/dev/core/test/unit/Cameras/arcRotateCameraMovement.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { ArcRotateCamera } from "core/Cameras/arcRotateCamera"; +import { Vector3 } from "core/Maths/math.vector"; +import { NullEngine } from "core/Engines/nullEngine"; +import { Scene } from "core/scene"; +import { type Nullable } from "core/types"; + +describe("ArcRotateCameraMovement", () => { + let engine: Nullable = null; + let scene: Nullable = null; + let camera: ArcRotateCamera; + + beforeEach(() => { + engine = new NullEngine({ + renderHeight: 256, + renderWidth: 256, + textureSize: 256, + deterministicLockstep: false, + lockstepMaxSteps: 1, + }); + scene = new Scene(engine); + camera = new ArcRotateCamera("test", 0, 0, 10, Vector3.Zero(), scene); + }); + + afterEach(() => { + scene?.dispose(); + engine?.dispose(); + }); + + describe("always-on movement", () => { + it("movement is defined on construction without any opt-in", () => { + expect(camera.movement).toBeDefined(); + }); + }); + + describe("default inputMap", () => { + it("should have exactly 8 entries", () => { + expect(camera.movement.input.inputMap).toHaveLength(8); + }); + + it("should map left-click to rotate", () => { + expect(camera.movement.input.resolveInteraction("pointer", { button: 0 })?.interaction).toBe("rotate"); + }); + + it("should map right-click to pan", () => { + expect(camera.movement.input.resolveInteraction("pointer", { button: 2 })?.interaction).toBe("pan"); + }); + + it("should map ctrl+left-drag to pan (legacy useCtrlForPanning behavior)", () => { + expect(camera.movement.input.resolveInteraction("pointer", { button: 0, modifiers: { ctrl: true } })?.interaction).toBe("pan"); + }); + + it("should still map plain left-drag to rotate when ctrl is not held", () => { + expect(camera.movement.input.resolveInteraction("pointer", { button: 0, modifiers: { ctrl: false } })?.interaction).toBe("rotate"); + }); + + it("should map wheel to zoom", () => { + expect(camera.movement.input.resolveInteraction("wheel")?.interaction).toBe("zoom"); + }); + + it("should map +/- keys to zoom", () => { + expect(camera.movement.input.resolveInteraction("keyboard", { key: 187 })?.interaction).toBe("zoom"); // + key + expect(camera.movement.input.resolveInteraction("keyboard", { key: 107 })?.interaction).toBe("zoom"); // numpad + + expect(camera.movement.input.resolveInteraction("keyboard", { key: 189 })?.interaction).toBe("zoom"); // - key + expect(camera.movement.input.resolveInteraction("keyboard", { key: 109 })?.interaction).toBe("zoom"); // numpad - + }); + + it("should map zoom keys with ctrl to zoom (key match wins over modifier match)", () => { + expect(camera.movement.input.resolveInteraction("keyboard", { key: 187, modifiers: { ctrl: true } })?.interaction).toBe("zoom"); + }); + + it("should map ctrl+keyboard to pan", () => { + expect(camera.movement.input.resolveInteraction("keyboard", { modifiers: { ctrl: true } })?.interaction).toBe("pan"); + }); + + it("should map alt+keyboard to zoom", () => { + expect(camera.movement.input.resolveInteraction("keyboard", { modifiers: { alt: true } })?.interaction).toBe("zoom"); + }); + + it("should map plain keyboard to rotate", () => { + expect(camera.movement.input.resolveInteraction("keyboard", { modifiers: {} })?.interaction).toBe("rotate"); + }); + }); + + describe("default handlers", () => { + it("should accumulate pan deltas", () => { + camera.movement.input.handlers.pan(5, 10); + expect(camera.movement.panAccumulatedPixels.x).toBe(5); + expect(camera.movement.panAccumulatedPixels.y).toBe(10); + }); + + it("should accumulate rotation deltas", () => { + camera.movement.input.handlers.rotate(3, 7); + expect(camera.movement.rotationAccumulatedPixels.x).toBe(3); + expect(camera.movement.rotationAccumulatedPixels.y).toBe(7); + }); + + it("should accumulate zoom deltas", () => { + camera.movement.input.handlers.zoom(4); + expect(camera.movement.zoomAccumulatedPixels).toBe(4); + }); + + it("should accumulate multiple calls", () => { + camera.movement.input.handlers.pan(1, 2); + camera.movement.input.handlers.pan(1, 2); + expect(camera.movement.panAccumulatedPixels.x).toBe(2); + expect(camera.movement.panAccumulatedPixels.y).toBe(4); + }); + }); + + describe("resetInputMap", () => { + it("should restore default inputMap after modification", () => { + camera.movement.input.inputMap = []; + expect(camera.movement.input.inputMap).toHaveLength(0); + + camera.movement.input.resetInputMap(); + expect(camera.movement.input.inputMap).toHaveLength(8); + + expect(camera.movement.input.resolveInteraction("pointer", { button: 0 })?.interaction).toBe("rotate"); + expect(camera.movement.input.resolveInteraction("pointer", { button: 2 })?.interaction).toBe("pan"); + expect(camera.movement.input.resolveInteraction("wheel")?.interaction).toBe("zoom"); + expect(camera.movement.input.resolveInteraction("keyboard", { key: 187 })?.interaction).toBe("zoom"); + expect(camera.movement.input.resolveInteraction("keyboard", { modifiers: { ctrl: true } })?.interaction).toBe("pan"); + expect(camera.movement.input.resolveInteraction("keyboard", { modifiers: { alt: true } })?.interaction).toBe("zoom"); + expect(camera.movement.input.resolveInteraction("keyboard", { modifiers: {} })?.interaction).toBe("rotate"); + }); + }); + + describe("_useCtrlForPanning setter", () => { + it("removing ctrl panning removes the inputMap entry", () => { + camera._useCtrlForPanning = false; + expect(camera.movement.input.resolveInteraction("keyboard", { modifiers: { ctrl: true } })?.interaction).not.toBe("pan"); + }); + + it("removing ctrl panning also removes the pointer ctrl+left-drag pan entry", () => { + camera._useCtrlForPanning = false; + expect(camera.movement.input.resolveInteraction("pointer", { button: 0, modifiers: { ctrl: true } })?.interaction).not.toBe("pan"); + }); + + it("re-enabling ctrl panning re-adds the inputMap entry", () => { + camera._useCtrlForPanning = false; + camera._useCtrlForPanning = true; + expect(camera.movement.input.resolveInteraction("keyboard", { modifiers: { ctrl: true } })?.interaction).toBe("pan"); + }); + + it("re-enabling ctrl panning re-adds the pointer ctrl+left-drag pan entry", () => { + camera._useCtrlForPanning = false; + camera._useCtrlForPanning = true; + expect(camera.movement.input.resolveInteraction("pointer", { button: 0, modifiers: { ctrl: true } })?.interaction).toBe("pan"); + }); + }); + + describe("_panningMouseButton setter", () => { + it("changes which button triggers pan", () => { + camera._panningMouseButton = 1; + expect(camera.movement.input.resolveInteraction("pointer", { button: 1 })?.interaction).toBe("pan"); + expect(camera.movement.input.resolveInteraction("pointer", { button: 2 })?.interaction).not.toBe("pan"); + }); + }); + + describe("useMovementSystem backward compat", () => { + it("setter is a no-op, getter always returns true", () => { + camera.useMovementSystem = false; // no-op + expect(camera.useMovementSystem).toBe(true); + expect(camera.movement).toBeDefined(); + }); + }); + + describe("panningInertia setter", () => { + it("syncs panningInertia to movement.panInertia", () => { + camera.panningInertia = 0.5; + expect(camera.movement.panInertia).toBe(0.5); + }); + }); + + describe("ArcRotateCameraKeyboardMoveInput.useAltToZoom", () => { + it("applies the cached value when the input is later attached to a camera", async () => { + const { ArcRotateCameraKeyboardMoveInput } = await import("core/Cameras/Inputs/arcRotateCameraKeyboardMoveInput"); + + // Drop the auto-added keyboard input so we can simulate the bug scenario: + // a fresh, detached input that is configured before being added to a camera. + const existingKeyboard = camera.inputs.attached["keyboard"]; + if (existingKeyboard) { + camera.inputs.remove(existingKeyboard); + } + + // Configure the detached input — this.camera is undefined here, so the cached + // value cannot be applied to any inputMap yet. + const input = new ArcRotateCameraKeyboardMoveInput(); + input.useAltToZoom = false; + expect(input.useAltToZoom).toBe(false); + // Camera's default inputMap still contains the alt-zoom entry (no input has touched it). + expect(camera.movement.input.resolveInteraction("keyboard", { modifiers: { alt: true } })?.interaction).toBe("zoom"); + + // Add to camera (sets input.camera) then attach — attachControl must flush the cached value. + camera.inputs.add(input); + input.attachControl(); + + expect(camera.movement.input.resolveInteraction("keyboard", { modifiers: { alt: true } })?.interaction).not.toBe("zoom"); + }); + }); +}); diff --git a/packages/dev/core/test/unit/Cameras/cameraMovement.test.ts b/packages/dev/core/test/unit/Cameras/cameraMovement.test.ts new file mode 100644 index 000000000000..105115ce5c92 --- /dev/null +++ b/packages/dev/core/test/unit/Cameras/cameraMovement.test.ts @@ -0,0 +1,323 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { CameraMovement } from "core/Cameras/cameraMovement"; +import { InputMapper } from "core/Cameras/inputMapper"; +import { Vector3 } from "core/Maths/math.vector"; +import { NullEngine } from "core/Engines/nullEngine"; +import { Scene } from "core/scene"; +import type { Nullable } from "core/types"; + +describe("InputMapper", () => { + // Dummy handlers for testing — InputMapper needs handler keys to match interaction strings + type TestHandlers = { rotate: () => void; translate: () => void; zoom: () => void; pan: () => void }; + const noop = () => {}; + let mapper: InputMapper; + + beforeEach(() => { + mapper = new InputMapper({ rotate: noop, translate: noop, zoom: noop, pan: noop }); + }); + + describe("resolveInteraction", () => { + it("should match by source type", () => { + mapper.inputMap = [ + { source: "pointer", button: 0, interaction: "rotate" }, + { source: "keyboard", interaction: "translate" }, + { source: "wheel", interaction: "zoom" }, + { source: "touch", interaction: "pan" }, + ]; + + expect(mapper.resolveInteraction("pointer", { button: 0 })?.interaction).toBe("rotate"); + expect(mapper.resolveInteraction("keyboard")?.interaction).toBe("translate"); + expect(mapper.resolveInteraction("wheel")?.interaction).toBe("zoom"); + expect(mapper.resolveInteraction("touch")?.interaction).toBe("pan"); + }); + + it("should return first match when multiple entries exist for same source", () => { + mapper.inputMap = [ + { source: "pointer", button: 0, interaction: "rotate" }, + { source: "pointer", button: 0, interaction: "pan" }, + ]; + + expect(mapper.resolveInteraction("pointer", { button: 0 })?.interaction).toBe("rotate"); + }); + + it("should match exact modifiers", () => { + mapper.inputMap = [{ source: "keyboard", modifiers: { ctrl: true }, interaction: "pan" }]; + + expect(mapper.resolveInteraction("keyboard", { modifiers: { ctrl: true } })?.interaction).toBe("pan"); + expect(mapper.resolveInteraction("keyboard", { modifiers: { ctrl: false } })).toBeNull(); + }); + + it("should match when entry has no modifiers (matches anything)", () => { + mapper.inputMap = [{ source: "keyboard", interaction: "rotate" }]; + + expect(mapper.resolveInteraction("keyboard", { modifiers: { ctrl: true } })?.interaction).toBe("rotate"); + expect(mapper.resolveInteraction("keyboard")?.interaction).toBe("rotate"); + }); + + it("should match partial modifiers (only check specified keys)", () => { + mapper.inputMap = [{ source: "keyboard", modifiers: { ctrl: true }, interaction: "pan" }]; + + expect(mapper.resolveInteraction("keyboard", { modifiers: { ctrl: true, shift: true } })?.interaction).toBe("pan"); + }); + + it("should match pointer button", () => { + mapper.inputMap = [{ source: "pointer", button: 2, interaction: "pan" }]; + + expect(mapper.resolveInteraction("pointer", { button: 2 })?.interaction).toBe("pan"); + expect(mapper.resolveInteraction("pointer", { button: 0 })).toBeNull(); + }); + + it("should match touch count", () => { + mapper.inputMap = [{ source: "touch", touchCount: 2, interaction: "zoom" }]; + + expect(mapper.resolveInteraction("touch", { touchCount: 2 })?.interaction).toBe("zoom"); + expect(mapper.resolveInteraction("touch", { touchCount: 1 })).toBeNull(); + }); + + it("should return null when no entry matches", () => { + mapper.inputMap = [{ source: "pointer", button: 0, interaction: "rotate" }]; + + expect(mapper.resolveInteraction("keyboard")).toBeNull(); + }); + + it("should return null with empty inputMap", () => { + mapper.inputMap = []; + expect(mapper.resolveInteraction("pointer", { button: 0 })).toBeNull(); + }); + + it("should return sensitivity from matched entry", () => { + mapper.inputMap = [{ source: "pointer", button: 0, interaction: "rotate", sensitivity: 0.5 }]; + expect(mapper.resolveInteraction("pointer", { button: 0 })?.sensitivity).toBe(0.5); + }); + + it("should return undefined sensitivity when not specified", () => { + mapper.inputMap = [{ source: "pointer", button: 0, interaction: "rotate" }]; + expect(mapper.resolveInteraction("pointer", { button: 0 })?.sensitivity).toBeUndefined(); + }); + }); + + describe("resetInputMap", () => { + it("should reset to empty array when no factory provided", () => { + mapper.inputMap = [{ source: "pointer", button: 0, interaction: "rotate" }]; + mapper.resetInputMap(); + expect(mapper.inputMap).toEqual([]); + }); + + it("should reset to factory-provided defaults", () => { + const defaults = [ + { source: "pointer" as const, button: 0, interaction: "rotate" as const }, + { source: "wheel" as const, interaction: "zoom" as const }, + ]; + const mapperWithDefaults = new InputMapper({ rotate: noop, translate: noop, zoom: noop, pan: noop }, () => [...defaults]); + expect(mapperWithDefaults.inputMap).toHaveLength(2); + + mapperWithDefaults.inputMap = []; + mapperWithDefaults.resetInputMap(); + expect(mapperWithDefaults.inputMap).toHaveLength(2); + expect(mapperWithDefaults.inputMap[0].interaction).toBe("rotate"); + }); + }); + + describe("getEntry", () => { + it("should find entry by source and interaction", () => { + mapper.inputMap = [ + { source: "pointer", button: 0, interaction: "rotate" }, + { source: "pointer", button: 2, interaction: "pan" }, + ]; + expect(mapper.getEntry("pointer", "pan")?.source).toBe("pointer"); + expect(mapper.getEntry("pointer", "zoom")).toBeUndefined(); + }); + + it("should find entry by optional entry conditions", () => { + mapper.inputMap = [ + { source: "pointer", button: 0, modifiers: { ctrl: true }, interaction: "pan" }, + { source: "pointer", button: 2, interaction: "pan" }, + ]; + + expect(mapper.getEntry("pointer", "pan", { modifiers: {} })?.button).toBe(2); + expect(mapper.getEntry("pointer", "pan", { button: 0, modifiers: { ctrl: true } })?.modifiers?.ctrl).toBe(true); + expect(mapper.getEntry("pointer", "pan", { button: 1 })).toBeUndefined(); + }); + + it("should find all entries by source, interaction, and optional entry conditions", () => { + mapper.inputMap = [ + { source: "keyboard", modifiers: { ctrl: true }, interaction: "pan", sensitivity: 2 }, + { source: "keyboard", modifiers: { alt: true }, interaction: "pan", sensitivity: 3 }, + { source: "keyboard", interaction: "pan", sensitivity: 4 }, + { source: "pointer", button: 2, interaction: "pan" }, + ]; + + expect(mapper.getEntries("keyboard", "pan")).toHaveLength(3); + expect(mapper.getEntries("keyboard", "pan", { modifiers: { ctrl: true } })).toEqual([mapper.inputMap[0]]); + expect(mapper.getEntries("keyboard", "pan", { modifiers: {} })).toEqual([mapper.inputMap[2]]); + }); + }); + + describe("addEntry", () => { + it("should insert more specific entries before less specific ones", () => { + mapper.inputMap = [{ source: "keyboard", interaction: "rotate" }]; + mapper.addEntry({ source: "keyboard", modifiers: { ctrl: true }, interaction: "pan" }); + expect(mapper.inputMap[0].interaction).toBe("pan"); + expect(mapper.inputMap[1].interaction).toBe("rotate"); + }); + }); + + describe("setInteractions", () => { + it("should update every matching entry and return the count", () => { + mapper.inputMap = [ + { source: "pointer", button: 0, interaction: "rotate" }, + { source: "pointer", button: 0, interaction: "pan" }, + { source: "pointer", button: 2, interaction: "pan" }, + ]; + + const updated = mapper.setInteractions("pointer", { button: 0 }, "zoom"); + + expect(updated).toBe(2); + expect(mapper.inputMap[0].interaction).toBe("zoom"); + expect(mapper.inputMap[1].interaction).toBe("zoom"); + expect(mapper.inputMap[2].interaction).toBe("pan"); + }); + + it("should return 0 when no entries match", () => { + mapper.inputMap = [{ source: "pointer", button: 0, interaction: "rotate" }]; + + expect(mapper.setInteractions("keyboard", undefined, "zoom")).toBe(0); + expect(mapper.inputMap[0].interaction).toBe("rotate"); + }); + + it("should also update entries with no conditions when conditions match (wildcard semantics)", () => { + mapper.inputMap = [ + { source: "keyboard", interaction: "rotate" }, + { source: "keyboard", modifiers: { ctrl: true }, interaction: "pan" }, + ]; + + const updated = mapper.setInteractions("keyboard", { modifiers: { ctrl: true } }, "zoom"); + + expect(updated).toBe(2); + expect(mapper.inputMap[0].interaction).toBe("zoom"); + expect(mapper.inputMap[1].interaction).toBe("zoom"); + }); + }); +}); + +describe("CameraMovement", () => { + let engine: Nullable = null; + let scene: Nullable = null; + let movement: CameraMovement; + + beforeEach(() => { + engine = new NullEngine({ + renderHeight: 256, + renderWidth: 256, + textureSize: 256, + deterministicLockstep: false, + lockstepMaxSteps: 1, + }); + scene = new Scene(engine); + // Mock getDeltaTime to return a stable 16.67ms (60fps) + vi.spyOn(engine, "getDeltaTime").mockReturnValue(1000 / 60); + movement = new CameraMovement(scene, Vector3.Zero()); + }); + + afterEach(() => { + scene?.dispose(); + engine?.dispose(); + }); + + describe("computeCurrentFrameDeltas", () => { + it("should reset accumulators after computation", () => { + movement.panAccumulatedPixels.x = 10; + movement.rotationAccumulatedPixels.y = 5; + movement.zoomAccumulatedPixels = 3; + movement.activeInput = true; + + movement.computeCurrentFrameDeltas(); + + expect(movement.panAccumulatedPixels.x).toBe(0); + expect(movement.rotationAccumulatedPixels.y).toBe(0); + expect(movement.zoomAccumulatedPixels).toBe(0); + }); + + it("should apply panSpeed multiplier", () => { + movement.panSpeed = 2; + movement.panAccumulatedPixels.x = 10; + movement.activeInput = true; + + movement.computeCurrentFrameDeltas(); + const firstDelta = movement.panDeltaCurrentFrame.x; + + // Reset velocity to isolate the speed multiplier effect from cross-frame accumulation + movement.resetPanVelocity(); + movement.panSpeed = 4; + movement.panAccumulatedPixels.x = 10; + movement.activeInput = true; + + movement.computeCurrentFrameDeltas(); + const secondDelta = movement.panDeltaCurrentFrame.x; + + expect(Math.abs(secondDelta / firstDelta - 2)).toBeLessThan(0.01); + }); + + it("should use per-axis rotation speeds", () => { + movement.rotationXSpeed = 0.5; + movement.rotationYSpeed = 2.0; + movement.rotationAccumulatedPixels.x = 10; + movement.rotationAccumulatedPixels.y = 10; + movement.activeInput = true; + + movement.computeCurrentFrameDeltas(); + + const ratio = movement.rotationDeltaCurrentFrame.y / movement.rotationDeltaCurrentFrame.x; + expect(Math.abs(ratio - 4)).toBeLessThan(0.01); + }); + + it("should compute zoom delta with zoomSpeed", () => { + movement.zoomSpeed = 3; + movement.zoomAccumulatedPixels = 5; + movement.activeInput = true; + + movement.computeCurrentFrameDeltas(); + + expect(movement.zoomDeltaCurrentFrame).not.toBe(0); + }); + + it("should decay velocity with inertia when no input", () => { + movement.panInertia = 0.9; + movement.panAccumulatedPixels.x = 100; + movement.activeInput = true; + + movement.computeCurrentFrameDeltas(); + const firstDelta = Math.abs(movement.panDeltaCurrentFrame.x); + + movement.panAccumulatedPixels.x = 0; + movement.computeCurrentFrameDeltas(); + const secondDelta = Math.abs(movement.panDeltaCurrentFrame.x); + + expect(secondDelta).toBeGreaterThan(0); + expect(secondDelta).toBeLessThan(firstDelta); + }); + + it("should stop instantly with zero inertia", () => { + movement.panInertia = 0; + movement.panAccumulatedPixels.x = 100; + movement.activeInput = true; + + movement.computeCurrentFrameDeltas(); + + movement.panAccumulatedPixels.x = 0; + movement.computeCurrentFrameDeltas(); + + expect(movement.panDeltaCurrentFrame.x).toBe(0); + }); + + it("should produce zero deltas with no input", () => { + movement.computeCurrentFrameDeltas(); + + expect(movement.panDeltaCurrentFrame.x).toBe(0); + expect(movement.panDeltaCurrentFrame.y).toBe(0); + expect(movement.rotationDeltaCurrentFrame.x).toBe(0); + expect(movement.rotationDeltaCurrentFrame.y).toBe(0); + expect(movement.zoomDeltaCurrentFrame).toBe(0); + }); + }); +}); diff --git a/packages/dev/core/test/unit/Cameras/geospatialCameraMovement.test.ts b/packages/dev/core/test/unit/Cameras/geospatialCameraMovement.test.ts new file mode 100644 index 000000000000..9ddde3451803 --- /dev/null +++ b/packages/dev/core/test/unit/Cameras/geospatialCameraMovement.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { GeospatialCamera } from "core/Cameras/geospatialCamera"; +import { NullEngine } from "core/Engines/nullEngine"; +import { Scene } from "core/scene"; +import type { Nullable } from "core/types"; + +describe("GeospatialCameraMovement", () => { + let engine: Nullable = null; + let scene: Nullable = null; + let camera: GeospatialCamera; + + beforeEach(() => { + engine = new NullEngine({ + renderHeight: 256, + renderWidth: 256, + textureSize: 256, + deterministicLockstep: false, + lockstepMaxSteps: 1, + }); + scene = new Scene(engine); + camera = new GeospatialCamera("test", scene!, { planetRadius: 1 }); + }); + + afterEach(() => { + scene?.dispose(); + engine?.dispose(); + }); + + // ============================================ + // Input map resolution + // ============================================ + describe("default inputMap", () => { + it("should have 8 entries", () => { + expect(camera.movement.input.inputMap).toHaveLength(8); + }); + + it("should map left-click to pan", () => { + expect(camera.movement.input.resolveInteraction("pointer", { button: 0 })?.interaction).toBe("pan"); + }); + + it("should map middle-click to rotate", () => { + expect(camera.movement.input.resolveInteraction("pointer", { button: 1 })?.interaction).toBe("rotate"); + }); + + it("should map right-click to rotate", () => { + expect(camera.movement.input.resolveInteraction("pointer", { button: 2 })?.interaction).toBe("rotate"); + }); + + it("should map wheel to zoom", () => { + expect(camera.movement.input.resolveInteraction("wheel")?.interaction).toBe("zoom"); + }); + + it("should map +/- keys to zoom", () => { + expect(camera.movement.input.resolveInteraction("keyboard", { key: 187 })?.interaction).toBe("zoom"); // + key + expect(camera.movement.input.resolveInteraction("keyboard", { key: 107 })?.interaction).toBe("zoom"); // numpad + + expect(camera.movement.input.resolveInteraction("keyboard", { key: 189 })?.interaction).toBe("zoom"); // - key + expect(camera.movement.input.resolveInteraction("keyboard", { key: 109 })?.interaction).toBe("zoom"); // numpad - + }); + + it("should map ctrl+keyboard to rotate", () => { + expect(camera.movement.input.resolveInteraction("keyboard", { modifiers: { ctrl: true } })?.interaction).toBe("rotate"); + }); + + it("should map alt+keyboard to rotate", () => { + expect(camera.movement.input.resolveInteraction("keyboard", { modifiers: { alt: true } })?.interaction).toBe("rotate"); + }); + + it("should map plain keyboard to pan", () => { + expect(camera.movement.input.resolveInteraction("keyboard", { modifiers: {} })?.interaction).toBe("pan"); + }); + + it("should map arrow keys without modifiers to pan", () => { + expect(camera.movement.input.resolveInteraction("keyboard", { key: 38, modifiers: {} })?.interaction).toBe("pan"); // up arrow + expect(camera.movement.input.resolveInteraction("keyboard", { key: 40, modifiers: {} })?.interaction).toBe("pan"); // down arrow + expect(camera.movement.input.resolveInteraction("keyboard", { key: 37, modifiers: {} })?.interaction).toBe("pan"); // left arrow + expect(camera.movement.input.resolveInteraction("keyboard", { key: 39, modifiers: {} })?.interaction).toBe("pan"); // right arrow + }); + + it("should map arrow keys with ctrl to rotate", () => { + expect(camera.movement.input.resolveInteraction("keyboard", { key: 38, modifiers: { ctrl: true } })?.interaction).toBe("rotate"); + expect(camera.movement.input.resolveInteraction("keyboard", { key: 37, modifiers: { ctrl: true } })?.interaction).toBe("rotate"); + }); + + it("should map zoom keys with ctrl to zoom (key match wins over modifier match)", () => { + // Key-specific entry comes before modifier entry in the inputMap, so it wins + expect(camera.movement.input.resolveInteraction("keyboard", { key: 187, modifiers: { ctrl: true } })?.interaction).toBe("zoom"); + }); + + it("should map pointer with shift modifier when configured", () => { + camera.movement.input.inputMap = [ + { source: "pointer", button: 0, modifiers: { shift: true }, interaction: "rotate" }, + { source: "pointer", button: 0, interaction: "pan" }, + ]; + expect(camera.movement.input.resolveInteraction("pointer", { button: 0, modifiers: { shift: true } })?.interaction).toBe("rotate"); + expect(camera.movement.input.resolveInteraction("pointer", { button: 0, modifiers: {} })?.interaction).toBe("pan"); + }); + }); + + // ============================================ + // Handler accumulation direction tests + // ============================================ + describe("rotate handler", () => { + it("should accumulate positive yaw to rotationAccumulatedPixels.y", () => { + camera.movement.input.handlers.rotate(5, 0); + expect(camera.movement.rotationAccumulatedPixels.y).toBe(5); + expect(camera.movement.rotationAccumulatedPixels.x).toBe(0); + }); + + it("should accumulate negative yaw to rotationAccumulatedPixels.y", () => { + camera.movement.input.handlers.rotate(-3, 0); + expect(camera.movement.rotationAccumulatedPixels.y).toBe(-3); + }); + + it("should accumulate positive pitch to rotationAccumulatedPixels.x", () => { + camera.movement.input.handlers.rotate(0, 7); + expect(camera.movement.rotationAccumulatedPixels.x).toBe(7); + expect(camera.movement.rotationAccumulatedPixels.y).toBe(0); + }); + + it("should accumulate negative pitch to rotationAccumulatedPixels.x", () => { + camera.movement.input.handlers.rotate(0, -4); + expect(camera.movement.rotationAccumulatedPixels.x).toBe(-4); + }); + + it("should accumulate yaw and pitch independently", () => { + camera.movement.input.handlers.rotate(2, 3); + expect(camera.movement.rotationAccumulatedPixels.y).toBe(2); + expect(camera.movement.rotationAccumulatedPixels.x).toBe(3); + }); + + it("should accumulate multiple calls", () => { + camera.movement.input.handlers.rotate(1, 2); + camera.movement.input.handlers.rotate(3, 4); + expect(camera.movement.rotationAccumulatedPixels.y).toBe(4); // 1 + 3 + expect(camera.movement.rotationAccumulatedPixels.x).toBe(6); // 2 + 4 + }); + }); + + describe("zoom handler", () => { + it("should accumulate positive zoom delta", () => { + camera.movement.input.handlers.zoom(10, false); + expect(camera.movement.zoomAccumulatedPixels).toBe(10); + }); + + it("should accumulate negative zoom delta", () => { + camera.movement.input.handlers.zoom(-5, false); + expect(camera.movement.zoomAccumulatedPixels).toBe(-5); + }); + + it("should accumulate multiple zoom calls", () => { + camera.movement.input.handlers.zoom(3, false); + camera.movement.input.handlers.zoom(7, false); + expect(camera.movement.zoomAccumulatedPixels).toBe(10); + }); + }); + + // ============================================ + // Direction sign convention tests + // These verify that the handler-to-accumulator mapping matches the + // convention used by geospatialCamera._checkInputs, which reads: + // rotationDeltaCurrentFrame.x → pitch (added to _pitch) + // rotationDeltaCurrentFrame.y → yaw (added to _yaw) + // ============================================ + describe("direction sign conventions", () => { + it("positive yaw (right) should produce positive rotationAccumulatedPixels.y", () => { + // Simulates: right arrow key or rightward pointer drag + camera.movement.input.handlers.rotate(1, 0); + expect(camera.movement.rotationAccumulatedPixels.y).toBeGreaterThan(0); + }); + + it("negative yaw (left) should produce negative rotationAccumulatedPixels.y", () => { + // Simulates: left arrow key or leftward pointer drag + camera.movement.input.handlers.rotate(-1, 0); + expect(camera.movement.rotationAccumulatedPixels.y).toBeLessThan(0); + }); + + it("pointer drag up (negative offsetY) should produce negative pitch accumulator for tilt-up", () => { + // Pointer: offsetY < 0 when dragging up, caller passes -offsetY as pitch + // This should result in positive pitch accumulator → _pitch increases → tilts toward horizon + const offsetY = -10; + camera.movement.input.handlers.rotate(0, -offsetY); // caller convention: negate offsetY for pitch + expect(camera.movement.rotationAccumulatedPixels.x).toBeGreaterThan(0); + }); + + it("keyboard up arrow should produce negative pitch accumulator for tilt-up (toward top-down)", () => { + // Keyboard convention: up arrow passes negative pitch + // _pitch decreases → tilts away from horizon (more top-down) + camera.movement.input.handlers.rotate(0, -1); + expect(camera.movement.rotationAccumulatedPixels.x).toBeLessThan(0); + }); + + it("keyboard down arrow should produce positive pitch accumulator for tilt-down (toward horizon)", () => { + // Keyboard convention: down arrow passes positive pitch + // _pitch increases → tilts toward horizon + camera.movement.input.handlers.rotate(0, 1); + expect(camera.movement.rotationAccumulatedPixels.x).toBeGreaterThan(0); + }); + + it("positive zoom should increase zoomAccumulatedPixels (zoom in)", () => { + camera.movement.input.handlers.zoom(1, false); + expect(camera.movement.zoomAccumulatedPixels).toBeGreaterThan(0); + }); + + it("negative zoom should decrease zoomAccumulatedPixels (zoom out)", () => { + camera.movement.input.handlers.zoom(-1, false); + expect(camera.movement.zoomAccumulatedPixels).toBeLessThan(0); + }); + }); + + // ============================================ + // resetInputMap + // ============================================ + describe("resetInputMap", () => { + it("should restore default inputMap after modification", () => { + camera.movement.input.inputMap = []; + expect(camera.movement.input.inputMap).toHaveLength(0); + + camera.movement.input.resetInputMap(); + expect(camera.movement.input.inputMap).toHaveLength(8); + + expect(camera.movement.input.resolveInteraction("pointer", { button: 0 })?.interaction).toBe("pan"); + expect(camera.movement.input.resolveInteraction("pointer", { button: 1 })?.interaction).toBe("rotate"); + expect(camera.movement.input.resolveInteraction("pointer", { button: 2 })?.interaction).toBe("rotate"); + expect(camera.movement.input.resolveInteraction("wheel")?.interaction).toBe("zoom"); + expect(camera.movement.input.resolveInteraction("keyboard", { key: 187 })?.interaction).toBe("zoom"); + expect(camera.movement.input.resolveInteraction("keyboard", { modifiers: { ctrl: true } })?.interaction).toBe("rotate"); + expect(camera.movement.input.resolveInteraction("keyboard", { modifiers: {} })?.interaction).toBe("pan"); + }); + }); +}); diff --git a/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-beta-limits.png b/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-beta-limits.png new file mode 100644 index 000000000000..d933b749068c Binary files /dev/null and b/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-beta-limits.png differ diff --git a/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-combined-glide.png b/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-combined-glide.png new file mode 100644 index 000000000000..4cb9e78e4f90 Binary files /dev/null and b/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-combined-glide.png differ diff --git a/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-inertia-comparison.png b/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-inertia-comparison.png new file mode 100644 index 000000000000..447299c843ab Binary files /dev/null and b/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-inertia-comparison.png differ diff --git a/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-panning-inertia.png b/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-panning-inertia.png new file mode 100644 index 000000000000..6186809d7a58 Binary files /dev/null and b/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-panning-inertia.png differ diff --git a/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-radius-glide.png b/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-radius-glide.png new file mode 100644 index 000000000000..2abf94c554c8 Binary files /dev/null and b/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-radius-glide.png differ diff --git a/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-radius-limits.png b/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-radius-limits.png new file mode 100644 index 000000000000..eafa85a8bee9 Binary files /dev/null and b/packages/tools/tests/test/visualization/ReferenceImages/arc-legacy-radius-limits.png differ diff --git a/packages/tools/tests/test/visualization/config.json b/packages/tools/tests/test/visualization/config.json index 23c3516a4d4b..fe69794a2aa5 100644 --- a/packages/tools/tests/test/visualization/config.json +++ b/packages/tools/tests/test/visualization/config.json @@ -5114,6 +5114,43 @@ "excludedEngines": ["webgl1"], "dependsOn": ["Particles"], "excludeFromPerformance": true + }, + { + "title": "ArcRotate legacy inertia comparison", + "playgroundId": "#UAPORV#0", + "renderCount": 30, + "referenceImage": "arc-legacy-inertia-comparison.png", + "dependsOn": ["Cameras"] + }, + { + "title": "ArcRotate legacy panningInertia glide", + "playgroundId": "#8S8H0S#0", + "renderCount": 30, + "referenceImage": "arc-legacy-panning-inertia.png" + }, + { + "title": "ArcRotate legacy inertialRadiusOffset glide", + "playgroundId": "#98108I#0", + "renderCount": 30, + "referenceImage": "arc-legacy-radius-glide.png" + }, + { + "title": "ArcRotate legacy beta limits clamp glide", + "playgroundId": "#FBLW1J#0", + "renderCount": 30, + "referenceImage": "arc-legacy-beta-limits.png" + }, + { + "title": "ArcRotate legacy radius limits clamp glide", + "playgroundId": "#QWTJH9#0", + "renderCount": 30, + "referenceImage": "arc-legacy-radius-limits.png" + }, + { + "title": "ArcRotate legacy combined axis glide", + "playgroundId": "#SJJRY4#0", + "renderCount": 30, + "referenceImage": "arc-legacy-combined-glide.png" } ] }