@egjs/view3d
Version:
Fast & Customizable glTF 3D model viewer, packed with full of features!
449 lines (384 loc) • 13.5 kB
text/typescript
/*
* Copyright (c) 2020 NAVER Corp.
* egjs projects are licensed under the MIT license
*/
import * as THREE from "three";
import Component from "@egjs/component";
import View3D from "../View3D";
import Motion from "../core/Motion";
import Pose from "../core/Pose";
import * as BROWSER from "../const/browser";
import * as DEFAULT from "../const/default";
import { CONTROL_EVENTS } from "../const/internal";
import { AUTO, INPUT_TYPE, ZOOM_TYPE } from "../const/external";
import { getObjectOption } from "../utils";
import { ControlEvents, OptionGetters, ValueOf } from "../type/utils";
import CameraControl from "./CameraControl";
/**
* @interface
* @param {ZOOM_TYPE} [type="fov"] Zoom control type.
* @param {number} [scale=1] Scale factor for panning.
* @param {number} [duration=300] Duration of the input animation (ms)
* @param {number} [minFov=1] Minimum vertical fov(field of view).
* Only available when type is "fov".
* You can get a bigger image with the smaller value of this.
* @param {number} [maxFov="auto"] Maximum vertical fov(field of view).
* Only available when type is "fov".
* You can get a smaller image with the bigger value of this.
* If `"auto"` is given, it will use Math.min(default fov + 45, 175).
* @param {number} [minDistance=0.1] Minimum camera distance. This will be scaled to camera's default distance({@link camera.baseDistance Camera#baseDistance})
* Only available when type is "distance".
* @param {number} [maxDistance=2] Maximum camera distance. This will be scaled to camera's default distance({@link camera.baseDistance Camera#baseDistance})
* Only available when type is "distance".
* @param {boolean|object} [doubleTap=true] Configures double tap to zoom behavior, `false` to disable.
* @param {number} [doubleTap.zoomIn=0.8] Zoom-in value, relative to fov/distance range.max.
* @param {boolean} [doubleTap.useZoomOut=true] Whether to use zoom-out behavior on double tap.
* @param {number} [doubleTap.duration=300] Duration of the zoom-in and zoom-out animation.
* @param {number} [doubleTap.easing=EASING.EASE_OUT_CUBIC] Easing function of the zoom-in and zoom-out animation.
* @param {function} [easing=EASING.EASE_OUT_CUBIC] Easing function of the animation.
*/
export interface ZoomControlOptions {
type: ValueOf<typeof ZOOM_TYPE>;
scale: number;
duration: number;
minFov: number;
maxFov: typeof AUTO | number;
minDistance: number;
maxDistance: number;
doubleTap: boolean | Partial<{
zoomIn: number;
useZoomOut: boolean;
duration: number;
easing: (x: number) => number;
}>;
easing: (x: number) => number;
}
/**
* Distance controller handling both mouse wheel and pinch zoom(fov)
*/
class ZoomControl extends Component<ControlEvents> implements CameraControl, OptionGetters<ZoomControlOptions> {
// Options
private _type: ZoomControlOptions["type"];
private _scale: ZoomControlOptions["scale"];
private _duration: ZoomControlOptions["duration"];
private _minFov: ZoomControlOptions["minFov"];
private _maxFov: ZoomControlOptions["maxFov"];
private _minDistance: ZoomControlOptions["minDistance"];
private _maxDistance: ZoomControlOptions["maxDistance"];
private _doubleTap: ZoomControlOptions["doubleTap"];
private _easing: ZoomControlOptions["easing"];
// Internal values
private _view3D: View3D;
private _scaleModifier: number = 1;
private _wheelModifier: number = 0.02;
private _touchModifier: number = 0.05;
private _motion: Motion;
private _prevTouchDistance: number = -1;
private _enabled: boolean = false;
private _isFirstTouch: boolean = true;
private _isWheelScrolling: boolean = false;
/**
* Whether this control is enabled or not
* @readonly
*/
public get enabled() { return this._enabled; }
/**
* Whether this control is animating the camera
* @readonly
* @type {boolean}
*/
public get animating() { return this._motion.activated; }
/**
* Currenet fov/distance range
* @readonly
* @type {Range}
*/
public get range() { return this._motion.range; }
/**
* Current control type
* @readonly
* @see {ZOOM_TYPE}
* @default "fov"
*/
public get type() { return this._type; }
/**
* Scale factor of the zoom
* @type number
* @default 1
*/
public get scale() { return this._scale; }
/**
* Duration of the input animation (ms)
* @type {number}
* @default 300
*/
public get duration() { return this._duration; }
/**
* Minimum vertical fov(field of view).
* Only available when type is "fov".
* You can get a bigger image with the smaller value of this.
* @type {number}
* @default 1
*/
public get minFov() { return this._minFov; }
/**
* Maximum vertical fov(field of view).
* Only available when type is "fov".
* You can get a smaller image with the bigger value of this.
* If `"auto"` is given, it will use Math.min(default fov + 45, 175).
* @type {"auto" | number}
* @default "auto"
*/
public get maxFov() { return this._maxFov; }
/**
* Minimum camera distance. This will be scaled to camera's default distance({@link camera.baseDistance Camera#baseDistance})
* Only available when type is "distance".
* @type {number}
* @default 0.1
*/
public get minDistance() { return this._minDistance; }
/**
* Maximum camera distance. This will be scaled to camera's default distance({@link camera.baseDistance Camera#baseDistance})
* Only available when type is "distance".
* @type {number}
* @default 2
*/
public get maxDistance() { return this._maxDistance; }
public get doubleTap() { return this._doubleTap; }
/**
* Easing function of the animation
* @type {function}
* @default EASING.EASE_OUT_CUBIC
* @see EASING
*/
public get easing() { return this._easing; }
public set type(val: ZoomControlOptions["type"]) {
this._type = val;
}
public set scale(val: ZoomControlOptions["scale"]) { this._scale = val; }
/**
* Create new ZoomControl instance
* @param {View3D} view3D An instance of View3D
* @param {ZoomControlOptions} [options={}] Options
*/
public constructor(view3D: View3D, {
type = ZOOM_TYPE.FOV,
scale = 1,
duration = DEFAULT.ANIMATION_DURATION,
minFov = 1,
maxFov = AUTO,
minDistance = 0.1,
maxDistance = 2,
doubleTap = true,
easing = DEFAULT.EASING
}: Partial<ZoomControlOptions> = {}) {
super();
this._view3D = view3D;
this._type = type;
this._scale = scale;
this._duration = duration;
this._minFov = minFov;
this._maxFov = maxFov;
this._minDistance = minDistance;
this._maxDistance = maxDistance;
this._doubleTap = doubleTap;
this._easing = easing;
this._motion = new Motion({
duration,
easing,
range: {
min: -Infinity,
max: Infinity
}
});
}
/**
* Destroy the instance and remove all event listeners attached
* @returns {void}
*/
public destroy(): void {
this.disable();
this.reset();
this.off();
}
/**
* Reset internal values
* @returns {void}
*/
public reset(): void {
this._prevTouchDistance = -1;
this._isFirstTouch = true;
this._isWheelScrolling = false;
}
/**
* Update control by given deltaTime
* @param deltaTime Number of milisec to update
* @returns {void}
*/
public update(deltaTime: number): void {
const camera = this._view3D.camera;
const newPose = camera.newPose;
const motion = this._motion;
const prevProgress = motion.progress;
const delta = motion.update(deltaTime);
const newProgress = motion.progress;
newPose.zoom -= delta;
if (this._isWheelScrolling && prevProgress < 1 && newProgress >= 1) {
this.trigger(CONTROL_EVENTS.RELEASE, {
inputType: INPUT_TYPE.ZOOM
});
this._isWheelScrolling = false;
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public resize(size: { width: number; height: number }) {
// DO NOTHING
}
/**
* Enable this input and add event listeners
* @returns {void}
*/
public enable(): void {
if (this._enabled) return;
const targetEl = this._view3D.renderer.canvas;
targetEl.addEventListener(BROWSER.EVENTS.WHEEL, this._onWheel, { passive: false, capture: false });
targetEl.addEventListener(BROWSER.EVENTS.TOUCH_MOVE, this._onTouchMove, { passive: false, capture: false });
targetEl.addEventListener(BROWSER.EVENTS.TOUCH_END, this._onTouchEnd, { passive: false, capture: false });
targetEl.addEventListener(BROWSER.EVENTS.DOUBLE_CLICK, this._onDoubleClick);
this._enabled = true;
this.sync();
this.trigger(CONTROL_EVENTS.ENABLE, {
inputType: INPUT_TYPE.ZOOM
});
}
/**
* Disable this input and remove all event handlers
* @returns {void}
*/
public disable(): void {
if (!this._enabled) return;
const targetEl = this._view3D.renderer.canvas;
targetEl.removeEventListener(BROWSER.EVENTS.WHEEL, this._onWheel, false);
targetEl.removeEventListener(BROWSER.EVENTS.TOUCH_MOVE, this._onTouchMove, false);
targetEl.removeEventListener(BROWSER.EVENTS.TOUCH_END, this._onTouchEnd, false);
targetEl.removeEventListener(BROWSER.EVENTS.DOUBLE_CLICK, this._onDoubleClick);
this._enabled = false;
this.trigger(CONTROL_EVENTS.DISABLE, {
inputType: INPUT_TYPE.ZOOM
});
}
/**
* Synchronize this control's state to given camera position
* @returns {void}
*/
public sync(): void {
const camera = this._view3D.camera;
const motion = this._motion;
motion.reset(-camera.zoom);
if (this._type === ZOOM_TYPE.FOV) {
this._scaleModifier = -1;
} else {
this._scaleModifier = -camera.baseDistance / 44;
}
}
/**
* Update fov range by the camera's current fov value
* @returns {void}
*/
public updateRange(): void {
const range = this._motion.range;
const { camera } = this._view3D;
if (this._type === ZOOM_TYPE.FOV) {
const baseFov = camera.baseFov;
const maxFov = this._maxFov;
range.max = maxFov === AUTO
? Math.min(baseFov + 45, 175) - baseFov
: maxFov - baseFov;
range.min = this._minFov - baseFov;
} else {
range.max = camera.baseDistance * this._maxDistance - camera.baseDistance;
range.min = camera.baseDistance * this._minDistance - camera.baseDistance;
}
}
private _onWheel = (evt: WheelEvent) => {
const wheelScrollable = this._view3D.wheelScrollable;
if (evt.deltaY === 0 || wheelScrollable) return;
evt.preventDefault();
evt.stopPropagation();
const motion = this._motion;
const delta = -this._scale * this._scaleModifier * this._wheelModifier * evt.deltaY;
if (!this._isWheelScrolling) {
this.trigger(CONTROL_EVENTS.HOLD, {
inputType: INPUT_TYPE.ZOOM
});
}
this._isWheelScrolling = true;
motion.setEndDelta(delta);
};
private _onTouchMove = (evt: TouchEvent) => {
const touches = evt.touches;
if (touches.length !== 2) return;
if (evt.cancelable !== false) {
evt.preventDefault();
}
evt.stopPropagation();
const motion = this._motion;
const prevTouchDistance = this._prevTouchDistance;
const touchPoint1 = new THREE.Vector2(touches[0].pageX, touches[0].pageY);
const touchPoint2 = new THREE.Vector2(touches[1].pageX, touches[1].pageY);
const touchDiff = touchPoint1.sub(touchPoint2);
const touchDistance = touchDiff.length() * this._scale * this._scaleModifier * this._touchModifier;
const delta = this._isFirstTouch
? 0
: touchDistance - prevTouchDistance;
this._prevTouchDistance = touchDistance;
if (this._isFirstTouch) {
this.trigger(CONTROL_EVENTS.HOLD, {
inputType: INPUT_TYPE.ZOOM
});
}
this._isFirstTouch = false;
motion.setEndDelta(delta);
};
private _onTouchEnd = (evt: TouchEvent) => {
if (evt.touches.length !== 0) return;
this.trigger(CONTROL_EVENTS.RELEASE, {
inputType: INPUT_TYPE.ZOOM
});
this._prevTouchDistance = -1;
this._isFirstTouch = true;
};
private _onDoubleClick = (evt: MouseEvent) => {
const view3D = this._view3D;
if (!this._doubleTap || !view3D.model) return;
const {
zoomIn = 0.8,
duration = DEFAULT.ANIMATION_DURATION,
easing = DEFAULT.EASING,
useZoomOut = true
} = getObjectOption(this._doubleTap);
const zoomRange = this._motion.range;
const maxZoom = -zoomRange.min * zoomIn;
if (view3D.camera.zoom >= maxZoom && useZoomOut) {
const resetPose = view3D.camera.currentPose.clone();
resetPose.zoom = 0;
void view3D.camera.reset(duration, easing, resetPose);
return;
}
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const canvasSize = view3D.renderer.canvasSize;
pointer.x = (evt.offsetX / canvasSize.x) * 2 - 1;
pointer.y = -(evt.offsetY / canvasSize.y) * 2 + 1;
raycaster.setFromCamera(pointer, view3D.camera.threeCamera);
const intersects = raycaster.intersectObject(view3D.model.scene);
if (!intersects.length) return;
// Nearest
const intersect = intersects[0];
const newPivot = intersect.point;
const { yaw, pitch } = view3D.camera;
const resetPose = new Pose(yaw, pitch, maxZoom, newPivot.toArray());
void view3D.camera.reset(duration, easing, resetPose);
};
}
export default ZoomControl;