control/SimpleTouchNavigation.tstypescript
/*
 * Copyright 2026 Michael Gevlich
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { Control, type IControlParams } from "../control/Control";
import type { ITouchState } from "../renderer/RendererEvents";
import { Vec2 } from "../math/Vec2";
import { Vec3 } from "../math/Vec3";
import { Ray } from "../math/Ray";
import { Plane } from "../math/Plane";
import * as math from "../math";

const PLANE_Y = Plane.fromPoints(new Vec3(0, 0, 0), new Vec3(1, 0, 0), new Vec3(0, 0, 1));

export class SimpleTouchNavigation extends Control {
    protected _grabbedPoint: Vec3 | undefined;
    protected _grabbedScreenPoint: Vec2;
    protected _eye0: Vec3;

    protected _prev_t0: Vec2 = new Vec2();
    protected _prev_t1: Vec2 = new Vec2();

    protected _dead: number = 0.5;

    protected _orthoMinFocusDistance: number = 1e-6;
    protected _twoFingerTiltMinMove: number = 2.0;
    protected _twoFingerTiltAlignDot: number = 0.85;
    protected _twoFingerTiltVerticalRatio: number = 0.7;
    protected _twoFingerTiltHorizontalLineRatio: number = 0.35;
    protected _twoFingerTiltStrength: number = 0.75;
    protected _twoFingerTiltActive: boolean = false;

    constructor(options: IControlParams = {}) {
        super({
            name: "SimpleTouchNavigation",
            autoActivate: true,
            ...options
        });

        this._grabbedPoint = undefined;
        this._grabbedScreenPoint = new Vec2();
        this._eye0 = new Vec3();
    }

    override oninit() {}

    public override onactivate() {
        super.onactivate();

        let r = this.renderer!;

        if (r.activeCamera.isOrthographic) {
            r.getDepthMinDistanceAsync().then((dist) => {
                r.activeCamera.focusDistance = dist;
            });
        }

        r.events.on("touchstart", this.onTouchStart);
        r.events.on("touchend", this.onTouchEnd);
        //r.events.on('touchcancel', this.onTouchCancel);
        r.events.on("touchmove", this.onTouchMove);

        r.events.on("predraw", this.onDraw, this, -1000);
    }

    public override ondeactivate() {
        super.ondeactivate();
        let r = this.renderer!;

        r.events.off("touchstart", this.onTouchStart);
        r.events.off("touchend", this.onTouchEnd);
        //r.events.off('touchcancel', this.onTouchCancel);
        r.events.off("touchmove", this.onTouchMove);

        r.events.off("predraw", this.onDraw);
    }

    protected onTouchEnd = (e: ITouchState) => {
        this.onTouchStart(e);
    };

    // protected onTouchCancel = (e: ITouchState) => {
    //   //noop
    // };

    protected _pointerToScreenPoint(pointer: PointerEvent, sys: NonNullable<ITouchState["sys"]>): Vec2 {
        const handler = this.renderer!.handler;
        return new Vec2(
            (pointer.clientX - sys.offsetLeft) * handler.pixelRatio,
            (pointer.clientY - sys.offsetTop) * handler.pixelRatio
        );
    }

    protected _getGrabbedPoint(screenPoint: Vec2): Vec3 {
        const renderer = this.renderer!;
        const cam = renderer.activeCamera;

        let grabbedPoint = renderer.getCartesianFromPixel(screenPoint);
        if (!grabbedPoint) {
            const direction = cam.unproject(screenPoint.x, screenPoint.y, cam.eye.y);
            const px = new Vec3();
            if (new Ray(cam.eye, direction).hitPlaneRes(PLANE_Y, px) === Ray.INSIDE) {
                grabbedPoint = px;
            } else {
                grabbedPoint = cam.eye.add(direction.scale(10));
            }
        }

        return grabbedPoint;
    }

    protected _updateGrabbedScreenPoint(screenPoint: Vec2) {
        const handler = this.renderer!.handler;
        this._grabbedScreenPoint.set(screenPoint.x / handler.getWidth(), screenPoint.y / handler.getHeight());
    }

    protected _startSingleFingerGesture(pointer: PointerEvent, sys: NonNullable<ITouchState["sys"]>) {
        const t0 = this._pointerToScreenPoint(pointer, sys);
        this._grabbedPoint = this._getGrabbedPoint(t0);
        this._updateGrabbedScreenPoint(t0);
    }

    protected _startTwoFingerGesture(
        pointer0: PointerEvent,
        pointer1: PointerEvent,
        sys: NonNullable<ITouchState["sys"]>,
        skipPointGrabbing: boolean
    ) {
        const t0 = this._pointerToScreenPoint(pointer0, sys);
        const t1 = this._pointerToScreenPoint(pointer1, sys);

        this._prev_t0.copy(t0);
        this._prev_t1.copy(t1);

        const middle_t = t0.add(t1).scale(0.5);
        this._updateGrabbedScreenPoint(middle_t);

        if (!skipPointGrabbing) {
            this._grabbedPoint = this._getGrabbedPoint(middle_t);
        }

        this._twoFingerTiltActive = false;
    }

    protected _getPerspectiveDragPlane(anchor: Vec3): Plane {
        const cam = this.renderer!.activeCamera;
        const camSlope = Math.abs(cam.getForward().dot(Vec3.UP));
        if (camSlope > 0.7) {
            return Plane.fromPoints(anchor, Vec3.add(anchor, Vec3.LEFT), Vec3.add(anchor, cam.getRight()));
        }

        return Plane.fromPoints(anchor, Vec3.add(anchor, cam.getRight()), Vec3.add(anchor, Vec3.UP));
    }

    protected _applyOrthographicDrag(screenPoint: Vec2) {
        const cam = this.renderer!.activeCamera;
        const handler = this.renderer!.handler;

        const nx = screenPoint.x / handler.getWidth() - this._grabbedScreenPoint.x;
        const ny = screenPoint.y / handler.getHeight() - this._grabbedScreenPoint.y;
        const f = cam.frustum;
        const dx = -(f.right - f.left) * nx;
        const dy = (f.top - f.bottom) * ny;
        const cam_sy = cam.getUp().scale(dy);
        const cam_sx = cam.getRight().scale(dx);
        cam.eye = this._eye0.add(cam_sx.add(cam_sy));
    }

    protected _applyPerspectiveDrag(screenPoint: Vec2, anchor: Vec3) {
        const cam = this.renderer!.activeCamera;
        const px = new Vec3();
        const direction = cam.unproject(screenPoint.x, screenPoint.y);
        if (new Ray(cam.eye, direction).hitPlaneRes(this._getPerspectiveDragPlane(anchor), px) === Ray.INSIDE) {
            cam.eye = cam.eye.add(anchor.sub(px));
        }
    }

    protected _applyDragGesture(screenPoint: Vec2) {
        const cam = this.renderer!.activeCamera;
        if (cam.isOrthographic) {
            this._applyOrthographicDrag(screenPoint);
        } else {
            this._applyPerspectiveDrag(screenPoint, this._grabbedPoint!);
        }
    }

    protected _moveSingleFingerGesture(pointer: PointerEvent, sys: NonNullable<ITouchState["sys"]>) {
        const t0 = this._pointerToScreenPoint(pointer, sys);
        this._prev_t0.copy(t0);
        this._prev_t1.copy(t0);

        this._applyDragGesture(t0);
    }

    protected _applyTwoFingerZoom(vPrev: Vec2, vCurr: Vec2, middle: Vec2) {
        const cam = this.renderer!.activeCamera;
        const lenPrev = Math.hypot(vPrev.x, vPrev.y);
        const lenCurr = Math.hypot(vCurr.x, vCurr.y);
        if (lenPrev <= this._dead || lenCurr <= this._dead) {
            return;
        }

        let scale = lenPrev / lenCurr;
        if (scale < 0.25) scale = 0.25;
        if (scale > 4.0) scale = 4.0;

        const anchor = this._grabbedPoint!;

        if (cam.isOrthographic) {
            const fBefore = cam.frustum;
            const widthBefore = fBefore.right - fBefore.left;
            const heightBefore = fBefore.top - fBefore.bottom;
            const dxBefore = -widthBefore * (0.5 - this._grabbedScreenPoint.x);
            const dyBefore = heightBefore * (0.5 - this._grabbedScreenPoint.y);
            const worldBefore = cam.eye.add(cam.getRight().scale(dxBefore)).add(cam.getUp().scale(dyBefore));

            cam.focusDistance = Math.max(this._orthoMinFocusDistance, cam.focusDistance * scale);
            cam.update();

            const fAfter = cam.frustum;
            const widthAfter = fAfter.right - fAfter.left;
            const heightAfter = fAfter.top - fAfter.bottom;
            const dxAfter = -widthAfter * (0.5 - this._grabbedScreenPoint.x);
            const dyAfter = heightAfter * (0.5 - this._grabbedScreenPoint.y);
            const worldAfter = cam.eye.add(cam.getRight().scale(dxAfter)).add(cam.getUp().scale(dyAfter));

            cam.eye = cam.eye.add(worldBefore.sub(worldAfter));
            this._eye0.copy(cam.eye);
        } else {
            cam.eye = anchor.add(cam.eye.sub(anchor).scale(scale));
            this._applyPerspectiveDrag(middle, anchor);
        }
    }

    protected _applyTwoFingerRotation(vPrev: Vec2, vCurr: Vec2) {
        const cam = this.renderer!.activeCamera;
        const dot = vPrev.x * vCurr.x + vPrev.y * vCurr.y;
        const cross = vPrev.x * vCurr.y - vPrev.y * vCurr.x;
        const rotAngle = Math.atan2(cross, dot);
        cam.rotateHorizontal(-rotAngle, false, this._grabbedPoint!, Vec3.UP);
    }

    protected _isTwoFingerTiltGesture(d0: Vec2, d1: Vec2, touchLine: Vec2): boolean {
        const len0 = Math.hypot(d0.x, d0.y);
        const len1 = Math.hypot(d1.x, d1.y);
        if (len0 < this._twoFingerTiltMinMove || len1 < this._twoFingerTiltMinMove) {
            return false;
        }

        // Tilt is allowed only when fingers are roughly on the same horizontal line.
        const lineDx = Math.abs(touchLine.x);
        const lineDy = Math.abs(touchLine.y);
        const horizontalLine =
            lineDx > this._twoFingerTiltMinMove && lineDy <= lineDx * this._twoFingerTiltHorizontalLineRatio;
        if (!horizontalLine) {
            return false;
        }

        const dir0 = new Vec2(d0.x / len0, d0.y / len0);
        const dir1 = new Vec2(d1.x / len1, d1.y / len1);
        const sameDirection = dir0.dot(dir1) > this._twoFingerTiltAlignDot;
        const movementOnTiltAxis =
            Math.abs(dir0.y) > this._twoFingerTiltVerticalRatio && Math.abs(dir1.y) > this._twoFingerTiltVerticalRatio;

        return sameDirection && movementOnTiltAxis;
    }

    protected _applyTwoFingerTilt(d0: Vec2, d1: Vec2, touchLine: Vec2): boolean {
        const cam = this.renderer!.activeCamera;
        if (!this._grabbedPoint) {
            return false;
        }

        // Gesture recognition: two fingers move in one vertical direction.
        if (!this._isTwoFingerTiltGesture(d0, d1, touchLine)) {
            return false;
        }

        // Gesture movement: vertical two-finger swipe changes camera tilt.
        const averageTiltDelta = (d0.y + d1.y) * 0.5;
        const distance = Math.max(this._grabbedPoint.distance(cam.eye), this._orthoMinFocusDistance);
        let sensitivity = (0.5 / distance) * math.RADIANS;
        if (sensitivity > 0.007) {
            sensitivity = 0.007;
        } else if (sensitivity < 0.003) {
            sensitivity = 0.003;
        }

        cam.rotateVertical(sensitivity * averageTiltDelta * this._twoFingerTiltStrength, this._grabbedPoint);
        return true;
    }

    protected _isTwoFingerTiltHoldGesture(d0: Vec2, d1: Vec2, touchLine: Vec2): boolean {
        const holdMove = this._twoFingerTiltMinMove * 0.5;
        const holdAlignDot = this._twoFingerTiltAlignDot - 0.2;
        const holdTiltAxisRatio = this._twoFingerTiltVerticalRatio - 0.15;
        const holdLineAxisRatio = this._twoFingerTiltHorizontalLineRatio * 1.6;

        const len0 = Math.hypot(d0.x, d0.y);
        const len1 = Math.hypot(d1.x, d1.y);
        if (len0 < holdMove || len1 < holdMove) {
            return false;
        }

        const lineDx = Math.abs(touchLine.x);
        const lineDy = Math.abs(touchLine.y);
        const horizontalLine = lineDx > holdMove && lineDy <= lineDx * holdLineAxisRatio;
        if (!horizontalLine) {
            return false;
        }

        const dir0 = new Vec2(d0.x / len0, d0.y / len0);
        const dir1 = new Vec2(d1.x / len1, d1.y / len1);
        const movementOnTiltAxis = Math.abs(dir0.y) > holdTiltAxisRatio && Math.abs(dir1.y) > holdTiltAxisRatio;

        return dir0.dot(dir1) > holdAlignDot && movementOnTiltAxis;
    }

    protected _moveTwoFingerGesture(
        e: ITouchState,
        pointer0: PointerEvent,
        pointer1: PointerEvent,
        sys: NonNullable<ITouchState["sys"]>
    ) {
        const cam = this.renderer!.activeCamera;
        const t0 = this._pointerToScreenPoint(pointer0, sys);
        const t1 = this._pointerToScreenPoint(pointer1, sys);
        const middle = t0.add(t1).scale(0.5);
        const d0 = t0.sub(this._prev_t0);
        const d1 = t1.sub(this._prev_t1);
        const vPrev = this._prev_t1.sub(this._prev_t0);
        const vCurr = t1.sub(t0);

        // Keep tilt mode stable to prevent jitter between tilt and drag/zoom.
        if (this._twoFingerTiltActive) {
            if (this._isTwoFingerTiltHoldGesture(d0, d1, vCurr)) {
                this._applyTwoFingerTilt(d0, d1, vCurr);
                if (cam.isOrthographic) {
                    this._applyTwoFingerZoom(vPrev, vCurr, middle);
                }
            } else {
                // Re-anchor two-finger gesture before leaving tilt mode.
                this._startTwoFingerGesture(pointer0, pointer1, sys, false);
            }
            this._prev_t0.copy(t0);
            this._prev_t1.copy(t1);
            return;
        }

        // Gesture recognition: if both fingers move vertically in one direction, use tilt.
        if (this._applyTwoFingerTilt(d0, d1, vCurr)) {
            this._twoFingerTiltActive = true;
            if (cam.isOrthographic) {
                this._applyTwoFingerZoom(vPrev, vCurr, middle);
            }
            this._prev_t0.copy(t0);
            this._prev_t1.copy(t1);
            return;
        }

        this._applyTwoFingerZoom(vPrev, vCurr, middle);
        this._applyDragGesture(middle);
        this._applyTwoFingerRotation(vPrev, vCurr);

        if (cam.isOrthographic) {
            this.onTouchStart(e, true);
        }

        this._prev_t0.copy(t0);
        this._prev_t1.copy(t1);
    }

    protected onTouchStart = (e: ITouchState, skipPointGrabbing?: boolean) => {
        if (!this._active || !this.renderer) return;

        const sys = e.sys!;
        const pointers = sys.pointers;

        if (pointers.length === 1) {
            this._twoFingerTiltActive = false;
            this._startSingleFingerGesture(pointers[0], sys);
        } else if (pointers.length === 2) {
            this._startTwoFingerGesture(pointers[0], pointers[1], sys, !!skipPointGrabbing);
        } else {
            this._twoFingerTiltActive = false;
            this._grabbedPoint = undefined;
            return;
        }

        if (this._grabbedPoint) {
            this._eye0.copy(this.renderer.activeCamera.eye);
        }
    };

    protected onTouchMove = (e: ITouchState) => {
        if (!this.renderer || !this._grabbedPoint) return;

        const sys = e.sys!;
        const pointers = sys.pointers;

        if (pointers.length === 1) {
            this._twoFingerTiltActive = false;
            this._moveSingleFingerGesture(pointers[0], sys);
        } else if (pointers.length === 2) {
            this._moveTwoFingerGesture(e, pointers[0], pointers[1], sys);
        } else {
            this._twoFingerTiltActive = false;
        }

        this.renderer.activeCamera.update();
    };

    protected onDraw() {}
}