UNPKG

@egjs/view3d

Version:

Fast & Customizable glTF 3D model viewer, packed with full of features!

392 lines (328 loc) 12 kB
/* * Copyright (c) 2020 NAVER Corp. * egjs projects are licensed under the MIT license */ import * as THREE from "three"; import View3D from "../View3D"; import AnimationControl from "../control/AnimationControl"; import { toRadian, clamp, circulate, toDegree, getRotatedPosition, isNumber, isString, parseAsBboxRatio } from "../utils"; import * as DEFAULT from "../const/default"; import { AUTO, EVENTS, ZOOM_TYPE } from "../const/external"; import Pose from "./Pose"; import Model from "./Model"; /** * Camera that renders the scene of View3D */ class Camera { private _view3D: View3D; private _threeCamera: THREE.PerspectiveCamera; private _baseDistance: number; private _baseFov: number; private _defaultPose: Pose; private _currentPose: Pose; private _newPose: Pose; private _maxTanHalfHFov: number; /** * Three.js {@link https://threejs.org/docs/#api/en/cameras/PerspectiveCamera PerspectiveCamera} instance * @readonly * @type THREE.PerspectiveCamera */ public get threeCamera() { return this._threeCamera; } /** * Camera's default pose(yaw, pitch, zoom, pivot) * This will be new currentPose when {@link Camera#reset reset()} is called * @readonly * @type {Pose} */ public get defaultPose(): Pose { return this._defaultPose; } /** * Camera's current pose value * @readonly * @type {Pose} */ public get currentPose(): Pose { return this._currentPose.clone(); } /** * Camera's new pose that will be applied on the next frame * {@link Camera#updatePosition} should be called after changing this value. * @type {Pose} */ public get newPose(): Pose { return this._newPose; } /** * Camera's current yaw * {@link Camera#updatePosition} should be called after changing this value. * @type {number} */ public get yaw() { return this._currentPose.yaw; } /** * Camera's current pitch * {@link Camera#updatePosition} should be called after changing this value. * @type {number} */ public get pitch() { return this._currentPose.pitch; } /** * Camera's current zoom value * {@link Camera#updatePosition} should be called after changing this value. * @type {number} */ public get zoom() { return this._currentPose.zoom; } /** * Camera's disatance from current camera pivot(target) * @type {number} * @readonly */ public get distance() { return this._view3D.control.zoom.type === ZOOM_TYPE.FOV ? this._baseDistance : this._baseDistance - this._currentPose.zoom; } /** * Camera's default distance from the model center. * This will be automatically calculated based on the model size. * @type {number} * @readonly */ public get baseDistance() { return this._baseDistance; } /** * Camera's default fov value. * This will be automatically chosen when `view3D.fov` is "auto", otherwise it is equal to `view3D.fov` * @type {number} * @readonly */ public get baseFov() { return this._baseFov; } /** * Current pivot point of camera rotation * @type THREE.Vector3 * @readonly * @see {@link https://threejs.org/docs/#api/en/math/Vector3 THREE#Vector3} */ public get pivot() { return this._currentPose.pivot; } /** * Camera's focus of view value (vertical) * @type number * @readonly * @see {@link https://threejs.org/docs/#api/en/cameras/PerspectiveCamera.fov THREE#PerspectiveCamera} */ public get fov() { return this._threeCamera.fov; } /** * Camera's frustum width * @type number * @readonly */ public get renderWidth() { return this.renderHeight * this._threeCamera.aspect; } /** * Camera's frustum height * @type number * @readonly */ public get renderHeight() { return 2 * this.distance * Math.tan(toRadian(this._threeCamera.getEffectiveFOV() / 2)); } public set yaw(val: number) { this._newPose.yaw = val; } public set pitch(val: number) { this._newPose.pitch = val; } public set zoom(val: number) { this._newPose.zoom = val; } public set pivot(val: THREE.Vector3) { this._newPose.pivot.copy(val); } public set baseFov(val: number) { this._baseFov = val; } /** * Create new Camera instance * @param {View3D} view3D An instance of View3D */ public constructor(view3D: View3D) { this._view3D = view3D; this._threeCamera = new THREE.PerspectiveCamera(); this._maxTanHalfHFov = 0; this._baseFov = 45; this._baseDistance = 0; const initialZoom = isNumber(view3D.initialZoom) ? view3D.initialZoom : 0; this._defaultPose = new Pose(view3D.yaw, view3D.pitch, initialZoom); this._currentPose = this._defaultPose.clone(); this._newPose = this._currentPose.clone(); } /** * Reset camera to default pose * @param {number} [duration=0] Duration of the reset animation * @param {function} [easing] Easing function for the reset animation * @param {Pose} [pose] Pose to reset, camera will reset to `defaultPose` if pose is not given. * @returns Promise that resolves when the animation finishes */ public reset(duration: number = 0, easing: (x: number) => number = DEFAULT.EASING, pose?: Pose): Promise<void> { const view3D = this._view3D; const control = view3D.control; const autoPlayer = view3D.autoPlayer; const newPose = this._newPose; const currentPose = this._currentPose; const targetPose = pose ?? this._defaultPose; if (duration <= 0) { // Reset camera immediately newPose.copy(targetPose); currentPose.copy(targetPose); view3D.renderer.renderSingleFrame(); control.sync(); return Promise.resolve(); } else { // Play the animation const autoplayEnabled = autoPlayer.enabled; const resetControl = new AnimationControl(view3D, currentPose, targetPose); resetControl.duration = duration; resetControl.easing = easing; resetControl.enable(); if (autoplayEnabled) { autoPlayer.disable(); } control.add(resetControl); return new Promise(resolve => { resetControl.onFinished(() => { newPose.copy(targetPose); currentPose.copy(targetPose); control.remove(resetControl); control.sync(); if (autoplayEnabled) { autoPlayer.enableAfterDelay(); } resolve(); }); }); } } /** * Update camera's aspect to given size * @param {object} size New size to apply * @param {number} [size.width] New width * @param {number} [size.height] New height * @returns {void} */ public resize( { width, height }: { width: number; height: number }, prevSize: { width: number; height: number } | null = null ): void { const { control, fov, maintainSize } = this._view3D; const threeCamera = this._threeCamera; const aspect = width / height; threeCamera.aspect = aspect; if (fov === AUTO) { if (!maintainSize || prevSize == null) { this._applyEffectiveFov(DEFAULT.FOV); } else { const heightRatio = height / prevSize.height; const currentZoom = this._currentPose.zoom; const tanHalfFov = Math.tan(toRadian((this._baseFov - currentZoom) / 2)); this._baseFov = toDegree(2 * Math.atan(heightRatio * tanHalfFov)) + currentZoom; } } else { this._baseFov = fov; } control.zoom.updateRange(); } /** * Fit camera frame to the given model */ public fit(model: Model): void { const view3D = this._view3D; const camera = this._threeCamera; const defaultPose = this._defaultPose; const control = view3D.control; const pivot = view3D.pivot; const bbox = model.bbox; const fov = view3D.fov; const hfov = fov === AUTO ? DEFAULT.FOV : fov; const modelCenter = model.center; const maxDistToCenterSquared = view3D.ignoreCenterOnFit || view3D.center === AUTO ? new THREE.Vector3().subVectors(bbox.max, bbox.min).lengthSq() / 4 : model.reduceVertices((dist, vertice) => { return Math.max(dist, vertice.distanceToSquared(modelCenter)); }, 0); const maxDistToCenter = Math.sqrt(maxDistToCenterSquared); const effectiveCamDist = maxDistToCenter / Math.sin(toRadian(hfov / 2)); const maxTanHalfHFov = model.reduceVertices((res, vertex) => { const distToCenter = new THREE.Vector3().subVectors(vertex, modelCenter); const radiusXZ = Math.hypot(distToCenter.x, distToCenter.z); return Math.max(res, radiusXZ / (effectiveCamDist - Math.abs(distToCenter.y))); }, 0); if (fov === AUTO) { // Cache for later use in resize this._maxTanHalfHFov = maxTanHalfHFov; this._applyEffectiveFov(hfov); } else { this._maxTanHalfHFov = fov; } defaultPose.pivot = pivot === AUTO ? modelCenter.clone() : parseAsBboxRatio(pivot, bbox); this._baseDistance = effectiveCamDist; camera.near = (effectiveCamDist - maxDistToCenter) * 0.1; camera.far = (effectiveCamDist + maxDistToCenter) * 10; control.zoom.updateRange(); if (!isNumber(view3D.initialZoom)) { const baseFov = this._baseFov; const modelBbox = model.bbox; const alignAxis = view3D.initialZoom.axis; const targetRatio = view3D.initialZoom.ratio; const bboxDiff = new THREE.Vector3().subVectors(modelBbox.max, modelBbox.min); const axisDiff = bboxDiff[alignAxis]; const newViewHeight = alignAxis === "y" ? axisDiff / targetRatio : axisDiff / (targetRatio * camera.aspect); const camDist = alignAxis !== "z" ? effectiveCamDist - bboxDiff.z / 2 : effectiveCamDist - bboxDiff.x / 2; const newFov = toDegree(2 * Math.atan(newViewHeight / (2 * camDist))); defaultPose.zoom = baseFov - newFov; } else { defaultPose.zoom = view3D.initialZoom; } } /** * Update camera position * @returns {void} */ public updatePosition(): void { const view3D = this._view3D; const control = view3D.control; const threeCamera = this._threeCamera; const currentPose = this._currentPose; const newPose = this._newPose; const baseFov = this._baseFov; const baseDistance = this._baseDistance; const isFovZoom = control.zoom.type === ZOOM_TYPE.FOV; const prevPose = currentPose.clone(); // Clamp current pose currentPose.yaw = circulate(newPose.yaw, 0, 360); currentPose.pitch = clamp(newPose.pitch, DEFAULT.PITCH_RANGE.min, DEFAULT.PITCH_RANGE.max); currentPose.zoom = newPose.zoom; currentPose.pivot.copy(newPose.pivot); const fov = isFovZoom ? baseFov - currentPose.zoom : baseFov; const distance = isFovZoom ? baseDistance : baseDistance - currentPose.zoom; const newCamPos = getRotatedPosition(distance, currentPose.yaw, currentPose.pitch); newCamPos.add(currentPose.pivot); threeCamera.fov = fov; threeCamera.position.copy(newCamPos); threeCamera.lookAt(currentPose.pivot); threeCamera.updateProjectionMatrix(); newPose.copy(currentPose); view3D.trigger(EVENTS.CAMERA_CHANGE, { type: EVENTS.CAMERA_CHANGE, target: view3D, pose: currentPose.clone(), prevPose }); } private _applyEffectiveFov(fov: number) { const camera = this._threeCamera; const tanHalfHFov = Math.tan(toRadian(fov / 2)); const tanHalfVFov = tanHalfHFov * Math.max(1, (this._maxTanHalfHFov / tanHalfHFov) / camera.aspect); this._baseFov = toDegree(2 * Math.atan(tanHalfVFov)); } private _parseBboxRatioOption(arr: Array<number | string>, bbox: THREE.Box3) { const min = bbox.min.toArray(); const size = new THREE.Vector3().subVectors(bbox.max, bbox.min).toArray(); return arr.map((val, idx) => { if (!isString(val)) return val; const ratio = parseFloat(val) * 0.01; return min[idx] + ratio * size[idx]; }); } } export default Camera;